formlab-mcp 0.6.54 → 0.6.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.54",
3
+ "version": "0.6.56",
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.54",
5
+ "version": "0.6.56",
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.54",
16
+ "version": "0.6.56",
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/vendor/cost.js CHANGED
@@ -138,28 +138,39 @@ function getAllUnits() {
138
138
  const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
139
139
  return [...new Set([...builtin, ...custom])];
140
140
  }
141
+ // The units people reach for first, in that order (22x). Every list puts these ahead of the
142
+ // rest of their group (the picker groups by Mass / Volume / …), so a list opens on g, kg, mg —
143
+ // not on µg, ug, mcg, the size order the conversion tables are written in. Everything else,
144
+ // custom units included, keeps its place after them.
145
+ const _UNITS_COMMON_FIRST = ['wt%', 'vol%', 'g', 'kg', 'mg', 'µg', 'lb', 'oz', 't', 'tonne', 'ton',
146
+ 'mL', 'L', 'µL', 'gal', 'qt', 'pt', 'fl_oz', 'm³', 'mm', 'cm', 'm', 'in', 'ft', 'units', 'each'];
147
+ function unitsCommonFirst(units) {
148
+ const rank = new Map(_UNITS_COMMON_FIRST.map((u, i) => [u, i]));
149
+ return units.map((u, i) => [u, rank.has(u) ? rank.get(u) : 1000 + i]).sort((a, b) => a[1] - b[1]).map(x => x[0]);
150
+ }
141
151
  function ensureUnitDatalists() {
142
152
  if (typeof document === 'undefined' || !document.body) return;
143
153
  const q = (s) => String(s).replace(/"/g, '&quot;');
144
154
  const make = (id, units) => {
145
155
  let dl = document.getElementById(id);
146
156
  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('');
157
+ dl.innerHTML = unitsCommonFirst(units).map((u) => `<option value="${q(u)}"></option>`).join('');
148
158
  };
149
159
  make('fl-units-all', getAllUnits()); // composition / batch quantity
150
160
  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)
161
+ make('fl-units-qty', getQuantityUnits()); // an amount made: batch target, sample amount (mass/vol/count)
162
+ make('fl-units-vol', Object.keys(getVolUnits())); // a measured volume (22w)
152
163
  }
