Files
familysync/.planning/phases/11-per-event-reminders/11-02-PLAN.md
T

205 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 11-per-event-reminders
plan: 02
type: tdd
wave: 2
depends_on: [11-01]
files_modified:
- apps/api/src/broker/reminderScheduler.ts
- apps/api/tests/broker/reminderScheduler.test.ts
autonomous: true
requirements: [NOTIF-04, NOTIF-05, NOTIF-06]
must_haves:
truths:
- "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)"
artifacts:
- path: "apps/api/src/broker/reminderScheduler.ts"
provides: "humanizeLeadMinutes, variable-lead query, uid:dtstartMs dedup, all-day 9 AM branch, NULL-vs-0 guard, dropped isShared restriction"
contains: "humanizeLeadMinutes"
- path: "apps/api/tests/broker/reminderScheduler.test.ts"
provides: "TDD coverage for variable-lead, all-day 9 AM, dedup, NULL-vs-0, humanized body"
contains: "uid:dtstartMs"
key_links:
- from: "runReminderCheck"
to: "calendarEvents.reminderLeadMinutes"
via: "SQL WHERE reminder_lead_minutes IS NOT NULL"
pattern: "reminderLeadMinutes"
- from: "notification.body"
to: "humanizeLeadMinutes"
via: "function call replacing the hardcoded string"
pattern: "humanizeLeadMinutes\\("
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<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>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: Variable-lead window + uid:dtstartMs dedup + drop isShared/allDay restrictions + NULL-vs-0 (timed)</name>
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>Scheduler fires per-event lead, ignores NULL and timed-0, fires on personal calendars, dedups on uid:dtstartMs; existing isShared/allDay-exclusion tests replaced.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: humanizeLeadMinutes body formatter</name>
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>humanizeLeadMinutes covers every preset bucket; scheduler body uses it driven by the configured lead (D-09).</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: All-day 9 AM-local fire branch (NOTIF-06)</name>
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts</automated>
</verify>
<acceptance_criteria>
- 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).
</acceptance_criteria>
<done>All-day reminders fire at 9 AM local on the computed alert day for 0/1440/2880/10080 leads, exactly once per uid:dtstartMs.</done>
</task>
</tasks>
<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>
<verification>
- `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).
</verification>
<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>
<output>
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.
</output>