mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
`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.
175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|