153
164
 
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`).
165
+ // Units for an amount made — a batch's target quantity and a sample's amount (22w: one list
166
+ // for both; the batch had borrowed the composition list, wt% / ppm / lengths / mol included,
167
+ // while samples lacked tonne, bbl, m³…): every mass and volume unit (your custom ones
168
+ // included), plus a count. NOT fractions (wt% / ppm), which only make sense in a recipe ratio,
169
+ // and not lengths.
157
170
  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])];
171
+ const customCount = Object.entries((typeof db !== 'undefined' && db?.customUnits) || {})
172
+ .filter(([_, v]) => v?.dimension === 'count').map(([k]) => k);
173
+ return [...new Set([...Object.keys(getMassUnits()), ...Object.keys(getVolUnits()), 'units', 'each', ...customCount])];
163
174
  }
164
175
 
165
176
  // ============================================================
@@ -309,9 +320,25 @@ function getQuantityUnits() {
309
320
  }
310
321
 
311
322
  document.addEventListener('focusin', function (e) { if (isCombo(e.target) && !refocusing) open(e.target); });
323
+ // The ▾ drawn at the right end (css input.fl-unit-combo) acts like a ▾ button, as
324
+ // js/combo.js's fields do (22v): a hand over it, and a click there closes an OPEN list.
325
+ // "Open" is judged at mousedown: a click into an unfocused field opens it on focusin, and
326
+ // that same click must not close it again.
327
+ var ARROW_W = 28, openAtDown = false;
328
+ function onArrow(el, e) { var r = el.getBoundingClientRect(); return e.clientX >= r.right - ARROW_W; }
329
+ document.addEventListener('mousemove', function (e) {
330
+ if (!isCombo(e.target)) return;
331
+ var c = onArrow(e.target, e) ? 'pointer' : '';
332
+ if (e.target.style.cursor !== c) e.target.style.cursor = c;
333
+ });
334
+ document.addEventListener('mouseout', function (e) { if (isCombo(e.target)) e.target.style.cursor = ''; });
312
335
  // A click on the field reopens the list after a pick (the field kept focus,
313
336
  // so no focusin fires). Capture phase: dialogs stop click propagation.
314
- document.addEventListener('click', function (e) { if (isCombo(e.target) && !isOpen()) open(e.target); }, true);
337
+ document.addEventListener('click', function (e) {
338
+ if (!isCombo(e.target)) return;
339
+ if (openAtDown && onArrow(e.target, e)) { openAtDown = false; hide(); return; }
340
+ if (!isOpen()) open(e.target);
341
+ }, true);
315
342
  // Typing directly in the field filters too (only matters when there's no
316
343
  // search box — i.e. short lists; otherwise focus is in the search box).
317
344
  document.addEventListener('input', function (e) {
@@ -322,6 +349,7 @@ function getQuantityUnits() {
322
349
  document.addEventListener('keydown', function (e) { if (isCombo(e.target)) navKeydown(e); });
323
350
  // mousedown (capture) so a click on a suggestion wins over the input's blur.
324
351
  document.addEventListener('mousedown', function (e) {
352
+ if (isCombo(e.target)) openAtDown = isOpen() && activeInput === e.target;
325
353
  if (!isOpen()) return;
326
354
  var item = e.target.closest && e.target.closest('.fl-unit-combo-item');
327
355
  if (item) { e.preventDefault(); choose(item.getAttribute('data-val')); return; }
@@ -16,6 +16,10 @@
16
16
  // ============================================================
17
17
 
18
18
  const EQ_CAL_SOON_DAYS = 30;
19
+ // The longest interval taken as real (20 years). Anything above, at or below zero, or not a
20
+ // number counts as "no interval": a typo like 1e9 months used to throw in the date maths
21
+ // and take the Equipment list and the dashboard down with it.
22
+ const EQ_CAL_MAX_MONTHS = 240;
19
23
  const EQ_CAL_RESULTS = [
20
24
  { value: 'pass', label: 'Pass', hint: 'within tolerance as found' },
21
25
  { value: 'adjusted', label: 'Adjusted, then pass', hint: 'out of tolerance as found, adjusted and verified' },
@@ -23,20 +27,38 @@ const EQ_CAL_RESULTS = [
23
27
  ];
24
28
  const _EQ_CAL_RESULT_LABEL = Object.fromEntries(EQ_CAL_RESULTS.map(r => [r.value, r.label]));
25
29
 
30
+ // Today in LOCAL time (flTodayIso when utils.js is loaded; the MCP copy passes its own).
31
+ function _eqCalToday() {
32
+ if (typeof flTodayIso === 'function') return flTodayIso();
33
+ const d = new Date();
34
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
35
+ }
26
36
  function eqCalOn() { return typeof hasFeature !== 'function' || hasFeature('equipment.calibration'); }
37
+ // Whole months, 1..EQ_CAL_MAX_MONTHS, else 0.
38
+ function eqCalInterval(e) {
39
+ const n = Math.round(Number(e && e.calIntervalMonths));
40
+ return (n >= 1 && n <= EQ_CAL_MAX_MONTHS) ? n : 0;
41
+ }
42
+ const _EQ_CAL_ISO = /^\d{4}-\d{2}-\d{2}$/;
43
+ // Ids go into onclick="fn('…')": escJsAttr (utils.js) also covers the ' that esc() leaves.
44
+ function _eqCalJs(v) { return typeof escJsAttr === 'function' ? escJsAttr(v) : String(v == null ? '' : v).replace(/[^\w-]/g, ''); }
27
45
 
28
- // Newest first; entries without a date last.
46
+ // Newest first; entries without a date last. Two on the same day (a fail, then the
47
+ // adjusted re-calibration) are ordered by when they were logged, the later first.
29
48
  function eqCalHistory(e) {
30
- const list = (e && Array.isArray(e.calibrations)) ? e.calibrations.filter(Boolean) : [];
31
- return list.slice().sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
49
+ const list = (e && Array.isArray(e.calibrations)) ? e.calibrations.filter(c => c && typeof c === 'object') : [];
50
+ return list.slice().sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))
51
+ || String(b.loggedAt || '').localeCompare(String(a.loggedAt || '')));
32
52
  }
33
53
 
34
54
  // Calendar months, clamped to the month's last day (Jan 31 + 1 mo = Feb 28/29), as
35
55
  // flDateAddIso (utils.js) — kept here so the MCP server's vendored copy needs nothing else.
36
56
  function _eqCalAddMonths(iso, months) {
37
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(iso || '')) || !(months > 0)) return '';
57
+ months = Math.round(Number(months));
58
+ if (!_EQ_CAL_ISO.test(String(iso || '')) || !(months > 0) || months > EQ_CAL_MAX_MONTHS) return '';
38
59
  const [y, m, d] = String(iso).split('-').map(Number);
39
60
  const first = new Date(Date.UTC(y, m - 1 + months, 1));
61
+ if (!isFinite(first.getTime())) return '';
40
62
  const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
41
63
  return new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), Math.min(d, last))).toISOString().slice(0, 10);
42
64
  }
@@ -54,16 +76,19 @@ function _eqCalDays(fromIso, toIso) {
54
76
  // 'ok'
55
77
  // due: the next due date (the latest entry's own nextDue, else its date + the interval)
56
78
  function eqCalStatus(e, todayIso) {
57
- const today = todayIso || (typeof flTodayIso === 'function' ? flTodayIso() : new Date().toISOString().slice(0, 10));
58
- const interval = Number(e && e.calIntervalMonths) || 0;
79
+ const today = todayIso || _eqCalToday();
80
+ const interval = eqCalInterval(e);
59
81
  const hist = eqCalHistory(e);
60
82
  const last = hist[0] || null;
61
- const base = { state: 'none', due: '', last: last ? (last.date || '') : '', lastResult: last ? (last.result || '') : '', interval, daysToDue: null, count: hist.length };
83
+ // Results are written lower-case; an imported "FAIL" is still a fail.
84
+ const result = last ? String(last.result || '').trim().toLowerCase() : '';
85
+ const base = { state: 'none', due: '', last: last ? (last.date || '') : '', lastResult: result, interval, daysToDue: null, count: hist.length };
62
86
  if (!e || e.status === 'Retired') return base;
63
87
  if (!last) return interval ? { ...base, state: 'never' } : base;
64
88
  // A failed instrument has no next due date: it needs a passing calibration first.
65
- if (last.result === 'fail') return { ...base, state: 'failed' };
66
- const due = last.nextDue || _eqCalAddMonths(last.date, interval) || '';
89
+ if (result === 'fail') return { ...base, state: 'failed' };
90
+ // A next due that is not a date (an import's "soon") falls back to the interval.
91
+ const due = (_EQ_CAL_ISO.test(String(last.nextDue || '')) ? last.nextDue : '') || _eqCalAddMonths(last.date, interval) || '';
67
92
  const days = due ? _eqCalDays(today, due) : null;
68
93
  if (!due) return { ...base, state: 'ok' };
69
94
  const state = days < 0 ? 'overdue' : days <= EQ_CAL_SOON_DAYS ? 'soon' : 'ok';
@@ -112,12 +137,12 @@ function eqCalCardHtml(e) {
112
137
  </div></div>`;
