From b6b51fcb5e148753ac9b0eac799ffd9e8e6c6435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:17:14 +0200 Subject: [PATCH] fix(medications): let a snooze set before a dose's window hold that dose Since a snooze holds only the slots whose window had opened when it was set, a snooze set ahead of a dose, such as "remind me later" in the app before the dose is due, held nothing and the reminder came anyway. A snooze set while no window of the medication is open now holds the next slot to open. One set while a window is open still holds only that slot, so a morning skip that snoozes to the end of the day leaves the evening dose reminded. --- .../medication-reminder-check.test.ts | 62 ++++++++++++++- .../reminder/medication-reminder-check.ts | 77 +++++++++++++++++-- 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 7bc66a553..89d87ce9e 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -108,7 +108,10 @@ vi.mock("@/lib/notifications/reminder-dedup", () => ({ REMINDER_DEDUP_LOOKBACK_MS: 48 * 60 * 60 * 1000, })); -import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; +import { + handleReminderCheck, + nextSlotHeldBySnooze, +} from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; function medicationWithSchedule( @@ -891,4 +894,61 @@ describe("handleReminderCheck — a snooze holds only the slot it answered", () expect(dispatchNotification).not.toHaveBeenCalled(); }); + + it("holds the next slot when the snooze was set before its window opened", async () => { + // "Remind me later" from the app at 06:00 for the 08:00 dose, until + // 09:30: no window was open, so the snooze answers the next one. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + { + ...snoozed("2026-07-28T06:00:00.000Z"), + snoozedUntil: new Date("2026-07-28T09:30:00.000Z"), + }, + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("holds only that next slot, not the ones after it", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T18:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + snoozed("2026-07-28T06:00:00.000Z"), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ timeOfDay: "18:00" }), + }), + ); + }); +}); + +describe("nextSlotHeldBySnooze", () => { + const w = (start: string, end: string) => ({ + start: new Date(`2026-07-28T${start}:00.000Z`), + end: new Date(`2026-07-28T${end}:00.000Z`), + }); + const windows = [w("18:00", "19:00"), w("08:00", "09:00")]; + + it("is nothing while a window is open at the snooze", () => { + expect( + nextSlotHeldBySnooze(windows, new Date("2026-07-28T08:30:00.000Z")), + ).toBeNull(); + }); + + it("is the next window to open when none is", () => { + expect( + nextSlotHeldBySnooze(windows, new Date("2026-07-28T06:00:00.000Z")), + ).toEqual(new Date("2026-07-28T08:00:00.000Z")); + expect( + nextSlotHeldBySnooze(windows, new Date("2026-07-28T12:00:00.000Z")), + ).toEqual(new Date("2026-07-28T18:00:00.000Z")); + }); }); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index d14f25c94..41958f516 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -144,20 +144,51 @@ export async function cleanupScheduledTelegramDeletions(): Promise { /** * Whether a medication-level snooze holds the reminder of one slot. It - * holds only while it runs (`now < snoozedUntil`) and only for a slot whose + * holds only while it runs (`now < snoozedUntil`), and only for a slot whose * reminder window had opened when the snooze was set (`snoozedAt`, the - * medication row's @updatedAt of the snooze write). A later edit to the - * row moves `snoozedAt` forward, which can only widen the hold back to the - * old medication-wide behaviour, never lose a snooze. + * medication row's @updatedAt of the snooze write), or for the slot the + * snooze was set ahead of (`heldNextWindowStart`, see + * {@link nextSlotHeldBySnooze}). A later edit to the row moves `snoozedAt` + * forward, which can only widen the hold back to the old medication-wide + * behaviour, never lose a snooze. */ export function snoozeHoldsSlot( snoozedUntil: Date | null, snoozedAt: Date, slotWindowStart: Date, now: Date, + heldNextWindowStart: Date | null = null, ): boolean { if (!snoozedUntil || now.getTime() >= snoozedUntil.getTime()) return false; - return slotWindowStart.getTime() <= snoozedAt.getTime(); + if (slotWindowStart.getTime() <= snoozedAt.getTime()) return true; + return ( + heldNextWindowStart !== null && + slotWindowStart.getTime() === heldNextWindowStart.getTime() + ); +} + +/** + * The window start of the slot a snooze was set ahead of, or `null`. A + * snooze set while one of the medication's reminder windows was open answers + * that slot, and a later slot stays reminded (a morning "skip" that snoozes + * to the end of the day must not silence the evening dose). A snooze set + * while no window was open, such as "remind me later" on a dose that is + * not due yet, answers the next slot to open, so that one is held too. + */ +export function nextSlotHeldBySnooze( + windows: ReadonlyArray<{ start: Date; end: Date }>, + snoozedAt: Date, +): Date | null { + const at = snoozedAt.getTime(); + let next: Date | null = null; + for (const window of windows) { + const start = window.start.getTime(); + if (start <= at && at <= window.end.getTime()) return null; + if (start > at && (next === null || start < next.getTime())) { + next = window.start; + } + } + return next; } /** @@ -416,6 +447,31 @@ export async function handleReminderCheck( : null; }); + // The slot a running snooze was set ahead of, if any (see + // `nextSlotHeldBySnooze`), read over every slot of the medication. + const snoozeRunning = + med.snoozedUntil !== null && + now.getTime() < med.snoozedUntil.getTime(); + const heldNextWindowStart = snoozeRunning + ? nextSlotHeldBySnooze( + sortedSchedules.flatMap((schedule, index) => { + const canonicalSchedule = canonicalSchedules[index]; + return ( + occurrencesByScheduleId.get(canonicalSchedule.id) ?? [] + ).map((occurrence) => + resolveSlotPhaseWindow( + schedule, + occurrence.timeOfDay, + canonicalSchedule.doseWindows ?? null, + occurrence.at, + userTz, + ), + ); + }), + med.updatedAt, + ) + : null; + for ( let scheduleIndex = 0; scheduleIndex < sortedSchedules.length; @@ -569,14 +625,21 @@ export async function handleReminderCheck( } // A snooze answers the reminder of the slot that was open when it - // was set, so it holds only slots whose window had opened by then. + // was set, so it holds only slots whose window had opened by then, + // or, set while none was open, the next slot to open. // `snoozedUntil` lives on the medication row and the snooze write // stamps the row's @updatedAt, which dates the decision (the same // reading the skip / pin suppression above uses). A later slot of // the same medication, such as the evening dose after a morning // "skip" that snoozes to the end of the day, is still reminded. if ( - snoozeHoldsSlot(med.snoozedUntil, med.updatedAt, windowStart, now) + snoozeHoldsSlot( + med.snoozedUntil, + med.updatedAt, + windowStart, + now, + heldNextWindowStart, + ) ) { continue; }