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);
@@ -18,6 +18,8 @@ process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { POST as createVaccination } from "@/app/api/vaccinations/route";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { POST as mintBooster } from "@/app/api/vaccinations/[id]/booster/route";
import { GET as listReminders } from "@/app/api/measurement-reminders/route";
@@ -221,7 +223,10 @@ describe("the whole activity-aware loop, through real routes", () => {
const after = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: reminderId },
});
expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString());
// The start of the dose's calendar day in the person's zone.
expect(after.lastSatisfiedAt).toEqual(
startOfLocalDayKey(dateOnlyKey(occurredAt), "Europe/Berlin"),
);
expect(after.nextDueAt!.getTime()).toBeGreaterThan(
before.nextDueAt!.getTime(),
);
@@ -24,6 +24,8 @@ process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { POST as createVaccination } from "@/app/api/vaccinations/route";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -132,7 +134,10 @@ describe("a dose satisfies the boosters it answers", () => {
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
const after = await reminderById(reminder.id);
expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString());
// The start of the dose's calendar day in the person's zone.
expect(after.lastSatisfiedAt).toEqual(
startOfLocalDayKey(dateOnlyKey(occurredAt), "Europe/Berlin"),
);
// Re-anchored from the satisfy instant, so the next one lands roughly ten
// years out rather than staying on the date it was already overdue on.
expect(after.nextDueAt).not.toBeNull();