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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:59:57 +02:00
1 parent 5820078008
commit 7c5ff89def
105 files changed
+807 -200

No files matched your search

+2 -5
View File
@@ -41,6 +41,7 @@ import {
labReadingDaySearchRange,
} from "@/lib/labs/reading-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { statedDateKey } from "@/lib/tz/date-only";
const SYSTEM_PROMPT = `You transcribe a photograph or PDF of a laboratory test report into structured data.
@@ -95,12 +96,8 @@ Respond ONLY with a JSON object of this exact shape:
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) return null;
// Keep only the calendar day — the lab reports a date, not an instant.
return parsed.toISOString().slice(0, 10);
return statedDateKey(raw);
}
/**
+3 -3
View File
@@ -9,7 +9,7 @@
* put a morning draw east of UTC on the previous day, so a scan of the same
* report wrote the reading a second time.
*/
import { dateOnlyKey, dbDate } from "@/lib/tz/date-only";
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
const DAY_MS = 86_400_000;
@@ -31,7 +31,7 @@ export function labReadingDaySearchRange(day: string): {
lt: Date;
} {
return {
gte: dbDate(shiftDateKey(day, -1)),
lt: dbDate(shiftDateKey(day, 2)),
gte: dayKeyAsUtcMidnight(shiftDateKey(day, -1)),
lt: dayKeyAsUtcMidnight(shiftDateKey(day, 2)),
};
}
+2 -1
View File
@@ -28,6 +28,7 @@ import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** One inserted lab row to cross-link back to its source vault document. */
export interface InsertedLabForLink {
@@ -54,7 +55,7 @@ function observationFromLab(lab: InsertedLabForLink): ObservationFactData {
unit: lab.unit,
referenceLow: lab.referenceLow,
referenceHigh: lab.referenceHigh,
effectiveDate: lab.takenAt.toISOString().slice(0, 10),
effectiveDate: dateOnlyKey(lab.takenAt),
};
}