From 8e741cf528411019239e4103128c8b3e54ba6e6a Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 21:40:22 -0400 Subject: [PATCH] docs(05-06): complete reminderScheduler plan --- .planning/ROADMAP.md | 6 +- .planning/STATE.md | 14 +- .../05-06-SUMMARY.md | 120 ++++++++++++++++++ 3 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/05-web-push-notifications/05-06-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 064a47f..b016383 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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: diff --git a/.planning/STATE.md b/.planning/STATE.md index d838043..c48c796 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/05-web-push-notifications/05-06-SUMMARY.md b/.planning/phases/05-web-push-notifications/05-06-SUMMARY.md new file mode 100644 index 0000000..7dd4e0a --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-06-SUMMARY.md @@ -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