From 9d7c99e60c2b4e20cfa538569eac15a49ec4a9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:50:34 +0100 Subject: [PATCH] fix(vaccinations): date a dose by its calendar day on every surface A dose date is stored at UTC midnight. The health record PDF, the share page, the related-records list, the document link chips and both dose pickers formatted it as an instant in the reader's zone, so across the Americas each of them showed the day before the vaccination list did. The booster anchor added the interval to the stored instant, which put the first due day a day early there too. The report and share page print dose dates through the calendar-date formatter, the link surfaces through `useCalendarDate()`, and the booster counts its interval from the dose's date to the start of that day in the person's zone. --- .../__tests__/clinician-view.test.tsx | 22 ++++++++ src/components/clinician/clinician-view.tsx | 2 +- src/components/clinician/history-sections.tsx | 7 +-- .../documents/document-record-links.tsx | 7 ++- src/components/links/related-records.tsx | 16 +++--- .../__tests__/doctor-report-pdf-core.test.ts | 32 ++++++++++++ .../clinical-records-notes-section.ts | 16 ++++-- .../__tests__/booster-mint.test.ts | 50 +++++++++++++++++++ src/lib/vaccinations/booster-mint.ts | 14 ++++-- 9 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/booster-mint.test.ts diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index c6ef44d2f..21666b4eb 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -519,6 +519,28 @@ describe("", () => { expect(html).toContain("05/14/1960"); expect(html).not.toContain("05/13/1960"); }); + + it("dates a vaccination on its own day west of UTC", () => { + const html = render( + makeReport({ + immunizations: [ + { + // A dose date at UTC midnight: the evening before in New York. + occurredAt: "2026-04-11T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Influenza", + lotNumber: null, + site: null, + practitionerName: null, + series: [], + }, + ], + }), + { timezone: "America/New_York", locale: "en" }, + ); + expect(html).toContain("04/11/2026"); + expect(html).not.toContain("04/10/2026"); + }); }); /** diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 141456606..5480c050d 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -303,7 +303,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtDate={fmtDate} + fmtCalendarDate={fmtCalendarDate} /> diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index dd6a25138..8b66da8b4 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -302,12 +302,13 @@ export function ImmunizationsSection({ t, report, scope, - fmtDate, + fmtCalendarDate, }: { t: Translate; report: DoctorReportData; scope: LeafScope; - fmtDate: (iso: string) => string; + /** A dose date is a calendar date at UTC midnight, never an instant. */ + fmtCalendarDate: (iso: string) => string; }) { const doses = report.immunizations ?? []; @@ -342,7 +343,7 @@ export function ImmunizationsSection({ : (dose.vaccineName ?? "—") } value={compose([ - fmtDate(dose.occurredAt), + fmtCalendarDate(dose.occurredAt), doseDisplay(dose.series), dose.lotNumber ? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}` diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index c3cbd6827..fcfce50e6 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -46,6 +46,7 @@ import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; @@ -161,6 +162,7 @@ export function DocumentRecordLinks({ }) { const { t } = useTranslations(); const format = useFormatters(); + const calendarDate = useCalendarDate(); const { user } = useAuth(); const vaccinationsOn = user?.modules?.vaccinations !== false; const anchor = doc.documentDate ?? doc.reportDate; @@ -205,6 +207,7 @@ export function DocumentRecordLinks({ anchor, labels, ); + // A dose date is a calendar date at UTC midnight, never an instant. const doseOptions = recordOptions( (vaccinations.data?.vaccinations ?? []).map((dose) => ({ id: dose.id, @@ -213,7 +216,7 @@ export function DocumentRecordLinks({ href: doseHref(dose.id), })), anchor, - labels, + { ...labels, date: calendarDate }, ); const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; @@ -324,7 +327,7 @@ export function DocumentRecordLinks({ {link.occurredAt ? ( - {format.date(link.occurredAt)} + {calendarDate(link.occurredAt)} ) : null} diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx index 85ff071f4..682aa3b1a 100644 --- a/src/components/links/related-records.tsx +++ b/src/components/links/related-records.tsx @@ -37,6 +37,7 @@ import { type EntityLinkOption, } from "@/components/links/entity-link-picker"; import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { pickerPivot, useLinkPickerPages, @@ -133,6 +134,7 @@ export function RelatedRecordsList({ }) { const { t } = useTranslations(); const format = useFormatters(); + const calendarDate = useCalendarDate(); const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind)); if (rows.length === 0) return null; return ( @@ -142,7 +144,12 @@ export function RelatedRecordsList({ const via = viaText(t, row.via); const meta = [ kindText(t, row.kind), - row.date ? format.date(row.date) : null, + row.date + ? // A dose date is a calendar date at UTC midnight. + row.kind === "vaccination" + ? calendarDate(row.date) + : format.date(row.date) + : null, via, ] .filter(Boolean) @@ -381,7 +388,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) { function DosePicker({ selected, onChange, anchor, known }: PickerProps) { const { t } = useTranslations(); - const format = useFormatters(); + const calendarDate = useCalendarDate(); const doses = useVaccinations(null, true); const options = recordOptions( (doses.data?.vaccinations ?? []).map((dose) => ({ @@ -393,10 +400,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) { href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`, })), anchor, - { - suggested: t("links.picker.suggested"), - date: (iso: string) => format.date(iso), - }, + { suggested: t("links.picker.suggested"), date: calendarDate }, ); return ( { expect(text).toContain("05/14/1960"); expect(text).not.toContain("05/13/1960"); }); + + it("dates a vaccination on its own day west of UTC", async () => { + const { t } = getServerTranslator("en"); + const text = await extractText( + renderDoctorReportPdfBytes( + makeData({ + immunizations: [ + { + // A dose date at UTC midnight: 10 April, 20:00 in New York. + occurredAt: "2026-04-11T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Influenza", + lotNumber: null, + site: null, + practitionerName: null, + series: [], + }, + ], + }), + { + t, + locale: "en", + now: FIXED_NOW, + userTz: "America/New_York", + timeFormat: "AUTO", + dateFormat: "AUTO", + }, + ), + ); + expect(text).toContain("04/11/2026"); + expect(text).not.toContain("04/10/2026"); + }); }); // ── script coverage ── the report is read in every shipped locale and carries diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 59b2c265e..40c314fd4 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -23,8 +23,17 @@ export function buildClinicalRecordsNotesSection( context: DoctorReportPdfRenderContext, state: DoctorReportPdfCursorState, ): DoctorReportPdfCursorState { - const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } = - context; + const { + doc, + data, + t, + num, + fmtDate, + fmtCalendarDate, + margin, + tableBottomMargin, + ensureSpace, + } = context; let y = state.y; // v1.17.1 — structured lab-results section. Populated when the `labs` @@ -422,7 +431,8 @@ export function buildClinicalRecordsNotesSection( }; const immunizationRows = data.immunizations.map((row) => [ - fmtDate(row.occurredAt), + // A dose date is a calendar date at UTC midnight, never an instant. + fmtCalendarDate(row.occurredAt), row.antigenSlug ? t(`vaccinations.catalog.${row.antigenSlug}`) : (row.vaccineName ?? "—"), diff --git a/src/lib/vaccinations/__tests__/booster-mint.test.ts b/src/lib/vaccinations/__tests__/booster-mint.test.ts new file mode 100644 index 000000000..48c2f4364 --- /dev/null +++ b/src/lib/vaccinations/__tests__/booster-mint.test.ts @@ -0,0 +1,50 @@ +/** + * The first booster is due one interval after the dose. The dose date is a + * calendar date stored at UTC midnight; adding the interval to that instant + * put the anchor on the evening before for anyone west of UTC, so the + * reminder came due a day early there. + */ +import { describe, expect, it, vi } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +function fakeTx() { + const created: Array> = []; + const tx = { + vaccinationRecord: { + findFirst: vi.fn(async () => ({ + id: "dose-1", + antigenSlug: "tetanus", + customVaccineId: null, + occurredAt: new Date("2026-11-11T00:00:00.000Z"), + })), + update: vi.fn(async () => ({})), + }, + measurementReminder: { + findFirst: vi.fn(async () => null), + create: vi.fn(async ({ data }: { data: Record }) => { + created.push(data); + return { id: "reminder-1" }; + }), + }, + }; + return { tx: tx as unknown as Prisma.TransactionClient, created }; +} + +describe("mintOrReanchorBooster", () => { + it("counts the interval from the dose's own date west of UTC", async () => { + const { tx, created } = fakeTx(); + // 12 months → round(12 × 30.4375) = 365 days after 11 November 2026. + await mintOrReanchorBooster( + tx, + "u1", + { vaccinationId: "dose-1", intervalMonths: 12, label: "Tetanus" }, + "America/New_York", + ); + expect(created[0].anchorDate).toEqual( + startOfLocalDayKey("2027-11-11", "America/New_York"), + ); + }); +}); diff --git a/src/lib/vaccinations/booster-mint.ts b/src/lib/vaccinations/booster-mint.ts index 871fc68f1..8faeeb47a 100644 --- a/src/lib/vaccinations/booster-mint.ts +++ b/src/lib/vaccinations/booster-mint.ts @@ -28,6 +28,9 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { shiftDateKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { componentsForDose, loadCustomVaccineLookup, @@ -111,10 +114,13 @@ export async function mintOrReanchorBooster( // dose date plus the interval — a never-satisfied rolling reminder is due at // its anchor, and the dose date itself is not when the next shot is due. Once // the person logs that next dose, `satisfyReminder` re-anchors on the satisfy - // instant and this anchor no longer matters. - const DAY_MS = 24 * 60 * 60 * 1000; - const anchorDate = new Date( - record.occurredAt.getTime() + intervalDays * DAY_MS, + // instant and this anchor no longer matters. The dose date is a calendar + // date at UTC midnight, so the interval counts from its date part to the + // start of that day in the person's zone, where adding days to the stored + // instant fell on the evening before west of UTC. + const anchorDate = startOfLocalDayKey( + shiftDateKey(dateOnlyKey(record.occurredAt), intervalDays), + timezone, ); const nextDueAt = computeReminderNextDueAt( {