From 2dc73be2334067cc87712a3e992344821884abf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:27:56 +0200 Subject: [PATCH 01/10] fix(dashboard): put the mood card's instant on the user's local day The mood series carries local day keys, and the summary wrote the newest one out as UTC midnight. West of UTC that instant is the previous evening, so a client formatting it in its own zone showed today's entry as yesterday's. Emit the first instant of the day in the account's zone instead; the field keeps its shape and type. --- docs/api/openapi.yaml | 7 +++-- .../dashboard/summary/__tests__/route.test.ts | 28 ++++++++++++++++++- src/app/api/dashboard/summary/route.ts | 12 +++++--- src/lib/openapi/routes/dashboard.ts | 2 +- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..80bd0007c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -70819,9 +70819,10 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START - of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a - lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does. + description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first + instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a + local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For + `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does. allTimeCount: type: integer minimum: -9007199254740991 diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 57fb570ad..e61286e5b 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -105,6 +105,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { userDayKey } from "@/lib/tz/format"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -1167,7 +1168,32 @@ describe("GET /api/dashboard/summary — mood + BMI cards", () => { expect(mood?.allTimeCount).toBe(3); expect(mood?.titleKey).toBe("dashboard.metric.title.mood"); expect(mood?.unitKey).toBe("dashboard.metric.unit.mood"); - expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z"); + // The day key is the account's local day (default zone, summer time): + // its first instant, not the key written out as UTC midnight. + expect(mood?.lastSeenAt).toBe("2026-07-02T22:00:00.000Z"); + expect(mood?.updatedAt).toBe("2026-07-02T22:00:00.000Z"); + }); + + it("puts the mood instant on the logged day for a zone west of UTC", async () => { + // The day key as UTC midnight is the previous evening in Los Angeles, + // so a client formatting the instant in its own zone showed today's + // entry as yesterday's. + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: "America/Los_Angeles" }, + } as never); + vi.mocked(buildMoodDailySeries).mockResolvedValue({ + entries: [{ date: "2026-07-03", score: 4, samples: 1 }], + summary: null, + entryCount: 1, + source: "rollup", + } as never); + + const res = await callGet(makeReq()); + const mood = cardsOf(await res.json()).find((c) => c.kind === "mood"); + const at = new Date(mood?.updatedAt as string); + expect(userDayKey(at, "America/Los_Angeles")).toBe("2026-07-03"); + expect(mood?.updatedAt).toBe("2026-07-03T07:00:00.000Z"); }); it("omits the mood card when the account has never logged a mood", async () => { diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 084b12087..26d76552f 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -65,6 +65,7 @@ import { type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { validTimezoneOr } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { computeStreak } from "@/lib/dashboard/streak"; @@ -879,11 +880,14 @@ async function buildDashboardSummary( if (moodSeries.entryCount > 0) { const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score); const latestEntry = moodSeries.entries[moodSeries.entries.length - 1]; - // The series carries UTC day labels, not timestamps — the entry's day is - // the only instant the source actually pins, so it is emitted as the - // day's start rather than inventing a reading time. + // The series carries the user's LOCAL day keys (`MoodEntry.date`), not + // timestamps, so the only honest instant is the first instant of that + // day in the user's zone. Writing the key as `T00:00Z` instead put the + // instant on the previous calendar day for anyone west of UTC, and a + // client formatting it in its own zone showed today's entry as + // yesterday's. const latestAt = latestEntry - ? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString() + ? startOfLocalDayKey(latestEntry.date, userTz).toISOString() : null; metrics.push({ id: "mood", diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index e71ded325..2779f7448 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -223,7 +223,7 @@ const dashboardMetricCard = z .datetime({ offset: true }) .nullable() .describe( - "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.", + "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.", ), allTimeCount: z .number() From 4ac05364fe3ab81ea4ff37d60f476ebf1cc5493c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:29:01 +0200 Subject: [PATCH 02/10] fix(coach): count GLP-1 injection days on the user's calendar The snapshot cut the last injection, the dose history and the projected next injection on UTC days and compared getUTCDay() with the schedule's local weekday. An evening injection west of UTC landed on the next day, and east of UTC 'tomorrow' was today. Day keys now come from the user's zone, which the Coach snapshot already resolves and now passes in. --- .../ai/coach/__tests__/glp1-snapshot.test.ts | 54 +++++++++++++++++++ src/lib/ai/coach/glp1-snapshot.ts | 46 +++++++++------- src/lib/ai/coach/snapshot.ts | 2 +- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index 51a1485b5..4836ac307 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -278,3 +278,57 @@ describe("buildGlp1SnapshotBlock side-effect tags across locales", () => { ]); }); }); + +/** + * Injection days are the user's calendar days. The snapshot used to cut + * them on UTC days and compare `getUTCDay()` with the schedule's local + * weekday, so an evening injection west of UTC was reported on the next + * day and the projection counted from the wrong day. + */ +describe("buildGlp1SnapshotBlock injection days in the user's zone", () => { + it("dates a Friday-evening injection in Los Angeles on Friday", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + schedules: [ + { daysOfWeek: "5", windowStart: "19:00", windowEnd: "21:00" }, + ], + // Friday 2026-09-25, 20:00 in Los Angeles — Saturday in UTC. + intakeEvents: [ + { + takenAt: new Date("2026-09-26T03:00:00.000Z"), + injectionSite: null, + }, + ], + }), + ]); + // Monday 2026-09-28, 11:00 in Los Angeles. + const now = new Date("2026-09-28T18:00:00.000Z"); + const out = await buildGlp1SnapshotBlock( + "user-1", + now, + "America/Los_Angeles", + ); + const med = out?.medications[0]; + expect(med?.lastInjection?.date).toBe("2026-09-25"); + expect(med?.nextInjection).toEqual({ date: "2026-10-02", daysAway: 4 }); + }); + + it("names tomorrow from the local day east of UTC", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + schedules: [{ daysOfWeek: null, windowStart: null, windowEnd: null }], + }), + ]); + // 2026-09-28 12:00 UTC is already 2026-09-29 02:00 on Kiritimati. + const now = new Date("2026-09-28T12:00:00.000Z"); + const out = await buildGlp1SnapshotBlock( + "user-1", + now, + "Pacific/Kiritimati", + ); + expect(out?.medications[0].nextInjection).toEqual({ + date: "2026-09-30", + daysAway: 1, + }); + }); +}); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 0a17ba5ca..6cb529d11 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -25,6 +25,7 @@ import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag- import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; import { isRecordOnly } from "@/lib/medications/intake-tracking"; import { floorWhole } from "@/lib/medications/units-per-dose"; +import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -159,13 +160,20 @@ function weeksBetween(from: Date, to: Date): number { return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000))); } -function isoDate(d: Date): string { - return d.toISOString().slice(0, 10); +/** Midnight UTC of a calendar-day key: a label for day arithmetic only. */ +function keyEpochDays(key: string): number { + return Math.round(Date.parse(`${key}T00:00:00.000Z`) / 86_400_000); } /** * Predict next injection from cadence + last intake. * + * Works on the user's calendar days throughout: the last injection's day, + * today and the schedule's `daysOfWeek` are all local, so the projection + * walks local day keys. Walking UTC days and testing `getUTCDay()` against + * a local weekday named the wrong day for anyone whose injection time sits + * on the other side of UTC midnight. + * * - Weekly cadence with a single weekday: project to the next matching * weekday after the last intake (or after today if no last intake). * - Daily / unset cadence: next is tomorrow (the existing reminder @@ -176,26 +184,25 @@ function predictNextInjection( schedule: ScheduleEntry | null, lastInjection: LastInjection | null, now: Date, + tz: string, ): NextInjection | null { if (!schedule) return null; + const todayKey = userDayKey(now, tz); if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) { const targetDow = schedule.daysOfWeek[0]; - const anchor = lastInjection - ? new Date(lastInjection.date + "T00:00:00Z") - : now; // Project forward from the anchor (lastInjection if known, otherwise - // today) until we land on the target weekday. - const cursor = new Date(anchor.getTime()); + // today) until we land on the target weekday. The weekday of a + // calendar date does not depend on any zone, so the key's UTC label + // answers it. + const anchorKey = lastInjection ? lastInjection.date : todayKey; for (let i = 1; i <= 14; i += 1) { - cursor.setUTCDate(cursor.getUTCDate() + 1); - if (cursor.getUTCDay() === targetDow) { + const key = shiftDateKey(anchorKey, i); + if (new Date(`${key}T00:00:00.000Z`).getUTCDay() === targetDow) { const daysAway = Math.max( 0, - Math.round( - (cursor.getTime() - now.getTime()) / (24 * 60 * 60 * 1000), - ), + keyEpochDays(key) - keyEpochDays(todayKey), ); - return { date: isoDate(cursor), daysAway }; + return { date: key, daysAway }; } } } @@ -203,9 +210,7 @@ function predictNextInjection( // depending on what's already been logged. Surfacing "tomorrow" keeps // the Coach from making confident "today is your injection day" // claims on partial data. - const tomorrow = new Date(now.getTime()); - tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); - return { date: isoDate(tomorrow), daysAway: 1 }; + return { date: shiftDateKey(todayKey, 1), daysAway: 1 }; } /** @@ -216,6 +221,7 @@ function predictNextInjection( export async function buildGlp1SnapshotBlock( userId: string, now: Date = new Date(), + tz: string = DEFAULT_TIMEZONE, ): Promise { // Test environments mock parts of prisma (only `measurement` + // `moodEntry`) and leave `medication` undefined. Treat the absence @@ -295,7 +301,7 @@ export async function buildGlp1SnapshotBlock( return { value: dc.doseValue, unit: sanitizeForPrompt(dc.doseUnit, 20), - effectiveFrom: isoDate(dc.effectiveFrom), + effectiveFrom: userDayKey(dc.effectiveFrom, tz), note: decryptedNote === null ? null : sanitizeForPrompt(decryptedNote, 200), }; @@ -305,7 +311,7 @@ export async function buildGlp1SnapshotBlock( ? { value: latestChange.doseValue, unit: sanitizeForPrompt(latestChange.doseUnit, 20), - since: isoDate(latestChange.effectiveFrom), + since: userDayKey(latestChange.effectiveFrom, tz), weeksOnDose: weeksBetween(latestChange.effectiveFrom, now), } : null; @@ -334,7 +340,7 @@ export async function buildGlp1SnapshotBlock( const lastInjection: LastInjection | null = last && last.takenAt ? { - date: isoDate(last.takenAt), + date: userDayKey(last.takenAt, tz), site: last.injectionSite ?? null, weeksAgo: weeksBetween(last.takenAt, now), } @@ -344,7 +350,7 @@ export async function buildGlp1SnapshotBlock( // information but no injection is predicted as due. const nextInjection = isRecordOnly(med) ? null - : predictNextInjection(schedule, lastInjection, now); + : predictNextInjection(schedule, lastInjection, now, tz); // Inventory math over the per-item entities (v1.16.10 — the same // rows the Bestand tab and the consumption hook move). diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 665925363..be95be3fc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -744,7 +744,7 @@ async function buildCoachSnapshotImpl( : null; const glp1BlockPromise = excludesMedications ? null - : buildGlp1SnapshotBlock(userId, now); + : buildGlp1SnapshotBlock(userId, now, userTz); const derivedBlockPromise = derivedActive ? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz) : null; From 838629276cd34f88632f75063b59ac3db96b59e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:33:50 +0200 Subject: [PATCH 03/10] fix(documents): store stated dates at noon UTC A report's stated date, a document's filing date and the date a committed lab reading or condition carries were written as UTC midnight, which is the previous evening west of UTC: a reading dated the 1st showed on the 31st. Date-only values are now stored at noon UTC through one helper in the time zone module, the convention the lab scan and the cycle engine already use. Rows written at midnight still read back to the same date. A document uploaded without a date files under the uploader's own day rather than the UTC one. --- .../inbound/[id]/__tests__/route.test.ts | 23 ++++++++++++ .../[id]/extract/__tests__/route.test.ts | 8 +++++ .../documents/inbound/[id]/extract/route.ts | 3 +- src/app/api/documents/inbound/[id]/route.ts | 3 +- .../__tests__/auto-stage-labs.test.ts | 7 ++++ src/lib/documents/__tests__/commit.test.ts | 8 +++++ src/lib/documents/auto-stage-labs.ts | 3 +- src/lib/documents/commit.ts | 11 +++--- src/lib/documents/ingest.ts | 20 ++++++++--- src/lib/tz/date-only.ts | 36 +++++++++++++++++++ 10 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/lib/tz/date-only.ts diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 3b1f74874..b82bd9d65 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -232,6 +232,29 @@ describe("PATCH /api/documents/inbound/[id]", () => { expect("documentDate" in arg.data).toBe(false); }); + it("stores an edited filing date at noon UTC so it reads back on that day everywhere", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.inboundDocument.update).mockResolvedValue( + docRow() as never, + ); + vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue( + docRow() as never, + ); + + const res = await PATCH( + mkReq("doc-1", { documentDate: "2026-05-03" }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0]; + // UTC midnight is the previous evening west of UTC. + expect((arg.data.documentDate as Date).toISOString()).toBe( + "2026-05-03T12:00:00.000Z", + ); + }); + it("422s on an empty update", async () => { const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never); expect(res.status).toBe(422); diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index 267c366bd..ccb39338b 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -288,6 +288,14 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => { data: expect.objectContaining({ status: "EXTRACTED" }), }), ); + // The stated report date is a date-only value: stored at noon UTC, not + // at UTC midnight (the previous evening west of UTC). + const staged = tx.inboundDocument.update.mock.calls[0]![0] as { + data: { reportDate: Date }; + }; + expect(staged.data.reportDate.toISOString()).toBe( + "2026-08-01T12:00:00.000Z", + ); expect(auditLog).toHaveBeenCalledWith( "documents.inbound.extract", expect.objectContaining({ diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index f6bd96a3b..930d8c895 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -76,6 +76,7 @@ import { inboundStoredExtractSchema, inboundTextExtractSchema, } from "@/lib/validations/inbound-documents"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; export const dynamic = "force-dynamic"; @@ -111,7 +112,7 @@ async function stageExtraction( status: "EXTRACTED", providerType: result.providerType, reportDate: result.reportDate - ? new Date(`${result.reportDate}T00:00:00.000Z`) + ? dateOnlyAtNoonUtc(result.reportDate) : null, facts: { create: result.facts.map((f) => ({ diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 8876d279c..b6f512a27 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -41,6 +41,7 @@ import { toContentIndexSource, } from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; type RouteParams = { params: Promise<{ id: string }> }; @@ -232,7 +233,7 @@ export const PATCH = apiHandler( if (parsed.data.kind !== undefined) data.kind = parsed.data.kind; if (parsed.data.documentDate !== undefined) { data.documentDate = parsed.data.documentDate - ? new Date(`${parsed.data.documentDate}T00:00:00.000Z`) + ? dateOnlyAtNoonUtc(parsed.data.documentDate) : null; } diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts index 8f522f927..ebcde0792 100644 --- a/src/lib/documents/__tests__/auto-stage-labs.test.ts +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -159,6 +159,13 @@ describe("maybeAutoStageLabFacts", () => { data: expect.objectContaining({ status: "EXTRACTED" }), }), ); + // The stated report date is stored at noon UTC, not UTC midnight. + const moved = updateMany.mock.calls[0]![0] as { + data: { reportDate: Date }; + }; + expect(moved.data.reportDate.toISOString()).toBe( + "2026-07-10T12:00:00.000Z", + ); const created = createMany.mock.calls[0]![0] as { data: { status: string }[]; }; diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 1e858b531..4a54f40d9 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { decryptFactData } from "@/lib/documents/store"; import type { ExtractedFact } from "@/generated/prisma/client"; +import { userDayKey } from "@/lib/tz/format"; function observationFact(): ExtractedFact { return { @@ -94,5 +95,12 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { const ref = await commitApprovedFact("user-1", observationFact()); expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" }); expect(prisma.labResult.create).toHaveBeenCalledOnce(); + // The stated date is stored at noon UTC: it reads back on the stated day + // as a UTC label and formatted in a zone west of UTC alike. UTC midnight + // showed the reading a day early there. + const takenAt = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data + .takenAt as Date; + expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z"); + expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01"); }); }); diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index c66b24b13..ef9133ee8 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -48,6 +48,7 @@ import { localisedValues } from "@/lib/i18n/shared-resolve"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; /** * Lab-report signals used to decide whether a transcribed document is worth an @@ -215,7 +216,7 @@ async function stageFactsIfStored( status: "EXTRACTED", providerType: result.providerType, reportDate: result.reportDate - ? new Date(`${result.reportDate}T00:00:00.000Z`) + ? dateOnlyAtNoonUtc(result.reportDate) : null, }, }); diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index e22f3d349..1bc271dfd 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -39,6 +39,7 @@ import type { MedicationStatementFactData, ObservationFactData, } from "@/lib/validations/inbound-documents"; +import { dateOnlyAtNoonUtc, isDateOnlyKey } from "@/lib/tz/date-only"; /** A per-fact commit failure the caller maps to a per-fact 422 entry. */ export class FactCommitError extends Error { @@ -55,11 +56,13 @@ export interface CommittedRecordRef { recordId: string; } -/** Parse a stated YYYY-MM-DD into a UTC instant, or fall back to now. */ +/** + * A stated YYYY-MM-DD as the instant a date-only value is stored at (noon + * UTC), or now when the document states none. UTC midnight read back as the + * previous day anywhere west of UTC. + */ function statedDateOrNow(date: string | null): Date { - if (date && /^\d{4}-\d{2}-\d{2}$/u.test(date)) { - return new Date(`${date}T00:00:00.000Z`); - } + if (date && isDateOnlyKey(date)) return dateOnlyAtNoonUtc(date); return new Date(); } diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts index 0932ba813..ece28bae6 100644 --- a/src/lib/documents/ingest.ts +++ b/src/lib/documents/ingest.ts @@ -58,6 +58,9 @@ import type { DocumentSourceSystemValue, InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; /** * The person's own uploads per hour, from the web or the phone, and the @@ -74,7 +77,12 @@ export function personalUploadBucket(userId: string): string { return `documents-upload:${userId}`; } -/** Parse a YYYY-MM-DD value into a UTC midnight Date. */ +/** + * A YYYY-MM-DD as UTC midnight: the lower bound of that date for a range + * filter over stored date-only values (which sit at noon UTC, or at UTC + * midnight for rows written before the noon anchor). Not a storage anchor; + * a stored date goes through `dateOnlyAtNoonUtc`. + */ export function isoDateToUtc(value: string): Date { return new Date(`${value}T00:00:00.000Z`); } @@ -297,9 +305,13 @@ export async function ingestDocument( contentCodec: codec, contentSha256, status: "STORED", - documentDate: input.documentDate - ? isoDateToUtc(input.documentDate) - : new Date(), + // The upload day when none is stated is the uploader's own day, + // not the UTC one (just after midnight east of UTC that was + // yesterday). + documentDate: dateOnlyAtNoonUtc( + input.documentDate ?? + userDayKey(new Date(), await resolveUserTimezone(input.userId)), + ), sourceSystem, sourceId, sourceInstance, diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts new file mode 100644 index 000000000..df98246d9 --- /dev/null +++ b/src/lib/tz/date-only.ts @@ -0,0 +1,36 @@ +/** + * Date-only values: a calendar date (`YYYY-MM-DD`) with no time of day — a + * report's stated date, a document's filing date, a backup's label for a + * `@db.Date` column. + * + * Such a value is stored in a timestamp column as noon UTC. Noon keeps the + * date on the same calendar day whether it is read back as a UTC label + * ({@link dateOnlyKey}) or formatted in any zone from UTC-11 to UTC+11. The + * older anchor, UTC midnight, sits on the previous evening everywhere west + * of UTC, which is how a stated report date came to show a day early. + * + * Rows written at UTC midnight before the switch still read back to the same + * key through {@link dateOnlyKey}, so the two anchors coexist without a + * migration. + */ + +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u; + +/** True for a strict `YYYY-MM-DD` string. */ +export function isDateOnlyKey(value: string): boolean { + return DATE_ONLY_RE.test(value); +} + +/** The instant a date-only value is stored as: 12:00 UTC on that date. */ +export function dateOnlyAtNoonUtc(key: string): Date { + return new Date(`${key}T12:00:00.000Z`); +} + +/** + * The calendar date of a stored date-only value (or of a `@db.Date` column, + * which Prisma hands back as UTC midnight). Only for values that ARE dates: + * the day an instant fell on for a person is `userDayKey(instant, tz)`. + */ +export function dateOnlyKey(value: Date): string { + return value.toISOString().slice(0, 10); +} From 67d88de3d528c21449517ed45b67916b5e6b5e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:34:13 +0200 Subject: [PATCH 04/10] fix(glp1): file timeline side effects under the mood entry's own day The timeline grouped side effects by the UTC day of moodLoggedAt, so an evening entry west of UTC (or a morning one east of it) showed on the neighbouring day. The entry's stored date is already the writer's local day; group by that. --- .../glp1-timeline/__tests__/route.test.ts | 15 +++++++++++++++ src/app/api/insights/glp1-timeline/route.ts | 7 +++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/api/insights/glp1-timeline/__tests__/route.test.ts b/src/app/api/insights/glp1-timeline/__tests__/route.test.ts index 966d55e94..eeeee3c4b 100644 --- a/src/app/api/insights/glp1-timeline/__tests__/route.test.ts +++ b/src/app/api/insights/glp1-timeline/__tests__/route.test.ts @@ -54,6 +54,7 @@ function medication() { function moodDay(day: number, tags: string[]) { return { + date: `2026-04-${String(day).padStart(2, "0")}`, moodLoggedAt: new Date(Date.UTC(2026, 3, day, 9, 0, 0)), tags: JSON.stringify(tags), }; @@ -99,6 +100,20 @@ describe("GET /api/insights/glp1-timeline side-effect days", () => { ]); }); + it("files a side effect under the entry's own day, not its UTC day", async () => { + // Logged 2026-04-10 at 20:00 in Los Angeles: already the 11th in UTC. + const out = await timeline([ + { + date: "2026-04-10", + moodLoggedAt: new Date("2026-04-11T03:00:00.000Z"), + tags: JSON.stringify(["nausea"]), + }, + ]); + expect(out.entries).toEqual([ + { date: "2026-04-10T12:00:00Z", kind: "side-effect", tags: ["nausea"] }, + ]); + }); + it("recognises the chip label of every shipped locale", async () => { const out = await timeline([ moodDay(10, ["Übelkeit"]), diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 513cb76ef..67af7ff9c 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -80,7 +80,7 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo }, }, - select: { moodLoggedAt: true, tags: true }, + select: { date: true, tags: true }, orderBy: { moodLoggedAt: "desc" }, }); @@ -140,7 +140,10 @@ export const GET = apiHandler(async (request: NextRequest) => { const { matched, unresolvedCount } = matchGlp1SideEffectTags(mood.tags); unresolvedTagCount += unresolvedCount; if (matched.length === 0) continue; - const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10); + // `date` is the day the entry belongs to in the writer's zone. The UTC + // day of `moodLoggedAt` moved an evening entry west of UTC (a morning + // one east of it) onto the neighbouring day. + const dayKey = mood.date; const existing = sideEffectByDay.get(dayKey) ?? []; for (const t of matched) { if (!existing.includes(t)) existing.push(t); From 9662a3d1041ac1bf943ff8bcebee8922ce24cb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:35:11 +0200 Subject: [PATCH 05/10] fix(insights): keep a medication course in the storyline through its last day endsOn is a calendar date, and the storyline compared it with the instant now, so a course dropped out on its final day. Compare it with the user's own today as the column's date value instead. --- .../adherence-storyline-engine-parity.test.ts | 17 +++++++++++++++++ src/lib/insights/derived/adherence-storyline.ts | 10 ++++++++-- src/lib/tz/date-only.ts | 10 ++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts index ff5fc0e81..c8c43c269 100644 --- a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts +++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts @@ -194,3 +194,20 @@ describe("adherence storyline ↔ dashboard-tile engine parity", () => { expect(story).toBeNull(); }); }); + +describe("adherence storyline course window", () => { + it("keeps a course on its last day in the user's zone", async () => { + // 2026-06-21 20:00 in Los Angeles, already the 22nd in UTC. A course + // whose `endsOn` (a calendar date) is the 21st is still running. + const now = new Date("2026-06-22T03:00:00.000Z"); + await buildAdherenceStoryline("user-1", "America/Los_Angeles", now); + const where = mocks.medicationFindMany.mock.calls[0]![0].where as { + OR: [unknown, { endsOn: { gte: Date } }]; + }; + const lastDay = new Date("2026-06-21T00:00:00.000Z"); // @db.Date value + expect(where.OR[1].endsOn.gte.getTime()).toBeLessThanOrEqual( + lastDay.getTime(), + ); + expect(where.OR[1].endsOn.gte.toISOString()).toBe(lastDay.toISOString()); + }); +}); diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index 893ff68f0..fd06e1a8b 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -28,6 +28,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { inferMedTargetClass, @@ -35,6 +36,7 @@ import { type MedTargetClass, } from "@/lib/medications/med-target-map"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { dbDate } from "@/lib/tz/date-only"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const MAD_TO_SIGMA = 1.4826; @@ -160,7 +162,11 @@ export async function buildAdherenceStoryline( timezone: string | null, now: Date = new Date(), ): Promise { - // 1. Active medications with a confidently-known target class. + // 1. Active medications with a confidently-known target class. `endsOn` is + // a calendar date, so a course is over only once the user's own day has + // passed it; comparing the column against the instant `now` dropped the + // course's last day. + const todayKey = userDayKey(now, timezone ?? DEFAULT_TIMEZONE); const meds = await prisma.medication.findMany({ where: { userId, @@ -168,7 +174,7 @@ export async function buildAdherenceStoryline( // v1.39.1 (#1033) — the adherence figure below counts tracked // medications only, so a record-only one cannot be its subject. ...TRACKED_INTAKE_WHERE, - OR: [{ endsOn: null }, { endsOn: { gte: now } }], + OR: [{ endsOn: null }, { endsOn: { gte: dbDate(todayKey) } }], }, select: { name: true, treatmentClass: true }, }); diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index df98246d9..92526a625 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -34,3 +34,13 @@ export function dateOnlyAtNoonUtc(key: string): Date { export function dateOnlyKey(value: Date): string { return value.toISOString().slice(0, 10); } + +/** + * The value a `@db.Date` column holds for calendar date `key` (Prisma reads + * and writes those columns as UTC midnight). Use it to compare such a column + * with a day: `endsOn >= dbDate(todayKey)` keeps a course through its last + * day, where comparing against the instant `now` dropped that day. + */ +export function dbDate(key: string): Date { + return new Date(`${key}T00:00:00.000Z`); +} From a67ba9ee6611c1966c4faca80f237172eafcb192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:37:59 +0200 Subject: [PATCH 06/10] fix(labs): match scanned duplicates on the reading's calendar day The scan commit and the review screen looked for an existing reading in the UTC day of the scanned date. A hand-entered morning draw east of UTC sits on the previous UTC day, so scanning the same report wrote the reading a second time. Both checks now compare calendar days: a date-only reading's stated date, or the user's day of a hand-entered instant. --- .../labs/ocr/commit/__tests__/route.test.ts | 65 +++++++++++++++++++ src/app/api/labs/ocr/commit/route.ts | 41 +++++++----- src/lib/labs/__tests__/reading-day.test.ts | 29 +++++++++ src/lib/labs/ocr-extract.ts | 24 +++++-- src/lib/labs/reading-day.ts | 37 +++++++++++ 5 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 src/lib/labs/__tests__/reading-day.test.ts create mode 100644 src/lib/labs/reading-day.ts diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts index ed84c475c..e77e0b794 100644 --- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -21,6 +21,11 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +const tzMock = vi.hoisted(() => ({ zone: "UTC" })); +vi.mock("@/lib/tz/resolver", async (importOriginal) => ({ + ...(await importOriginal()), + resolveUserTimezone: vi.fn(async () => tzMock.zone), +})); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -84,6 +89,7 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); + tzMock.zone = "UTC"; vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); @@ -133,3 +139,62 @@ describe("POST /api/labs/ocr/commit dedup", () => { expect(body.data.skipped).toHaveLength(0); }); }); + +describe("POST /api/labs/ocr/commit dedup across the user's day", () => { + /** + * A database stand-in that honours the `takenAt` range the route asks for, + * so the test sees what the real query would return. + */ + function liveReadings(rows: Array<{ value: number; takenAt: Date }>) { + vi.mocked(prisma.labResult.findMany).mockImplementation(((args: { + where: { takenAt: { gte?: Date; lt?: Date; lte?: Date } }; + }) => { + const { gte, lt, lte } = args.where.takenAt; + return Promise.resolve( + rows + .filter( + (r) => + (!gte || r.takenAt >= gte) && + (!lt || r.takenAt < lt) && + (!lte || r.takenAt <= lte), + ) + .map((r) => ({ ...r, valueText: null })), + ); + }) as never); + } + + it("skips a scanned reading already entered by hand on the same local day east of UTC", async () => { + tzMock.zone = "Asia/Tokyo"; + // Entered by hand: 2026-06-10 08:00 in Tokyo, the 9th in UTC. + liveReadings([ + { value: 95, takenAt: new Date("2026-06-09T23:00:00.000Z") }, + ]); + // The scan of the same report states the 10th (stored at noon UTC). + const res = await POST( + postReq({ + rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }], + }), + ); + const body = (await res.json()) as { + data: { skipped: Array<{ reason: string }> }; + }; + expect(body.data.skipped).toEqual([ + { analyte: "Glucose", reason: "duplicate" }, + ]); + expect(prisma.labResult.create).not.toHaveBeenCalled(); + }); + + it("writes a reading from the neighbouring local day", async () => { + tzMock.zone = "Asia/Tokyo"; + // 2026-06-09 08:00 in Tokyo: a different day from the scanned 10th. + liveReadings([ + { value: 95, takenAt: new Date("2026-06-08T23:00:00.000Z") }, + ]); + await POST( + postReq({ + rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }], + }), + ); + expect(prisma.labResult.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 5fd00dc2b..6f193ef36 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -47,29 +47,37 @@ import { type OcrCommitRow, type OcrSkippedRowDto, } from "@/lib/validations/labs-ocr"; +import { + labReadingDay, + labReadingDaySearchRange, +} from "@/lib/labs/reading-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; export const POST = apiHandler(withIdempotency<[NextRequest]>(commitOcrRows)); -/** True when a live reading already records this analyte+day+value. */ +/** + * True when a live reading already records this analyte+day+value. The day + * is the reading's calendar day (`labReadingDay`), not its UTC day: a + * hand-entered morning draw east of UTC sits on the previous UTC day, and the + * UTC window let the scan of the same report write it again. + */ async function isDuplicate( userId: string, row: OcrCommitRow, + tz: string, ): Promise { - const dayStart = new Date(row.takenAt); - dayStart.setUTCHours(0, 0, 0, 0); - const dayEnd = new Date(row.takenAt); - dayEnd.setUTCHours(23, 59, 59, 999); - - const candidates = await prisma.labResult.findMany({ + const day = labReadingDay(new Date(row.takenAt), tz); + const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, analyte: { equals: row.analyte.trim(), mode: "insensitive" }, - takenAt: { gte: dayStart, lte: dayEnd }, + takenAt: labReadingDaySearchRange(day), }, - select: { value: true, valueText: true }, - take: 25, + select: { value: true, valueText: true, takenAt: true }, + take: 100, }); + const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day); for (const c of candidates) { if (row.value !== undefined && c.value !== null && c.value === row.value) { return true; @@ -86,15 +94,15 @@ async function isDuplicate( } /** - * Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day - * + the value dimension that `isDuplicate` matches on. Two rows in ONE document + * Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + the + * reading's calendar day + the value dimension that `isDuplicate` matches on. Two rows in ONE document * that resolve to the same key are the same reading; only the first is written. * Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends * on a prior row autocommitting before the next row's live query runs. */ -function inBatchKey(row: OcrCommitRow): string { +function inBatchKey(row: OcrCommitRow, tz: string): string { const analyte = row.analyte.trim().toLowerCase(); - const dayKey = new Date(row.takenAt).toISOString().slice(0, 10); + const dayKey = labReadingDay(new Date(row.takenAt), tz); const valuePart = row.value !== undefined ? `n:${row.value}` @@ -145,9 +153,10 @@ async function commitOcrRows(request: NextRequest) { // is benign and self-healing — the manual lab-write path mints the same way. const writtenInBatch = new Set(); + const tz = await resolveUserTimezone(user.id); for (const row of parsed.data.rows) { - const key = inBatchKey(row); - if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) { + const key = inBatchKey(row, tz); + if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) { skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" }); continue; } diff --git a/src/lib/labs/__tests__/reading-day.test.ts b/src/lib/labs/__tests__/reading-day.test.ts new file mode 100644 index 000000000..e0d2c452f --- /dev/null +++ b/src/lib/labs/__tests__/reading-day.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { labReadingDay, labReadingDaySearchRange } from "../reading-day"; + +describe("labReadingDay", () => { + it("reads a date-only reading as its stated date in every zone", () => { + const noon = new Date("2026-06-10T12:00:00.000Z"); + const legacyMidnight = new Date("2026-06-10T00:00:00.000Z"); + for (const tz of ["America/Los_Angeles", "Pacific/Kiritimati", "UTC"]) { + expect(labReadingDay(noon, tz)).toBe("2026-06-10"); + expect(labReadingDay(legacyMidnight, tz)).toBe("2026-06-10"); + } + }); + + it("reads a hand-entered instant on the user's own day", () => { + const draw = new Date("2026-06-09T23:00:00.000Z"); // 08:00 on the 10th in Tokyo + expect(labReadingDay(draw, "Asia/Tokyo")).toBe("2026-06-10"); + expect(labReadingDay(draw, "America/Los_Angeles")).toBe("2026-06-09"); + }); + + it("searches a range that holds the day's readings in any zone", () => { + const range = labReadingDaySearchRange("2026-06-10"); + const kiritimatiMorning = new Date("2026-06-09T10:30:00.000Z"); // 00:30 on the 10th at UTC+14 + const utcMinus12Late = new Date("2026-06-11T10:30:00.000Z"); // 22:30 on the 10th at UTC-12 + expect(kiritimatiMorning >= range.gte && kiritimatiMorning < range.lt).toBe( + true, + ); + expect(utcMinus12Late >= range.gte && utcMinus12Late < range.lt).toBe(true); + }); +}); diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index 4329211b8..bd1872833 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -36,6 +36,11 @@ import { type OcrExtractedRowDto, OCR_MAX_ROWS, } from "@/lib/validations/labs-ocr"; +import { + labReadingDay, + labReadingDaySearchRange, +} from "@/lib/labs/reading-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; const SYSTEM_PROMPT = `You transcribe a photograph or PDF of a laboratory test report into structured data. @@ -133,6 +138,7 @@ async function annotateRow( userId: string, row: ExtractedRow, reportDate: string | null, + tz: string, ): Promise { const analyte = row.analyte.trim(); const takenAt = normaliseDate(row.takenAt) ?? reportDate; @@ -149,18 +155,21 @@ async function annotateRow( // a likely re-scan. Uses the existing (userId, analyte, takenAt) index. let duplicateOf: string | null = null; if (takenAt) { - const dayStart = new Date(`${takenAt}T00:00:00.000Z`); - const dayEnd = new Date(`${takenAt}T23:59:59.999Z`); - const candidates = await prisma.labResult.findMany({ + // The reading's calendar day, the same rule the commit applies, so the + // review screen flags exactly the rows the commit will skip. + const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, analyte: { equals: analyte, mode: "insensitive" }, - takenAt: { gte: dayStart, lte: dayEnd }, + takenAt: labReadingDaySearchRange(takenAt), }, - select: { id: true, value: true, valueText: true }, - take: 25, + select: { id: true, value: true, valueText: true, takenAt: true }, + take: 100, }); + const candidates = rows.filter( + (c) => labReadingDay(c.takenAt, tz) === takenAt, + ); for (const c of candidates) { if (row.value !== null && c.value !== null && c.value === row.value) { duplicateOf = c.id; @@ -279,11 +288,12 @@ export async function runOcrExtraction( // noticing that a report layout or a language is unsupported and never // hearing about it. A count only: the string is the user's document. let unreadableRanges = 0; + const tz = await resolveUserTimezone(args.userId); for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) { if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) { unreadableRanges += 1; } - rows.push(await annotateRow(args.userId, row, reportDate)); + rows.push(await annotateRow(args.userId, row, reportDate, tz)); } annotate({ diff --git a/src/lib/labs/reading-day.ts b/src/lib/labs/reading-day.ts new file mode 100644 index 000000000..8c3318c4d --- /dev/null +++ b/src/lib/labs/reading-day.ts @@ -0,0 +1,37 @@ +/** + * The calendar day a lab reading belongs to. + * + * Two kinds of reading share `LabResult.takenAt`. A scanned or document + * reading states a date only, stored at noon UTC (rows from before the noon + * anchor sit at UTC midnight); its day is that date. A reading entered by + * hand carries the real instant of the draw; its day is the user's day of + * that instant. Cutting both on the UTC day, as the duplicate checks did, + * 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 { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +const DAY_MS = 86_400_000; +const NOON_MS = 43_200_000; + +export function labReadingDay(takenAt: Date, tz: string): string { + const msOfDay = ((takenAt.getTime() % DAY_MS) + DAY_MS) % DAY_MS; + if (msOfDay === 0 || msOfDay === NOON_MS) return dateOnlyKey(takenAt); + return userDayKey(takenAt, tz); +} + +/** + * A `takenAt` range wide enough to hold every reading whose + * {@link labReadingDay} is `day`, in any zone from UTC-12 to UTC+14. It + * over-selects by design; filter the rows with `labReadingDay` afterwards. + */ +export function labReadingDaySearchRange(day: string): { + gte: Date; + lt: Date; +} { + return { + gte: dbDate(shiftDateKey(day, -1)), + lt: dbDate(shiftDateKey(day, 2)), + }; +} From 5820078008ef6d7e68be93dfc1123ac7e3f403b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:39:09 +0200 Subject: [PATCH 07/10] fix(environment): default the date fields to the user's own day The travel and backfill pickers defaulted to the UTC day, which was tomorrow east of UTC in the small hours and yesterday west of it in the evening, and the backfill start cut the home's effective instant the same way. Both now read the day in the account's zone. --- .../environment-section-dates.test.tsx | 58 +++++++++++++++++++ .../settings/environment-section.tsx | 20 +++++-- 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 src/components/settings/__tests__/environment-section-dates.test.tsx diff --git a/src/components/settings/__tests__/environment-section-dates.test.tsx b/src/components/settings/__tests__/environment-section-dates.test.tsx new file mode 100644 index 000000000..a37b6c670 --- /dev/null +++ b/src/components/settings/__tests__/environment-section-dates.test.tsx @@ -0,0 +1,58 @@ +/** + * The environment settings default their date fields to today. "Today" is + * the user's day: the UTC day was yesterday west of UTC in the evening and + * tomorrow east of it in the small hours. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const auth = vi.hoisted(() => ({ timezone: "America/Los_Angeles" })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: auth.timezone, modules: { environment: true } }, + isAuthenticated: true, + }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/environment", + useSearchParams: () => new URLSearchParams(""), +})); + +import { EnvironmentSection } from "../environment-section"; + +function backfillEnd(): string | undefined { + const client = new QueryClient({ defaultOptions: { queries: { retry: 0 } } }); + const html = renderToStaticMarkup( + + + + + , + ); + return /id="env-backfill-end"[^>]*value="([^"]*)"/.exec(html)?.[1]; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe(" date defaults", () => { + it("defaults to the user's day west of UTC", () => { + auth.timezone = "America/Los_Angeles"; + vi.setSystemTime(new Date("2026-07-03T03:00:00.000Z")); // 2 July, 20:00 + expect(backfillEnd()).toBe("2026-07-02"); + }); + + it("defaults to the user's day east of UTC", () => { + auth.timezone = "Pacific/Kiritimati"; + vi.setSystemTime(new Date("2026-07-02T12:00:00.000Z")); // 3 July, 02:00 + expect(backfillEnd()).toBe("2026-07-03"); + }); +}); diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 0a9de3eb6..166500a69 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -32,6 +32,12 @@ import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; +import { + DEFAULT_TIMEZONE, + detectBrowserTimezone, + userDayKey, + validTimezoneOr, +} from "@/lib/tz/format"; interface GeocodeResult { lat: number; @@ -72,14 +78,18 @@ interface EnvironmentOverview { attribution: string; } -function todayKey(): string { - return new Date().toISOString().slice(0, 10); -} - export function EnvironmentSection() { const { t, tCount } = useTranslations(); const { user } = useAuth(); const queryClient = useQueryClient(); + // Date defaults are the user's own days. The UTC day was tomorrow for + // anyone east of UTC in the small hours and yesterday west of it in the + // evening. + const tz = validTimezoneOr( + user?.timezone ?? detectBrowserTimezone(), + DEFAULT_TIMEZONE, + ); + const todayKey = () => userDayKey(new Date(), tz); const enabled = user?.modules?.environment === true; @@ -217,7 +227,7 @@ export function EnvironmentSection() { // user can still type an earlier start, but pre-home days resolve to SKIP on // the server — to fill the deep past they add an explicit location period. const effectiveBackfillStart = - backfillStart || home?.since?.slice(0, 10) || ""; + backfillStart || (home?.since ? userDayKey(new Date(home.since), tz) : ""); return (
From 7c5ff89deff8e882abccea51f8aa9db3e7f2d98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:59:57 +0200 Subject: [PATCH 08/10] 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. --- .../__tests__/no-utc-day-key.test.mjs | 154 ++++++++++++ eslint-plugins/healthlog/index.js | 4 + eslint-plugins/healthlog/no-utc-day-key.js | 226 ++++++++++++++++++ eslint.config.mjs | 7 + .../utc-day-key-allowlist-guard.test.ts | 80 +++++++ .../api/admin/backups/[id]/download/route.ts | 1 + src/app/api/cycle/calendar/route.ts | 3 +- src/app/api/cycle/insights/route.ts | 7 +- src/app/api/export/encrypted/route.ts | 1 + src/app/api/export/full-backup/route.ts | 1 + src/app/api/export/health-record/route.ts | 1 + src/app/api/export/measurements/route.ts | 1 + src/app/api/export/medications/route.ts | 1 + src/app/api/export/mood/route.ts | 1 + src/app/api/export/route.ts | 1 + src/app/api/insights/cards/route.ts | 3 +- src/app/api/insights/comprehensive/route.ts | 6 +- src/app/api/managed-profiles/route.ts | 3 +- .../[id]/intake/[eventId]/route.ts | 3 +- src/app/api/record-settings/[family]/route.ts | 11 +- src/app/api/sleep/night/route.ts | 7 +- src/app/c/[token]/fhir/route.ts | 1 + src/app/c/[token]/report.pdf/route.ts | 1 + src/components/cycle/bbt-chart.tsx | 5 +- .../links/vault-document-options.ts | 13 +- .../vorsorge-section.tsx | 19 +- .../sections/data-portability-row.tsx | 1 + src/components/mood/use-linked-day-context.ts | 5 +- src/components/onboarding/baseline-fields.tsx | 3 +- src/components/records/allergy-form.tsx | 12 +- .../settings/account-section/index.tsx | 11 +- .../settings/environment-section.tsx | 5 +- src/components/settings/export-section.tsx | 3 + .../report-selection/report-visit-offer.tsx | 5 +- .../report-selection/selection-panel.tsx | 25 +- .../vaccination-document-suggestion.tsx | 3 +- .../vaccinations/vaccination-form.tsx | 7 +- .../vaccinations/vaccination-list.tsx | 6 +- src/lib/ai/coach/budget.ts | 1 + src/lib/ai/coach/cycle-snapshot.ts | 5 +- src/lib/ai/coach/follow-ups/derive.ts | 6 +- src/lib/ai/coach/glp1-snapshot.ts | 24 +- src/lib/ai/coach/results/metric-table-tool.ts | 17 +- src/lib/ai/coach/results/projections.ts | 12 +- .../snapshot-blocks/core-metrics-block.ts | 4 +- src/lib/ai/coach/snapshot-series.ts | 4 +- src/lib/analytics/bp-in-target-fast-path.ts | 1 + src/lib/analytics/correlations-fast-path.ts | 3 +- src/lib/analytics/intraday-pulse-io.ts | 1 + src/lib/analytics/mood-prognosis/load.ts | 1 + src/lib/analytics/range-delta.ts | 10 +- src/lib/analytics/score/lipids.ts | 1 + src/lib/clinician-share/share-view-data.ts | 3 +- src/lib/cycle/prediction-refresh.ts | 8 +- src/lib/daily/milestones.ts | 3 +- src/lib/dashboard/snapshot.ts | 4 +- src/lib/doctor-report/collect.ts | 7 +- src/lib/documents/extract.ts | 7 +- src/lib/documents/ingest.ts | 4 +- src/lib/documents/store.ts | 9 +- src/lib/environment/service.ts | 12 +- src/lib/export/records-backup.ts | 7 +- src/lib/fitbit/client.ts | 1 + src/lib/google-health/client.ts | 1 + src/lib/illness/correlation.ts | 5 +- src/lib/insights/briefing-failure-marker.ts | 1 + src/lib/insights/comprehensive-aggregator.ts | 3 +- src/lib/insights/comprehensive-generate.ts | 1 + .../insights/correlation-channel-series.ts | 4 +- src/lib/insights/correlation-discovery.ts | 6 +- .../insights/derived/adherence-storyline.ts | 7 +- src/lib/insights/derived/baseline.ts | 3 +- src/lib/insights/derived/trajectory.ts | 13 +- src/lib/insights/derived/wellness-scores.ts | 3 +- src/lib/insights/glp1-plateau.ts | 1 + src/lib/insights/labs-changes.ts | 1 + src/lib/insights/measurement-freshness.ts | 7 +- src/lib/insights/mood-crosstab.ts | 7 +- src/lib/insights/score-row.ts | 1 + src/lib/insights/strain-score.ts | 9 +- src/lib/insights/streak-detector.ts | 7 +- src/lib/insights/stress-score.ts | 1 + src/lib/jobs/coach-plan-review.ts | 1 + src/lib/jobs/offhost-backup.ts | 1 + src/lib/jobs/offhost-purge.ts | 3 +- src/lib/jobs/reminder/cleanup-handlers.ts | 1 + src/lib/jobs/restore-drill.ts | 1 + src/lib/jobs/step-consolidation-repair.ts | 1 + src/lib/labs/ocr-extract.ts | 7 +- src/lib/labs/reading-day.ts | 6 +- src/lib/labs/vault-link.ts | 3 +- src/lib/managed-profiles/lifecycle.ts | 5 +- src/lib/mcp/record-search.ts | 1 + .../measurements/dense-intraday-retention.ts | 1 + .../intraday-cumulative-profile.ts | 1 + .../scheduling/project-today-intakes.ts | 5 +- src/lib/mood/linked-context.ts | 1 + src/lib/nutrients/day-bounds.ts | 1 + src/lib/oura/sync.ts | 1 + src/lib/polar/sync.ts | 1 + src/lib/rollups/mood-rollups.ts | 5 +- src/lib/tz/date-only.ts | 46 +++- src/lib/tz/format.ts | 21 ++ src/lib/validations/measurement.ts | 10 +- src/lib/withings/sync-sleep.ts | 1 + 105 files changed, 807 insertions(+), 200 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs create mode 100644 eslint-plugins/healthlog/no-utc-day-key.js create mode 100644 src/__tests__/utc-day-key-allowlist-guard.test.ts diff --git a/eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs b/eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs new file mode 100644 index 000000000..ea2c96e9b --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs @@ -0,0 +1,154 @@ +/** + * Unit tests for the `healthlog/no-utc-day-key` rule: the UTC-day idioms are + * refused in application source, allowed in `src/lib/tz/` and in tests, and a + * disable directive for the rule must be next-line-only and carry a reason. + */ +import { describe, expect, it } from "vitest"; +import { Linter, RuleTester } from "eslint"; +import rule from "../no-utc-day-key.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP = "/repo/src/lib/insights/some-reader.ts"; + +ruleTester.run("no-utc-day-key", rule, { + valid: [ + { code: "const day = userDayKey(at, tz);", filename: APP }, + { code: "const day = dateOnlyKey(row.documentDate);", filename: APP }, + // A month key is not a day key. + { code: "const month = at.toISOString().slice(0, 7);", filename: APP }, + // A full instant is fine. + { code: "const iso = at.toISOString();", filename: APP }, + // Noon UTC is the date-only storage anchor. + { code: "const d = new Date(`${key}T12:00:00.000Z`);", filename: APP }, + // A fixed instant literal is not day parsing. + { code: 'const d = new Date("2026-06-10T08:00:00.000Z");', filename: APP }, + // The time zone module owns the idiom. + { + code: "export const k = (d) => d.toISOString().slice(0, 10);", + filename: "/repo/src/lib/tz/date-only.ts", + }, + // Fixtures pick concrete days on purpose. + { + code: 'const d = new Date("2026-06-10"); const k = d.toISOString().slice(0, 10);', + filename: "/repo/src/lib/insights/__tests__/reader.test.ts", + }, + // Outside src/ entirely. + { + code: "const k = d.toISOString().slice(0, 10);", + filename: "/repo/scripts/backfill.ts", + }, + ], + invalid: [ + { + code: "const day = at.toISOString().slice(0, 10);", + filename: APP, + errors: [{ messageId: "utcSlice" }], + }, + { + code: "const day = at.toISOString().substring(0, 10);", + filename: APP, + errors: [{ messageId: "utcSlice" }], + }, + { + code: 'const day = at.toISOString().split("T")[0];', + filename: APP, + errors: [{ messageId: "utcSlice" }], + }, + { + code: "const day = new Date(now - 86400000)\n .toISOString()\n .slice(0, 10);", + filename: APP, + errors: [{ messageId: "utcSlice" }], + }, + { + code: 'const d = new Date("2026-06-10");', + filename: APP, + errors: [{ messageId: "utcMidnightParse" }], + }, + { + code: "const d = new Date(`${key}T00:00:00.000Z`);", + filename: APP, + errors: [{ messageId: "utcMidnightParse" }], + }, + { + code: "const ms = Date.parse(`${key}T00:00:00Z`);", + filename: APP, + errors: [{ messageId: "utcMidnightParse" }], + }, + { + code: ";", + filename: "/repo/src/components/settings/form.tsx", + errors: [{ messageId: "utcSlice" }], + }, + ], +}); + +/** + * Disable directives name the rule under the plugin's own prefix, which the + * RuleTester does not use, so they run through a Linter with the plugin + * registered as `healthlog` — the way the flat config loads it. + */ +describe("no-utc-day-key disable directives", () => { + const linter = new Linter({ configType: "flat" }); + const config = [ + { + files: ["**/*.ts"], + plugins: { healthlog: { rules: { "no-utc-day-key": rule } } }, + rules: { "healthlog/no-utc-day-key": "error" }, + }, + ]; + const lint = (code) => + linter + .verify(code, config, { filename: "src/lib/insights/reader.ts" }) + .map((m) => m.messageId ?? m.message); + + it("accepts a next-line directive that gives a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp\nconst s = new Date().toISOString().slice(0, 10);", + ), + ).toEqual([]); + }); + + it("refuses a directive without a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-utc-day-key\nconst s = d.toISOString().slice(0, 10);", + ), + ).toEqual(["missingReason"]); + }); + + it("refuses a reason too short to say anything", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-utc-day-key -- ok\nconst s = d.toISOString().slice(0, 10);", + ), + ).toEqual(["missingReason"]); + }); + + it("refuses a same-line or whole-file disable, which would hide the next site too", () => { + // The site itself is suppressed by the directive, so the only report is + // the directive's own, placed at the top of the file where the directive + // does not reach. + expect( + lint( + "const a = 1;\nconst s = d.toISOString().slice(0, 10); // eslint-disable-line healthlog/no-utc-day-key -- UTC by design: file name stamp", + ), + ).toEqual(["nextLineOnly"]); + expect( + lint( + "const a = 1;\n/* eslint-disable healthlog/no-utc-day-key -- UTC by design: file name stamp */\nconst s = d.toISOString().slice(0, 10);", + ), + ).toEqual(["nextLineOnly"]); + }); +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 961372c8b..da764d680 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -12,6 +12,8 @@ * whose handler returns a JobOutcome. * - `no-default-zone-literal` — the default zone is DEFAULT_TIMEZONE from * src/lib/tz/format, never written out. + * - `no-utc-day-key` — a day is cut through the tz module, not by + * slicing an ISO string or parsing UTC midnight. */ "use strict"; @@ -23,6 +25,7 @@ const noRawPaletteColor = require("./no-raw-palette-color.js"); const spacingScale = require("./spacing-scale.js"); const jobHandlerOutcome = require("./job-handler-outcome.js"); const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); +const noUtcDayKey = require("./no-utc-day-key.js"); module.exports = { rules: { @@ -38,5 +41,6 @@ module.exports = { "no-dracula-utility": noRawPaletteColor, "job-handler-outcome": jobHandlerOutcome, "no-default-zone-literal": noDefaultZoneLiteral, + "no-utc-day-key": noUtcDayKey, }, }; diff --git a/eslint-plugins/healthlog/no-utc-day-key.js b/eslint-plugins/healthlog/no-utc-day-key.js new file mode 100644 index 000000000..ea1f300fe --- /dev/null +++ b/eslint-plugins/healthlog/no-utc-day-key.js @@ -0,0 +1,226 @@ +/** + * @fileoverview ESLint rule — a day is cut in a named way, not by slicing UTC. + * + * `instant.toISOString().slice(0, 10)` is the UTC day of an instant. For a + * person that is the wrong day for part of every day anywhere off UTC: the + * evening west of it, the small hours east of it. The same goes for parsing a + * day key back as UTC midnight (`new Date("2026-06-10")`, + * `` new Date(`${key}T00:00:00Z`) ``), which is the previous evening west of + * UTC. The idiom kept coming back because it looks harmless, and each copy + * was a place where an entry landed on the neighbouring day. + * + * The time zone module names every legitimate form: + * + * - the day an instant fell on for a person: `userDayKey(instant, tz)` + * - a date-only value or a `@db.Date` column read back: `dateOnlyKey(value)` + * - a date-only value to store: `dateOnlyAtNoonUtc(key)` + * - a `@db.Date` column value to compare with: `dayKeyAsUtcMidnight(key)` + * - the first instant of a person's day: `startOfLocalDayKey(key, tz)` + * + * FLAGGED, in `src/` outside `src/lib/tz/` and outside test files: + * + * - `.toISOString().slice(0, 10)` and `.substring(0, 10)` + * - `.toISOString().split("T")[0]` + * - `new Date("YYYY-MM-DD")` / `Date.parse("YYYY-MM-DD")` + * - `` new Date(`${key}T00:00:00Z`) `` (also `T00:00Z`, `T00:00:00.000Z`) + * and the same through `Date.parse` + * + * A site that means the UTC day on purpose (a file name, a key that is UTC by + * design and documented as such) stays, with the reason on the line above: + * + * // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: + * + * The sites that were already there when the rule arrived and should move to + * the user's day carry `-- baseline: ` instead. The guard + * `src/__tests__/utc-day-key-allowlist-guard.test.ts` requires one of the two + * prefixes on every directive and holds the baseline count to a ceiling that + * only goes down. + * + * The rule refuses a disable directive for itself that carries no reason, or + * that uses any form other than `eslint-disable-next-line` (a whole-file or + * same-line disable would hide the next occurrence too). + * + * WHAT THIS RULE DOES NOT CATCH + * + * The UTC day assembled another way: `getUTCFullYear()` / `getUTCMonth()` / + * `getUTCDate()` arithmetic, `toISOString().slice(0, 7)` month keys, an + * `Intl.DateTimeFormat` pinned to `timeZone: "UTC"`, a slice through a + * variable (`const iso = d.toISOString(); iso.slice(0, 10)`), or SQL + * `date_trunc` without `AT TIME ZONE`. It removes the idiom, not every way to + * reach the UTC calendar. + */ + +"use strict"; + +const RULE_NAME = "healthlog/no-utc-day-key"; +const EXEMPT_ROOTS = ["src/lib/tz/"]; +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; +const UTC_MIDNIGHT_TAIL_RE = /^T00:00(?::00(?:\.0{1,3})?)?Z$/; +const MIN_REASON_CHARS = 8; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!posix.includes("/src/") && !posix.startsWith("src/")) return false; + if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false; + if (isTestFile(posix)) return false; + return true; +} + +function propertyName(member) { + if (!member || member.type !== "MemberExpression") return null; + if (!member.computed && member.property.type === "Identifier") { + return member.property.name; + } + if (member.computed && member.property.type === "Literal") { + return String(member.property.value); + } + return null; +} + +/** `.toISOString()` with no arguments. */ +function isToIsoStringCall(node) { + return ( + node && + node.type === "CallExpression" && + node.arguments.length === 0 && + propertyName(node.callee) === "toISOString" + ); +} + +function isNumberLiteral(node, value) { + return node && node.type === "Literal" && node.value === value; +} + +/** A day key parsed as UTC midnight: a date-only string or `${k}T00:00Z`. */ +function isUtcMidnightDayArg(arg) { + if (!arg) return false; + if (arg.type === "Literal" && typeof arg.value === "string") { + return DATE_ONLY_RE.test(arg.value); + } + if (arg.type === "TemplateLiteral" && arg.expressions.length >= 1) { + const last = arg.quasis[arg.quasis.length - 1]; + const tail = last.value.cooked ?? last.value.raw; + return typeof tail === "string" && UTC_MIDNIGHT_TAIL_RE.test(tail); + } + return false; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noUtcDayKeyRule = { + meta: { + type: "problem", + docs: { + description: + "Cut a day through the time zone module (userDayKey, dateOnlyKey, dateOnlyAtNoonUtc, dayKeyAsUtcMidnight) instead of slicing or parsing UTC.", + }, + schema: [], + messages: { + utcSlice: + "This is the UTC day of an instant. For a person's day use userDayKey(instant, tz); for a date-only value or a @db.Date column use dateOnlyKey(value) (both in @/lib/tz). If UTC is meant, say why: `// eslint-disable-next-line healthlog/no-utc-day-key -- `.", + utcMidnightParse: + "This parses a day as UTC midnight, the previous evening west of UTC. Store a date-only value with dateOnlyAtNoonUtc(key), compare a @db.Date column with dayKeyAsUtcMidnight(key), or take a person's day start with startOfLocalDayKey(key, tz). If UTC midnight is meant, say why: `// eslint-disable-next-line healthlog/no-utc-day-key -- `.", + missingReason: + "A disable directive for healthlog/no-utc-day-key must say why the UTC day is right here: `-- ` after the rule name.", + nextLineOnly: + "Disable healthlog/no-utc-day-key only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.", + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + function checkDirectives() { + for (const comment of sourceCode.getAllComments()) { + const text = comment.value.trim(); + const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec( + text, + ); + if (!match || !match[2].includes(RULE_NAME)) continue; + if (match[1] !== "-next-line") { + // A same-line or block directive suppresses reports at its own + // position, so the refusal is placed at the top of the file. One + // sitting on the very first line still hides it; the allowlist + // guard test (src/__tests__/utc-day-key-allowlist-guard.test.ts) + // reads every directive and catches that case. + context.report({ + loc: { line: 1, column: 0 }, + messageId: "nextLineOnly", + }); + continue; + } + const reason = match[2].split(/\s--\s/)[1]; + if (!reason || reason.trim().length < MIN_REASON_CHARS) { + context.report({ loc: comment.loc, messageId: "missingReason" }); + } + } + } + + function checkDateParse(node) { + if (isUtcMidnightDayArg(node.arguments[0])) { + context.report({ node, messageId: "utcMidnightParse" }); + } + } + + return { + "Program:exit": checkDirectives, + CallExpression(node) { + const name = propertyName(node.callee); + if ( + (name === "slice" || name === "substring") && + node.arguments.length === 2 && + isNumberLiteral(node.arguments[0], 0) && + isNumberLiteral(node.arguments[1], 10) && + isToIsoStringCall(node.callee.object) + ) { + context.report({ node, messageId: "utcSlice" }); + return; + } + if ( + name === "parse" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "Date" + ) { + checkDateParse(node); + } + }, + MemberExpression(node) { + // `.toISOString().split("T")[0]` + if ( + node.computed && + isNumberLiteral(node.property, 0) && + node.object.type === "CallExpression" && + propertyName(node.object.callee) === "split" && + node.object.arguments.length >= 1 && + node.object.arguments[0].type === "Literal" && + node.object.arguments[0].value === "T" && + isToIsoStringCall(node.object.callee.object) + ) { + context.report({ node, messageId: "utcSlice" }); + } + }, + NewExpression(node) { + if (node.callee.type === "Identifier" && node.callee.name === "Date") { + checkDateParse(node); + } + }, + }; + }, +}; + +module.exports = noUtcDayKeyRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 13102ce22..897750959 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -108,6 +108,13 @@ const eslintConfig = defineConfig([ // literal anywhere else is where a user outside that zone got someone // else's calendar. src/lib/tz/ and test files are exempt; see the rule. "healthlog/no-default-zone-literal": "error", + // A day is cut through src/lib/tz (userDayKey, dateOnlyKey, + // dateOnlyAtNoonUtc, dayKeyAsUtcMidnight), never by slicing an ISO string or by + // parsing a key as UTC midnight: each copy put an entry on the + // neighbouring day for someone off UTC. A site where UTC is meant + // carries `eslint-disable-next-line ... -- `; the rule refuses + // a directive without one. See the rule header. + "healthlog/no-utc-day-key": "error", }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/src/__tests__/utc-day-key-allowlist-guard.test.ts b/src/__tests__/utc-day-key-allowlist-guard.test.ts new file mode 100644 index 000000000..08ec93095 --- /dev/null +++ b/src/__tests__/utc-day-key-allowlist-guard.test.ts @@ -0,0 +1,80 @@ +/** + * The allowlist of `healthlog/no-utc-day-key`. + * + * The lint rule refuses the UTC-day idioms in application source; a site + * where the UTC day is right keeps it with an + * `eslint-disable-next-line healthlog/no-utc-day-key -- ` directive. + * This guard reads every such directive and holds three lines the rule alone + * cannot: + * + * - every directive is the next-line form (a same-line or block disable on + * the first line of a file hides the rule's own refusal); + * - every reason says which kind of exception it is: `UTC by design:` (the + * UTC day is the correct answer) or `baseline:` (a known site that should + * move to the user's day and has not yet); + * - the `baseline:` set can only shrink. A new UTC-day site is fixed or + * argued as UTC by design, never parked. + * + * Mutation, done once by hand: a bare directive on a new line in + * `src/lib/insights/score-row.ts` fails the reason check; an eleventh + * `baseline:` directive fails the ceiling. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); +const RULE = "healthlog/no-utc-day-key"; +/** Known sites still on the UTC day that should not be. Lower it, never raise it. */ +const BASELINE_CEILING = 10; + +interface Directive { + file: string; + line: number; + text: string; +} + +function directives(): Directive[] { + const out: Directive[] = []; + for (const file of walkSourceFiles(SRC, { floor: 2000 })) { + // The rule does not run on tests, so no directive there means anything + // (this file names the directive in its own documentation). + if (/(^|\/)__tests__\/|\.test\.tsx?$/.test(file)) continue; + const lines = readFileSync(join(SRC, file), "utf8").split("\n"); + lines.forEach((text, i) => { + if (/eslint-disable/.test(text) && text.includes(RULE)) { + out.push({ file, line: i + 1, text: text.trim() }); + } + }); + } + return out; +} + +describe("no-utc-day-key allowlist", () => { + const found = directives(); + + it("finds the allowlist it guards", () => { + // A walk that finds nothing would agree with every assertion below. + expect(found.length).toBeGreaterThanOrEqual(40); + }); + + it("uses only next-line directives", () => { + const offenders = found.filter( + (d) => !d.text.includes(`eslint-disable-next-line ${RULE}`), + ); + expect(offenders).toEqual([]); + }); + + it("gives every directive a classified reason", () => { + const offenders = found.filter( + (d) => !/ -- (UTC by design|baseline): \S.{8,}/.test(d.text), + ); + expect(offenders).toEqual([]); + }); + + it("never grows the baseline", () => { + const baseline = found.filter((d) => d.text.includes(" -- baseline: ")); + expect(baseline.length).toBeLessThanOrEqual(BASELINE_CEILING); + }); +}); diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts index 31b703a7e..d79aca81d 100644 --- a/src/app/api/admin/backups/[id]/download/route.ts +++ b/src/app/api/admin/backups/[id]/download/route.ts @@ -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`; diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 7d3b07dda..247f0341a 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -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" }, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 4fc89c5b3..d33978122 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -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 }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 70acef73a..3cc347b00 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -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 diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index a0581850a..5a6235b43 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -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 diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 282da0731..067c04888 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -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); diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index 8fb5c199d..809bc14f2 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -208,6 +208,7 @@ function csvResponse( body: ReadableStream, 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, diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts index 00d2142cb..8ec77cd18 100644 --- a/src/app/api/export/medications/route.ts +++ b/src/app/api/export/medications/route.ts @@ -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, diff --git a/src/app/api/export/mood/route.ts b/src/app/api/export/mood/route.ts index 6feb26b35..b9b9ce83b 100644 --- a/src/app/api/export/mood/route.ts +++ b/src/app/api/export/mood/route.ts @@ -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, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 0e26e7560..4020fc835 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -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") { diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 499171ebe..61b69805c 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -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, })); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index cfe291dd4..6309dfb22 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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(); 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), diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts index 693a7af47..9cb3fe433 100644 --- a/src/app/api/managed-profiles/route.ts +++ b/src/app/api/managed-profiles/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index ecd12aeba..2ae0d8840 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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: { diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 5b91fa3de..e45bcecb6 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -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; diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index fc8c429ba..9a856f88f 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -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(), }); diff --git a/src/app/c/[token]/fhir/route.ts b/src/app/c/[token]/fhir/route.ts index 82a25f1d6..ac882d340 100644 --- a/src/app/c/[token]/fhir/route.ts +++ b/src/app/c/[token]/fhir/route.ts @@ -33,6 +33,7 @@ export const GET = apiHandler( const bundle = buildFhirDocumentBundle(resolved.report, { insuranceNumber: null, }); + // 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(JSON.stringify(bundle), { status: 200, diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts index eafbc2d29..c02777e3d 100644 --- a/src/app/c/[token]/report.pdf/route.ts +++ b/src/app/c/[token]/report.pdf/route.ts @@ -46,6 +46,7 @@ export const GET = apiHandler( insuranceNumber: null, includeCharts: true, }); + // 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(bytes.slice().buffer, { status: 200, diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index 88528dc5f..75471ce7d 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -44,6 +44,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { cn } from "@/lib/utils"; import type { CalendarDay, CervicalMucus, OvulationTest } from "./types"; import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens"; +import { shiftDateKey } from "@/lib/tz/format"; /** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */ const FALLBACK_WINDOW_DAYS = 35; @@ -97,9 +98,7 @@ export function BbtChart({ // recent temperatures. const fromDate = useMemo(() => { if (cycleStartDate) return cycleStartDate; - const fallback = new Date(ymdToMs(today)); - fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS); - return fallback.toISOString().slice(0, 10); + return shiftDateKey(today, -FALLBACK_WINDOW_DAYS); }, [cycleStartDate, today]); const points = useMemo(() => { diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 1c2832320..91aee8604 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -31,6 +31,7 @@ import { DOCUMENT_LIST_MAX_LIMIT, type InboundDocumentDto, } from "@/lib/validations/inbound-documents"; +import { daysBetweenDateKeys } from "@/lib/tz/format"; /** * How many pages the picker walks. At the route's ceiling of 100 that is a @@ -38,8 +39,6 @@ import { */ const VAULT_PICKER_MAX_PAGES = 10; -const DAY_MS = 24 * 60 * 60 * 1000; - /** The fields the option list reads off a document. */ export type VaultDocument = Pick< InboundDocumentDto, @@ -82,10 +81,12 @@ export function isNearAnchor( anchorIso: string | null, ): boolean { if (!documentDay || !anchorIso) return false; - const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`); - const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`); - if (Number.isNaN(day) || Number.isNaN(anchor)) return false; - return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS; + const gap = daysBetweenDateKeys( + anchorIso.slice(0, 10), + documentDay.slice(0, 10), + ); + if (Number.isNaN(gap)) return false; + return Math.abs(gap) <= ENCOUNTER_SUGGEST_WINDOW_DAYS; } /** diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index f1085e2c5..40ff9afdd 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -98,6 +98,11 @@ import { useMeasurementReminderMutations, type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; +import { + detectBrowserTimezone, + shiftDateKey, + userDayKey, +} from "@/lib/tz/format"; // v1.18.1 (V3) — the active-measurement set, grouped so the dropdown // doesn't read as a flat 15-item list. Mirrors the Zod allow-list in @@ -177,8 +182,7 @@ function toDateInputValue(iso: string | null): string { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; - const offset = d.getTimezoneOffset(); - return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10); + return userDayKey(d, detectBrowserTimezone()); } /** @@ -839,12 +843,11 @@ export function postponeOffsetTargets( // in between moves it an hour, which flips the date for anyone acting near // midnight. Anchoring today's local date at UTC noon and stepping whole // UTC days keeps every offset on the day its label names. - const anchor = new Date(`${isoDayInTz(nowMs, tz)}T12:00:00.000Z`); - return SNOOZE_CHIP_DAYS.map((days) => { - const target = new Date(anchor); - target.setUTCDate(target.getUTCDate() + days); - return { days, date: target.toISOString().slice(0, 10) }; - }); + const today = isoDayInTz(nowMs, tz); + return SNOOZE_CHIP_DAYS.map((days) => ({ + days, + date: shiftDateKey(today, days), + })); } function intervalProgress( diff --git a/src/components/medications/sections/data-portability-row.tsx b/src/components/medications/sections/data-portability-row.tsx index 4eae729f0..2ad68d5f5 100644 --- a/src/components/medications/sections/data-portability-row.tsx +++ b/src/components/medications/sections/data-portability-row.tsx @@ -66,6 +66,7 @@ export function DataPortabilityRow({ intake: includeIntake ? "true" : "false", medicationId, }).toString(); + // 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); await downloadFromUrl( `/api/export/medications?${query}`, diff --git a/src/components/mood/use-linked-day-context.ts b/src/components/mood/use-linked-day-context.ts index 00cc3c71d..a89e60e2a 100644 --- a/src/components/mood/use-linked-day-context.ts +++ b/src/components/mood/use-linked-day-context.ts @@ -30,6 +30,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import type { LinkedDayContext } from "@/lib/mood/linked-context"; +import { userDayKey } from "@/lib/tz/format"; /** * The browser's own zone — the right answer for an entry that does not exist @@ -42,9 +43,7 @@ export function browserTimezone(): string { /** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */ export function dayOfLocalInput(value: string): string { if (value.length >= 10) return value.slice(0, 10); - const now = new Date(); - const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000); - return local.toISOString().slice(0, 10); + return userDayKey(new Date(), browserTimezone()); } /** diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index c543b42ed..bd83c1e77 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -16,6 +16,7 @@ import type { HeightDraft, HeightUnitAdapter, } from "@/lib/profile/height-unit-display"; +import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format"; /** * The four profile inputs of the onboarding baseline step. @@ -184,7 +185,7 @@ export function BaselineFields({ id={IDS.dateOfBirth} value={value.dateOfBirth} onChange={(next) => onChange("dateOfBirth", next)} - max={new Date().toISOString().slice(0, 10)} + max={userDayKey(new Date(), detectBrowserTimezone())} autoComplete="bday" aria-invalid={errors.dateOfBirth ? true : undefined} aria-describedby={ diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx index cc1dfb472..77544005b 100644 --- a/src/components/records/allergy-form.tsx +++ b/src/components/records/allergy-form.tsx @@ -27,6 +27,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import type { AllergyDTO } from "@/lib/records/dto"; +import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only"; const TEXT_MAX_LENGTH = 2000; /** Select sentinel for the optional "not assessed" severity. */ @@ -41,11 +42,14 @@ interface AllergyFormProps { footerSlot?: HTMLElement | null; } -/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */ +/** + * ISO date input (YYYY-MM-DD) → the instant a date-only value is stored at + * (noon UTC), or undefined. UTC midnight read back as the previous day west + * of UTC. + */ function toInstant(value: string): string | undefined { - if (!value) return undefined; - const d = new Date(`${value}T00:00:00.000Z`); - return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); + if (!isCalendarDateKey(value)) return undefined; + return dateOnlyAtNoonUtc(value).toISOString(); } /** diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 359be334f..a4cde390e 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -33,7 +33,11 @@ import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select"; -import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + detectBrowserTimezone, + DEFAULT_TIMEZONE, + userDayKey, +} from "@/lib/tz/format"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { recentProofErrorMessage, @@ -53,6 +57,7 @@ import { } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; import { SetupCard } from "./setup-card"; +import { dateOnlyKey } from "@/lib/tz/date-only"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -145,7 +150,7 @@ export function AccountSection() { email: user.email ?? "", height: heightAdapter.toDraft(user.heightCm), dateOfBirth: user.dateOfBirth - ? new Date(user.dateOfBirth).toISOString().slice(0, 10) + ? dateOnlyKey(new Date(user.dateOfBirth)) : "", gender: user.gender ?? "", timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()), @@ -528,7 +533,7 @@ export function AccountSection() { setDateOfBirth(next); clearFieldError("dateOfBirth"); }} - max={new Date().toISOString().slice(0, 10)} + max={userDayKey(new Date(), detectBrowserTimezone())} aria-invalid={fieldErrors.dateOfBirth ? true : undefined} aria-describedby={ fieldErrors.dateOfBirth ? "dob-error" : undefined diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 166500a69..ff6a31342 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -227,7 +227,10 @@ export function EnvironmentSection() { // user can still type an earlier start, but pre-home days resolve to SKIP on // the server — to fill the deep past they add an explicit location period. const effectiveBackfillStart = - backfillStart || (home?.since ? userDayKey(new Date(home.since), tz) : ""); + // `since` stays on the server's day for the home (a UTC day key in the + // environment module), so the default start matches what the worker + // compares against. + backfillStart || home?.since?.slice(0, 10) || ""; return (
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 3fe5f5954..7ee5b7b13 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -246,6 +246,7 @@ function CsvCard({ since: since || undefined, until: until || undefined, }); + // 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 filename = `${filenamePrefix}-${stamp}.csv`; await downloadFromUrl(`${endpoint}${query}`, filename); @@ -329,6 +330,7 @@ function MedicationsCsvCard() { until: until || undefined, intake: includeIntake ? "true" : "false", }); + // 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); await downloadFromUrl( `/api/export/medications${query}`, @@ -447,6 +449,7 @@ function FullBackupCard() { } async function downloadOnce() { + // 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); if (encrypt) { const res = await throwIfReproofRequired( diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx index 5108a1588..b31708730 100644 --- a/src/components/settings/report-selection/report-visit-offer.tsx +++ b/src/components/settings/report-selection/report-visit-offer.tsx @@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button"; import { useEncounters } from "@/hooks/use-encounters"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { EncounterKind } from "@/generated/prisma/client"; +import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format"; /** * How far ahead a booked visit is treated as the reason for this report. @@ -36,9 +37,7 @@ export const REPORT_VISIT_HORIZON_DAYS = 14; /** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */ function localDay(iso: string): string { - const at = new Date(iso); - const offset = at.getTimezoneOffset(); - return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10); + return userDayKey(new Date(iso), detectBrowserTimezone()); } export function ReportVisitOffer({ diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 2f9f1875c..4fb1830cb 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -66,6 +66,13 @@ import { orderLeaves } from "@/lib/report-selection/selection"; import { ReportScopePicker } from "./report-scope-picker"; import { ScopeSummary } from "./scope-summary"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { + DEFAULT_TIMEZONE, + detectBrowserTimezone, + shiftDateKey, + validTimezoneOr, +} from "@/lib/tz/format"; type ExportFormat = "pdf" | "fhir" | "package"; @@ -78,6 +85,12 @@ class ReportRequestError extends Error {} export function HealthRecordExportPanel() { const { t, locale } = useTranslations(); const { user } = useAuth(); + // The report reads its window in the profile zone (the server's + // `reportTz`), so the picked days are cut there too. + const reportTz = validTimezoneOr( + user?.timezone ?? detectBrowserTimezone(), + DEFAULT_TIMEZONE, + ); const queryClient = useQueryClient(); const [format, setFormat] = useState( @@ -155,8 +168,15 @@ export function HealthRecordExportPanel() { const range = customRange && startDate && endDate ? { - startDate: new Date(`${startDate}T00:00:00Z`).toISOString(), - endDate: new Date(`${endDate}T23:59:59Z`).toISOString(), + // The picked days are the user's days: from the first instant + // of the first to the last instant of the last, in their zone. + // UTC midnight started the window the previous evening west of + // UTC, and the report read it as one day more. + startDate: startOfLocalDayKey(startDate, reportTz).toISOString(), + endDate: new Date( + startOfLocalDayKey(shiftDateKey(endDate, 1), reportTz).getTime() - + 1, + ).toISOString(), } : { days }; const res = await throwIfReproofRequired( @@ -190,6 +210,7 @@ export function HealthRecordExportPanel() { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared a.download = `healthlog-health-record-${new Date() .toISOString() .slice(0, 10)}.${ext}`; diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index ea07169aa..0648aab7f 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -60,7 +60,8 @@ export function VaccinationDocumentSuggestion({ const name = candidate.antigenSlug ? t(`vaccinations.catalog.${candidate.antigenSlug}`) : (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed")); - return `${name} · ${format.date(candidate.occurredAt)}`; + // A date stored at UTC midnight: render the date, not the instant. + return `${name} · ${format.date(`${candidate.occurredAt.slice(0, 10)}T12:00:00.000Z`)}`; }; const onPick = (candidate: VaccinationSuggestion) => { diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index a000a3f39..f65b2ac37 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -35,6 +35,7 @@ import type { Practitioner } from "@/hooks/use-practitioners"; import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations"; import { CatalogPicker } from "./catalog-picker"; import { VaccinationDocumentPicker } from "./vaccination-document-picker"; +import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only"; /** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */ export const VACCINATION_SITES = [ @@ -116,8 +117,10 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft { /** The day, at UTC midnight — an Impfpass carries dates, never times. */ export function draftInstant(day: string): string | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null; - const parsed = new Date(`${day}T00:00:00.000Z`); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + if (!isCalendarDateKey(day)) return null; + // Stored at UTC midnight by the column's own convention; every reader + // takes the date part. + return dayKeyAsUtcMidnight(day).toISOString(); } /** Does the draft carry at least one identity arm? */ diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx index 8f84e14f7..1f373c61f 100644 --- a/src/components/vaccinations/vaccination-list.tsx +++ b/src/components/vaccinations/vaccination-list.tsx @@ -198,7 +198,11 @@ function DoseRow({ ) : null}
- {format.date(record.occurredAt)} + + {/* A date, stored at UTC midnight: render its date part, not + the instant (the previous evening west of UTC). */} + {format.date(`${record.occurredAt.slice(0, 10)}T12:00:00.000Z`)} + {record.lotNumber ? ( {t("vaccinations.row.lot", { lot: record.lotNumber })} diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index b96f6d7e1..5e77dcc94 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -235,6 +235,7 @@ export async function readDailySpend( * the local meter to the same boundary keeps reasoning trivial. */ export function buildDateKey(at: Date = new Date()): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment) return at.toISOString().slice(0, 10); } diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index 1edd20a02..510638c23 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -43,6 +43,7 @@ import { addDays } from "@/lib/cycle/day-math"; import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates"; 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). Mirrors the read route. */ const WINDOW_DAYS = 365; @@ -191,7 +192,7 @@ export async function buildCycleSnapshotBlock( 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" }, @@ -206,7 +207,7 @@ export async function buildCycleSnapshotBlock( readSourceDayAggregates({ userId, types: PHASE_CROSSTAB_METRIC_TYPES, - since: new Date(Date.parse(`${from}T00:00:00Z`)), + since: startOfLocalDayKey(from, tz), timeZone: tz, }), prisma.user.findUnique({ diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts index 6bf30bee9..34f596af5 100644 --- a/src/lib/ai/coach/follow-ups/derive.ts +++ b/src/lib/ai/coach/follow-ups/derive.ts @@ -30,7 +30,7 @@ */ import type { Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; -import { userDayKey } from "@/lib/tz/format"; +import { userDayKey, shiftDateKey } from "@/lib/tz/format"; import { annotate } from "@/lib/logging/context"; import type { CoachFollowUp, @@ -93,9 +93,7 @@ export function followUpChipsEnabled(prefs: CoachPrefs): boolean { /** `YYYY-MM-DD` minus `days`, as a day key. */ function dayKeyMinus(dayKey: string, days: number): string { - const at = new Date(`${dayKey}T00:00:00Z`); - at.setUTCDate(at.getUTCDate() - days); - return at.toISOString().slice(0, 10); + return shiftDateKey(dayKey, -days); } /** True when the record's first reading lies before `dayKey`. */ diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 6cb529d11..4256e827d 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -25,7 +25,13 @@ import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag- import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; import { isRecordOnly } from "@/lib/medications/intake-tracking"; import { floorWhole } from "@/lib/medications/units-per-dose"; -import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { + DEFAULT_TIMEZONE, + daysBetweenDateKeys, + shiftDateKey, + userDayKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -160,11 +166,6 @@ function weeksBetween(from: Date, to: Date): number { return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000))); } -/** Midnight UTC of a calendar-day key: a label for day arithmetic only. */ -function keyEpochDays(key: string): number { - return Math.round(Date.parse(`${key}T00:00:00.000Z`) / 86_400_000); -} - /** * Predict next injection from cadence + last intake. * @@ -191,17 +192,12 @@ function predictNextInjection( if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) { const targetDow = schedule.daysOfWeek[0]; // Project forward from the anchor (lastInjection if known, otherwise - // today) until we land on the target weekday. The weekday of a - // calendar date does not depend on any zone, so the key's UTC label - // answers it. + // today) until we land on the target weekday. const anchorKey = lastInjection ? lastInjection.date : todayKey; for (let i = 1; i <= 14; i += 1) { const key = shiftDateKey(anchorKey, i); - if (new Date(`${key}T00:00:00.000Z`).getUTCDay() === targetDow) { - const daysAway = Math.max( - 0, - keyEpochDays(key) - keyEpochDays(todayKey), - ); + if (weekdayOfDateKey(key) === targetDow) { + const daysAway = Math.max(0, daysBetweenDateKeys(todayKey, key)); return { date: key, daysAway }; } } diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts index 68a4ba84c..23d230ed9 100644 --- a/src/lib/ai/coach/results/metric-table-tool.ts +++ b/src/lib/ai/coach/results/metric-table-tool.ts @@ -33,7 +33,7 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import type { Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { readDailySeries, @@ -66,6 +66,7 @@ import type { import { aggregationKind } from "./chart-spec"; import { RESULT_TABLE_MAX_ROWS } from "./project"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** * Sources the table tool leaves to their own tools: glucose needs its @@ -167,16 +168,14 @@ export function resolveTableRange(args: { // ── Period keys ───────────────────────────────────────────────────────── -/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp. */ +/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp (a day label). */ function dayKeyOfUtc(ms: number): string { - return new Date(ms).toISOString().slice(0, 10); + return dateOnlyKey(new Date(ms)); } /** Monday (`YYYY-MM-DD`) of the ISO week `dayKey` falls in. */ function weekKeyOf(dayKey: string): string { - const ms = Date.parse(`${dayKey}T00:00:00Z`); - const dow = (new Date(ms).getUTCDay() + 6) % 7; - return dayKeyOfUtc(ms - dow * 86_400_000); + return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7)); } function monthKeyOf(dayKey: string): string { @@ -416,8 +415,12 @@ async function readMeasurementDays(args: { return { series: ordered, counts, suffixes }; } -/** `YYYY-MM-DD` of an instant in UTC. */ +/** + * `YYYY-MM-DD` of an instant in UTC. Only for the all-time month read: the + * MONTH rollup tier buckets UTC months, so its range edges are UTC days. + */ function utcDayKey(date: Date): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the MONTH rollup tier buckets UTC months, so this read keys its edges on UTC days return date.toISOString().slice(0, 10); } diff --git a/src/lib/ai/coach/results/projections.ts b/src/lib/ai/coach/results/projections.ts index 04586b1bb..9d8bd4930 100644 --- a/src/lib/ai/coach/results/projections.ts +++ b/src/lib/ai/coach/results/projections.ts @@ -12,7 +12,7 @@ */ import type { Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format"; import type { CoachResultCell, CoachResultTable, @@ -189,17 +189,13 @@ function mondayOfIsoWeek(weekIso: string): string | null { const year = Number(match[1]); const week = Number(match[2]); // Week 1 holds January 4th; its Monday is the ISO year's first Monday. - const jan4 = Date.UTC(year, 0, 4); - const jan4Dow = (new Date(jan4).getUTCDay() + 6) % 7; - const monday = jan4 - jan4Dow * 86_400_000 + (week - 1) * 7 * 86_400_000; - return new Date(monday).toISOString().slice(0, 10); + const jan4 = `${String(year).padStart(4, "0")}-01-04`; + return shiftDateKey(mondayOfDay(jan4), (week - 1) * 7); } /** Monday (`YYYY-MM-DD`) of the ISO week a day key falls in. */ function mondayOfDay(dayKey: string): string { - const ms = Date.parse(`${dayKey}T00:00:00Z`); - const dow = (new Date(ms).getUTCDay() + 6) % 7; - return shiftDateKey(new Date(ms).toISOString().slice(0, 10), -dow); + return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7)); } /** diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts index 6049a5986..2a3e23493 100644 --- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts @@ -59,8 +59,8 @@ export function buildCoreMetricsBlocks( ); const olderSys = sysRows.filter((row) => row.measuredAt < ctx.recentCutoff); const olderDia = diaRows.filter((row) => row.measuredAt < ctx.recentCutoff); - const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS"); - const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA"); + const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS", ctx.userTz); + const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA", ctx.userTz); const usualRange = sysBand || diaBand ? { diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index cc5752508..4df20de6e 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -297,10 +297,12 @@ export function buildDailyBpRows( export function bpBandFromRows( rows: ReadonlyArray<{ measuredAt: Date; value: number }>, type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", + tz: string, ): { low: number; high: number } | null { const byDay = new Map(); for (const r of rows) { - const day = r.measuredAt.toISOString().slice(0, 10); + // The user's day, like every other day mean in the snapshot. + const day = tzDayKey(r.measuredAt, tz); const acc = byDay.get(day) ?? { sum: 0, count: 0 }; acc.sum += r.value; acc.count += 1; diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index 431f7def9..b5ebcbcc9 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -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); } diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index 1779e4df9..e3999a58b 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -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 { diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index d271aeebc..30bdafdb7 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -183,6 +183,7 @@ export async function loadIntradayPulse( dateKey: string, ): Promise { 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); diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts index 58376eb85..833dd4a1a 100644 --- a/src/lib/analytics/mood-prognosis/load.ts +++ b/src/lib/analytics/mood-prognosis/load.ts @@ -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); } diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts index eba1c4abe..667cf37c9 100644 --- a/src/lib/analytics/range-delta.ts +++ b/src/lib/analytics/range-delta.ts @@ -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> { - 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, })); } diff --git a/src/lib/analytics/score/lipids.ts b/src/lib/analytics/score/lipids.ts index 062db9a6b..0c1a3ddca 100644 --- a/src/lib/analytics/score/lipids.ts +++ b/src/lib/analytics/score/lipids.ts @@ -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 ?? ""}`; } diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 1d0eb370a..69d899033 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -31,6 +31,7 @@ import { type ReportSelection, } from "@/lib/report-selection/selection"; import type { ShareContext } from "@/lib/clinician-share/resolve-share-token"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** * v1.28 — metadata for one document on the share's frozen set. NEVER carries @@ -207,7 +208,7 @@ async function loadShareDocuments( title: document.title, kind: document.kind, documentDate: document.documentDate - ? document.documentDate.toISOString().slice(0, 10) + ? dateOnlyKey(document.documentDate) : null, byteSize: document.byteSize, mimeType: document.mimeType, diff --git a/src/lib/cycle/prediction-refresh.ts b/src/lib/cycle/prediction-refresh.ts index 02641f861..ca18119d6 100644 --- a/src/lib/cycle/prediction-refresh.ts +++ b/src/lib/cycle/prediction-refresh.ts @@ -27,6 +27,7 @@ import { addDays } from "@/lib/cycle/day-math"; import { BBT_WINDOW } from "@/lib/cycle/types"; import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; /** Day-log lookback: the calendar's default past span or the symptothermal * window, whichever reaches further back. */ @@ -97,10 +98,9 @@ export async function refreshPredictionCacheForUser( deletedAt: null, type: "WRIST_TEMPERATURE", measuredAt: { - gte: new Date( - Date.parse( - `${addDays(today, -TEMPERATURE_LOOKBACK_DAYS)}T00:00:00Z`, - ), + gte: startOfLocalDayKey( + addDays(today, -TEMPERATURE_LOOKBACK_DAYS), + tz, ), }, }, diff --git a/src/lib/daily/milestones.ts b/src/lib/daily/milestones.ts index 7cfa2d81b..c6df8e160 100644 --- a/src/lib/daily/milestones.ts +++ b/src/lib/daily/milestones.ts @@ -30,6 +30,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector"; +import { shiftDateKey } from "@/lib/tz/format"; /** Closed set of durable-state kinds. Grows by PR, never with a counter field. */ export const MILESTONE_KINDS = [ @@ -101,7 +102,7 @@ function dayKeyToSerial(key: string): number { } function serialToDayKey(serial: number): string { - return new Date(serial * 86_400_000).toISOString().slice(0, 10); + return shiftDateKey("1970-01-01", serial); } /** Shift a UTC-ISO day key by a whole number of days. */ diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 6d0f074a7..9aa25a588 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -120,6 +120,7 @@ import { toProfileSex, type ProfileSex, } from "@/lib/profile/sex"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; @@ -602,6 +603,7 @@ async function buildNutrientWaterBlock( prisma: PrismaClient, userId: string, ): Promise<{ summary: DataSummary; lastSeenAt: string } | null> { + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: 30-day lower bound on stored water day keys; a day either way at the far edge does not change the tile const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); @@ -617,7 +619,7 @@ async function buildNutrientWaterBlock( } const points: DataPoint[] = [...sumByDay.entries()] .map(([day, amount]) => ({ - date: new Date(`${day}T00:00:00.000Z`), + date: dayKeyAsUtcMidnight(day), value: amount, })) .sort((a, b) => a.date.getTime() - b.date.getTime()); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index fe2154b0a..53385ae25 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -73,6 +73,7 @@ import { loadSurgicalHistory, loadLabResults, } from "./clinical-records"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; const DENSE_REPORT_RAW_WINDOW_DAYS = 90; @@ -166,8 +167,8 @@ export async function collectDoctorReportData( // they compare against the window's first and last day in the report zone, // not its instants: west of UTC the window starts hours after UTC midnight, // and a course that ended on the first day read as over before it began. - const firstDay = new Date(`${userDayKey(start, reportTz)}T00:00:00.000Z`); - const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`); + const firstDay = dayKeyAsUtcMidnight(userDayKey(start, reportTz)); + const lastDay = dayKeyAsUtcMidnight(userDayKey(end, reportTz)); const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS; const densePulse = @@ -579,7 +580,7 @@ export async function collectDoctorReportData( emergency, ] = await Promise.all([ gate.admits("CYCLE") - ? buildCycleExportSummary(userId, end.toISOString().slice(0, 10)) + ? buildCycleExportSummary(userId, userDayKey(end, reportTz)) : Promise.resolve(null), gate.admits("LAB_RESULTS") ? loadLabResults(userId, start, end) diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index 3d02c342c..04477338f 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -52,6 +52,7 @@ import { type MedicationStatementFactData, type ObservationFactData, } from "@/lib/validations/inbound-documents"; +import { statedDateKey } from "@/lib/tz/date-only"; const SYSTEM_PROMPT = `You transcribe a photograph, PDF, or scan of a CLINICAL DOCUMENT (a doctor's report or a hospital discharge letter) into structured facts. @@ -103,11 +104,7 @@ NOTE: the document below was produced by automatic OCR, so it may contain garble /** 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; - return parsed.toISOString().slice(0, 10); + return statedDateKey(raw); } export class InboundExtractError extends Error { diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts index ece28bae6..9af617538 100644 --- a/src/lib/documents/ingest.ts +++ b/src/lib/documents/ingest.ts @@ -58,7 +58,7 @@ import type { DocumentSourceSystemValue, InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; -import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -84,7 +84,7 @@ export function personalUploadBucket(userId: string): string { * a stored date goes through `dateOnlyAtNoonUtc`. */ export function isoDateToUtc(value: string): Date { - return new Date(`${value}T00:00:00.000Z`); + return dayKeyAsUtcMidnight(value); } export interface IngestInput { diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 268504ed7..41849833b 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -35,6 +35,7 @@ import type { InboundDocumentKindValue, InboundFactType, } from "@/lib/validations/inbound-documents"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** * The two storage codecs `InboundDocument.contentEncrypted` may carry, @@ -226,12 +227,8 @@ export function serialiseDocument( byteSize: doc.byteSize, status: doc.status as InboundDocumentStatus, providerType: doc.providerType, - reportDate: doc.reportDate - ? doc.reportDate.toISOString().slice(0, 10) - : null, - documentDate: doc.documentDate - ? doc.documentDate.toISOString().slice(0, 10) - : null, + reportDate: doc.reportDate ? dateOnlyKey(doc.reportDate) : null, + documentDate: doc.documentDate ? dateOnlyKey(doc.documentDate) : null, errorReason: doc.errorReason, factCount: counts.factCount, pendingCount: counts.pendingCount, diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index 3c2fb43ed..a38f95d00 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -28,8 +28,8 @@ import { type DailyEnvironmentObservation, } from "@/lib/environment/open-meteo"; import type { EnvironmentLocationSource } from "@/generated/prisma/client"; +import { shiftDateKey } from "@/lib/tz/format"; -const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Default lookback window (days) for the nightly fetch — absorbs the archive * settling lag and any days missed across worker reboots. */ export const ENVIRONMENT_LOOKBACK_DAYS = 7; @@ -63,19 +63,15 @@ interface TravelOverride { /** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */ export function utcDayKey(date: Date): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the environment module keys homeSince and the backfill window on UTC days throughout, and the worker compares against the same keys return date.toISOString().slice(0, 10); } /** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */ export function enumerateDays(start: string, end: string): string[] { const out: string[] = []; - const [sy, sm, sd] = start.split("-").map(Number); - const [ey, em, ed] = end.split("-").map(Number); - let cur = Date.UTC(sy, sm - 1, sd); - const last = Date.UTC(ey, em - 1, ed); - while (cur <= last) { - out.push(new Date(cur).toISOString().slice(0, 10)); - cur += MS_PER_DAY; + for (let cur = start; cur <= end; cur = shiftDateKey(cur, 1)) { + out.push(cur); } return out; } diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 2bb4cd278..75fdfb71d 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -32,6 +32,7 @@ import { type AllergyDTO, type FamilyHistoryEntryDTO, } from "@/lib/records/dto"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** Disclosed in the payload manifest AND mirrored in the export UI copy. */ export const DOCUMENTS_MANIFEST_NOTE = @@ -543,10 +544,8 @@ export async function buildRecordsBackupSection( mimeType: d.mimeType, byteSize: d.byteSize, status: d.status, - reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null, - documentDate: d.documentDate - ? d.documentDate.toISOString().slice(0, 10) - : null, + reportDate: d.reportDate ? dateOnlyKey(d.reportDate) : null, + documentDate: d.documentDate ? dateOnlyKey(d.documentDate) : null, sourceSystem: d.sourceSystem, sourceId: d.sourceId, sourceInstance: d.sourceInstance, diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 3d39eea8c..b7be1b503 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -386,6 +386,7 @@ export const FITBIT_SLEEP_RANGE_DAYS = 30; /** `YYYY-MM-DD` (UTC) for a Fitbit date-path segment. */ export function fitbitDate(d: Date): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates return d.toISOString().slice(0, 10); } diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index f9cc7ea90..923968b2a 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -646,6 +646,7 @@ export function incrementalFilter( case "date": return { field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`, + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates bound: start.toISOString().slice(0, 10), }; case "rollup": diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 7dd52abcc..95f47e60b 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -44,6 +44,7 @@ import { nowProvenanceTimestamp, } from "@/lib/insights/derived/coverage"; import type { Derived } from "@/lib/insights/derived/types"; +import { daysBetweenDateKeys } from "@/lib/tz/format"; /* ── tunables (documented invariants) ────────────────────────────────── */ @@ -1050,7 +1051,5 @@ function runFlag( /** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */ export function dayDiff(from: string, to: string): number { - const a = Date.parse(`${from}T00:00:00Z`); - const b = Date.parse(`${to}T00:00:00Z`); - return Math.round((b - a) / (24 * 60 * 60 * 1000)); + return daysBetweenDateKeys(from, to); } diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts index 71abc304a..025a3abe4 100644 --- a/src/lib/insights/briefing-failure-marker.ts +++ b/src/lib/insights/briefing-failure-marker.ts @@ -61,6 +61,7 @@ export function classifyBriefingFailure(args: { /** UTC YYYY-MM-DD calendar-day key for the marker payload. */ function dateKey(at: Date = new Date()): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day marker key, written and read by this module only, never shown return at.toISOString().slice(0, 10); } diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 9f10da2e0..548f89268 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -123,6 +123,7 @@ import { isFullyCovered, probeRollupCoverage, } from "@/lib/rollups/measurement-coverage"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** * Heavy aggregate row — count/min/max/mean alongside the non-composable @@ -939,7 +940,7 @@ function buildDailyByType( // `bucketStart` is the UTC midnight of the bucketed day — the // same boundary Postgres' `date_trunc('day', ...)` would pick // when the session timezone is UTC (the container default). - day: b.day.toISOString().slice(0, 10), + day: dateOnlyKey(b.day), // Match the legacy `ROUND(AVG, 2)::double precision` semantics // so correlation thresholds tuned against the v1.4.34 output // don't drift on the third decimal. diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 0009faab5..79d81f52d 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -155,6 +155,7 @@ const BRIEFING_REROLL_TEMPERATURE = 0.6; /** UTC YYYY-MM-DD calendar-day key gating the once-per-day briefing re-roll. */ function buildRerollDateKey(at: Date = new Date()): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day re-roll gate key, never shown return at.toISOString().slice(0, 10); } diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 9efd60145..aaed68cfa 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -60,6 +60,7 @@ import type { MeasurementType, } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. @@ -223,6 +224,7 @@ export async function fetchEnvironmentSeries( userId: string, since: Date, ): Promise { + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: lower bound on stored environment day keys; a day either way at the far edge does not change the series const sinceKey = since.toISOString().slice(0, 10); const rows = await prisma.environmentContext.findMany({ where: { userId, date: { gte: sinceKey } }, @@ -758,7 +760,7 @@ function composeRollupDailyMeans( // DAY buckets are minted at UTC midnight; the UTC date IS the bucket's // day key (the same convention graded-series and the derived baselines // read the tier under). - const day = r.bucketStart.toISOString().slice(0, 10); + const day = dateOnlyKey(r.bucketStart); const acc = byDay.get(day) ?? { sum: 0, count: 0 }; acc.sum += sum; acc.count += r.count; diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 49852dd93..0fd87e959 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -41,6 +41,7 @@ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; import { type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { shiftDateKey } from "@/lib/tz/format"; /** * Locale-aware string builder — the same signature the server translator @@ -238,10 +239,7 @@ export interface CorrelationDiscoveryResult { /** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */ function shiftDay(day: string, lagDays: number): string { - const [y, m, d] = day.split("-").map(Number); - const dt = new Date(Date.UTC(y, m - 1, d)); - dt.setUTCDate(dt.getUTCDate() + lagDays); - return dt.toISOString().slice(0, 10); + return shiftDateKey(day, lagDays); } /** diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index fd06e1a8b..27a5c3962 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -36,7 +36,7 @@ import { type MedTargetClass, } from "@/lib/medications/med-target-map"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; -import { dbDate } from "@/lib/tz/date-only"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const MAD_TO_SIGMA = 1.4826; @@ -174,7 +174,10 @@ export async function buildAdherenceStoryline( // v1.39.1 (#1033) — the adherence figure below counts tracked // medications only, so a record-only one cannot be its subject. ...TRACKED_INTAKE_WHERE, - OR: [{ endsOn: null }, { endsOn: { gte: dbDate(todayKey) } }], + OR: [ + { endsOn: null }, + { endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } }, + ], }, select: { name: true, treatmentClass: true }, }); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 821ab52d6..c267e44c4 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -54,6 +54,7 @@ import { type Derived, type DerivedProvenanceSource, } from "./types"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */ const DEFAULT_MAD_K = 3; @@ -255,7 +256,7 @@ export async function readDayMeanSeries( const points = resolved.rows .filter((row) => isPlausibleMetricValue(type, row.mean)) .map((row) => ({ - day: row.bucketStart.toISOString().slice(0, 10), + day: dateOnlyKey(row.bucketStart), mean: row.mean, })); if (points.length > 0) return { points, source: "DAY" }; diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts index 244a06e63..5707ca590 100644 --- a/src/lib/insights/derived/trajectory.ts +++ b/src/lib/insights/derived/trajectory.ts @@ -52,6 +52,7 @@ import { } from "./coverage"; import { readDayMeanSeries, type BaselineProfile } from "./baseline"; import type { Derived, DerivedProvenanceSource } from "./types"; +import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format"; /** Trailing window the fit reads (days). Matches the baseline engine. */ const DEFAULT_WINDOW_DAYS = 30; @@ -266,11 +267,7 @@ export async function computeTrajectory( } // x = days since the FIRST in-window day; y = the per-day mean. - const dayMs = 24 * 60 * 60 * 1000; - const startMs = new Date(`${points[0].day}T00:00:00Z`).getTime(); - const xs = points.map( - (p) => (new Date(`${p.day}T00:00:00Z`).getTime() - startMs) / dayMs, - ); + const xs = points.map((p) => daysBetweenDateKeys(points[0].day, p.day)); const ys = points.map((p) => p.mean); const fit = fitOls(xs, ys); @@ -286,9 +283,7 @@ export async function computeTrajectory( // Project from the last observed day-offset forward over the horizon, // each point carrying the widening prediction band. const lastX = xs[xs.length - 1]; - const lastDayMs = new Date( - `${points[points.length - 1].day}T00:00:00Z`, - ).getTime(); + const lastDay = points[points.length - 1].day; const projection: TrajectoryPoint[] = []; for (let h = 1; h <= horizonDays; h++) { const x = lastX + h; @@ -296,7 +291,7 @@ export async function computeTrajectory( const half = predictionIntervalHalfWidth(fit, x); projection.push({ dayOffset: h, - date: new Date(lastDayMs + h * dayMs).toISOString().slice(0, 10), + date: shiftDateKey(lastDay, h), projected, bandLow: projected - half, bandHigh: projected + half, diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index fb486bbef..4e0830d4b 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -23,6 +23,7 @@ import type { StrainAnchor } from "@/lib/insights/strain-score"; import { computeReadiness, type ReadinessComponent } from "./readiness"; import { resolveCanonicalRecovery } from "./recovery-resolve"; import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; +import { dateOnlyKey } from "@/lib/tz/date-only"; /** A 0–100 wellness score band. Higher is better for recovery; for stress a * higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */ @@ -207,7 +208,7 @@ export async function computeWellnessScore( // truth is the cache row, not a re-derivation here. let anchor: StrainAnchor | null = null; if (type === "STRAIN_SCORE") { - const day = latest.measuredAt.toISOString().slice(0, 10); + const day = dateOnlyKey(latest.measuredAt); const cache = await prisma.strainTrimpCache.findUnique({ where: { userId_day: { userId, day } }, select: { anchor: true }, diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts index 3f4cb2768..3bdc36ea3 100644 --- a/src/lib/insights/glp1-plateau.ts +++ b/src/lib/insights/glp1-plateau.ts @@ -104,6 +104,7 @@ export async function detectGlp1Plateau( drug, doseValue: latestDose.doseValue, doseUnit: latestDose.doseUnit, + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: dose-start label in a prompt; this builder has no zone yet doseSince: latestDose.effectiveFrom.toISOString().slice(0, 10), daysOnDose, weightDeltaKg: Math.round(delta * 10) / 10, diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts index a16ea22cc..c3afb2cfe 100644 --- a/src/lib/insights/labs-changes.ts +++ b/src/lib/insights/labs-changes.ts @@ -84,6 +84,7 @@ const ABSENT: LabChangesSummary = { /** 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); } diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts index 54068b1ba..2fcf162fa 100644 --- a/src/lib/insights/measurement-freshness.ts +++ b/src/lib/insights/measurement-freshness.ts @@ -1,3 +1,4 @@ +import { daysBetweenDateKeys } from "@/lib/tz/format"; /** * How old a reading may be before "today" stops being true about it. * @@ -50,8 +51,6 @@ export function isCurrentForTodayClaim( * answers `null` rather than a fabricated distance. */ export function dayKeyAgeInDays(then: string, today: string): number | null { - const a = Date.parse(`${then}T00:00:00.000Z`); - const b = Date.parse(`${today}T00:00:00.000Z`); - if (!Number.isFinite(a) || !Number.isFinite(b)) return null; - return Math.round((b - a) / 86_400_000); + const days = daysBetweenDateKeys(then, today); + return Number.isFinite(days) ? days : null; } diff --git a/src/lib/insights/mood-crosstab.ts b/src/lib/insights/mood-crosstab.ts index 8e42fc9ff..2e59b2ce5 100644 --- a/src/lib/insights/mood-crosstab.ts +++ b/src/lib/insights/mood-crosstab.ts @@ -17,7 +17,7 @@ import { welchTTest } from "@/lib/insights/correlations"; import { round } from "@/lib/insights/status-shared"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { MS_PER_DAY } from "@/lib/time-constants"; -import { userDayKey } from "@/lib/tz/format"; +import { userDayKey, shiftDateKey } from "@/lib/tz/format"; import type { CrossMetricMeasurement, MoodAggregateEntry, @@ -254,10 +254,7 @@ export function metricDayMap( /** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */ export function shiftDayKey(day: string, lagDays: number): string { - const [y, m, d] = day.split("-").map(Number); - const dt = new Date(Date.UTC(y, m - 1, d)); - dt.setUTCDate(dt.getUTCDate() + lagDays); - return dt.toISOString().slice(0, 10); + return shiftDateKey(day, lagDays); } /** Convert a raw metric value to the row's display unit. */ diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts index 98d63211a..c82b07487 100644 --- a/src/lib/insights/score-row.ts +++ b/src/lib/insights/score-row.ts @@ -34,6 +34,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; * `YYYY-MM-DD`. Resolved in ONE place so all three engines agree. */ export function scoreDayKey(now: Date): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the derived-score engines score UTC days, resolved here in one place (see the doc comment) return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10); } diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 00c2bc400..3cfee08f0 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -71,6 +71,7 @@ import { scoreMeasuredAt, upsertScoreRow, } from "@/lib/insights/score-row"; +import { shiftDateKey } from "@/lib/tz/format"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -395,6 +396,7 @@ export async function computeStrainScore( }, ): Promise { const dayKey = strainDayKey(now); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey) const dayStart = new Date(`${dayKey}T00:00:00.000Z`); const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY); @@ -548,12 +550,7 @@ export async function loadStrainAnchorHistory( now: Date, ): Promise { const dayKey = strainDayKey(now); - const windowStart = new Date( - new Date(`${dayKey}T00:00:00.000Z`).getTime() - - STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY, - ) - .toISOString() - .slice(0, 10); + const windowStart = shiftDateKey(dayKey, -STRAIN_CHRONIC_WINDOW_DAYS); // The chronic window of cached rows, excluding the scored day itself (its // TRIMP is added at compute time so the distribution always reflects the diff --git a/src/lib/insights/streak-detector.ts b/src/lib/insights/streak-detector.ts index bb21c1ffb..0b0db4ac3 100644 --- a/src/lib/insights/streak-detector.ts +++ b/src/lib/insights/streak-detector.ts @@ -30,6 +30,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { buildBaselineBand } from "@/lib/insights/derived/baseline"; +import { daysBetweenDateKeys } from "@/lib/tz/format"; /** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */ export interface StreakPoint { @@ -220,8 +221,6 @@ function buildBandFromSeries( /** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */ function dayGap(earlier: string, later: string): number { - const a = Date.parse(`${earlier}T00:00:00Z`); - const b = Date.parse(`${later}T00:00:00Z`); - if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY; - return Math.round((b - a) / (24 * 60 * 60 * 1000)); + const gap = daysBetweenDateKeys(earlier, later); + return Number.isNaN(gap) ? Number.POSITIVE_INFINITY : gap; } diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts index 887eed35f..04d572859 100644 --- a/src/lib/insights/stress-score.ts +++ b/src/lib/insights/stress-score.ts @@ -133,6 +133,7 @@ async function readIntradaySdnn( now: Date, ): Promise { const dayKey = stressDayKey(now); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey) const dayStart = new Date(`${dayKey}T00:00:00.000Z`); const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY); const rows = await prisma.measurement.findMany({ diff --git a/src/lib/jobs/coach-plan-review.ts b/src/lib/jobs/coach-plan-review.ts index acf500618..9ea17eff0 100644 --- a/src/lib/jobs/coach-plan-review.ts +++ b/src/lib/jobs/coach-plan-review.ts @@ -293,6 +293,7 @@ export async function runCoachPlanReviewTick( timeZone: "UTC", db: prisma, }); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the day series above is read with timeZone UTC, so the plan start is cut on the same calendar const startKey = plan.createdAt.toISOString().slice(0, 10); const before: number[] = []; const after: number[] = []; diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index a2f2e34fa..aad173ceb 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -897,6 +897,7 @@ export async function runOffhostBackup( } const s3 = s3Override ?? (await getS3Client(cfg)); const runStartedAt = options.runStartedAt; + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: off-host object key date, the name the restore drill and the purge look up const dateKey = (runStartedAt ?? now).toISOString().slice(0, 10); const users = await prisma.user.findMany({ diff --git a/src/lib/jobs/offhost-purge.ts b/src/lib/jobs/offhost-purge.ts index a42fcb78f..c7089b98a 100644 --- a/src/lib/jobs/offhost-purge.ts +++ b/src/lib/jobs/offhost-purge.ts @@ -211,7 +211,8 @@ export async function processOffhostPurges( const subject = request.subjectId; const day = request.reason === "data_wiped" && live.has(subject) - ? request.requestedAt.toISOString().slice(0, 10) + ? // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: matches the UTC dates in the off-host object keys + request.requestedAt.toISOString().slice(0, 10) : null; const prior = lastDayToDelete.get(subject); if (prior === undefined) lastDayToDelete.set(subject, day); diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 5795aae78..14c86bb90 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -104,6 +104,7 @@ export async function handleMoodReminderCleanup( try { const cutoff = new Date(); cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: retention cutoff; a day either way does not matter const cutoffIso = cutoff.toISOString().slice(0, 10); const deleted = await p.moodReminderDispatch.deleteMany({ where: { date: { lt: cutoffIso } }, diff --git a/src/lib/jobs/restore-drill.ts b/src/lib/jobs/restore-drill.ts index 4571af414..efd0baf6e 100644 --- a/src/lib/jobs/restore-drill.ts +++ b/src/lib/jobs/restore-drill.ts @@ -167,6 +167,7 @@ export async function runRestoreDrill( } const ageDays = Math.floor( + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: age of a UTC-dated off-host object key (now.getTime() - Date.parse(`${dateKey}T00:00:00Z`)) / (24 * 60 * 60 * 1000), ); diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts index a0b1db8d2..09c8a3bc3 100644 --- a/src/lib/jobs/step-consolidation-repair.ts +++ b/src/lib/jobs/step-consolidation-repair.ts @@ -119,6 +119,7 @@ export async function runStepConsolidationRepairForUser( // in different UTC days, so both instants are tracked). const touchedByUtcDay = new Map(); const markTouched = (instant: Date) => { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the rollup tier keys DAY buckets by UTC day (see the comment above) touchedByUtcDay.set(instant.toISOString().slice(0, 10), instant); }; diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index bd1872833..af2926a09 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -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); } /** diff --git a/src/lib/labs/reading-day.ts b/src/lib/labs/reading-day.ts index 8c3318c4d..47937c191 100644 --- a/src/lib/labs/reading-day.ts +++ b/src/lib/labs/reading-day.ts @@ -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)), }; } diff --git a/src/lib/labs/vault-link.ts b/src/lib/labs/vault-link.ts index 4d4a42d5d..936efd781 100644 --- a/src/lib/labs/vault-link.ts +++ b/src/lib/labs/vault-link.ts @@ -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), }; } diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 1160825e5..5ab65a432 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -5,6 +5,7 @@ import { import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import type { Prisma } from "@/generated/prisma/client"; +import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only"; type Transaction = Prisma.TransactionClient; @@ -266,7 +267,7 @@ export function toManagedProfileView(row: { return { id: row.id, displayName: row.displayName, - dateOfBirth: row.dateOfBirth?.toISOString().slice(0, 10) ?? null, + dateOfBirth: row.dateOfBirth ? dateOnlyKey(row.dateOfBirth) : null, gender: row.gender, locale: row.locale, timezone: row.timezone, @@ -373,7 +374,7 @@ export async function updateManagedProfile(input: { ...(patch.dateOfBirth !== undefined ? { dateOfBirth: patch.dateOfBirth - ? new Date(`${patch.dateOfBirth}T00:00:00.000Z`) + ? dayKeyAsUtcMidnight(patch.dateOfBirth) : null, } : {}), diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 3084f5b12..bb1b69ffc 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -453,6 +453,7 @@ function clip(text: string, max: number): { text: string; truncated: boolean } { } function dateOnly(value: Date | null): string | null { + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: text label mixing date-only columns and instants, read without a zone return value ? value.toISOString().slice(0, 10) : null; } diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index 032b173b8..e5e939613 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -708,6 +708,7 @@ export async function runDenseIntradayRetention( // The fold's canonical timestamp is local-noon of the day; reuse // its UTC date for the resting row's `stats:` key so a re-run // upserts the same row instead of minting a sibling. + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run canonicalTimestamp.toISOString().slice(0, 10), ); await pc.measurement.upsert({ diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts index 6d527017a..f05e95cd0 100644 --- a/src/lib/measurements/intraday-cumulative-profile.ts +++ b/src/lib/measurements/intraday-cumulative-profile.ts @@ -250,6 +250,7 @@ export async function pruneIntradayCumulativeProfiles( retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS, ): Promise { const cutoffKey = shiftDateKey( + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: 400-day retention cutoff; no zone moves it by more than a day (see the doc comment) now.toISOString().slice(0, 10), -retentionDays, ); diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 940162cc9..1ab61da58 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -42,6 +42,7 @@ import { LIVE_ERA_REVISION_ARGS, liveEraStart, } from "@/lib/medications/scheduling/live-era"; +import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; export interface ProjectTodayIntakesResult { projected: number; @@ -238,7 +239,9 @@ export async function projectTodayIntakesAndRecompute(input: { const seenDayKeys = new Set(); const recomputeJobs: Array> = []; for (const m of missing) { - const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; + // The compliance rollup's own day for the slot: two slots on different + // local days that share a UTC date must each get their recompute. + const key = `${m.medicationId}|${dayKeyForScheduledFor(m.scheduledFor, userTz)}`; if (seenDayKeys.has(key)) continue; seenDayKeys.add(key); recomputeJobs.push( diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts index a39910892..ce632e10b 100644 --- a/src/lib/mood/linked-context.ts +++ b/src/lib/mood/linked-context.ts @@ -236,6 +236,7 @@ function localDayWindow(day: string): { from: Date; to: Date } { // helper agrees belongs to this day. Cheaper and far safer than arithmetic // on `new Date(y, m, d)`, which slips by an hour twice a year and has // already been solved once in this codebase. + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset window; rows are kept only when their own local day matches const naive = new Date(`${day}T00:00:00.000Z`); const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000); const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000); diff --git a/src/lib/nutrients/day-bounds.ts b/src/lib/nutrients/day-bounds.ts index ee440c155..64b6c816e 100644 --- a/src/lib/nutrients/day-bounds.ts +++ b/src/lib/nutrients/day-bounds.ts @@ -16,5 +16,6 @@ */ export function maxAcceptableNutrientDay(now: Date): string { const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: upper sanity bound with two days of slack for every real zone (see the doc comment) return limit.toISOString().slice(0, 10); } diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index cc087bbc2..194aaa626 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -181,6 +181,7 @@ export async function recordOuraSyncFailure( } function ymd(d: Date): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates return d.toISOString().slice(0, 10); } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index db71ff320..23db25dbf 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -140,6 +140,7 @@ function toUpsert( // A timed row keys on its instant's UTC date, as it always has. externalId: m.externalId ?? + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: timed rows keep their historical UTC-date externalId so re-syncs upsert (see the comment above) `${resourcePrefix}:${m.day ?? m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, })); diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index 9f9986bd0..a1ebd21e9 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -55,6 +55,7 @@ import type { PrismaClient, RollupGranularity, } from "@/generated/prisma/client"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; /** Granularities that recompute asynchronously via pg-boss. */ const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"]; @@ -250,7 +251,7 @@ export async function recomputeMoodBucketsForEntry( // on `entry.date`) and puts a late-evening / after-midnight local // mood on the calendar day the user logged it in their own timezone. // `bucket_start` encodes the label as its UTC midnight instant. - const bucketStart = new Date(`${dateLabel}T00:00:00.000Z`); + const bucketStart = dayKeyAsUtcMidnight(dateLabel); await client.$executeRaw` WITH aggregate AS ( SELECT @@ -718,7 +719,7 @@ function bucketSpan( ): { from: Date; to: Date } { // Anchor at the label's UTC midnight; its UTC calendar parts ARE the // label's parts, so the existing UTC date math below is exact. - const anchor = new Date(`${dateLabel}T00:00:00.000Z`); + const anchor = dayKeyAsUtcMidnight(dateLabel); switch (granularity) { case "DAY": { return { diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 92526a625..589fee3c5 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -21,6 +21,38 @@ export function isDateOnlyKey(value: string): boolean { return DATE_ONLY_RE.test(value); } +/** + * True for a `YYYY-MM-DD` string that names a real calendar date. + * `2026-02-30` passes the shape check but overflows to 2 March when parsed, + * so a reader would return a different day than the one asked for. + */ +export function isCalendarDateKey(value: string): boolean { + if (!DATE_ONLY_RE.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return ( + !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value) + ); +} + +/** + * The calendar date a free-form date string states, or `null`. An ISO string + * (`2026-06-10`, `2026-06-10T08:00:00+09:00`) states the date it starts with. + * Anything else (`June 10, 2026`) is parsed the way `Date` parses it, as local + * time in the process zone, so the date is read back in that same zone; + * reading it as UTC put it on the previous day for a process east of UTC. + */ +export function statedDateKey(raw: string): string | null { + const trimmed = raw.trim(); + const isoPrefix = /^(\d{4}-\d{2}-\d{2})(?:$|T|\s)/u.exec(trimmed); + if (isoPrefix) { + return isCalendarDateKey(isoPrefix[1]) ? isoPrefix[1] : null; + } + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) return null; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${String(parsed.getFullYear()).padStart(4, "0")}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`; +} + /** The instant a date-only value is stored as: 12:00 UTC on that date. */ export function dateOnlyAtNoonUtc(key: string): Date { return new Date(`${key}T12:00:00.000Z`); @@ -36,11 +68,15 @@ export function dateOnlyKey(value: Date): string { } /** - * The value a `@db.Date` column holds for calendar date `key` (Prisma reads - * and writes those columns as UTC midnight). Use it to compare such a column - * with a day: `endsOn >= dbDate(todayKey)` keeps a course through its last - * day, where comparing against the instant `now` dropped that day. + * Calendar date `key` as UTC midnight. A LABEL, never the instant the day + * began for anyone: it is how a `@db.Date` column holds a date (Prisma reads + * and writes those as UTC midnight), how the day rollups key `bucketStart`, + * and how a day-keyed series places a point on a time axis. Use it to compare + * such a column with a day (`endsOn >= dayKeyAsUtcMidnight(todayKey)` keeps a + * course through its last day, where comparing with the instant `now` dropped + * it) or to build such a label. The first instant of a person's day is + * `startOfLocalDayKey(key, tz)`. */ -export function dbDate(key: string): Date { +export function dayKeyAsUtcMidnight(key: string): Date { return new Date(`${key}T00:00:00.000Z`); } diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 8f026c41d..5e6bde119 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -233,6 +233,27 @@ export function shiftDateKey(dateKey: string, deltaDays: number): string { return dt.toISOString().slice(0, 10); } +/** + * Whole calendar days from `fromKey` to `toKey` (negative when `toKey` is + * earlier). Pure key arithmetic like {@link shiftDateKey}: both keys are + * already resolved calendar dates, so no zone takes part. `NaN` when either + * key is malformed, so a caller decides what a bad key means. + */ +export function daysBetweenDateKeys(fromKey: string, toKey: string): number { + const a = Date.parse(`${fromKey}T00:00:00.000Z`); + const b = Date.parse(`${toKey}T00:00:00.000Z`); + return Math.round((b - a) / 86_400_000); +} + +/** + * Weekday of a calendar date key, 0 = Sunday … 6 = Saturday (the + * `getDay()` / schedule `daysOfWeek` convention). A calendar date has the + * same weekday in every zone, so this needs none. + */ +export function weekdayOfDateKey(dateKey: string): number { + return new Date(`${dateKey}T00:00:00.000Z`).getUTCDay(); +} + /** * Wall-clock hour (0–23) an observer in `tz` reads off the clock at * `date`. Used for the time-of-day greeting so a traveller whose device diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 0fe13f33d..8f8483c47 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { validateEntryInstant } from "./entry-instant"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; export const measurementTypeEnum = z.enum([ "WEIGHT", @@ -869,11 +870,10 @@ export const listMeasurementsSchema = z // the admin drain route, so a malformed CLI invocation has the // same blast radius. Reject the impossible shapes at the // validator instead. - .refine((s) => { - const parsed = new Date(`${s}T00:00:00Z`); - if (Number.isNaN(parsed.getTime())) return false; - return s === parsed.toISOString().slice(0, 10); - }, "dayKey must be a real calendar date (YYYY-MM-DD)") + .refine( + isCalendarDateKey, + "dayKey must be a real calendar date (YYYY-MM-DD)", + ) .optional(), }) .refine( diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index d95cf4302..db58a44b9 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -191,6 +191,7 @@ export interface WithingsSleepSegment { * fetch derives from the same `start` / `now` Dates. */ function ymdUtc(d: Date): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: day-grained summary window matching the unix-second segment window (see the doc comment) return d.toISOString().slice(0, 10); } From e37263e8cc6cd5c7bc5746a31c030b7f0bde6a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:01:17 +0200 Subject: [PATCH 09/10] fix(vaccinations): render a dose date through the calendar-date formatter The noon-UTC rendering trick fixes the day west of UTC and moves it east of UTC+12. A dose date is a calendar date, so it goes through the UTC-pinned formatter the date fields already use, which is right in every zone. --- .../vaccination-document-suggestion.tsx | 12 ++++++----- .../vaccinations/vaccination-list.tsx | 21 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index 0648aab7f..79628668a 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -22,12 +22,13 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; import { useLinkDocumentToVaccination, useVaccinationSuggestion, } from "./use-vaccinations"; import type { VaccinationSuggestion } from "@/lib/vaccinations/document-suggestion"; +import { formatDate as formatCalendarDate } from "@/lib/date-format"; export function VaccinationDocumentSuggestion({ anchor, @@ -39,8 +40,8 @@ export function VaccinationDocumentSuggestion({ documentId: string; disabled?: boolean; }) { - const { t } = useTranslations(); - const format = useFormatters(); + const { t, locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); // Lift the date-only anchor to noon UTC — the rule is a ±7-day window, so the // time never changes the verdict, and noon keeps a viewer's local midnight // from rounding the day either way. The bare date would 422 the endpoint. @@ -60,8 +61,9 @@ export function VaccinationDocumentSuggestion({ const name = candidate.antigenSlug ? t(`vaccinations.catalog.${candidate.antigenSlug}`) : (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed")); - // A date stored at UTC midnight: render the date, not the instant. - return `${name} · ${format.date(`${candidate.occurredAt.slice(0, 10)}T12:00:00.000Z`)}`; + // A date stored at UTC midnight: render the calendar date, not the + // instant (the previous evening west of UTC). + return `${name} · ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`; }; const onPick = (candidate: VaccinationSuggestion) => { diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx index 1f373c61f..9f7c39c26 100644 --- a/src/components/vaccinations/vaccination-list.tsx +++ b/src/components/vaccinations/vaccination-list.tsx @@ -33,12 +33,17 @@ import { AlarmClock, Syringe } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { InfoPopover } from "@/components/ui/info-popover"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import type { SeriesPosition } from "@/lib/vaccinations/series"; import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal"; import { cn } from "@/lib/utils"; import type { Vaccination } from "./use-vaccinations"; import { CatalogInfo, catalogInfoAvailable } from "./catalog-info"; +import { formatDate as formatCalendarDate } from "@/lib/date-format"; type Translate = ReturnType["t"]; @@ -148,8 +153,8 @@ function DoseRow({ row: GroupRow; onEdit?: (record: Vaccination) => void; }) { - const { t } = useTranslations(); - const format = useFormatters(); + const { t, locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); const { record, position } = row; const series = seriesLabel(t, position); const identity = recordIdentity(t, record); @@ -199,9 +204,13 @@ function DoseRow({
- {/* A date, stored at UTC midnight: render its date part, not - the instant (the previous evening west of UTC). */} - {format.date(`${record.occurredAt.slice(0, 10)}T12:00:00.000Z`)} + {/* A date, stored at UTC midnight: render the calendar date, + not the instant (the previous evening west of UTC). */} + {formatCalendarDate( + record.occurredAt.slice(0, 10), + dateFormat, + locale, + )} {record.lotNumber ? ( From 8821fdeb54a380d2d74370c50f3c4d2be4916278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:02:50 +0200 Subject: [PATCH 10/10] test(tz): run core day paths with the host at both edges of the clock The suite and the production image both sit near UTC, so a UTC day and a local day agree for most of every day and day-boundary defects kept passing. The harness sets the host zone in code to Los Angeles and to Kiritimati, gives the account the same zone, and checks a schedule slot, a course's last day, a mood entry, the dashboard summary's mood instant and a document date round trip at the instant where the local and the UTC day differ. Unit tests cover the new date-only and key helpers. --- src/__tests__/tz-day-paths-harness.test.ts | 290 +++++++++++++++++++++ src/lib/tz/__tests__/date-only.test.ts | 52 ++++ 2 files changed, 342 insertions(+) create mode 100644 src/__tests__/tz-day-paths-harness.test.ts create mode 100644 src/lib/tz/__tests__/date-only.test.ts diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts new file mode 100644 index 000000000..7adf653da --- /dev/null +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -0,0 +1,290 @@ +/** + * Core day paths under a host zone far from UTC. + * + * The suite pins the host to UTC (`vitest.config.mts`), and the production + * image to Europe/Berlin. Both sit close enough to UTC that a UTC day and a + * local day agree for most of every day, which is how day-boundary defects + * kept passing: the evening west of UTC and the small hours east of it are + * exactly the hours no fixture lived in. This harness sets the HOST zone + * (`process.env.TZ`, in code; the `TZ=` prefix on the command line does not + * reach Vitest's workers here) to one zone at each edge, gives the account + * the same zone, and checks that a handful of the paths people hit name the + * local day at the edge of it: + * + * - a medication schedule slot, and a course on its last day + * - a mood entry's day + * - the dashboard summary's mood instant (the iOS home tile) + * - a document date from the stated string to the stored instant to the + * DTO + * + * Each zone is tested at the instant where its local day and the UTC day + * differ: 23:30 in Los Angeles (already tomorrow in UTC) and 00:30 on + * Kiritimati (still yesterday in UTC). + */ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => + Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])), + ), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { groupBy: vi.fn(), findMany: vi.fn() }, + medicationIntakeEvent: { + findMany: vi.fn(), + createMany: vi.fn(), + groupBy: vi.fn(), + }, + medication: { findMany: vi.fn() }, + medicationScheduleRevision: { groupBy: vi.fn() }, + user: { findUnique: vi.fn() }, + appSettings: { findUnique: vi.fn(async () => null) }, + $queryRaw: vi.fn(), + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); +vi.mock("@/lib/analytics/mood-series", () => ({ + buildMoodDailySeries: vi.fn(), +})); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as dashboardSummary } from "@/app/api/dashboard/summary/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { serialiseDocument } from "@/lib/documents/store"; +import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; +import { + dateOnlyAtNoonUtc, + dayKeyAsUtcMidnight, + statedDateKey, +} from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; +import { localHmAsUtc } from "@/lib/tz/local-day"; + +interface EdgeCase { + zone: string; + /** An instant where the local day and the UTC day differ. */ + now: Date; + /** The local calendar day at `now`. */ + localDay: string; + /** The UTC calendar day at `now`, for the record. */ + utcDay: string; +} + +const EDGES: EdgeCase[] = [ + { + zone: "America/Los_Angeles", + now: new Date("2026-07-03T06:30:00.000Z"), // 2 July, 23:30 local + localDay: "2026-07-02", + utcDay: "2026-07-03", + }, + { + zone: "Pacific/Kiritimati", + now: new Date("2026-07-02T10:30:00.000Z"), // 3 July, 00:30 local + localDay: "2026-07-03", + utcDay: "2026-07-02", + }, +]; + +describe.each(EDGES)("day paths with the host in $zone", (edge) => { + let previousTz: string | undefined; + + beforeAll(() => { + previousTz = process.env.TZ; + process.env.TZ = edge.zone; + }); + afterAll(() => { + if (previousTz === undefined) delete process.env.TZ; + else process.env.TZ = previousTz; + }); + + it("runs with the host clock in the zone (the harness itself)", () => { + // If the zone did not take, every assertion below would be testing UTC. + expect(edge.now.getDate()).toBe(Number(edge.localDay.slice(8, 10))); + expect(edge.localDay).not.toBe(edge.utcDay); + }); + + describe("medication schedule day", () => { + it("keys a slot on the local day it falls on", () => { + expect(dayKeyForScheduledFor(edge.now, edge.zone)).toBe(edge.localDay); + }); + + it("places today's 08:00 slot on the local day", () => { + const slot = localHmAsUtc(edge.now, edge.zone, 8, 0); + expect(userDayKey(slot, edge.zone)).toBe(edge.localDay); + }); + + it("keeps a course running through its last local day", () => { + const status = resolveIntakeActionability( + { + active: true, + trackIntake: true, + startsOn: null, + // `endsOn` is a @db.Date column: the calendar date as UTC midnight. + endsOn: dayKeyAsUtcMidnight(edge.localDay), + }, + edge.now, + edge.zone, + ); + expect(status.courseStatus).toBe("CURRENT"); + expect(status.intakeActionable).toBe(true); + }); + }); + + describe("mood day", () => { + it("files an entry under the local day it was logged", () => { + expect(moodDateKey(edge.now, edge.zone)).toBe(edge.localDay); + }); + }); + + describe("dashboard summary day", () => { + beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + heightCm: null, + } as never); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER", + displayName: null, + timezone: edge.zone, + }, + } as never); + }); + + it("puts the mood card's instant on the entry's local day", async () => { + vi.mocked(buildMoodDailySeries).mockResolvedValue({ + entries: [{ date: edge.localDay, score: 4, samples: 1 }], + summary: null, + entryCount: 1, + source: "rollup", + } as never); + const call = dashboardSummary as unknown as ( + req: NextRequest, + ) => Promise; + const res = await call( + new NextRequest("http://localhost/api/dashboard/summary"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metrics: Array<{ kind: string; updatedAt: string | null }> }; + }; + const mood = body.data.metrics.find((m) => m.kind === "mood"); + expect(mood?.updatedAt).toBeTruthy(); + const at = new Date(mood!.updatedAt!); + // Read the way a client in the zone reads it: the host clock and the + // account zone agree here. + expect(userDayKey(at, edge.zone)).toBe(edge.localDay); + expect( + `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`, + ).toBe(edge.localDay); + }); + }); + + describe("document date round trip", () => { + function documentRow(documentDate: Date) { + return { + id: "doc-1", + userId: "user-1", + kind: "OTHER", + title: null, + filename: "report.pdf", + mimeType: "application/pdf", + byteSize: 1, + status: "STORED", + providerType: null, + reportDate: documentDate, + documentDate, + errorReason: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, + sourceSystem: null, + sourceId: null, + createdAt: edge.now, + updatedAt: edge.now, + } as never; + } + + it("reads a written-out date as the date it states", () => { + // Parsed as local time in the host zone; read back in UTC this was the + // previous day east of UTC. + expect(statedDateKey("July 3, 2026")).toBe("2026-07-03"); + expect(statedDateKey("2026-07-03")).toBe("2026-07-03"); + expect(statedDateKey("2026-07-03T08:00:00+09:00")).toBe("2026-07-03"); + }); + + it("stores a stated date and serves the same date back", () => { + const stored = dateOnlyAtNoonUtc(edge.localDay); + const dto = serialiseDocument(documentRow(stored), { + factCount: 0, + pendingCount: 0, + }); + expect(dto.documentDate).toBe(edge.localDay); + expect(dto.reportDate).toBe(edge.localDay); + }); + + it("serves a row written before the noon anchor on its date too", () => { + const legacy = dayKeyAsUtcMidnight(edge.localDay); + const dto = serialiseDocument(documentRow(legacy), { + factCount: 0, + pendingCount: 0, + }); + expect(dto.documentDate).toBe(edge.localDay); + }); + }); +}); diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts new file mode 100644 index 000000000..6c09a06b2 --- /dev/null +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + dateOnlyAtNoonUtc, + dateOnlyKey, + dayKeyAsUtcMidnight, + isCalendarDateKey, + isDateOnlyKey, + statedDateKey, +} from "../date-only"; +import { daysBetweenDateKeys, weekdayOfDateKey } from "../format"; + +describe("date-only values", () => { + it("stores a date at noon UTC and reads it back", () => { + const stored = dateOnlyAtNoonUtc("2026-06-10"); + expect(stored.toISOString()).toBe("2026-06-10T12:00:00.000Z"); + expect(dateOnlyKey(stored)).toBe("2026-06-10"); + }); + + it("reads a row stored at UTC midnight as the same date", () => { + expect(dateOnlyKey(dayKeyAsUtcMidnight("2026-06-10"))).toBe("2026-06-10"); + }); + + it("tells a shape from a real calendar date", () => { + expect(isDateOnlyKey("2026-02-30")).toBe(true); + expect(isCalendarDateKey("2026-02-30")).toBe(false); + expect(isCalendarDateKey("2028-02-29")).toBe(true); + expect(isCalendarDateKey("2026-6-1")).toBe(false); + }); + + it("reads the date a string states", () => { + expect(statedDateKey("2026-06-10")).toBe("2026-06-10"); + expect(statedDateKey("2026-06-10T23:30:00-07:00")).toBe("2026-06-10"); + expect(statedDateKey(" 2026-06-10 ")).toBe("2026-06-10"); + expect(statedDateKey("June 10, 2026")).toBe("2026-06-10"); + expect(statedDateKey("2026-02-30")).toBeNull(); + expect(statedDateKey("not a date")).toBeNull(); + }); +}); + +describe("calendar key arithmetic", () => { + it("counts whole days between keys across a DST change", () => { + expect(daysBetweenDateKeys("2026-03-28", "2026-03-30")).toBe(2); + expect(daysBetweenDateKeys("2026-03-30", "2026-03-28")).toBe(-2); + expect(daysBetweenDateKeys("2025-12-31", "2026-01-01")).toBe(1); + expect(daysBetweenDateKeys("bad", "2026-01-01")).toBeNaN(); + }); + + it("names the weekday of a key", () => { + expect(weekdayOfDateKey("2026-10-01")).toBe(4); // Thursday + expect(weekdayOfDateKey("2026-10-04")).toBe(0); // Sunday + }); +});