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

16 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
11-per-event-reminders 02 tdd 2
11-01
apps/api/src/broker/reminderScheduler.ts
apps/api/tests/broker/reminderScheduler.test.ts
true
NOTIF-04
NOTIF-05
NOTIF-06
truths artifacts key_links
A reminder fires at the event's chosen lead time (e.g. T-30 for a 30-min lead), not a hardcoded 15-min lead
An event with NULL reminderLeadMinutes produces no push
A timed event with reminderLeadMinutes=0 produces no push; an all-day event with 0 fires at 9 AM on the event date
Reminders fire on personal calendars too (the isShared-only restriction is dropped)
The same event fires exactly once across consecutive ticks; a rescheduled event (new dtstart) re-fires — dedup key is uid:dtstartMs
Push body is humanized to the largest sensible unit (30 min / 1 hour / 1 day / 7 days) (D-09)
path provides contains
apps/api/src/broker/reminderScheduler.ts humanizeLeadMinutes, variable-lead query, uid:dtstartMs dedup, all-day 9 AM branch, NULL-vs-0 guard, dropped isShared restriction humanizeLeadMinutes
path provides contains
apps/api/tests/broker/reminderScheduler.test.ts TDD coverage for variable-lead, all-day 9 AM, dedup, NULL-vs-0, humanized body uid:dtstartMs
from to via pattern
runReminderCheck calendarEvents.reminderLeadMinutes SQL WHERE reminder_lead_minutes IS NOT NULL reminderLeadMinutes
from to via pattern
notification.body humanizeLeadMinutes function call replacing the hardcoded string humanizeLeadMinutes(
Generalize the reminder scheduler in `apps/api/src/broker/reminderScheduler.ts` from the fixed shared-timed-15-min scan to a per-event variable-lead scheduler that reads `reminder_lead_minutes` from the DB as ground truth: variable per-event window, `uid:dtstartMs` compound dedup, dropped `isShared`-only restriction, an all-day 9 AM-local branch (D-04), NULL-vs-0 semantics, and a humanized push body (`humanizeLeadMinutes`).

Purpose: Satisfy NOTIF-04 (fire at chosen lead), NOTIF-05 (no fire when no reminder / timed-0), and NOTIF-06 (all-day 9 AM, exactly-once dedup across catch-up + reschedule). The dispatch plumbing (dispatchPush, per-event/per-sub try/catch, deep-link navigate, prune) is preserved — only the query window, dedup key, lead source, fire-time computation, and body text change. Output: Rewritten runReminderCheck logic + new humanizeLeadMinutes; extended reminderScheduler.test.ts.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/11-per-event-reminders/11-RESEARCH.md @.planning/phases/11-per-event-reminders/11-PATTERNS.md @.planning/phases/11-per-event-reminders/11-VALIDATION.md @.planning/phases/11-per-event-reminders/11-UI-SPEC.md

<artifacts_this_phase_produces> This plan creates (exclude from drift checks — NEW):

  • humanizeLeadMinutes(leadMinutes) → string — reminderScheduler.ts
  • Compound dedup key format uid:dtstartMs (replaces bare uid) — reminderScheduler.ts
  • All-day 9 AM scheduler branch reading reminderLeadMinutes — reminderScheduler.ts NOTE: computeAlertInstantUtc is created by Plan 11-01 in vevent.ts and imported here — it is NOT new to this plan. </artifacts_this_phase_produces>

<context_note> This plan imports computeAlertInstantUtc from ../broker/vevent.js (created in Plan 11-01). This plan is wave 2, depends_on: [11-01] — 11-01 lands before this plan runs, so the symbol is available and the all-day-9AM test can go GREEN within this plan. Sequence the all-day task LAST within this plan. Do not redefine computeAlertInstantUtc here — import it from 11-01. </context_note>

Task 1: Variable-lead window + uid:dtstartMs dedup + drop isShared/allDay restrictions + NULL-vs-0 (timed) apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts - apps/api/src/broker/reminderScheduler.ts (full) — the existing query (lines 84-107), byUid grouping (109-138), dispatch+dedup loop (141-188), prune (193-197) - apps/api/tests/broker/reminderScheduler.test.ts — existing describe blocks: shared+timed filtering (76), catch-up window single-fire across 3 ticks (167-228), fan-out (265), mark-sent-after-dispatch (306), prune (362) - 11-RESEARCH.md Pitfall 4 (uid:dtstartMs dedup, variable window Option B) and "Pitfall: 0 Lead Minutes on a Timed Event" - 11-PATTERNS.md § reminderScheduler.ts (dedup key change, SQL WHERE changes, prune key change) - RED: a timed event with reminderLeadMinutes=30 dispatches exactly when now is in (dtstart - 30min - 60s, dtstart - 30min] window; does NOT dispatch a 30-min-lead event 5 min before start with no other coverage — i.e. fire time is lead-driven, not a fixed 16-min window. Replace the existing single-fire test to use a per-event lead. - RED: a personal (isShared=false) timed event with a non-null lead DOES dispatch (drop the isShared restriction — NOTIF-05 corollary; the existing "does not dispatch for non-shared" test must be inverted/replaced). - RED: an event with reminderLeadMinutes=NULL produces zero dispatches. - RED: a timed event with reminderLeadMinutes=0 produces zero dispatches (0 on timed = None, D-06). - RED: dedup — the same event (same uid + same dtstartMs) fires exactly once across 3 consecutive ticks inside its fire window; an event whose dtstart is rescheduled to a new instant (new dtstartMs, same uid) fires again (new compound key). The existing "single-fire across three ticks" test is updated to assert the uid:dtstartMs key. - GREEN: implement; REFACTOR: keep per-event and per-sub try/catch intact. Change `sentReminders` key from bare `uid` to `` `${uid}:${dtstartMs}` `` (template using `event.dtstartUtc.getTime()`); update the dedup check, the `sentReminders.set(...)` call, and the prune loop to use the compound key while still pruning on stored dtstartMs <= now. In the query: REMOVE `eq(calendars.isShared, true)` and `eq(calendarEvents.allDay, false)`; ADD `reminderLeadMinutes` to the `.select()` and a `sql\`${calendarEvents.reminderLeadMinutes} IS NOT NULL\`` predicate. For TIMED events compute fire time per-event as `dtstartUtc - reminderLeadMinutes minutes` and fire when that instant falls in `(now - 60s, now]` (catch-up of one missed tick) while `dtstartUtc > now`; widen the SQL pre-filter window to `dtstartUtc <= now + max-lead` (use the max preset, 2880 min) so long-lead events enter the JS filter, then apply the precise per-event fire-time check in JS. Guard: timed events with `reminderLeadMinutes === 0` are skipped (treated as None per D-06) — only all-day uses 0. Keep dispatchPush fan-out, per-sub try/catch, mark-sent-after-dispatch (WR-01), and the navigate deep-link unchanged. pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts - reminderScheduler.test.ts asserts a 30-min-lead timed event fires in the lead-driven window (NOTIF-04), and a NULL-lead event and a timed-0-lead event both produce zero dispatches (NOTIF-05). - test asserts a personal (isShared=false) event with a non-null lead DOES dispatch (restriction dropped). - test asserts the dedup key is `uid:dtstartMs`: same compound key fires once across 3 ticks; a new dtstartMs re-fires (NOTIF-06 reschedule). - vitest run exits 0. Scheduler fires per-event lead, ignores NULL and timed-0, fires on personal calendars, dedups on uid:dtstartMs; existing isShared/allDay-exclusion tests replaced. Task 2: humanizeLeadMinutes body formatter apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts - apps/api/src/broker/reminderScheduler.ts line 157 (hardcoded `Starts in ${minutes} min` to replace) - 11-UI-SPEC.md § Notification Push Copy (humanized thresholds table) and 11-RESEARCH.md Code Examples (humanizeLeadMinutes incl. the 90-min/1-hour ordering note) - RED: humanizeLeadMinutes(30) → 'Starts in 30 min' - RED: humanizeLeadMinutes(59) → 'Starts in 59 min' - RED: humanizeLeadMinutes(60) → 'Starts in 1 hour' - RED: humanizeLeadMinutes(90) → 'Starts in 1 hour' (60119 bucket, per UI-SPEC) - RED: humanizeLeadMinutes(120) → 'Starts in 2 hours' - RED: humanizeLeadMinutes(1440) → 'Starts in 1 day' - RED: humanizeLeadMinutes(2880) → 'Starts in 2 days' - RED: humanizeLeadMinutes(10080) → 'Starts in 7 days' - GREEN: implement; the notification body in runReminderCheck calls humanizeLeadMinutes(event.reminderLeadMinutes) instead of the actual minutes-to-start string. Add `humanizeLeadMinutes(leadMinutes: number): string` with branch order: `< 60` → `Starts in ${N} min`; `< 120` → `Starts in 1 hour`; `< 1440` → `Starts in ${Math.round(N/60)} hours`; `< 2880` → `Starts in 1 day`; else `Starts in ${Math.round(N/1440)} days`. Replace the `body: \`Starts in ${minutes} min\`` line with `body: humanizeLeadMinutes(event.reminderLeadMinutes)` — drive off the EVENT's configured lead (the DB ground-truth value carried into the byUid record), not the live minutes-to-start delta. Carry `reminderLeadMinutes` into the byUid grouped record so the body has the configured lead available. Title and navigate unchanged. pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts - reminderScheduler.test.ts asserts humanizeLeadMinutes maps 30→"30 min", 60→"1 hour", 90→"1 hour", 120→"2 hours", 1440→"1 day", 10080→"7 days". - a dispatch test asserts the dispatched notification.body equals the humanized string for the event's configured lead (e.g. a 1440 all-day lead → "Starts in 1 day"). - vitest run exits 0. humanizeLeadMinutes covers every preset bucket; scheduler body uses it driven by the configured lead (D-09). Task 3: All-day 9 AM-local fire branch (NOTIF-06) apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts - apps/api/src/broker/reminderScheduler.ts (the query + JS filter from Task 1 — extend with an all-day branch) - 11-RESEARCH.md "Pitfall: All-Day 9 AM UTC Computation at DST Boundaries" and "Variable-lead scheduler SQL query" (all-day: fetch with non-null leads, compute 9 AM UTC in JS) ; 11-01 artifacts for computeAlertInstantUtc signature - 11-PATTERNS.md § reminderScheduler.ts (all-day handling) - RED: an all-day event (allDay=true, dtstartDate='2026-06-15') with reminderLeadMinutes=0 dispatches when now is ~9 AM local on 2026-06-15 (computeAlertInstantUtc('2026-06-15',0,tz) in the fire window), and does NOT dispatch at midnight. - RED: an all-day event with reminderLeadMinutes=1440 dispatches at 9 AM local on the day before (2026-06-14). - RED: an all-day event with reminderLeadMinutes=10080 dispatches at 9 AM local 7 days before. - RED: all-day dedup uses the same uid:dtstartMs (use dtstartDate-derived ms or the computed alert ms consistently as the dtstart component) so it fires once across ticks. - GREEN: implement using the imported computeAlertInstantUtc; REFACTOR. Import `computeAlertInstantUtc` from `./vevent.js` (Plan 11-01). Add an all-day branch to the scan: fetch all-day events (allDay=true) with non-null `reminderLeadMinutes` whose `dtstartDate` is within the next `max-lead-days` (7) of now; for each compute `alertInstant = computeAlertInstantUtc(dtstartDate, reminderLeadMinutes / 1440, tz)` where `tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`; fire when `alertInstant` falls in `(now - 60s, now]`. All-day reminderLeadMinutes=0 IS valid (same-day 9 AM; D-04 fire time, D-05 minute mapping 0/1440/2880/10080, D-06 NULL-vs-0) — do NOT apply the timed-0 skip to all-day rows. Use the all-day event's stable dtstart component for the `uid:dtstartMs` dedup key (e.g. the dtstartDate's UTC-midnight ms, consistent between ticks). Keep dispatch/fan-out/body (humanizeLeadMinutes) identical to the timed path. pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts - reminderScheduler.test.ts asserts an all-day 0-lead event fires at the computeAlertInstantUtc(date,0,tz) instant (≈9 AM local) and not at midnight (NOTIF-06). - test asserts a 1440 all-day lead fires 9 AM the prior day and a 10080 lead fires 7 days prior. - test asserts the all-day event dedups once across consecutive ticks via uid:dtstartMs. - vitest run exits 0 (requires Plan 11-01's computeAlertInstantUtc merged). All-day reminders fire at 9 AM local on the computed alert day for 0/1440/2880/10080 leads, exactly once per uid:dtstartMs.

<threat_model>

Trust Boundaries

Boundary Description
DB calendar_events → scheduler reminder_lead_minutes is a bounded int already validated on write; read path, no external input
scheduler → browser push dispatchPush (existing, unchanged) signs VAPID payloads; body is a hardcoded humanized string

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-11-03 Information Disclosure Dropping the isShared restriction accept Personal-event reminders fan out to all push subscriptions in a 2-member (expanding) household by design (NOTIF-05 corollary); body carries only event title + relative time, no sensitive field; same exposure model as the existing shared path
T-11-04 Denial of Service Variable-lead scan window mitigate Window capped at max preset lead (2880 min timed / 7 days all-day); JS-side per-event filter bounds work; setInterval-only (node-cron forbidden)
T-11-05 Repudiation Exactly-once delivery mitigate uid:dtstartMs dedup + mark-sent-after-dispatch (WR-01) preserved; prune prevents unbounded Map growth
T-11-SC Tampering npm installs accept No new packages this phase

No new security surface: the only behavioral expansion (personal-calendar reminders) is an authorized requirement (NOTIF-05 corollary), not a new untrusted-input path. </threat_model>

- `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts` green. - `pnpm --filter @familysync/api exec tsc --noEmit` clean. - Grep confirms `setInterval` retained and no `node-cron` import added (hard project rule).

<success_criteria>

  • Scheduler reads reminder_lead_minutes as ground truth; fires per-event lead; ignores NULL and timed-0; fires personal events; dedups on uid:dtstartMs.
  • All-day events fire at 9 AM local; humanized body for every preset bucket.
  • All NOTIF-04/05/06 rows in 11-VALIDATION.md Per-Task Verification Map are claimed by an automated test here. </success_criteria>
Create `.planning/phases/11-per-event-reminders/11-02-SUMMARY.md` when done. Record the new dedup key format, the dropped predicates, and the humanizeLeadMinutes branch thresholds.