From 190941944c10ca6bbe51b45ef791f5e9a141e8f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
{signedDelta} diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index a6d020465..f3a3e5ddd 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -1,10 +1,20 @@ 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, @@ -23,28 +33,31 @@ function row( describe("summariseLabChanges", () => { it("is absent with no rows", () => { - expect(summariseLabChanges([])).toMatchObject({ present: false }); + expect(summariseLabChanges([], NOW)).toMatchObject({ present: false }); }); it("is absent with a single panel", () => { - const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]); + 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"), - ]); + 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"), - ]); + 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"); @@ -58,10 +71,65 @@ describe("summariseLabChanges", () => { }); it("skips qualitative (non-finite) values", () => { - const s = summariseLabChanges([ - row("LDL", Number.NaN, "2026-06-01"), - row("LDL", 140, "2026-05-01"), - ]); + 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); + }); }); diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts index 19887667c..6486e2b15 100644 --- a/src/lib/insights/labs-changes.ts +++ b/src/lib/insights/labs-changes.ts @@ -9,8 +9,9 @@ * numeric value, so they are skipped — a delta is meaningless for them. * * Pure + Prisma-free so the present / absent states are unit-testable: it is - * absent when there are fewer than two panel dates or no analyte is shared. - * Neutral framing only — a delta is not a diagnosis. + * absent when there are fewer than two panel dates, no analyte is shared, or + * the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing + * only — a delta is not a diagnosis. */ import { classifyReferenceRange, @@ -47,6 +48,29 @@ export interface LabChangesSummary { changes: LabChange[]; } +/** + * How long the comparison keeps calling itself "since your last panel". + * + * The card's whole claim is time-bound: a delta between the two newest panels + * is news for a while, and then it is simply history the labs page already + * holds. Someone whose newest panel is years old was still being shown "what + * changed since your last panel", which is not a stale piece of UI state — it + * is a stale STATEMENT. + * + * A year, and not less, because routine panels are commonly drawn annually. A + * shorter window would hide a comparison that is genuinely the newest thing + * the person has for months of every year. Past a year the wording cannot be + * defended at all, so the comparison stops being surfaced rather than being + * re-worded into something nobody asked for. + * + * Expressed on the DATA, not per user: nothing to acknowledge, nothing to + * persist, no housekeeping asked of anyone, and every client — web and iOS — + * reads the same answer through the existing `present: false` contract. + */ +export const LAB_CHANGE_RECENCY_DAYS = 365; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + const ABSENT: LabChangesSummary = { present: false, latestDate: null, @@ -73,6 +97,7 @@ const round2 = (n: number): number => Math.round(n * 100) / 100; */ export function summariseLabChanges( rows: readonly LabChangeRow[], + now: Date = new Date(), ): LabChangesSummary { const numeric = rows.filter((r) => Number.isFinite(r.value)); if (numeric.length === 0) return ABSENT; @@ -101,6 +126,16 @@ export function summariseLabChanges( const latest = byDay.get(latestDate)!; const previous = byDay.get(previousDate)!; + // Read the freshness off the newest actual sample instant in that panel, not + // off the day key: the key is a UTC calendar day and re-parsing it would put + // the boundary a few hours out for anyone east or west of UTC. + const newestSampleAt = Math.max( + ...[...latest.values()].map((r) => r.takenAt.getTime()), + ); + if (now.getTime() - newestSampleAt > LAB_CHANGE_RECENCY_DAYS * MS_PER_DAY) { + return ABSENT; + } + const changes: LabChange[] = []; for (const [key, latestRow] of latest) { const previousRow = previous.get(key);