/** * v1.25 — "what changed since your last panel" shaping. * * Groups the user's numeric lab results by panel DATE (the calendar day of * `takenAt`, UTC), finds the two most-recent dates, and for every analyte that * appears in BOTH the latest and the previous panel reports the signed delta, * the direction, and where the latest value sits against the reference window * in force for it — the range that reading's own report printed when it has * one, the catalog band otherwise, resolved through the shared * `resolveEffectiveReferenceRange`. Qualitative (valueText-only) results carry * no 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, 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 { classifyAgainstEffectiveRange, resolveEffectiveReferenceRange, type ReferenceRangeStatus, type SourceReferenceRange, } from "@/lib/labs/reference-range"; /** 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; referenceHigh: number | null; takenAt: Date; } export interface LabChange { analyte: string; unit: string; latest: number; previous: number; /** Signed latest − previous, rounded to 2dp. */ delta: number; direction: "up" | "down" | "flat"; status: ReferenceRangeStatus; } export interface LabChangesSummary { present: boolean; /** YYYY-MM-DD of the most-recent panel, or null when absent. */ latestDate: string | null; /** YYYY-MM-DD of the prior panel, or null when absent. */ previousDate: string | null; 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, previousDate: null, changes: [], }; /** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */ function dayKey(d: Date): string { // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: groups draws by UTC day; this summariser has no zone yet (labReadingDay is the target) return d.toISOString().slice(0, 10); } /** * 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; /** * Summarise the change between the two most-recent numeric lab panels. `rows` * should already be the user's live (non-deleted) numeric results; this helper * does the grouping + pairing. */ 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; // Group by panel day, keeping the latest reading per analyte within a day. const byDay = new Map>(); for (const row of numeric) { const day = dayKey(row.takenAt); let analytes = byDay.get(day); if (!analytes) { analytes = new Map(); byDay.set(day, analytes); } const key = analyteKey(row); const existing = analytes.get(key); if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) { analytes.set(key, row); } } const days = [...byDay.keys()].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); if (days.length < 2) return ABSENT; const latestDate = days[0]; const previousDate = days[1]; 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); // 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"; changes.push({ analyte: latestRow.analyte, unit: latestRow.unit, latest: latestRow.value, previous: previousRow.value, delta, direction, status: classifyAgainstEffectiveRange( latestRow.value, resolveEffectiveReferenceRange( latestRow.referenceLow, latestRow.referenceHigh, latestRow, ), ), }); } if (changes.length === 0) return ABSENT; changes.sort((a, b) => a.analyte.localeCompare(b.analyte)); return { present: true, latestDate, previousDate, changes }; }