---
phase: 11-per-event-reminders
plan: "02"
subsystem: api/broker
tags: [scheduler, tdd, reminders, variable-lead, dedup, humanize, all-day, notif-04, notif-05, notif-06]
dependency_graph:
  requires:
    - computeAlertInstantUtc (Plan 11-01, vevent.ts)
  provides:
    - humanizeLeadMinutes
    - runReminderCheck (variable-lead, uid:dtstartMs dedup, all-day branch)
    - startReminderScheduler (unchanged)
  affects:
    - apps/api/src/broker/reminderScheduler.ts (Plan 11-03+ consumer if any)
tech_stack:
  added: []
  patterns:
    - Two-query split (timed vs all-day) to avoid mixing SQL filter semantics
    - Per-event JS fire-time check within a wide pre-filter SQL window (T-11-04 cap)
    - uid:dtstartMs compound dedup key — identity stable across consecutive ticks, re-fires on reschedule
    - pruneMs separate from dtstartMs — all-day events need end-of-event-day as prune boundary
    - humanizeLeadMinutes branch order: check < 120 before hours division (90-min = 1 hour, not 2)
key_files:
  created: []
  modified:
    - apps/api/src/broker/reminderScheduler.ts
    - apps/api/tests/broker/reminderScheduler.test.ts
decisions:
  - "D-PRUNE-SPLIT: Introduced separate pruneMs field alongside dtstartMs in the byKey map. For timed events pruneMs = dtstartUtc (prune when event starts). For all-day events pruneMs = start-of-next-day UTC (prune after event date), because UTC midnight of the event date is always before the 9 AM fire time — storing dtstartMs as the prune value caused immediate eviction after tick 1."
  - "D-TWO-QUERY: Split the single DB query into timed + all-day separate queries. This avoids ambiguous WHERE predicates (e.g. reminder_lead_minutes > 0 is wrong for all-day where 0 = same-day) and keeps SQL pre-filter logic readable per event type."
  - "D-WIDE-PREFILTER: SQL pre-filter uses wide window (now + MAX_LEAD_MINUTES / MAX_ALLDAY_LEAD_DAYS); per-event JS check narrows to exact 60s catch-up window. Avoids complex MariaDB timezone arithmetic for all-day, keeps correctness in JS."
metrics:
  duration_minutes: 13
  completed_date: "2026-06-14"
  tasks_completed: 3
  files_modified: 2
---

# Phase 11 Plan 02: Variable-Lead Reminder Scheduler Summary

Generalized the reminder scheduler from a fixed shared-event 15-min scan to a per-event variable-lead scheduler: uid:dtstartMs compound dedup, dropped isShared restriction, all-day 9 AM-local branch (via computeAlertInstantUtc from Plan 11-01), NULL-vs-0 guard, and humanized push body.

## Tasks Completed

| Task | Description | Commit |
|------|-------------|--------|
| RED | Failing tests: variable-lead, uid:dtstartMs dedup, NULL-vs-0, personal calendar | 9635aa9 |
| GREEN Task 1 | Variable-lead window, uid:dtstartMs dedup, drop isShared/allDay restrictions, timed-0 skip | 62d3f58 |
| Task 2 | humanizeLeadMinutes tests (8 bucket cases) + body dispatch assertion | 57f9d67 |
| Task 3 | All-day 9 AM-local tests + all-day prune-boundary fix (NOTIF-06) | 0dc227a |

## New Exported Symbols

| Symbol | File | Description |
|--------|------|-------------|
| `humanizeLeadMinutes(leadMinutes)` | reminderScheduler.ts | Maps minutes → human string: `< 60` → `N min`; `< 120` → `1 hour`; `< 1440` → `N hours`; `< 2880` → `1 day`; else `N days`. Branch order prevents 90-min rounding to 2 hours. |

## Key Changes to runReminderCheck

### SQL Query: Two queries replacing one

**Before:** Single query with `isShared=true`, `allDay=false`, fixed `(now, now+16min]` window.

**After (timed query):**
- Removed `eq(calendars.isShared, true)` — personal events fire (NOTIF-05)
- Removed `eq(calendarEvents.allDay, false)` — handled separately
- Added `reminderLeadMinutes IS NOT NULL` (NOTIF-05)
- Changed window to `(now, now + MAX_LEAD_MINUTES]` (2880 min) as a pre-filter

**After (all-day query):**
- `allDay=true`, `reminderLeadMinutes IS NOT NULL`, `dtstartDate <= today + 7 days`
- Alert time computed in JS via `computeAlertInstantUtc(dtstartDate, leadDays, serverTz)`

### JS Filter: Per-event fire-time check

- **Timed:** `fireTime = dtstartUtc - lead * 60s`. Fire if `fireTime ∈ (now - 60s, now]`. Skip if `lead === 0` (D-06).
- **All-day:** `alertInstant = computeAlertInstantUtc(dtstartDate, lead/1440, serverTz)`. Fire if `alertInstant ∈ (now - 60s, now]`.

