@reekon-tools/boldr-utils 1.7.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ import type { CalculatorDefinition } from './schema.js';
2
+ /**
3
+ * Category ids, as PERSISTED on `calculators/{id}.category`.
4
+ *
5
+ * Never rename a value — it is stored data. To retire a category, delete its
6
+ * entry from CALCULATOR_CATEGORIES and leave existing docs alone: they resolve
7
+ * to `undefined` and degrade into the "Other" bucket rather than breaking.
8
+ */
9
+ export declare enum CalculatorCategoryId {
10
+ AreaAndVolume = "areaAndVolume",
11
+ Concrete = "concrete",
12
+ Electrical = "electrical",
13
+ Stairs = "stairs",
14
+ Framing = "framing",
15
+ FinishCarpentry = "finishCarpentry",
16
+ Hardscaping = "hardscaping",
17
+ Roofing = "roofing",
18
+ HVAC = "hvac",
19
+ Landscaping = "landscaping",
20
+ MaterialEstimator = "materialEstimator",
21
+ Optimizers = "optimizers",
22
+ Plumbing = "plumbing"
23
+ }
24
+ export interface CalculatorCategory {
25
+ id: CalculatorCategoryId;
26
+ /** Display label: the editor dropdown row AND the mobile category screen title. */
27
+ name: string;
28
+ /**
29
+ * Tile-glyph tint from the design. Uppercase 6-digit hex.
30
+ *
31
+ * This is the source of truth for the color: the icon generator bakes it
32
+ * into the per-app SVGs (see scripts/gen-category-icons.mjs), so a color
33
+ * change here needs a regenerate to reach the apps.
34
+ */
35
+ color: string;
36
+ /**
37
+ * Glyph name in the legacy ROCK app's IcoMoon set, which is where this
38
+ * artwork comes from. Consumed only by the icon generator — clients load the
39
+ * emitted SVG by category id.
40
+ */
41
+ glyph: string;
42
+ }
43
+ /**
44
+ * Canonical order, matching the design's tile grid.
45
+ *
46
+ * This array IS the ordering — there is deliberately no `sortIndex` field for
47
+ * it to drift out of sync with.
48
+ */
49
+ export declare const CALCULATOR_CATEGORIES: readonly CalculatorCategory[];
50
+ export declare const CALCULATOR_CATEGORY_BY_ID: Readonly<Record<CalculatorCategoryId, CalculatorCategory>>;
51
+ /**
52
+ * Bucket key / route segment for calculators with no category, or with one
53
+ * that no longer exists. NOT a CalculatorCategoryId — guarded by a unit test,
54
+ * because a collision would silently swallow a real category.
55
+ */
56
+ export declare const UNCATEGORIZED_CATEGORY_ID = "uncategorized";
57
+ export declare const UNCATEGORIZED_CATEGORY_NAME = "Other";
58
+ export type CalculatorCategoryBucketId = CalculatorCategoryId | typeof UNCATEGORIZED_CATEGORY_ID;
59
+ export declare const isCalculatorCategoryId: (value: unknown) => value is CalculatorCategoryId;
60
+ /**
61
+ * Resolve a stored category value. Returns undefined for missing, null, and
62
+ * retired ids — callers should treat all three as "Other" rather than trusting
63
+ * the raw field.
64
+ */
65
+ export declare const findCalculatorCategory: (value: string | null | undefined) => CalculatorCategory | undefined;
66
+ export declare const calculatorCategoryOf: (calculator: Pick<CalculatorDefinition, "category">) => CalculatorCategory | undefined;
67
+ /** Display label for a stored value, falling back to "Other". */
68
+ export declare const calculatorCategoryName: (value: string | null | undefined) => string;
69
+ /**
70
+ * Group calculators by category id, collecting anything missing or
71
+ * unresolvable under UNCATEGORIZED_CATEGORY_ID.
72
+ *
73
+ * Keys exist only for non-empty buckets, which is what lets the mobile grid
74
+ * hide empty categories without a second pass.
75
+ */
76
+ export declare const groupCalculatorsByCategory: <T extends Pick<CalculatorDefinition, "category">>(calculators: readonly T[]) => Map<CalculatorCategoryBucketId, T[]>;
@@ -0,0 +1,160 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Calculator categories — the hard-coded taxonomy used to group published
3
+ // calculators in the mobile catalog and to label them in the web authoring
4
+ // tool.
5
+ //
6
+ // Deliberately NOT a Firestore collection: the set is authored here and ships
7
+ // inside the package, so both clients render the same list with no extra read,
8
+ // no rules surface, and no offline story to maintain.
9
+ //
10
+ // Distinct from `CalculatorDefinition.folderId`, which predates this and is
11
+ // still unused — `folderId` implies a hierarchy, a category is flat.
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Category ids, as PERSISTED on `calculators/{id}.category`.
15
+ *
16
+ * Never rename a value — it is stored data. To retire a category, delete its
17
+ * entry from CALCULATOR_CATEGORIES and leave existing docs alone: they resolve
18
+ * to `undefined` and degrade into the "Other" bucket rather than breaking.
19
+ */
20
+ export var CalculatorCategoryId;
21
+ (function (CalculatorCategoryId) {
22
+ CalculatorCategoryId["AreaAndVolume"] = "areaAndVolume";
23
+ CalculatorCategoryId["Concrete"] = "concrete";
24
+ CalculatorCategoryId["Electrical"] = "electrical";
25
+ CalculatorCategoryId["Stairs"] = "stairs";
26
+ CalculatorCategoryId["Framing"] = "framing";
27
+ CalculatorCategoryId["FinishCarpentry"] = "finishCarpentry";
28
+ CalculatorCategoryId["Hardscaping"] = "hardscaping";
29
+ CalculatorCategoryId["Roofing"] = "roofing";
30
+ CalculatorCategoryId["HVAC"] = "hvac";
31
+ CalculatorCategoryId["Landscaping"] = "landscaping";
32
+ CalculatorCategoryId["MaterialEstimator"] = "materialEstimator";
33
+ CalculatorCategoryId["Optimizers"] = "optimizers";
34
+ CalculatorCategoryId["Plumbing"] = "plumbing";
35
+ })(CalculatorCategoryId || (CalculatorCategoryId = {}));
36
+ /**
37
+ * Canonical order, matching the design's tile grid.
38
+ *
39
+ * This array IS the ordering — there is deliberately no `sortIndex` field for
40
+ * it to drift out of sync with.
41
+ */
42
+ export const CALCULATOR_CATEGORIES = [
43
+ {
44
+ id: CalculatorCategoryId.AreaAndVolume,
45
+ name: 'Area and Volume',
46
+ color: '#FF3A6E',
47
+ glyph: 'shape_calculator',
48
+ },
49
+ {
50
+ id: CalculatorCategoryId.Concrete,
51
+ name: 'Concrete',
52
+ color: '#E2AB6B',
53
+ glyph: 'concrete',
54
+ },
55
+ {
56
+ id: CalculatorCategoryId.Electrical,
57
+ name: 'Electrical',
58
+ color: '#60D8FE',
59
+ glyph: 'electrical',
60
+ },
61
+ {
62
+ id: CalculatorCategoryId.Stairs,
63
+ name: 'Stairs',
64
+ color: '#D09C9C',
65
+ glyph: 'stair_tread',
66
+ },
67
+ {
68
+ id: CalculatorCategoryId.Framing,
69
+ name: 'Framing',
70
+ color: '#FABC61',
71
+ glyph: 'frame',
72
+ },
73
+ {
74
+ id: CalculatorCategoryId.FinishCarpentry,
75
+ name: 'Finish Carpentry',
76
+ color: '#2E9E82',
77
+ glyph: 'finish_carpentry',
78
+ },
79
+ {
80
+ id: CalculatorCategoryId.Hardscaping,
81
+ name: 'Hardscaping',
82
+ color: '#6B77E2',
83
+ glyph: 'hardscaping',
84
+ },
85
+ {
86
+ id: CalculatorCategoryId.Roofing,
87
+ name: 'Roofing',
88
+ color: '#E48787',
89
+ glyph: 'roofing',
90
+ },
91
+ {
92
+ id: CalculatorCategoryId.HVAC,
93
+ name: 'HVAC',
94
+ color: '#FEF260',
95
+ glyph: 'hvac',
96
+ },
97
+ {
98
+ id: CalculatorCategoryId.Landscaping,
99
+ name: 'Landscaping',
100
+ color: '#C892FF',
101
+ glyph: 'landscaping',
102
+ },
103
+ {
104
+ id: CalculatorCategoryId.MaterialEstimator,
105
+ name: 'Material Estimator',
106
+ color: '#FFAD00',
107
+ glyph: 'raw_materials',
108
+ },
109
+ {
110
+ id: CalculatorCategoryId.Optimizers,
111
+ name: 'Optimizers',
112
+ color: '#CA5726',
113
+ glyph: 'wall',
114
+ },
115
+ {
116
+ id: CalculatorCategoryId.Plumbing,
117
+ name: 'Plumbing',
118
+ color: '#008EFD',
119
+ glyph: 'plumbing',
120
+ },
121
+ ];
122
+ export const CALCULATOR_CATEGORY_BY_ID = Object.fromEntries(CALCULATOR_CATEGORIES.map((category) => [category.id, category]));
123
+ /**
124
+ * Bucket key / route segment for calculators with no category, or with one
125
+ * that no longer exists. NOT a CalculatorCategoryId — guarded by a unit test,
126
+ * because a collision would silently swallow a real category.
127
+ */
128
+ export const UNCATEGORIZED_CATEGORY_ID = 'uncategorized';
129
+ export const UNCATEGORIZED_CATEGORY_NAME = 'Other';
130
+ export const isCalculatorCategoryId = (value) => typeof value === 'string' && value in CALCULATOR_CATEGORY_BY_ID;
131
+ /**
132
+ * Resolve a stored category value. Returns undefined for missing, null, and
133
+ * retired ids — callers should treat all three as "Other" rather than trusting
134
+ * the raw field.
135
+ */
136
+ export const findCalculatorCategory = (value) => isCalculatorCategoryId(value) ? CALCULATOR_CATEGORY_BY_ID[value] : undefined;
137
+ export const calculatorCategoryOf = (calculator) => findCalculatorCategory(calculator.category);
138
+ /** Display label for a stored value, falling back to "Other". */
139
+ export const calculatorCategoryName = (value) => findCalculatorCategory(value)?.name ?? UNCATEGORIZED_CATEGORY_NAME;
140
+ /**
141
+ * Group calculators by category id, collecting anything missing or
142
+ * unresolvable under UNCATEGORIZED_CATEGORY_ID.
143
+ *
144
+ * Keys exist only for non-empty buckets, which is what lets the mobile grid
145
+ * hide empty categories without a second pass.
146
+ */
147
+ export const groupCalculatorsByCategory = (calculators) => {
148
+ const buckets = new Map();
149
+ for (const calculator of calculators) {
150
+ const key = isCalculatorCategoryId(calculator.category)
151
+ ? calculator.category
152
+ : UNCATEGORIZED_CATEGORY_ID;
153
+ const bucket = buckets.get(key);
154
+ if (bucket)
155
+ bucket.push(calculator);
156
+ else
157
+ buckets.set(key, [calculator]);
158
+ }
159
+ return buckets;
160
+ };
@@ -1,4 +1,5 @@
1
1
  export * from './schema.js';
