@reekon-tools/boldr-utils 1.7.5 → 1.8.1

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.
@@ -42,6 +42,11 @@ const useBackgroundUrl = (image, resolveUrl) => {
42
42
  // on a null/undefined source (despite its DataSourceParam type), so it can't
43
43
  // be called unconditionally from a component that usually renders raster
44
44
  // backgrounds. An empty url resolves to null (nothing to draw yet).
45
+ // Loaded through Skia's own scheme-agnostic data loader rather than fetch():
46
+ // RN's Android networking rejects non-http(s) schemes, and offline-first
47
+ // consumers resolve the background to a local file:// URI (fromURI handles
48
+ // file://, http(s) and data: on native and web alike). fetch() remains as a
49
+ // fallback for any environment where fromURI can't service the URL.
45
50
  const useBackgroundSvg = (url) => {
46
51
  const [svg, setSvg] = useState(null);
47
52
  useEffect(() => {
@@ -52,9 +57,16 @@ const useBackgroundSvg = (url) => {
52
57
  let cancelled = false;
53
58
  (async () => {
54
59
  try {
55
- const res = await fetch(url);
56
- const text = await res.text();
57
- const next = Skia.SVG.MakeFromString(text);
60
+ let next = null;
61
+ try {
62
+ const data = await Skia.Data.fromURI(url);
63
+ next = Skia.SVG.MakeFromData(data);
64
+ data.dispose();
65
+ }
66
+ catch {
67
+ const res = await fetch(url);
68
+ next = Skia.SVG.MakeFromString(await res.text());
69
+ }
58
70
  if (!cancelled)
59
71
  setSvg(next);
60
72
  }
@@ -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) => {
@@ -97,8 +97,9 @@ export function buildJobCsv(input) {
97
97
  const columnHeaders = [];
98
98
  section.tableConfig.forEach((column) => {
99
99
  columnHeaders.push(column.name);
100
- if (column.type === ColumnType.Measurement ||
101
- column.type === ColumnType.Angle) {
100
+ if (includeField('notes') &&
101
+ (column.type === ColumnType.Measurement ||
102
+ column.type === ColumnType.Angle)) {
102
103
  columnHeaders.push(`${column.name} Notes`);
103
104
  }
104
105
  });
@@ -164,8 +165,9 @@ export function buildJobCsv(input) {
164
165
  else {
165
166
  row.push('');
166
167
  }
167
- if (column.type === ColumnType.Measurement ||
168
- column.type === ColumnType.Angle) {
168
+ if (includeField('notes') &&
169
+ (column.type === ColumnType.Measurement ||
170
+ column.type === ColumnType.Angle)) {
169
171
  if (group.descriptions && group.descriptions[column.id]) {
170
172
  row.push(escapeCSVField(group.descriptions[column.id]));
171
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.7.5",
3
+ "version": "1.8.1",
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 .",