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

16 KiB

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 01 tdd 1
apps/api/src/broker/vevent.ts
apps/api/tests/broker/vevent.test.ts
true
CAL-13
CAL-14
truths artifacts key_links
A timed reminder serializes to a VALARM with a DURATION trigger and never emits VALUE=TEXT
An all-day reminder serializes to a VALARM with an absolute DATE-TIME (UTC) trigger
An existing single relative VALARM not on the preset list is classified as off-list with its real lead minutes
An absolute-time trigger or multiple VALARMs are classified as custom
computeAlertInstantUtc returns the 9 AM-local instant in UTC, correct across DST boundaries
path provides contains
apps/api/src/broker/vevent.ts buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, VALARM emission in buildVeventString, extended NewEventParams export function classifyValarms
path provides contains
apps/api/tests/broker/vevent.test.ts TDD coverage for all five units (RED-first) TRIGGER:-PT30M
from to via pattern
buildVeventString buildTimedValarm / buildAllDayValarm / params.valarms vevent.addSubcomponent addSubcomponent
from to via pattern
classifyValarms PRESET_MINUTES set leadMinutes membership test PRESET_MINUTES
Build the pure VALARM serialization + classification layer in `apps/api/src/broker/vevent.ts` — the contract every downstream plumbing plan consumes. Five independently testable units: `buildTimedValarm`, `buildAllDayValarm`, `classifyValarms`, `extractValarms`, and `computeAlertInstantUtc`, plus the VALARM emission branch inside `buildVeventString` and the extended `NewEventParams` interface.

Purpose: Isolate every encoding-sensitive piece (TRIGGER value type, absolute DATE-TIME for all-day, off-list vs custom classification, DST-correct 9 AM-local→UTC math — D-04 9 AM-local fire, D-05 0/1440/2880/10080-minute leads) into pure functions with TDD coverage, so Plan 03 (plumbing) and Plan 02 (scheduler) wire against a proven contract rather than discovering it. Output: New exported functions + extended interface in vevent.ts; extended vevent.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

<artifacts_this_phase_produces> This plan creates (exclude these from any source-drift / "symbol not found" check — they are NEW):

  • buildTimedValarm(leadMinutes) — vevent.ts
  • buildAllDayValarm(alertInstantUtc) — vevent.ts
  • classifyValarms(rawVevent){ kind: 'none' | 'preset' | 'offlist' | 'custom'; leadMinutes? } — vevent.ts
  • extractValarms(rawVevent)ICAL.Component[] — vevent.ts
  • computeAlertInstantUtc(eventDateStr, leadDays, tz)Date — vevent.ts
  • AlarmClassification exported type — vevent.ts
  • NewEventParams.reminderLeadMinutes, NewEventParams.valarms, NewEventParams.allDayAlertInstantUtc — new optional fields </artifacts_this_phase_produces>