113
138
  }
114
139
  const st = eqCalStatus(e);
115
- const interval = Number(e.calIntervalMonths) || 0;
140
+ const interval = eqCalInterval(e);
116
141
  const files = Array.isArray(e.fileAttachments) ? e.fileAttachments : [];
117
142
  const certName = (id) => { const f = files.find(a => a && a.id === id); return f ? (f.name || f.title || f.url || 'Certificate') : ''; };
118
143
  const editBtns = on ? `<div style="display:flex;gap:6px">
119
- <button class="btn btn-secondary btn-sm" onclick="openEqCalIntervalModal('${e.id}')">${interval ? 'Every ' + interval + ' mo' : 'Set interval'}</button>
120
- <button class="btn btn-primary btn-sm" onclick="openEqCalLogModal('${e.id}')">+ Log calibration</button>
144
+ <button class="btn btn-secondary btn-sm" onclick="openEqCalIntervalModal('${_eqCalJs(e.id)}')">${interval ? 'Every ' + interval + ' mo' : 'Set interval'}</button>
145
+ <button class="btn btn-primary btn-sm" onclick="openEqCalLogModal('${_eqCalJs(e.id)}')">+ Log calibration</button>
121
146
  </div>` : `<button class="btn btn-secondary btn-sm" onclick="proUpsell('equipment.calibration')"><span class="fl-pro" aria-hidden="true"></span> Pro</button>`;
122
147
  const summary = st.state === 'none'
123
148
  ? `<div style="color:var(--text-muted);font-size:13px">Not tracked yet. Set how often it is calibrated, or log its last calibration.</div>`
