fix(vaccinations): re-anchor a booster on the logged dose's own day

Logging the next dose satisfies a matching booster reminder with the
dose date, a calendar date stored at UTC midnight. The reminder engine
reads that instant in the person's zone, where west of UTC it is the
evening before, so the next booster came due a day early. The first
booster's anchor was already counted from the start of the dose day in
the person's zone; the satisfy path now hands the engine the same
instant.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:55:59 +01:00
1 parent f76e5cdafd
commit 46906916d8
4 files changed
+81 -3

No files matched your search

@@ -0,0 +1,62 @@
/**
* Logging the next dose re-anchors a booster reminder on the dose date. The
* dose date is a calendar date stored at UTC midnight, which is the evening
* before west of UTC, so the next booster came due a day early there.
*/
import { describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const TZ = "America/New_York";
function fakeTx() {
const updates: Array<Record<string, unknown>> = [];
const reminder = {
id: "reminder-1",
userId: "u1",
vaccinationAntigen: "tetanus",
intervalDays: 3650,
rrule: null,
anchorDate: new Date("2016-03-10T05:00:00.000Z"),
notifyHour: 9,
nextDueAt: new Date("2026-03-07T14:00:00.000Z"),
lastSatisfiedAt: null,
lastSkippedAt: null,
createdAt: new Date("2016-03-10T05:00:00.000Z"),
endsOn: null,
};
const tx = {
measurementReminder: {
findMany: vi.fn(async () => [reminder]),
updateMany: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
updates.push(data);
return { count: 1 };
}),
},
measurementReminderEvent: { create: vi.fn(async () => ({})) },
};
return { tx: tx as unknown as Prisma.TransactionClient, updates };
}
describe("satisfyBoostersForDose", () => {
it("counts the next interval from the dose's own date west of UTC", async () => {
const { tx, updates } = fakeTx();
await satisfyBoostersForDose(
tx,
"u1",
{ antigenSlug: "tetanus" },
new Date("2026-03-10T00:00:00.000Z"),
TZ,
);
// Ten years of days after 10 March, on the person's calendar.
expect(userDayKey(updates[0].nextDueAt as Date, TZ)).toBe(
shiftDateKey("2026-03-10", 3650),
);
expect(updates[0].lastSatisfiedAt).toEqual(
startOfLocalDayKey("2026-03-10", TZ),
);
});
});
+7 -1
View File
@@ -52,6 +52,8 @@ import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
componentsForDose,
loadCustomVaccineLookup,
@@ -136,6 +138,10 @@ export async function satisfyBoostersForDose(
return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id);
});
// The dose date is a calendar date at UTC midnight, the evening before west
// of UTC; the reminder engine reads an instant in the person's zone, so it
// gets the start of that day there, as the booster mint does.
const satisfiedAt = startOfLocalDayKey(dateOnlyKey(occurredAt), timezone);
const satisfiedReminderIds: string[] = [];
for (const reminder of ordered) {
const row: SatisfiableReminder = {
@@ -160,7 +166,7 @@ export async function satisfyBoostersForDose(
tx as unknown as PrismaClient,
row,
timezone,
occurredAt,
satisfiedAt,
"vaccination",
);
if (result.satisfied) satisfiedReminderIds.push(reminder.id);