fix(insights): let the lab-comparison card age out, and give its delta the row's tier

Two things about the card that shows what changed since the last panel.

The comparison had no expiry. Someone whose newest panel is years old was
still reading "since your last panel" about a draw from another year — not
stale UI state but a stale sentence, and one the app kept repeating on the
overview every day.

The window is a year, applied to the data rather than to a per-user
dismissal. A year because routine panels are commonly drawn annually, so a
shorter window would hide a comparison that is genuinely the newest thing
somebody has for months out of every year, while past a year the wording
cannot be defended at all. Doing it on the data means nothing to
acknowledge, nothing to persist, no housekeeping asked of anyone, and one
answer for every client through the existing absent contract — the card
already un-mounts on it, and so does anything else reading that route.
Verified in both directions over the real endpoint.

The delta on the right was muted at the smaller size, the same typography
as the meta line under the analyte, so it optically hung off that lower
line and the row read as bottom-aligned even though it measures centred to
the pixel at every width. A delta is the number the row exists to report,
so it takes the content tier, and the cell states its own centring rather
than inheriting it.

The existing cases pinned their clock at the same time: fixed fixture days
against a relative window is a test that passes until a date in the future
and then fails for reasons unrelated to the code.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 16:52:24 +02:00
1 parent 41faf165ee
commit 190941944c
4 files changed
+140 -21

No files matched your search

+11 -3
View File
@@ -6,8 +6,10 @@
* Groups the user's live numeric lab results by panel date, pairs the two
* most-recent panels, and reports the per-analyte delta + reference-band
* standing (`summariseLabChanges`). Absent when there are fewer than two
* panels or no analyte is shared. Neutral framing only — a delta is not a
* diagnosis.
* panels, no analyte is shared, or the newest panel is older than the
* recency window — the card's "since your last panel" wording is a claim
* about recent news, and it stops being made rather than being made about a
* years-old draw. Neutral framing only — a delta is not a diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
@@ -62,7 +64,13 @@ export const GET = apiHandler(async () => {
annotate({
action: { name: "insights.labs-changes.read" },
meta: { present: summary.present, changes: summary.changes.length },
meta: {
present: summary.present,
changes: summary.changes.length,
// Distinguishes "no comparable panels" from "the comparison aged out",
// so an absent card is diagnosable without reaching for the database.
lab_rows: rows.length,
},
});
return apiSuccess({ ...summary, generatedAt: new Date().toISOString() });
@@ -97,8 +97,16 @@ export function LabsChangesCard({
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
{/* The delta is CONTENT — the number the row exists to report —
so it takes the analyte's tier, not the meta tier of the
line under it (UI-STANDARDS §3). At `text-xs` muted it wore
the sub-line's typography and optically attached itself to
that lower line, which is what made a row centred to the
pixel read as bottom-aligned. `self-center` states the row's
alignment on the cell itself so it survives a future change
to the row's own `items-*`. */}
<p
className="text-muted-foreground shrink-0 text-xs tabular-nums"
className="text-foreground shrink-0 self-center text-sm tabular-nums"
data-slot="labs-change-delta"
>
{signedDelta}
+83 -15
View File
@@ -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);
});
});
+37 -2
View File
@@ -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);