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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:17:14 +02:00
1 parent 4d3a98ed43
commit b6b51fcb5e
2 files changed
+131 -8

No files matched your search

@@ -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"));
});
});
@@ -144,20 +144,51 @@ export async function cleanupScheduledTelegramDeletions(): Promise<void> {
/**
* 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;
}