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();