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.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
---
|
||||
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).
|
||||
Reference in New Issue
Block a user