fix(checkups): keep a missed rolling reminder due until it is measured

A rolling reminder ("every N days") is anchored on its last reading, but
the reminder tick rolled any cycle of a week or less on after a delivered
reminder, re-anchoring it on the send. A missed every-two-days blood
pressure reading therefore moved two days ahead each evening, read "in 2
days" on the card, and never showed as today or overdue.

A rolling reminder now stays due after its reminder, whatever its
interval, until a reading lands or it is done, skipped or snoozed. While
it stays open the repeat follows its interval, capped at a week, still in
its notify hour and under the per-day claim. Calendar rules that recur
within a week (a twice-daily course) keep rolling on; appointments are
unchanged.

holdsOpenAfterReminder stays the one definition for the tick and the
edit route. The daily digest asks the narrower question underneath it,
hasLongCycle, so a short rhythm still waits for its time of day on the
start page and is not pinned under the rail cap.

Refs #1369
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 22:14:02 +01:00
1 parent d110f0456c
commit ea3fcce80c
15 files changed
+530 -116

No files matched your search

@@ -272,8 +272,13 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
});
it("re-enabling a disabled short-cycle reminder schedules it from now", async () => {
const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false };
it("re-enabling a disabled weekly calendar rule schedules it from now", async () => {
const weekly = {
...BASE_ROW,
intervalDays: null,
rrule: "FREQ=WEEKLY",
enabled: false,
};
findFirstMock.mockResolvedValue(weekly);
const res = await PATCH(makeRequest({ enabled: true }), params);
@@ -303,6 +308,26 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect("nextDueAt" in persisted).toBe(false);
});
it("re-enabling a missed short rolling reminder keeps its open slot", async () => {
// Every two days, never measured: switching it off and on again is not
// measuring it either.
findFirstMock.mockResolvedValue({
...BASE_ROW,
intervalDays: 2,
enabled: false,
});
const res = await PATCH(makeRequest({ enabled: true }), params);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(persisted.enabled).toBe(true);
expect("nextDueAt" in persisted).toBe(false);
});
it("reads a resent first due date on the same calendar day as unchanged", async () => {
// A booster row stores the dose instant plus N days; the web form sends
// the same day back as local midnight. Same day in the profile zone, so
@@ -219,7 +219,7 @@ export const PATCH = apiHandler(
const reEnabled = updateData.enabled === true && !existing.enabled;
// Re-enabling a reminder that stays due after it is sent keeps the slot it
// was on, overdue or not: switching it off and on again is not doing it.
// A short-cycle reminder is scheduled from now, as before.
// A calendar rule that recurs within the week is scheduled from now.
const keepsOpenSlot =
reEnabled &&
!cadenceChanged &&