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

@@ -468,6 +468,7 @@ function bucketWindow(
* safe for every code path that reaches this helper.
*/
function bucketDayKey(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: rollup bucketStart key; non-near-UTC users are routed to the live path (see the doc comment)
return d.toISOString().slice(0, 10);
}
+2 -1
View File
@@ -66,6 +66,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -413,7 +414,7 @@ async function fetchSeriesChunked(
}
function dateFromDayKey(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
return dayKeyAsUtcMidnight(key);
}
function isoWeekdayInTz(d: Date, timeZone: string): number {
+1
View File
@@ -183,6 +183,7 @@ export async function loadIntradayPulse(
dateKey: string,
): Promise<IntradayPulseResult> {
const localOf = makeLocalResolver(timezone);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset read window; rows are cut to the exact local day afterwards
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
const start = new Date(anchorMs - WINDOW_LEAD_MS);
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
+1
View File
@@ -60,6 +60,7 @@ export const MAX_PROGNOSIS_ROWS = 5000;
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: far edge of the fit window on stored day keys; a day either way does not change the fit
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}
+5 -5
View File
@@ -60,6 +60,7 @@ import {
type RangeDeltaResult,
type WindowAggregate,
} from "@/lib/analytics/range-shared";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
// The range constants + result shapes live in the client-safe
// `range-shared` module so the insights client bundle never pulls the
@@ -156,6 +157,7 @@ function splitPerPeriod(
export function dailyTotalsEndKey(now: number, timezone: string): string {
const localYesterday = shiftDateKey(userDayKey(new Date(now), timezone), -1);
const utcYesterday = shiftDateKey(
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the DAY rollups are UTC buckets, so the earlier of the local and the UTC yesterday wins (see the doc comment)
new Date(now).toISOString().slice(0, 10),
-1,
);
@@ -193,13 +195,11 @@ async function readDailyTotals(
windowDays: number,
endKey: string,
): Promise<Array<{ day: string; value: number }>> {
const from = new Date(
`${shiftDateKey(endKey, -(2 * windowDays - 1))}T00:00:00.000Z`,
);
const to = new Date(`${shiftDateKey(endKey, 1)}T00:00:00.000Z`);
const from = dayKeyAsUtcMidnight(shiftDateKey(endKey, -(2 * windowDays - 1)));
const to = dayKeyAsUtcMidnight(shiftDateKey(endKey, 1));
const rows = await readRollupBuckets(userId, type, "DAY", from, to);
return rows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(r.bucketStart),
value: r.mean * r.count,
}));
}
+1
View File
@@ -16,6 +16,7 @@ export const LIPIDS_WINDOW_DAYS = 365;
export const LIPIDS_MIN_PANELS = 1;
function panelKey(row: LipidReading): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: grouping key only, never shown; the readings of one panel share one takenAt, so any consistent day partition groups them alike
return `${row.at.toISOString().slice(0, 10)}|${row.panel ?? ""}`;
}