Files
HealthLog/src/lib/insights/labs-changes.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00

175 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
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);
}
/** Normalised analyte name for cross-panel matching (free-text tolerant). */
function analyteKey(analyte: string): string {
return analyte.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<string, Map<string, LabChangeRow>>();
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.analyte);
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);
if (!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 };
}