---
phase: quick-260610-hbu
plan: "01"
subsystem: api/broker
tags: [push-notifications, reminder-scheduler, resilience, dedup]
dependency_graph:
  requires: []
  provides: [resilient-reminder-scan, per-uid-exactly-once-dedup]
  affects: [apps/api/src/broker/reminderScheduler.ts]
tech_stack:
  added: []
  patterns: [catch-up-window, per-uid-dedup, mark-after-dispatch]
key_files:
  created: []
  modified:
    - apps/api/src/broker/reminderScheduler.ts
    - apps/api/tests/broker/reminderScheduler.test.ts
decisions:
  - "Window changed from [now+14min, now+16min] to (now, now+16min] for missed-tick catch-up"
  - "Dedup key changed from uid:minuteBucket (Set) to bare uid (Map<uid, dtstartMs>) for cross-tick exactly-once"
  - "Body changed from hardcoded 'Starts in 15 min' to lead-accurate 'Starts in N min'"
  - "CR-01 pruning changed from minuteBucket-age to started-event (dtstartMs <= now)"
metrics:
  duration: "~15min"
  completed: "2026-06-10"
  tasks_completed: 2
  files_modified: 2
---

# Phase quick-260610-hbu Plan 01: Reminder Scheduler Resilience Summary

**One-liner:** Catch-up scan window `(now, now+16min]` with per-uid `Map` dedup replaces fixed `[now+14, now+16]` + minuteBucket `Set`, closing the missed-tick reminder drop (UAT Test 1).

## Tasks Completed

| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Catch-up window + per-uid dedup in reminderScheduler.ts | 3fdb242 | apps/api/src/broker/reminderScheduler.ts |
| 2 | Update tests for catch-up + single-fire + missed-tick | 93bb2c1 | apps/api/tests/broker/reminderScheduler.test.ts |

## Verification Output

### pnpm --filter @familysync/api typecheck

```
$ tsc --noEmit
(exit 0 — no output)
```

### pnpm --filter @familysync/api test (reminderScheduler only)

```
 RUN  v4.1.8 /home/luc/Projects/familysync/apps/api

 Test Files  1 passed (1)
      Tests  10 passed (10)
   Start at  12:34:43
   Duration  1.01s (transform 181ms, setup 647ms, import 74ms, tests 114ms, environment 0ms)
```

### pnpm --filter @familysync/api test (full suite)

```
 Test Files  4 failed | 19 passed (23)
      Tests  68 failed | 166 passed (234)
   Duration  22.54s
```

The 4 failing test files and 68 failing tests are all pre-existing `ECONNREFUSED 127.0.0.1:3306` DB-integration failures (MariaDB not running locally). None are related to reminderScheduler. The reminderScheduler file is tested exclusively through mocked DB calls and is not among the failures.

### Grep sanity

```
grep -n "gt(" apps/api/src/broker/reminderScheduler.ts
103:  gt(calendarEvents.dtstartUtc, now),

grep -n "minuteBucket" apps/api/src/broker/reminderScheduler.ts
42: // Key: event uid (bare string — no minuteBucket suffix).
142: // Per-uid exactly-once dedup (D-12): keyed on bare uid, no minuteBucket.
(no variable named minuteBucket remains)
```

## What Changed

### Task 1 — reminderScheduler.ts

**Query window:** `gte(dtstartUtc, windowStart)` where `windowStart = now+14min` replaced with `gt(dtstartUtc, now)`. Upper bound `lte(dtstartUtc, windowEnd)` where `windowEnd = now+16min` unchanged. Import `gte` dropped; `gt` added.

**Dedup store:** `Set<string>` keyed `uid:minuteBucket` replaced with `Map<string, number>` keyed `uid` (value = dtstartMs). The `minuteBucket` variable is removed entirely.

**Dispatch loop check:** `sentReminders.has(uid:minuteBucket)` replaced with `sentReminders.has(uid)`. WR-01 mark-after-dispatch preserved: `sentReminders.set(uid, dtstartMs)` after fan-out loop.

**Notification body:** Hardcoded `'Starts in 15 min'` replaced with `\`Starts in ${minutes} min\`` where `minutes = Math.max(1, Math.round((dtstartMs - now) / 60000))`.

**CR-01 pruning:** Stale minuteBucket iteration replaced with started-event pruning: delete entries whose stored `dtstartMs <= now.getTime()`.

### Task 2 — reminderScheduler.test.ts

New tests added:
- **SINGLE-FIRE across 3 consecutive ticks:** 3 calls to `runReminderCheck` with event still in window; asserts `dispatchPush` called exactly once.
- **MISSED-TICK-RECOVERY:** No scan at the ideal 15-min mark; call at 8 min before start; asserts dispatch fires.
- **ALREADY-STARTED:** dtstart <= now excluded by SQL `gt`; mocked as empty rows; 0 dispatches.
- **D-16:** Empty subscriptions row set; zero sends, no crash.
- **T-05-19:** Per-subscription error isolation; first sub throws, second still dispatched.
- **Fan-out:** 2 subscriber rows for one event uid; 2 dispatches.

Old minuteBucket tests removed:
- `(eventUid, minuteBucket) twice within the same run` — replaced by per-uid SINGLE-FIRE test.
- `CR-01: re-dispatch in next minute bucket` — replaced by CR-01 started-event pruning test.

Retained:
- D-07 all-day exclusion test.
- D-05 non-shared exclusion test.
- WR-01 mark-after-dispatch test (updated to per-uid dedup language).

## Decisions Made

1. **Window: open lower bound on `now`** — `gt(now)` instead of `gte(now+14min)` gives the catch-up property: any scan while the event is still in the future finds it, regardless of when the cron last fired.

2. **Dedup: uid-only Map** — Removing the minuteBucket suffix from the key means a recovered (late) scan on the same event does not see a new key and does not double-fire. The Map value (dtstartMs) is used only for CR-01 pruning, not for keying.

3. **CR-01 pruning on started-event, not stale-bucket** — With per-uid dedup there is no bucket concept. Pruning on `dtstartMs <= now` achieves the same bounded-growth goal and is semantically cleaner: an event that has started will never re-enter `(now, now+16min]`.

4. **Body is lead-accurate** — `Math.max(1, ...)` guards against a 0-min display if dispatch runs extremely close to dtstart.

## Deviations from Plan

None — plan executed exactly as written.

## Known Stubs

None.

## Threat Flags

None — no new network endpoints, auth paths, or schema changes introduced.

## Self-Check

- [x] `apps/api/src/broker/reminderScheduler.ts` exists and compiles (typecheck exit 0)
- [x] `apps/api/tests/broker/reminderScheduler.test.ts` exists and passes (10/10)
- [x] Commit 3fdb242 exists
- [x] Commit 93bb2c1 exists

## Self-Check: PASSED
