From 46906916d816abfc5330a8ade482a3fab3306d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:55:24 +0100 Subject: [PATCH] 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. --- .../__tests__/satisfy-matching.test.ts | 62 +++++++++++++++++++ src/lib/vaccinations/satisfy-matching.ts | 8 ++- .../vaccination-booster-mint.test.ts | 7 ++- .../vaccination-booster-satisfy.test.ts | 7 ++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/satisfy-matching.test.ts diff --git a/src/lib/vaccinations/__tests__/satisfy-matching.test.ts b/src/lib/vaccinations/__tests__/satisfy-matching.test.ts new file mode 100644 index 000000000..6c241c304 --- /dev/null +++ b/src/lib/vaccinations/__tests__/satisfy-matching.test.ts @@ -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> = []; + 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 }) => { + 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), + ); + }); +}); diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 4febaa950..ee12ec64e 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -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); diff --git a/tests/integration/vaccination-booster-mint.test.ts b/tests/integration/vaccination-booster-mint.test.ts index 3386bd2aa..14184ad47 100644 --- a/tests/integration/vaccination-booster-mint.test.ts +++ b/tests/integration/vaccination-booster-mint.test.ts @@ -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(), ); diff --git a/tests/integration/vaccination-booster-satisfy.test.ts b/tests/integration/vaccination-booster-satisfy.test.ts index f6d1db2a3..489286d86 100644 --- a/tests/integration/vaccination-booster-satisfy.test.ts +++ b/tests/integration/vaccination-booster-satisfy.test.ts @@ -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();