Files
familysync/.planning/phases/11-per-event-reminders/11-02-SUMMARY.md
T
Lucas Berger 23a773edc3 docs(11-02): complete variable-lead scheduler plan — 28/28 tests, 314/314 suite
SUMMARY.md covers: uid:dtstartMs dedup, dropped isShared restriction,
all-day 9 AM branch, humanizeLeadMinutes buckets, pruneMs split fix.
Requirements NOTIF-04/05/06 claimed by automated tests.
2026-06-13 22:27:12 -04:00

165 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 |
|-------------|--------|
| 559 | `Starts in N min` |
| 60119 | `Starts in 1 hour` |
| 1201439 | `Starts in N hours` |
| 14402879 | `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).