Files
familysync/.planning/quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/260610-hbu-PLAN.md
T

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
quick-260610-hbu 01 execute 1
apps/api/src/broker/reminderScheduler.ts
apps/api/tests/broker/reminderScheduler.test.ts
true
PUSH-REMIND-RESILIENCE
truths artifacts key_links
A shared timed event whose start is in (now, now+16min] triggers a reminder push on the next scan, even if the cron tick at the ideal 15-min mark was missed/late (catch-up).
A given shared timed event fires EXACTLY ONCE across all scans while it sits in the catch-up window — no cross-tick double-fire.
An event whose dtstart has already passed (dtstart <= now) does NOT trigger a reminder.
The notification body reflects the actual lead time (e.g. 'Starts in 8 min'), guarded to a minimum of 1.
Existing Phase-5 behaviours stay green: isShared-only (D-05), allDay excluded (D-07), always-visible notification (D-11), empty push_subscriptions -> zero sends/no crash (D-16), per-event and per-sub error isolation (T-05-17/18/19).
path provides contains
apps/api/src/broker/reminderScheduler.ts Resilient catch-up reminder scan with per-uid exactly-once dedup, in-memory (D-12). runReminderCheck
path provides contains
apps/api/tests/broker/reminderScheduler.test.ts Updated tests covering single-fire across consecutive ticks, missed-tick recovery, already-started exclusion, plus retained Phase-5 behaviours. missed
from to via pattern
apps/api/src/broker/reminderScheduler.ts calendarEvents.dtstartUtc WHERE gt(now) AND lte(now+16min) gt(.*dtstartUtc
from to via pattern
apps/api/src/broker/reminderScheduler.ts dispatchPush fan-out per subscription, then mark uid sent dispatchPush
Make the Phase 5 reminder scheduler resilient to missed/late node-cron ticks (UAT Test 1, 2026-06-10). Replace the fixed `[now+14min, now+16min]` single-tick window with a catch-up scan `(now, now+16min]`, and replace per-`(uid, minuteBucket)` dedup with per-`uid` exactly-once dedup so a recovered reminder fires once, not 2-3 times.

Purpose: A scheduled 15-min reminder did NOT fire on a real iOS device because node-cron missed the tick during the 2-min window and the scan had no catch-up — the window slid past and the reminder was dropped permanently. The dispatch pipeline is PROVEN working (manual injected-time scan -> Apple 201 -> notification arrived). This is purely a scheduling-resilience defect.

Output: Updated reminderScheduler.ts (in-memory only, no schema/deps/Redis per D-12) and updated tests, both passing typecheck + vitest.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@./CLAUDE.md @apps/api/src/broker/reminderScheduler.ts @apps/api/tests/broker/reminderScheduler.test.ts @apps/api/src/lib/pushDispatcher.ts @.planning/phases/05-web-push-notifications/05-UAT.md Task 1: Make the reminder scan catch-up resilient with per-uid exactly-once dedup apps/api/src/broker/reminderScheduler.ts Target behaviours (proven by Task 2 tests): - Window: scan shared timed events whose `dtstartUtc` is in `(now, now+16min]` — strictly greater than `now` (future, excludes already-started) and at most 16 min out. A missed/late tick is recovered on the next run because the lower bound is `now`, not `now+14min`. - Dedup: keyed on `uid` ALONE. The same event fires EXACTLY ONCE no matter how many consecutive ticks it sits in the window. - Mark-after-dispatch (WR-01 preserved): mark the uid as sent only AFTER all dispatch attempts for that event complete, so a throwing dispatch does not pre-suppress retry (at-least-once delivery property). - Bounded memory / re-arm: track per-uid the event's `dtstart` (ms). Prune entries whose dtstart is in the past (`dtstart <= now`) — bounds growth and avoids permanently suppressing a future re-created uid. - Body: lead-accurate. Compute `minutes = Math.max(1, Math.round((dtstart - now) / 60000))` and set body to `` `Starts in ${minutes} min` ``. Edit apps/api/src/broker/reminderScheduler.ts in place. Keep the injectable `now = new Date()` signature on `runReminderCheck` (tests rely on it) and keep `startReminderScheduler` unchanged.
Query / window changes:
- Replace `windowStart = now+14min` / `gte(dtstartUtc, windowStart)` with a strict lower bound on `now`: use Drizzle `gt(calendarEvents.dtstartUtc, now)`. Import `gt` from drizzle-orm and drop the now-unused `gte` import.
- Keep `windowEnd = now + 16*60*1000` and `lte(calendarEvents.dtstartUtc, windowEnd)`.
- Leave the rest of the query identical: innerJoin calendars, `eq(calendars.isShared, true)` (D-05 in QUERY, T-05-17), `eq(calendarEvents.allDay, false)` (D-07), cross-join ALL pushSubscriptions via `sql\`1=1\``, the same selected columns. Pass the raw `now` Date to `gt` (Drizzle/mysql2 binds Date params for datetime columns the same way the existing `lte` does).

Dedup changes:
- Replace `const sentReminders = new Set<string>()` with `const sentReminders = new Map<string, number>()` mapping `uid -> dtstartMs`. Update the module header comment block: key is now the bare event `uid` (not `uid:minuteBucket`); value is the event's dtstart in ms used for pruning; "acceptable data loss on process restart" (D-12) still applies. Remove the `minuteBucket` variable entirely.
- In the dispatch loop: `if (sentReminders.has(uid)) continue` (no minuteBucket). After the fan-out loop, `sentReminders.set(uid, event.dtstartUtc.getTime())` (mark-after-dispatch — WR-01).
- Build the notification body from the actual lead: `const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000))` then `body: \`Starts in ${minutes} min\``. Keep title fallback (`event.title ?? uid`), `tag: \`reminder-${uid}\``, and `navigate` exactly as-is.

Pruning changes:
- Replace the CR-01 minuteBucket-based prune block with: iterate `sentReminders` entries and `sentReminders.delete(uid)` for any entry whose stored dtstartMs is `<= now.getTime()` (event has started). Update the CR-01 comment to describe started-event pruning.

Comments / decisions to preserve and update: D-05, D-07, D-11, D-12, D-16, T-05-17, T-05-18, T-05-19, WR-01, CR-01 must remain referenced in the header/inline comments with their meanings updated for per-uid dedup and the catch-up window. Add a short inline CAVEAT comment near the dedup: if an event's dtstart is RESCHEDULED earlier after a reminder already fired, it will not re-fire — acceptable for v1.

Do NOT: add schema/migration changes, new dependencies, Redis, or DB persistence. Do NOT change pushDispatcher or its call signature. Do NOT place fenced code blocks anywhere — this is directive prose.
pnpm --filter @familysync/api typecheck reminderScheduler.ts compiles under strict TS; query uses `gt(dtstartUtc, now)` + `lte(dtstartUtc, now+16min)`; dedup is a `Map` keyed by bare uid; body is lead-accurate; pruning drops started events; injectable `now` and `startReminderScheduler` unchanged; no new imports beyond `gt` (and `gte` removed). Task 2: Update tests for catch-up + single-fire + missed-tick recovery apps/api/tests/broker/reminderScheduler.test.ts New/updated assertions: - Single-fire across consecutive ticks: an event in the catch-up window fires EXACTLY ONCE when `runReminderCheck` is invoked on multiple consecutive minutes while the event still sits in `(now, now+16min]` (regression for the secondary cross-tick double-fire bug). - Missed-tick recovery (core Test 1 regression): skip the scan at the ideal 15-min mark; call `runReminderCheck` a few minutes later with the event now ~8 min out (still `> now`, `<= now+16min`) -> the reminder STILL fires. - Already-started exclusion: an event with `dtstart <= now` produces NO dispatch. - Retained behaviours stay green: isShared-only (D-05), allDay excluded (D-07), empty pushSubscriptions -> zero sends / no crash (D-16), per-sub error isolation, fan-out to all subs, WR-01 mark-after-dispatch. Edit apps/api/tests/broker/reminderScheduler.test.ts. Keep the existing vitest setup: `vi.useFakeTimers()` + `vi.resetModules()` in beforeEach, the `db.select` mock and `dispatchPush` mock, and the chained mock shape `from().innerJoin().innerJoin().where()` whose `.where()` resolves to the row array. The query shape is unchanged by Task 1, so the existing `makeSelectMock()` helper pattern remains valid — reuse it.
Because `sentReminders` is module-level state, each independent test must get a fresh module via the existing `vi.resetModules()` + dynamic `await import(...)` pattern already used. Keep importing `runReminderCheck`, `db`, `dispatchPush` inside each test after `resetModules`.

Rewrite/replace the dedup tests for per-uid semantics:
- Replace the "(eventUid, minuteBucket) twice within the same run" test and the CR-01 "re-dispatch in next minute bucket" test (which asserted the OLD per-minuteBucket re-fire) with a SINGLE-FIRE-ACROSS-TICKS test: set an event row with `dtstartUtc` = now+15min and one subscriber; call `runReminderCheck(t0)`, then `runReminderCheck(t0 + 1 min)`, then `runReminderCheck(t0 + 2 min)` — each time re-mocking `db.select` (event still in window: 15, 14, 13 min out). Assert `dispatchPush` was called exactly ONCE total across all three runs.
- Add a MISSED-TICK-RECOVERY test: event `dtstartUtc` fixed (e.g. `2026-06-15T10:15:00Z`). Do NOT run at the ideal mark. Call `runReminderCheck` with `now = 2026-06-15T10:07:00Z` (event 8 min out, still > now and <= now+16min). Assert `dispatchPush` fired (>= 1 call). This is the Test 1 regression.
- Add an ALREADY-STARTED test: with the real query this is excluded by `gt(dtstartUtc, now)` in SQL, so the mocked `.where()` returns `[]` (mirror the existing all-day/non-shared "SQL filtered it out -> empty rows" pattern) and assert `dispatchPush` NOT called. Name it clearly (e.g. "does not dispatch for an event whose start has already passed (dtstart <= now)").
- Update the WR-01 test description and any "minute bucket" wording to per-uid dedup language, but keep its assertion (second run in the same scan context does not re-dispatch).
- Keep the all-day (D-07), non-shared (D-05) empty-result tests as-is. Update the file's top docblock to describe the catch-up `(now, now+16min]` window and per-uid exactly-once dedup instead of the old `[now+14,now+16]` / minuteBucket description.

Do NOT place fenced code blocks in this action prose. Do NOT introduce real DB or real push; keep everything mocked.
pnpm --filter @familysync/api test `pnpm --filter @familysync/api test` (vitest) passes green. The suite includes: single-fire-across-three-consecutive-ticks (exactly 1 dispatch), missed-tick-recovery (fires at 8-min lead), already-started exclusion (0 dispatches), plus retained D-05/D-07/D-16/WR-01/per-sub-isolation coverage. - `pnpm --filter @familysync/api typecheck` exits 0 (no TS errors in reminderScheduler.ts). - `pnpm --filter @familysync/api test` exits 0 with all reminderScheduler tests green, including the three new behaviours. - Manual grep sanity: `grep -n "gt(" apps/api/src/broker/reminderScheduler.ts` shows the `gt(...dtstartUtc, now)` lower bound; `grep -n "minuteBucket" apps/api/src/broker/reminderScheduler.ts` returns nothing (old per-bucket logic fully removed).

<success_criteria>

  • A shared timed event in (now, now+16min] fires a reminder on the next scan after a missed/late tick (catch-up) — Test 1 closed.
  • Each such event fires EXACTLY ONCE across consecutive ticks (cross-tick double-fire bug fixed).
  • Events with dtstart <= now never fire.
  • Notification body reflects actual lead time, guarded to >= 1 min.
  • All prior Phase-5 decisions/threat mitigations preserved (D-05/D-07/D-11/D-12/D-16, T-05-17/18/19, WR-01, CR-01).
  • In-memory only: no schema change, no new deps, no Redis/DB persistence. Only reminderScheduler.ts and its test touched. </success_criteria>
Create `.planning/quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/260610-hbu-SUMMARY.md` when done.