2
+ export * from './categories.js';
2
3
  export * from './units.js';
3
4
  export * from './evaluate.js';
4
5
  export * from './solve.js';
@@ -3,6 +3,7 @@
3
3
  // definition validation. Pure logic (mathjs + zod only — no Skia/React), safe
4
4
  // on web, native, and Node.
5
5
  export * from './schema.js';
6
+ export * from './categories.js';
6
7
  export * from './units.js';
7
8
  export * from './evaluate.js';
8
9
  export * from './solve.js';
@@ -1,5 +1,6 @@
1
1
  import { ColumnType, type ColumnConfig, type ConversionTableColumnData, type InstructionsColumnData, type SelectColumnData, type DecimalTolerance, type FractionalTolerance } from '../types/firestore.js';
2
2
  import type { AngleUnit, CalculatorUnit, FieldDimension, MeasurementDimension } from './units.js';
3
+ import type { CalculatorCategoryId } from './categories.js';
3
4
  /**
4
5
  * Version of the definition format itself. Bumped only on breaking schema
5
6
  * changes; independent of the npm package version and of each calculator's
@@ -123,6 +124,17 @@ export interface CalculatorDefinition {
123
124
  name: string;
124
125
  description: string;
125
126
  folderId: string | null;
127
+ /**
128
+ * Hard-coded category (see categories.ts) driving the mobile category grid
129
+ * and the web library badge.
130
+ *
131
+ * AUTHORING METADATA ONLY — never needed to run a calculator, so it stays
132
+ * optional and nullable: docs authored before categories existed have no
133
+ * value and must keep validating. Always resolve through
134
+ * findCalculatorCategory() rather than comparing the raw field, so a retired
135
+ * id degrades to "Other" instead of vanishing.
136
+ */
137
+ category?: CalculatorCategoryId | null;
126
138
  /** Inputs + outputs in one list, discriminated by `role`. */
