docs(05-06): complete reminderScheduler plan

This commit is contained in:
Lucas Berger
2026-06-09 21:40:22 -04:00
parent b95f671485
commit 8e741cf528
3 changed files with 131 additions and 9 deletions
+3 -3
View File
@@ -203,7 +203,7 @@ Plans:
**Wave 4** *(blocked on Wave 3)*
- [x] 05-05-PLAN.md — NOTIF-02 list-change slice: listChangeDispatcher + hook coalescer into mutations, reorder-silent (D-01/D-02/D-03)
- [ ] 05-06-PLAN.md — TDD: NOTIF-01 reminderScheduler — shared-timed 15-min scan (query-enforced D-05), all-day excl, dedup, empty-set safe (D-05/D-06/D-07)
- [x] 05-06-PLAN.md — TDD: NOTIF-01 reminderScheduler — shared-timed 15-min scan (query-enforced D-05), all-day excl, dedup, empty-set safe (D-05/D-06/D-07)
**Wave 5** *(blocked on Wave 4)*
@@ -246,7 +246,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
| 2. Calendar Display | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 |
| 5. Web Push Notifications | 5/8 | In Progress| |
| 5. Web Push Notifications | 6/8 | In Progress| |
| 6. UX Polish | 0/? | Not started | - |
## Backlog
@@ -255,7 +255,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD
**Plans:** 5/8 plans executed
**Plans:** 6/8 plans executed
Plans:
+8 -6
View File
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Completed 05-05-PLAN.md
last_updated: "2026-06-10T01:28:57.604Z"
stopped_at: Completed 05-06-PLAN.md
last_updated: "2026-06-10T01:40:01.911Z"
last_activity: 2026-06-10
progress:
total_phases: 15
completed_phases: 4
total_plans: 36
completed_plans: 33
completed_plans: 34
percent: 27
---
@@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-07)
## Current Position
Phase: 05 (web-push-notifications) — EXECUTING
Plan: 7 of 8
Plan: 8 of 8
Status: Ready to execute
Last activity: 2026-06-10
@@ -70,6 +70,7 @@ Progress: [█████████░] 89%
| Phase 05 P03 | 5 | - tasks | - files |
| Phase 05 P04 | 11 | 3 tasks | 9 files |
| Phase 05 P05 | 8 | 2 tasks | 4 files |
| Phase 05 P06 | 6 | 1 tasks | 2 files |
## Accumulated Context
@@ -109,6 +110,7 @@ Recent decisions affecting current work:
- [Phase ?]: dispatchPush uses sub.id (not a separate dbRowId argument) — 2-arg signature matches existing test
- [Phase ?]: coalesceListPush dispatch signature is (listId, actorId, count) — test scaffold canonical; richer payload deferred to Plan 05-05 caller
- [Phase ?]: notifyListChange fires for all list/item mutations except reorder (position) and list-create per D-01
- [Phase ?]: D-05-06-crossjoin: Drizzle cross-join in reminderScheduler pairs shared events with all pushSubscriptions; grouping by uid post-join ensures full fan-out per deduped event (reminderScheduler.ts)
### Roadmap Evolution
@@ -150,6 +152,6 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-10T01:28:57.597Z
Stopped at: Completed 05-05-PLAN.md
Last session: 2026-06-10T01:40:01.904Z
Stopped at: Completed 05-06-PLAN.md
Resume file: None
@@ -0,0 +1,120 @@
---
phase: 05-web-push-notifications
plan: 06
subsystem: api/reminder-scheduler
tags: [web-push, reminder, node-cron, tdd, red-green, notif-01, shared-calendar]
dependency_graph:
requires: [05-01, 05-02, 05-04]
provides: [startReminderScheduler, runReminderCheck, shared-event 15-min reminder dispatch]
affects:
- apps/api/src/broker/reminderScheduler.ts
- apps/api/src/index.ts
tech_stack:
added: []
patterns:
- node-cron 1-min schedule (same shape as startBrokerPoller in poller.ts)
- Drizzle cross-join (sql`1=1`) to fan shared events out to all push subscribers
- in-memory dedup Set keyed uid:minuteBucket (D-12 single-process, no Redis)
- per-event + per-subscription try/catch error isolation (T-05-18, T-05-19)
key_files:
created:
- apps/api/src/broker/reminderScheduler.ts
modified:
- apps/api/src/index.ts
decisions:
- "Cross-join (sql`1=1`) used to pair each due shared event with ALL push subscriptions in one Drizzle query (2 innerJoins: calendarEvents→calendars→pushSubscriptions) — matches the test scaffold's mock chain shape"
- "Grouping by uid after the flat cross-join result ensures all subscriptions for a deduped event are dispatched in one pass (prevents sub2 being silently skipped after dedup fires for sub1)"
- "sentReminders.add(key) called BEFORE iterating subs to prevent re-entry on concurrent ticks"
- "title fallback: event.title ?? uid — prevents 'undefined' in push copy for pre-05-07 rows"
metrics:
duration: 6
completed_date: "2026-06-10"
tasks_completed: 1
files_changed: 2
---
# Phase 05 Plan 06: reminderScheduler — shared timed 15-min reminder scan — Summary
TDD GREEN: `reminderScheduler.ts` implemented with D-05/D-07 SQL-enforced filtering, in-memory dedup, fan-out cross-join, and per-event error isolation — all 3 RED scaffold tests pass. Scheduler wired into `index.ts` `isMainModule()` guard.
## Tasks Executed
### Task 1: Implement reminderScheduler.ts (GREEN)
**Status:** Completed. Commit: `b95f671`
The RED scaffold (`tests/broker/reminderScheduler.test.ts`) was already committed in Plan 05-01 at `ef558b6`. This plan turns it GREEN.
Created `apps/api/src/broker/reminderScheduler.ts` with:
**`runReminderCheck(now = new Date())`** — single reminder scan cycle:
- Drizzle query: `db.select().from(calendarEvents).innerJoin(calendars, ...).innerJoin(pushSubscriptions, sql\`1=1\`)` — 2 innerJoins; cross-join fans each event out to all subscribers
- WHERE: `isShared=true AND allDay=false AND dtstartUtc >= now+14min AND dtstartUtc <= now+16min`
- D-05 enforced in QUERY (not copy) — personal events excluded at SQL level
- D-07 enforced in QUERY — all-day events excluded at SQL level
- Groups flat rows by uid, collects per-event subscription list
- Dedup: `sentReminders.add(\`${uid}:${minuteBucket}\`)` prevents window-boundary double-fire (T-05-18)
- Title fallback: `event.title ?? uid` — no "undefined" in reminder copy (NOTIF-01 / plan note)
- Notification payload: `{ title, body: 'Starts in 15 min', tag: \`reminder-${uid}\`, navigate: \`/calendar?date=${yyyyMmDd(dtstartUtc)}&event=${uid}\` }`
- Per-event and per-subscription try/catch for error isolation (T-05-18, T-05-19)
- Empty shared-calendar / empty push_subscriptions: cross-join returns 0 rows → zero sends, no crash (D-16)
**`startReminderScheduler()`** — node-cron `* * * * *` schedule (every minute):
- Same shape as `startBrokerPoller` in `poller.ts``.catch()` on the returned promise
- Not called at import time (guards the test process per WR-04)
**`index.ts`** — added `startReminderScheduler()` call in the `isMainModule()` guard, after `startOutboxWorker()` and after `webpush.setVapidDetails()` (so VAPID is configured before the scheduler starts).
**TDD Gate Compliance:**
- RED: `test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation``ef558b6` (Plan 05-01)
- GREEN: `feat(05-06): implement reminderScheduler — shared timed 15-min reminder scan``b95f671`
## Verification
```
pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts
Test Files 1 passed (1)
Tests 3 passed (3)
grep -q "startReminderScheduler" apps/api/src/index.ts → PASSED
pnpm --filter @familysync/api typecheck → passed (no errors)
```
## Deviations from Plan
### Auto-fixed issues
None. Plan executed exactly as written.
### Architecture note (no deviation — design decision)
The cross-join approach (`innerJoin(pushSubscriptions, sql\`1=1\`)`) was chosen over two separate `db.select()` calls because:
1. The test scaffold's mock requires exactly 2 `innerJoin()` calls in a single chain (`.from().innerJoin().innerJoin().where()`)
2. A cross-join is semantically correct: shared reminder → all members
3. Grouping by uid after the flat result correctly handles the fan-out while maintaining the `uid:minuteBucket` dedup semantics
## Known Stubs
None. The scheduler is fully implemented. The `calendar_events.title` column may be NULL for events synced before Plan 05-07 (which adds title extraction to the sync path), but the null fallback (`event.title ?? uid`) handles this gracefully without stubbing.
## Threat Flags
No new threat surface. All three plan threats mitigated:
| Threat | Status |
|--------|--------|
| T-05-17: personal-calendar event in reminder | Mitigated — `WHERE isShared=true` enforced in SQL |
| T-05-18: duplicate reminder storm at window boundary | Mitigated — in-memory dedup Set; per-event try/catch |
| T-05-19: one bad subscription aborting cycle | Mitigated — per-subscription try/catch; dispatchPush swallows 410/404 |
## Self-Check
**Files created/verified:**
- [x] apps/api/src/broker/reminderScheduler.ts — exists
**Commits verified:**
- ef558b6: test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation (RED gate — Plan 05-01)
- b95f671: feat(05-06): implement reminderScheduler — shared timed 15-min reminder scan (GREEN gate)
## Self-Check: PASSED