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 -1
View File
@@ -35,6 +35,7 @@ import {
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
type Severity = "alert" | "caution" | "info" | "good";
@@ -135,7 +136,7 @@ export const GET = apiHandler(async () => {
rollupCoverage,
);
const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({
date: new Date(`${p.day}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(p.day),
value: p.mean,
}));
+4 -2
View File
@@ -44,6 +44,7 @@ import {
} from "@/lib/rollups/mood-rollups";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
export const dynamic = "force-dynamic";
@@ -176,7 +177,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
let moodEntryCount: number;
if (moodRollupDayRows.length > 0) {
dailyMoodEntries = moodRollupDayRows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(r.bucketStart),
value: Math.round(r.mean * 100) / 100,
}));
moodDataPoints = moodRollupDayRows.map((r) => ({
@@ -526,6 +527,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// the scatter and the coefficient rather than dragging continuity down.
const loggedDays = new Set<string>();
for (const event of bpMedicationEvents) {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
loggedDays.add(dayKey);
if (event.skipped || !event.takenAt) continue;
@@ -540,7 +542,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
pairs.push({
a: continuity,
b: avgSys,
date: new Date(`${dayKey}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(dayKey),
});
bpMedicationScatterData.push({
continuityPct: Math.round(continuity * 100),