mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous.
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
LAB_CHANGE_RECENCY_DAYS,
|
|
summariseLabChanges,
|
|
type LabChangeRow,
|
|
} from "@/lib/insights/labs-changes";
|
|
|
|
/**
|
|
* Every case pins `now`. The fixtures are fixed calendar days and the summary
|
|
* has a relative freshness window, so a floating `new Date()` would leave the
|
|
* pairing case green until the day it silently slid out of the window and then
|
|
* fail for a reason that has nothing to do with the code under test.
|
|
*/
|
|
const NOW = new Date("2026-06-15T09:00:00.000Z");
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function row(
|
|
analyte: string,
|
|
value: number,
|
|
day: string,
|
|
opts: {
|
|
unit?: string;
|
|
low?: number | null;
|
|
high?: number | null;
|
|
sourceLow?: number | null;
|
|
sourceHigh?: number | null;
|
|
sourceText?: string | null;
|
|
} = {},
|
|
): LabChangeRow {
|
|
return {
|
|
analyte,
|
|
unit: opts.unit ?? "mg/dL",
|
|
value,
|
|
referenceLow: opts.low ?? null,
|
|
referenceHigh: opts.high ?? null,
|
|
sourceReferenceLow: opts.sourceLow ?? null,
|
|
sourceReferenceHigh: opts.sourceHigh ?? null,
|
|
sourceReferenceText: opts.sourceText ?? null,
|
|
takenAt: new Date(`${day}T08:00:00.000Z`),
|
|
};
|
|
}
|
|
|
|
describe("summariseLabChanges", () => {
|
|
it("is absent with no rows", () => {
|
|
expect(summariseLabChanges([], NOW)).toMatchObject({ present: false });
|
|
});
|
|
|
|
it("is absent with a single panel", () => {
|
|
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")], NOW);
|
|
expect(s.present).toBe(false);
|
|
});
|
|
|
|
it("is absent when no analyte is shared across panels", () => {
|
|
const s = summariseLabChanges(
|
|
[row("LDL", 130, "2026-06-01"), row("HbA1c", 5.4, "2026-05-01")],
|
|
NOW,
|
|
);
|
|
expect(s.present).toBe(false);
|
|
});
|
|
|
|
it("pairs the two most-recent panels for a shared analyte", () => {
|
|
const s = summariseLabChanges(
|
|
[
|
|
row("LDL", 120, "2026-06-01", { high: 116 }),
|
|
row("LDL", 140, "2026-05-01"),
|
|
row("LDL", 150, "2026-04-01"),
|
|
],
|
|
NOW,
|
|
);
|
|
expect(s.present).toBe(true);
|
|
expect(s.latestDate).toBe("2026-06-01");
|
|
expect(s.previousDate).toBe("2026-05-01");
|
|
expect(s.changes).toHaveLength(1);
|
|
const c = s.changes[0];
|
|
expect(c.latest).toBe(120);
|
|
expect(c.previous).toBe(140);
|
|
expect(c.delta).toBe(-20);
|
|
expect(c.direction).toBe("down");
|
|
expect(c.status).toBe("above");
|
|
});
|
|
|
|
it("skips qualitative (non-finite) values", () => {
|
|
const s = summariseLabChanges(
|
|
[row("LDL", Number.NaN, "2026-06-01"), row("LDL", 140, "2026-05-01")],
|
|
NOW,
|
|
);
|
|
expect(s.present).toBe(false);
|
|
});
|
|
|
|
// The comparison is a claim about what changed "since your last panel". Past
|
|
// the window that sentence is no longer true of anybody's data, so the
|
|
// summary reports absent rather than presenting a years-old delta as news.
|
|
it("is present while the newest panel is inside the recency window", () => {
|
|
const latest = new Date(
|
|
NOW.getTime() - (LAB_CHANGE_RECENCY_DAYS - 1) * DAY_MS,
|
|
);
|
|
const previous = new Date(latest.getTime() - 30 * DAY_MS);
|
|
const s = summariseLabChanges(
|
|
[
|
|
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
|
|
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
|
|
],
|
|
NOW,
|
|
);
|
|
expect(s.present).toBe(true);
|
|
expect(s.changes).toHaveLength(1);
|
|
});
|
|
|
|
it("is absent once the newest panel falls outside it", () => {
|
|
const latest = new Date(
|
|
NOW.getTime() - (LAB_CHANGE_RECENCY_DAYS + 1) * DAY_MS,
|
|
);
|
|
const previous = new Date(latest.getTime() - 30 * DAY_MS);
|
|
const s = summariseLabChanges(
|
|
[
|
|
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
|
|
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
|
|
],
|
|
NOW,
|
|
);
|
|
expect(s.present).toBe(false);
|
|
expect(s.changes).toEqual([]);
|
|
expect(s.latestDate).toBeNull();
|
|
});
|
|
|
|
// The boundary is read off the sample instant, not off the UTC day key: a
|
|
// panel drawn in the evening east of UTC keys to the NEXT calendar day, and
|
|
// re-parsing that key would move the cut-off by hours in one direction and
|
|
// hours the other way for anyone west of it.
|
|
it("measures the window from the sample instant, not the calendar-day key", () => {
|
|
const latest = new Date(
|
|
NOW.getTime() - LAB_CHANGE_RECENCY_DAYS * DAY_MS + 60_000,
|
|
);
|
|
const previous = new Date(latest.getTime() - 30 * DAY_MS);
|
|
const s = summariseLabChanges(
|
|
[
|
|
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
|
|
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
|
|
],
|
|
NOW,
|
|
);
|
|
expect(s.present).toBe(true);
|
|
});
|
|
});
|