From 9afcfd43514f90da31fe58224ab226a91f2c569b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:47:53 +0100 Subject: [PATCH] fix(day): read dose and allergy dates as calendar dates A dose date is stored at UTC midnight and an allergy onset at noon UTC; neither has a time of day. The day view, the day index, the visit preparation and the timeline treated both as instants in the person's zone: west of UTC a dose landed on the previous day, and the day view printed a clock time nobody entered (01:00 for a dose in Berlin, 13:00 for an onset). These readers now take the date part, as the vaccination list and the allergy form already do: the day and the index query the UTC calendar day (the window documents already use), the events carry no instant, and the timeline dates both kinds by their key. Correct for midnight and noon rows in every zone, without touching stored data. --- .../day/__tests__/date-only-sections.test.ts | 177 ++++++++++++++++++ src/lib/day/day-index.ts | 48 +++-- src/lib/day/records.ts | 37 ++-- src/lib/day/visit-preparation.ts | 9 +- src/lib/timeline/lanes.ts | 4 +- src/lib/timeline/readiness.ts | 6 +- 6 files changed, 244 insertions(+), 37 deletions(-) create mode 100644 src/lib/day/__tests__/date-only-sections.test.ts diff --git a/src/lib/day/__tests__/date-only-sections.test.ts b/src/lib/day/__tests__/date-only-sections.test.ts new file mode 100644 index 000000000..90b22fb76 --- /dev/null +++ b/src/lib/day/__tests__/date-only-sections.test.ts @@ -0,0 +1,177 @@ +/** + * A vaccination and an allergy onset carry a date, not a time of day: a dose + * is stored at UTC midnight on its date, an onset typed in the form at noon + * UTC. The day view, the day index and the timeline used to read both as + * instants in the person's zone, so west of UTC a dose sat on the previous + * day, and the day view printed a clock time nobody entered ("01:00" for a + * dose in Berlin). + * + * The rows below are filtered by the window each reader asks for, so the + * assertions read which day a row lands on, not how the query is spelled. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record; + +const { tables } = vi.hoisted(() => ({ + tables: {} as Record, +})); + +function inWindow(value: unknown, window: unknown): boolean { + if (window === undefined) return true; + if (!(value instanceof Date)) return false; + const { gte, lt } = window as { gte?: Date; lt?: Date }; + return (!gte || value >= gte) && (!lt || value < lt); +} + +vi.mock("@/lib/db", () => { + const model = (name: string) => ({ + findMany: async (args?: { where?: Record }) => { + const where = args?.where ?? {}; + return (tables[name] ?? []).filter( + (row) => + inWindow(row.occurredAt, where.occurredAt) && + inWindow(row.onsetAt, where.onsetAt), + ); + }, + }); + return { + prisma: new Proxy({} as Record, { + get: (_target, name: string) => model(name), + }), + }; +}); + +vi.mock("@/lib/day/daily-stats", () => ({ + readLocalDaysWithReadings: async () => [], +})); +vi.mock("@/lib/day/notable", () => ({ loadNotableRange: async () => [] })); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: async () => null, +})); + +import { RECORD_SECTION_READERS, type DayFrame } from "@/lib/day/records"; +import { loadDayIndex } from "@/lib/day/day-index"; +import { loadSinceLastVisit } from "@/lib/day/visit-preparation"; +import type { DaySectionKey } from "@/lib/day/contract"; +import type { DayAccess } from "@/lib/day/sections"; +import { LANE_READERS } from "@/lib/timeline/lanes"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { shiftDateKey } from "@/lib/tz/format"; + +const TZ = "America/New_York"; + +function frame(day: string): DayFrame { + return { + userId: "u1", + day, + tz: TZ, + dayStart: startOfLocalDayKey(day, TZ), + dayEnd: startOfLocalDayKey(shiftDateKey(day, 1), TZ), + documentsReadable: false, + }; +} + +beforeEach(() => { + tables.vaccinationRecord = [ + { + id: "dose-1", + occurredAt: new Date("2026-11-11T00:00:00.000Z"), + vaccineName: "Influenza", + antigenSlug: null, + doseNumber: null, + noteEncrypted: null, + customVaccine: null, + }, + ]; + tables.allergy = [ + { + id: "allergy-1", + substance: "Penicillin", + onsetAt: new Date("2026-11-11T12:00:00.000Z"), + severity: null, + status: "ACTIVE", + }, + ]; +}); + +describe("the day view", () => { + it("files a dose and an allergy onset under their own date west of UTC", async () => { + const dose = await RECORD_SECTION_READERS.vaccinations!( + frame("2026-11-11"), + ); + const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11")); + expect(dose.events.map((e) => e.id)).toEqual(["dose-1"]); + expect(onset.events.map((e) => e.id)).toEqual(["allergy-1"]); + + const before = await RECORD_SECTION_READERS.vaccinations!( + frame("2026-11-10"), + ); + expect(before.events).toEqual([]); + }); + + it("gives neither a clock time", async () => { + const dose = await RECORD_SECTION_READERS.vaccinations!( + frame("2026-11-11"), + ); + const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11")); + expect(dose.events[0].at).toBeNull(); + expect(onset.events[0].at).toBeNull(); + }); +}); + +describe("the day index", () => { + it("marks the date itself, not the evening before", async () => { + const access = { + readable: new Set(["vaccinations", "allergies"]), + notShared: [], + modules: {}, + moduleOff: [], + } as unknown as DayAccess; + const index = await loadDayIndex({ + recordId: "u1", + from: "2026-11-01", + to: "2026-11-30", + access, + tz: TZ, + }); + expect(index.days).toEqual({ + "2026-11-11": ["allergies", "vaccinations"], + }); + }); +}); + +describe("the visit preparation", () => { + it("lists a dose under its own date", async () => { + const window = await loadSinceLastVisit({ + recordId: "u1", + access: { + readable: new Set(["vaccinations"]), + notShared: [], + modules: {}, + moduleOff: [], + } as unknown as DayAccess, + tz: TZ, + today: "2026-11-30", + from: "2026-11-11", + to: "2026-11-30", + }); + expect(window.changes.map((c) => [c.date, c.kind])).toEqual([ + ["2026-11-11", "vaccination"], + ]); + }); +}); + +describe("the timeline", () => { + it("starts a dose and an allergy on their own date", async () => { + const laneFrame = { + userId: "u1", + tz: TZ, + access: {} as never, + }; + const doses = await LANE_READERS.vaccinations(laneFrame); + const allergies = await LANE_READERS.allergies(laneFrame); + expect(doses.items[0].start).toBe("2026-11-11"); + expect(allergies.items[0].start).toBe("2026-11-11"); + }); +}); diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts index 70b7f7504..28ae9d84d 100644 --- a/src/lib/day/day-index.ts +++ b/src/lib/day/day-index.ts @@ -102,6 +102,28 @@ const instantWindow = (frame: IndexFrame) => ({ lt: frame.end, }); +/** + * The window as UTC calendar days, for a column that holds a date rather + * than an instant (a dose date at UTC midnight, an allergy onset or a + * document date at noon UTC). Its date part is its day in every zone. + */ +const calendarWindow = (frame: IndexFrame) => ({ + gte: dayKeyAsUtcMidnight(frame.from), + lt: dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)), +}); + +/** Mark the calendar date of every date-only value. */ +function markDates( + frame: IndexFrame, + section: DaySectionKey, + dates: ReadonlyArray, +): void { + for (const date of dates) { + if (date) + mark(frame.marks, dateOnlyKey(date), section, frame.from, frame.to); + } +} + /** The per-section readers of the index. */ const INDEX_READERS: Partial< Record Promise> @@ -232,11 +254,11 @@ const INDEX_READERS: Partial< where: { userId: frame.userId, deletedAt: null, - onsetAt: instantWindow(frame), + onsetAt: calendarWindow(frame), }, select: { onsetAt: true }, }); - markInstants( + markDates( frame, "allergies", rows.map((r) => r.onsetAt), @@ -278,11 +300,11 @@ const INDEX_READERS: Partial< where: { userId: frame.userId, deletedAt: null, - occurredAt: instantWindow(frame), + occurredAt: calendarWindow(frame), }, select: { occurredAt: true }, }); - markInstants( + markDates( frame, "vaccinations", rows.map((r) => r.occurredAt), @@ -305,24 +327,20 @@ const INDEX_READERS: Partial< ); }, async documents(frame) { - const start = dayKeyAsUtcMidnight(frame.from); - const end = dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)); + const days = calendarWindow(frame); const rows = await prisma.inboundDocument.findMany({ where: { userId: frame.userId, deletedAt: null, - OR: [ - { reportDate: { gte: start, lt: end } }, - { reportDate: null, documentDate: { gte: start, lt: end } }, - ], + OR: [{ reportDate: days }, { reportDate: null, documentDate: days }], }, select: { reportDate: true, documentDate: true }, }); - for (const row of rows) { - const date = row.reportDate ?? row.documentDate; - if (date) - mark(frame.marks, dateOnlyKey(date), "documents", frame.from, frame.to); - } + markDates( + frame, + "documents", + rows.map((r) => r.reportDate ?? r.documentDate), + ); }, async mood(frame) { const rows = await prisma.moodEntry.findMany({ diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index ec2073c8e..139cdd416 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -130,6 +130,19 @@ function dateKeyOrNull(value: Date | null): string | null { return value ? dateOnlyKey(value) : null; } +/** + * The day as a window over a column that holds a date, not an instant (a + * dose date at UTC midnight, an allergy onset or a document date at noon + * UTC). Its date part decides the day, whatever the person's zone; the + * local day window put a UTC-midnight dose on the previous day west of UTC. + */ +function calendarDayWindow(day: string): { gte: Date; lt: Date } { + return { + gte: dayKeyAsUtcMidnight(day), + lt: dayKeyAsUtcMidnight(shiftDateKey(day, 1)), + }; +} + /* ─── medications ─────────────────────────────────────────────────────────── */ async function medicationsPart(frame: DayFrame): Promise { @@ -578,15 +591,16 @@ async function allergiesPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - onsetAt: { gte: frame.dayStart, lt: frame.dayEnd }, + onsetAt: calendarDayWindow(frame.day), }, - select: { id: true, substance: true, onsetAt: true, severity: true }, + select: { id: true, substance: true, severity: true }, }); return { running: [], events: rows.map((row) => event({ - at: row.onsetAt ? row.onsetAt.toISOString() : null, + // A date without a time of day: no clock time to print. + at: null, kind: "allergyOnset", section: "allergies", id: row.id, @@ -648,11 +662,10 @@ async function vaccinationsPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - occurredAt: { gte: frame.dayStart, lt: frame.dayEnd }, + occurredAt: calendarDayWindow(frame.day), }, select: { id: true, - occurredAt: true, vaccineName: true, antigenSlug: true, doseNumber: true, @@ -670,7 +683,8 @@ async function vaccinationsPart(frame: DayFrame): Promise { running: [], events: rows.map((row) => event({ - at: row.occurredAt.toISOString(), + // A dose carries a date, never a time of day. + at: null, kind: "vaccination", section: "vaccinations", id: row.id, @@ -761,18 +775,13 @@ async function checkupsPart(frame: DayFrame): Promise { async function documentsPart(frame: DayFrame): Promise { // A document's date is a calendar date stored at noon UTC (older rows at - // UTC midnight): both anchors fall inside this UTC calendar day, and the - // date key decides. The stated report date wins over the filing date. - const start = dayKeyAsUtcMidnight(frame.day); - const end = dayKeyAsUtcMidnight(shiftDateKey(frame.day, 1)); + // UTC midnight). The stated report date wins over the filing date. + const day = calendarDayWindow(frame.day); const rows = await prisma.inboundDocument.findMany({ where: { userId: frame.userId, deletedAt: null, - OR: [ - { reportDate: { gte: start, lt: end } }, - { reportDate: null, documentDate: { gte: start, lt: end } }, - ], + OR: [{ reportDate: day }, { reportDate: null, documentDate: day }], }, select: { id: true, diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index f3ab26c74..533f530d9 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -240,7 +240,12 @@ async function vaccinationChanges(w: Window): Promise { where: { userId: w.userId, deletedAt: null, - occurredAt: { gte: w.start, lt: w.end }, + // A dose date is stored at UTC midnight: its date part is its day in + // every zone, so the window is UTC calendar days, not local instants. + occurredAt: { + gte: dayKeyAsUtcMidnight(w.from), + lt: dayKeyAsUtcMidnight(shiftDateKey(w.to, 1)), + }, }, select: { id: true, @@ -251,7 +256,7 @@ async function vaccinationChanges(w: Window): Promise { }, }); return rows.map((row) => - change(userDayKey(row.occurredAt, w.tz), "vaccinations", { + change(dateOnlyKey(row.occurredAt), "vaccinations", { kind: "vaccination", id: row.id, title: diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index eeb0f9053..3d440a628 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -204,7 +204,7 @@ async function allergiesLane(frame: LaneFrame): Promise { item({ id: r.id, kind: "allergy", - start: userDayKey(r.onsetAt, frame.tz), + start: dateOnlyKey(r.onsetAt), end: null, open: r.status === "ACTIVE", label: r.substance, @@ -388,7 +388,7 @@ async function vaccinationsLane(frame: LaneFrame): Promise { item({ id: r.id, kind: "vaccination", - start: userDayKey(r.occurredAt, frame.tz), + start: dateOnlyKey(r.occurredAt), end: null, open: false, label: r.vaccineName ?? r.customVaccine?.name ?? r.antigenSlug ?? "", diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts index a65e313d4..db122b9a5 100644 --- a/src/lib/timeline/readiness.ts +++ b/src/lib/timeline/readiness.ts @@ -271,9 +271,7 @@ async function allergiesLane(frame: Frame) { const detail = { key: "allergies", params: { count: rows.length } }; const dated = rows.filter((r) => r.onsetAt !== null); if (dated.length > 0) { - const since = dated - .map((r) => userDayKey(r.onsetAt as Date, frame.tz)) - .sort()[0]; + const since = dated.map((r) => dateOnlyKey(r.onsetAt as Date)).sort()[0]; return lane("carries", rows.length, since, { detail }); } // Undated allergies are standing items, not a shortcoming: no gap. @@ -351,7 +349,7 @@ async function vaccinationsLane(frame: Frame) { gaps: [gap("vaccinationsEmpty", 0, "/vaccinations")], }); } - return lane("carries", count, userDayKey(first.occurredAt, frame.tz), { + return lane("carries", count, dateOnlyKey(first.occurredAt), { detail: { key: "vaccinations", params: { count } }, }); }