Files
familysync/.planning/milestones/v1.1-phases/11-per-event-reminders/11-02-SUMMARY.md
T
2026-06-18 22:21:38 -04:00

8.6 KiB
Raw Blame History

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
scheduler
tdd
reminders
variable-lead
dedup
humanize
all-day
notif-04
notif-05
notif-06
requires provides affects
computeAlertInstantUtc (Plan 11-01, vevent.ts)
humanizeLeadMinutes
runReminderCheck (variable-lead, uid:dtstartMs dedup, all-day branch)
startReminderScheduler (unchanged)
apps/api/src/broker/reminderScheduler.ts (Plan 11-03+ consumer if any)
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)
created modified
apps/api/src/broker/reminderScheduler.ts
apps/api/tests/broker/reminderScheduler.test.ts
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.
duration_minutes completed_date tasks_completed files_modified
13 2026-06-14 3 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: < 60N min; < 1201 hour; < 1440N hours; < 28801 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
559 Starts in N min
60119 Starts in 1 hour
1201439 Starts in N hours
14402879 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:

Exports verified: humanizeLeadMinutes exported from reminderScheduler.ts, computeAlertInstantUtc imported from vevent.ts (Plan 11-01 artifact).