formlab-mcp 0.6.46 → 0.6.48
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 +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +90 -13
package/README.md
CHANGED
|
@@ -44,8 +44,8 @@ Ask Claude (or another MCP client) things like:
|
|
|
44
44
|
| `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed as-prepared inputs — mL rows through that ingredient's density — else measured volume × density), **estimatedVolume** (mass ÷ the formula's finished density when no actual exists, flagged `estimate: true`), **plannedMass** for volume-target batches (target × finished density) and **formulaFinishedDensity**, the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage, and — as a finished-goods lot — its storage **`location`** + finished-goods **`stockLedger`** (shipments out + adjustments; `shippedQty` total) |
|
|
45
45
|
| `list_samples` | Filtered list of physical specimens |
|
|
46
46
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
47
|
-
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
|
48
|
-
| `get_test_result` | Full report: every measurement's value, spec, any per-measurement run **conditions**, and the **resolved instrument** (`instrumentSource`: row / method / run). Complex types carry a `representative` (time-series → final, distribution → D50); a time series adds `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (repeat readings at one `t`). Report-level `seriesReplicates` lists parameters measured 2+ times as separate rows — point vs series replicates are distinct. Each measurement also gives its **effective spec** (`effectiveSpec`, `specSource`: override / template / parameter / none) and `passFail` — panel and Test Method specs apply exactly as in the app, and every pass/fail count across the tools uses the same rule |
|
|
47
|
+
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max` — a curve at its final point, a distribution at its spec's statistic or D50), **grade** (`min_grade`/`max_grade` on a ranked scale), **text value** (`value_equals`), **run condition** (`condition`), date, or lab) |
|
|
48
|
+
| `get_test_result` | Full report: every measurement's value, spec, any per-measurement run **conditions**, and the **resolved instrument** (`instrumentSource`: row / method / run). Complex types carry a `representative` (time-series → final, distribution → D50; a distribution also its `bins` and, when its spec names a statistic, `judged` at it); a ranked grade carries its `scale` and `rank`; a time series adds `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (repeat readings at one `t`). Report-level `seriesReplicates` lists parameters measured 2+ times as separate rows — point vs series replicates are distinct. Each measurement also gives its **effective spec** (`effectiveSpec`, `specSource`: override / template / parameter / none) and `passFail` — panel and Test Method specs apply exactly as in the app, and every pass/fail count across the tools uses the same rule |
|
|
49
49
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters. A parameter measured under two or more **run conditions** (storage 25 °C vs 40 °C) is one column per condition (`pH`, `pH · 40 °C`; the bare name is the base arm), never averaged together — same columns, in the same order (grouped by measurement type, the test panel's order within a group), as the app's DOE Matrix |
|
|
50
50
|
| `find_failures` | Parameters ranked by failure **rate** (failing ÷ all readings; replicates in one report count once), `lowN` flag under 5 readings, plus formulations ranked by share of failing reports — same numbers as the app's Test Analytics → Failures |
|
|
51
51
|
| `get_coverage_matrix` | TEST coverage by formulation, batch or sample (`grain`) × parameter. Each cell is the **latest** reading by test date (same-day reports averaged, any fail → fail) with its date and how many reports measured it; one column per **run condition** when a parameter was measured under two or more — same rollup as the app's Test Analytics → Coverage. A ranked scale shows its median grade (with its `rank`) when several readings share the date, a text result its most frequent value, a distribution with no D50 its bins; columns in the app's order (type groups, the test panel's order) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.48",
|
|
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.
|
|
5
|
+
"version": "0.6.48",
|
|
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.
|
|
16
|
+
"version": "0.6.48",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -84,7 +84,9 @@ function _mcpTsAggregate(points) {
|
|
|
84
84
|
|
|
85
85
|
function _trimMeasurement(m, t, db) {
|
|
86
86
|
const ins = _resolveInstrument(m, t, db);
|
|
87
|
-
|
|
87
|
+
// The app's type rule (13s): a legacy valueType 'Categorical' row is text,
|
|
88
|
+
// not a scalar.
|
|
89
|
+
const type = _mcpMeasType(m);
|
|
88
90
|
// Reduce a complex type to the single representative every analysis surface
|
|
89
91
|
// uses (time_series → final point, distribution → D50), and — for a series —
|
|
90
92
|
// expose per-timestamp aggregation so an agent can see POINT replicates
|
|
@@ -101,6 +103,16 @@ function _trimMeasurement(m, t, db) {
|
|
|
101
103
|
} else if (type === 'distribution' && Array.isArray(m.bins) && m.bins.length) {
|
|
102
104
|
const d50 = m.bins.find(b => String(b.label || '').toUpperCase().replace(/\s/g, '') === 'D50');
|
|
103
105
|
complex.representative = d50 ? { value: _round(parseFloat(d50.value), 6), basis: 'D50' } : null;
|
|
106
|
+
complex.bins = m.bins.map(b => ({ label: b.label, value: b.value }));
|
|
107
|
+
// The statistic its spec names is the one it is judged on ("D90 <=30").
|
|
108
|
+
const sp = _resolveSpec(m, t && t.templateId, db).spec;
|
|
109
|
+
if (sp && sp.stat) { const v = _mcpRepNumber(m, { dist: String(sp.stat) }); complex.judged = { value: v != null ? _round(v, 6) : null, basis: String(sp.stat) }; }
|
|
110
|
+
} else if (type === 'ordinal' && Array.isArray(m.scale) && m.scale.length) {
|
|
111
|
+
// A grade's order (13s): the scale and its place on it, so a floor like
|
|
112
|
+
// "at least HB" can be reasoned about.
|
|
113
|
+
complex.scale = m.scale.slice();
|
|
114
|
+
const r = _mcpRepNumber(m, { ordinal: 'rank' });
|
|
115
|
+
if (r != null) complex.rank = r;
|
|
104
116
|
}
|
|
105
117
|
return {
|
|
106
118
|
parameter: m.parameter || '',
|
|
@@ -127,7 +139,7 @@ function _trimMeasurement(m, t, db) {
|
|
|
127
139
|
const list_test_results = {
|
|
128
140
|
definition: {
|
|
129
141
|
name: 'list_test_results',
|
|
130
|
-
description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
|
|
142
|
+
description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, grade, text value, run condition, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). The number a range filter reads: a scalar as recorded, a curve at its final point, a distribution at the statistic its spec names (D90) or else D50; a ranked scale or text result has no number — use min_grade / max_grade (positions on the reading\'s own scale, e.g. parameter="Pencil hardness", min_grade="HB") or value_equals (a text result, case-insensitive). condition narrows to readings under a run condition (substring of e.g. "Storage 40 °C"; "none" = readings with no condition). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
|
|
131
143
|
inputSchema: {
|
|
132
144
|
type: 'object',
|
|
133
145
|
properties: {
|
|
@@ -135,6 +147,10 @@ const list_test_results = {
|
|
|
135
147
|
parameter: { type: 'string', description: 'Filter to results that include this parameter (case-insensitive substring).' },
|
|
136
148
|
value_min: { type: 'number', description: 'Only reports where the parameter (above) measured >= this value. Use with `parameter`; applies to any measurement if `parameter` is omitted.' },
|
|
137
149
|
value_max: { type: 'number', description: 'Only reports where the parameter (above) measured <= this value. Use with `parameter`; applies to any measurement if `parameter` is omitted.' },
|
|
150
|
+
min_grade: { type: 'string', description: 'A ranked scale: only readings at or above this grade on the reading\'s own scale (e.g. "HB").' },
|
|
151
|
+
max_grade: { type: 'string', description: 'A ranked scale: only readings at or below this grade.' },
|
|
152
|
+
value_equals: { type: 'string', description: 'A text (or ranked) result equal to this value, case-insensitive (e.g. "pass", "slight haze").' },
|
|
153
|
+
condition: { type: 'string', description: 'Only readings under a run condition whose label contains this (e.g. "40 °C"); "none" = readings with no run condition.' },
|
|
138
154
|
since_date: { type: 'string', description: 'ISO date — only reports on/after this date.' },
|
|
139
155
|
until_date: { type: 'string', description: 'ISO date — only reports on/before this date.' },
|
|
140
156
|
lab: { type: 'string', description: 'Filter by lab name (case-insensitive substring).' },
|
|
@@ -159,16 +175,32 @@ const list_test_results = {
|
|
|
159
175
|
if (args.until_date && (t.testDate || '') > args.until_date) return false;
|
|
160
176
|
if (args.lab && !norm(t.lab).includes(norm(args.lab))) return false;
|
|
161
177
|
const hasRange = args.value_min != null || args.value_max != null;
|
|
162
|
-
|
|
178
|
+
const hasGrade = !!(args.min_grade || args.max_grade);
|
|
179
|
+
if (args.parameter || hasRange || hasGrade || args.value_equals || args.condition) {
|
|
163
180
|
const pn = norm(args.parameter);
|
|
164
181
|
const found = (t.measurements || []).some(m => {
|
|
165
182
|
if (args.parameter && !norm(m.parameter).includes(pn)) return false;
|
|
183
|
+
if (args.condition) {
|
|
184
|
+
const lbl = norm(_mcpCondLabel(m));
|
|
185
|
+
if (norm(args.condition) === 'none' ? lbl !== '' : !lbl.includes(norm(args.condition))) return false;
|
|
186
|
+
}
|
|
166
187
|
if (hasRange) {
|
|
167
|
-
|
|
188
|
+
// The value it is judged on (13s): a distribution at its spec's statistic.
|
|
189
|
+
const sp = _mcpMeasType(m) === 'distribution' ? (_resolveSpec(m, t.templateId, db).spec || null) : null;
|
|
190
|
+
const v = sp && sp.stat ? _mcpRepNumber(m, { dist: String(sp.stat) }) : _mcpRepNumber(m); // a curve's final value, not its point count (12v)
|
|
168
191
|
if (v == null) return false;
|
|
169
192
|
if (args.value_min != null && v < args.value_min) return false;
|
|
170
193
|
if (args.value_max != null && v > args.value_max) return false;
|
|
171
194
|
}
|
|
195
|
+
if (hasGrade) {
|
|
196
|
+
if (_mcpMeasType(m) !== 'ordinal' || !Array.isArray(m.scale)) return false;
|
|
197
|
+
const up = m.scale.map(g => String(g).trim().toUpperCase());
|
|
198
|
+
const r = up.indexOf(String(m.value == null ? '' : m.value).trim().toUpperCase());
|
|
199
|
+
if (r < 0) return false;
|
|
200
|
+
if (args.min_grade) { const lo = up.indexOf(String(args.min_grade).trim().toUpperCase()); if (lo < 0 || r < lo) return false; }
|
|
201
|
+
if (args.max_grade) { const hi = up.indexOf(String(args.max_grade).trim().toUpperCase()); if (hi < 0 || r > hi) return false; }
|
|
202
|
+
}
|
|
203
|
+
if (args.value_equals && norm(String(m.value == null ? '' : m.value).trim()) !== norm(String(args.value_equals).trim())) return false;
|
|
172
204
|
return true;
|
|
173
205
|
});
|
|
174
206
|
if (!found) return false;
|
|
@@ -359,13 +391,23 @@ const get_doe_matrix = {
|
|
|
359
391
|
|
|
360
392
|
// 4) Aggregate measurements per column per row (mean of numeric scalar
|
|
361
393
|
// values), reading only the column's own run condition when split.
|
|
394
|
+
const testOfM = new Map();
|
|
395
|
+
(db.testResults || []).forEach(t => (t.measurements || []).forEach(x => testOfM.set(x, t)));
|
|
362
396
|
const cellParamValue = (r, col) => {
|
|
363
397
|
const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
|
|
364
398
|
if (!ms.length) return null;
|
|
365
399
|
// The app's DOE Matrix numbers (12v): a curve's final value, a
|
|
366
|
-
// distribution's D50,
|
|
400
|
+
// distribution's D50, a grade's place on its scale. 13r: a ranked scale
|
|
401
|
+
// is its LOWER-median grade (never an averaged rank); text its most
|
|
402
|
+
// frequent value, a tie to a failing one (it took the first reading); a
|
|
403
|
+
// distribution with no D50 its bins (it read "8 bins").
|
|
367
404
|
const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
|
|
368
|
-
if (!nums.length)
|
|
405
|
+
if (!nums.length) {
|
|
406
|
+
const rows = ms.map(m => { const t = testOfM.get(m); return { m, type: _mcpMeasType(m), value: m.value, numeric: null, rank: null, pf: _evalPassFail(m, t && t.templateId) }; });
|
|
407
|
+
if (rows.every(x => x.type === 'categorical')) return _mcpGradedPick(rows).value ?? null;
|
|
408
|
+
return _mcpCellValue(rows[0]);
|
|
409
|
+
}
|
|
410
|
+
if (ms.every(m => _mcpMeasType(m) === 'ordinal')) { const so = nums.slice().sort((a, b) => a - b); return so[Math.floor((so.length - 1) / 2)]; }
|
|
369
411
|
const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
|
|
370
412
|
return +mean.toFixed(4);
|
|
371
413
|
};
|
|
@@ -462,7 +504,7 @@ function _mcpRepNumber(m, opts) {
|
|
|
462
504
|
// app's Stability view draws it (FormLab 12w): one point per test date × run
|
|
463
505
|
// condition — an ordinal date is the MEDIAN rank of its readings (rank =
|
|
464
506
|
// 1-based position on the reading's own scale), a text date its value (or
|
|
465
|
-
//
|
|
507
|
+
// the most frequent value, a tie to a failing one) — failing when any reading fails; one series per condition.
|
|
466
508
|
function _mcpGradedStability(entity, entityType, args, graded) {
|
|
467
509
|
const nOrd = graded.filter(r => r.type === 'ordinal').length;
|
|
468
510
|
const kind = nOrd >= graded.length - nOrd ? 'ordinal' : 'categorical';
|
|
@@ -478,8 +520,11 @@ function _mcpGradedStability(entity, entityType, args, graded) {
|
|
|
478
520
|
rank = rs.length ? rs[Math.floor((rs.length - 1) / 2)] : null;
|
|
479
521
|
label = rank != null && scale ? scale[rank - 1] : g[0].label;
|
|
480
522
|
} else {
|
|
481
|
-
|
|
482
|
-
|
|
523
|
+
// The most frequent value, a tie to a failing one (13r) — "mixed: a / b" said
|
|
524
|
+
// nothing about which reading counts. The chip's tooltip lists them all.
|
|
525
|
+
const k = r => String(r.label).toLowerCase(); const cnt = new Map(); g.forEach(r => cnt.set(k(r), (cnt.get(k(r)) || 0) + 1));
|
|
526
|
+
const top = Math.max(...cnt.values()); const tied = g.filter(r => cnt.get(k(r)) === top);
|
|
527
|
+
label = (tied.find(r => r.pf === 'fail') || tied[0]).label;
|
|
483
528
|
}
|
|
484
529
|
return { date: g[0].day, condition: split ? (g[0].cond || null) : null, label, ...(kind === 'ordinal' ? { rank } : {}),
|
|
485
530
|
readings: g.length, values: g.map(r => r.label), reports: [...new Set(g.map(r => r.report))],
|
|
@@ -504,7 +549,7 @@ function _mcpGradedStability(entity, entityType, args, graded) {
|
|
|
504
549
|
parameter: args.parameter, kind, ...(scale ? { scale } : {}), spec, byCondition: split, pointCount: pts.length, series,
|
|
505
550
|
note: kind === 'ordinal'
|
|
506
551
|
? 'A ranked scale (ordinal) has no number, so there is no regression, control chart or shelf-life projection: each point is one test date per condition, its value the MEDIAN rank of that date\'s readings (rank = position on `scale`, 1-based; higher = later on the scale). pf "fail" when any reading that date failed. scaleShift.steps is the rank change first → latest (negative = moved down the scale). Narrate firstFail / latest; do not average ranks into a number.'
|
|
507
|
-
: 'A text (categorical) result has no number: each point is one test date per condition with its value (
|
|
552
|
+
: 'A text (categorical) result has no number: each point is one test date per condition with its value (several readings that differ → the most frequent, a tie to a failing one; `values` lists them) and pass/fail against `spec.required`; pf "fail" when any reading failed. Narrate firstFail / latest.',
|
|
508
553
|
};
|
|
509
554
|
}
|
|
510
555
|
function _mcpCondSig(m) {
|
|
@@ -812,7 +857,8 @@ function _evalPassFail(m, templateId, db) {
|
|
|
812
857
|
if (type === 'ordinal') {
|
|
813
858
|
if (!raw) return 'untested';
|
|
814
859
|
const scale = Array.isArray(m.scale) ? m.scale : [];
|
|
815
|
-
|
|
860
|
+
// Case-insensitive, as flMeasNum places a grade (13r): "hb" was judged untested but plotted at HB.
|
|
861
|
+
const idx = scale.map(g => String(g).trim().toUpperCase()).indexOf(raw.toUpperCase());
|
|
816
862
|
if (idx < 0) return 'untested';
|
|
817
863
|
const minRank = _ordinalMinRank(crit, scale);
|
|
818
864
|
if (minRank == null) return 'no-criteria';
|
|
@@ -1511,8 +1557,39 @@ function _mcpCompareCell(item, parameter, agg, db) {
|
|
|
1511
1557
|
if (!arms.has(sig)) arms.set(sig, { sig, m, t, rows: [] });
|
|
1512
1558
|
arms.get(sig).rows.push({ m, t, date: _tcDate(t), value: v, pf: _evalPassFail(m, t.templateId, db) });
|
|
1513
1559
|
});
|
|
1514
|
-
//
|
|
1515
|
-
// the latest
|
|
1560
|
+
// A ranked scale or a text result (13r, as the app's Compare): per run
|
|
1561
|
+
// condition, no condition first, the latest test date — several readings on
|
|
1562
|
+
// it → the lower-median grade / the most frequent text (a tie to a failing
|
|
1563
|
+
// one), failing if any reading fails; two or more conditions → arms.
|
|
1564
|
+
if (!arms.size && (type === 'ordinal' || type === 'categorical')) {
|
|
1565
|
+
const g = new Map();
|
|
1566
|
+
hits.forEach(({ m, t }) => {
|
|
1567
|
+
const label = String(m.value == null ? '' : m.value).trim(); if (!label) return;
|
|
1568
|
+
const sig = _mcpCondSig(m), day = String(t.testDate || '').slice(0, 10);
|
|
1569
|
+
if (!g.has(sig)) g.set(sig, { sig, m, days: new Map() });
|
|
1570
|
+
const a = g.get(sig); if (!a.days.has(day)) a.days.set(day, []);
|
|
1571
|
+
a.days.get(day).push({ type: _mcpMeasType(m), value: label, rank: _mcpRepNumber(m, { ordinal: 'rank' }), pf: _evalPassFail(m, t.templateId, db), m });
|
|
1572
|
+
});
|
|
1573
|
+
const gl = [...g.values()].sort((x, y) => ((x.sig ? 1 : 0) - (y.sig ? 1 : 0)) || (y.days.size - x.days.size) || x.sig.localeCompare(y.sig));
|
|
1574
|
+
if (gl.length) {
|
|
1575
|
+
const cellOf = (a) => {
|
|
1576
|
+
const day = [...a.days.keys()].sort().pop(); const rs = a.days.get(day); const p = _mcpGradedPick(rs);
|
|
1577
|
+
const pf = rs.some(r => r.pf === 'fail') ? 'fail' : rs.some(r => r.pf === 'pass') ? 'pass' : rs[0].pf;
|
|
1578
|
+
const c = { type, value: p.value, pf, date: day, n: a.days.size, readings: [...a.days.values()].reduce((x, r) => x + r.length, 0),
|
|
1579
|
+
...(rs.length > 1 ? { readingsOnDate: rs.map(r => r.value), basis: type === 'ordinal' ? 'middle grade of the date\'s readings' : 'most frequent of the date\'s readings' } : {}) };
|
|
1580
|
+
if (type === 'ordinal' && p.rank != null && Array.isArray(p.m.scale)) { c.rank = p.rank; c.scaleLength = p.m.scale.length; }
|
|
1581
|
+
return c;
|
|
1582
|
+
};
|
|
1583
|
+
const cells = gl.map(cellOf);
|
|
1584
|
+
if (agg !== 'latest' && hits.length > 1) cells.forEach(c => { c.note = 'not a number — the latest test date is shown'; });
|
|
1585
|
+
if (gl.length === 1) return { ...cells[0], ...(gl[0].sig ? { condition: _mcpCondLabel(gl[0].m) } : {}) };
|
|
1586
|
+
const tags = _mcpCondTags(gl.map(a => a.sig ? _mcpCondLabel(a.m) : ''));
|
|
1587
|
+
return { ...cells[0], splitByCondition: true,
|
|
1588
|
+
arms: gl.map((a, i) => ({ condition: a.sig ? _mcpCondLabel(a.m) : null, tag: tags[i], ...cells[i] })),
|
|
1589
|
+
note: 'measured under more than one run condition: one arm per condition, never mixed; the top-level value is the first arm (no run condition when there is one)' };
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
// Nothing numeric (a distribution with no D50): the latest reading.
|
|
1516
1593
|
if (!arms.size) {
|
|
1517
1594
|
const { m, t } = hits[hits.length - 1];
|
|
1518
1595
|
const out = { type, value: m.value ?? null, unit: m.unit || '', pf: _evalPassFail(m, t.templateId, db), date: t.testDate || '', readings: hits.length };
|