Task 1: VALARM builders (buildTimedValarm, buildAllDayValarm) + buildVeventString emission apps/api/src/broker/vevent.ts, apps/api/tests/broker/vevent.test.ts - apps/api/src/broker/vevent.ts lines 21-33 (NewEventParams) and 149-153 (RRULE property construction analog — the resetType/setValue technique that prevents VALUE=TEXT, Pitfall 2) - apps/api/tests/broker/vevent.test.ts lines 22-156 (existing buildVeventString describe blocks to extend) - 11-PATTERNS.md § vevent.ts (VALARM emission block to insert after line 153; NewEventParams extension) - 11-RESEARCH.md Q1 (TRIGGER encoding: timed relative DURATION vs all-day absolute DATE-TIME) and Code Examples (buildTimedValarm / buildAllDayValarm) - RED: buildTimedValarm(30) → ICAL.Component whose serialized form contains `TRIGGER:-PT30M`, `ACTION:DISPLAY`, `DESCRIPTION:Reminder`, and does NOT contain `VALUE=TEXT`. - RED: buildTimedValarm(120) → contains `TRIGGER:-PT2H` (or RFC-equivalent `-PT120M`), no `VALUE=TEXT`. - RED: buildAllDayValarm(new Date('2026-06-14T13:00:00Z')) → serialized TRIGGER is an absolute DATE-TIME `20260614T130000Z` with `VALUE=DATE-TIME`, no DURATION. - RED: buildVeventString({allDay:false, reminderLeadMinutes:15, ...}) → emitted ICS contains exactly one BEGIN:VALARM block with `TRIGGER:-PT15M`. - RED: buildVeventString({allDay:false, reminderLeadMinutes:0, ...}) → emitted ICS contains NO VALARM (timed 0 = None per D-06). - RED: buildVeventString({allDay:true, reminderLeadMinutes:1440, allDayAlertInstantUtc:, ...}) → emitted ICS contains one VALARM with absolute DATE-TIME trigger. - RED: buildVeventString({reminderLeadMinutes:null, ...}) → no VALARM. - RED: buildVeventString({valarms:[], ...}) → that VALARM appears verbatim in the ICS AND no new VALARM is synthesized even if reminderLeadMinutes is also passed (preserve path wins). - GREEN: implement; REFACTOR: factor shared ACTION/DESCRIPTION setup if duplicated. Add `buildTimedValarm(leadMinutes: number): ICAL.Component` and `buildAllDayValarm(alertInstantUtc: Date): ICAL.Component` (both exported). Use `new ICAL.Property('trigger')` + `resetType('duration')` + `setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60))` for timed; `resetType('date-time')` + `setValue(ICAL.Time.fromJSDate(alertInstantUtc, true))` for all-day. NEVER use `addPropertyWithValue('trigger', '-PT15M')` (Pitfall 2 — emits VALUE=TEXT). Each VALARM also gets `addPropertyWithValue('action','DISPLAY')` and `addPropertyWithValue('description','Reminder')`. Extend `NewEventParams` (after the existing `dtstamp` field) with three optional fields: `reminderLeadMinutes?: number | null` (null = no VALARM; 0 = same-day all-day; positive = timed lead), `valarms?: ICAL.Component[]` (pre-parsed preserve-on-edit components), `allDayAlertInstantUtc?: Date` (9 AM-local-in-UTC for all-day absolute trigger). Insert the VALARM emission block in `buildVeventString` after the RRULE block (after line 153), before the optional location/description fields. Branch order: (1) if `params.valarms?.length` → addSubcomponent each, return without synthesizing (preserve wins); (2) else if `reminderLeadMinutes != null` and `allDay && allDayAlertInstantUtc` → addSubcomponent(buildAllDayValarm(allDayAlertInstantUtc)); (3) else if `!allDay && reminderLeadMinutes > 0` → addSubcomponent(buildTimedValarm(reminderLeadMinutes)). Note `reminderLeadMinutes === 0` on a timed event emits NO VALARM (D-06). pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts - tests/broker/vevent.test.ts asserts the emitted ICS for a 30-min timed lead contains `TRIGGER:-PT30M` and does NOT contain `VALUE=TEXT`. - tests assert buildAllDayValarm output contains an absolute `VALUE=DATE-TIME` trigger ending in `Z`. - test asserts a timed event with reminderLeadMinutes=0 emits no `BEGIN:VALARM`. - test asserts params.valarms preserve path emits the supplied VALARM verbatim and does not double-emit when reminderLeadMinutes is also set. - `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts` exits 0. buildTimedValarm, buildAllDayValarm exported and tested; buildVeventString emits the correct VALARM per allDay + NULL-vs-0 rules; preserve path takes precedence. Task 2: VALARM classifier + extractor (classifyValarms, extractValarms) apps/api/src/broker/vevent.ts, apps/api/tests/broker/vevent.test.ts - apps/api/src/broker/vevent.ts lines 63-79 (extractRruleString — the ICAL.parse try/catch + getFirstSubcomponent structure to mirror for extractValarms) - 11-RESEARCH.md Q3 (classifyValarms full implementation incl. the `firstValue instanceof ICAL.Time` check for absolute triggers, PRESET_MINUTES set, getAllSubcomponents('valarm')) - 11-PATTERNS.md § Shared Patterns (ICAL.parse try/catch + getFirstSubcomponent) - RED: classifyValarms(ICS with no VALARM) → { kind: 'none' }. - RED: classifyValarms(ICS with single `TRIGGER:-PT15M`) → { kind: 'preset', leadMinutes: 15 }. - RED: classifyValarms(ICS with single `TRIGGER:-PT45M`) → { kind: 'offlist', leadMinutes: 45 }. - RED: classifyValarms(ICS with `TRIGGER;VALUE=DATE-TIME:20260615T130000Z`) → { kind: 'custom' }. - RED: classifyValarms(ICS with two VALARM blocks) → { kind: 'custom' }. - RED: classifyValarms('not valid ics') → { kind: 'none' } (parse failure safe default). - RED: extractValarms(ICS with one VALARM) → array length 1 of ICAL.Component; extractValarms(ICS with none) → []; extractValarms('garbage') → []. - GREEN: implement; REFACTOR: share the parse-to-vevent helper between classifyValarms and extractValarms if it reduces duplication. Add exported `AlarmClassification` union type (`{ kind:'none' } | { kind:'preset'; leadMinutes:number } | { kind:'offlist'; leadMinutes:number } | { kind:'custom' }`). Add `classifyValarms(rawVevent: string): AlarmClassification` and `extractValarms(rawVevent: string): ICAL.Component[]`. Mirror extractRruleString's try/catch + `getFirstSubcomponent('vevent')` guard. In classifyValarms: `getAllSubcomponents('valarm')` — length 0 → none; length > 1 → custom; single → read `getFirstProperty('trigger')`, take `getFirstValue()`; if `instanceof ICAL.Time` → custom (absolute); otherwise treat as ICAL.Duration, `Math.round(Math.abs(dur.toSeconds())/60)` → leadMinutes; membership-test against `PRESET_MINUTES = new Set([0,5,10,15,30,60,120,1440,2880,10080])` → preset else offlist. Guard the duration value: if it lacks `toSeconds`, return custom. pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts - tests/broker/vevent.test.ts asserts classifyValarms returns offlist with leadMinutes 45 for a single `TRIGGER:-PT45M`. - test asserts an absolute `VALUE=DATE-TIME` trigger → { kind:'custom' } and two VALARMs → { kind:'custom' }. - test asserts preset minute 1440 (1 day) and 10080 (1 week) classify as preset. - test asserts extractValarms round-trips one VALARM as a live ICAL.Component (re-attachable via addSubcomponent). - vitest run exits 0. classifyValarms distinguishes none/preset/offlist/custom per D-07; extractValarms returns live components for preserve-on-edit; both safe on parse failure. Task 3: computeAlertInstantUtc (9 AM-local→UTC, DST-correct) apps/api/src/broker/vevent.ts, apps/api/tests/broker/vevent.test.ts - 11-RESEARCH.md Code Examples (computeAllDayAlertUtc / getUtcOffsetMs sketch — note the sketch has a placeholder; implement the offset correctly) and the TDD note requiring DST-boundary test cases - 11-RESEARCH.md Assumptions Log A2/A5 (server TZ = household TZ; no per-user TZ column) - RED: computeAlertInstantUtc('2026-06-15', 0, 'America/New_York') → Date equal to `2026-06-15T13:00:00Z` (9 AM EDT, summer offset -4). - RED: computeAlertInstantUtc('2026-06-15', 1, 'America/New_York') → `2026-06-14T13:00:00Z` (1 day before, still EDT). - RED: computeAlertInstantUtc('2026-01-15', 0, 'America/New_York') → `2026-01-15T14:00:00Z` (9 AM EST, winter offset -5). - RED (spring-forward): alert day = 2026-03-08 (US DST starts) → 9 AM local resolves with the correct post-transition offset (-4 → `2026-03-08T13:00:00Z`). - RED (fall-back): alert day = 2026-11-01 (US DST ends) → 9 AM local resolves with -5 → `2026-11-01T14:00:00Z`. - GREEN: implement using Intl-based offset for the SPECIFIC alert date (not today's offset). REFACTOR: extract the offset helper if it clarifies. Add exported `computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date`. Parse `eventDateStr` ('YYYY-MM-DD'), subtract `leadDays` to get the alert date, then compute the UTC instant for 09:00 local in `tz` ON THAT alert date. Derive the offset for that specific date via `Intl.DateTimeFormat` (e.g. format a probe UTC instant in `tz` and measure the wall-clock delta, or use `timeZoneName:'shortOffset'` parsing) — must use the alert date's own offset so DST transitions resolve correctly. Do NOT hardcode a fixed offset and do NOT use `new Date('...T09:00:00')` relying on the process TZ. leadDays is `reminderLeadMinutes / 1440` (0, 1, 2, or 7) — the D-05 minute mapping (0/1440/2880/10080); the 09:00 fire time is D-04. Caller passes `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` as tz. pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts - tests/broker/vevent.test.ts asserts computeAlertInstantUtc('2026-06-15', 0, 'America/New_York').toISOString() === '2026-06-15T13:00:00.000Z'. - test asserts the winter case ('2026-01-15', 0) → '2026-01-15T14:00:00.000Z'. - test asserts a spring-forward alert date and a fall-back alert date each resolve to the correct UTC instant using that date's offset (not today's). - vitest run exits 0. computeAlertInstantUtc returns the correct 9 AM-local UTC instant for same-day and N-day leads, verified at both standard and DST-transition dates.

<threat_model>

Trust Boundaries

Boundary Description
stored rawVevent → classifyValarms/extractValarms Untrusted iCalendar text (set by external clients: Fastmail/Apple) parsed server-side

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-11-01 Tampering classifyValarms/extractValarms parsing rawVevent mitigate ICAL.parse wrapped in try/catch returning safe defaults (none / []); no eval, no string splicing — ical.js handles line-folding + escaping
T-11-02 Denial of Service VALARM serialization accept Inputs are bounded integers (lead minutes from a fixed preset set) and a single Date; no unbounded loops; ical.js is the established serializer
T-11-SC Tampering npm/pip/cargo installs accept No new packages this phase (RESEARCH.md § Package Legitimacy Audit: not applicable); nothing to install

This phase adds NO new security surface: the only new external-input path (parsing stored rawVevent) is already wrapped in try/catch matching the existing extractRruleString idiom. </threat_model>

- `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts` green. - `pnpm --filter @familysync/api exec tsc --noEmit` clean (esbuild/vitest can pass while tsc fails — run tsc explicitly). - No `VALUE=TEXT` substring in any emitted timed-VALARM ICS (asserted in tests).

<success_criteria>

  • All five units (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc) exist, exported, and covered by RED-first tests now GREEN.
  • buildVeventString emits the correct VALARM honoring allDay, NULL-vs-0, and preserve precedence.
  • tsc --noEmit clean for apps/api. </success_criteria>
Create `.planning/phases/11-per-event-reminders/11-01-SUMMARY.md` when done. List every new exported symbol and the exact TRIGGER assertions added.