formlab-mcp 0.6.53 → 0.6.55

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/README.md CHANGED
@@ -58,8 +58,8 @@ Ask Claude (or another MCP client) things like:
58
58
  | `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
59
59
  | `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
60
60
  | `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES; peptides that only store a sequence are searched with the structure derived from it, as the app draws them (`smilesDerived: true`). Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
61
- | `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
62
- | `get_equipment` | Full equipment record + `usedIn` (panels, step presets, test methods, formula steps, batches, testReports resolved to it), `usage` (reports / formulas / batches / samples / pass rate / top formulas) and `measured` (per parameter: mean here vs mean on other instruments, biasPct) |
61
+ | `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…), with each tracked instrument's `calibration` (state ok / soon / overdue / failed / never, next due, last calibrated, interval). Filters: `category`, `status`, `manufacturer`, `name_contains`, `calibration` (`due` = overdue + failed + never + due within 30 days, most urgent first) |
62
+ | `get_equipment` | Full equipment record + its `calibration` and `calibrationHistory` (date, by, result, next due, certificate, notes) + `usedIn` (panels, step presets, test methods, formula steps, batches, testReports resolved to it), `usage` (reports / formulas / batches / samples / pass rate / top formulas) and `measured` (per parameter: mean here vs mean on other instruments, biasPct) |
63
63
  | `list_test_methods` | Filtered list of Test Methods (the parameter library). Filter by `analyte` (the property measured, e.g. `Viscosity` — finds every method for it), `category`, `name_contains`, `include_retired` |
64
64
  | `get_test_method` | One Test Method: full definition, run conditions, analyte siblings, using panels, plus `usage`, `valueSummary` (mean / sd / min / max) and `byInstrument` |
65
65
  | `list_step_presets` | Filtered list of Step Presets (reusable procedure steps: duration / temp / RPM / equipment). Filters: `industry`, `name_contains` |
package/calibration.js ADDED
@@ -0,0 +1,30 @@
1
+ // =====================================================================
2
+ // Equipment calibration state for the MCP server: the app's own rule, not a
3
+ // port. mcp/vendor/equipment-calibration.js is an exact copy of
4
+ // js/equipment-calibration.js (test/equipment-calibration.test.js keeps them
5
+ // identical), so get_equipment / list_equipment report the same OK / due soon /
6
+ // overdue / failed / never the Equipment page shows. Only its pure status
7
+ // functions run, in a prototype-less node:vm with no host objects.
8
+ // =====================================================================
9
+ import { readFileSync } from 'node:fs';
10
+ import vm from 'node:vm';
11
+
12
+ let _ctx = null;
13
+ function ctx() {
14
+ if (_ctx) return _ctx;
15
+ const src = readFileSync(new URL('./vendor/equipment-calibration.js', import.meta.url), 'utf8');
16
+ const sandbox = Object.create(null);
17
+ vm.createContext(sandbox);
18
+ vm.runInContext(src + `
19
+ ;function __calStatus(json, today) { return JSON.stringify(eqCalStatus(JSON.parse(json), today)); }
20
+ ;function __calHistory(json) { return JSON.stringify(eqCalHistory(JSON.parse(json))); }`, sandbox, { timeout: 1000 });
21
+ _ctx = sandbox;
22
+ return _ctx;
23
+ }
24
+ const _today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
25
+ const _slim = (e) => JSON.stringify({ status: e.status || '', calIntervalMonths: e.calIntervalMonths || null, calibrations: Array.isArray(e.calibrations) ? e.calibrations : [] });
26
+
27
+ // { state, due, last, lastResult, interval, daysToDue, count } — state: ok | soon | overdue | failed | never | none
28
+ export function calibrationStatus(e, today) { return JSON.parse(ctx().__calStatus(_slim(e), today || _today())); }
29
+ // Newest first.
30
+ export function calibrationHistory(e) { return JSON.parse(ctx().__calHistory(_slim(e))); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.53",
3
+ "version": "0.6.55",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
4
4
  "description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
5
- "version": "0.6.53",
5
+ "version": "0.6.55",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
7
  "repository": {
8
8
  "url": "https://github.com/juliu1980/FormLab",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "formlab-mcp",
16
- "version": "0.6.53",
16
+ "version": "0.6.55",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -1279,6 +1279,16 @@ function _iChart(ys) {
1279
1279
 
1280
1280
  // Shelf life: fit vs elapsed months, extrapolate to the spec limit the trend
1281
1281
  // heads toward. Point-estimate crossing + the conservative 95% CI-bound crossing.
1282
+ // One-sided 95% Student t for df = n − 2 (ICH Q1E) — a copy of the app's _stabT95
1283
+ // (js/views/stability.js); test/stability-ich-t.test.js holds the two equal.
1284
+ const _T95_TABLE = [null, 6.314, 2.920, 2.353, 2.132, 2.015, 1.943, 1.895, 1.860, 1.833, 1.812, 1.796, 1.782, 1.771, 1.761, 1.753, 1.746, 1.740, 1.734, 1.729, 1.725, 1.721, 1.717, 1.714, 1.711, 1.708, 1.706, 1.703, 1.701, 1.699, 1.697];
1285
+ function _t95(df) {
1286
+ df = Math.floor(df);
1287
+ if (!(df >= 1)) return Infinity;
1288
+ if (df < _T95_TABLE.length) return _T95_TABLE[df];
1289
+ const z = 1.6448536;
1290
+ return z + (z ** 3 + z) / (4 * df) + (5 * z ** 5 + 16 * z ** 3 + 3 * z) / (96 * df * df);
1291
+ }
1282
1292
  function _shelfLife(points, spec) {
1283
1293
  if (!points || points.length < 3 || (spec.min == null && spec.max == null)) return null;
1284
1294
  const t0 = Math.min(...points.map(p => p.date.getTime()));
@@ -1295,7 +1305,7 @@ function _shelfLife(points, spec) {
1295
1305
  const ssX = xs.reduce((a, b) => a + (b - mx) ** 2, 0);
1296
1306
  let ssR = 0; xs.forEach((x, i) => { const pr = fit.intercept + fit.slope * x; ssR += (ys[i] - pr) ** 2; });
1297
1307
  const sResid = Math.sqrt(ssR / Math.max(1, n - 2));
1298
- const tcrit = n > 30 ? 1.96 : n > 10 ? 2.1 : 2.5;
1308
+ const tcrit = _t95(n - 2); // ICH Q1E one-sided 95% (app 22t, js/views/stability.js _stabT95)
1299
1309
  const sign = fit.slope < 0 ? -1 : 1;
1300
1310
  const bound = (tt) => fit.intercept + fit.slope * tt + sign * tcrit * sResid * Math.sqrt(1 / n + ((tt - mx) ** 2) / Math.max(1e-9, ssX));
1301
1311
  let ciRemaining = null;
package/tools/library.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // ============================================================
13
13
 
14
14
  import { getStore, resolveById } from '../data.js';
15
+ import { calibrationStatus, calibrationHistory } from '../calibration.js';
15
16
  import { _evalPassFail, _mcpRepNumber, _mcpMeasType, _resolveSpec, _mcpCondSig, _mcpCondLabel, _mcpCondTags, _mcpParamGroupsOf } from './analytics.js';
16
17
 
17
18
  // ── Usage roll-ups (mirror the Usage tabs on the Panel / Method / Equipment
@@ -91,6 +92,11 @@ function _trimEquipment(e) {
91
92
  manufacturer: e.manufacturer || '', location: e.location || '',
92
93
  owner: e.owner || '', assetTag: e.assetTag || '',
93
94
  ...(e.notes ? { notes: e.notes } : {}),
95
+ // Calibration (app 22p, the app's own rule via mcp/calibration.js): omitted when not tracked.
96
+ ...(() => {
97
+ const st = calibrationStatus(e);
98
+ return st.state === 'none' ? {} : { calibration: { state: st.state, nextDue: st.due || null, daysToDue: st.daysToDue, lastCalibrated: st.last || null, lastResult: st.lastResult || null, intervalMonths: st.interval || null } };
99
+ })(),
94
100
  };
95
101
  }
96
102
 
@@ -126,7 +132,7 @@ function _equipUsage(e, db) {
126
132
  const list_equipment = {
127
133
  definition: {
128
134
  name: 'list_equipment',
129
- description: 'List equipment / instruments in the FormLab Equipment registry (mixers, ovens, viscometers, balances…), optionally filtered by category, status, manufacturer, or name. Returns metadata; use get_equipment for one record plus where it is used.',
135
+ description: 'List equipment / instruments in the FormLab Equipment registry (mixers, ovens, viscometers, balances…), optionally filtered by category, status, manufacturer, name, or calibration state. Returns metadata, plus `calibration` (state ok | soon | overdue | failed | never, nextDue, lastCalibrated, intervalMonths) on tracked instruments; for "which instruments are overdue / due for calibration?" pass calibration: "due" (most urgent first). Use get_equipment for one record, its calibration history and where it is used.',
130
136
  inputSchema: {
131
137
  type: 'object',
132
138
  properties: {
@@ -134,6 +140,7 @@ const list_equipment = {
134
140
  status: { type: 'string', description: 'Filter by status (e.g. "In service", "Retired"). Case-insensitive substring.' },
135
141
  manufacturer: { type: 'string', description: 'Filter by manufacturer. Case-insensitive substring.' },
136
142
  name_contains: { type: 'string', description: 'Case-insensitive substring on equipment name / model / serial / asset tag.' },
143
+ calibration: { type: 'string', enum: ['due', 'overdue', 'soon', 'failed', 'never', 'ok', 'tracked'], description: 'Filter by calibration state: due = overdue + failed + never calibrated + due within 30 days; tracked = any instrument with an interval or a calibration logged. Rows then sort most urgent first.' },
137
144
  limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
138
145
  },
139
146
  },
@@ -151,8 +158,18 @@ const list_equipment = {
151
158
  const hay = [e.name, e.model, e.serial, e.assetTag].map(_norm).join(' ');
152
159
  if (!hay.includes(q)) return false;
153
160
  }
161
+ if (args.calibration) {
162
+ const st = calibrationStatus(e).state;
163
+ const want = args.calibration;
164
+ if (want === 'due' ? !['overdue', 'failed', 'never', 'soon'].includes(st) : want === 'tracked' ? st === 'none' : st !== want) return false;
165
+ }
154
166
  return true;
155
167
  });
168
+ if (args.calibration) {
169
+ const rank = { failed: 0, overdue: 1, never: 2, soon: 3, ok: 4, none: 9 };
170
+ const key = (e) => { const st = calibrationStatus(e); return [rank[st.state], st.due || '9999']; };
171
+ filtered.sort((a, b) => { const x = key(a), y = key(b); return (x[0] - y[0]) || x[1].localeCompare(y[1]); });
172
+ }
156
173
  return {
157
174
  totalMatching: filtered.length,
158
175
  returned: Math.min(filtered.length, limit),
@@ -164,7 +181,7 @@ const list_equipment = {
164
181
  const get_equipment = {
165
182
  definition: {
166
183
  name: 'get_equipment',
167
- description: 'Get one equipment record by UID (e.g. EQP-001) or id, including `usedIn` (everywhere its DEFINITION is referenced: test panels, step presets, test methods, formula steps, batches, plus testReports = reports with a measurement resolved to it), `usage` (those reports: totals, last used, distinct formulas/batches/samples, pass rate, top formulas) and `measured` (per parameter: readings and mean on this unit vs readings/mean on all OTHER instruments, with biasPct — the calibration question). Instrument resolution follows the app: named on the measurement → the Test Method\'s equipment → the report\'s run default.',
184
+ description: 'Get one equipment record by UID (e.g. EQP-001) or id, including `usedIn` (everywhere its DEFINITION is referenced: test panels, step presets, test methods, formula steps, batches, plus testReports = reports with a measurement resolved to it), `usage` (those reports: totals, last used, distinct formulas/batches/samples, pass rate, top formulas) and `measured` (per parameter: readings and mean on this unit vs readings/mean on all OTHER instruments, with biasPct — the calibration question). Instrument resolution follows the app: named on the measurement → the Test Method\'s equipment → the report\'s run default. `calibration` (state ok | soon (≤30 days) | overdue | failed | never, nextDue, lastCalibrated, intervalMonths) and `calibrationHistory` (newest first: date, by, result pass | adjusted | fail, nextDue, certificate, notes) when the instrument is tracked.',
168
185
  inputSchema: {
169
186
  type: 'object',
170
187
  properties: { id: { type: 'string', description: 'Equipment UID or internal id.' } },
@@ -207,7 +224,8 @@ const get_equipment = {
207
224
  });
208
225
  const usedIn = _equipUsage(e, db);
209
226
  usedIn.testReports = tests.length;
210
- return { ...(_trimEquipment(e)), usedIn, usage: _usageRollup(db, tests, mine), measured };
227
+ const calHistory = calibrationHistory(e).map(c => ({ date: c.date || null, by: c.by || '', result: c.result || '', nextDue: c.nextDue || null, ...(c.certificateRef ? { certificate: c.certificateRef } : {}), ...(c.certificateId ? { certificateFile: ((e.fileAttachments || []).find(a => a && a.id === c.certificateId) || {}).name || c.certificateId } : {}), ...(c.notes ? { notes: c.notes } : {}) }));
228
+ return { ...(_trimEquipment(e)), ...(calHistory.length ? { calibrationHistory: calHistory } : {}), usedIn, usage: _usageRollup(db, tests, mine), measured };
211
229
  },
212
230
  };
213
231
 
package/vendor/README.md CHANGED
@@ -9,10 +9,11 @@ reach files outside `mcp/`):
9
9
  | `cost.js` | `js/cost.js` |
10
10
  | `nesting.js` | `js/nesting.js` |
11
11
  | `theoretical-params.js` | `js/views/theoretical-params.js` |
12
+ | `equipment-calibration.js` | `js/equipment-calibration.js` (calibration due state, `mcp/calibration.js`) |
12
13
 
13
14
  Edit the source, then copy it over:
14
15
 
15
- cp js/cost.js js/nesting.js js/views/theoretical-params.js mcp/vendor/
16
+ cp js/cost.js js/nesting.js js/views/theoretical-params.js js/equipment-calibration.js mcp/vendor/
16
17
 
17
18
  The pre-commit hook (`scripts/hooks/pre-commit`) refreshes them when a commit touches a source; `test/mcp-theoretical.test.js` fails while a copy differs. `mcp/theoretical.js`
18
19
  runs them in a `node:vm` context holding only a JSON copy of the data.
@@ -0,0 +1,268 @@
1
+ // ============================================================
2
+ // EQUIPMENT CALIBRATION (Pro: equipment.calibration, build 22p)
3
+ // ------------------------------------------------------------
4
+ // Capture + due state for each instrument: how often it is calibrated
5
+ // (calIntervalMonths), a history of calibrations (calibrations[]: date, by,
6
+ // result, next due, certificate, notes), and what follows from them — OK /
7
+ // due soon / overdue / failed — on the equipment page, the Equipment list,
8
+ // Needs Attention, smart search, the AI and MCP.
9
+ //
10
+ // ENFORCEMENT is not here: flagging or blocking data recorded on an overdue
11
+ // or out-of-service instrument, and qualification records with review, are the
12
+ // Team slice (equipment.calibrationOps, features.js).
13
+ //
14
+ // Both fields ride the equipment record (the full record syncs as JSONB, so no
15
+ // migration). Free keeps the data and shows it read-only, as Regulatory does.
16
+ // ============================================================
17
+
18
+ const EQ_CAL_SOON_DAYS = 30;
19
+ const EQ_CAL_RESULTS = [
20
+ { value: 'pass', label: 'Pass', hint: 'within tolerance as found' },
21
+ { value: 'adjusted', label: 'Adjusted, then pass', hint: 'out of tolerance as found, adjusted and verified' },
22
+ { value: 'fail', label: 'Fail', hint: 'out of tolerance; take it out of service' },
23
+ ];
24
+ const _EQ_CAL_RESULT_LABEL = Object.fromEntries(EQ_CAL_RESULTS.map(r => [r.value, r.label]));
25
+
26
+ // Today in LOCAL time (flTodayIso when utils.js is loaded; the MCP copy passes its own).
27
+ function _eqCalToday() {
28
+ if (typeof flTodayIso === 'function') return flTodayIso();
29
+ const d = new Date();
30
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
31
+ }
32
+ function eqCalOn() { return typeof hasFeature !== 'function' || hasFeature('equipment.calibration'); }
33
+
34
+ // Newest first; entries without a date last.
35
+ function eqCalHistory(e) {
36
+ const list = (e && Array.isArray(e.calibrations)) ? e.calibrations.filter(Boolean) : [];
37
+ return list.slice().sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
38
+ }
39
+
40
+ // Calendar months, clamped to the month's last day (Jan 31 + 1 mo = Feb 28/29), as
41
+ // flDateAddIso (utils.js) — kept here so the MCP server's vendored copy needs nothing else.
42
+ function _eqCalAddMonths(iso, months) {
43
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(iso || '')) || !(months > 0)) return '';
44
+ const [y, m, d] = String(iso).split('-').map(Number);
45
+ const first = new Date(Date.UTC(y, m - 1 + months, 1));
46
+ const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
47
+ return new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), Math.min(d, last))).toISOString().slice(0, 10);
48
+ }
49
+ function _eqCalDays(fromIso, toIso) {
50
+ const a = Date.parse(fromIso + 'T00:00:00Z'), b = Date.parse(toIso + 'T00:00:00Z');
51
+ return (isFinite(a) && isFinite(b)) ? Math.round((b - a) / 864e5) : null;
52
+ }
53
+
54
+ // The calibration state of one instrument, as of `todayIso` (default today).
55
+ // state: 'none' — not tracked (no interval, no history), or Retired
56
+ // 'never' — an interval is set but it has never been calibrated
57
+ // 'failed' — the latest calibration failed
58
+ // 'overdue' — past its due date
59
+ // 'soon' — due within EQ_CAL_SOON_DAYS
60
+ // 'ok'
61
+ // due: the next due date (the latest entry's own nextDue, else its date + the interval)
62
+ function eqCalStatus(e, todayIso) {
63
+ const today = todayIso || _eqCalToday();
64
+ const interval = Number(e && e.calIntervalMonths) || 0;
65
+ const hist = eqCalHistory(e);
66
+ const last = hist[0] || null;
67
+ const base = { state: 'none', due: '', last: last ? (last.date || '') : '', lastResult: last ? (last.result || '') : '', interval, daysToDue: null, count: hist.length };
68
+ if (!e || e.status === 'Retired') return base;
69
+ if (!last) return interval ? { ...base, state: 'never' } : base;
70
+ // A failed instrument has no next due date: it needs a passing calibration first.
71
+ if (last.result === 'fail') return { ...base, state: 'failed' };
72
+ const due = last.nextDue || _eqCalAddMonths(last.date, interval) || '';
73
+ const days = due ? _eqCalDays(today, due) : null;
74
+ if (!due) return { ...base, state: 'ok' };
75
+ const state = days < 0 ? 'overdue' : days <= EQ_CAL_SOON_DAYS ? 'soon' : 'ok';
76
+ return { ...base, state, due, daysToDue: days };
77
+ }
78
+
79
+ const _EQ_CAL_TONE = {
80
+ ok: 'background:var(--dk-green-bg, #dcfce7);color:var(--dk-green-fg, #166534)',
81
+ soon: 'background:var(--dk-amber-bg2, #fef3c7);color:var(--dk-amber-fg, #92400e)',
82
+ overdue: 'background:var(--dk-red-bg, #fee2e2);color:var(--dk-red-fg, #b91c1c)',
83
+ failed: 'background:var(--dk-red-bg, #fee2e2);color:var(--dk-red-fg, #b91c1c)',
84
+ never: 'background:var(--dk-amber-bg2, #fef3c7);color:var(--dk-amber-fg, #92400e)',
85
+ };
86
+ // One line: what the instrument's calibration says right now.
87
+ function eqCalStatusText(st) {
88
+ if (!st || st.state === 'none') return '';
89
+ const d = st.daysToDue;
90
+ if (st.state === 'never') return 'Never calibrated';
91
+ if (st.state === 'failed') return 'Failed calibration';
92
+ if (st.state === 'overdue') return `Overdue ${-d} d · due ${st.due}`;
93
+ if (st.state === 'soon') return d === 0 ? `Due today` : `Due in ${d} d · ${st.due}`;
94
+ return st.due ? `OK · due ${st.due}` : 'OK';
95
+ }
96
+ function eqCalChipHtml(st) {
97
+ if (!st || st.state === 'none') return '<span style="color:var(--dk-dim, #cbd5e1)">—</span>';
98
+ return `<span class="badge eq-cal-chip" style="${_EQ_CAL_TONE[st.state] || ''};font-size:11px" title="${esc(st.last ? `Last calibrated ${st.last}${st.lastResult ? ' (' + (_EQ_CAL_RESULT_LABEL[st.lastResult] || st.lastResult) + ')' : ''}` : 'No calibration logged yet')}">${esc(eqCalStatusText(st))}</span>`;
99
+ }
100
+ // Sort key for the list column: most urgent first when ascending.
101
+ function eqCalSortKey(e) {
102
+ const st = eqCalStatus(e);
103
+ const rank = { failed: 0, overdue: 1, never: 2, soon: 3, ok: 4, none: 9 }[st.state];
104
+ return `${rank}|${st.due || '9999'}`;
105
+ }
106
+
107
+ // ---- The Calibration card on the equipment page ----
108
+ function eqCalCardHtml(e) {
109
+ const on = eqCalOn();
110
+ const hist = eqCalHistory(e);
111
+ if (!on && !hist.length && !e.calIntervalMonths) {
112
+ return `<div class="card" style="margin-top:16px"><div class="card-header"><h2>Calibration</h2></div>
113
+ <div class="card-body" style="text-align:center;padding:20px 18px">
114
+ <div style="font-size:22px;line-height:1"><span class="fl-pro" aria-hidden="true"></span></div>
115
+ <div style="margin-top:8px;font-weight:700;font-size:14px;color:var(--dk-blue-fg, #2563eb)">Calibration tracking is a Pro feature</div>
116
+ <div style="margin:8px auto 14px;max-width:360px;font-size:12.5px;color:var(--text-muted);line-height:1.55">Set how often this instrument is calibrated, log each calibration with its certificate, and see what is due or overdue across the lab.</div>
117
+ <button class="btn btn-primary btn-sm" onclick="proUpsell('equipment.calibration')">What's in Pro?</button>
118
+ </div></div>`;
119
+ }
120
+ const st = eqCalStatus(e);
121
+ const interval = Number(e.calIntervalMonths) || 0;
122
+ const files = Array.isArray(e.fileAttachments) ? e.fileAttachments : [];
123
+ const certName = (id) => { const f = files.find(a => a && a.id === id); return f ? (f.name || f.title || f.url || 'Certificate') : ''; };
124
+ const editBtns = on ? `<div style="display:flex;gap:6px">
125
+ <button class="btn btn-secondary btn-sm" onclick="openEqCalIntervalModal('${e.id}')">${interval ? 'Every ' + interval + ' mo' : 'Set interval'}</button>
126
+ <button class="btn btn-primary btn-sm" onclick="openEqCalLogModal('${e.id}')">+ Log calibration</button>
127
+ </div>` : `<button class="btn btn-secondary btn-sm" onclick="proUpsell('equipment.calibration')"><span class="fl-pro" aria-hidden="true"></span> Pro</button>`;
128
+ const summary = st.state === 'none'
129
+ ? `<div style="color:var(--text-muted);font-size:13px">Not tracked yet. Set how often it is calibrated, or log its last calibration.</div>`
130
+ : `<div style="display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;font-size:13px">
131
+ ${eqCalChipHtml(st)}
132
+ <span><span style="color:var(--text-muted)">Last</span> <strong>${esc(st.last || '—')}</strong>${st.lastResult ? ` · ${esc(_EQ_CAL_RESULT_LABEL[st.lastResult] || st.lastResult)}` : ''}</span>
133
+ <span><span style="color:var(--text-muted)">Next due</span> <strong>${esc(st.due || '—')}</strong></span>
134
+ <span><span style="color:var(--text-muted)">Interval</span> <strong>${interval ? interval + ' month' + (interval === 1 ? '' : 's') : '—'}</strong></span>
135
+ </div>${st.state === 'failed' && e.status !== 'Out of service' ? `<div style="margin-top:10px;font-size:12.5px;color:var(--dk-red-fg, #b91c1c)">The latest calibration failed. Set the instrument <strong>Out of service</strong> (Edit) until it passes again.</div>` : ''}`;
136
+ const rows = hist.map(c => `<tr>
137
+ <td style="font-variant-numeric:tabular-nums">${esc(c.date || '—')}</td>
138
+ <td>${esc(c.by || '—')}</td>
139
+ <td>${c.result ? `<span class="badge" style="${_EQ_CAL_TONE[c.result === 'fail' ? 'failed' : c.result === 'adjusted' ? 'soon' : 'ok']};font-size:11px">${esc(_EQ_CAL_RESULT_LABEL[c.result] || c.result)}</span>` : '—'}</td>
140
+ <td style="font-variant-numeric:tabular-nums">${esc(c.nextDue || '—')}</td>
141
+ <td>${c.certificateId && certName(c.certificateId) ? `<a style="color:var(--primary);cursor:pointer" onclick="attachOpenInTab('${esc(c.certificateId)}')">${esc(certName(c.certificateId))}</a>` : (c.certificateRef ? esc(c.certificateRef) : '<span style="color:var(--dk-dim, #cbd5e1)">—</span>')}</td>
142
+ <td style="color:var(--text-muted);max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(c.notes || '')}">${esc(c.notes || '')}</td>
143
+ <td style="text-align:right;white-space:nowrap">${on ? `<button class="btn btn-secondary btn-sm" onclick="openEqCalLogModal('${e.id}','${esc(c.id)}')" title="Edit this calibration">${typeof ICON !== 'undefined' && ICON.edit ? ICON.edit : 'Edit'}</button>` : ''}</td>
144
+ </tr>`).join('');
145
+ const table = hist.length ? `<div class="table-wrap" style="margin-top:14px"><table class="eq-cal-table">
146
+ <thead><tr><th>Date</th><th>Performed by</th><th>Result</th><th>Next due</th><th>Certificate</th><th>Notes</th><th></th></tr></thead>
147
+ <tbody>${rows}</tbody></table></div>` : '';
148
+ const freeNote = on ? '' : `<div style="margin-top:10px;font-size:12px;color:var(--text-muted)">Read-only on Free: your calibration records are kept. Logging and due-date alerts are Pro.</div>`;
149
+ return `<div class="card" id="eq-cal-card" style="margin-top:16px">
150
+ <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap"><h2>Calibration</h2>${editBtns}</div>
151
+ <div class="card-body">${summary}${table}${freeNote}</div></div>`;
152
+ }
153
+
154
+ // ---- Interval ----
155
+ function openEqCalIntervalModal(eqId) {
156
+ if (!eqCalOn()) { proUpsell('equipment.calibration'); return; }
157
+ const e = typeof getEquipment === 'function' ? getEquipment(eqId) : (db.equipment || []).find(x => x.id === eqId);
158
+ if (!e) return;
159
+ showModal('Calibration interval', `
160
+ <div class="form-group"><label>Calibrate every</label>
161
+ <div style="display:flex;align-items:center;gap:8px"><input id="eq-cal-interval" type="number" min="0" step="1" style="width:110px" value="${esc(e.calIntervalMonths || '')}" placeholder="12"> <span>months</span></div>
162
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px">Sets the next due date from each calibration's date. Leave empty to stop tracking a due date (the history stays).</div>
163
+ </div>`, () => {
164
+ const raw = (document.getElementById('eq-cal-interval') || {}).value;
165
+ const n = raw === '' ? null : Math.round(Number(raw));
166
+ if (n != null && !(n >= 0)) { toast('Enter a number of months.'); return false; }
167
+ updateEquipment(eqId, { calIntervalMonths: n || null });
168
+ if (typeof renderEquipmentDetail === 'function') renderEquipmentDetail(eqId);
169
+ return true;
170
+ }, false, null, 'Save');
171
+ }
172
+
173
+ // ---- Log / edit a calibration ----
174
+ function _eqCalMe() {
175
+ try { const p = (typeof window !== 'undefined') ? window.__pro : null; const me = (p && p.getCurrentUser) ? p.getCurrentUser() : null; return me ? (me.displayName || me.email || '') : ''; } catch (_) { return ''; }
176
+ }
177
+ function openEqCalLogModal(eqId, calId) {
178
+ if (!eqCalOn()) { proUpsell('equipment.calibration'); return; }
179
+ const e = typeof getEquipment === 'function' ? getEquipment(eqId) : (db.equipment || []).find(x => x.id === eqId);
180
+ if (!e) return;
181
+ const c = calId ? (e.calibrations || []).find(x => x && x.id === calId) : null;
182
+ const interval = Number(e.calIntervalMonths) || 0;
183
+ const date = c ? (c.date || '') : _eqCalToday();
184
+ const files = Array.isArray(e.fileAttachments) ? e.fileAttachments : [];
185
+ const certOpts = `<option value="">— None —</option>` + files.map(a => `<option value="${esc(a.id)}"${c && c.certificateId === a.id ? ' selected' : ''}>${esc(a.name || a.title || a.url || a.id)}</option>`).join('');
186
+ const hint = (t) => `<div style="font-size:11.5px;color:var(--text-muted);margin-top:4px;line-height:1.4">${t}</div>`;
187
+ const body = `<div class="form-grid">
188
+ <div class="form-group"><label>Date calibrated *</label><input id="eq-cal-date" type="date" value="${esc(date)}" onchange="_eqCalAutoDue(${interval})"></div>
189
+ <div class="form-group"><label>Performed by</label><input id="eq-cal-by" value="${esc(c ? (c.by || '') : _eqCalMe())}" placeholder="Name, or the service provider" data-fl-person="equipment.calibrations[].by testResults.performedBy equipment.owner"></div>
190
+ <div class="form-group span-2"><label>Result *</label>
191
+ <select id="eq-cal-result">${EQ_CAL_RESULTS.map(r => `<option value="${r.value}"${(c ? c.result : 'pass') === r.value ? ' selected' : ''}>${esc(r.label)} — ${esc(r.hint)}</option>`).join('')}</select>
192
+ </div>
193
+ <div class="form-group"><label>Next due ${typeof flDateQuickHtml === 'function' ? flDateQuickHtml('eq-cal-due', { from: 'eq-cal-date', fromLabel: 'calibration date', add: [[6, 'm'], [1, 'y'], [2, 'y']] }) : ''}</label>
194
+ <input id="eq-cal-due" type="date" value="${esc(c ? (c.nextDue || '') : _eqCalAddMonths(date, interval))}" data-auto="${c && c.nextDue ? '0' : '1'}" oninput="this.dataset.auto='0'">
195
+ ${hint(interval ? `From the interval (every ${interval} month${interval === 1 ? '' : 's'}); change it for this one if needed.` : 'No interval set: enter it here, or set one on the Calibration card.')}
196
+ </div>
197
+ <div class="form-group"><label>Certificate</label>
198
+ ${files.length ? `<select id="eq-cal-cert">${certOpts}</select>` : `<input id="eq-cal-certref" value="${esc(c ? (c.certificateRef || '') : '')}" placeholder="Certificate no.">`}
199
+ ${hint(files.length ? 'One of this instrument\'s files. Upload more on its Attachments card.' : 'Or upload the certificate on this instrument\'s Attachments card to pick it here.')}
200
+ </div>
201
+ <div class="form-group span-2"><label>Notes</label><textarea id="eq-cal-notes" style="min-height:56px" placeholder="As-found / as-left readings, standards used, provider…">${esc(c ? (c.notes || '') : '')}</textarea></div>
202
+ </div>`;
203
+ showModal(c ? 'Edit calibration' : 'Log calibration', body, () => {
204
+ const v = (id) => ((document.getElementById(id) || {}).value || '').trim();
205
+ const rec = {
206
+ id: c ? c.id : (typeof uid === 'function' ? uid() : 'cal' + Date.now()),
207
+ date: v('eq-cal-date'), by: v('eq-cal-by'), result: v('eq-cal-result') || 'pass',
208
+ nextDue: v('eq-cal-due'), notes: v('eq-cal-notes'),
209
+ certificateId: v('eq-cal-cert') || undefined, certificateRef: v('eq-cal-certref') || undefined,
210
+ loggedAt: c ? (c.loggedAt || new Date().toISOString()) : new Date().toISOString(),
211
+ loggedBy: c ? (c.loggedBy || '') : _eqCalMe(),
212
+ };
213
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(rec.date)) { toast('Enter the date it was calibrated.'); return false; }
214
+ if (rec.nextDue && rec.nextDue < rec.date) { toast('Next due is before the calibration date.'); return false; }
215
+ Object.keys(rec).forEach(k => rec[k] === undefined && delete rec[k]);
216
+ const list = (e.calibrations || []).filter(x => x && x.id !== rec.id);
217
+ list.push(rec);
218
+ const patch = { calibrations: list };
219
+ updateEquipment(eqId, patch);
220
+ if (typeof renderEquipmentDetail === 'function' && typeof currentView !== 'undefined' && currentView === 'equipment-detail') renderEquipmentDetail(eqId);
221
+ toast(c ? 'Calibration updated.' : (rec.result === 'fail' ? 'Calibration logged as failed. Set the instrument Out of service until it passes.' : 'Calibration logged.'));
222
+ return true;
223
+ }, true, null, c ? 'Save' : 'Log calibration');
224
+ if (c) {
225
+ // Delete sits in the dialog's footer, left of the buttons.
226
+ setTimeout(() => {
227
+ const foot = document.querySelector('#modal-backdrop .modal-footer');
228
+ if (foot && !foot.querySelector('.eq-cal-del')) foot.insertAdjacentHTML('afterbegin', `<button class="btn btn-danger btn-sm eq-cal-del" style="margin-right:auto" onclick="deleteEqCal('${eqId}','${esc(c.id)}')">Delete</button>`);
229
+ }, 0);
230
+ }
231
+ }
232
+ // Keep "Next due" following the date while it hasn't been typed over.
233
+ function _eqCalAutoDue(interval) {
234
+ const due = document.getElementById('eq-cal-due'), d = document.getElementById('eq-cal-date');
235
+ if (!due || !d || due.dataset.auto !== '1' || !interval) return;
236
+ due.value = _eqCalAddMonths(d.value, interval);
237
+ }
238
+ function deleteEqCal(eqId, calId) {
239
+ const e = typeof getEquipment === 'function' ? getEquipment(eqId) : null;
240
+ if (!e) return;
241
+ const c = (e.calibrations || []).find(x => x && x.id === calId);
242
+ if (!c) return;
243
+ const go = () => {
244
+ updateEquipment(eqId, { calibrations: (e.calibrations || []).filter(x => x && x.id !== calId) });
245
+ if (typeof renderEquipmentDetail === 'function') renderEquipmentDetail(eqId);
246
+ toast('Calibration deleted.');
247
+ };
248
+ if (typeof closeModal === 'function') closeModal();
249
+ if (typeof confirmDataAction !== 'function') { go(); return; }
250
+ confirmDataAction({
251
+ title: 'Delete this calibration?',
252
+ message: `The calibration of <strong>${esc(c.date || 'this date')}</strong>${c.by ? ' by ' + esc(c.by) : ''} is removed from the history. The record's audit log keeps the change.`,
253
+ confirmLabel: 'Delete', danger: true, onConfirm: go,
254
+ });
255
+ }
256
+
257
+ // ---- Needs Attention (dashboard.js) ----
258
+ // Instruments failed, overdue, never calibrated (with an interval) or due within 30 days.
259
+ function eqCalAttentionRows(todayIso) {
260
+ if (!eqCalOn()) return [];
261
+ const rows = [];
262
+ (db.equipment || []).forEach(e => {
263
+ const st = eqCalStatus(e, todayIso);
264
+ if (['failed', 'overdue', 'never', 'soon'].includes(st.state)) rows.push({ e, st });
265
+ });
266
+ const rank = { failed: 0, overdue: 1, never: 2, soon: 3 };
267
+ return rows.sort((a, b) => (rank[a.st.state] - rank[b.st.state]) || ((a.st.daysToDue ?? -1e9) - (b.st.daysToDue ?? -1e9)));
268
+ }