### Dedup Key: uid → uid:dtstartMs

- Key format: `` `${uid}:${dtstartMs}` ``
- Timed events: `dtstartMs = dtstartUtc.getTime()`
- All-day events: `dtstartMs = Date.UTC(y, m-1, d)` (UTC midnight of event date)
- Reschedule detection: same uid with new dtstart gets a new compound key → re-fires

### Prune Boundary (New Field: pruneMs)

- Timed: `pruneMs = dtstartMs` (same as before — prune when event starts)
- All-day: `pruneMs = Date.UTC(y, m-1, d+1)` (end-of-event-day) — avoids immediate prune since UTC midnight of event date is before the 9 AM fire instant

### Body: humanizeLeadMinutes

```
body: humanizeLeadMinutes(event.reminderLeadMinutes)
```
Driven by the DB-stored configured lead (D-09 ground truth), not the live minutes-to-start delta.

## humanizeLeadMinutes Bucket Table

| Input (min) | Output |
|-------------|--------|
| 5–59 | `Starts in N min` |
| 60–119 | `Starts in 1 hour` |
| 120–1439 | `Starts in N hours` |
| 1440–2879 | `Starts in 1 day` |
| 2880+ | `Starts in N days` |

90 min → `Starts in 1 hour` (not 2 hours — the `< 120` check comes before the hours division).

## Requirements Satisfied

| Req ID | Behavior | Test |
|--------|----------|------|
| NOTIF-04 | Fires at T-lead for 30-min lead event; not-fired outside window | `NOTIF-04: dispatches a timed event when now is inside the lead-driven fire window (30-min lead)` |
| NOTIF-05 | NULL lead → no push; timed 0-lead → no push; personal → dispatch | 3 tests in `variable-lead, NULL-vs-0, personal calendar` |
| NOTIF-06 | uid:dtstartMs dedup (once/3 ticks); reschedule re-fires; all-day 9 AM | 5 tests covering dedup + all-day |
| D-09 | Humanized body: 1440-min lead → "Starts in 1 day" | `dispatched notification body is humanized from configured lead` |

## Verification Results

- `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts`: 28/28 PASS
- `pnpm --filter @familysync/api exec vitest run` (full suite): 314/314 PASS
- `pnpm --filter @familysync/api exec tsc --noEmit`: CLEAN (0 errors)
- `grep 'setInterval' reminderScheduler.ts`: retained (no node-cron)

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] All-day dedup immediate prune via UTC-midnight dtstartMs**
- **Found during:** Task 3 GREEN — all-day dedup test failed: dispatched twice across 2 ticks
- **Issue:** `sentReminders` stored `dtstartMs = Date.UTC(y, m-1, d)` (UTC midnight of event date) as the prune value. By the time a 9 AM reminder fires, this value is already `<= now`, so the CR-01 prune loop evicted the entry in the same tick. The next tick re-entered the fire window and dispatched again.
- **Fix:** Introduced separate `pruneMs` field. For all-day events, `pruneMs = Date.UTC(y, m-1, d+1)` (start-of-next-day), ensuring the entry persists through the full event date. For timed events, `pruneMs = dtstartMs` (unchanged behavior).
- **Files modified:** apps/api/src/broker/reminderScheduler.ts
- **Commit:** 0dc227a

**2. [Rule 3 - Blocking] Test mock needed two-query support**
- **Found during:** Task 1 GREEN — existing `makeSelectMock` assumed two `innerJoin` calls (calendars + pushSubscriptions). New implementation uses a single `innerJoin` per query but makes two queries.
- **Fix:** Replaced `vi.mocked(db.select).mockReturnValue(...)` pattern with `mockTwoQueries(db, timedRows, allDayRows)` that sequences two `mockReturnValueOnce` calls to correctly simulate the timed vs all-day query split.
- **Files modified:** apps/api/tests/broker/reminderScheduler.test.ts
- **Commit:** 62d3f58

## Known Stubs

None. All implemented functions are fully wired and produce real output. No placeholder values or TODO markers.

## Threat Flags

None. No new network endpoints, auth paths, file access patterns, or schema changes introduced. The only behavioral expansion (personal-calendar reminders) matches T-11-03 (accepted risk per threat register — authorized requirement NOTIF-05 corollary, body carries only event title + relative time).

## Self-Check: PASSED

Files exist:
- FOUND: apps/api/src/broker/reminderScheduler.ts
- FOUND: apps/api/tests/broker/reminderScheduler.test.ts
- FOUND: .planning/phases/11-per-event-reminders/11-02-SUMMARY.md

Commits exist:
- 9635aa9: RED test commit (test(11-02))
- 62d3f58: GREEN Task 1 (feat(11-02))
- 57f9d67: Task 2 (feat(11-02))
- 0dc227a: Task 3 (feat(11-02))

Exports verified: `humanizeLeadMinutes` exported from reminderScheduler.ts, `computeAlertInstantUtc` imported from vevent.ts (Plan 11-01 artifact).