@@ -132,9 +157,9 @@ function eqCalCardHtml(e) {
132
157
  <td>${esc(c.by || '—')}</td>
133
158
  <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>
134
159
  <td style="font-variant-numeric:tabular-nums">${esc(c.nextDue || '—')}</td>
135
- <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>
160
+ <td>${c.certificateId && certName(c.certificateId) ? `<a style="color:var(--primary);cursor:pointer" onclick="attachOpenInTab('${_eqCalJs(c.certificateId)}')">${esc(certName(c.certificateId))}</a>` : (c.certificateRef ? esc(c.certificateRef) : '<span style="color:var(--dk-dim, #cbd5e1)">—</span>')}</td>
136
161
  <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>
137
- <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>
162
+ <td style="text-align:right;white-space:nowrap">${on ? `<button class="btn btn-secondary btn-sm" onclick="openEqCalLogModal('${_eqCalJs(e.id)}','${_eqCalJs(c.id)}')" title="Edit this calibration">${typeof ICON !== 'undefined' && ICON.edit ? ICON.edit : 'Edit'}</button>` : ''}</td>
138
163
  </tr>`).join('');
139
164
  const table = hist.length ? `<div class="table-wrap" style="margin-top:14px"><table class="eq-cal-table">
140
165
  <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>
@@ -152,12 +177,13 @@ function openEqCalIntervalModal(eqId) {
152
177
  if (!e) return;
153
178
  showModal('Calibration interval', `
154
179
  <div class="form-group"><label>Calibrate every</label>
155
- <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>
180
+ <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(eqCalInterval(e) || '')}" placeholder="12" max="${EQ_CAL_MAX_MONTHS}"> <span>months</span></div>
156
181
  <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>
157
182
  </div>`, () => {
158
183
  const raw = (document.getElementById('eq-cal-interval') || {}).value;
159
184
  const n = raw === '' ? null : Math.round(Number(raw));
160
185
  if (n != null && !(n >= 0)) { toast('Enter a number of months.'); return false; }
186
+ if (n > EQ_CAL_MAX_MONTHS) { toast(`Up to ${EQ_CAL_MAX_MONTHS} months (20 years).`); return false; }
161
187
  updateEquipment(eqId, { calIntervalMonths: n || null });
162
188
  if (typeof renderEquipmentDetail === 'function') renderEquipmentDetail(eqId);
163
189
  return true;
@@ -173,14 +199,14 @@ function openEqCalLogModal(eqId, calId) {
173
199
  const e = typeof getEquipment === 'function' ? getEquipment(eqId) : (db.equipment || []).find(x => x.id === eqId);
174
200
  if (!e) return;
175
201
  const c = calId ? (e.calibrations || []).find(x => x && x.id === calId) : null;
176
- const interval = Number(e.calIntervalMonths) || 0;
177
- const date = c ? (c.date || '') : (typeof flTodayIso === 'function' ? flTodayIso() : new Date().toISOString().slice(0, 10));
202
+ const interval = eqCalInterval(e);
203
+ const date = c ? (c.date || '') : _eqCalToday();
178
204
  const files = Array.isArray(e.fileAttachments) ? e.fileAttachments : [];
179
205
  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('');
180
206
  const hint = (t) => `<div style="font-size:11.5px;color:var(--text-muted);margin-top:4px;line-height:1.4">${t}</div>`;
181
207
  const body = `<div class="form-grid">
182
- <div class="form-group"><label>Date calibrated *</label><input id="eq-cal-date" type="date" value="${esc(date)}" onchange="_eqCalAutoDue(${interval})"></div>
183
- <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"></div>
208
+ <div class="form-group"><label>Date calibrated *</label><input id="eq-cal-date" type="date" value="${esc(date)}" max="${_eqCalToday()}" onchange="_eqCalAutoDue(${interval})"></div>
209
+ <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>
184
210
  <div class="form-group span-2"><label>Result *</label>
185
211
  <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>
186
212
  </div>
@@ -205,9 +231,13 @@ function openEqCalLogModal(eqId, calId) {
205
231
  loggedBy: c ? (c.loggedBy || '') : _eqCalMe(),
206
232
  };
207
233
  if (!/^\d{4}-\d{2}-\d{2}$/.test(rec.date)) { toast('Enter the date it was calibrated.'); return false; }
234
+ if (rec.date > _eqCalToday()) { toast('The calibration date is in the future. Log it once it is done.'); return false; }
208
235
  if (rec.nextDue && rec.nextDue < rec.date) { toast('Next due is before the calibration date.'); return false; }
209
236
  Object.keys(rec).forEach(k => rec[k] === undefined && delete rec[k]);
210
- const list = (e.calibrations || []).filter(x => x && x.id !== rec.id);
237
+ // The record as it is NOW: a calibration synced in from another device while this
238
+ // dialog was open must not be dropped by a list read when it opened.
239
+ const cur = (typeof getEquipment === 'function' ? getEquipment(eqId) : null) || e;
240
+ const list = (cur.calibrations || []).filter(x => x && x.id !== rec.id);
211
241
  list.push(rec);
212
242
  const patch = { calibrations: list };
213
243
  updateEquipment(eqId, patch);
@@ -219,7 +249,7 @@ function openEqCalLogModal(eqId, calId) {
219
249
  // Delete sits in the dialog's footer, left of the buttons.
220
250
  setTimeout(() => {
221
251
  const foot = document.querySelector('#modal-backdrop .modal-footer');
222
- 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>`);
252
+ 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('${_eqCalJs(eqId)}','${_eqCalJs(c.id)}')">Delete</button>`);
223
253
  }, 0);
224
254
  }
225
255
  }