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