From 7167ba8cdcbe49910c0b0f5218b23696987d6cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:32:13 +0200 Subject: [PATCH] fix(labs): pair the panel changes by marker, not by printed name --- src/app/api/insights/labs-changes/route.ts | 2 + .../insights/__tests__/labs-changes.test.ts | 46 +++++++++++++++++++ src/lib/insights/labs-changes.ts | 26 +++++++++-- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index d48a6b69f..3436b15b6 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -45,6 +45,7 @@ export const GET = apiHandler(async () => { where: { userId: user.id, deletedAt: null, value: { not: null } }, select: { analyte: true, + biomarkerId: true, unit: true, value: true, referenceLow: true, @@ -61,6 +62,7 @@ export const GET = apiHandler(async () => { const summary = summariseLabChanges( rows.map((r) => ({ analyte: r.analyte, + biomarkerId: r.biomarkerId, unit: r.unit, value: r.value as number, referenceLow: r.referenceLow, diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index 71d50807a..f1f003b72 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -26,10 +26,12 @@ function row( sourceLow?: number | null; sourceHigh?: number | null; sourceText?: string | null; + biomarkerId?: string | null; } = {}, ): LabChangeRow { return { analyte, + biomarkerId: opts.biomarkerId ?? null, unit: opts.unit ?? "mg/dL", value, referenceLow: opts.low ?? null, @@ -143,4 +145,48 @@ describe("summariseLabChanges", () => { ); expect(s.present).toBe(true); }); + + it("pairs two printed names of one marker by the marker", () => { + const out = summariseLabChanges( + [ + row("HbA1c", 5.9, "2026-06-01", { unit: "%", biomarkerId: "bm-1" }), + row("Glykiertes Hämoglobin", 5.6, "2026-03-01", { + unit: "%", + biomarkerId: "bm-1", + }), + ], + NOW, + ); + expect(out.present).toBe(true); + expect(out.changes).toEqual([ + expect.objectContaining({ + analyte: "HbA1c", + latest: 5.9, + previous: 5.6, + delta: 0.3, + }), + ]); + }); + + it("keeps two markers apart even when the printed name matches", () => { + const out = summariseLabChanges( + [ + row("Glucose", 95, "2026-06-01", { biomarkerId: "fasting" }), + row("Glucose", 140, "2026-03-01", { biomarkerId: "postprandial" }), + ], + NOW, + ); + expect(out.present).toBe(false); + }); + + it("reports no delta across two units", () => { + const out = summariseLabChanges( + [ + row("Glucose", 5.3, "2026-06-01", { unit: "mmol/L", biomarkerId: "g" }), + row("Glucose", 95, "2026-03-01", { unit: "mg/dL", biomarkerId: "g" }), + ], + NOW, + ); + expect(out.present).toBe(false); + }); }); diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts index c3afb2cfe..455e02c6f 100644 --- a/src/lib/insights/labs-changes.ts +++ b/src/lib/insights/labs-changes.ts @@ -25,6 +25,12 @@ import { /** A single numeric lab reading, with both windows it could be judged against. */ export interface LabChangeRow extends SourceReferenceRange { analyte: string; + /** + * The marker the reading is filed under. Two reports print the same marker + * under different names ("HbA1c", "Glykiertes Hämoglobin"); the marker is + * what pairs them. A reading without one pairs by its printed name. + */ + biomarkerId?: string | null; unit: string; value: number; referenceLow: number | null; @@ -88,9 +94,18 @@ function dayKey(d: Date): string { return d.toISOString().slice(0, 10); } -/** Normalised analyte name for cross-panel matching (free-text tolerant). */ -function analyteKey(analyte: string): string { - return analyte.trim().toLowerCase(); +/** + * Cross-panel matching key: the marker when the reading is filed under one, + * else the normalised printed name (free-text tolerant). + */ +function analyteKey(row: LabChangeRow): string { + return row.biomarkerId + ? `marker:${row.biomarkerId}` + : `name:${row.analyte.trim().toLowerCase()}`; +} + +function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean { + return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase(); } const round2 = (n: number): number => Math.round(n * 100) / 100; @@ -116,7 +131,7 @@ export function summariseLabChanges( analytes = new Map(); byDay.set(day, analytes); } - const key = analyteKey(row.analyte); + const key = analyteKey(row); const existing = analytes.get(key); if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) { analytes.set(key, row); @@ -144,7 +159,8 @@ export function summariseLabChanges( const changes: LabChange[] = []; for (const [key, latestRow] of latest) { const previousRow = previous.get(key); - if (!previousRow) continue; + // A delta across two units is no change, it is a conversion; skip it. + if (!previousRow || !sameUnit(latestRow, previousRow)) continue; const delta = round2(latestRow.value - previousRow.value); const direction: LabChange["direction"] = delta > 0 ? "up" : delta < 0 ? "down" : "flat";