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.
8.6 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-per-event-reminders | 02 | api/broker |
|
|
|
|
|
|
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 iffireTime ∈ (now - 60s, now]. Skip iflead === 0(D-06). - All-day:
alertInstant = computeAlertInstantUtc(dtstartDate, lead/1440, serverTz). Fire ifalertInstant ∈ (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 PASSpnpm --filter @familysync/api exec vitest run(full suite): 314/314 PASSpnpm --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:
sentRemindersstoreddtstartMs = 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
pruneMsfield. 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
makeSelectMockassumed twoinnerJoincalls (calendars + pushSubscriptions). New implementation uses a singleinnerJoinper query but makes two queries. - Fix: Replaced
vi.mocked(db.select).mockReturnValue(...)pattern withmockTwoQueries(db, timedRows, allDayRows)that sequences twomockReturnValueOncecalls 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).