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 } }, }); }