diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 5283974a8..1ae88dcbd 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -1354,7 +1354,10 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place it("keeps a due check-up on a full rail", () => { const d = buildDailyDigest( - input({ ...crowd, preventiveDue: [{ label: "Skin check" }] }), + input({ + ...crowd, + preventiveDue: [{ label: "Skin check", staysDue: true }], + }), t, ); expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK); @@ -1363,6 +1366,14 @@ 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", () => { + const d = buildDailyDigest( + input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }), + t, + ); + expect(d.worthALook.map((i) => i.kind)).not.toContain("preventive_care"); + }); + it("keeps today's visit on a full rail", () => { const d = buildDailyDigest( input({ ...crowd, upcomingVisits: [visit()] }), @@ -1375,7 +1386,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place const d = buildDailyDigest( input({ ...crowd, - preventiveDue: [{ label: "Skin check" }], + preventiveDue: [{ label: "Skin check", staysDue: true }], upcomingVisits: [visit()], }), t, diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index d08d83728..c52a90995 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -302,6 +302,22 @@ describe("loadDailyDigest — AI parts", () => { }); }); +/** A reminder row as the preventive read selects it; due this morning. */ +function reminderRow(over: Record = {}) { + return { + label: "Skin check", + origin: "VORSORGE", + intervalDays: null, + rrule: "FREQ=YEARLY;INTERVAL=1", + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + nextDueAt: new Date("2026-07-17T07:00:00.000Z"), + ...over, + }; +} + // v1.39.2 — the two reads behind the check-up and visit rail items. NOW is // 11:00 in Berlin on 2026-07-17, so the local day runs 2026-07-16T22:00Z to // 2026-07-17T22:00Z. @@ -370,8 +386,8 @@ describe("loadDailyDigest — due check-ups and today's visits", () => { await import("@/lib/i18n/server-translator"); vi.mocked(getServerTranslator).mockReturnValueOnce({ t } as never); vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([ - { label: "coach.cadence.bp7day.label", origin: "COACH" }, - { label: "coach.cadence.bp7day.label", origin: "VORSORGE" }, + reminderRow({ label: "coach.cadence.bp7day.label", origin: "COACH" }), + reminderRow({ label: "coach.cadence.bp7day.label" }), ] as never); await loadDailyDigest(USER, NOW); @@ -380,4 +396,56 @@ describe("loadDailyDigest — due check-ups and today's visits", () => { labels: "Blood pressure week, coach.cadence.bp7day.label", }); }); + + // Watched red: with every reminder due before the end of the day on the + // rail, a daily weigh-in at 20:00 read "check-up due" from the morning on. + it("shows a reminder that stays due from the morning of its day", async () => { + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([ + reminderRow({ + label: "PHQ-9", + measurementType: "PHQ9_SCORE", + rrule: null, + intervalDays: 14, + nextDueAt: new Date("2026-07-17T16:00:00.000Z"), // 18:00 today + }), + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care"); + }); + + it("leaves a short-cycle reminder due later today off until its time", async () => { + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([ + reminderRow({ + label: "Weigh-in", + measurementType: "WEIGHT", + rrule: null, + intervalDays: 1, + notifyHour: 20, + nextDueAt: new Date("2026-07-17T18:00:00.000Z"), // 20:00 today + }), + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(digest.worthALook.map((i) => i.kind)).not.toContain( + "preventive_care", + ); + }); + + it("shows a short-cycle reminder once its time has come", async () => { + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([ + reminderRow({ + label: "Weigh-in", + rrule: null, + intervalDays: 1, + nextDueAt: new Date("2026-07-17T06:00:00.000Z"), // 08:00 today + }), + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care"); + }); }); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 9441f5344..ad03736fd 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -142,6 +142,12 @@ export interface DailyDigestSyncIssue { 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. + */ + staysDue?: boolean; } /** @@ -670,8 +676,9 @@ 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 check-up that is due or overdue (its reminder has - * already gone out) and a visit booked for today (the day ends). The rest fill + * 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) + * 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. */ @@ -1116,9 +1123,10 @@ export function buildDailyDigest( // Due check-ups and today's visits keep their place under the cap (see // `capWithPinned`); they still sit below a dose in the displayed order. const pinned = new Set( - [preventive, visits.today].filter( - (item): item is PriorityItem => item !== null, - ), + [ + input.preventiveDue.some((due) => due.staysDue) ? preventive : null, + visits.today, + ].filter((item): item is PriorityItem => item !== null), ); // S11 — the calm, informational tension marker sits last: it is context, not // an action that expires, so a time-sensitive dose / sync / check-in wins the diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 97c164a58..b85fb5311 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -28,6 +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 { 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"; @@ -407,10 +408,11 @@ export async function loadDailyDigest( // proves every one of them, and the DTO mapper refuses such a row // outright so a site that lost its filter fails loudly. // - // Due TODAY or overdue: anything whose due instant falls before the end - // of the person's local day, not only what has passed by now. Since - // v1.39.2 an open check-up keeps its due date after the reminder, so - // the overdue ones stay here until they are done, skipped or snoozed. + // 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. where: { userId: user.id, enabled: true, @@ -418,7 +420,17 @@ export async function loadDailyDigest( origin: { not: "ENCOUNTER" }, nextDueAt: { not: null, lt: todayEndExclusive }, }, - select: { label: true, origin: true }, + select: { + label: true, + origin: true, + intervalDays: true, + rrule: true, + anchorDate: true, + notifyHour: true, + lastSatisfiedAt: true, + createdAt: true, + nextDueAt: true, + }, orderBy: { nextDueAt: "asc" }, take: PREVENTIVE_DUE_READ_LIMIT, }), @@ -637,11 +649,24 @@ export async function loadDailyDigest( // A Coach-suggested measurement cadence stores its label as a bundle key; // 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. - const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => { - if (row.origin !== "COACH") return { label: row.label }; - const translated = t(row.label); - return { label: translated === row.label ? row.label : translated }; - }); + // + // 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. + 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; + let label = row.label; + if (row.origin === "COACH") { + const translated = t(row.label); + if (translated !== row.label) label = translated; + } + preventiveDue.push({ label, staysDue }); + } // Group the two figures for the reader. `Intl.NumberFormat` is the only // locale-aware step the digest takes on its own; everything else it renders diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index a2a644d30..76e43553e 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -878,4 +878,49 @@ describe("runMeasurementReminderTick", () => { // The evening slot of the same day. expect(updates[0].data.nextDueAt).toEqual(new Date("2026-06-15T17:00:00Z")); }); + + it("skips an already-reminded open slot outside its hour before any read", async () => { + const { prisma } = makePrisma({ + reminders: [ + checkup({ + nextDueAt: new Date("2026-06-14T07:00:00Z"), + lastNotifiedAt: new Date("2026-06-14T07:00:04Z"), + }), + ], + labMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + new Date("2026-06-15T12:00:00Z"), // 14:00 Berlin + { dispatch }, + ); + + expect(summary.skippedOutsideWindow).toBe(1); + expect(prisma.labResult.findFirst).not.toHaveBeenCalled(); + }); + + it("still polls an already-reminded open slot for a result in its hour", async () => { + const takenAt = new Date("2026-06-14T18:00:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [ + checkup({ + nextDueAt: new Date("2026-06-14T07:00:00Z"), + lastNotifiedAt: new Date("2026-06-14T07:00:04Z"), + }), + ], + labMatch: { takenAt }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.autoResolved).toBe(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt); + }); }); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 6da948ecd..93ca3e0a0 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -19,7 +19,7 @@ * 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 `staysDueAfterReminder`). The per-day claim + * 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 @@ -57,6 +57,7 @@ import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; +import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open"; /** * v1.18.0 — map a reminder's `measurementType` to the toggleable module @@ -96,46 +97,9 @@ function moduleForMeasurementType( */ const DUE_QUERY_SLACK_MS = 15 * 60_000; -/** - * Calendar days an open reminder waits before it is sent again, and the cycle - * length at or under which a reminder rolls on instead of staying due: a - * weekly or daily reminder's own next slot comes round at least as soon as the - * repeat would. - */ +/** Calendar days an open reminder waits before it is sent again. */ export const CHECKUP_REPEAT_DAYS = 7; -/** - * v1.39.2 — whether a delivered reminder leaves the row due. - * - * Decided by the cycle length, not by what the reminder asks for. A reminder - * whose next slot is more than a week away 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 held for - * free-text check-ups and for measurement reminders alike; the report that - * led here was a fortnightly PHQ-9 and GAD-7 pair that each rolled two weeks - * forward the morning they were reminded, without either questionnaire being - * filled. A one-shot (no interval, no rule) has no next slot and also stays - * due. A reminder on a weekly or shorter cycle rolls on as before, because its - * next slot is the next reminder anyway; holding a daily course on a missed - * slot would stall it. A cadence with no next slot left rolls to nothing as - * before. An appointment (`ENCOUNTER`) always stays one-shot. - */ -export function staysDueAfterReminder( - reminder: { - origin?: string | null; - intervalDays: number | null; - rrule: string | null; - }, - rolledTo: Date | null, - timezone: string, - now: Date, -): boolean { - if (reminder.origin === "ENCOUNTER") return false; - if (reminder.intervalDays === null && reminder.rrule === null) return true; - if (rolledTo === null) return false; - return calendarDaysUntil(rolledTo, now, timezone) > CHECKUP_REPEAT_DAYS; -} - /** * v1.39.2 — whether an open check-up's repeat nudge is still being held. * @@ -322,6 +286,24 @@ export async function runMeasurementReminderTick( try { const timezone = reminder.user.timezone || "Europe/Berlin"; + // v1.39.2 — a reminder already sent for its current slot stays in + // this scan every tick until it is satisfied. Outside its notify hour + // 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 + // hold stays after that poll on purpose, so the safety net is daily + // rather than weekly. + if ( + reminder.lastNotifiedAt != null && + reminder.nextDueAt !== null && + reminder.lastNotifiedAt.getTime() >= reminder.nextDueAt.getTime() && + wallClockInTz(now, timezone).hour !== reminder.notifyHour + ) { + summary.skippedOutsideWindow += 1; + continue; + } + // ── Auto-resolve from an incoming event ──────────────────────── // Cheap safety-net poll, in the cron (the eventful `reminder-satisfy` // worker is the fast path). A typed reminder resolves from a matching @@ -485,12 +467,14 @@ export async function runMeasurementReminderTick( // 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. - const rolledTo = nextSlotAfterReminder(reminder, timezone, now); await prisma.measurementReminder.update({ where: { id: reminder.id }, - data: staysDueAfterReminder(reminder, rolledTo, timezone, now) + data: holdsOpenAfterReminder(reminder, timezone, now) ? { lastNotifiedAt: now } - : { nextDueAt: rolledTo, lastNotifiedAt: now }, + : { + nextDueAt: nextSlotAfterReminder(reminder, timezone, now), + lastNotifiedAt: now, + }, }); summary.dispatched += 1; } catch (err: unknown) { diff --git a/src/lib/measurement-reminders/__tests__/holds-open.test.ts b/src/lib/measurement-reminders/__tests__/holds-open.test.ts new file mode 100644 index 000000000..6c63f2d8f --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/holds-open.test.ts @@ -0,0 +1,111 @@ +/** + * v1.39.2 — which reminders stay due after they are sent. One predicate for + * the reminder tick, the daily digest and the edit route, so the three cannot + * disagree about whether a reminder is an open task or a rhythm. + */ +import { describe, expect, it } from "vitest"; + +import { + HOLD_OPEN_MIN_CYCLE_DAYS, + holdsOpenAfterReminder, +} from "../holds-open"; + +const TZ = "Europe/Berlin"; +const SLOT = new Date("2026-09-26T07:00:00.000Z"); + +function r(over: Partial[0]> = {}) { + return { + origin: "VORSORGE", + intervalDays: null as number | null, + rrule: null as string | null, + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...over, + }; +} + +describe("holdsOpenAfterReminder", () => { + it("holds a cycle longer than a week open", () => { + expect(HOLD_OPEN_MIN_CYCLE_DAYS).toBe(7); + expect(holdsOpenAfterReminder(r({ intervalDays: 14 }), TZ, SLOT)).toBe( + true, + ); + 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, + ); + }); + + it("reads a rule by the gap to its next occurrence", () => { + expect( + holdsOpenAfterReminder(r({ rrule: "FREQ=YEARLY;INTERVAL=1" }), TZ, SLOT), + ).toBe(true); + expect( + holdsOpenAfterReminder(r({ rrule: "FREQ=MONTHLY;INTERVAL=1" }), TZ, SLOT), + ).toBe(true); + expect(holdsOpenAfterReminder(r({ rrule: "FREQ=WEEKLY" }), TZ, SLOT)).toBe( + false, + ); + expect( + holdsOpenAfterReminder(r({ rrule: "FREQ=DAILY;BYHOUR=9,19" }), TZ, SLOT), + ).toBe(false); + }); + + it("holds a rule with no occurrence left open: its last slot is the task", () => { + expect( + holdsOpenAfterReminder( + r({ rrule: "FREQ=DAILY;UNTIL=20260926T235959Z" }), + TZ, + SLOT, + ), + ).toBe(true); + }); + + it("holds a reminder with no cadence open", () => { + expect(holdsOpenAfterReminder(r(), TZ, SLOT)).toBe(true); + }); + + it("never holds an appointment", () => { + expect(holdsOpenAfterReminder(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe( + false, + ); + expect( + holdsOpenAfterReminder( + r({ origin: "ENCOUNTER", intervalDays: 365 }), + TZ, + SLOT, + ), + ).toBe(false); + }); + + it("holds the last occurrence open when it is sent", () => { + // The tick passes the send instant, a few seconds past the occurrence. + expect( + holdsOpenAfterReminder( + r({ rrule: "FREQ=DAILY;UNTIL=20260926T235959Z" }), + TZ, + new Date(SLOT.getTime() + 20_000), + ), + ).toBe(true); + }); + + it("measures the gap that follows the slot, not the one after it", () => { + // Twice a month, on the 3rd and on the 1st: from a slot on the 3rd the + // next occurrence is four weeks out, and it is that cycle that is open. + const rule = r({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,3" }); + expect( + holdsOpenAfterReminder(rule, TZ, new Date("2026-09-03T07:00:00.000Z")), + ).toBe(true); + expect( + holdsOpenAfterReminder(rule, TZ, new Date("2026-09-01T07:00:00.000Z")), + ).toBe(false); + }); +}); diff --git a/src/lib/measurement-reminders/holds-open.ts b/src/lib/measurement-reminders/holds-open.ts new file mode 100644 index 000000000..f66138528 --- /dev/null +++ b/src/lib/measurement-reminders/holds-open.ts @@ -0,0 +1,81 @@ +/** + * 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. + * + * 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. + * + * A course window (`endsOn`) is deliberately not consulted: it ends a + * Coach-suggested measurement course, which is a short-cycle rhythm and rolls + * on regardless. + */ +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. */ +export const HOLD_OPEN_MIN_CYCLE_DAYS = 7; + +export interface HoldOpenInput { + origin?: string | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + lastSatisfiedAt: Date | null; + createdAt: Date; +} + +/** + * `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. + */ +export function holdsOpenAfterReminder( + reminder: HoldOpenInput, + timezone: string, + slot: Date, +): boolean { + if (reminder.origin === "ENCOUNTER") return false; + if (reminder.intervalDays !== null) { + return reminder.intervalDays > HOLD_OPEN_MIN_CYCLE_DAYS; + } + if (reminder.rrule === null) return true; + const input = { + intervalDays: null, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: reminder.lastSatisfiedAt, + createdAt: reminder.createdAt, + }; + // At or after `slot`: a stored slot sits exactly on an occurrence. + const first = computeReminderNextDueAt( + input, + timezone, + new Date(slot.getTime() - 1), + ); + if (first === null) return true; + const second = computeReminderNextDueAt(input, timezone, first); + if (second === null) { + // `slot` is the last occurrence: nothing to roll on to. + if (first.getTime() <= slot.getTime()) return true; + return calendarDaysUntil(first, slot, timezone) > HOLD_OPEN_MIN_CYCLE_DAYS; + } + return calendarDaysUntil(second, first, timezone) > HOLD_OPEN_MIN_CYCLE_DAYS; +}