Files
familysync/.planning/milestones/v1.0-phases/05-web-push-notifications/05-06-SUMMARY.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

6.1 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
05-web-push-notifications 06 api/reminder-scheduler
web-push
reminder
node-cron
tdd
red-green
notif-01
shared-calendar
requires provides affects
05-01
05-02
05-04
startReminderScheduler
runReminderCheck
shared-event 15-min reminder dispatch
apps/api/src/broker/reminderScheduler.ts
apps/api/src/index.ts
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)
created modified
apps/api/src/broker/reminderScheduler.ts
apps/api/src/index.ts
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
duration completed_date tasks_completed files_changed
6 2026-06-10 1 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 truncationef558b6 (Plan 05-01)
  • GREEN: feat(05-06): implement reminderScheduler — shared timed 15-min reminder scanb95f671

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:

  • 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