formlab-mcp 0.6.49 → 0.6.51

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.
package/vendor/cost.js ADDED
@@ -0,0 +1,2619 @@
1
+ // ============================================================
2
+ // COST — per-ingredient pricing, currency conversion, formulation
3
+ // cost rollup. The shape of ing.cost is:
4
+ // { value: number, currency: 'USD', unit: 'kg', source: 'manual'
5
+ // | 'csv' | 'erp', updatedAt: ISO8601 }
6
+ // ============================================================
7
+
8
+ const COST_CURRENCIES = [
9
+ { code: 'USD', symbol: '$', label: 'US Dollar' },
10
+ { code: 'EUR', symbol: '€', label: 'Euro' },
11
+ { code: 'GBP', symbol: '£', label: 'British Pound' },
12
+ { code: 'JPY', symbol: '¥', label: 'Japanese Yen' },
13
+ { code: 'CNY', symbol: '¥', label: 'Chinese Yuan' },
14
+ { code: 'CAD', symbol: 'C$', label: 'Canadian Dollar' },
15
+ { code: 'AUD', symbol: 'A$', label: 'Australian Dollar' },
16
+ { code: 'CHF', symbol: 'Fr', label: 'Swiss Franc' },
17
+ { code: 'INR', symbol: '₹', label: 'Indian Rupee' },
18
+ { code: 'BRL', symbol: 'R$', label: 'Brazilian Real' },
19
+ // Asia-Pacific + Mexico (2026-09-09): all four are ECB reference currencies,
20
+ // so Frankfurter serves live rates for them like the rest.
21
+ { code: 'SGD', symbol: 'S$', label: 'Singapore Dollar' },
22
+ { code: 'HKD', symbol: 'HK$', label: 'Hong Kong Dollar' },
23
+ { code: 'KRW', symbol: '₩', label: 'South Korean Won' },
24
+ { code: 'MXN', symbol: 'MX$', label: 'Mexican Peso' },
25
+ // PKR is NOT an ECB reference currency, so Frankfurter (our primary FX
26
+ // source) doesn't price it. It's live-rated via the secondary source in
27
+ // _backfillMissingFxRates; see the note there. Added 2026-10-02 for a
28
+ // Pakistan-based compounder.
29
+ { code: 'PKR', symbol: '₨', label: 'Pakistani Rupee' },
30
+ ];
31
+ // Picker order: USD / EUR / GBP pinned on top, the rest alphabetical by code —
32
+ // so a new currency slots into place instead of landing at the end of the list.
33
+ (function () {
34
+ const pinned = ['USD', 'EUR', 'GBP'];
35
+ COST_CURRENCIES.sort((a, b) => {
36
+ const pa = pinned.indexOf(a.code), pb = pinned.indexOf(b.code);
37
+ if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
38
+ return a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
39
+ });
40
+ })();
41
+
42
+ // Built-in mass units → kg
43
+ const MASS_TO_KG = {
44
+ µg: 1e-9, ug: 1e-9, mcg: 1e-9,
45
+ mg: 1e-6, cg: 1e-5, dg: 1e-4, g: 0.001,
46
+ hg: 0.1, kg: 1,
47
+ t: 1000, ton: 1000, tonne: 1000,
48
+ lb: 0.45359237, oz: 0.0283495, grain: 6.479891e-5, stone: 6.35029,
49
+ };
50
+ // Built-in volume units → L
51
+ const VOL_TO_L = {
52
+ µL: 1e-6, uL: 1e-6,
53
+ mL: 0.001, cL: 0.01, dL: 0.1, L: 1,
54
+ m3: 1000, 'm³': 1000,
55
+ cm3: 0.001, 'cm³': 0.001,
56
+ dm3: 1, 'dm³': 1,
57
+ gal: 3.78541, 'gal_uk': 4.54609, pt: 0.473176, qt: 0.946353,
58
+ tsp: 0.00492892, tbsp: 0.0147868, cup: 0.24,
59
+ fl_oz: 0.0295735, 'fl oz': 0.0295735,
60
+ drum: 208.198, bbl: 158.987,
61
+ };
62
+ // Built-in length units → m
63
+ const LENGTH_TO_M = {
64
+ µm: 1e-6, um: 1e-6,
65
+ mm: 0.001, cm: 0.01, m: 1, km: 1000,
66
+ in: 0.0254, ft: 0.3048, yd: 0.9144, mi: 1609.34,
67
+ };
68
+
69
+ // === Custom-unit overlay ===
70
+ // Reads db.customUnits and merges with built-ins. Format:
71
+ // db.customUnits = {
72
+ // 'bag-50kg': { dimension: 'mass', factorToBase: 50 },
73
+ // 'pail-5gal':{ dimension: 'volume', factorToBase: 18.927 },
74
+ // 'each': { dimension: 'count' } // no conversion
75
+ // }
76
+ function _customMassUnits() {
77
+ const out = {};
78
+ const cu = (typeof db !== 'undefined' && db?.customUnits) || {};
79
+ for (const [k, v] of Object.entries(cu)) {
80
+ if (v?.dimension === 'mass' && isFinite(v.factorToBase) && v.factorToBase > 0) out[k] = v.factorToBase;
81
+ }
82
+ return out;
83
+ }
84
+ function _customVolUnits() {
85
+ const out = {};
86
+ const cu = (typeof db !== 'undefined' && db?.customUnits) || {};
87
+ for (const [k, v] of Object.entries(cu)) {
88
+ if (v?.dimension === 'volume' && isFinite(v.factorToBase) && v.factorToBase > 0) out[k] = v.factorToBase;
89
+ }
90
+ return out;
91
+ }
92
+ function _customLengthUnits() {
93
+ const out = {};
94
+ const cu = (typeof db !== 'undefined' && db?.customUnits) || {};
95
+ for (const [k, v] of Object.entries(cu)) {
96
+ if (v?.dimension === 'length' && isFinite(v.factorToBase) && v.factorToBase > 0) out[k] = v.factorToBase;
97
+ }
98
+ return out;
99
+ }
100
+ function getMassUnits() { return { ...MASS_TO_KG, ..._customMassUnits() }; }
101
+ function getVolUnits() { return { ...VOL_TO_L, ..._customVolUnits() }; }
102
+ function getLengthUnits() { return { ...LENGTH_TO_M, ..._customLengthUnits() }; }
103
+ function isMassUnit(u) { return u != null && (u in MASS_TO_KG || u in _customMassUnits()); }
104
+ function isVolUnit(u) { return u != null && (u in VOL_TO_L || u in _customVolUnits()); }
105
+ function isLengthUnit(u) { return u != null && (u in LENGTH_TO_M || u in _customLengthUnits()); }
106
+ function massToKg(u) { return MASS_TO_KG[u] ?? _customMassUnits()[u] ?? null; }
107
+ function volToL(u) { return VOL_TO_L[u] ?? _customVolUnits()[u] ?? null; }
108
+ function lengthToM(u) { return LENGTH_TO_M[u] ?? _customLengthUnits()[u] ?? null; }
109
+
110
+ function getCostUnits() {
111
+ const customCount = Object.entries((typeof db !== 'undefined' && db?.customUnits) || {})
112
+ .filter(([_, v]) => v?.dimension === 'count').map(([k]) => k);
113
+ return [
114
+ ...Object.keys(getMassUnits()),
115
+ ...Object.keys(getVolUnits()),
116
+ ...Object.keys(getLengthUnits()),
117
+ 'each',
118
+ ...customCount,
119
+ ];
120
+ }
121
+
122
+ // ============================================================
123
+ // SHARED UNIT DATALISTS — make every unit field type-to-filter
124
+ // ------------------------------------------------------------
125
+ // A native <select> of ~30 units means scrolling to find one. Instead,
126
+ // unit inputs are <input list="fl-units-…"> referencing one of these
127
+ // shared <datalist>s: the user can just type ("ml", "lb") to filter,
128
+ // or open it to browse. Common units are listed first. Rebuilt on
129
+ // load and whenever a custom unit is added.
130
+ function getAllUnits() {
131
+ const builtin = [
132
+ 'wt%', 'vol%',
133
+ ...Object.keys(getMassUnits()),
134
+ ...Object.keys(getVolUnits()),
135
+ ...Object.keys(getLengthUnits()),
136
+ 'ppm', 'ppb', 'eq', 'mol', 'each',
137
+ ];
138
+ const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
139
+ return [...new Set([...builtin, ...custom])];
140
+ }
141
+ function ensureUnitDatalists() {
142
+ if (typeof document === 'undefined' || !document.body) return;
143
+ const q = (s) => String(s).replace(/"/g, '&quot;');
144
+ const make = (id, units) => {
145
+ let dl = document.getElementById(id);
146
+ if (!dl) { dl = document.createElement('datalist'); dl.id = id; document.body.appendChild(dl); }
147
+ dl.innerHTML = units.map((u) => `<option value="${q(u)}"></option>`).join('');
148
+ };
149
+ make('fl-units-all', getAllUnits()); // composition / batch quantity
150
+ make('fl-units-cost', getCostUnits()); // ingredient set-price (mass/vol/length/each)
151
+ make('fl-units-qty', getQuantityUnits()); // physical quantity made (mass/vol/count, no fractions)
152
+ }
153
+
154
+ // Units for a physical quantity made (sample / batch yield): mass, volume,
155
+ // count — NOT fractions (wt%/ppm), which only make sense in a recipe ratio.
156
+ // Mirrors the curated set the old <select> used (`_quantityUnitOptions`).
157
+ function getQuantityUnits() {
158
+ const mass = ['mg', 'g', 'kg', 'ton', 'lb', 'oz'];
159
+ const vol = ['µL', 'mL', 'L', 'tsp', 'tbsp', 'cup', 'fl_oz', 'pt', 'qt', 'gal', 'drum'];
160
+ const count = ['units', 'each'];
161
+ const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
162
+ return [...new Set([...mass, ...vol, ...count, ...custom])];
163
+ }
164
+
165
+ // ============================================================
166
+ // Unit combobox — replaces the native <datalist> popup.
167
+ // ------------------------------------------------------------
168
+ // The native datalist dropdown mis-anchors to the top-left of the window when
169
+ // its <input> lives inside a position:fixed modal (a long-standing Chromium
170
+ // bug) — it showed up as a stray "floating fill box". So unit inputs now carry
171
+ // `class="fl-unit-combo" data-unit-list="<datalist id>"` (NOT `list=`), and we
172
+ // render our own correctly-positioned, type-to-filter suggestion list off the
173
+ // same datalist options. Free typing still works (custom units are allowed);
174
+ // the list is just suggestions. Delegated listeners cover inputs created later
175
+ // (modals, dynamic ingredient rows) with no per-field wiring.
176
+ (function initUnitCombo() {
177
+ if (typeof document === 'undefined') return;
178
+ var POP_ID = 'fl-unit-combo-pop';
179
+ var activeInput = null, activeIdx = -1;
180
+ // Show the in-popup search box once a list has more than this many units.
181
+ var SEARCH_THRESHOLD = 8;
182
+
183
+ function escA(s) { return String(s).replace(/"/g, '&quot;'); }
184
+ function escH(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
185
+ function isCombo(el) { return !!(el && el.classList && el.classList.contains('fl-unit-combo')); }
186
+ function popEl() {
187
+ var p = document.getElementById(POP_ID);
188
+ if (!p) { p = document.createElement('div'); p.id = POP_ID; p.className = 'fl-unit-combo-pop'; p.style.display = 'none'; document.body.appendChild(p); }
189
+ return p;
190
+ }
191
+ function isOpen() { var p = document.getElementById(POP_ID); return !!(p && p.style.display !== 'none'); }
192
+ function optionsFor(input) {
193
+ var dl = document.getElementById(input.getAttribute('data-unit-list') || '');
194
+ return dl ? Array.prototype.map.call(dl.children, function (o) { return o.value; }) : [];
195
+ }
196
+ function listEl() { var p = document.getElementById(POP_ID); return p ? p.querySelector('.fl-unit-combo-list') : null; }
197
+ function items() { var l = listEl(); return l ? Array.prototype.slice.call(l.querySelectorAll('.fl-unit-combo-item')) : []; }
198
+
199
+ // Classify a unit into a display group. Mirrors the formula picker's grouping
200
+ // (Mass / Volume / Length …) and uses the same isMassUnit/isVolUnit helpers
201
+ // so custom units land in the right bucket too.
202
+ function classify(u) {
203
+ if (['wt%', 'vol%', '%', 'ppm', 'ppb'].indexOf(u) !== -1) return 'frac';
204
+ if (typeof isMassUnit === 'function' && isMassUnit(u)) return 'mass';
205
+ if (typeof isVolUnit === 'function' && isVolUnit(u)) return 'vol';
206
+ if (typeof isLengthUnit === 'function' && isLengthUnit(u)) return 'len';
207
+ if (['mol', 'eq', 'each', 'pcs', 'unit', 'units'].indexOf(u) !== -1) return 'amt';
208
+ return 'other';
209
+ }
210
+ var GROUP_ORDER = [['frac', 'Fraction'], ['mass', 'Mass'], ['vol', 'Volume'], ['len', 'Length'], ['amt', 'Amount / count'], ['other', 'Other']];
211
+ function groupUnits(units) {
212
+ var by = {};
213
+ units.forEach(function (u) { var k = classify(u); (by[k] = by[k] || []).push(u); });
214
+ return GROUP_ORDER.map(function (p) { return { label: p[1], units: by[p[0]] || [] }; }).filter(function (g) { return g.units.length; });
215
+ }
216
+
217
+ function position(input) {
218
+ var p = popEl(), r = input.getBoundingClientRect();
219
+ p.style.position = 'fixed';
220
+ p.style.minWidth = r.width + 'px';
221
+ p.style.top = (r.bottom + 2) + 'px';
222
+ p.style.left = r.left + 'px';
223
+ var h = p.offsetHeight;
224
+ if (r.bottom + 2 + h > window.innerHeight - 8) p.style.top = Math.max(8, r.top - h - 2) + 'px';
225
+ var w = p.offsetWidth, left = r.left;
226
+ if (left + w > window.innerWidth - 8) left = Math.max(8, window.innerWidth - w - 8);
227
+ p.style.left = left + 'px';
228
+ }
229
+
230
+ // Re-render ONLY the grouped list for a filter query (keeps the search box —
231
+ // and its focus/caret — intact across keystrokes).
232
+ function renderList(input, query) {
233
+ var l = listEl(); if (!l) return;
234
+ var q = String(query || '').trim();
235
+ var ql = q.toLowerCase();
236
+ var all = optionsFor(input);
237
+ var cur = String(input.value || '').trim().toLowerCase();
238
+ var matches = all.filter(function (u) { return !ql || u.toLowerCase().indexOf(ql) !== -1; });
239
+ var html = groupUnits(matches).map(function (g) {
240
+ return '<div class="fl-unit-combo-group">' + escH(g.label) + '</div>' +
241
+ g.units.map(function (u) {
242
+ return '<div class="fl-unit-combo-item' + (u.toLowerCase() === cur ? ' current' : '') + '" data-val="' + escA(u) + '">' + escH(u) + '</div>';
243
+ }).join('');
244
+ }).join('');
245
+ // Free-typed custom unit: offer to use it verbatim when it isn't an option.
246
+ var exact = all.some(function (u) { return u.toLowerCase() === ql; });
247
+ if (q && !exact) html += '<div class="fl-unit-combo-item fl-unit-combo-custom" data-val="' + escA(q) + '">Use “' + escH(q) + '”</div>';
248
+ if (!html) html = '<div class="fl-unit-combo-empty">No matching units</div>';
249
+ l.innerHTML = html;
250
+ activeIdx = -1;
251
+ position(input);
252
+ }
253
+
254
+ function open(input) {
255
+ activeInput = input;
256
+ var p = popEl();
257
+ var showSearch = optionsFor(input).length > SEARCH_THRESHOLD;
258
+ p.innerHTML =
259
+ (showSearch ? '<div class="fl-unit-combo-searchwrap"><input type="text" class="fl-unit-combo-search" placeholder="Search units…" autocomplete="off" spellcheck="false"></div>' : '') +
260
+ '<div class="fl-unit-combo-list"></div>';
261
+ p.style.display = 'block';
262
+ if (showSearch) {
263
+ var sb = p.querySelector('.fl-unit-combo-search');
264
+ sb.addEventListener('input', function () { renderList(input, sb.value); });
265
+ sb.addEventListener('keydown', navKeydown);
266
+ sb.addEventListener('blur', function () {
267
+ setTimeout(function () {
268
+ var pp = document.getElementById(POP_ID);
269
+ if (pp && !pp.contains(document.activeElement) && document.activeElement !== input) hide();
270
+ }, 120);
271
+ });
272
+ }
273
+ renderList(input, '');
274
+ position(input);
275
+ // Move focus into the search box so the user can type to filter (matches
276
+ // the ingredient picker). The field keeps the chosen value.
277
+ if (showSearch) setTimeout(function () { var sb2 = p.querySelector('.fl-unit-combo-search'); if (sb2) sb2.focus(); }, 0);
278
+ }
279
+ function hide() { var p = document.getElementById(POP_ID); if (p) p.style.display = 'none'; activeIdx = -1; }
280
+ function choose(val) {
281
+ var inp = activeInput; if (!inp) return;
282
+ inp.value = val;
283
+ inp.dispatchEvent(new Event('input', { bubbles: true }));
284
+ inp.dispatchEvent(new Event('change', { bubbles: true }));
285
+ hide();
286
+ try { inp.focus(); } catch (_) {}
287
+ }
288
+ function highlight(i) {
289
+ var its = items(); if (!its.length) { activeIdx = -1; return; }
290
+ activeIdx = (i + its.length) % its.length;
291
+ its.forEach(function (el, idx) { el.classList.toggle('active', idx === activeIdx); });
292
+ var c = its[activeIdx]; if (c && c.scrollIntoView) c.scrollIntoView({ block: 'nearest' });
293
+ }
294
+ // Shared arrow/Enter/Esc handling for both the field and the search box.
295
+ function navKeydown(e) {
296
+ if (!isOpen()) { if (e.key === 'ArrowDown' && activeInput) { open(activeInput); e.preventDefault(); } return; }
297
+ if (e.key === 'ArrowDown') { highlight(activeIdx + 1); e.preventDefault(); }
298
+ else if (e.key === 'ArrowUp') { highlight(activeIdx - 1); e.preventDefault(); }
299
+ else if (e.key === 'Enter') {
300
+ var its = items();
301
+ var pick = activeIdx >= 0 ? its[activeIdx] : (its.length === 1 ? its[0] : null);
302
+ if (pick) { choose(pick.getAttribute('data-val')); e.preventDefault(); }
303
+ } else if (e.key === 'Escape') { hide(); if (activeInput) try { activeInput.focus(); } catch (_) {} }
304
+ }
305
+
306
+ document.addEventListener('focusin', function (e) { if (isCombo(e.target)) open(e.target); });
307
+ // Typing directly in the field filters too (only matters when there's no
308
+ // search box — i.e. short lists; otherwise focus is in the search box).
309
+ document.addEventListener('input', function (e) {
310
+ if (isCombo(e.target) && isOpen() && !(document.getElementById(POP_ID) || {}).querySelector('.fl-unit-combo-search')) {
311
+ renderList(e.target, e.target.value);
312
+ }
313
+ });
314
+ document.addEventListener('keydown', function (e) { if (isCombo(e.target)) navKeydown(e); });
315
+ // mousedown (capture) so a click on a suggestion wins over the input's blur.
316
+ document.addEventListener('mousedown', function (e) {
317
+ if (!isOpen()) return;
318
+ var item = e.target.closest && e.target.closest('.fl-unit-combo-item');
319
+ if (item) { e.preventDefault(); choose(item.getAttribute('data-val')); return; }
320
+ var p = document.getElementById(POP_ID);
321
+ if (e.target !== activeInput && !(p && p.contains(e.target))) hide();
322
+ }, true);
323
+ document.addEventListener('focusout', function (e) {
324
+ if (!isCombo(e.target)) return;
325
+ setTimeout(function () {
326
+ var p = document.getElementById(POP_ID);
327
+ if (p && p.contains(document.activeElement)) return; // focus moved into the popup (search box)
328
+ if (isCombo(document.activeElement)) return;
329
+ hide();
330
+ }, 120);
331
+ });
332
+ window.addEventListener('scroll', function () { if (activeInput && isOpen()) position(activeInput); }, true);
333
+ window.addEventListener('resize', hide);
334
+ })();
335
+
336
+ // Backwards-compat alias — older code paths reference COST_UNITS directly.
337
+ // We can't make it a live getter without breaking destructuring, so anyone
338
+ // hitting it gets the snapshot taken when cost.js loaded. New code should
339
+ // call getCostUnits() instead.
340
+ const COST_UNITS = [
341
+ ...Object.keys(MASS_TO_KG),
342
+ ...Object.keys(VOL_TO_L),
343
+ 'each',
344
+ ];
345
+
346
+ // ============================================================
347
+ // COST ESTIMATES — rough market prices for common ingredients,
348
+ // used by the "⚲ Lookup" button in the Ingredient Edit modal as
349
+ // a best-effort default. Values are approximate spot/bulk prices
350
+ // in USD and meant as a starting point — the user should verify
351
+ // against their actual vendor.
352
+ // ============================================================
353
+ const COST_ESTIMATES = {
354
+ // Food / commodity
355
+ 'water': { value: 0.002, unit: 'L' },
356
+ 'wheat flour': { value: 1.20, unit: 'kg' },
357
+ 'flour': { value: 1.20, unit: 'kg' },
358
+ 'rye flour': { value: 1.80, unit: 'kg' },
359
+ 'rice flour': { value: 2.40, unit: 'kg' },
360
+ 'salt': { value: 0.85, unit: 'kg' },
361
+ 'sodium chloride': { value: 0.85, unit: 'kg' },
362
+ 'sugar': { value: 0.90, unit: 'kg' },
363
+ 'sucrose': { value: 0.90, unit: 'kg' },
364
+ 'yeast': { value: 18.50, unit: 'kg' },
365
+ 'active dry yeast': { value: 18.50, unit: 'kg' },
366
+ 'olive oil': { value: 7.50, unit: 'L' },
367
+ 'sunflower oil': { value: 2.20, unit: 'L' },
368
+ 'butter': { value: 8.50, unit: 'kg' },
369
+ // Solvents / chemicals
370
+ 'ethanol': { value: 0.85, unit: 'L' },
371
+ 'methanol': { value: 0.55, unit: 'L' },
372
+ 'isopropanol': { value: 1.50, unit: 'L' },
373
+ 'glycerin': { value: 1.50, unit: 'kg' },
374
+ 'glycerol': { value: 1.50, unit: 'kg' },
375
+ 'propylene glycol': { value: 1.80, unit: 'kg' },
376
+ 'ethylene glycol': { value: 1.20, unit: 'kg' },
377
+ 'acetone': { value: 1.10, unit: 'L' },
378
+ // Industrial / formulation
379
+ 'titanium dioxide': { value: 3.40, unit: 'kg' },
380
+ 'calcium carbonate': { value: 0.30, unit: 'kg' },
381
+ 'silica': { value: 1.20, unit: 'kg' },
382
+ 'sodium hydroxide': { value: 0.80, unit: 'kg' },
383
+ 'citric acid': { value: 1.40, unit: 'kg' },
384
+ 'stearic acid': { value: 2.00, unit: 'kg' },
385
+ 'lauric acid': { value: 2.50, unit: 'kg' },
386
+ 'methylcellulose': { value: 7.50, unit: 'kg' },
387
+ 'methyl cellulose': { value: 7.50, unit: 'kg' },
388
+ 'acrylic latex': { value: 2.10, unit: 'kg' },
389
+ 'acrylic latex emulsion': { value: 2.10, unit: 'kg' },
390
+ // Personal-care
391
+ 'cetyl alcohol': { value: 3.50, unit: 'kg' },
392
+ 'beeswax': { value: 25.00, unit: 'kg' },
393
+ 'lanolin': { value: 18.00, unit: 'kg' },
394
+ 'shea butter': { value: 12.00, unit: 'kg' },
395
+ // Surfactants / detergents
396
+ 'sodium lauryl sulfate': { value: 2.00, unit: 'kg' },
397
+ 'sls': { value: 2.00, unit: 'kg' },
398
+ 'sodium laureth sulfate': { value: 1.80, unit: 'kg' },
399
+ 'sles': { value: 1.80, unit: 'kg' },
400
+ 'cocamidopropyl betaine': { value: 2.60, unit: 'kg' },
401
+ 'linear alkylbenzene sulfonate': { value: 1.60, unit: 'kg' },
402
+ 'alcohol ethoxylate': { value: 2.20, unit: 'kg' },
403
+ 'nonionic surfactant': { value: 2.20, unit: 'kg' },
404
+ 'decyl glucoside': { value: 4.00, unit: 'kg' },
405
+ // Thickeners / rheology / fillers
406
+ 'hydroxyethyl cellulose': { value: 7.50, unit: 'kg' },
407
+ 'sodium polyacrylate': { value: 2.50, unit: 'kg' },
408
+ 'carbomer': { value: 16.00, unit: 'kg' },
409
+ 'xanthan gum': { value: 6.00, unit: 'kg' },
410
+ 'guar gum': { value: 3.50, unit: 'kg' },
411
+ 'carrageenan': { value: 12.00, unit: 'kg' },
412
+ 'bentonite': { value: 0.60, unit: 'kg' },
413
+ 'kaolin': { value: 0.50, unit: 'kg' },
414
+ 'talc': { value: 0.55, unit: 'kg' },
415
+ // Preservatives / chelants
416
+ 'phenoxyethanol': { value: 4.00, unit: 'kg' },
417
+ 'benzyl alcohol': { value: 2.50, unit: 'kg' },
418
+ 'potassium sorbate': { value: 4.00, unit: 'kg' },
419
+ 'sodium benzoate': { value: 1.80, unit: 'kg' },
420
+ 'benzisothiazolinone': { value: 8.00, unit: 'kg' },
421
+ 'in-can preservative': { value: 5.00, unit: 'kg' },
422
+ 'methylparaben': { value: 5.50, unit: 'kg' },
423
+ 'disodium edta': { value: 3.00, unit: 'kg' },
424
+ 'edta': { value: 3.00, unit: 'kg' },
425
+ // Humectants / emollients / silicones
426
+ 'pentylene glycol': { value: 8.00, unit: 'kg' },
427
+ 'butylene glycol': { value: 4.00, unit: 'kg' },
428
+ 'dipropylene glycol': { value: 2.50, unit: 'kg' },
429
+ 'sorbitol': { value: 1.50, unit: 'kg' },
430
+ 'caprylic/capric triglyceride': { value: 4.00, unit: 'kg' },
431
+ 'dimethicone': { value: 6.00, unit: 'kg' },
432
+ 'isopropyl myristate': { value: 3.50, unit: 'kg' },
433
+ 'mineral oil': { value: 1.50, unit: 'L' },
434
+ 'petrolatum': { value: 2.00, unit: 'kg' },
435
+ // Cosmetic actives
436
+ 'niacinamide': { value: 15.00, unit: 'kg' },
437
+ 'sodium hyaluronate': { value: 250.00, unit: 'kg' },
438
+ 'hyaluronic acid': { value: 250.00, unit: 'kg' },
439
+ 'salicylic acid': { value: 6.00, unit: 'kg' },
440
+ 'ascorbic acid': { value: 8.00, unit: 'kg' },
441
+ 'panthenol': { value: 15.00, unit: 'kg' },
442
+ 'allantoin': { value: 12.00, unit: 'kg' },
443
+ 'caffeine': { value: 15.00, unit: 'kg' },
444
+ 'zinc oxide': { value: 3.00, unit: 'kg' },
445
+ // Paints / coatings
446
+ 'styrene acrylic': { value: 2.00, unit: 'kg' },
447
+ 'coalescent': { value: 3.50, unit: 'kg' },
448
+ 'texanol': { value: 3.50, unit: 'kg' },
449
+ 'defoamer': { value: 4.00, unit: 'kg' },
450
+ 'iron oxide': { value: 2.50, unit: 'kg' },
451
+ 'carbon black': { value: 2.00, unit: 'kg' },
452
+ // Solvents (industrial)
453
+ 'toluene': { value: 1.20, unit: 'L' },
454
+ 'xylene': { value: 1.30, unit: 'L' },
455
+ 'mineral spirits': { value: 1.50, unit: 'L' },
456
+ 'butyl acetate': { value: 2.00, unit: 'L' },
457
+ 'ethyl acetate': { value: 1.50, unit: 'L' },
458
+ 'methyl ethyl ketone': { value: 2.00, unit: 'L' },
459
+ 'butanol': { value: 1.60, unit: 'L' },
460
+ // Acids / bases / salts
461
+ 'potassium hydroxide': { value: 1.20, unit: 'kg' },
462
+ 'hydrochloric acid': { value: 0.40, unit: 'L' },
463
+ 'sulfuric acid': { value: 0.30, unit: 'kg' },
464
+ 'acetic acid': { value: 0.90, unit: 'L' },
465
+ 'lactic acid': { value: 2.00, unit: 'kg' },
466
+ 'phosphoric acid': { value: 1.20, unit: 'kg' },
467
+ 'ammonia': { value: 0.50, unit: 'L' },
468
+ 'sodium carbonate': { value: 0.40, unit: 'kg' },
469
+ 'soda ash': { value: 0.40, unit: 'kg' },
470
+ 'sodium bicarbonate': { value: 0.50, unit: 'kg' },
471
+ 'sodium citrate': { value: 2.00, unit: 'kg' },
472
+ 'potassium chloride': { value: 1.00, unit: 'kg' },
473
+ 'magnesium sulfate': { value: 0.50, unit: 'kg' },
474
+ // Food / beverage
475
+ 'fructose': { value: 1.20, unit: 'kg' },
476
+ 'dextrose': { value: 1.00, unit: 'kg' },
477
+ 'glucose': { value: 1.00, unit: 'kg' },
478
+ 'corn syrup': { value: 0.80, unit: 'kg' },
479
+ 'malic acid': { value: 2.50, unit: 'kg' },
480
+ 'monosodium glutamate': { value: 1.50, unit: 'kg' },
481
+ 'aqua': { value: 0.002, unit: 'L' },
482
+ // Waxes / fats / fatty alcohols
483
+ 'carnauba wax': { value: 10.00, unit: 'kg' },
484
+ 'paraffin wax': { value: 1.50, unit: 'kg' },
485
+ 'microcrystalline wax': { value: 2.50, unit: 'kg' },
486
+ 'coconut oil': { value: 2.50, unit: 'L' },
487
+ 'castor oil': { value: 2.50, unit: 'kg' },
488
+ 'palm oil': { value: 1.20, unit: 'kg' },
489
+ 'cocoa butter': { value: 9.00, unit: 'kg' },
490
+ 'stearyl alcohol': { value: 3.50, unit: 'kg' },
491
+ 'cetearyl alcohol': { value: 3.50, unit: 'kg' },
492
+ };
493
+
494
+ // Returns { value, currency, unit, source, matchedKey } or null.
495
+ // Currency is always USD for now. Match is case-insensitive and tries
496
+ // the full name first, then any whole-word substring match.
497
+ function lookupIngredientCost(name) {
498
+ if (!name) return null;
499
+ const key = String(name).toLowerCase().trim();
500
+ if (key in COST_ESTIMATES) {
501
+ return { ...COST_ESTIMATES[key], currency: 'USD', source: 'estimate', matchedKey: key };
502
+ }
503
+ // Substring fallback — longest match wins so "wheat flour" beats "flour"
504
+ let best = null;
505
+ for (const [k, v] of Object.entries(COST_ESTIMATES)) {
506
+ if (key.includes(k) && (!best || k.length > best.key.length)) {
507
+ best = { key: k, ...v };
508
+ }
509
+ }
510
+ if (best) return { value: best.value, unit: best.unit, currency: 'USD', source: 'estimate', matchedKey: best.key };
511
+ return null;
512
+ }
513
+
514
+ // Add a user-defined unit, immediately available to the cost engine.
515
+ function addCustomUnit(name, dimension, factorToBase) {
516
+ // sync-exempt: custom units aren't a per-record entity — they sync via the
517
+ // workspace_settings blob through window.__pro.onSettingsChanged() below.
518
+ if (typeof db === 'undefined') return false;
519
+ if (!name || !dimension) return false;
520
+ db.customUnits = db.customUnits || {};
521
+ if (dimension === 'count' || dimension === 'other') {
522
+ db.customUnits[name] = { dimension };
523
+ } else if (
524
+ (dimension === 'mass' || dimension === 'volume' || dimension === 'length') &&
525
+ isFinite(parseFloat(factorToBase)) && parseFloat(factorToBase) > 0
526
+ ) {
527
+ db.customUnits[name] = { dimension, factorToBase: parseFloat(factorToBase) };
528
+ } else {
529
+ return false;
530
+ }
531
+ if (typeof saveDB === 'function') saveDB();
532
+ registerCustomUnits();
533
+ ensureUnitDatalists(); // refresh the shared unit pickers with the new unit
534
+ try { if (typeof window !== 'undefined' && window.__pro && typeof window.__pro.onSettingsChanged === 'function') window.__pro.onSettingsChanged(); } catch (_) {}
535
+ return true;
536
+ }
537
+
538
+ // Remove a user-defined unit. Also strips it from the live conversion maps that
539
+ // registerCustomUnits() populates (that fn only ADDS), so the unit vanishes from
540
+ // pickers/conversions without a reload. Records still carrying the label keep it
541
+ // (they just won't convert until it's re-added). Syncs via the workspace_settings
542
+ // blob through onSettingsChanged() — so a removal now propagates across devices.
543
+ function deleteCustomUnit(name) {
544
+ // sync-exempt: custom units aren't a per-record entity — see addCustomUnit.
545
+ if (typeof db === 'undefined' || !db || !db.customUnits || !(name in db.customUnits)) return false;
546
+ const def = db.customUnits[name];
547
+ delete db.customUnits[name];
548
+ if (def) {
549
+ if (def.dimension === 'mass') delete MASS_TO_KG[name];
550
+ if (def.dimension === 'volume') delete VOL_TO_L[name];
551
+ if (def.dimension === 'length') delete LENGTH_TO_M[name];
552
+ }
553
+ if (typeof saveDB === 'function') saveDB();
554
+ registerCustomUnits();
555
+ if (typeof ensureUnitDatalists === 'function') ensureUnitDatalists();
556
+ try { if (typeof window !== 'undefined' && window.__pro && typeof window.__pro.onSettingsChanged === 'function') window.__pro.onSettingsChanged(); } catch (_) {}
557
+ return true;
558
+ }
559
+
560
+ // How many records still reference a unit string (for the delete warning).
561
+ // Scans the common unit-bearing fields across the library.
562
+ function customUnitUsageCount(name) {
563
+ if (typeof db === 'undefined' || !db || !name) return 0;
564
+ let n = 0;
565
+ const eq = (u) => u === name;
566
+ (db.ingredients || []).forEach(i => { if (i && eq(i.cost && i.cost.unit)) n++; if (i && eq(i.densityUnit)) n++; });
567
+ (db.formulations || []).forEach(f => (f && f.composition || []).forEach(c => { if (eq(c && c.unit)) n++; }));
568
+ (db.batches || []).forEach(b => { if (b && eq(b.unit)) n++; (b && b.actualComposition || []).forEach(c => { if (eq(c && c.unit)) n++; }); });
569
+ (db.samples || []).forEach(s => { if (s && eq(s.unit)) n++; });
570
+ (db.lots || []).forEach(l => { if (l && eq(l.unit)) n++; });
571
+ return n;
572
+ }
573
+
574
+ // Mutate MASS_TO_KG / VOL_TO_L to include user-defined units, so existing
575
+ // `unit in MASS_TO_KG` checks throughout the cost engine pick them up with
576
+ // no extra refactor. Idempotent — safe to call multiple times.
577
+ function registerCustomUnits() {
578
+ if (typeof db === 'undefined' || !db?.customUnits) return;
579
+ for (const [name, def] of Object.entries(db.customUnits)) {
580
+ if (!def) continue;
581
+ const factor = parseFloat(def.factorToBase);
582
+ if (def.dimension === 'mass' && isFinite(factor) && factor > 0) MASS_TO_KG[name] = factor;
583
+ if (def.dimension === 'volume' && isFinite(factor) && factor > 0) VOL_TO_L[name] = factor;
584
+ if (def.dimension === 'length' && isFinite(factor) && factor > 0) LENGTH_TO_M[name] = factor;
585
+ }
586
+ }
587
+
588
+ // Returns the unit category by looking at both built-ins and custom units.
589
+ // 'mass-frac' | 'vol-frac' | 'mass-abs' | 'vol-abs' | 'count' | 'unknown'
590
+ function detectUnitDimension(unit) {
591
+ if (unit == null) return 'unknown';
592
+ const s = String(unit).trim();
593
+ const lower = s.toLowerCase();
594
+ if (lower.startsWith('wt') || s === '%') return 'mass-frac';
595
+ if (lower.startsWith('vol')) return 'vol-frac';
596
+ if (s in MASS_TO_KG || s in _customMassUnits()) return 'mass-abs';
597
+ if (s in VOL_TO_L || s in _customVolUnits()) return 'vol-abs';
598
+ if (s in LENGTH_TO_M || s in _customLengthUnits()) return 'length-abs';
599
+ // Case-insensitive fallback for typo-tolerance
600
+ for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return 'mass-abs';
601
+ for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return 'vol-abs';
602
+ for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return 'length-abs';
603
+ const custom = (typeof db !== 'undefined' && db?.customUnits) || {};
604
+ if (custom[s]?.dimension === 'count' || custom[s]?.dimension === 'other') return 'count';
605
+ return 'unknown';
606
+ }
607
+
608
+ function costUnitDimension(u) {
609
+ if (u in MASS_TO_KG) return 'mass';
610
+ if (u in VOL_TO_L) return 'volume';
611
+ return 'other';
612
+ }
613
+
614
+ // ---- Amount "view in other units" conversion (composition table) ----
615
+ // Curated common units shown in the click-to-convert popover. We don't
616
+ // list every mass/volume unit the engine knows — just the ones a
617
+ // formulator typically reaches for, so the popover stays scannable.
618
+ const _AMT_VIEW_MASS = ['mg', 'g', 'kg', 'oz', 'lb', 'ton'];
619
+ const _AMT_VIEW_VOL = ['mL', 'L', 'tsp', 'tbsp', 'fl_oz', 'cup', 'gal'];
620
+
621
+ function _massFactorCI(unit) {
622
+ if (unit in MASS_TO_KG) return MASS_TO_KG[unit];
623
+ const l = String(unit).toLowerCase();
624
+ for (const k in MASS_TO_KG) if (k.toLowerCase() === l) return MASS_TO_KG[k];
625
+ return null;
626
+ }
627
+ function _volFactorCI(unit) {
628
+ if (unit in VOL_TO_L) return VOL_TO_L[unit];
629
+ const l = String(unit).toLowerCase();
630
+ for (const k in VOL_TO_L) if (k.toLowerCase() === l) return VOL_TO_L[k];
631
+ return null;
632
+ }
633
+
634
+ // Convert `amount` (in `unit`) into the other common units of the same
635
+ // dimension, plus a cross-dimension block (mass↔volume) when the
636
+ // ingredient's density is known. For relative-fraction units (wt% /
637
+ // vol%) or count/unknown units, returns a `note` instead of rows —
638
+ // those have no absolute conversion without a batch basis.
639
+ //
640
+ // Returns: { dim, source:{value,unit}, same:[{unit,value}],
641
+ // cross:{label, rows:[{unit,value}]}|null, note:'' } | null
642
+ function amountConversions(amount, unit, ingredient, opts) {
643
+ const amt = parseFloat(amount);
644
+ if (!isFinite(amt)) return null;
645
+ const dim = (typeof detectUnitDimension === 'function') ? detectUnitDimension(unit) : 'unknown';
646
+ const res = { dim, source: { value: amt, unit: unit || '' }, same: [], cross: null, note: '' };
647
+
648
+ if (dim === 'mass-abs') {
649
+ const f = _massFactorCI(unit);
650
+ if (f == null) { res.note = 'Unrecognized mass unit.'; return res; }
651
+ const kg = amt * f;
652
+ res.same = _AMT_VIEW_MASS.filter(u => u !== unit).map(u => ({ unit: u, value: kg / MASS_TO_KG[u] }));
653
+ const d = ingredient ? _densityToKgPerL(ingredient.density, ingredient.densityUnit) : null; // kg/L
654
+ if (d) {
655
+ const L = kg / d;
656
+ res.cross = { label: `via density (${(+d.toFixed(3))} g/mL)`, rows: [
657
+ { unit: 'mL', value: L / VOL_TO_L.mL },
658
+ { unit: 'L', value: L },
659
+ ] };
660
+ }
661
+ } else if (dim === 'vol-abs') {
662
+ const f = _volFactorCI(unit);
663
+ if (f == null) { res.note = 'Unrecognized volume unit.'; return res; }
664
+ const L = amt * f;
665
+ res.same = _AMT_VIEW_VOL.filter(u => u !== unit).map(u => ({ unit: u, value: L / VOL_TO_L[u] }));
666
+ const d = ingredient ? _densityToKgPerL(ingredient.density, ingredient.densityUnit) : null;
667
+ if (d) {
668
+ const kg = L * d;
669
+ res.cross = { label: `via density (${(+d.toFixed(3))} g/mL)`, rows: [
670
+ { unit: 'g', value: kg / MASS_TO_KG.g },
671
+ { unit: 'kg', value: kg },
672
+ ] };
673
+ }
674
+ } else if (dim === 'mass-frac' || dim === 'vol-frac') {
675
+ // When a batch size is provided (opts.batchAmount + opts.batchUnit),
676
+ // materialize the fraction into an absolute amount so the click
677
+ // popover stops being a dead end on wt%/vol% rows. The caller passes
678
+ // the live Scale-to-batch state; without it we fall back to the
679
+ // legacy "depends on batch size" note.
680
+ const batchAmt = opts && parseFloat(opts.batchAmount);
681
+ const batchUnit = opts && opts.batchUnit;
682
+ const batchKg = (isFinite(batchAmt) && batchUnit in MASS_TO_KG) ? batchAmt * MASS_TO_KG[batchUnit] : null;
683
+ const batchL = (isFinite(batchAmt) && batchUnit in VOL_TO_L ) ? batchAmt * VOL_TO_L[batchUnit] : null;
684
+ const dens = ingredient ? _densityToKgPerL(ingredient.density, ingredient.densityUnit) : null;
685
+ // Match the fraction dimension to the batch dimension when possible.
686
+ // Cross-dimension (mass-frac × volume-batch, or vol-frac × mass-batch)
687
+ // requires the row's density — handled below when available.
688
+ let absKg = null, absL = null;
689
+ if (dim === 'mass-frac') {
690
+ if (batchKg != null) absKg = (amt / 100) * batchKg;
691
+ else if (batchL != null && dens != null) absKg = (amt / 100) * (batchL * dens);
692
+ } else { // vol-frac
693
+ if (batchL != null) absL = (amt / 100) * batchL;
694
+ else if (batchKg != null && dens != null) absL = (amt / 100) * (batchKg / dens);
695
+ }
696
+ if (absKg != null) {
697
+ res.same = [{ unit: 'kg', value: absKg }].concat(
698
+ _AMT_VIEW_MASS.filter(u => u !== 'kg').map(u => ({ unit: u, value: absKg / MASS_TO_KG[u] }))
699
+ );
700
+ if (dens != null) {
701
+ const L = absKg / dens;
702
+ res.cross = { label: `via density (${(+dens.toFixed(3))} g/mL)`, rows: [
703
+ { unit: 'mL', value: L / VOL_TO_L.mL },
704
+ { unit: 'L', value: L },
705
+ ] };
706
+ }
707
+ res.note = `Computed at ${batchAmt} ${batchUnit} batch.`;
708
+ } else if (absL != null) {
709
+ res.same = [{ unit: 'L', value: absL }].concat(
710
+ _AMT_VIEW_VOL.filter(u => u !== 'L').map(u => ({ unit: u, value: absL / VOL_TO_L[u] }))
711
+ );
712
+ if (dens != null) {
713
+ const kg = absL * dens;
714
+ res.cross = { label: `via density (${(+dens.toFixed(3))} g/mL)`, rows: [
715
+ { unit: 'g', value: kg / MASS_TO_KG.g },
716
+ { unit: 'kg', value: kg },
717
+ ] };
718
+ }
719
+ res.note = `Computed at ${batchAmt} ${batchUnit} batch.`;
720
+ } else {
721
+ res.note = `"${unit}" is a relative fraction — its absolute mass/volume depends on the batch size. Set a "Scale to batch" amount above to materialize.`;
722
+ }
723
+ } else {
724
+ res.note = 'No unit conversions available for this unit.';
725
+ }
726
+ return res;
727
+ }
728
+
729
+ // Normalize whatever density unit the user picked into kg/L (= g/mL).
730
+ function _densityToKgPerL(value, unit) {
731
+ const v = parseFloat(value);
732
+ if (!isFinite(v) || v <= 0) return null;
733
+ switch (unit) {
734
+ case 'g/mL': case 'kg/L': case 'g/cm³': case 'kg/dm³': return v;
735
+ case 'g/L': case 'mg/mL': return v / 1000;
736
+ case 'kg/m³': case 't/m³': return v / 1000;
737
+ case 'mg/L': case 'µg/mL': return v / 1e6;
738
+ default: return v; // assume g/mL
739
+ }
740
+ }
741
+
742
+ // Convert an ing.cost to $/kg in the cost's own currency.
743
+ // Returns null if conversion isn't possible (no density for vol→mass).
744
+ function costPerKgInOwnCurrency(cost, density, densityUnit) {
745
+ if (!cost || cost.value == null || cost.value === '') return null;
746
+ const v = parseFloat(cost.value);
747
+ if (!isFinite(v)) return null;
748
+ const u = cost.unit || 'kg';
749
+
750
+ if (u in MASS_TO_KG) return v / MASS_TO_KG[u];
751
+
752
+ if (u in VOL_TO_L) {
753
+ const d = _densityToKgPerL(density, densityUnit);
754
+ if (!d) return null;
755
+ // $/u → $/L → $/kg
756
+ return (v / VOL_TO_L[u]) / d;
757
+ }
758
+ return null;
759
+ }
760
+
761
+ // ============================================================
762
+ // CURRENCY RATES — live via Frankfurter (free, no key), cached
763
+ // daily in localStorage. Falls back to static rates if offline.
764
+ // ============================================================
765
+ const FX_LS_KEY = 'formlab_fx_v1';
766
+ const FX_FALLBACK = {
767
+ // Approximate rates per 1 USD as of mid-2026. Used only when the
768
+ // live fetch fails (offline). Don't rely on these for finance.
769
+ base: 'USD',
770
+ rates: {
771
+ USD: 1, EUR: 0.92, GBP: 0.79, JPY: 156, CNY: 7.23,
772
+ CAD: 1.37, AUD: 1.52, CHF: 0.89, INR: 83.5, BRL: 5.1,
773
+ SGD: 1.26, HKD: 7.84, KRW: 1336, MXN: 16.9, // Frankfurter 2026-09-09
774
+ PKR: 278, // not on Frankfurter; see _backfillMissingFxRates
775
+ },
776
+ };
777
+ let _fxCache = null;
778
+
779
+ async function ensureFxRates() {
780
+ if (_fxCache) return _fxCache;
781
+ try {
782
+ const raw = localStorage.getItem(FX_LS_KEY);
783
+ if (raw) {
784
+ const parsed = JSON.parse(raw);
785
+ const today = flTodayIso();
786
+ if (parsed && parsed.date === today && parsed.base === 'USD') {
787
+ _fxCache = parsed;
788
+ return _fxCache;
789
+ }
790
+ }
791
+ } catch {}
792
+
793
+ try {
794
+ // Frankfurter migrated api.frankfurter.app → api.frankfurter.dev (the old
795
+ // host now 301-redirects, and a browser fetch can't follow that cross-origin
796
+ // redirect under CORS — the request failed with "No Access-Control-Allow-
797
+ // Origin"). Hit the new /v1 endpoint directly; it sends `access-control-
798
+ // allow-origin: *` and returns the same { base, date, rates } shape.
799
+ const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD');
800
+ if (!res.ok) throw new Error('Frankfurter returned ' + res.status);
801
+ const data = await res.json();
802
+ const rates = { USD: 1, ...data.rates };
803
+ // Frankfurter is ECB-only (~30 currencies). Any currency we list that it
804
+ // doesn't price (PKR today) gets a live rate from a broader secondary
805
+ // source, so the picker never offers a currency whose conversions are null.
806
+ await _backfillMissingFxRates(rates);
807
+ const date = flTodayIso();
808
+ _fxCache = { date, base: 'USD', rates };
809
+ try { localStorage.setItem(FX_LS_KEY, JSON.stringify(_fxCache)); } catch {}
810
+ return _fxCache;
811
+ } catch {
812
+ _fxCache = FX_FALLBACK;
813
+ return _fxCache;
814
+ }
815
+ }
816
+
817
+ // Backfill live rates for listed currencies the primary (ECB/Frankfurter)
818
+ // source doesn't cover — e.g. PKR, which isn't an ECB reference currency.
819
+ // Mutates `rates` in place. Best-effort: on any failure the currency simply
820
+ // stays missing from the live cache and convertCurrencySync falls back to the
821
+ // FX_FALLBACK approximate for it. Source: exchangerate-api's free open endpoint
822
+ // (no key, CORS '*', ~160 currencies, daily refresh).
823
+ async function _backfillMissingFxRates(rates) {
824
+ const missing = COST_CURRENCIES.map(c => c.code).filter(code => rates[code] == null);
825
+ if (!missing.length) return;
826
+ try {
827
+ const res = await fetch('https://open.er-api.com/v6/latest/USD');
828
+ if (!res.ok) return;
829
+ const data = await res.json();
830
+ const supp = data && data.rates;
831
+ if (!supp) return;
832
+ for (const code of missing) {
833
+ if (typeof supp[code] === 'number' && isFinite(supp[code])) rates[code] = supp[code];
834
+ }
835
+ } catch { /* leave missing; the per-code FX_FALLBACK in convertCurrencySync covers it */ }
836
+ }
837
+
838
+ function convertCurrencySync(amount, from, to) {
839
+ if (amount == null || !isFinite(amount)) return null;
840
+ if (from === to) return amount;
841
+ const cache = _fxCache || FX_FALLBACK;
842
+ const r = cache.rates;
843
+ // Per-code fallback: if the live cache is missing a rate (e.g. the PKR
844
+ // backfill fetch failed this session), use the approximate FX_FALLBACK for
845
+ // that code rather than returning null and blanking the whole cost panel.
846
+ const rateOf = code => (r[code] != null ? r[code] : FX_FALLBACK.rates[code]);
847
+ const rFrom = rateOf(from), rTo = rateOf(to);
848
+ if (rFrom == null || rTo == null) return null;
849
+ // Cache is USD-based; convert from→USD→to
850
+ const usd = amount / rFrom;
851
+ return usd * rTo;
852
+ }
853
+
854
+ // ============================================================
855
+ // DISPLAY CURRENCY — user's preferred currency for totals/UI
856
+ // ============================================================
857
+ const DISPLAY_CCY_LS_KEY = 'formlab_display_currency';
858
+
859
+ function getDisplayCurrency() {
860
+ return localStorage.getItem(DISPLAY_CCY_LS_KEY) || 'USD';
861
+ }
862
+ function setDisplayCurrency(code) {
863
+ if (!COST_CURRENCIES.some(c => c.code === code)) return;
864
+ localStorage.setItem(DISPLAY_CCY_LS_KEY, code);
865
+ // Per-user preference: write-through to the cloud (Pro). localStorage stays the
866
+ // offline/Free store; the hook persists it on the user's profile. Best-effort.
867
+ try { (typeof window !== 'undefined' ? window.__pro : null)?.onDisplayCurrency?.(code); } catch (_) {}
868
+ }
869
+ // Apply a cloud-loaded currency without echoing back to the cloud (used by the
870
+ // Pro layer after it reads the signed-in user's profile on boot).
871
+ function applyDisplayCurrencyFromCloud(code) {
872
+ if (!code || !COST_CURRENCIES.some(c => c.code === code)) return;
873
+ if (localStorage.getItem(DISPLAY_CCY_LS_KEY) === code) return;
874
+ localStorage.setItem(DISPLAY_CCY_LS_KEY, code);
875
+ if (typeof reRender === 'function') { try { reRender(); } catch (_) {} }
876
+ }
877
+
878
+ // Live-FX provenance for cost panels: the date the cached rates were fetched,
879
+ // or a clear "offline / approximate" note when the live fetch hasn't landed.
880
+ function fxAsOfHint() {
881
+ if (_fxCache && _fxCache !== FX_FALLBACK && _fxCache.date) return 'FX ' + _fxCache.date;
882
+ return 'approx FX';
883
+ }
884
+
885
+ // Reusable global display-currency switcher for any cost panel. All cost figures
886
+ // share the one global pref (getDisplayCurrency), so changing it here re-renders
887
+ // the whole app in that currency. `reRenderJs` is a JS expression run right after
888
+ // the change to repaint the current view (e.g. "renderBatchDetail('b1')").
889
+ // `betweenHtml` (optional) is slotted between the select and the FX hint — the
890
+ // ingredient Cost tab puts its display-unit switcher there so currency, unit
891
+ // and FX date read as one row.
892
+ function displayCurrencyControlHtml(reRenderJs, betweenHtml) {
893
+ const cur = getDisplayCurrency();
894
+ const opts = COST_CURRENCIES
895
+ .map(c => `<option value="${c.code}"${c.code === cur ? ' selected' : ''}>${c.code} ${c.symbol}</option>`)
896
+ .join('');
897
+ const after = String(reRenderJs || '');
898
+ return `<select class="cost-ccy-select" onclick="event.stopPropagation()" `
899
+ + `title="Display currency for all cost figures, live FX" `
900
+ + `onchange="setDisplayCurrency(this.value);${after}" `
901
+ + `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:#fff">${opts}</select>`
902
+ + (betweenHtml || '')
903
+ + `<span class="cost-ccy-fxhint" style="font-size:10.5px;color:var(--text-muted);margin-left:6px;white-space:nowrap">${fxAsOfHint()}</span>`;
904
+ }
905
+
906
+ // ============================================================
907
+ // DISPLAY COST UNIT — per-user "show prices per …" for the ingredient Cost
908
+ // tab. Mirrors the display currency: the ENTERED price stays the source of
909
+ // truth, this only picks the unit of the read-only "≈ …" conversion line.
910
+ // '' = show in the unit the price was entered in. Synced per user via
911
+ // VIEW_PREF_KEYS (list-shell.js) like the other view prefs.
912
+ // ============================================================
913
+ const DISPLAY_COST_UNIT_LS_KEY = 'formlab_cost_display_unit';
914
+ function getDisplayCostUnit() {
915
+ try { return localStorage.getItem(DISPLAY_COST_UNIT_LS_KEY) || ''; } catch (_) { return ''; }
916
+ }
917
+ function setDisplayCostUnit(u) {
918
+ u = u || '';
919
+ if (u && !(u in MASS_TO_KG) && !(u in VOL_TO_L)) return;
920
+ if (typeof saveViewPref === 'function') saveViewPref(DISPLAY_COST_UNIT_LS_KEY, u);
921
+ else { try { localStorage.setItem(DISPLAY_COST_UNIT_LS_KEY, u); } catch (_) {} }
922
+ }
923
+
924
+ // Convert an ing.cost to price-per-`unit` in the cost's OWN currency.
925
+ // mass↔mass and vol↔vol are exact; mass↔vol needs the ingredient's density.
926
+ // Returns null when the conversion isn't possible (no density, unknown unit).
927
+ function costPerUnitInOwnCurrency(cost, unit, density, densityUnit) {
928
+ if (!cost || cost.value == null || cost.value === '' || !unit) return null;
929
+ const v = parseFloat(cost.value);
930
+ if (!isFinite(v)) return null;
931
+ const from = cost.unit || 'kg';
932
+ if (from === unit) return v;
933
+ const kgPerL = _densityToKgPerL(density, densityUnit);
934
+ return convertPricePerBasis(v, from, unit, kgPerL || null);
935
+ }
936
+
937
+ // Unit switcher paired with displayCurrencyControlHtml on the Cost tab. Options
938
+ // are the common basis units plus the entered unit; the cross-family choices
939
+ // (volume for a mass price, or vice versa) are disabled until the ingredient
940
+ // has a density, with the reason in the option's tooltip.
941
+ function displayCostUnitControlHtml(cost, density, densityUnit, reRenderJs) {
942
+ const entered = (cost && cost.unit) || 'kg';
943
+ const cur = getDisplayCostUnit() || entered;
944
+ const hasDensity = !!_densityToKgPerL(density, densityUnit);
945
+ const fromMass = entered in MASS_TO_KG;
946
+ const list = COST_BASIS_UNITS.slice();
947
+ if (list.indexOf(entered) === -1) list.unshift(entered);
948
+ if (cur !== entered && list.indexOf(cur) === -1) list.push(cur);
949
+ const opts = list.map(u => {
950
+ const sameFamily = (u in MASS_TO_KG) === fromMass;
951
+ const off = !sameFamily && !hasDensity;
952
+ const t = off ? ' title="Set a density on the Identification tab to convert between mass and volume"' : '';
953
+ return `<option value="${u}"${u === cur ? ' selected' : ''}${off ? ' disabled' : ''}${t}>/ ${u}</option>`;
954
+ }).join('');
955
+ const after = String(reRenderJs || '');
956
+ return `<select class="cost-ccy-select cost-unit-select" onclick="event.stopPropagation()" `
957
+ + `title="Show the price per this unit (the entered price is unchanged)" `
958
+ + `onchange="setDisplayCostUnit(this.value);${after}" `
959
+ + `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:#fff">${opts}</select>`;
960
+ }
961
+
962
+ // ============================================================
963
+ // FORMATTING
964
+ // ============================================================
965
+ function formatMoney(amount, currency) {
966
+ if (amount == null || !isFinite(amount)) return '—';
967
+ // Under one cent (a per-kg price of water, say), 2 decimals read "$0.00";
968
+ // show 3 significant digits instead ("$0.002"). Everything else unchanged
969
+ // (build 33g).
970
+ const tiny = amount !== 0 && Math.abs(amount) < 0.01;
971
+ try {
972
+ return new Intl.NumberFormat(undefined, tiny
973
+ ? { style: 'currency', currency, maximumSignificantDigits: 3 }
974
+ : { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount);
975
+ } catch {
976
+ const sym = COST_CURRENCIES.find(c => c.code === currency)?.symbol || '';
977
+ return sym + (tiny ? String(+amount.toPrecision(3)) : amount.toFixed(2));
978
+ }
979
+ }
980
+
981
+ function formatCostPerUnit(cost) {
982
+ if (!cost || cost.value == null || cost.value === '') return '—';
983
+ const v = parseFloat(cost.value);
984
+ if (!isFinite(v)) return '—';
985
+ return `${formatMoney(v, cost.currency || 'USD')} / ${cost.unit || 'kg'}`;
986
+ }
987
+
988
+ // ============================================================
989
+ // FORMULATION COST ROLLUP
990
+ // Returns { perKg, currency, breakdown, missing, basis }.
991
+ // Handles three cases:
992
+ // - all rows in wt%: cost-per-kg directly from $/kg × wt fraction
993
+ // - all rows in vol%: derive formulation density from per-row
994
+ // (vol fraction × ing density), then $/kg
995
+ // - mixed: best-effort wt% with vol rows in `missing`
996
+ // Volume rows need each ingredient's density to be priced.
997
+ // ============================================================
998
+ function computeFormulationCost(formulation, displayCcy, basisUnit, _seen) {
999
+ const comp = formulation.composition || [];
1000
+ // Currency precedence: explicit arg → viewer's display preference → formulation's saved currency → USD.
1001
+ // formulation.costCurrency is the currency the owner used when entering costs, but in a shared
1002
+ // workspace each viewer should always see costs in their own preferred currency.
1003
+ displayCcy = displayCcy || getDisplayCurrency() || formulation.costCurrency || 'USD';
1004
+ basisUnit = basisUnit || formulation.costBasisUnit || 'kg';
1005
+ const overrides = formulation.costOverrides || {};
1006
+ const breakdown = [];
1007
+ const missing = [];
1008
+
1009
+ // Recursion guard for nested formulations. _seen is the chain of
1010
+ // formulation ids currently being priced; if the same id shows up
1011
+ // again, that's a cycle (should have been caught at add time, but be
1012
+ // defensive) and we skip the offending sub-row.
1013
+ _seen = _seen || new Set();
1014
+ if (formulation && formulation.id) _seen = new Set([..._seen, formulation.id]);
1015
+
1016
+ // Resolve each sub-formula row to a "synthetic ingredient" with a
1017
+ // recursively-computed $/kg and a rolled-up density. The main loop
1018
+ // below then treats sub-formula rows just like ingredient rows.
1019
+ const subResolve = new Map();
1020
+ for (const c of comp) {
1021
+ if (!c.formulationId) continue;
1022
+ // By id through the draw's index (12a): a scan per row, per formula, was
1023
+ // most of the Inventory roll-up's cost at 20k records.
1024
+ const subF = (typeof flFindById === 'function') ? flFindById('formulations', c.formulationId) : db.formulations.find(g => g.id === c.formulationId);
1025
+ if (!subF) { missing.push({ reason: 'sub-formula deleted', row: c }); continue; }
1026
+ if (_seen.has(subF.id)) { missing.push({ reason: 'cycle detected', row: c }); continue; }
1027
+ const subRes = computeFormulationCost(subF, displayCcy, 'kg', _seen);
1028
+ const subDensity = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(subF) : null;
1029
+ subResolve.set(c, {
1030
+ subFormula: subF,
1031
+ perKgDisplay: subRes.perKg, // $/kg in displayCcy
1032
+ perKgOwn: subRes.perKg, // already converted
1033
+ density: subDensity, // kg/L, may be null
1034
+ missing: subRes.missing || [],
1035
+ basisOk: subRes.basisOk,
1036
+ });
1037
+ }
1038
+
1039
+ // Classify each row's unit into one of five buckets:
1040
+ // wt-frac "wt%" / "%"
1041
+ // vol-frac "vol%"
1042
+ // mass-abs g / kg / mg / lb / oz / ton / ...
1043
+ // vol-abs mL / L / gal / drum / ...
1044
+ // unknown anything else (each, count, etc.)
1045
+ // NOTE: MASS_TO_KG / VOL_TO_L keys are case-sensitive ('L' for litre,
1046
+ // 'mL' for millilitre — distinct from 'l' / 'ml' which aren't keys).
1047
+ // We check the raw unit first; if that misses, fall back to a
1048
+ // case-normalized form so users typing "ML" or "kG" still resolve.
1049
+ const classifyUnit = u => {
1050
+ const raw = String(u || 'wt%').trim();
1051
+ const lower = raw.toLowerCase();
1052
+ if (lower.startsWith('wt') || raw === '%') return 'wt-frac';
1053
+ if (lower.startsWith('vol')) return 'vol-frac';
1054
+ if (raw in MASS_TO_KG) return 'mass-abs';
1055
+ if (raw in VOL_TO_L) return 'vol-abs';
1056
+ if (raw in LENGTH_TO_M) return 'length-abs';
1057
+ // Case-insensitive fallback
1058
+ for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return 'mass-abs';
1059
+ for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return 'vol-abs';
1060
+ for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return 'length-abs';
1061
+ return 'unknown';
1062
+ };
1063
+ // Map any user-typed unit back to the canonical key.
1064
+ const canonUnit = u => {
1065
+ const raw = String(u || '').trim();
1066
+ if (raw in MASS_TO_KG || raw in VOL_TO_L || raw in LENGTH_TO_M) return raw;
1067
+ const lower = raw.toLowerCase();
1068
+ for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return k;
1069
+ for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return k;
1070
+ for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return k;
1071
+ return raw;
1072
+ };
1073
+
1074
+ const kinds = comp.map(c => classifyUnit(c.unit));
1075
+ const allWt = comp.length && kinds.every(k => k === 'wt-frac');
1076
+ const allVol = comp.length && kinds.every(k => k === 'vol-frac');
1077
+ const allAbs = comp.length && kinds.every(k => k === 'mass-abs' || k === 'vol-abs');
1078
+ const basis = allVol ? 'vol' : (allAbs ? 'abs' : 'wt');
1079
+
1080
+ // === Vol-% recipe: derive formulation density from per-row densities ===
1081
+ // Sub-formula rows use their rolled-up density (computed above).
1082
+ let formulationKgPerL = null;
1083
+ if (allVol) {
1084
+ let sumKg = 0, ok = true;
1085
+ for (const c of comp) {
1086
+ let d = null;
1087
+ if (c.formulationId) {
1088
+ d = subResolve.get(c)?.density || null;
1089
+ } else {
1090
+ const ing = db.ingredients.find(i => i.id === c.ingredientId);
1091
+ d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
1092
+ }
1093
+ const amt = parseFloat(c.amount);
1094
+ if (!d || !isFinite(amt)) { ok = false; break; }
1095
+ sumKg += (amt / 100) * d;
1096
+ }
1097
+ formulationKgPerL = ok ? sumKg : null;
1098
+ }
1099
+
1100
+ // === Absolute recipe: convert every row to kg (using density for volume
1101
+ // rows), sum to total mass, then use per-row mass fractions. Also track
1102
+ // total volume so we can derive the formulation density for basis-unit
1103
+ // conversion if all rows have densities. ===
1104
+ const massKgPerRow = new Map(); // c <span class="fl-ar fl-ar-e"></span> row mass in kg
1105
+ const volLPerRow = new Map(); // c <span class="fl-ar fl-ar-e"></span> row volume in L (when known)
1106
+ let totalMassKg = 0;
1107
+ let totalVolL = 0;
1108
+ let allAbsHaveDensity = true;
1109
+ if (allAbs) {
1110
+ for (const c of comp) {
1111
+ const u = canonUnit(c.unit || '');
1112
+ const amt = parseFloat(c.amount);
1113
+ let d = null;
1114
+ if (c.formulationId) {
1115
+ if (!subResolve.has(c)) continue;
1116
+ d = subResolve.get(c).density;
1117
+ } else {
1118
+ const ing = db.ingredients.find(i => i.id === c.ingredientId);
1119
+ if (!ing) continue;
1120
+ d = _densityToKgPerL(ing.density, ing.densityUnit);
1121
+ }
1122
+ if (!isFinite(amt)) continue;
1123
+ if (u in MASS_TO_KG) {
1124
+ const m = amt * MASS_TO_KG[u];
1125
+ massKgPerRow.set(c, m);
1126
+ totalMassKg += m;
1127
+ if (d) { const v = m / d; volLPerRow.set(c, v); totalVolL += v; }
1128
+ else allAbsHaveDensity = false;
1129
+ } else if (u in VOL_TO_L) {
1130
+ const v = amt * VOL_TO_L[u];
1131
+ if (d) {
1132
+ const m = v * d;
1133
+ massKgPerRow.set(c, m);
1134
+ volLPerRow.set(c, v);
1135
+ totalMassKg += m;
1136
+ totalVolL += v;
1137
+ } else {
1138
+ allAbsHaveDensity = false;
1139
+ }
1140
+ }
1141
+ }
1142
+ if (allAbsHaveDensity && totalMassKg > 0 && totalVolL > 0) {
1143
+ formulationKgPerL = totalMassKg / totalVolL;
1144
+ }
1145
+ }
1146
+
1147
+ let totalPerKg = 0;
1148
+ let totalPct = 0;
1149
+
1150
+ const tryAutoPrice = (ing, c, unit, amount, kind) => {
1151
+ if (!ing.cost || ing.cost.value == null || ing.cost.value === '') return { ok: false, reason: 'no cost on file' };
1152
+ const perKgOwn = costPerKgInOwnCurrency(ing.cost, ing.density, ing.densityUnit);
1153
+ if (perKgOwn == null) return { ok: false, reason: 'could not convert price unit (needs density?)' };
1154
+ const perKgDisplay = convertCurrencySync(perKgOwn, ing.cost.currency || 'USD', displayCcy);
1155
+ if (perKgDisplay == null) return { ok: false, reason: 'unknown currency rate' };
1156
+
1157
+ if (kind === 'wt-frac') {
1158
+ return { ok: true, perKgOwn, perKgDisplay, contribution: (amount / 100) * perKgDisplay };
1159
+ }
1160
+ if (kind === 'vol-frac') {
1161
+ const d = _densityToKgPerL(ing.density, ing.densityUnit);
1162
+ if (!allVol || !formulationKgPerL || !d) return { ok: false, reason: 'vol-% needs density for every row and a vol-only recipe' };
1163
+ const massFraction = ((amount / 100) * d) / formulationKgPerL;
1164
+ return { ok: true, perKgOwn, perKgDisplay, contribution: massFraction * perKgDisplay };
1165
+ }
1166
+ if (kind === 'mass-abs' || kind === 'vol-abs') {
1167
+ if (!allAbs) return { ok: false, reason: 'absolute amounts cannot be mixed with wt%/vol% in the same recipe' };
1168
+ const m = massKgPerRow.get(c);
1169
+ if (m == null || totalMassKg <= 0) {
1170
+ return { ok: false, reason: kind === 'vol-abs' ? 'volume amount needs ingredient density' : 'cannot determine row mass' };
1171
+ }
1172
+ const massFraction = m / totalMassKg;
1173
+ return { ok: true, perKgOwn, perKgDisplay, contribution: massFraction * perKgDisplay };
1174
+ }
1175
+ if (kind === 'length-abs') {
1176
+ return { ok: false, reason: 'length-based recipes are not auto-priced yet — set a per-row override' };
1177
+ }
1178
+ return { ok: false, reason: `unsupported composition unit "${c.unit}"` };
1179
+ };
1180
+
1181
+ for (const c of comp) {
1182
+ // ---- Sub-formula row: treat as a synthetic ingredient priced
1183
+ // via the recursive computeFormulationCost above. ----
1184
+ if (c.formulationId) {
1185
+ const sub = subResolve.get(c);
1186
+ if (!sub) continue; // 'sub-formula deleted' / 'cycle detected' already in missing[]
1187
+ const amount = parseFloat(c.amount);
1188
+ if (!isFinite(amount)) { missing.push({ reason: 'no amount', subFormula: sub.subFormula }); continue; }
1189
+ const kind = classifyUnit((c.unit || 'wt%').toLowerCase());
1190
+ if (kind === 'unknown') { missing.push({ reason: `unsupported composition unit "${c.unit}"`, subFormula: sub.subFormula }); continue; }
1191
+ const perKgDisplay = sub.perKgDisplay;
1192
+ let contribution = null;
1193
+ if (kind === 'wt-frac') {
1194
+ contribution = (amount / 100) * perKgDisplay;
1195
+ } else if (kind === 'vol-frac') {
1196
+ if (!allVol || !formulationKgPerL || !sub.density) {
1197
+ missing.push({ reason: 'vol-% needs density for every row and a vol-only recipe', subFormula: sub.subFormula });
1198
+ continue;
1199
+ }
1200
+ const massFraction = ((amount / 100) * sub.density) / formulationKgPerL;
1201
+ contribution = massFraction * perKgDisplay;
1202
+ } else if (kind === 'mass-abs' || kind === 'vol-abs') {
1203
+ if (!allAbs) { missing.push({ reason: 'absolute amounts cannot be mixed with wt%/vol%', subFormula: sub.subFormula }); continue; }
1204
+ const m = massKgPerRow.get(c);
1205
+ if (m == null || totalMassKg <= 0) {
1206
+ missing.push({ reason: kind === 'vol-abs' ? 'volume amount needs sub-formula density' : 'cannot determine row mass', subFormula: sub.subFormula });
1207
+ continue;
1208
+ }
1209
+ const massFraction = m / totalMassKg;
1210
+ contribution = massFraction * perKgDisplay;
1211
+ } else {
1212
+ missing.push({ reason: `unsupported composition unit "${c.unit}"`, subFormula: sub.subFormula });
1213
+ continue;
1214
+ }
1215
+ totalPerKg += contribution;
1216
+ totalPct += amount;
1217
+ breakdown.push({
1218
+ subFormula: sub.subFormula, ingredient: null,
1219
+ amount, unit: c.unit || 'wt%',
1220
+ perKgOwn: sub.perKgOwn, perKgDisplay,
1221
+ contribution, overridden: false,
1222
+ density: sub.density,
1223
+ });
1224
+ // Surface any deep-pricing gaps inside the sub-formula so the parent's
1225
+ // "X rows unpriced" warning still reflects reality. Preserve the
1226
+ // original ingredient (or subFormula) reference so the unpriced
1227
+ // tooltip can render the actual name — the earlier shape dropped
1228
+ // m.ingredient and the display fell back to "?" for anything at
1229
+ // depth >= 2 (Parent → SubB → ingredient inside SubB → all surfaced
1230
+ // as "?: (in SubB) no cost on file" instead of "PigmentX: …").
1231
+ for (const m of (sub.missing || [])) {
1232
+ missing.push({
1233
+ reason: `(in ${sub.subFormula.name || 'sub-formula'}) ${m.reason}`,
1234
+ ingredient: m.ingredient || null,
1235
+ subFormula: m.subFormula || sub.subFormula,
1236
+ });
1237
+ }
1238
+ continue;
1239
+ }
1240
+
1241
+ const ing = (typeof flFindById === 'function') ? flFindById('ingredients', c.ingredientId) : db.ingredients.find(i => i.id === c.ingredientId);
1242
+ if (!ing) { missing.push({ reason: 'ingredient deleted', row: c }); continue; }
1243
+ const unit = (c.unit || 'wt%').toLowerCase();
1244
+ const amount = parseFloat(c.amount);
1245
+ if (!isFinite(amount)) { missing.push({ reason: 'no amount', ingredient: ing }); continue; }
1246
+
1247
+ const kind = classifyUnit(unit);
1248
+ if (kind === 'unknown') {
1249
+ missing.push({ reason: `unsupported composition unit "${c.unit}"`, ingredient: ing });
1250
+ continue;
1251
+ }
1252
+
1253
+ const auto = tryAutoPrice(ing, c, unit, amount, kind);
1254
+ if (auto.ok) {
1255
+ totalPerKg += auto.contribution;
1256
+ totalPct += amount;
1257
+ breakdown.push({
1258
+ ingredient: ing, amount, unit: c.unit || 'wt%',
1259
+ perKgOwn: auto.perKgOwn, perKgDisplay: auto.perKgDisplay,
1260
+ contribution: auto.contribution,
1261
+ overridden: false,
1262
+ });
1263
+ continue;
1264
+ }
1265
+
1266
+ // Auto-pricing failed. Check for a manual override.
1267
+ const ov = overrides[ing.id];
1268
+ if (ov && ov.value != null && isFinite(parseFloat(ov.value))) {
1269
+ const ovValueOwn = parseFloat(ov.value);
1270
+ const ovUnit = ov.basisUnit || 'kg';
1271
+ const ovCcy = ov.currency || 'USD';
1272
+ // Currency conversion: override was stored in ovCcy; rollup is in displayCcy.
1273
+ const ovValue = ovCcy === displayCcy ? ovValueOwn : convertCurrencySync(ovValueOwn, ovCcy, displayCcy);
1274
+ // Unit conversion (override is in "$ per basisUnit of formulation").
1275
+ let perBasisContribution = null;
1276
+ if (ovValue != null) {
1277
+ if (ovUnit === basisUnit) {
1278
+ perBasisContribution = ovValue;
1279
+ } else if (ovUnit in MASS_TO_KG && basisUnit in MASS_TO_KG) {
1280
+ const inKg = ovValue / MASS_TO_KG[ovUnit];
1281
+ perBasisContribution = inKg * MASS_TO_KG[basisUnit];
1282
+ }
1283
+ }
1284
+ const stale = perBasisContribution == null;
1285
+ breakdown.push({
1286
+ ingredient: ing, amount, unit: c.unit || 'wt%',
1287
+ perKgOwn: null, perKgDisplay: null,
1288
+ contribution: stale ? 0 : perBasisContribution,
1289
+ overridden: true,
1290
+ overrideValue: ovValueOwn,
1291
+ overrideUnit: ovUnit,
1292
+ overrideCurrency: ovCcy,
1293
+ overrideStale: stale,
1294
+ contributionIsAlreadyInBasis: true,
1295
+ });
1296
+ continue;
1297
+ }
1298
+
1299
+ missing.push({ reason: auto.reason, ingredient: ing });
1300
+ }
1301
+
1302
+ // === Convert $/kg of formulation → $/basisUnit of formulation ===
1303
+ // For per-row priced contributions, totalPerKg is the running sum in $/kg.
1304
+ // Compute scaling so we can also expose perBasisUnit and perBasisRow.
1305
+ let basisOk = true;
1306
+ let basisScale = 1; // multiplier from $/kg of formulation to $/basisUnit of formulation
1307
+ if (basisUnit in MASS_TO_KG) {
1308
+ basisScale = MASS_TO_KG[basisUnit];
1309
+ } else if (basisUnit in VOL_TO_L) {
1310
+ // Need a formulation density (kg/L) to do mass→volume conversion
1311
+ let formKgPerL = formulationKgPerL;
1312
+ if (!formKgPerL) {
1313
+ // Estimate from composition: weight-average density (only works if all densities present).
1314
+ // Sub-formula rows use their rolled-up density.
1315
+ let sumA = 0, sumW = 0;
1316
+ for (const c of comp) {
1317
+ let d = null;
1318
+ if (c.formulationId) {
1319
+ d = subResolve.get(c)?.density || null;
1320
+ } else {
1321
+ const ing = db.ingredients.find(i => i.id === c.ingredientId);
1322
+ d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
1323
+ }
1324
+ const a = parseFloat(c.amount);
1325
+ if (d && isFinite(a)) { sumA += a * d; sumW += a; }
1326
+ }
1327
+ formKgPerL = sumW ? sumA / sumW : null;
1328
+ }
1329
+ if (formKgPerL) {
1330
+ basisScale = formKgPerL * VOL_TO_L[basisUnit];
1331
+ } else {
1332
+ basisOk = false;
1333
+ }
1334
+ } else if (basisUnit !== 'kg') {
1335
+ basisOk = false;
1336
+ }
1337
+
1338
+ // Apply scale to per-row priced contributions (overridden rows already
1339
+ // carry their contribution in basis terms).
1340
+ let totalPerBasis = 0;
1341
+ for (const b of breakdown) {
1342
+ if (b.contributionIsAlreadyInBasis) {
1343
+ totalPerBasis += b.contribution;
1344
+ b.perBasis = b.contribution;
1345
+ } else if (basisOk) {
1346
+ b.perBasis = b.contribution * basisScale;
1347
+ totalPerBasis += b.perBasis;
1348
+ } else {
1349
+ b.perBasis = null;
1350
+ }
1351
+ // Per-kg of the ingredient → also expose per-basisUnit of the ingredient
1352
+ if (b.perKgDisplay != null && basisOk && !(basisUnit in VOL_TO_L)) {
1353
+ // Ingredient per-basisUnit: only meaningful for mass conversions
1354
+ b.perBasisRate = b.perKgDisplay * MASS_TO_KG[basisUnit];
1355
+ } else if (b.perKgDisplay != null && basisOk) {
1356
+ // Volume basis: use ingredient density (or sub-formula rolled-up density)
1357
+ let d = null;
1358
+ if (b.ingredient) {
1359
+ d = _densityToKgPerL(b.ingredient.density, b.ingredient.densityUnit);
1360
+ } else if (b.subFormula) {
1361
+ d = b.density || (typeof _rolledUpDensity === 'function' ? _rolledUpDensity(b.subFormula) : null);
1362
+ }
1363
+ b.perBasisRate = d ? b.perKgDisplay * d * VOL_TO_L[basisUnit] : null;
1364
+ }
1365
+ }
1366
+
1367
+ return {
1368
+ perKg: totalPerKg,
1369
+ perBasis: basisOk ? totalPerBasis : null,
1370
+ basisUnit,
1371
+ basisOk,
1372
+ coveragePct: totalPct,
1373
+ currency: displayCcy,
1374
+ breakdown,
1375
+ missing,
1376
+ basis, // 'wt' or 'vol' (recipe basis, not display basis)
1377
+ formulationKgPerL, // only meaningful when basis === 'vol'
1378
+ };
1379
+ }
1380
+
1381
+ // Common basis units the user can pick in the composition cost column.
1382
+ const COST_BASIS_UNITS = ['kg', 'g', 'lb', 'oz', 'ton', 'L', 'mL', 'gal'];
1383
+
1384
+ // Convert a money-per-basis value from one basis to another. Mass↔mass and
1385
+ // volume↔volume are direct; mass↔volume needs formulationKgPerL (derived
1386
+ // by computeFormulationCost when possible). Returns null if a needed
1387
+ // density is missing.
1388
+ function convertPricePerBasis(price, fromBasis, toBasis, formulationKgPerL) {
1389
+ if (price == null || !isFinite(price)) return null;
1390
+ if (fromBasis === toBasis) return price;
1391
+ const scaleFromKg = u => {
1392
+ if (u === 'kg') return 1;
1393
+ if (u in MASS_TO_KG) return MASS_TO_KG[u];
1394
+ if (u in VOL_TO_L) {
1395
+ if (!formulationKgPerL) return null;
1396
+ return formulationKgPerL * VOL_TO_L[u];
1397
+ }
1398
+ return null;
1399
+ };
1400
+ const sFrom = scaleFromKg(fromBasis);
1401
+ const sTo = scaleFromKg(toBasis);
1402
+ if (sFrom == null || sTo == null) return null;
1403
+ // price_per_kg = price_per_fromBasis / sFrom
1404
+ // price_per_toBasis = price_per_kg × sTo
1405
+ return price * (sTo / sFrom);
1406
+ }
1407
+
1408
+ // ============================================================
1409
+ // COMPOSITION FRACTIONS — per-row mass/volume fractions of the
1410
+ // formulation, computed regardless of whether rows use wt%, vol%,
1411
+ // or absolute mass/volume amounts. Used by the "% of Total"
1412
+ // column when the user wants "by weight" / "by volume" instead of
1413
+ // raw-amount percentages.
1414
+ // ============================================================
1415
+ function computeCompositionFractions(formulation) {
1416
+ const comp = formulation.composition || [];
1417
+
1418
+ const canonUnit = u => {
1419
+ const raw = String(u || '').trim();
1420
+ if (raw in MASS_TO_KG || raw in VOL_TO_L || raw in LENGTH_TO_M) return raw;
1421
+ const lower = raw.toLowerCase();
1422
+ for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return k;
1423
+ for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return k;
1424
+ for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return k;
1425
+ return raw;
1426
+ };
1427
+
1428
+ // Pass 1: classify each row, compute absolute mass, volume, length when possible.
1429
+ // Sub-formula rows resolve to a "synthetic ingredient" with the sub-formula's
1430
+ // rolled-up density so the same mass/volume math applies.
1431
+ const rows = comp.map(c => {
1432
+ const rawU = (c.unit || 'wt%').trim();
1433
+ const u = rawU.toLowerCase();
1434
+ const amount = parseFloat(c.amount);
1435
+ let ing = null, subFormula = null, d = null;
1436
+ if (c.formulationId) {
1437
+ subFormula = db.formulations.find(g => g.id === c.formulationId) || null;
1438
+ d = subFormula && typeof _rolledUpDensity === 'function' ? _rolledUpDensity(subFormula) : null;
1439
+ } else {
1440
+ ing = db.ingredients.find(i => i.id === c.ingredientId);
1441
+ d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
1442
+ }
1443
+ const out = { composition: c, ingredient: ing, subFormula, amount, unit: rawU, density: d };
1444
+ const resolved = !!(ing || subFormula);
1445
+ if (!isFinite(amount) || !resolved) { out.kind = 'invalid'; return out; }
1446
+ if (u.startsWith('wt') || u === '%') { out.kind = 'wt-frac'; return out; }
1447
+ if (u.startsWith('vol')) { out.kind = 'vol-frac'; return out; }
1448
+ const cu = canonUnit(rawU);
1449
+ if (cu in MASS_TO_KG) {
1450
+ out.kind = 'mass-abs';
1451
+ out.massKg = amount * MASS_TO_KG[cu];
1452
+ out.volL = d ? out.massKg / d : null;
1453
+ return out;
1454
+ }
1455
+ if (cu in VOL_TO_L) {
1456
+ out.kind = 'vol-abs';
1457
+ out.volL = amount * VOL_TO_L[cu];
1458
+ out.massKg = d ? out.volL * d : null;
1459
+ return out;
1460
+ }
1461
+ if (cu in LENGTH_TO_M) {
1462
+ out.kind = 'length-abs';
1463
+ out.lengthM = amount * LENGTH_TO_M[cu];
1464
+ return out;
1465
+ }
1466
+ out.kind = 'unknown';
1467
+ return out;
1468
+ });
1469
+
1470
+ // Sums + same-kind detection
1471
+ let totalMassKg = 0, totalVolL = 0, totalLengthM = 0;
1472
+ rows.forEach(r => {
1473
+ if (r.massKg != null) totalMassKg += r.massKg;
1474
+ if (r.volL != null) totalVolL += r.volL;
1475
+ if (r.lengthM != null) totalLengthM += r.lengthM;
1476
+ });
1477
+ const allWt = rows.length && rows.every(r => r.kind === 'wt-frac');
1478
+ const allVol = rows.length && rows.every(r => r.kind === 'vol-frac');
1479
+ const allLen = rows.length && rows.every(r => r.kind === 'length-abs');
1480
+
1481
+ // For pure wt% / vol% recipes, derive formulation density from densities
1482
+ // so we can also compute the *other* fraction (vol for wt%, mass for vol%).
1483
+ let wtFormKgPerL = null;
1484
+ let volFormKgPerL = null;
1485
+ if (allWt && rows.every(r => r.density)) {
1486
+ let invSum = 0; // sum over rows of (wt_frac / density)
1487
+ rows.forEach(r => { invSum += (r.amount / 100) / r.density; });
1488
+ if (invSum > 0) wtFormKgPerL = 1 / invSum;
1489
+ }
1490
+ if (allVol && rows.every(r => r.density)) {
1491
+ let sum = 0;
1492
+ rows.forEach(r => { sum += (r.amount / 100) * r.density; });
1493
+ if (sum > 0) volFormKgPerL = sum;
1494
+ }
1495
+
1496
+ // Mixed-basis guard. A composition that combines PERCENTAGE rows (wt%/vol%)
1497
+ // with ABSOLUTE mass/volume rows (g, lb, mL, …) is under-determined: the
1498
+ // percentage rows carry no absolute mass, and the absolute rows carry no
1499
+ // percentage basis, so there is no shared 100% to normalize against. Without
1500
+ // this guard each group self-normalizes to ~100% and the two stack — e.g.
1501
+ // 25/25/50 wt% + one 5 lb row reports 200%. When we detect the mix we refuse
1502
+ // to fabricate a fraction (null it out) and flag `mixedBasis` so the UI can
1503
+ // explain and prompt for a single basis. (Length is a separate dimension and
1504
+ // is left alone.)
1505
+ const hasFracRow = rows.some(r => r.kind === 'wt-frac' || r.kind === 'vol-frac');
1506
+ const hasAbsMassVol = rows.some(r => r.kind === 'mass-abs' || r.kind === 'vol-abs');
1507
+ const mixedBasis = hasFracRow && hasAbsMassVol;
1508
+
1509
+ // Pass 2: fill in massFraction / volFraction per row
1510
+ rows.forEach(r => {
1511
+ if (mixedBasis && (r.kind === 'wt-frac' || r.kind === 'vol-frac' ||
1512
+ r.kind === 'mass-abs' || r.kind === 'vol-abs')) {
1513
+ // Ill-posed mix — no coherent fraction. Surface "—", not a fake number.
1514
+ r.massFraction = null;
1515
+ r.volFraction = null;
1516
+ return;
1517
+ }
1518
+ if (r.kind === 'wt-frac') {
1519
+ r.massFraction = r.amount / 100;
1520
+ r.volFraction = (wtFormKgPerL && r.density)
1521
+ ? ((r.amount / 100) / r.density) * wtFormKgPerL
1522
+ : null;
1523
+ } else if (r.kind === 'vol-frac') {
1524
+ r.volFraction = r.amount / 100;
1525
+ r.massFraction = (volFormKgPerL && r.density)
1526
+ ? ((r.amount / 100) * r.density) / volFormKgPerL
1527
+ : null;
1528
+ } else if (r.kind === 'mass-abs' || r.kind === 'vol-abs') {
1529
+ r.massFraction = (r.massKg != null && totalMassKg > 0) ? r.massKg / totalMassKg : null;
1530
+ r.volFraction = (r.volL != null && totalVolL > 0) ? r.volL / totalVolL : null;
1531
+ } else if (r.kind === 'length-abs') {
1532
+ r.lengthFraction = (r.lengthM != null && totalLengthM > 0) ? r.lengthM / totalLengthM : null;
1533
+ r.massFraction = null;
1534
+ r.volFraction = null;
1535
+ } else {
1536
+ r.massFraction = null;
1537
+ r.volFraction = null;
1538
+ }
1539
+ });
1540
+
1541
+ return { rows, totalMassKg, totalVolL, totalLengthM, allWt, allVol, allLen, mixedBasis };
1542
+ }
1543
+
1544
+ // Scale-to-batch: given a target batch (amount + unit), return the
1545
+ // absolute amount of each composition row in that unit. The math is
1546
+ // simply fraction × batchAmount, using mass fractions when the batch
1547
+ // unit is a mass and volume fractions when it's a volume — so it
1548
+ // works for %-recipes, absolute recipes, and mixed alike (the
1549
+ // fraction computation already normalizes them).
1550
+ //
1551
+ // Rows whose fraction can't be resolved in the chosen dimension
1552
+ // (e.g. a volume batch but the row lacks density) come back null so
1553
+ // the UI can show "—". Returns:
1554
+ // { unit, dim:'mass'|'volume', byRow: Map<compRowObj, number|null> } | null
1555
+ // Metric "ladders" for auto-scaling a value to the most readable unit
1556
+ // in its dimension. factor = base units (kg or L) per 1 of this unit.
1557
+ const _MASS_LADDER = [
1558
+ { unit: 'µg', factor: 1e-9 }, { unit: 'mg', factor: 1e-6 },
1559
+ { unit: 'g', factor: 1e-3 }, { unit: 'kg', factor: 1 }, { unit: 'ton', factor: 1000 },
1560
+ ];
1561
+ const _VOL_LADDER = [
1562
+ { unit: 'µL', factor: 1e-6 }, { unit: 'mL', factor: 1e-3 }, { unit: 'L', factor: 1 },
1563
+ ];
1564
+
1565
+ // Re-express `value` (in `unit`) using the most readable unit of the
1566
+ // same dimension — the largest unit that keeps the magnitude >= 1.
1567
+ // Turns "0.00912 kg" into "9.12 g", "1500 kg" into "1.5 ton", etc.
1568
+ // Leaves the value untouched for non mass/volume units. Returns
1569
+ // { value, unit }.
1570
+ function smartScaleAmount(value, unit) {
1571
+ if (!isFinite(value)) return { value, unit };
1572
+ let ladder, base;
1573
+ if (unit in MASS_TO_KG) { ladder = _MASS_LADDER; base = value * MASS_TO_KG[unit]; }
1574
+ else if (unit in VOL_TO_L) { ladder = _VOL_LADDER; base = value * VOL_TO_L[unit]; }
1575
+ else return { value, unit };
1576
+ if (base === 0) return { value: 0, unit };
1577
+ const a = Math.abs(base);
1578
+ let chosen = ladder[0];
1579
+ for (const l of ladder) { if (a / l.factor >= 1) chosen = l; else break; }
1580
+ return { value: base / chosen.factor, unit: chosen.unit };
1581
+ }
1582
+
1583
+ // → { unit, dim, byRow: Map<compRow, amount-in-batchUnit|null>, anyNull,
1584
+ // density?: { kgPerL, source: 'finished'|'ideal', how, batchId, temp },
1585
+ // totalMassKg?, partsSum? }
1586
+ //
1587
+ // A VOLUME target on a recipe is under-determined until you know the density
1588
+ // of the FINISHED mix: 250 mL of syrup weighs 250 × ρ_mix, and ρ_mix is not
1589
+ // the sum of parts (solutions contract or expand on mixing). So:
1590
+ // • finished density declared on the formula (measured on a batch / spec)
1591
+ // → total mass = V × ρ_mix; each row = its mass fraction of that; shown
1592
+ // in the batch unit via the ROW's own density (the volume you would
1593
+ // measure out of the pure ingredient). Row volumes therefore do NOT sum
1594
+ // to the target — `partsSum` says what they do sum to.
1595
+ // • no finished density → ideal-mix estimate, exactly as before: row volume
1596
+ // fractions × target (parts add up, which is the assumption).
1597
+ // Mass targets are unaffected either way.
1598
+ function scaleCompositionToBatch(formulation, batchAmount, batchUnit) {
1599
+ const amt = parseFloat(batchAmount);
1600
+ if (!isFinite(amt) || amt <= 0) return null;
1601
+ const dim = (batchUnit in MASS_TO_KG) ? 'mass'
1602
+ : (batchUnit in VOL_TO_L) ? 'volume'
1603
+ : null;
1604
+ if (!dim) return null;
1605
+ const fr = computeCompositionFractions(formulation);
1606
+ const comp = formulation.composition || [];
1607
+ const byRow = new Map();
1608
+ let anyNull = false;
1609
+ const pd = (typeof formulaPlanningDensity === 'function') ? formulaPlanningDensity(formulation) : null;
1610
+ const useFinished = dim === 'volume' && pd && pd.source === 'finished';
1611
+ if (useFinished) {
1612
+ const totalMassKg = amt * VOL_TO_L[batchUnit] * pd.kgPerL;
1613
+ let partsSum = 0;
1614
+ fr.rows.forEach((r, i) => {
1615
+ const mf = r.massFraction;
1616
+ const rowD = (r.density != null && isFinite(r.density) && r.density > 0) ? r.density : null; // kg/L
1617
+ const ok = (mf != null && isFinite(mf) && rowD != null);
1618
+ if (!ok) anyNull = true;
1619
+ const vol = ok ? ((mf * totalMassKg) / rowD) / VOL_TO_L[batchUnit] : null;
1620
+ if (ok) partsSum += vol;
1621
+ byRow.set(comp[i], vol);
1622
+ });
1623
+ return { unit: batchUnit, dim, byRow, anyNull, density: pd, totalMassKg, partsSum };
1624
+ }
1625
+ fr.rows.forEach((r, i) => {
1626
+ const frac = dim === 'mass' ? r.massFraction : r.volFraction;
1627
+ const ok = (frac != null && isFinite(frac));
1628
+ if (!ok) anyNull = true;
1629
+ byRow.set(comp[i], ok ? frac * amt : null);
1630
+ });
1631
+ const out = { unit: batchUnit, dim, byRow, anyNull };
1632
+ if (dim === 'volume' && pd) { out.density = pd; out.totalMassKg = amt * VOL_TO_L[batchUnit] * pd.kgPerL; out.partsSum = amt; }
1633
+ return out;
1634
+ }
1635
+
1636
+ // Effective on-hand amount of a batch for budget math (Tier 3). Once a batch
1637
+ // is Completed and has a logged actual yield, the budget reconciles against
1638
+ // that actual; otherwise it reserves against the planned target quantity.
1639
+ // Returns { amount, isActual } in the batch's own unit (actualYield shares
1640
+ // the target-quantity unit).
1641
+ function effectiveBatchAmount(batch) {
1642
+ if (!batch) return { amount: null, isActual: false };
1643
+ const ay = parseFloat(batch.actualYield);
1644
+ if (batch.status === 'Completed' && batch.actualYield != null && batch.actualYield !== '' && isFinite(ay) && ay >= 0) {
1645
+ return { amount: ay, isActual: true };
1646
+ }
1647
+ const tq = parseFloat(batch.targetQuantity);
1648
+ return { amount: isFinite(tq) ? tq : null, isActual: false };
1649
+ }
1650
+
1651
+ // Shared "Δ vs plan" severity tiers — one source of truth for the
1652
+ // planned-vs-actual deviation colors used in the DOE Matrix column and the
1653
+ // Compare chip, so the color key means the same thing everywhere.
1654
+ // <2 wt-% → on-target (green) · 2–10 → drift (amber) · ≥10 → off (red)
1655
+ function deviationTier(pct) {
1656
+ const p = Math.abs(parseFloat(pct));
1657
+ if (!isFinite(p)) return { tier: 'none', color: '#94a3b8', bg: 'transparent', label: '—' };
1658
+ if (p < 2) return { tier: 'on', color: '#15803d', bg: '#f0fdf4', label: 'on-target' };
1659
+ if (p < 10) return { tier: 'drift', color: '#b45309', bg: '#fffbeb', label: 'drift' };
1660
+ return { tier: 'off', color: '#b91c1c', bg: '#fef2f2', label: 'off-target' };
1661
+ }
1662
+
1663
+ // Plain-text tooltip + inline color legend for the deviation tiers.
1664
+ const DEVIATION_TIP = 'Δ vs plan = worst-case wt-% drift of the as-prepared composition from the proposal. Colors: under 2% on-target (green), 2–10% drift (amber), 10%+ off-target (red).';
1665
+ function deviationLegendHtml() {
1666
+ const dot = c => `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${c};vertical-align:middle;margin-right:3px"></span>`;
1667
+ return `<span title="${DEVIATION_TIP}" style="font-size:10.5px;color:#64748b;white-space:nowrap">Δ vs plan: ${dot('#15803d')}&lt;2% ${dot('#b45309')}2–10% ${dot('#b91c1c')}≥10%</span>`;
1668
+ }
1669
+
1670
+ // Effective % active for grossing up a weigh-out amount of a technical
1671
+ // (impure) ingredient. Precedence:
1672
+ // 1. the ingredient's nominal `purity` (the stable spec / override), else
1673
+ // 2. the purity of the active lot we'd draw next (soonest expiry, then
1674
+ // oldest received) — so lot data entered on the CoA is used even with no
1675
+ // nominal set, else
1676
+ // 3. null → treat as 100% (no correction).
1677
+ // A nominal (or lot) value of ≥100 means "pure" → returns null (no correction).
1678
+ // Used by the batch AND sample weigh-outs so both correct identically. This is
1679
+ // a bench/weigh-out concept only — composition, cost, and property math keep
1680
+ // using the as-declared amounts.
1681
+ function effectivePurity(ing) {
1682
+ if (!ing) return null;
1683
+ const nom = parseFloat(ing.purity);
1684
+ if (isFinite(nom) && nom > 0) return nom < 100 ? nom : null;
1685
+ // No usable nominal → fall back to a representative active lot with purity.
1686
+ const lots = (typeof db !== 'undefined' && Array.isArray(db.lots))
1687
+ ? db.lots.filter(l => l.ingredientId === ing.id
1688
+ && (parseFloat(l.qtyRemaining) || 0) > 0
1689
+ && (!l.status || l.status === 'active')
1690
+ && l.purity != null && isFinite(parseFloat(l.purity)) && parseFloat(l.purity) > 0)
1691
+ : [];
1692
+ if (!lots.length) return null;
1693
+ lots.sort((a, b) => {
1694
+ const ax = a.expiryDate || '9999-12-31', bx = b.expiryDate || '9999-12-31';
1695
+ if (ax !== bx) return ax < bx ? -1 : 1;
1696
+ return (a.receivedDate || '').localeCompare(b.receivedDate || '');
1697
+ });
1698
+ const p = parseFloat(lots[0].purity);
1699
+ return (isFinite(p) && p > 0 && p < 100) ? p : null;
1700
+ }
1701
+
1702
+ // Lot-assay deviation warning. The weigh-out is grossed up on the ingredient's
1703
+ // NOMINAL purity (the design basis — see effectivePurity); a delivered lot may
1704
+ // assay differently. When the lot this row will draw from (the batch's consumed
1705
+ // lots, else the FIFO front lot) deviates from the nominal by ≥ PURITY_DEVIATION_WARN
1706
+ // absolute points, return the facts + the weigh amount that would deliver the
1707
+ // spec active at the lot's assay. Null when there's nothing to warn about.
1708
+ const PURITY_DEVIATION_WARN = 1; // absolute % points
1709
+ function lotPurityDeviation(ing, opts) {
1710
+ opts = opts || {};
1711
+ if (!ing || typeof db === 'undefined' || !Array.isArray(db.lots)) return null;
1712
+ const nominal = parseFloat(ing.purity);
1713
+ if (!(isFinite(nominal) && nominal > 0 && nominal < 100)) return null;
1714
+ let lots = [];
1715
+ if (opts.batchId && typeof lotsUsedInBatch === 'function') {
1716
+ lots = lotsUsedInBatch(db.stockMovements || [], opts.batchId)
1717
+ .map(u => db.lots.find(l => l && l.id === u.lotId)).filter(l => l && l.ingredientId === ing.id);
1718
+ }
1719
+ const consumed = lots.length > 0;
1720
+ if (!lots.length) {
1721
+ lots = db.lots.filter(l => l && l.ingredientId === ing.id && (parseFloat(l.qtyRemaining) || 0) > 0 && (!l.status || l.status === 'active'))
1722
+ .sort((a, b) => { const ax = a.expiryDate || '9999-12-31', bx = b.expiryDate || '9999-12-31'; return ax !== bx ? (ax < bx ? -1 : 1) : (a.receivedDate || '').localeCompare(b.receivedDate || ''); });
1723
+ }
1724
+ const lot = lots.find(l => l.purity != null && isFinite(parseFloat(l.purity)) && parseFloat(l.purity) > 0);
1725
+ if (!lot) return null;
1726
+ const p = parseFloat(lot.purity);
1727
+ const dev = p - nominal;
1728
+ if (Math.abs(dev) < PURITY_DEVIATION_WARN) return null;
1729
+ const amount = (opts.amount != null && isFinite(opts.amount)) ? opts.amount : null;
1730
+ return { lot, lotNumber: lot.lotNumber || lot.uid || '', lotPurity: p, nominal, deviation: +dev.toFixed(2), consumed,
1731
+ correctedAmount: amount != null ? amount * (nominal / p) : null };
1732
+ }
1733
+
1734
+ // Gross up a single weigh-out amount for the row's ingredient purity (drift
1735
+ // model — other rows are NOT rebalanced). Returns the amount technicians
1736
+ // weigh, the purity used, and the active amount it delivers. Sub-formula rows
1737
+ // and rows with no purity data pass through unchanged.
1738
+ function correctAmountForPurity(comp, amt) {
1739
+ const passthrough = { amount: amt, purity: null, activeAmount: amt };
1740
+ if (!comp || !comp.ingredientId || amt == null || !isFinite(amt)) return passthrough;
1741
+ const ing = (typeof db !== 'undefined' && Array.isArray(db.ingredients))
1742
+ ? db.ingredients.find(i => i.id === comp.ingredientId) : null;
1743
+ const p = effectivePurity(ing);
1744
+ if (p == null || !(p > 0) || p >= 100) return passthrough;
1745
+ return { amount: amt / (p / 100), purity: p, activeAmount: amt };
1746
+ }
1747
+
1748
+ // A composition row carrying this role is the "balance / q.s." ingredient:
1749
+ // at weigh-out it's set to whatever amount holds the batch at its target size
1750
+ // (absorbing the purity gross-up), so concentrations stay on-spec. Kept in
1751
+ // sync with the role option lists in js/views/formulations.js.
1752
+ const QS_ROLE = 'Balance (q.s.)';
1753
+
1754
+ // Actual MASS of a set of as-prepared rows, in kg, from each row's OWN unit:
1755
+ // mass units directly; volume units × that ingredient's (or sub-formula's)
1756
+ // density — never the formula density, and never a sum of volumes. This is the
1757
+ // make-up-to-volume truth: weigh the solids, fill the solvent to the mark, log
1758
+ // what went in, and Σ row masses ÷ the mark volume is the finished density.
1759
+ // Returns { kg, n } or null when no row resolves / any comparable row can't.
1760
+ function sumActualRowsMassKg(rows, formula) {
1761
+ if (!Array.isArray(rows)) return null;
1762
+ let kg = 0, n = 0;
1763
+ for (const c of rows) {
1764
+ if (!c || !(c.ingredientId || c.formulationId)) continue;
1765
+ const amt = parseFloat(c.amount);
1766
+ if (!isFinite(amt)) continue;
1767
+ const u = (c.unit || '').trim();
1768
+ if (u in MASS_TO_KG) { kg += amt * MASS_TO_KG[u]; n++; continue; }
1769
+ if (u in VOL_TO_L) {
1770
+ let d = null;
1771
+ if (c.ingredientId) {
1772
+ const ing = (typeof db !== 'undefined' ? (db.ingredients || []) : []).find(i => i.id === c.ingredientId);
1773
+ d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
1774
+ } else if (typeof _rolledUpDensity === 'function') {
1775
+ const sub = (typeof db !== 'undefined' ? (db.formulations || []) : []).find(g => g.id === c.formulationId);
1776
+ d = sub ? _rolledUpDensity(sub) : null;
1777
+ }
1778
+ if (d == null || !isFinite(d) || d <= 0) return null;
1779
+ kg += amt * VOL_TO_L[u] * d; n++;
1780
+ }
1781
+ // % rows aren't absolute amounts — skipped, as everywhere else.
1782
+ }
1783
+ return n ? { kg, n } : null;
1784
+ }
1785
+
1786
+ // Post-process weigh-out rows: if one row is the q.s. balance ingredient, set
1787
+ // its amount to targetQty − (sum of all other rows) so the total lands exactly
1788
+ // on target. Mutates rows in place. Returns { qsApplied, qsShort } — qsShort is
1789
+ // true when the other rows already exceed target (can't balance; balance
1790
+ // clamped to 0). Runs whenever a balance row exists, purity-corrected or not.
1791
+ function applyQsBalance(rows, targetQty) {
1792
+ const out = { qsApplied: false, qsShort: false };
1793
+ if (!Array.isArray(rows) || !isFinite(targetQty) || targetQty <= 0) return out;
1794
+ const bi = rows.findIndex(r => r && r.comp && r.comp.role === QS_ROLE);
1795
+ if (bi < 0) return out;
1796
+ let others = 0;
1797
+ rows.forEach((r, i) => { if (i !== bi && r.amount != null && isFinite(r.amount)) others += r.amount; });
1798
+ let bal = targetQty - others;
1799
+ out.qsApplied = true;
1800
+ out.qsShort = bal < -1e-9;
1801
+ if (out.qsShort) bal = 0;
1802
+ rows[bi].amount = bal;
1803
+ rows[bi].qs = true; // panel shows a q.s. tag
1804
+ rows[bi].purity = null; // the balance row isn't purity-corrected
1805
+ rows[bi].activeAmount = bal;
1806
+ return out;
1807
+ }
1808
+
1809
+ // Shared weigh-out panel (Tier 1/2). Renders planned per-ingredient amounts
1810
+ // and — once as-prepared actuals exist — Planned / Actual / Δ columns, all in
1811
+ // absolute amounts (the "more than percentages" bench view). Used by both
1812
+ // the batch detail and the sample modal.
1813
+ // w – result of _*PlannedWeighout(): { available, unit, qty, rows:[{key,name,amount,comp}], total, anyNull, reason, formula }
1814
+ // actuals – as-prepared composition rows ([{ ingredientId|formulationId, amount, unit }])
1815
+ // formula – linked formula (for density-aware unit conversion)
1816
+ // copyOnclick – onclick string for the "Copy into As-prepared" button
1817
+ // noun – 'batch' | 'sample' (hint wording)
1818
+ // Volume-target explainer under the weigh-out subtitle: which density turned
1819
+ // the target volume into a mass, where it came from, and (finished density)
1820
+ // that the per-row volumes are pure-ingredient volumes that do not add up to
1821
+ // the target because mixing isn't additive. Empty for mass targets.
1822
+ function _weighoutDensityLineHtml(w) {
1823
+ if (!w || !w.density || w.totalMassKg == null || !isFinite(w.totalMassKg)) return '';
1824
+ const d = w.density;
1825
+ const g = w.totalMassKg * 1000;
1826
+ const mass = g >= 1000 ? `${+(g / 1000).toFixed(3)} kg` : `${+g.toFixed(1)} g`;
1827
+ const rho = `${+d.kgPerL.toFixed(4)} g/mL`;
1828
+ const temp = (d.temp != null && d.temp !== '') ? ` @ ${esc(String(d.temp))} °C` : '';
1829
+ const qty = `${w.qty} ${esc(w.unit)}`;
1830
+ if (d.source === 'finished') {
1831
+ let src = d.how === 'measured' ? 'measured' : (d.how === 'spec' ? 'from spec' : 'entered');
1832
+ if (d.batchId && typeof db !== 'undefined') {
1833
+ const b = (db.batches || []).find(x => x.id === d.batchId);
1834
+ if (b) src += ` on <a href="#" onclick="navigate('batch-detail','${esc(b.id)}');return false" style="color:#2563eb">${esc(b.uid)}</a>`;
1835
+ }
1836
+ const parts = (w.partsSum != null && isFinite(w.partsSum) && Math.abs(w.partsSum - w.qty) > 1e-6)
1837
+ ? ` Pure-ingredient volumes sum to ${+w.partsSum.toFixed(2)} ${esc(w.unit)} — volumes aren't additive on mixing; the masses are what to weigh.` : '';
1838
+ return `<div style="margin-top:4px;font-size:11.5px;color:#0f172a"><strong>${qty} ≙ ${mass}</strong> at finished density <strong>${rho}</strong>${temp} <span style="color:#64748b">(${src})</span>.${parts}</div>`;
1839
+ }
1840
+ return `<div style="margin-top:4px;font-size:11.5px;color:#92400e"><strong>${qty} ≙ ${mass}</strong> at <strong>${rho}</strong> — an <strong>ideal-mix estimate</strong> from the ingredient densities (assumes volumes add up; real mixes contract or expand). Record a measured density on a batch and adopt it as the formula's finished density to make this exact.</div>`;
1841
+ }
1842
+
1843
+ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
1844
+ const fmt = n => (n == null || !isFinite(n)) ? '—' : parseFloat(n.toFixed(4)).toString();
1845
+ const sgn = n => (n == null || !isFinite(n)) ? '—' : (n > 0 ? '+' : '') + parseFloat(n.toFixed(4)).toString();
1846
+ if (!w || !w.available) {
1847
+ if (w && w.reason === 'bad-unit')
1848
+ return `<div style="padding:8px 11px;margin-bottom:12px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;color:#92400e;font-size:12px"><span class="fl-warn" aria-hidden="true"></span> Set the ${noun} quantity in a <strong>mass or volume unit</strong> (g, kg, mL, L…) to get a weigh-out sheet — currently <strong>${esc(w.unit || '—')}</strong>.</div>`;
1849
+ if (w && w.reason === 'no-qty')
1850
+ return `<div style="padding:8px 11px;margin-bottom:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1e40af;font-size:12px">Set a <strong>${noun} quantity</strong> to compute the per-ingredient weigh-out amounts technicians need.</div>`;
1851
+ return '';
1852
+ }
1853
+ // Map as-prepared actuals by ingredient / sub-formula key. Only absolute
1854
+ // (mass/volume) rows are comparable to the planned weigh-out — a row left in
1855
+ // % is a concentration figure, handled by the proposal-vs-actual %-diff, not
1856
+ // here, so it's skipped (the panel stays planned-only in that case).
1857
+ const actMap = new Map();
1858
+ (Array.isArray(actuals) ? actuals : []).forEach(c => {
1859
+ const key = c.formulationId ? ('f:' + c.formulationId) : (c.ingredientId ? ('i:' + c.ingredientId) : '');
1860
+ const amt = parseFloat(c.amount);
1861
+ const u = c.unit || '';
1862
+ const comparable = (typeof isMassUnit === 'function' && isMassUnit(u)) || (typeof isVolUnit === 'function' && isVolUnit(u));
1863
+ if (key && comparable && isFinite(amt)) actMap.set(key, { amount: amt, unit: u });
1864
+ });
1865
+ const hasActuals = actMap.size > 0;
1866
+ // ---- Display unit: show the whole sheet in any unit of the SAME dimension as
1867
+ // the batch's target unit (a per-device preference), converting every amount
1868
+ // through convertQuantity. Defaults to the batch unit; keeps one consistent
1869
+ // unit per sheet (never mixed per row).
1870
+ const _woDim = (typeof isMassUnit === 'function' && isMassUnit(w.unit)) ? 'mass'
1871
+ : (typeof isVolUnit === 'function' && isVolUnit(w.unit)) ? 'volume' : null;
1872
+ let displayUnit = w.unit;
1873
+ if (_woDim) {
1874
+ let pref = null; try { pref = localStorage.getItem('formlab_weighout_unit_' + _woDim); } catch (_) {}
1875
+ if (pref && pref !== w.unit && typeof convertQuantity === 'function') {
1876
+ const t = convertQuantity(1, w.unit, pref, formula);
1877
+ if (t != null && isFinite(t)) displayUnit = pref;
1878
+ }
1879
+ }
1880
+ const conv = amt => {
1881
+ if (amt == null || !isFinite(amt)) return null;
1882
+ if (displayUnit === w.unit) return amt;
1883
+ const v = (typeof convertQuantity === 'function') ? convertQuantity(amt, w.unit, displayUnit, formula) : null;
1884
+ return (v == null || !isFinite(v)) ? null : v;
1885
+ };
1886
+ const cv = amt => fmt(conv(amt)); // convert + format
1887
+ const cvs = amt => sgn(conv(amt)); // convert + signed format
1888
+ // Offer EVERY supported unit of the batch's dimension, deduped by conversion
1889
+ // factor (so µg/ug/mcg or t/ton/tonne collapse to one canonical spelling),
1890
+ // plus the batch's own unit and any custom units — sorted smallest-first.
1891
+ let _woUnits = [];
1892
+ if (_woDim) {
1893
+ const src = _woDim === 'mass' ? MASS_TO_KG : VOL_TO_L;
1894
+ const customs = _woDim === 'mass' ? (typeof _customMassUnits === 'function' ? _customMassUnits() : {}) : (typeof _customVolUnits === 'function' ? _customVolUnits() : {});
1895
+ const byFactor = new Map();
1896
+ Object.keys(src).forEach(u => { const f = src[u]; if (!byFactor.has(f)) byFactor.set(f, u); });
1897
+ const set = new Set([w.unit, ...byFactor.values()]);
1898
+ Object.keys(customs || {}).forEach(u => set.add(u));
1899
+ const factorOf = u => { const f = (u in src) ? src[u] : (customs && customs[u]); return (typeof f === 'number') ? f : 1; };
1900
+ _woUnits = [...set].sort((a, b) => factorOf(a) - factorOf(b));
1901
+ }
1902
+ const _woResetBtn = (_woDim && displayUnit !== w.unit)
1903
+ ? `<button type="button" onclick="_weighoutResetDisplayUnit('${_woDim}')" title="Reset to the batch's target unit (${esc(w.unit)})" style="font-size:11px;padding:2px 7px;border:1px solid var(--border);border-radius:5px;background:#fff;color:#64748b;cursor:pointer;white-space:nowrap">↺ ${esc(w.unit)}</button>`
1904
+ : '';
1905
+ const unitDropdown = (_woDim && _woUnits.length > 1)
1906
+ ? `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin-left:16px">
1907
+ <label style="font-size:11px;color:#64748b;display:inline-flex;align-items:center;gap:4px;white-space:nowrap">show in
1908
+ <select onchange="_weighoutSetDisplayUnit('${_woDim}', this.value)" title="Display the weigh-out amounts in this unit; the sheet stays in one consistent unit" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:#fff">
1909
+ ${_woUnits.map(u => `<option value="${esc(u)}" ${u === displayUnit ? 'selected' : ''}>${esc(u)}</option>`).join('')}
1910
+ </select></label>
1911
+ ${_woResetBtn}
1912
+ </span>`
1913
+ : '';
1914
+ // A logged actual in another unit converts to the sheet's unit through THAT
1915
+ // ROW's density (ingredient / sub-formula) — never the formula's. Cross-
1916
+ // dimension with no row density → null (shown raw, not fabricated).
1917
+ const toUnit = (amt, from, key) => {
1918
+ if (amt == null || !isFinite(amt)) return null;
1919
+ if (!from || from === w.unit) return amt;
1920
+ if (typeof convertQuantityAtDensity !== 'function') return null;
1921
+ const v = convertQuantityAtDensity(amt, from, w.unit, rowDensityKgPerL(key));
1922
+ return (v == null || !isFinite(v)) ? null : v;
1923
+ };
1924
+ let actualTotal = 0;
1925
+ const rows = w.rows.map(r => {
1926
+ const a = actMap.get(r.key);
1927
+ const planned = (r.amount != null && isFinite(r.amount)) ? r.amount : null;
1928
+ let actInUnit = null;
1929
+ if (a) { actInUnit = toUnit(a.amount, a.unit, r.key); if (actInUnit != null) actualTotal += actInUnit; }
1930
+ const delta = (hasActuals && planned != null && actInUnit != null) ? (actInUnit - planned) : null;
1931
+ const rel = (delta != null && planned > 0) ? Math.abs(delta) / planned : (delta != null && actInUnit > 0 ? 1 : null);
1932
+ return { key: r.key, name: r.name, planned, actInUnit, actRaw: a, delta, rel, purity: r.purity, activeAmount: r.activeAmount, qs: r.qs, lotDev: r.lotDev || null };
1933
+ });
1934
+ // Union in ingredients that were ADDED to the As-prepared table but aren't in
1935
+ // the recipe (a spike / colorant / q.s. carrier). Without this the weigh-out
1936
+ // silently drops them from the comparison AND from the Actual total, so the
1937
+ // total under-reports. They show with no Planned, their Actual amount, and a
1938
+ // full deviation (they're all "extra").
1939
+ if (hasActuals) {
1940
+ const plannedKeys = new Set(w.rows.map(r => r.key));
1941
+ actMap.forEach((a, key) => {
1942
+ if (plannedKeys.has(key)) return;
1943
+ const actInUnit = toUnit(a.amount, a.unit, key);
1944
+ if (actInUnit != null) actualTotal += actInUnit;
1945
+ let name = '(added)';
1946
+ if (key.slice(0, 2) === 'i:') { const ing = (typeof db !== 'undefined' && db.ingredients || []).find(i => i.id === key.slice(2)); name = ing ? ing.name : '(added ingredient)'; }
1947
+ else if (key.slice(0, 2) === 'f:') { const ff = (typeof db !== 'undefined' && db.formulations || []).find(x => x.id === key.slice(2)); name = ff ? '▸ ' + ff.name : '(added sub-formula)'; }
1948
+ rows.push({ key, name, planned: null, actInUnit, actRaw: a, delta: actInUnit, rel: (actInUnit > 0 ? 1 : null), purity: null, activeAmount: null, qs: false, _added: true });
1949
+ });
1950
+ }
1951
+ const _rNumFmt = v => (typeof _fmpNum === 'function') ? _fmpNum(v) : (Math.abs(v) >= 100 ? (+v).toFixed(1) : (+v).toFixed(3)).replace(/\.?0+$/, '');
1952
+ const _purityTagBase = r => (r.purity == null) ? '' :
1953
+ ` <span style="font-size:9.5px;color:#6d28d9;font-weight:600;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Grossed up for ${esc(String(+r.purity))}% purity, delivers ${cv(r.activeAmount)} ${esc(displayUnit)} of active">↑${esc(String(+r.purity))}%</span>`;
1954
+ // Lot-assay deviation: the lot this row draws from assays away from the nominal.
1955
+ const purityTag = r => _purityTagBase(r) + (!r.lotDev ? '' :
1956
+ ` <span style="font-size:9.5px;color:#b45309;font-weight:600;background:#fffbeb;border:1px solid #fde68a;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Lot ${esc(String(r.lotDev.lotNumber))} assays ${esc(String(+r.lotDev.lotPurity))}% but the weigh-out is grossed up on the nominal ${esc(String(+r.lotDev.nominal))}%. ${r.lotDev.consumed ? 'This batch drew from that lot.' : 'FIFO will draw from that lot next.'} To deliver the spec active at the lot's assay, weigh ${r.lotDev.correctedAmount != null ? esc(_rNumFmt(r.lotDev.correctedAmount)) + ' ' + esc(String(displayUnit || w.unit || '')) : 'the corrected amount'} instead.">⚠ lot ${esc(String(+r.lotDev.lotPurity))}% vs ${esc(String(+r.lotDev.nominal))}%${r.lotDev.correctedAmount != null ? ' → ' + esc(_rNumFmt(r.lotDev.correctedAmount)) : ''}</span>`);
1957
+ const _woVolTarget = (typeof isVolUnit === 'function') && isVolUnit(w.unit);
1958
+ const qsTag = r => (!r.qs) ? '' :
1959
+ ` <span style="font-size:9.5px;color:#0369a1;font-weight:600;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="${_woVolTarget ? `Balance ingredient — add it up to the ${w.qty} ${esc(w.unit)} mark rather than to this number, then log what actually went in` : 'Balance ingredient, set to hold the batch at its target size'}">${_woVolTarget ? `q.s. to ${w.qty} ${esc(w.unit)}` : 'q.s.'}</span>`;
1960
+ const dColor = rel => rel == null ? '#94a3b8' : (rel < 0.02 ? '#15803d' : rel < 0.10 ? '#b45309' : '#b91c1c');
1961
+ // Tint a whole row when it deviates, so off-plan ingredients pop at a glance
1962
+ // (matches the composition-% table's row tinting). On-target rows stay white.
1963
+ const dRowBg = rel => (rel == null || rel < 0.02) ? '' : (rel < 0.10 ? 'background:#fffbeb' : 'background:#fef2f2');
1964
+ // ---- Secondary reference column: each row shown in the OTHER dimension,
1965
+ // derived per-row from that ingredient's OWN density (sub-formulas use their
1966
+ // rolled-up density). The prepared unit stays the single basis; this is a
1967
+ // read-only convenience for batches that mix weighed and measured rows.
1968
+ // A row with no density shows — (never guessed from the formula aggregate),
1969
+ // and there's no total (the derived dimension isn't additive across rows).
1970
+ const _altDim = _woDim === 'mass' ? 'volume' : (_woDim === 'volume' ? 'mass' : null);
1971
+ const _ALT_SIBLING = { kg: 'L', g: 'mL', mg: 'µL', L: 'kg', mL: 'g', 'µL': 'mg' };
1972
+ // Units offered for the ≈ column — every supported unit of the ALT dimension,
1973
+ // deduped by conversion factor + any customs, smallest-first (mirrors the
1974
+ // show-in list). The column unit is a per-device preference; with none set it
1975
+ // auto-tracks the display unit's scale (g→mL, kg→L) via _ALT_SIBLING.
1976
+ let _altUnits = [];
1977
+ if (_altDim) {
1978
+ const src = _altDim === 'mass' ? MASS_TO_KG : VOL_TO_L;
1979
+ const customs = _altDim === 'mass' ? (typeof _customMassUnits === 'function' ? _customMassUnits() : {}) : (typeof _customVolUnits === 'function' ? _customVolUnits() : {});
1980
+ const byFactor = new Map();
1981
+ Object.keys(src).forEach(u => { const f = src[u]; if (!byFactor.has(f)) byFactor.set(f, u); });
1982
+ const set = new Set([...byFactor.values()]);
1983
+ Object.keys(customs || {}).forEach(u => set.add(u));
1984
+ const factorOf = u => { const f = (u in src) ? src[u] : (customs && customs[u]); return (typeof f === 'number') ? f : 1; };
1985
+ _altUnits = [...set].sort((a, b) => factorOf(a) - factorOf(b));
1986
+ }
1987
+ let _altPref = null; try { _altPref = _altDim ? localStorage.getItem('formlab_weighout_altunit_' + _altDim) : null; } catch (_) {}
1988
+ const altUnit = _altDim
1989
+ ? ((_altPref && _altUnits.indexOf(_altPref) !== -1) ? _altPref : (_ALT_SIBLING[displayUnit] || (_altDim === 'volume' ? 'mL' : 'g')))
1990
+ : null;
1991
+ const rowDensity = r => {
1992
+ const key = (r && r.key) || '';
1993
+ if (key.slice(0, 2) === 'f:') { const sf = db.formulations.find(g => g.id === key.slice(2)); return (sf && typeof _rolledUpDensity === 'function') ? _rolledUpDensity(sf) : null; }
1994
+ if (key.slice(0, 2) === 'i:') { const ing = db.ingredients.find(i => i.id === key.slice(2)); return (ing && typeof _densityToKgPerL === 'function') ? _densityToKgPerL(ing.density, ing.densityUnit) : null; }
1995
+ return null;
1996
+ };
1997
+ const altConv = (amt, d) => {
1998
+ if (amt == null || !isFinite(amt) || d == null || !isFinite(d) || d <= 0 || !altUnit) return null;
1999
+ if (_woDim === 'mass') { const kg = amt * massToKg(w.unit); const b = volToL(altUnit); return (b != null) ? (kg / d) / b : null; }
2000
+ if (_woDim === 'volume') { const L = amt * volToL(w.unit); const b = massToKg(altUnit); return (b != null) ? (L * d) / b : null; }
2001
+ return null;
2002
+ };
2003
+ // ≈ column shows the row's PRIMARY amount in the other dimension — planned for
2004
+ // recipe rows, but the ACTUAL amount for an added ("not in recipe") row, which
2005
+ // has no planned. Otherwise a density-bearing add (e.g. TiO2) shows a blank ≈.
2006
+ const _altAmt = r => (r.planned != null) ? r.planned : r.actInUnit;
2007
+ const altCell = r => {
2008
+ const v = altConv(_altAmt(r), rowDensity(r));
2009
+ if (v == null) return `<span style="color:#cbd5e1" title="No density set for this ingredient — add one to show its ${esc(_altDim || '')} equivalent">—</span>`;
2010
+ return `≈ ${fmt(v)} <span translate="no" style="color:#94a3b8;font-size:10px">${esc(altUnit)}</span>`;
2011
+ };
2012
+ const _altAny = !!_altDim && rows.some(r => altConv(_altAmt(r), rowDensity(r)) != null);
2013
+ // Unit picker for the ≈ column (mirrors the show-in dropdown). Only when the
2014
+ // column is actually showing and there's more than one unit to choose.
2015
+ const altDropdown = (_altAny && _altDim && _altUnits.length > 1)
2016
+ ? `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin-left:10px">
2017
+ <label style="font-size:11px;color:#64748b;display:inline-flex;align-items:center;gap:4px;white-space:nowrap">≈ in
2018
+ <select onchange="_weighoutSetAltUnit('${_altDim}', this.value)" title="Unit for the ≈ reference column — the other dimension, derived per-ingredient from its density" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:#fff">
2019
+ ${_altUnits.map(u => `<option value="${esc(u)}" ${u === altUnit ? 'selected' : ''}>${esc(u)}</option>`).join('')}
2020
+ </select></label>
2021
+ </span>`
2022
+ : '';
2023
+ const th = (txt, extra = '') => `<th style="padding:5px 8px;color:#475569;font-weight:400;${extra}">${txt}</th>`;
2024
+ const head = `<tr style="background:#eff6ff">
2025
+ ${th('#', 'text-align:left;width:28px')}
2026
+ ${th('Ingredient / Sub-formula', 'text-align:left')}
2027
+ ${th('Planned', 'text-align:right')}
2028
+ ${hasActuals ? th('Actual', 'text-align:right') + th('Δ', 'text-align:right') : ''}
2029
+ ${th('Unit', 'text-align:left')}
2030
+ ${_altAny ? `<th style="padding:5px 8px;color:#475569;font-weight:400;text-align:right" title="Approx. ${esc(_altDim || '')} equivalent, derived per-ingredient from its own density. Reference only — not additive, so no total.">≈ ${esc(altUnit)}</th>` : ''}
2031
+ </tr>`;
2032
+ const body = rows.map((r, i) => `<tr data-wo-key="${esc(r.key)}" style="${hasActuals ? dRowBg(r.rel) : ''}">
2033
+ <td style="padding:4px 8px;color:#94a3b8">${i + 1}</td>
2034
+ <td style="padding:4px 8px">${esc(r.name)}${r._added ? ` <span style="font-size:9.5px;color:#b91c1c;font-weight:600;background:#fef2f2;border:1px solid #fecaca;border-radius:4px;padding:0 4px;vertical-align:middle;white-space:nowrap" title="Added to the as-prepared batch — not part of the linked recipe">+ not in recipe</span>` : ''}</td>
2035
+ <td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;color:#0f172a">${r.planned != null ? `${cv(r.planned)}${purityTag(r)}${qsTag(r)}` : '<span style="color:#cbd5e1">—</span>'}</td>
2036
+ ${hasActuals ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums">${
2037
+ r.actInUnit != null ? cv(r.actInUnit)
2038
+ : (r.actRaw ? `${fmt(r.actRaw.amount)} <span translate="no" style="color:#94a3b8;font-size:10px">${esc(r.actRaw.unit)}</span>` : '<span style="color:#cbd5e1">—</span>')
2039
+ }</td>
2040
+ <td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:${dColor(r.rel)};font-weight:${r.delta ? 600 : 400}">${r.delta != null ? cvs(r.delta) : '—'}</td>` : ''}
2041
+ <td style="padding:4px 8px;color:#64748b">${esc(displayUnit)}</td>
2042
+ ${_altAny ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:#64748b">${altCell(r)}</td>` : ''}
2043
+ </tr>`).join('');
2044
+ const totalDelta = hasActuals ? (actualTotal - w.total) : null;
2045
+ // Volume target: the Planned column's sum is the sum of PURE-ingredient
2046
+ // volumes — not a lab quantity (the finished batch is w.qty, and mixing isn't
2047
+ // additive). Say so on the row, and put the number that IS real — the total
2048
+ // mass to weigh, V × ρ — in the ≈ mass column instead of a dash.
2049
+ const _volTarget = _woVolTarget && w.totalMassKg != null && isFinite(w.totalMassKg);
2050
+ const _totalMassCell = (() => {
2051
+ if (!_volTarget || !_altAny || _altDim !== 'mass') return null;
2052
+ const g = w.totalMassKg * 1000;
2053
+ const inAlt = (typeof convertQuantity === 'function') ? convertQuantity(g, 'g', altUnit, formula) : null;
2054
+ return (inAlt != null && isFinite(inAlt)) ? `<strong>${fmt(inAlt)}</strong> <span translate="no" style="color:#94a3b8;font-size:10px">${esc(altUnit)}</span>` : null;
2055
+ })();
2056
+ const tfoot = `<tr style="border-top:1px solid #dbeafe">
2057
+ <td></td><td style="padding:5px 8px;font-weight:600">Total${_volTarget ? ` <span style="font-weight:400;color:#94a3b8;font-size:10.5px" title="Sum of the pure-ingredient volumes in the Planned column. Not a lab quantity — the finished batch is ${w.qty} ${esc(w.unit)}; the mass in the ≈ column is what the batch weighs.">(pure-ingredient volumes)</span>` : ''}</td>
2058
+ <td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:${_volTarget ? 400 : 700};color:${_volTarget ? '#94a3b8' : 'inherit'}" title="${_volTarget ? `Volumes aren't additive on mixing — this sum has no bench meaning. Finished batch: ${w.qty} ${esc(w.unit)}.` : ''}">${cv(w.total)}</td>
2059
+ ${hasActuals ? `<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:700">${cv(actualTotal)}</td>
2060
+ <td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;color:#64748b">${cvs(totalDelta)}</td>` : ''}
2061
+ <td style="padding:5px 8px;color:#64748b">${esc(displayUnit)}</td>
2062
+ ${_altAny ? (_totalMassCell
2063
+ ? `<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums" title="Total mass of the finished ${w.qty} ${esc(w.unit)} = volume × finished density. This is what the batch weighs — the number to reconcile the balance against.">${_totalMassCell}</td>`
2064
+ : `<td style="padding:5px 8px;text-align:right;color:#cbd5e1" title="No total — the ${esc(_altDim || '')} equivalent isn't additive across ingredients">—</td>`) : ''}
2065
+ </tr>`;
2066
+ const densityNote = w.anyNull
2067
+ ? `<div style="margin-top:7px;font-size:11.5px;color:#92400e"><span class="fl-warn" aria-hidden="true"></span> Some rows can't be scaled to ${esc(displayUnit)} — set the formula / ingredient <strong>density</strong> to convert % across mass↔volume.</div>`
2068
+ : '';
2069
+ // Purity / q.s. footer. Three cases: q.s. balanced (on target), q.s. short
2070
+ // (actives exceed target), or drift (corrected, no balance ingredient).
2071
+ let purityNote = '';
2072
+ if (w.qsApplied && w.qsShort) {
2073
+ purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#b91c1c"><span class="fl-warn" aria-hidden="true"></span> Purity-corrected amounts already <strong>exceed the ${cv(w.qty)} ${esc(displayUnit)} target</strong> — the q.s. ingredient can't balance it (clamped to 0). Increase the batch size or reduce the actives.</div>`;
2074
+ } else if (w.qsApplied && w.correctedAny) {
2075
+ purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#0369a1">↑ Actives <strong>grossed up for purity</strong>; the <strong>q.s. ingredient</strong> was shrunk to hold the batch at <strong>${cv(w.total)} ${esc(displayUnit)}</strong> — on target, concentrations on-spec.</div>`;
2076
+ } else if (w.correctedAny && w.nominalTotal != null && isFinite(w.nominalTotal)) {
2077
+ purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#5b21b6">↑ Amounts <strong>grossed up for purity</strong> so each delivers its target active. Total <strong>${cv(w.total)} ${esc(displayUnit)}</strong> vs ${cv(w.nominalTotal)} nominal (+${cv(w.total - w.nominalTotal)}). Other rows are unchanged, so the batch runs a little over target — mark a diluent's role <strong>“${esc(QS_ROLE)}”</strong> to auto-balance back to ${cv(w.qty)} ${esc(displayUnit)}.</div>`;
2078
+ }
2079
+ // Make-up-to-volume instruction: a VOLUME target with a balance row is best
2080
+ // made the pharmacist's way — weigh everything else, fill the balance
2081
+ // ingredient to the mark, log what went in. Nobody has to predict the
2082
+ // contraction; the logged masses ÷ the mark give the finished density, which
2083
+ // the batch page then offers to adopt on the formula.
2084
+ let makeUpNote = '';
2085
+ const _qsRow = w.rows.find(r => r.qs);
2086
+ if (_woVolTarget && w.qsApplied && _qsRow && !hasActuals) {
2087
+ const bal = (_qsRow.amount != null && isFinite(_qsRow.amount)) ? _qsRow.amount : null;
2088
+ const balMass = (bal != null && typeof convertQuantity === 'function' && _qsRow.comp && _qsRow.comp.ingredientId)
2089
+ ? (() => { const ing = (db.ingredients || []).find(i => i.id === _qsRow.comp.ingredientId); const d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null; return (d && VOL_TO_L[w.unit]) ? bal * VOL_TO_L[w.unit] * d * 1000 : null; })() : null;
2090
+ makeUpNote = `<div style="margin-top:7px;padding:7px 10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-size:11.5px;color:#0c4a6e;line-height:1.45"><strong>Make up to volume.</strong> Weigh the other rows as planned, then add <strong>${esc(_qsRow.name)}</strong> up to the <strong>${w.qty} ${esc(w.unit)}</strong> mark and log the amount that actually went in${balMass != null ? ` (planned ≈ ${+balMass.toFixed(1)} g)` : ''}. Volumes aren't additive, so the mark — not the planned number — decides the balance. Once logged, the batch page derives the finished density from the total mass ÷ ${w.qty} ${esc(w.unit)} and offers to save it on the formula for next time.</div>`;
2091
+ }
2092
+ const title = hasActuals ? '⚖ Weigh-out — planned vs actual amounts' : '⚖ Weigh-out — target amounts';
2093
+ // One-line explainer so this table's job (concrete amounts) is unmistakable
2094
+ // and distinct from the composition-% table below it on the batch page.
2095
+ const subtitle = (hasActuals
2096
+ ? `How much of each ingredient to weigh, in <strong>${esc(displayUnit)}</strong>. <strong>Planned</strong> = the recipe scaled to ${cv(w.qty)} ${esc(displayUnit)}; <strong>Actual</strong> = what you logged as prepared.`
2097
+ : `How much of each ingredient to weigh for a ${cv(w.qty)} ${esc(displayUnit)} ${esc(noun)}, in <strong>${esc(displayUnit)}</strong>.`)
2098
+ + _weighoutDensityLineHtml(w);
2099
+ // Yield headline: the as-prepared input total vs the batch target, up front —
2100
+ // so a proportional over/under-make reads as a yield note, not as a mystery
2101
+ // (this is the "why is it +0.2 kg but composition matches" answer, surfaced).
2102
+ let yieldBanner = '';
2103
+ if (hasActuals && isFinite(actualTotal) && actualTotal > 0 && isFinite(w.qty) && w.qty > 0) {
2104
+ const d = actualTotal - w.qty;
2105
+ const pct = (d / w.qty) * 100;
2106
+ const near = Math.abs(pct) < 0.5;
2107
+ const mag = Math.abs(pct);
2108
+ const c = near ? '#15803d' : (mag < 5 ? '#b45309' : '#b91c1c');
2109
+ const bg = near ? '#f0fdf4' : (mag < 5 ? '#fffbeb' : '#fef2f2');
2110
+ const bd = near ? '#bbf7d0' : (mag < 5 ? '#fde68a' : '#fecaca');
2111
+ yieldBanner = `<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;background:${bg};border:1px solid ${bd};border-radius:7px;font-size:13px">
2112
+ <span style="font-weight:700;color:${c}">As-prepared total ${cv(actualTotal)} ${esc(displayUnit)}</span>
2113
+ ${near
2114
+ ? `<span style="color:#15803d;font-weight:600">✓ on the ${cv(w.qty)} ${esc(displayUnit)} target</span>`
2115
+ : `<span style="color:${c};font-weight:600">${cvs(d)} ${esc(displayUnit)} (${d > 0 ? '+' : ''}${pct.toFixed(1)}%) ${d > 0 ? 'over' : 'under'} the ${cv(w.qty)} ${esc(displayUnit)} target</span>`}
2116
+ </div>`;
2117
+ }
2118
+ const copyLabel = hasActuals ? '<span class="fl-import" aria-hidden="true"></span> Reset to planned' : '<span class="fl-import" aria-hidden="true"></span> Copy into As-prepared';
2119
+ return `
2120
+ <div style="border:1px solid #dbeafe;border-radius:8px;padding:12px 14px;margin-bottom:12px;background:#f8fbff">
2121
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;flex-wrap:wrap">
2122
+ <span style="font-size:13px;font-weight:600;color:#0f172a">${title}</span>
2123
+ <span style="font-size:11.5px;color:#64748b">scaled to ${cv(w.qty)} ${esc(displayUnit)}</span>
2124
+ ${unitDropdown}
2125
+ ${altDropdown}
2126
+ <button type="button" class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="${copyOnclick}" title="Seed / reset the as-prepared amounts to the planned weigh-out, then edit each to what you actually weighed.">${copyLabel}</button>
2127
+ </div>
2128
+ <div style="font-size:11px;color:#64748b;line-height:1.5;margin:0 0 9px">${subtitle}</div>
2129
+ ${yieldBanner}
2130
+ <table style="width:100%;border-collapse:collapse;font-size:12px">
2131
+ <thead>${head}</thead>
2132
+ <tbody>${body}</tbody>
2133
+ <tfoot>${tfoot}</tfoot>
2134
+ </table>
2135
+ ${densityNote}
2136
+ ${purityNote}${makeUpNote}
2137
+ </div>`;
2138
+ }
2139
+
2140
+ // Re-render every weigh-out panel on screen (batch + sample) AND the
2141
+ // Proposal-vs-As-prepared diff card (its Δ column shares the same unit), in place.
2142
+ function _weighoutRerenderPanels() {
2143
+ try {
2144
+ document.querySelectorAll('[id^="batch-weighout-"]').forEach(el => {
2145
+ const b = (typeof getBatch === 'function') ? getBatch(el.id.slice('batch-weighout-'.length)) : null;
2146
+ if (b && typeof _batchWeighoutPanelHtml === 'function') el.innerHTML = _batchWeighoutPanelHtml(b);
2147
+ });
2148
+ document.querySelectorAll('[id^="sm-weighout-"]').forEach(el => {
2149
+ const s = (typeof getSample === 'function') ? getSample(el.id.slice('sm-weighout-'.length)) : null;
2150
+ if (s && typeof _sampleWeighoutPanelHtml === 'function') el.innerHTML = _sampleWeighoutPanelHtml(s);
2151
+ });
2152
+ document.querySelectorAll('[id^="batch-actual-diff-"]').forEach(el => {
2153
+ const id = el.id.slice('batch-actual-diff-'.length);
2154
+ if (typeof renderBatchActualDiff === 'function') el.innerHTML = renderBatchActualDiff(id);
2155
+ });
2156
+ } catch (_) {}
2157
+ }
2158
+ // Persist a per-dimension weigh-out display unit (per-device preference) + re-render.
2159
+ function _weighoutSetDisplayUnit(dim, unit) {
2160
+ try { localStorage.setItem('formlab_weighout_unit_' + dim, unit); } catch (_) {}
2161
+ _weighoutRerenderPanels();
2162
+ }
2163
+ // Reset the weigh-out display unit back to each sheet's own target unit.
2164
+ function _weighoutResetDisplayUnit(dim) {
2165
+ try { localStorage.removeItem('formlab_weighout_unit_' + dim); } catch (_) {}
2166
+ _weighoutRerenderPanels();
2167
+ }
2168
+ // Persist the ≈ reference column's unit (per alt-dimension, per-device) + re-render.
2169
+ function _weighoutSetAltUnit(dim, unit) {
2170
+ try { localStorage.setItem('formlab_weighout_altunit_' + dim, unit); } catch (_) {}
2171
+ _weighoutRerenderPanels();
2172
+ }
2173
+
2174
+ // Heuristic: pick a default for the "% of Total" basis based on which
2175
+ // unit dimension the highest-cost-contributing row uses. Falls back to
2176
+ // mass when nothing is priced yet.
2177
+ function defaultPercentBasis(formulation, costSummary) {
2178
+ const isVolUnit = u => {
2179
+ const s = String(u || '').toLowerCase().trim();
2180
+ if (s.startsWith('vol')) return true;
2181
+ const canon = (() => {
2182
+ if (s in VOL_TO_L) return true;
2183
+ for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === s) return true;
2184
+ return false;
2185
+ })();
2186
+ return canon;
2187
+ };
2188
+ if (costSummary?.breakdown?.length) {
2189
+ const top = costSummary.breakdown.reduce(
2190
+ (best, b) => (b.contribution > (best?.contribution ?? -Infinity) ? b : best),
2191
+ null
2192
+ );
2193
+ if (top) return isVolUnit(top.unit) ? 'volume' : 'mass';
2194
+ }
2195
+ // Fallback: scan composition units alone
2196
+ const comp = formulation?.composition || [];
2197
+ const volRows = comp.filter(c => isVolUnit(c.unit)).length;
2198
+ const massRows = comp.length - volRows;
2199
+ return volRows > massRows ? 'volume' : 'mass';
2200
+ }
2201
+
2202
+ // ============================================================
2203
+ // BATCH + PROJECT COST ROLLUP
2204
+ // ============================================================
2205
+ // Approximate kg conversion for batch.targetQuantity.unit. If
2206
+ // volume, falls back to *assume kg* unless density is known.
2207
+ function computeBatchCost(batch, displayCcy = getDisplayCurrency()) {
2208
+ const f = db.formulations.find(x => x.id === batch.formulationId);
2209
+ if (!f) return null;
2210
+ const perKg = computeFormulationCost(f, displayCcy);
2211
+ // Batch size in kg via the shared quantityToKg (mass units convert directly;
2212
+ // volume units use the formula's rolled-up density). This is the SAME path the
2213
+ // sample/detail-field "Cost" cell uses, so the card and that field always agree.
2214
+ // (It replaced a private _batchSizeInKg that lowercased the unit before a
2215
+ // case-sensitive VOL_TO_L lookup — so "L" became "l", missed the table, and
2216
+ // every volume-unit batch silently reported no total.)
2217
+ const _umk = (typeof _batchUnitMassKg === 'function') ? _batchUnitMassKg(batch) : null;
2218
+ const sizeKg = quantityToKg(batch.targetQuantity, batch.unit, f, _umk);
2219
+ return {
2220
+ formulation: f,
2221
+ perKg: perKg.perKg,
2222
+ sizeKg,
2223
+ total: sizeKg != null ? perKg.perKg * sizeKg : null,
2224
+ currency: displayCcy,
2225
+ missing: perKg.missing,
2226
+ };
2227
+ }
2228
+
2229
+ // Finished-goods best-by: a formulation's shelf life (months) applied to a
2230
+ // prepared date → the expiry Date (or ISO string). Null when there's no shelf
2231
+ // life on the formula or the prepared date is missing/unparseable. Shared by the
2232
+ // inventory roll-up, batch detail/list and sample detail/list so they agree.
2233
+ function shelfLifeExpiry(formulation, preparedDate) {
2234
+ const m = formulation && formulation.shelfLifeMonths;
2235
+ if (m == null || m === '' || !isFinite(+m) || +m <= 0) return null;
2236
+ const pm = Date.parse(preparedDate);
2237
+ if (!isFinite(pm)) return null;
2238
+ // UTC arithmetic: a date-only prepared date parses as UTC midnight, and adding
2239
+ // months in LOCAL time then reading the UTC day back landed a day early for
2240
+ // US users whenever the span crossed a DST change (build 33w). A day past the
2241
+ // target month's end clamps to its last day (Jan 31 + 1 month = Feb 28/29).
2242
+ const d = new Date(pm);
2243
+ const day = d.getUTCDate();
2244
+ d.setUTCDate(1);
2245
+ d.setUTCMonth(d.getUTCMonth() + (+m));
2246
+ const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
2247
+ d.setUTCDate(Math.min(day, last));
2248
+ return d;
2249
+ }
2250
+ function shelfLifeExpiryISO(formulation, preparedDate) {
2251
+ const d = shelfLifeExpiry(formulation, preparedDate);
2252
+ return d ? d.toISOString().slice(0, 10) : null;
2253
+ }
2254
+ // A small coloured badge for a best-by date (red if past, amber if ≤30d).
2255
+ function shelfLifeBadgeHtml(iso) {
2256
+ if (!iso) return '—';
2257
+ const d = Math.floor((Date.parse(iso) - Date.now()) / 86400000);
2258
+ let style = 'color:var(--text)', note = '';
2259
+ if (d < 0) { style = 'background:#fee2e2;color:#991b1b;padding:1px 7px;border-radius:10px;font-weight:600'; note = ' · expired'; }
2260
+ else if (d <= 30) { style = 'background:#fef3c7;color:#b45309;padding:1px 7px;border-radius:10px;font-weight:600'; note = ` · ${d}d`; }
2261
+ else if (d <= 90) { style = 'color:#b45309'; note = ` · ${d}d`; }
2262
+ return `<span style="${style}">${(typeof esc === 'function' ? esc(iso) : iso)}${note}</span>`;
2263
+ }
2264
+
2265
+ function computeProjectCost(projectId, displayCcy = getDisplayCurrency()) {
2266
+ const project = db.projects.find(p => p.id === projectId);
2267
+ if (!project) return null;
2268
+ const formulations = db.formulations.filter(f => f.projectId === projectId);
2269
+ const batches = db.batches.filter(b => formulations.some(f => f.id === b.formulationId));
2270
+ const batchSummaries = batches.map(b => ({ batch: b, summary: computeBatchCost(b, displayCcy) }))
2271
+ .filter(x => x.summary);
2272
+ const total = batchSummaries.reduce((s, x) => s + (x.summary.total || 0), 0);
2273
+ const counted = batchSummaries.filter(x => x.summary.total != null).length;
2274
+ return {
2275
+ project,
2276
+ currency: displayCcy,
2277
+ batchCount: batches.length,
2278
+ pricedBatchCount: counted,
2279
+ total,
2280
+ batchSummaries,
2281
+ };
2282
+ }
2283
+
2284
+ // ============================================================
2285
+ // PRICE CHANGE / ALERTS — uses ing.cost.history (set on save).
2286
+ // Compares the current cost to the OLDEST history entry by default;
2287
+ // returns percent change in the cost's own currency, normalized to
2288
+ // $/kg so different units don't distort the comparison.
2289
+ // ============================================================
2290
+ function ingredientCostChangePct(ing) {
2291
+ if (!ing?.cost?.history?.length) return null;
2292
+ const cur = costPerKgInOwnCurrency(ing.cost, ing.density, ing.densityUnit);
2293
+ // Walk back to oldest entry that has a comparable per-kg value
2294
+ for (let i = 0; i < ing.cost.history.length; i++) {
2295
+ const old = ing.cost.history[i];
2296
+ const oldPerKg = costPerKgInOwnCurrency(old, ing.density, ing.densityUnit);
2297
+ // Convert to same currency for fair comparison
2298
+ const oldInCurCcy = convertCurrencySync(oldPerKg, old.currency || 'USD', ing.cost.currency || 'USD');
2299
+ if (cur != null && oldInCurCcy && oldInCurCcy > 0) {
2300
+ return { pct: (cur - oldInCurCcy) / oldInCurCcy * 100, baseline: oldInCurCcy, current: cur, since: old.updatedAt };
2301
+ }
2302
+ }
2303
+ return null;
2304
+ }
2305
+
2306
+ function getPriceAlerts(thresholdPct = 10) {
2307
+ const alerts = [];
2308
+ for (const ing of db.ingredients) {
2309
+ const change = ingredientCostChangePct(ing);
2310
+ if (change && Math.abs(change.pct) >= thresholdPct) {
2311
+ alerts.push({ ingredient: ing, ...change });
2312
+ }
2313
+ }
2314
+ alerts.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct));
2315
+ return alerts;
2316
+ }
2317
+
2318
+ // ============================================================
2319
+ // PHYSICAL-QUANTITY COST (samples / batches)
2320
+ // A sample (quantity) or batch (targetQuantity) is a physical amount of
2321
+ // a formula, so its cost = formula $/kg × quantity-in-kg. Mass units
2322
+ // convert directly; volume units need the formula's rolled-up density.
2323
+ // ============================================================
2324
+ // Convert a quantity between units for budget bookkeeping. Mass↔mass and
2325
+ // volume↔volume convert directly; mass↔volume needs the formula's rolled-up
2326
+ // density (kg/L). Returns the value in `toUnit`, or null when not possible
2327
+ // (count/"each" units, or cross-dimension with no known density).
2328
+ function convertQuantity(value, fromUnit, toUnit, formula) {
2329
+ const dens = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(formula) : null; // kg/L
2330
+ return convertQuantityAtDensity(value, fromUnit, toUnit, dens);
2331
+ }
2332
+
2333
+ // Same conversion with an EXPLICIT density (kg/L === g/mL). Use this for a
2334
+ // single ingredient row — 41.86 g of sucrose is 26.3 mL at sucrose's 1.59,
2335
+ // not 40 mL at the formula's 1.0465. Mass↔volume without a density → null.
2336
+ function convertQuantityAtDensity(value, fromUnit, toUnit, dens) {
2337
+ const v = parseFloat(value);
2338
+ if (!isFinite(v)) return null;
2339
+ const from = (fromUnit || '').trim(), to = (toUnit || '').trim();
2340
+ if (!to) return null;
2341
+ if (from === to) return v;
2342
+ const fM = (typeof isMassUnit === 'function') && isMassUnit(from);
2343
+ const tM = (typeof isMassUnit === 'function') && isMassUnit(to);
2344
+ const fV = (typeof isVolUnit === 'function') && isVolUnit(from);
2345
+ const tV = (typeof isVolUnit === 'function') && isVolUnit(to);
2346
+ if (fM && tM) { const a = massToKg(from), b = massToKg(to); return (a != null && b != null) ? v * a / b : null; }
2347
+ if (fV && tV) { const a = volToL(from), b = volToL(to); return (a != null && b != null) ? v * a / b : null; }
2348
+ if (dens == null || !isFinite(dens) || dens <= 0) return null;
2349
+ if (fM && tV) { const kg = v * massToKg(from); const L = kg / dens; const b = volToL(to); return (b != null) ? L / b : null; }
2350
+ if (fV && tM) { const L = v * volToL(from); const kg = L * dens; const b = massToKg(to); return (b != null) ? kg / b : null; }
2351
+ return null;
2352
+ }
2353
+
2354
+ // Density (kg/L) of ONE composition row: an ingredient's own density, or a
2355
+ // sub-formula's planning density. Keyed 'i:<id>' / 'f:<id>' like the
2356
+ // weigh-out rows, or pass a composition row object. null when unknown.
2357
+ function rowDensityKgPerL(keyOrRow) {
2358
+ let ingId = null, subId = null;
2359
+ if (typeof keyOrRow === 'string') {
2360
+ if (keyOrRow.slice(0, 2) === 'i:') ingId = keyOrRow.slice(2);
2361
+ else if (keyOrRow.slice(0, 2) === 'f:') subId = keyOrRow.slice(2);
2362
+ } else if (keyOrRow) { ingId = keyOrRow.ingredientId || null; subId = keyOrRow.formulationId || null; }
2363
+ const D = (typeof db !== 'undefined') ? db : { ingredients: [], formulations: [] };
2364
+ if (ingId) { const ing = (D.ingredients || []).find(i => i.id === ingId); return ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null; }
2365
+ if (subId) { const sf = (D.formulations || []).find(g => g.id === subId); return (sf && typeof _rolledUpDensity === 'function') ? _rolledUpDensity(sf) : null; }
2366
+ return null;
2367
+ }
2368
+
2369
+ // Classify whether `fromUnit` can be expressed as `toUnit`:
2370
+ // 'same' — identical units
2371
+ // 'ok' — same dimension, or cross-dimension with a known density
2372
+ // 'needs-density'— mass↔volume but the formula has no density (FIXABLE)
2373
+ // 'incompatible' — count/unknown units that no density can bridge
2374
+ // Lets callers show a light, actionable warning instead of a silent "—".
2375
+ function unitConvertibility(fromUnit, toUnit, formula) {
2376
+ const from = (fromUnit || '').trim(), to = (toUnit || '').trim();
2377
+ if (!from || !to || from === to) return 'same';
2378
+ const fM = (typeof isMassUnit === 'function') && isMassUnit(from);
2379
+ const tM = (typeof isMassUnit === 'function') && isMassUnit(to);
2380
+ const fV = (typeof isVolUnit === 'function') && isVolUnit(from);
2381
+ const tV = (typeof isVolUnit === 'function') && isVolUnit(to);
2382
+ if ((fM && tM) || (fV && tV)) return 'ok';
2383
+ if ((fM && tV) || (fV && tM)) {
2384
+ const dens = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(formula) : null;
2385
+ return (dens != null && isFinite(dens) && dens > 0) ? 'ok' : 'needs-density';
2386
+ }
2387
+ return 'incompatible';
2388
+ }
2389
+
2390
+ function quantityToKg(qty, unit, formula, unitMassKg) {
2391
+ const q = parseFloat(qty);
2392
+ if (!isFinite(q) || q <= 0) return null;
2393
+ const u = unit || '';
2394
+ if (typeof isMassUnit === 'function' && isMassUnit(u)) {
2395
+ const f = massToKg(u); return (f != null) ? q * f : null;
2396
+ }
2397
+ if (typeof isVolUnit === 'function' && isVolUnit(u)) {
2398
+ const litres = volToL(u); if (litres == null) return null;
2399
+ const dens = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(formula) : null; // kg/L
2400
+ if (dens == null || !isFinite(dens)) return null;
2401
+ return q * litres * dens;
2402
+ }
2403
+ // Count unit (tablet / capsule / unit / piece …) with a known per-unit mass:
2404
+ // total mass = count × mass-per-unit. Lets dose-form batches have a cost/yield.
2405
+ if (unitMassKg != null && isFinite(unitMassKg) && unitMassKg > 0) return q * unitMassKg;
2406
+ return null;
2407
+ }
2408
+
2409
+ // Returns { perKg, kg, computed, ccy, convertible } in the display currency.
2410
+ function quantityCost(qty, unit, formula, displayCcy) {
2411
+ const ccy = displayCcy ||
2412
+ (typeof getDisplayCurrency === 'function' ? getDisplayCurrency() : null) ||
2413
+ (formula && formula.costCurrency) || 'USD';
2414
+ let perKg = null;
2415
+ try {
2416
+ const c = formula && typeof computeFormulationCost === 'function' ? computeFormulationCost(formula, ccy) : null;
2417
+ if (c && isFinite(c.perKg)) perKg = c.perKg;
2418
+ } catch (_) { perKg = null; }
2419
+ const kg = quantityToKg(qty, unit, formula);
2420
+ const computed = (perKg != null && kg != null) ? perKg * kg : null;
2421
+ return { perKg, kg, computed, ccy, convertible: kg != null };
2422
+ }
2423
+
2424
+ // Open the global Display-currency setting — Settings modal → Preferences tab
2425
+ // (where the selector actually lives) — and scroll it into view with a brief
2426
+ // highlight. The old link went to navigate('data') (Import / Export), which has
2427
+ // NO currency selector, so the link went nowhere useful.
2428
+ function goToDisplayCurrencySetting() {
2429
+ if (typeof openSettingsModal === 'function') openSettingsModal('prefs');
2430
+ else if (typeof navigate === 'function') navigate('data'); // fallback if the modal helper is unavailable
2431
+ const focus = () => {
2432
+ const el = document.getElementById('display-currency-card');
2433
+ if (!el) return;
2434
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
2435
+ el.style.transition = 'box-shadow .35s ease';
2436
+ el.style.boxShadow = '0 0 0 3px rgba(37,99,235,0.35)';
2437
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
2438
+ };
2439
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focus);
2440
+ else setTimeout(focus, 50);
2441
+ }
2442
+
2443
+ // Effective cost for a batch/sample: the manual override when set, else the
2444
+ // live computed cost — plus the drift between them. Mirrors entityCostValueHtml's
2445
+ // override resolution (same currency conversion) so the list Cost column and the
2446
+ // detail Cost field always agree. Returns:
2447
+ // { total, computed, currency, overridden, note, driftPct }
2448
+ // total is null only when there's neither an override nor a computable cost.
2449
+ function entityEffectiveCost(qty, unit, formula, override, overrideCcy, note) {
2450
+ const { computed, ccy } = quantityCost(qty, unit, formula);
2451
+ const ovRaw = (override != null && override !== '' && isFinite(parseFloat(override))) ? parseFloat(override) : null;
2452
+ const ovCcy = overrideCcy || ccy;
2453
+ let ov = ovRaw;
2454
+ if (ovRaw != null && ovCcy !== ccy && typeof convertCurrencySync === 'function') {
2455
+ const conv = convertCurrencySync(ovRaw, ovCcy, ccy);
2456
+ if (isFinite(conv)) ov = conv;
2457
+ }
2458
+ const overridden = ov != null;
2459
+ return {
2460
+ total: overridden ? ov : computed,
2461
+ computed,
2462
+ currency: ccy,
2463
+ overridden,
2464
+ note: (note != null && String(note).trim() !== '') ? String(note).trim() : '',
2465
+ driftPct: (overridden && computed != null && computed > 0) ? (ov - computed) / computed * 100 : null,
2466
+ };
2467
+ }
2468
+
2469
+ // Amber-flag threshold: a manual override this far (%) from the live computed
2470
+ // cost is surfaced as "stale" in the compact list cell.
2471
+ const COST_DRIFT_PCT = 10;
2472
+
2473
+ // Compact Cost-cell HTML for LIST views (batch/sample tables + cards). Plain
2474
+ // formatted total for a computed cost; a superscript "ᴹ" marker when it's a
2475
+ // manual override — amber when the override has drifted >= COST_DRIFT_PCT from
2476
+ // computed — with the computed value + drift + note in the tooltip. "—" when
2477
+ // unpriceable. `label` names the value in the non-override tooltip.
2478
+ function entityCostCellHtml(summary, label) {
2479
+ const lbl = label || 'Cost';
2480
+ if (!summary || summary.total == null) {
2481
+ return `<span title="${esc(lbl)}, not priced (no ingredient prices, or a volume amount with no density)" style="color:#94a3b8">—</span>`;
2482
+ }
2483
+ const money = v => (typeof formatMoney === 'function') ? formatMoney(v, summary.currency) : (summary.currency + ' ' + (+v).toFixed(2));
2484
+ const amt = money(summary.total);
2485
+ if (!summary.overridden) return `<span title="${esc(lbl)} (live ingredient prices)">${amt}</span>`;
2486
+ const drift = summary.driftPct;
2487
+ const drifted = drift != null && Math.abs(drift) >= COST_DRIFT_PCT;
2488
+ const compStr = summary.computed != null ? money(summary.computed) : null;
2489
+ const dStr = (drift != null) ? ` (${drift > 0 ? '+' : ''}${drift.toFixed(0)}%)` : '';
2490
+ const tip = 'Manual override'
2491
+ + (compStr ? ` · computed ${compStr}${dStr}` : '')
2492
+ + (drifted ? ' · drifted from live cost' : '')
2493
+ + (summary.note ? ` · “${summary.note}”` : '');
2494
+ const mark = drifted
2495
+ ? ` <span style="color:#b45309;font-size:11px" aria-hidden="true">⚠</span>`
2496
+ : `<sup style="color:#2563eb;font-size:9px;font-weight:700;margin-left:1px" aria-hidden="true">M</sup>`;
2497
+ return `<span title="${esc(tip)}">${amt}${mark}</span>`;
2498
+ }
2499
+
2500
+ // Value-cell HTML for a sample/batch "Cost" detail field. Shows the
2501
+ // computed total cost (with breakdown), or — if a manual override is
2502
+ // set — the override + a divergence warning vs the live computed value.
2503
+ // o = { kind:'sample'|'batch', id, qty, unit, formula, override }.
2504
+ function entityCostValueHtml(o) {
2505
+ const { perKg, computed, ccy, convertible } = quantityCost(o.qty, o.unit, o.formula);
2506
+ const money = v => (typeof formatMoney === 'function') ? formatMoney(v, ccy) : (ccy + ' ' + (+v).toFixed(2));
2507
+ const ovRaw = (o.override != null && o.override !== '' && isFinite(parseFloat(o.override))) ? parseFloat(o.override) : null;
2508
+ // The override was entered in o.overrideCcy (legacy values w/o a stamp are
2509
+ // assumed to be in the current display currency). Convert it INTO the
2510
+ // display currency so both the shown value and the divergence vs computed
2511
+ // are apples-to-apples regardless of the active display currency.
2512
+ const ovCcy = o.overrideCcy || ccy;
2513
+ const ov = (ovRaw != null && ovCcy !== ccy && typeof convertCurrencySync === 'function')
2514
+ ? convertCurrencySync(ovRaw, ovCcy, ccy) : ovRaw;
2515
+ const note = (o.note != null && String(o.note).trim() !== '') ? String(o.note).trim() : '';
2516
+ const editBtn = `<a onclick="openCostOverrideModal('${o.kind}','${o.id}')" style="color:var(--primary);cursor:pointer;font-size:11px;margin-left:8px">${ov != null ? 'Edit' : 'Override'}</a>`;
2517
+ let chipTitle = note ? 'Reason: ' + note : 'Manually overridden cost';
2518
+ if (ovRaw != null && ovCcy !== ccy) chipTitle += ` · entered as ${formatMoney(ovRaw, ovCcy)}`;
2519
+ const manualChip = `<span title="${esc(chipTitle)}" style="font-size:10.5px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e40af;padding:1px 6px;border-radius:999px;margin-left:6px">manual</span>`;
2520
+
2521
+ // Currency source hint: viewer's own display preference always wins.
2522
+ // Only fall back to "from formula" if the user has no display preference set.
2523
+ const fromFormula = !!(o.formula && o.formula.costCurrency && !getDisplayCurrency());
2524
+ const ccyLine = `<div style="font-size:10.5px;color:#94a3b8;margin-top:2px">${esc(ccy)} · <a onclick="event.stopPropagation();${fromFormula ? `navigate('formulation-detail','${o.formula.id}')` : `goToDisplayCurrencySetting()`}" title="${fromFormula ? esc('Currency inherited from the formula; click to change it there') : esc('Global display currency; click to change in Settings → Preferences')}" style="color:var(--primary);cursor:pointer">${fromFormula ? 'from formula' : 'display currency'}</a></div>`;
2525
+
2526
+ if (ov != null) {
2527
+ let ref = '';
2528
+ if (computed != null) {
2529
+ if (computed > 0) {
2530
+ const pct = (ov - computed) / computed * 100;
2531
+ if (Math.abs(pct) >= 1) {
2532
+ ref = `<span title="Manual override differs from the live computed cost; recheck if the recipe or ingredient prices changed" style="margin-left:8px;font-size:10.5px;background:#fef3c7;border:1px solid #fcd34d;color:#92400e;padding:1px 6px;border-radius:999px"><span class="fl-warn" aria-hidden="true"></span> computed ${money(computed)} (Δ ${pct > 0 ? '+' : ''}${pct.toFixed(0)}%)</span>`;
2533
+ }
2534
+ }
2535
+ if (!ref) ref = `<span style="font-size:11px;color:var(--text-muted);margin-left:8px">computed ${money(computed)}</span>`;
2536
+ }
2537
+ const noteLine = note ? `<div style="font-size:11px;color:var(--text-muted);font-style:italic;margin-top:3px">“${esc(note)}”</div>` : '';
2538
+ return `<strong>${money(ov)}</strong>${manualChip}${ref}${editBtn}${ccyLine}${noteLine}`;
2539
+ }
2540
+ if (perKg == null) {
2541
+ // No formula is not "no ingredient prices" (35m): say whether it's in Trash.
2542
+ if (!o.formula) {
2543
+ const rec = ((typeof db !== 'undefined' && db && db[o.kind === 'batch' ? 'batches' : 'samples']) || []).find(x => x && x.id === o.id);
2544
+ const fid = rec && rec.formulationId;
2545
+ const why = !fid ? 'no formula' : (typeof flTrashEntryFor === 'function' && flTrashEntryFor('formulation', fid)) ? 'formula is in Trash' : 'formula deleted';
2546
+ return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:var(--text-muted)">${why}</span>${editBtn}`;
2547
+ }
2548
+ return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:var(--text-muted)">no ingredient prices</span>${editBtn}`;
2549
+ }
2550
+ if (!convertible) {
2551
+ // Light, actionable warning: mass↔volume just needs a density (fixable);
2552
+ // count/"each" units genuinely can't be priced by mass.
2553
+ const why = (typeof unitConvertibility === 'function') ? unitConvertibility(o.unit, 'kg', o.formula) : 'incompatible';
2554
+ const msg = (why === 'needs-density')
2555
+ ? `⚠ add a density to the formula to price ${esc(o.unit || 'this unit')} by mass`
2556
+ : `can't convert ${esc(o.unit || 'this unit')} to mass`;
2557
+ const col = (why === 'needs-density') ? '#b45309' : 'var(--text-muted)';
2558
+ return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:${col}">${msg}</span>${editBtn}`;
2559
+ }
2560
+ const breakdown = `<span style="font-size:11px;color:var(--text-muted);margin-left:6px">${esc(String(o.qty))} ${esc(o.unit || '')} × ${money(perKg)}/kg</span>`;
2561
+ return `<strong>${money(computed)}</strong>${breakdown}${editBtn}${ccyLine}`;
2562
+ }
2563
+
2564
+ function openCostOverrideModal(kind, id) {
2565
+ const ent = (kind === 'sample' ? db.samples : db.batches).find(x => x.id === id);
2566
+ if (!ent) return;
2567
+ const qty = (kind === 'sample') ? ent.quantity : ent.targetQuantity;
2568
+ const formula = db.formulations.find(f => f.id === ent.formulationId);
2569
+ const { computed, ccy } = quantityCost(qty, ent.unit, formula);
2570
+ // Prefill the input in the CURRENT display currency, converting a value
2571
+ // that was stored in a different currency, so editing is always in `ccy`.
2572
+ let cur = '';
2573
+ if (ent.costOverride != null && ent.costOverride !== '' && isFinite(parseFloat(ent.costOverride))) {
2574
+ const stored = parseFloat(ent.costOverride);
2575
+ const sCcy = ent.costOverrideCcy || ccy;
2576
+ cur = (sCcy !== ccy && typeof convertCurrencySync === 'function')
2577
+ ? +convertCurrencySync(stored, sCcy, ccy).toPrecision(6) : stored;
2578
+ }
2579
+ const curNote = (ent.costOverrideNote != null) ? ent.costOverrideNote : '';
2580
+ const compStr = (computed != null) ? formatMoney(computed, ccy) : '—';
2581
+ const body = `
2582
+ <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px;line-height:1.5">
2583
+ Computed cost is <strong>${compStr}</strong>. Enter a manual override to record the actual cost — it'll be flagged on the detail page whenever it diverges from the live computed value.
2584
+ </p>
2585
+ <label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">Override (${esc(ccy)})</label>
2586
+ <input id="cost-ov-input" type="number" step="any" value="${esc(String(cur))}" placeholder="${computed != null ? (+computed).toPrecision(4) : ''}" style="width:100%;padding:7px 9px;border:1px solid var(--border);border-radius:6px;font-size:14px" autofocus />
2587
+ <label style="font-size:12px;font-weight:600;display:block;margin:12px 0 4px">Reason <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
2588
+ <input id="cost-ov-note" type="text" value="${esc(String(curNote))}" placeholder="e.g. actual lot price incl. freight &amp; MOQ" style="width:100%;padding:7px 9px;border:1px solid var(--border);border-radius:6px;font-size:13px" />
2589
+ <p style="font-size:11.5px;color:var(--text-muted);margin-top:8px">Leave the override blank and save to clear it (and its reason) and fall back to the computed cost.</p>`;
2590
+ showModal(`${kind === 'sample' ? 'Sample' : 'Batch'} cost override`, body, () => _saveCostOverride(kind, id));
2591
+ }
2592
+
2593
+ function _saveCostOverride(kind, id) {
2594
+ const ent = (kind === 'sample' ? db.samples : db.batches).find(x => x.id === id);
2595
+ if (!ent) return;
2596
+ const el = document.getElementById('cost-ov-input');
2597
+ const noteEl = document.getElementById('cost-ov-note');
2598
+ const raw = el ? String(el.value).trim() : '';
2599
+ const note = noteEl ? String(noteEl.value).trim() : '';
2600
+ // The modal entered the value in the current display currency — stamp it so
2601
+ // the override survives a later display-currency change.
2602
+ const qty = (kind === 'sample') ? ent.quantity : ent.targetQuantity;
2603
+ const formula = db.formulations.find(f => f.id === ent.formulationId);
2604
+ const ccy = quantityCost(qty, ent.unit, formula).ccy;
2605
+ if (raw === '' || !isFinite(parseFloat(raw))) {
2606
+ delete ent.costOverride;
2607
+ delete ent.costOverrideNote; // clearing the override clears its reason
2608
+ delete ent.costOverrideCcy;
2609
+ } else {
2610
+ ent.costOverride = parseFloat(raw);
2611
+ ent.costOverrideCcy = ccy;
2612
+ if (note) ent.costOverrideNote = note; else delete ent.costOverrideNote;
2613
+ }
2614
+ ent.updatedAt = (typeof now === 'function') ? now() : new Date().toISOString();
2615
+ saveDB();
2616
+ if (typeof _proHook === 'function') _proHook(kind, 'update', ent); // kind = 'sample' | 'batch'
2617
+ if (kind === 'sample' && typeof renderSampleDetail === 'function') renderSampleDetail(id);
2618
+ else if (kind === 'batch' && typeof renderBatchDetail === 'function') renderBatchDetail(id);
2619
+ }