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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:50:34 +01:00
1 parent 9afcfd4351
commit 9d7c99e60c
9 files changed
+147 -19

No files matched your search

@@ -519,6 +519,28 @@ describe("<ClinicianView>", () => {
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");
});
});
/**
+1 -1
View File
@@ -303,7 +303,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={fmtCalendarDate}
/>
<AllergiesSection t={t} report={report} scope={scope} />
@@ -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}`
@@ -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({
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
{calendarDate(link.occurredAt)}
</span>
) : null}
</Link>
+10 -6
View File
@@ -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 (
<EntityLinkPicker
@@ -1201,6 +1201,38 @@ describe("doctor report — the user's date order", () => {
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
@@ -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 ?? "—"),
@@ -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<Record<string, unknown>> = [];
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<string, unknown> }) => {
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"),
);
});
});
+10 -4
View File
@@ -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(
{