diff --git a/CHANGELOG.md b/CHANGELOG.md index 270544dd6..6c975a114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,20 @@ ### Fixed +- **A missed "every N days" checkup now stays due until it is measured.** + A checkup on a cycle of a week or less, such as blood pressure every + two days, moved its next due date on by its cycle the moment its + reminder went out, measured or not. The card then read "in 2 days" + every evening and never "today" or "overdue", and the start page + dropped it. Such a checkup now counts from its last reading: once its + reminder is sent it stays due, then overdue, until a reading lands or + it is marked done, skipped or snoozed, and while it stays open the + reminder comes again every N days, at most once a week, at its usual + hour. A first due date in the past stays overdue past its first + reminder instead of jumping ahead. A checkup that already moved on + corrects itself at its next reminder. + Calendar schedules that repeat within a week, such as a twice-daily + measurement course, still move on to their next time. (#1369) - **A lab's "(calc)" note no longer becomes part of the unit.** Some reports print "(calc)" beside a calculated value, and a document read could copy it into the unit, so a marker was created in "g/dL (calc)" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 148febd8d..3297361ea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16986,12 +16986,13 @@ paths: - MeasurementReminders summary: List Vorsorge reminders (v1.17.1) description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt - ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since - v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is - more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without - a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or - completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it - stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder." + ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. A + rolling reminder (intervalDays, any length), an rrule reminder whose next occurrence is more than seven days + out, and a reminder with no cadence keep their nextDueAt after their reminder is sent, with or without a + measurementType: they stay due, then overdue, until satisfied (a matching reading or lab result, or completed), + skipped or snoozed. While one stays open the server repeats the reminder every intervalDays local days for a + rolling reminder, capped at seven, and every seven local days otherwise. An rrule reminder that recurs within + seven days rolls on to its next slot after a delivered reminder." responses: "200": description: The owner's measurement reminders. diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index adf8c6b01..20b560254 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 523, - "total": 5476, + "total": 5475, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -440,7 +440,7 @@ "lib/jobs/__tests__/insight-status-generate.test.ts": 2, "lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2, "lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2, - "lib/jobs/__tests__/measurement-reminder.test.ts": 33, + "lib/jobs/__tests__/measurement-reminder.test.ts": 32, "lib/jobs/__tests__/medication-intake-import.test.ts": 1, "lib/jobs/__tests__/medication-reminder-check.test.ts": 27, "lib/jobs/__tests__/mood-reminder.test.ts": 15, diff --git a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts index 28611e5fc..348936784 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 336e45d68..da5a080d4 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -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 && diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 33263b9ca..5b8ece9a1 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -1406,7 +1406,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place const d = buildDailyDigest( input({ ...crowd, - preventiveDue: [{ label: "Skin check", staysDue: true }], + preventiveDue: [{ label: "Skin check", longCycle: true }], }), t, ); @@ -1416,7 +1416,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place expect(d.worthALook[0].kind).toBe("dose_window"); }); - it("does not pin a short-cycle reminder: it rolls on anyway", () => { + it("does not pin a short-cycle reminder: it comes round within days", () => { const d = buildDailyDigest( input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }), t, @@ -1436,7 +1436,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place const d = buildDailyDigest( input({ ...crowd, - preventiveDue: [{ label: "Skin check", staysDue: true }], + preventiveDue: [{ label: "Skin check", longCycle: true }], upcomingVisits: [visit()], }), t, @@ -1546,7 +1546,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place it("leaves the due check-ups item on the checkups list", () => { const d = buildDailyDigest( input({ - preventiveDue: [{ label: "Skin check", staysDue: true }], + preventiveDue: [{ label: "Skin check", longCycle: true }], upcomingVisits: [visit()], }), t, diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index e6995062f..cfc55729d 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -166,11 +166,11 @@ export interface DailyDigestPreventiveDue { /** Display label, already resolved (a Coach cadence key is translated). */ label: string; /** - * Whether this reminder stays due after it is sent (a cycle longer than a - * week, or none). Only such a reminder earns the check-up item a place - * under the rail cap; a short-cycle rhythm rolls on by itself. + * Whether this reminder is on a cycle longer than a week, or has none + * (`hasLongCycle`). Only such a reminder earns the check-up item a place + * under the rail cap; a short-cycle rhythm comes round again within days. */ - staysDue?: boolean; + longCycle?: boolean; } /** @@ -743,8 +743,8 @@ function buildUpcomingVisitItems( /** * The bounded rail: at most `max` items, in priority order, with the pinned * items guaranteed a place. A pinned item is one a person can miss for good if - * the cap drops it today: a reminder that stays due and is due or overdue - * (its notification has already gone out, and it will not roll on by itself) + * the cap drops it today: a reminder on a long cycle that is due or overdue + * (its notification has already gone out, and the next one is a week away) * and a visit booked for today (the day ends). The rest fill * the remaining slots in order, and the result keeps the priority order, so an * overdue dose still leads. @@ -1207,7 +1207,7 @@ export function buildDailyDigest( // `capWithPinned`); they still sit below a dose in the displayed order. const pinned = new Set( [ - input.preventiveDue.some((due) => due.staysDue) ? preventive : null, + input.preventiveDue.some((due) => due.longCycle) ? preventive : null, visits.today, ].filter((item): item is PriorityItem => item !== null), ); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 1b15616a9..4f334b35d 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -28,7 +28,7 @@ import { import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; -import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open"; +import { hasLongCycle } from "@/lib/measurement-reminders/holds-open"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { isArrivalKind } from "@/lib/arrivals/types"; @@ -592,9 +592,9 @@ export async function loadDailyDigest( // // Read up to the end of the person's local day; the filter below keeps // what is due now, plus what is due later today when it is a reminder - // that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a - // reminder keeps its due date after it is sent, so the overdue ones - // stay here until they are satisfied, skipped or snoozed. + // on a long cycle (see `hasLongCycle`). A reminder that stays due after + // it is sent (`holdsOpenAfterReminder`) keeps its due date, so the + // overdue ones stay here until they are satisfied, skipped or snoozed. where: { userId: user.id, enabled: true, @@ -896,21 +896,21 @@ export async function loadDailyDigest( // the checkups page and the dashboard card translate it, and so does this // line. A free-text label (every other row) is the person's own words. // - // A reminder that stays due after it is sent (a cycle longer than a week, - // a fortnightly questionnaire, a yearly check-up) is on the rail for its - // whole due day and keeps its place under the cap. A short-cycle one (a - // daily weigh-in, a twice-daily course) is a rhythm: it shows only once its - // time has come, is not pinned, and its next reading moves it on. + // A reminder on a cycle longer than a week (a fortnightly questionnaire, a + // yearly check-up) is on the rail for its whole due day and keeps its place + // under the cap. A short-cycle one (a daily weigh-in, a twice-daily course) + // is a rhythm: it shows only once its time has come and is not pinned; a + // missed rolling one then stays here, overdue, until its reading lands. const preventiveDue: DailyDigestPreventiveDue[] = []; for (const row of dueReminders) { if (row.nextDueAt === null) continue; - const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt); - if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue; + const longCycle = hasLongCycle(row, user.timezone, row.nextDueAt); + if (!longCycle && row.nextDueAt.getTime() > now.getTime()) continue; let label = row.label; if (row.origin === "COACH") { label = translateOrNull(t, row.label) ?? label; } - preventiveDue.push({ label, staysDue }); + preventiveDue.push({ label, longCycle }); } // Group the two figures for the reader. `Intl.NumberFormat` is the only diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 1592377f7..4670b9a83 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -9,11 +9,12 @@ * logged since the last satisfy advances lastSatisfiedAt + recomputes * nextDueAt and suppresses the nudge. Free-text reminders never * auto-resolve. - * - A reminder on a weekly or shorter cycle rolls on: a successful - * dispatch advances nextDueAt past now. One on a longer cycle, or a - * one-shot, does NOT roll on (measurement reminder or free text): it - * stays due until it is satisfied, and a second nudge for the same open - * slot waits a week. Appointments stay one-shot. + * - A calendar rule recurring within the week rolls on: a successful + * dispatch advances nextDueAt past now. A rolling interval of any + * length, a rule on a longer cycle, or a one-shot does NOT roll on + * (measurement reminder or free text): it stays due until it is + * satisfied, and a second nudge for the same open slot waits its + * interval, at most a week. Appointments stay one-shot. * - clientManaged suppresses the APNs leg only: the tick still * dispatches, and a reminder nothing delivered stays overdue. * @@ -58,6 +59,7 @@ beforeEach(() => { // 09:00 Berlin in June = 07:00Z. const NINE_LOCAL = new Date("2026-06-15T07:00:00Z"); +const DAY = 24 * 60 * 60 * 1000; describe("evaluateMeasurementReminderDue — window boundary", () => { const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) }; @@ -216,6 +218,16 @@ function makePrisma(opts: { return { prisma, updates }; } +/** One tick against the stubbed Prisma surface: the one cast in this file's + * newer cases, rather than one per call. */ +function tick( + prisma: ReturnType["prisma"], + now: Date, + options: Parameters[2] = {}, +) { + return runMeasurementReminderTick(prisma as never, now, options); +} + function reminder(overrides: Partial): FakeReminder { return { id: "r1", @@ -312,7 +324,7 @@ describe("runMeasurementReminderTick", () => { it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => { const { prisma, updates } = makePrisma({ - reminders: [reminder({})], + reminders: [reminder({ intervalDays: null, rrule: "FREQ=WEEKLY" })], measurementMatch: null, }); const dispatch = vi.fn(async () => OK); @@ -662,9 +674,15 @@ describe("runMeasurementReminderTick", () => { expect("nextDueAt" in updates[0].data).toBe(false); }); - it("still rolls a weekly measurement reminder on after its reminder", async () => { + it("keeps a Coach-suggested daily measurement reminder due after its reminder", async () => { const { prisma, updates } = makePrisma({ - reminders: [reminder({ origin: "COACH" })], + reminders: [ + reminder({ + origin: "COACH", + measurementType: "WEIGHT", + intervalDays: 1, + }), + ], measurementMatch: null, }); const dispatch = vi.fn(async () => OK); @@ -672,9 +690,7 @@ describe("runMeasurementReminderTick", () => { await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); expect(updates).toHaveLength(1); - const advanced = updates[0].data.nextDueAt as Date; - expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); - expect(updates[0].data.lastNotifiedAt).toEqual(NINE_LOCAL); + expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); it("still closes an appointment reminder after its one nudge", async () => { @@ -728,11 +744,9 @@ describe("runMeasurementReminderTick", () => { const sixDays = makePrisma({ reminders: [open], labMatch: null }); const heldDispatch = vi.fn(async () => OK); - await runMeasurementReminderTick( - sixDays.prisma as never, - new Date("2026-06-14T07:00:00Z"), - { dispatch: heldDispatch }, - ); + await tick(sixDays.prisma, new Date("2026-06-14T07:00:00Z"), { + dispatch: heldDispatch, + }); expect(heldDispatch).not.toHaveBeenCalled(); // Seven calendar days on, at the same notify hour — a few seconds EARLIER @@ -740,11 +754,7 @@ describe("runMeasurementReminderTick", () => { // as "under a week". const sevenDays = makePrisma({ reminders: [open], labMatch: null }); const dispatch = vi.fn(async () => OK); - const summary = await runMeasurementReminderTick( - sevenDays.prisma as never, - NINE_LOCAL, - { dispatch }, - ); + const summary = await tick(sevenDays.prisma, NINE_LOCAL, { dispatch }); expect(dispatch).toHaveBeenCalledTimes(1); expect(summary.dispatched).toBe(1); expect(sevenDays.updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); @@ -783,7 +793,7 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); - it("rolls a weekly check-up on, since its next slot is the next nudge", async () => { + it("keeps a weekly rolling check-up due: its next slot needs it done first", async () => { const { prisma, updates } = makePrisma({ reminders: [checkup({ rrule: null, intervalDays: 7 })], labMatch: null, @@ -792,8 +802,7 @@ describe("runMeasurementReminderTick", () => { await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); - const advanced = updates[0].data.nextDueAt as Date; - expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); + expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); it("keeps a fortnightly check-up due", async () => { @@ -952,3 +961,160 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt); }); }); + +// ── A rolling reminder stays due until it is measured ──────────────────── +// +// The reported shape: blood pressure every two days at 18:00, first due a +// month back, never measured since. The old tick rolled it two days on the +// moment its reminder was sent, so the card read "in 2 days" every evening +// and never "today" or "overdue". +describe("runMeasurementReminderTick — a missed rolling reminder", () => { + // 18:00 in Berlin in November (UTC+1). + const SIX_PM = new Date("2026-11-08T17:00:00.000Z"); + const twoDay = (overrides: Partial = {}) => + reminder({ + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 2, + notifyHour: 18, + // First due 4 October, local midnight; never satisfied since. + anchorDate: new Date("2026-10-03T22:00:00.000Z"), + nextDueAt: new Date("2026-10-04T16:00:00.000Z"), + createdAt: new Date("2026-11-08T09:00:00.000Z"), + ...overrides, + }); + + it("keeps a two-day reminder due after its reminder instead of rolling it on", async () => { + const { prisma, updates } = makePrisma({ + reminders: [twoDay()], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await tick(prisma, SIX_PM, { + dispatch, + }); + + expect(summary.dispatched).toBe(1); + expect(updates).toHaveLength(1); + expect(updates[0].data).toEqual({ lastNotifiedAt: SIX_PM }); + }); + + it("keeps a daily and a weekly rolling reminder due the same way", async () => { + for (const intervalDays of [1, 7]) { + const { prisma, updates } = makePrisma({ + reminders: [twoDay({ intervalDays })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await tick(prisma, SIX_PM, { dispatch }); + + expect(updates[0].data, `every ${intervalDays} days`).toEqual({ + lastNotifiedAt: SIX_PM, + }); + } + }); + + it("repeats an open two-day reminder every second day, not every seventh", async () => { + const open = twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) }); + + const nextDay = makePrisma({ reminders: [open], measurementMatch: null }); + const heldDispatch = vi.fn(async () => OK); + const held = await tick( + nextDay.prisma, + new Date(SIX_PM.getTime() + 1 * DAY), + { dispatch: heldDispatch }, + ); + expect(held.skippedRepeatHeld).toBe(1); + expect(heldDispatch).not.toHaveBeenCalled(); + + const twoDaysOn = makePrisma({ reminders: [open], measurementMatch: null }); + const dispatch = vi.fn(async () => OK); + const at = new Date(SIX_PM.getTime() + 2 * DAY); + const sent = await tick(twoDaysOn.prisma, at, { dispatch }); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(sent.dispatched).toBe(1); + // Still open: the due date stays where it was. + expect(twoDaysOn.updates[0].data).toEqual({ lastNotifiedAt: at }); + }); + + it("repeats an open daily reminder the next day", async () => { + const { prisma } = makePrisma({ + reminders: [ + twoDay({ + intervalDays: 1, + lastNotifiedAt: new Date(SIX_PM.getTime() - 1 * DAY + 4_000), + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await tick(prisma, SIX_PM, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it("still holds a long rolling cycle's repeat to a week", async () => { + const { prisma } = makePrisma({ + reminders: [ + twoDay({ + intervalDays: 30, + lastNotifiedAt: new Date(SIX_PM.getTime() - 6 * DAY + 4_000), + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await tick(prisma, SIX_PM, { + dispatch, + }); + + expect(summary.skippedRepeatHeld).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("moves on from the reading that satisfies it, two days from the reading", async () => { + const measuredAt = new Date("2026-11-09T07:30:00.000Z"); + const { prisma, updates } = makePrisma({ + reminders: [ + twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) }), + ], + measurementMatch: { measuredAt }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await tick(prisma, new Date(SIX_PM.getTime() + 1 * DAY), { + dispatch, + }); + + expect(summary.autoResolved).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt); + // 11 November, 18:00 in Berlin. + expect(updates[0].data.nextDueAt).toEqual( + new Date("2026-11-11T17:00:00.000Z"), + ); + }); + + it("still rolls a weekly calendar rule on after its reminder", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + twoDay({ + intervalDays: null, + rrule: "FREQ=WEEKLY", + nextDueAt: SIX_PM, + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await tick(prisma, SIX_PM, { dispatch }); + + const advanced = updates[0].data.nextDueAt as Date; + expect(advanced.getTime()).toBeGreaterThan(SIX_PM.getTime()); + expect(updates[0].data.lastNotifiedAt).toEqual(SIX_PM); + }); +}); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index b8997bb2e..05a8f14d9 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -9,19 +9,20 @@ * * Differences from the mood reminder: * - * 1. What a delivered reminder does to the row depends on its cycle. - * A reminder whose next slot is more than a week away (a fortnightly - * questionnaire, a yearly check-up) or that has no next slot at all (a - * one-shot) stays due until it is satisfied, skipped or snoozed, so the - * start page and the check-up card keep showing it as due and then - * overdue; rolling it on silently filed an open task under a later - * slot. While it stays open it is reminded again at most once a week, - * gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on - * a weekly or shorter cycle rolls on to its next slot, which is the - * next nudge anyway, and an appointment (`origin: ENCOUNTER`) is - * one-shot and closes (see `holdsOpenAfterReminder`). The per-day claim - * in `notification_events` stays the guard against a second send on - * the same local day. + * 1. What a delivered reminder does to the row depends on its cadence. + * A rolling reminder ("every N days", anchored on its last reading), + * a calendar rule whose next slot is more than a week away (a yearly + * check-up), and a reminder with no next slot at all (a one-shot) stay + * due until they are satisfied, skipped or snoozed, so the start page + * and the check-up card keep showing them as due and then overdue; + * rolling them on silently filed an open task under a later slot. While + * one stays open it is reminded again every N days for a rolling + * interval, at most once a week, gated on `lastNotifiedAt` (see + * `isCheckupRepeatHeld`). A calendar rule that recurs within the week + * rolls on to its next slot, which is the next nudge anyway, and an + * appointment (`origin: ENCOUNTER`) is one-shot and closes (see + * `holdsOpenAfterReminder`). The per-day claim in `notification_events` + * stays the guard against a second send on the same local day. * 2. Auto-resolve from an incoming measurement. Before deciding to * fire, the runner checks whether a matching reading of the * reminder's `measurementType` has landed since the last satisfy @@ -105,31 +106,49 @@ function moduleForMeasurementType( */ const DUE_QUERY_SLACK_MS = 15 * 60_000; -/** Calendar days an open reminder waits before it is sent again. */ +/** Calendar days an open reminder waits at most before it is sent again. */ export const CHECKUP_REPEAT_DAYS = 7; +/** + * Calendar days an open reminder waits before it is sent again: its own + * interval for a rolling reminder, capped at {@link CHECKUP_REPEAT_DAYS}, so + * a missed every-two-days reading is asked for again every second day rather + * than once a week; a week for everything else that stays open. + */ +function checkupRepeatDays(reminder: { intervalDays?: number | null }): number { + const interval = reminder.intervalDays ?? null; + if (interval === null) return CHECKUP_REPEAT_DAYS; + return Math.max(1, Math.min(interval, CHECKUP_REPEAT_DAYS)); +} + /** * v1.39.2 — whether an open check-up's repeat nudge is still being held. * * Held when the last delivered nudge was for THIS slot (`lastNotifiedAt` at - * or after `nextDueAt`) and fewer than {@link CHECKUP_REPEAT_DAYS} calendar + * or after `nextDueAt`) and fewer than {@link checkupRepeatDays} calendar * days have passed in the person's timezone. Calendar days rather than a - * 7 × 24 h gap, because the tick fires within the notify hour and the next - * week's tick can land a few seconds earlier on the clock than the first - * send did. + * N × 24 h gap, because the tick fires within the notify hour and the next + * tick can land a few seconds earlier on the clock than the first send did. * * A slot that moved after the last nudge — a snooze to a named day, a * Telegram "later", a skip, a satisfy — is a new slot and is never held. */ export function isCheckupRepeatHeld( - reminder: { nextDueAt: Date | null; lastNotifiedAt?: Date | null }, + reminder: { + nextDueAt: Date | null; + lastNotifiedAt?: Date | null; + intervalDays?: number | null; + }, timezone: string, now: Date, ): boolean { const { nextDueAt, lastNotifiedAt } = reminder; if (!nextDueAt || !lastNotifiedAt) return false; if (lastNotifiedAt.getTime() < nextDueAt.getTime()) return false; - return calendarDaysUntil(now, lastNotifiedAt, timezone) < CHECKUP_REPEAT_DAYS; + return ( + calendarDaysUntil(now, lastNotifiedAt, timezone) < + checkupRepeatDays(reminder) + ); } export interface MeasurementReminderSummary { @@ -341,9 +360,9 @@ export async function runMeasurementReminderTick( // there is nothing it could do this tick, so skip it before the // auto-resolve read: the eventful `reminder-satisfy` worker resolves it // the moment a reading or lab result lands, and the safety-net poll - // below still runs for it once a day, in the notify hour. The weekly + // below still runs for it once a day, in the notify hour. The repeat // hold stays after that poll on purpose, so the safety net is daily - // rather than weekly. + // rather than once per repeat. if ( reminder.lastNotifiedAt != null && reminder.nextDueAt !== null && @@ -405,8 +424,8 @@ export async function runMeasurementReminderTick( } // v1.39.2 — an open check-up stays due after its nudge, so without - // this it would qualify again at every notify hour. Once a week is the - // repeat; the per-day claim below is not enough on its own. + // this it would qualify again at every notify hour. Its interval, at + // most a week, is the repeat; the per-day claim is not enough alone. if ( reminder.origin !== "ENCOUNTER" && isCheckupRepeatHeld(reminder, timezone, now) @@ -529,9 +548,9 @@ export async function runMeasurementReminderTick( continue; } - // Dispatch succeeded. Record the delivery; a check-up keeps its due - // date (it is still open, and `lastNotifiedAt` holds the repeat to a - // week), everything else rolls on past this slot. + // Dispatch succeeded. Record the delivery; a reminder that stays due + // keeps its due date (it is still open, and `lastNotifiedAt` holds the + // repeat), a short calendar rule rolls on past this slot. await prisma.measurementReminder.update({ where: { id: reminder.id }, data: holdsOpenAfterReminder(reminder, timezone, now) @@ -703,8 +722,9 @@ function nextSlotAfterReminder( // as-is is correct. For a ROLLING reminder the engine anchors the // first-due slot AT `anchorDate ?? createdAt` when never satisfied, which // stays ≤ now and would re-fire every tick — so re-anchor the rolling - // cadence on `now` (a fire is the rhythm restarting from this dispatch) to - // roll it forward by exactly one interval. + // cadence on `now` to roll it forward by exactly one interval. A delivered + // rolling reminder never comes here (it stays due); the rolling arm serves + // the disabled-module skip, where nothing is left to deliver. const rolling = reminder.intervalDays !== null; const scheduleInput: ReminderScheduleInput = { intervalDays: reminder.intervalDays, diff --git a/src/lib/measurement-reminders/__tests__/holds-open.test.ts b/src/lib/measurement-reminders/__tests__/holds-open.test.ts index 6c63f2d8f..3e21ab107 100644 --- a/src/lib/measurement-reminders/__tests__/holds-open.test.ts +++ b/src/lib/measurement-reminders/__tests__/holds-open.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { HOLD_OPEN_MIN_CYCLE_DAYS, + hasLongCycle, holdsOpenAfterReminder, } from "../holds-open"; @@ -35,13 +36,17 @@ describe("holdsOpenAfterReminder", () => { expect(holdsOpenAfterReminder(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true); }); - it("lets a weekly or shorter interval roll on", () => { - expect(holdsOpenAfterReminder(r({ intervalDays: 7 }), TZ, SLOT)).toBe( - false, - ); - expect(holdsOpenAfterReminder(r({ intervalDays: 1 }), TZ, SLOT)).toBe( - false, - ); + // A rolling interval is anchored on its last satisfaction, so its next + // slot only exists once it is measured: rolling it on after its reminder + // re-anchored it on the send instead, and a missed every-two-days blood + // pressure reading read "in 2 days" each evening and never "overdue". + it("holds every rolling interval open, however short", () => { + for (const intervalDays of [1, 2, 3, 7]) { + expect( + holdsOpenAfterReminder(r({ intervalDays }), TZ, SLOT), + `every ${intervalDays} days`, + ).toBe(true); + } }); it("reads a rule by the gap to its next occurrence", () => { @@ -109,3 +114,32 @@ describe("holdsOpenAfterReminder", () => { ).toBe(false); }); }); + +// The digest's narrower question: whether a reminder is infrequent enough to +// sit on the start page for its whole due day. A daily weigh-in at 20:00 is +// not, even though it now stays due once its reminder is sent. +describe("hasLongCycle", () => { + it("reads a rolling interval by its length", () => { + expect(hasLongCycle(r({ intervalDays: 1 }), TZ, SLOT)).toBe(false); + expect(hasLongCycle(r({ intervalDays: 7 }), TZ, SLOT)).toBe(false); + expect(hasLongCycle(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true); + }); + + it("agrees with holdsOpenAfterReminder on every rule and on no cadence", () => { + for (const rrule of [ + "FREQ=YEARLY;INTERVAL=1", + "FREQ=WEEKLY", + "FREQ=DAILY;BYHOUR=9,19", + "FREQ=DAILY;UNTIL=20260926T235959Z", + null, + ]) { + expect(hasLongCycle(r({ rrule }), TZ, SLOT), String(rrule)).toBe( + holdsOpenAfterReminder(r({ rrule }), TZ, SLOT), + ); + } + }); + + it("never counts an appointment", () => { + expect(hasLongCycle(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe(false); + }); +}); diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts index 78c85990f..2c534bab7 100644 --- a/src/lib/measurement-reminders/__tests__/scheduling.test.ts +++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts @@ -8,8 +8,10 @@ import { describe, it, expect } from "vitest"; import { byHourTimesOfDay, computeReminderNextDueAt, + parseReminderAnchor, type ReminderScheduleInput, } from "../scheduling"; +import { relativeDueKey } from "../due-day"; const TZ = "Europe/Berlin"; @@ -52,6 +54,26 @@ describe("computeReminderNextDueAt", () => { expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z"); }); + // The create and edit routes compute `nextDueAt` with exactly this call. + // A first due date weeks back, never measured since, stays on that day, so + // the card reads it as overdue: it is not walked forward past now. + it("rolling, never satisfied, first due weeks back: stays on the anchor day", () => { + const now = new Date("2026-11-08T19:40:00Z"); // 20:40 in Berlin + const reminder = base({ + intervalDays: 2, + notifyHour: 18, + anchorDate: parseReminderAnchor("2026-10-04", TZ), + createdAt: now, + }); + const next = computeReminderNextDueAt(reminder, TZ, now); + // 4 October, 18:00 in Berlin (UTC+2). + expect(next).toEqual(new Date("2026-10-04T16:00:00Z")); + expect(relativeDueKey(next!.toISOString(), now.getTime(), TZ)).toEqual({ + key: "measurementReminders.overdueByDays", + days: 35, + }); + }); + it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => { const reminder = base({ intervalDays: 7, diff --git a/src/lib/measurement-reminders/holds-open.ts b/src/lib/measurement-reminders/holds-open.ts index f66138528..aae9cef64 100644 --- a/src/lib/measurement-reminders/holds-open.ts +++ b/src/lib/measurement-reminders/holds-open.ts @@ -1,22 +1,32 @@ /** * v1.39.2 — whether a reminder stays due after it is sent. * - * Decided by the cycle length, not by what the reminder asks for. A reminder - * whose next slot is more than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days after the - * current one stays due until it is satisfied (a matching reading or lab - * result, or "done"), skipped or snoozed: rolling it on would file an open - * task under a slot weeks or a year away. That holds for free-text check-ups - * and measurement reminders alike; the report that led here was a fortnightly - * PHQ-9 and GAD-7 pair. A reminder with no cadence, or a rule with no - * occurrence left, has no next slot and also stays due: its last slot is the - * task. A weekly or shorter cycle rolls on, because its next slot is the next - * reminder anyway, and holding a daily course on a missed slot would stall it. - * An appointment (`ENCOUNTER`) is always one-shot. + * A reminder that stays due is not moved by its reminder: it keeps its due + * date until it is satisfied (a matching reading or lab result, or "done"), + * skipped or snoozed, so every surface reads it as due and then overdue. + * + * - A rolling interval (`intervalDays`) always stays due. It is anchored on + * its last satisfaction, so its next slot does not exist until it is + * satisfied; rolling it on after a reminder re-anchored it on the send, + * and a missed every-two-days blood pressure reading read "in 2 days" + * each evening and was never shown as due or overdue. + * - A calendar rule (`rrule`) stays due when its next occurrence is more + * than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days away: rolling it on would + * file an open task under a slot weeks or a year away. That holds for + * free-text check-ups and measurement reminders alike; the report that + * led here was a fortnightly PHQ-9 and GAD-7 pair. A rule that recurs + * within the week rolls on, because its next occurrence is the next + * reminder anyway and is fixed by the calendar, not by the last reading; + * holding a twice-daily Coach course on a missed slot would stall it. + * - A reminder with no cadence, or a rule with no occurrence left, has no + * next slot and stays due: its last slot is the task. + * - An appointment (`ENCOUNTER`) is always one-shot. * * The one definition. The reminder tick decides with it whether a delivered - * reminder rolls on; the daily digest decides with it whether a reminder due - * later today belongs on the start page already and keeps its place there; - * the edit route decides with it whether re-enabling keeps the open slot. + * reminder rolls on; the edit route decides with it whether re-enabling keeps + * the open slot. The daily digest asks the narrower question underneath it, + * {@link hasLongCycle}: whether a reminder is infrequent enough to sit on the + * start page for its whole due day. * * A course window (`endsOn`) is deliberately not consulted: it ends a * Coach-suggested measurement course, which is a short-cycle rhythm and rolls @@ -25,7 +35,7 @@ import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; -/** A cycle of at most this many calendar days rolls on after its reminder. */ +/** A calendar rule recurring within this many calendar days rolls on after its reminder. */ export const HOLD_OPEN_MIN_CYCLE_DAYS = 7; export interface HoldOpenInput { @@ -41,15 +51,34 @@ export interface HoldOpenInput { /** * `slot` is the instant of the current slot: the send instant in the tick * (a few seconds past the occurrence it sends), the stored `nextDueAt` - * elsewhere. For a rule the cycle is the calendar-day gap between its next - * two occurrences from `slot` on, so the answer does not depend on where - * between two occurrences `slot` sits. With one occurrence left the gap is - * measured from `slot` to it; with none left the reminder holds open. + * elsewhere. */ export function holdsOpenAfterReminder( reminder: HoldOpenInput, timezone: string, slot: Date, +): boolean { + if (reminder.origin === "ENCOUNTER") return false; + if (reminder.intervalDays !== null) return true; + return hasLongCycle(reminder, timezone, slot); +} + +/** + * Whether the reminder's cycle is longer than {@link HOLD_OPEN_MIN_CYCLE_DAYS} + * days, or it has no next slot at all. The daily digest puts such a reminder + * on the start page from the morning of its due day and keeps it there under + * the rail cap; a short rhythm (a daily weigh-in at 20:00) waits until its + * time has come. + * + * For a rule the cycle is the calendar-day gap between its next two + * occurrences from `slot` on, so the answer does not depend on where between + * two occurrences `slot` sits. With one occurrence left the gap is measured + * from `slot` to it; with none left the cycle counts as long. + */ +export function hasLongCycle( + reminder: HoldOpenInput, + timezone: string, + slot: Date, ): boolean { if (reminder.origin === "ENCOUNTER") return false; if (reminder.intervalDays !== null) { diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index a3e94af1a..d17a2bc00 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable = tags: ["MeasurementReminders"], summary: "List Vorsorge reminders (v1.17.1)", description: - "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.", + "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. A rolling reminder (intervalDays, any length), an rrule reminder whose next occurrence is more than seven days out, and a reminder with no cadence keep their nextDueAt after their reminder is sent, with or without a measurementType: they stay due, then overdue, until satisfied (a matching reading or lab result, or completed), skipped or snoozed. While one stays open the server repeats the reminder every intervalDays local days for a rolling reminder, capped at seven, and every seven local days otherwise. An rrule reminder that recurs within seven days rolls on to its next slot after a delivered reminder.", responses: { ...recordRefusal(), "200": { diff --git a/tests/integration/checkup-stays-due.test.ts b/tests/integration/checkup-stays-due.test.ts index 8c2dd23e9..86ca9926f 100644 --- a/tests/integration/checkup-stays-due.test.ts +++ b/tests/integration/checkup-stays-due.test.ts @@ -8,6 +8,10 @@ * it is reminded once, keeps its due date, shows on the start page's * digest, is not reminded again the same day or the next, is reminded * once more seven days on, and moves to next year when it is marked done. + * A missed every-two-days blood pressure reading, first due a month + * back, does the same on its own rhythm: due and overdue after each + * reminder, asked for again every second day, two days on from the + * reading that lands. * 2. **Migration 0355's repair.** The container has every migration applied * already, so the data steps are pulled out of the `.sql` file and run * again against rows seeded the way an upgrading instance holds them: @@ -24,6 +28,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Prisma } from "@/generated/prisma/client"; import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { + computeReminderNextDueAt, + parseReminderAnchor, +} from "@/lib/measurement-reminders/scheduling"; +import { + isOverdueDue, + relativeDueKey, +} from "@/lib/measurement-reminders/due-day"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; @@ -239,6 +251,97 @@ describe("an open fortnightly questionnaire through the reminder tick", () => { }); }); +describe("a missed every-two-days reading through the reminder tick", () => { + // 18:00 in Berlin in November (UTC+1). + const SIX_PM = new Date("2026-11-08T17:00:00.000Z"); + + it("stays due and overdue, repeats every second day, and moves on from the reading", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("bp-owner"); + // Created this morning with a first due date a month back, as the form + // does; the due date is the one the create route computes. + const createdAt = new Date("2026-11-08T09:00:00.000Z"); + const schedule = { + intervalDays: 2, + rrule: null, + anchorDate: parseReminderAnchor("2026-10-04", TZ), + notifyHour: 18, + lastSatisfiedAt: null, + createdAt, + }; + const firstDue = computeReminderNextDueAt(schedule, TZ, createdAt); + expect(firstDue).toEqual(new Date("2026-10-04T16:00:00.000Z")); + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: "Blood pressure", + measurementType: "BLOOD_PRESSURE_SYS", + ...schedule, + origin: "VORSORGE", + enabled: true, + nextDueAt: firstDue, + createdAt, + }, + }); + const dispatch = vi.fn(async () => OK); + const tick = (at: Date) => + runMeasurementReminderTick(prisma, at, { dispatch }); + const read = () => + prisma.measurementReminder.findUniqueOrThrow({ + where: { id: created.id }, + }); + + // Reminded at 18:00; at 20:40 it still reads overdue, not "in 2 days". + expect((await tick(SIX_PM)).dispatched).toBe(1); + let row = await read(); + expect(row.nextDueAt).toEqual(firstDue); + expect(row.lastNotifiedAt).toEqual(SIX_PM); + const evening = new Date(SIX_PM.getTime() + 160 * 60 * 1000); + expect( + isOverdueDue( + relativeDueKey(row.nextDueAt!.toISOString(), evening.getTime(), TZ), + ), + ).toBe(true); + const fullUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + const digest = await loadDailyDigest(fullUser, evening, { locale: "en" }); + const care = digest.worthALook.find((i) => i.kind === "preventive_care"); + expect(care?.body).toContain("Blood pressure"); + + // The next evening: held. Two days on: reminded again, still due. + expect( + (await tick(new Date(SIX_PM.getTime() + DAY))).skippedRepeatHeld, + ).toBe(1); + const twoDaysOn = new Date(SIX_PM.getTime() + 2 * DAY); + await tick(twoDaysOn); + await tick(new Date(twoDaysOn.getTime() + 15 * 60 * 1000)); + expect(dispatch).toHaveBeenCalledTimes(2); + row = await read(); + expect(row.nextDueAt).toEqual(firstDue); + expect(row.lastNotifiedAt).toEqual(twoDaysOn); + + // Measured the next morning: two days on from the reading. + const measuredAt = new Date("2026-11-11T07:30:00.000Z"); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + value: 128, + unit: "mmHg", + measuredAt, + source: "MANUAL", + }, + }); + const resolved = await tick(new Date(SIX_PM.getTime() + 3 * DAY)); + expect(resolved.autoResolved).toBe(1); + row = await read(); + expect(row.lastSatisfiedAt).toEqual(measuredAt); + expect(row.nextDueAt).toEqual(new Date("2026-11-13T17:00:00.000Z")); + expect(dispatch).toHaveBeenCalledTimes(2); + }); +}); + describe("migration 0355", () => { it("adds the nullable last_notified_at column", async () => { const rows = await getPrismaClient().$queryRawUnsafe< @@ -395,7 +498,7 @@ describe("migration 0355", () => { updatedAt: new Date("2026-09-26T07:01:00.000Z"), }, { - // A weekly check-up still rolls on under the new tick. + // A weekly cycle is outside this migration's repair. id: "weekly", data: { rrule: null, @@ -404,7 +507,7 @@ describe("migration 0355", () => { }, }, { - // A measurement reminder on a short cycle still rolls on. + // A measurement reminder on a short cycle: outside this repair too. id: "measurement-3-day", data: { measurementType: "WEIGHT",