127
139
  fields: CalculatorField[];
128
140
  equations: CalculatorEquation[];
@@ -122,6 +122,9 @@ export const createEmptyCalculatorDefinition = (id) => ({
122
122
  name: '',
123
123
  description: '',
124
124
  folderId: null,
125
+ // `null`, never undefined — this object is spread straight into setDoc and
126
+ // Firestore rejects undefined field values.
127
+ category: null,
125
128
  fields: [],
126
129
  equations: [],
127
130
  diagramFileId: null,
@@ -7,6 +7,7 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
7
7
  name: z.ZodString;
8
8
  description: z.ZodString;
9
9
  folderId: z.ZodNullable<z.ZodString>;
10
+ category: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
11
  fields: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
11
12
  kind: z.ZodLiteral<ColumnType.Number>;
12
13
  defaultValue: z.ZodOptional<z.ZodNumber>;
@@ -3,6 +3,7 @@ import { create, all } from 'mathjs';
3
3
  import { ColumnType, DecimalTolerance, FractionalTolerance, } from '../types/firestore.js';
4
4
  import { CALCULATOR_SCHEMA_VERSION, equationForField, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
5
5
  import { CALCULATOR_UNIT_INFO, unitDimension } from './units.js';
6
+ import { isCalculatorCategoryId } from './categories.js';
6
7
  const math = create(all);
7
8
  // ---------------------------------------------------------------------------
8
9
  // Definition validation: zod for structure, plus the cross-field invariants
@@ -101,6 +102,14 @@ export const calculatorDefinitionSchema = z.object({
101
102
  name: z.string(),
102
103
  description: z.string(),
103
104
  folderId: z.string().nullable(),
105
+ // Deliberately z.string(), NOT z.enum(CalculatorCategoryId): a safeParse
106
+ // failure returns early below, so a retired or typo'd id would suppress
107
+ // every other invariant and mark the whole calculator broken. Unknown ids
108
+ // are reported further down as an advisory warning instead.
109
+ //
110
+ // The key must be listed even though it's optional — zod strips unknown
111
+ // keys, so omitting it here would make the check below never fire.
112
+ category: z.string().nullish(),
104
113
  fields: z.array(calculatorFieldSchema),
105
114
  equations: z.array(calculatorEquationSchema),
106
115
  diagramFileId: z.string().nullable().optional(),
@@ -187,6 +196,12 @@ export const validateCalculatorDefinition = (input) => {
187
196
  if (def.schemaVersion > CALCULATOR_SCHEMA_VERSION) {
188
197
  push('schema-version-unsupported', 'schemaVersion', `schemaVersion ${def.schemaVersion} is newer than this client supports (${CALCULATOR_SCHEMA_VERSION})`);
189
198
  }
199
+ // A category the client doesn't know is a labelling problem, not a broken
200
+ // calculator — an older app build reading a newly-added category must still
201
+ // run it. Warning severity keeps `ok` true.
202
+ if (def.category != null && !isCalculatorCategoryId(def.category)) {
203
+ push('unknown-category', 'category', `Category "${def.category}" is not a known calculator category`, 'warning');
204
+ }
190
205
  // --- Unique ids ---------------------------------------------------------
191
206
  const fieldIds = new Set();
192
207
  def.fields.forEach((field, i) => {
@@ -0,0 +1,26 @@
1
+ import { type DecimalTolerance, type Formula, type FractionalTolerance, type Group, type Measurement, type Section, type Units } from '../types/firestore.js';
2
+ import type { ExportConfig } from './config.js';
3
+ export type ExportScope = 'all' | 'activeGroup';
4
+ export type ExportObjectRow = {
5
+ name: string;
6
+ url: string | null;
7
+ };
8
+ export type BuildJobCsvInput = {
9
+ scope: ExportScope;
10
+ activeGroupId?: string | null;
11
+ config: ExportConfig;
12
+ groups: Group[];
13
+ sections: Section[];
14
+ formulas: Formula[];
15
+ measurementsByGroup: Map<string, Measurement[]>;
16
+ objectsByGroup: Map<string, ExportObjectRow[]>;
17
+ formatMeasurementValue: (value: number, device?: string) => string;
18
+ unitPrefs: {
19
+ defaultUnit?: Units;
20
+ fractionalTolerance?: FractionalTolerance;
21
+ decimalTolerance?: DecimalTolerance;
22
+ };
23
+ };
24
+ export declare const escapeCSVField: (field: string) => string;
25
+ export declare const hyperlinkCell: (url: string, label: string) => string;
26
+ export declare function buildJobCsv(input: BuildJobCsvInput): string;
@@ -0,0 +1,189 @@
1
+ import { ColumnType, GroupType, } from '../types/firestore.js';
2
+ import { calculateFormula } from '../formulas/calculateFormula.js';
3
+ import { formatSelectedValues } from '../utils/selectValues.js';
4
+ import { isDefaultGroup, isFormGroupCompleted } from '../utils/groups.js';
5
+ export const escapeCSVField = (field) => `"${field.replace(/"/g, '""')}"`;
6
+ // Spreadsheet-formula hyperlink cell. Two escaping layers: quotes inside the
7
+ // formula's string literals are doubled (Excel/Sheets escaping), then the
8
+ // whole formula is CSV-escaped.
9
+ export const hyperlinkCell = (url, label) => escapeCSVField(`=HYPERLINK("${url.replace(/"/g, '""')}","${label.replace(/"/g, '""')}")`);
10
+ const objectCell = (obj) => obj.url ? hyperlinkCell(obj.url, obj.name) : escapeCSVField(obj.name);
11
+ // Pure CSV assembly for the job export, shared by rock-pro-app and
12
+ // rock-desktop so the two platforms emit identical files. With no objects
13
+ // selected and all measurement-data columns on, the measurement sections are
14
+ // identical to the legacy exportCSV output; objects add a trailing "Objects"
15
+ // column (list groups) or extend the form row (form groups), one skipped
16
+ // column after the existing cells.
17
+ export function buildJobCsv(input) {
18
+ const { scope, activeGroupId, config, groups, sections, formulas, measurementsByGroup, objectsByGroup, formatMeasurementValue, unitPrefs, } = input;
19
+ const csvRows = [];
20
+ let listGroups;
21
+ let formGroups;
22
+ if (scope === 'activeGroup') {
23
+ const active = groups.find((g) => g.id === activeGroupId);
24
+ listGroups = active?.type === GroupType.LIST ? [active] : [];
25
+ formGroups = active?.type === GroupType.FORM ? [active] : [];
26
+ }
27
+ else {
28
+ listGroups = groups.filter((g) => g.type === GroupType.LIST &&
29
+ config.categories.includes(isDefaultGroup(g) ? 'defaultGroup' : 'groups'));
30
+ formGroups = config.categories.includes('forms')
31
+ ? groups.filter((g) => g.type === GroupType.FORM)
32
+ : [];
33
+ }
34
+ const measurementPassesFilter = (m) => config.measurements === 'all' ||
35
+ (config.measurements === 'complete'
36
+ ? m.isCompleted === true
37
+ : m.isCompleted !== true);
38
+ const includeField = (field) => config.measurementData.includes(field);
39
+ // --- List Groups ---
40
+ if (listGroups.length > 0) {
41
+ csvRows.push('List Groups');
42
+ csvRows.push('');
43
+ listGroups.forEach((group) => {
44
+ csvRows.push(`Group: ${group.name}`);
45
+ const objects = objectsByGroup.get(group.id) ?? [];
46
+ const headerCells = [
47
+ '',
48
+ ...(includeField('measurement') ? ['Measurement'] : []),
49
+ ...(includeField('label') ? ['Label'] : []),
50
+ ...(includeField('notes') ? ['Notes'] : []),
51
+ ].map(escapeCSVField);
52
+ if (objects.length > 0) {
53
+ headerCells.push(escapeCSVField(''), escapeCSVField('Objects'));
54
+ }
55
+ csvRows.push(headerCells.join(','));
56
+ const measurements = (measurementsByGroup.get(group.id) ?? []).filter(measurementPassesFilter);
57
+ // Objects fill a parallel column, one per row from the top; extra rows
58
+ // are emitted when a group has more objects than measurements.
59
+ const rowCount = Math.max(measurements.length, objects.length);
60
+ for (let i = 0; i < rowCount; i++) {
61
+ const measurement = measurements[i];
62
+ const row = [escapeCSVField('')];
63
+ if (includeField('measurement')) {
64
+ row.push(escapeCSVField(measurement
65
+ ? formatMeasurementValue(measurement.value, measurement.device)
66
+ : ''));
67
+ }
68
+ if (includeField('label')) {
69
+ row.push(escapeCSVField(measurement?.label || ''));
70
+ }
71
+ if (includeField('notes')) {
72
+ row.push(escapeCSVField(measurement?.note || ''));
73
+ }
74
+ const obj = objects[i];
75
+ if (obj) {
76
+ row.push(escapeCSVField(''), objectCell(obj));
77
+ }
78
+ csvRows.push(row.join(','));
79
+ }
80
+ csvRows.push('');
81
+ });
82
+ }
83
+ // --- Form Groups ---
84
+ if (formGroups.length > 0) {
85
+ csvRows.push('Form Groups');
86
+ csvRows.push('');
87
+ const sectionIds = [...new Set(formGroups.map((g) => g.sectionId))];
88
+ sectionIds.forEach((sectionId) => {
89
+ const section = sections.find((s) => s.id === sectionId);
90
+ if (!section)
91
+ return;
92
+ const sectionGroups = formGroups.filter((g) => g.sectionId === sectionId);
93
+ if (sectionGroups.length === 0)
94
+ return;
95
+ csvRows.push(`Section: ${section.name}`);
96
+ csvRows.push('');
97
+ const columnHeaders = [];
98
+ section.tableConfig.forEach((column) => {
99
+ columnHeaders.push(column.name);
100
+ if (column.type === ColumnType.Measurement ||
101
+ column.type === ColumnType.Angle) {
102
+ columnHeaders.push(`${column.name} Notes`);
103
+ }
104
+ });
105
+ csvRows.push(['Form Name', ...columnHeaders].map(escapeCSVField).join(','));
106
+ sectionGroups.forEach((group) => {
107
+ // The completeness filter drops whole rows (their objects with
108
+ // them); cell references below still resolve against the group's
109
+ // full measurement list.
110
+ if (config.measurements !== 'all') {
111
+ const completed = isFormGroupCompleted(group, section);
112
+ if (config.measurements === 'complete' ? !completed : completed) {
113
+ return;
114
+ }
115
+ }
116
+ const row = [];
117
+ section.tableConfig.forEach((column) => {
118
+ if (column.type === ColumnType.Formula) {
119
+ const formula = formulas.find((f) => f.id === column.columnData?.formulaId);
120
+ if (formula) {
121
+ try {
122
+ const result = calculateFormula(formula, formulas, group.columns, section.tableConfig, measurementsByGroup.get(group.id) ?? [], unitPrefs.defaultUnit, unitPrefs.fractionalTolerance, unitPrefs.decimalTolerance);
123
+ if (result !== null &&
124
+ result !== undefined &&
125
+ typeof result === 'number') {
126
+ row.push(escapeCSVField(formatMeasurementValue(result)));
127
+ }
128
+ else {
129
+ row.push(escapeCSVField(result !== null && result !== undefined
130
+ ? String(result)
131
+ : ''));
132
+ }
133
+ }
134
+ catch {
135
+ row.push('');
136
+ }
137
+ }
138
+ else {
139
+ row.push('');
140
+ }
141
+ }
142
+ else if (column.type === ColumnType.Angle) {
143
+ const measurementId = group.columns[column.id];
144
+ const measurements = measurementsByGroup.get(group.id);
145
+ const measurement = measurements?.find((m) => m.id === measurementId);
146
+ if (measurement) {
147
+ row.push(escapeCSVField(`${measurement.value}°`));
148
+ }
149
+ else {
150
+ row.push(escapeCSVField(formatSelectedValues(group.columns[column.id])));
151
+ }
152
+ }
153
+ else if (column.id in group.columns) {
154
+ const measurementId = group.columns[column.id];
155
+ const measurements = measurementsByGroup.get(group.id);
156
+ const measurement = measurements?.find((m) => m.id === measurementId);
157
+ if (measurement) {
158
+ row.push(escapeCSVField(formatMeasurementValue(measurement.value, measurement.device)));
159
+ }
160
+ else {
161
+ row.push(escapeCSVField(formatSelectedValues(group.columns[column.id])));
162
+ }
163
+ }
164
+ else {
165
+ row.push('');
166
+ }
167
+ if (column.type === ColumnType.Measurement ||
168
+ column.type === ColumnType.Angle) {
169
+ if (group.descriptions && group.descriptions[column.id]) {
170
+ row.push(escapeCSVField(group.descriptions[column.id]));
171
+ }
172
+ else {
173
+ row.push('');
174
+ }
175
+ }
176
+ });
177
+ // Objects extend the row past the table, one skipped column after
178
+ // the last cell.
179
+ const objects = objectsByGroup.get(group.id) ?? [];
180
+ if (objects.length > 0) {
181
+ row.push(escapeCSVField(''), ...objects.map(objectCell));
182
+ }
183
+ csvRows.push([escapeCSVField(group.name), ...row].join(','));
184
+ });
185
+ csvRows.push('');
186
+ });
187
+ }
188
+ return csvRows.join('\n');
189
+ }
@@ -0,0 +1,18 @@
1
+ export type ExportCategory = 'defaultGroup' | 'groups' | 'forms';
2
+ export type ExportObjectType = 'calculators' | 'files' | 'photos' | 'cad' | 'labels' | 'notes';
3
+ export type ExportMeasurementFilter = 'all' | 'complete' | 'incomplete';
4
+ export type ExportImageFormat = 'hyperlink' | 'none';
5
+ export type ExportMeasurementDataField = 'measurement' | 'label' | 'notes';
6
+ export type ExportConfig = {
7
+ categories: ExportCategory[];
8
+ objects: ExportObjectType[];
9
+ measurements: ExportMeasurementFilter;
10
+ imageFormat: ExportImageFormat;
11
+ measurementData: ExportMeasurementDataField[];
12
+ };
13
+ export declare const ALL_EXPORT_CATEGORIES: ExportCategory[];
14
+ export declare const ALL_EXPORT_OBJECT_TYPES: ExportObjectType[];
15
+ export declare const ALL_EXPORT_MEASUREMENT_DATA_FIELDS: ExportMeasurementDataField[];
16
+ export declare const DEFAULT_EXPORT_CONFIG: ExportConfig;
17
+ export declare const EXPORT_EVERYTHING_CONFIG: ExportConfig;
18
+ export declare function normalizeExportConfig(raw: unknown): ExportConfig;
@@ -0,0 +1,72 @@
1
+ // Shared model for the configurable job CSV export (rock-pro-app +
2
+ // rock-desktop). The config is persisted on the user doc (`exportConfig`) so
3
+ // it follows the user across devices and platforms; both apps must therefore
4
+ // read/write the same shape and tolerate values written by the other.
5
+ export const ALL_EXPORT_CATEGORIES = [
6
+ 'defaultGroup',
7
+ 'groups',
8
+ 'forms',
9
+ ];
10
+ export const ALL_EXPORT_OBJECT_TYPES = [
11
+ 'calculators',
12
+ 'files',
13
+ 'photos',
14
+ 'cad',
15
+ 'labels',
16
+ 'notes',
17
+ ];
18
+ export const ALL_EXPORT_MEASUREMENT_DATA_FIELDS = ['measurement', 'label', 'notes'];
19
+ // Objects are opt-in: nothing ticked and images excluded until the user
20
+ // configures otherwise, so a default export is measurements-only.
21
+ export const DEFAULT_EXPORT_CONFIG = {
22
+ categories: [...ALL_EXPORT_CATEGORIES],
23
+ objects: [],
24
+ measurements: 'all',
25
+ imageFormat: 'none',
26
+ measurementData: [...ALL_EXPORT_MEASUREMENT_DATA_FIELDS],
27
+ };
28
+ // "Export All" (everything, ignoring the saved config): every category, every
29
+ // object kind as a hyperlink, all measurements, all list columns.
30
+ export const EXPORT_EVERYTHING_CONFIG = {
31
+ categories: [...ALL_EXPORT_CATEGORIES],
32
+ objects: [...ALL_EXPORT_OBJECT_TYPES],
33
+ measurements: 'all',
34
+ imageFormat: 'hyperlink',
35
+ measurementData: [...ALL_EXPORT_MEASUREMENT_DATA_FIELDS],
36
+ };
37
+ const isCategory = (v) => typeof v === 'string' && ALL_EXPORT_CATEGORIES.includes(v);
38
+ const isObjectType = (v) => typeof v === 'string' && ALL_EXPORT_OBJECT_TYPES.includes(v);
39
+ const isMeasurementFilter = (v) => v === 'all' || v === 'complete' || v === 'incomplete';
40
+ const isImageFormat = (v) => v === 'hyperlink' || v === 'none';
41
+ const isMeasurementDataField = (v) => typeof v === 'string' &&
42
+ ALL_EXPORT_MEASUREMENT_DATA_FIELDS.includes(v);
43
+ // Tolerant reader for the raw user-doc field. Missing/invalid fields fall
44
+ // back to defaults and unknown array members are dropped. Empty `categories`
45
+ // or `measurementData` fall back to all — the UIs can't save those, so treat
46
+ // them as corrupt. `objects` defaults to none (objects are opt-in), so
47
+ // missing and empty read the same.
48
+ export function normalizeExportConfig(raw) {
49
+ const r = raw && typeof raw === 'object' ? raw : {};
50
+ const categories = Array.isArray(r.categories)
51
+ ? r.categories.filter(isCategory)
52
+ : [];
53
+ const objects = Array.isArray(r.objects)
54
+ ? r.objects.filter(isObjectType)
55
+ : [];
56
+ const measurementData = Array.isArray(r.measurementData)
57
+ ? r.measurementData.filter(isMeasurementDataField)
58
+ : [];
59
+ return {
60
+ categories: categories.length > 0 ? categories : [...ALL_EXPORT_CATEGORIES],
61
+ objects,
62
+ measurements: isMeasurementFilter(r.measurements)
63
+ ? r.measurements
64
+ : DEFAULT_EXPORT_CONFIG.measurements,
65
+ imageFormat: isImageFormat(r.imageFormat)
66
+ ? r.imageFormat
67
+ : DEFAULT_EXPORT_CONFIG.imageFormat,
68
+ measurementData: measurementData.length > 0
69
+ ? measurementData
70
+ : [...ALL_EXPORT_MEASUREMENT_DATA_FIELDS],
71
+ };
72
+ }
@@ -0,0 +1,11 @@
1
+ import { type FileUpload } from '../types/firestore.js';
2
+ export type DesktopLinkContext = {
3
+ baseUrl: string;
4
+ orgId: string;
5
+ projectId: string;
6
+ jobId: string;
7
+ groupId: string;
8
+ isDefaultGroup: boolean;
9
+ };
10
+ export declare function desktopBaseUrlForFirebaseProject(firebaseProjectId: string | undefined): string;
11
+ export declare function desktopFileEditorUrl(file: FileUpload, ctx: DesktopLinkContext): string | null;
@@ -0,0 +1,45 @@
1
+ import { FileUploadType } from '../types/firestore.js';
2
+ // Maps the running mobile app's Firebase project onto the desktop
3
+ // deployment. Dev builds target a local rock-desktop dev server (`yarn dev`,
4
+ // Vite's default port) for testing.
5
+ export function desktopBaseUrlForFirebaseProject(firebaseProjectId) {
6
+ switch (firebaseProjectId) {
7
+ case 'rock-production-pro':
8
+ return 'https://app.boldrpro.com';
9
+ case 'rock-staging-pro':
10
+ return 'https://staging.app.boldrpro.com';
11
+ default:
12
+ return 'http://localhost:5173';
13
+ }
14
+ }
15
+ // Returns the desktop editor URL for an exported group file, or null when
16
+ // desktop has no editor for it (labels render inert there) — the CSV cell
17
+ // then degrades to the plain object name.
18
+ export function desktopFileEditorUrl(file, ctx) {
19
+ const { baseUrl, orgId, projectId, jobId, groupId, isDefaultGroup } = ctx;
20
+ const jobPath = `${baseUrl}/app/organizations/${orgId}/projects/${projectId}/jobs/${jobId}`;
21
+ const groupQuery = `groupId=${encodeURIComponent(groupId)}`;
22
+ switch (file.type) {
23
+ case FileUploadType.LayoutGroup:
24
+ return `${jobPath}/layout-group/${file.id}?${groupQuery}`;
25
+ case FileUploadType.Note:
26
+ return `${jobPath}/notes/${file.id}?${groupQuery}`;
27
+ case FileUploadType.Canvas:
28
+ return `${jobPath}/canvas/${file.id}?${groupQuery}`;
29
+ case FileUploadType.Annotation: {
30
+ // Pass a top-level fileType through when the doc carries one (desktop
31
+ // uses it to pick the 3D viewer); omit otherwise — the viewer defaults
32
+ // to the PDF path.
33
+ const rawFileType = file.fileType;
34
+ const fileTypeQuery = rawFileType
35
+ ? `&fileType=${encodeURIComponent(rawFileType)}`
36
+ : '';
37
+ const defaultQuery = isDefaultGroup ? '&isDefault=true' : '';
38
+ return `${jobPath}/viewer?fileId=${encodeURIComponent(file.id)}&${groupQuery}${fileTypeQuery}${defaultQuery}`;
39
+ }
40
+ default:
41
+ // Labels (no desktop editor) and anything classifyFileForExport should
42
+ // already have excluded.
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,3 @@
1
+ import { type FileUpload } from '../types/firestore.js';
2
+ import type { ExportObjectType } from './config.js';
3
+ export declare function classifyFileForExport(file: FileUpload): ExportObjectType | null;
@@ -0,0 +1,28 @@
1
+ import { FileUploadType } from '../types/firestore.js';
2
+ // Maps a group file onto the export config's object kinds. `null` means the
3
+ // file is never exported: Calculator is a config placeholder for now (the
4
+ // checkbox persists but nothing exports until calculator export ships),
5
+ // Conversion/Template/Background are internal utility docs, and Annotation
6
+ // files flagged `isLabel` are label-print artifacts hidden from group grids.
7
+ export function classifyFileForExport(file) {
8
+ switch (file.type) {
9
+ case FileUploadType.LayoutGroup:
10
+ return 'cad';
11
+ case FileUploadType.Note:
12
+ return 'notes';
13
+ case FileUploadType.Label:
14
+ return 'labels';
15
+ case FileUploadType.Canvas:
16
+ return 'photos';
17
+ case FileUploadType.Annotation: {
18
+ const fileData = file.fileData;
19
+ if (fileData?.isLabel)
20
+ return null;
21
+ // Legacy Nutrient sketches count as photos for filtering, but still
22
+ // open in the document viewer (they have no Skia canvas state).
23
+ return fileData?.fileType === 'sketch' ? 'photos' : 'files';
24
+ }
25
+ default:
26
+ return null;
27
+ }
28
+ }
package/dist/exports.d.ts CHANGED
@@ -7,7 +7,11 @@ export * from './types/firestore.js';
7
7
  export * from './types/layout.js';
8
8
  export * from './types/annotation.js';
9
9
  export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, generateToleranceGradient, createDefaultToleranceThresholds, DEFAULT_TOLERANCE_COLORS, type ToleranceThreshold, type ToleranceConfig, } from './utils/tolerance.js';
10
- export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
10
+ export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, isFormGroupCompleted, } from './utils/groups.js';
11
+ export { ALL_EXPORT_CATEGORIES, ALL_EXPORT_MEASUREMENT_DATA_FIELDS, ALL_EXPORT_OBJECT_TYPES, DEFAULT_EXPORT_CONFIG, EXPORT_EVERYTHING_CONFIG, normalizeExportConfig, type ExportCategory, type ExportConfig, type ExportImageFormat, type ExportMeasurementDataField, type ExportMeasurementFilter, type ExportObjectType, } from './export/config.js';
12
+ export { classifyFileForExport } from './export/objectKinds.js';
13
+ export { buildJobCsv, escapeCSVField, hyperlinkCell, type BuildJobCsvInput, type ExportObjectRow, type ExportScope, } from './export/buildJobCsv.js';
14
+ export { desktopBaseUrlForFirebaseProject, desktopFileEditorUrl, type DesktopLinkContext, } from './export/desktopLinks.js';
11
15
  export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
12
16
  export { toSelectedValues, formatSelectedValues, toStoredValue, } from './utils/selectValues.js';
13
17
  export * from './calculator/index.js';
package/dist/exports.js CHANGED
@@ -11,7 +11,13 @@ export * from './types/firestore.js';
11
11
  export * from './types/layout.js';
12
12
  export * from './types/annotation.js';
13
13
  export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, generateToleranceGradient, createDefaultToleranceThresholds, DEFAULT_TOLERANCE_COLORS, } from './utils/tolerance.js';
14
- export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
14
+ export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, isFormGroupCompleted, } from './utils/groups.js';
15
+ // Configurable job CSV export, shared by rock-pro-app and rock-desktop so
16
+ // both platforms emit identical files.
17
+ export { ALL_EXPORT_CATEGORIES, ALL_EXPORT_MEASUREMENT_DATA_FIELDS, ALL_EXPORT_OBJECT_TYPES, DEFAULT_EXPORT_CONFIG, EXPORT_EVERYTHING_CONFIG, normalizeExportConfig, } from './export/config.js';
18
+ export { classifyFileForExport } from './export/objectKinds.js';
19
+ export { buildJobCsv, escapeCSVField, hyperlinkCell, } from './export/buildJobCsv.js';
20
+ export { desktopBaseUrlForFirebaseProject, desktopFileEditorUrl, } from './export/desktopLinks.js';
15
21
  export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
16
22
  export { toSelectedValues, formatSelectedValues, toStoredValue, } from './utils/selectValues.js';
17
23
  // Construction Calculator shared module (schema, units, evaluator, solver,
@@ -1,4 +1,5 @@
1
- import type { Group } from '../types/firestore.js';
1
+ import { type Group, type Section } from '../types/firestore.js';
2
2
  export declare const DEFAULT_GROUP_INDEX = 0;
3
3
  export declare const isDefaultGroup: (group: Pick<Group, "groupIndex"> | null | undefined) => boolean;
4
4
  export declare const findDefaultGroup: <T extends Pick<Group, "groupIndex">>(groups: T[] | null | undefined) => T | null;
5
+ export declare const isFormGroupCompleted: (group: Group, section: Section) => boolean;
@@ -1,3 +1,17 @@
1
+ import { ColumnType } from '../types/firestore.js';
1
2
  export const DEFAULT_GROUP_INDEX = 0;
2
3
  export const isDefaultGroup = (group) => group?.groupIndex === DEFAULT_GROUP_INDEX;
3
4
  export const findDefaultGroup = (groups) => groups?.find((g) => g.groupIndex === DEFAULT_GROUP_INDEX) ?? null;
5
+ // A FORM group (one row of its section's table) counts as complete when every
6
+ // Measurement column holds a value. Angle columns are intentionally excluded
7
+ // (mirrors the apps' header-completion computations), and a section with no
8
+ // Measurement columns is never "complete".
9
+ export const isFormGroupCompleted = (group, section) => {
10
+ const measurementColumns = section.tableConfig.filter((column) => column.type === ColumnType.Measurement);
11
+ if (measurementColumns.length === 0)
12
+ return false;
13
+ return measurementColumns.every((column) => {
14
+ const value = group.columns?.[column.id];
15
+ return value !== undefined && value !== '';
16
+ });
17
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "build": "tsc",
26
26
  "prepack": "yarn run build",
27
27
  "sync:local": "./scripts/sync-local.sh",
28
+ "gen:category-icons": "tsc && node ./scripts/gen-category-icons.mjs",
28
29
  "test": "vitest",
29
30
  "coverage": "vitest run --coverage",
30
31
  "format": "prettier --write .",