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

@@ -132,6 +132,7 @@ export const GET = apiHandler(
return apiError("Backup payload failed schema validation", 500);
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
+2 -1
View File
@@ -43,6 +43,7 @@ import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FORWARD_DAYS = 180;
@@ -148,7 +149,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
+4 -3
View File
@@ -45,6 +45,7 @@ import {
import { addDays } from "@/lib/cycle/day-math";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Trailing window the phase-contrast walks (days). */
const WINDOW_DAYS = 365;
@@ -124,7 +125,7 @@ export const GET = apiHandler(async () => {
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
@@ -140,7 +141,7 @@ export const GET = apiHandler(async () => {
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
gte: startOfLocalDayKey(from, tz),
},
},
orderBy: { measuredAt: "asc" },
@@ -166,7 +167,7 @@ export const GET = apiHandler(async () => {
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
moodLoggedAt: { gte: startOfLocalDayKey(from, tz) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
+1
View File
@@ -172,6 +172,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
// self-contained `.hlx` archive openable with the user's passphrase via
+1
View File
@@ -88,6 +88,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
// the file is a self-contained backup — admin upload + restore expect
@@ -228,6 +228,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
const { t } = getServerTranslator(locale);
+1
View File
@@ -208,6 +208,7 @@ function csvResponse(
body: ReadableStream<Uint8Array>,
prefix: string,
): NextResponse {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+1
View File
@@ -145,6 +145,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+1
View File
@@ -83,6 +83,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(csv, {
status: 200,
+1
View File
@@ -155,6 +155,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
: formatMoodEntriesForExport(decryptedMood, userTz);
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
if (measurementChunks && format === "csv") {
+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),
+2 -1
View File
@@ -17,6 +17,7 @@ import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* The same ceiling its guardian sibling carries, and for the same reason.
@@ -59,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
creatorId: user.id,
displayName: parsed.data.displayName,
dateOfBirth: parsed.data.dateOfBirth
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
? dayKeyAsUtcMidnight(parsed.data.dateOfBirth)
: null,
locale: parsed.data.locale,
timezone: parsed.data.timezone,
@@ -28,6 +28,7 @@ import {
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
import { dateOnlyKey } from "@/lib/tz/date-only";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -100,7 +101,7 @@ export const PUT = apiHandler(
});
if (medication?.startsOn) {
const takenDay = dayKeyForUserTz(data.takenAt, user.timezone);
const startsOnDay = medication.startsOn.toISOString().slice(0, 10);
const startsOnDay = dateOnlyKey(medication.startsOn);
if (takenDay < startsOnDay) {
annotate({
action: {
@@ -41,6 +41,7 @@ import {
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
export const dynamic = "force-dynamic";
@@ -93,7 +94,9 @@ export const GET = apiHandler(
settings = {
displayName: record.displayName,
heightCm: record.heightCm,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
dateOfBirth: record.dateOfBirth
? dateOnlyKey(record.dateOfBirth)
: null,
gender: record.gender,
locale: record.locale,
timezone: record.timezone,
@@ -195,7 +198,7 @@ export const PATCH = apiHandler(
...(patch.dateOfBirth !== undefined
? {
dateOfBirth: patch.dateOfBirth
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
? dayKeyAsUtcMidnight(patch.dateOfBirth)
: null,
}
: {}),
@@ -235,7 +238,9 @@ export const PATCH = apiHandler(
}
settings = {
...record,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
dateOfBirth: record.dateOfBirth
? dateOnlyKey(record.dateOfBirth)
: null,
};
changed = Object.keys(patch);
break;
+2 -5
View File
@@ -39,6 +39,7 @@ import {
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isCalendarDateKey } from "@/lib/tz/date-only";
/** Sleep is read row-per-stage, so the window is bounded to one year. */
const SLEEP_NIGHT_MAX_DAYS = 365;
@@ -48,11 +49,7 @@ const querySchema = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
.refine((s) => {
const parsed = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return false;
return s === parsed.toISOString().slice(0, 10);
}, "date must be a real calendar date (YYYY-MM-DD)")
.refine(isCalendarDateKey, "date must be a real calendar date (YYYY-MM-DD)")
.optional(),
});