mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user