--- phase: 11-per-event-reminders plan: "01" subsystem: api/broker tags: [valarm, ical.js, tdd, reminders, dst, calendar] dependency_graph: requires: [] provides: - buildTimedValarm - buildAllDayValarm - classifyValarms - extractValarms - computeAlertInstantUtc - AlarmClassification - PRESET_MINUTES - NewEventParams.reminderLeadMinutes - NewEventParams.valarms - NewEventParams.allDayAlertInstantUtc affects: - apps/api/src/broker/outboxWorker.ts (Plan 03 consumer) - apps/api/src/broker/reminderScheduler.ts (Plan 02 consumer) tech_stack: added: [] patterns: - resetType('duration') + setValue(ICAL.Duration) to prevent VALUE=TEXT on TRIGGER - resetType('date-time') + setValue(ICAL.Time) for absolute DATE-TIME VALARM trigger - Intl.DateTimeFormat-based UTC offset probe at 9 AM (not midnight) for DST-correct computation - ICAL.parse try/catch safe-default pattern for T-11-01 tamper mitigation key_files: created: [] modified: - apps/api/src/broker/vevent.ts - apps/api/tests/broker/vevent.test.ts decisions: - "D-VALARM-PROBE: computeAlertInstantUtc probes UTC offset at naive-9AM-UTC (not midnight) so DST transitions before 9 AM (spring-forward at 2 AM) use the post-transition offset — single-pass Intl computation, no iteration needed" - "D-ICAL-INSTANCEOF: classifyValarms uses instanceof ICAL.Time (not getParameter('value')) to distinguish absolute vs relative TRIGGER — more robust per A3/A4 assumption log since getParameter returns undefined for default-type DURATION triggers" metrics: duration_minutes: 7 completed_date: "2026-06-14" tasks_completed: 3 files_modified: 2 --- # Phase 11 Plan 01: VALARM Serialization + Classification Layer Summary VALARM pure-function layer: five exported units covering timed DURATION trigger, all-day absolute DATE-TIME trigger, none/preset/offlist/custom classification, component extraction for preserve-on-edit, and DST-correct 9 AM-local→UTC computation — all TDD RED-first, 37/37 tests green. ## Tasks Completed | Task | Description | Commit | |------|-------------|--------| | RED | Failing tests for all 5 units + buildVeventString VALARM emission | 860c741 | | GREEN | Implementation: buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, VALARM emission in buildVeventString, extended NewEventParams | d9eb5c1 | ## New Exported Symbols | Symbol | File | Description | |--------|------|-------------| | `buildTimedValarm(leadMinutes)` | vevent.ts | VALARM with DURATION trigger `-PTNmM`; never emits VALUE=TEXT (resetType('duration')) | | `buildAllDayValarm(alertInstantUtc)` | vevent.ts | VALARM with absolute DATE-TIME trigger `VALUE=DATE-TIME:YYYYMMDDTHHMMSSz` | | `classifyValarms(rawVevent)` | vevent.ts | Returns `AlarmClassification`: none/preset/offlist/custom; safe on parse failure | | `extractValarms(rawVevent)` | vevent.ts | Returns live `ICAL.Component[]` for preserve-on-edit re-attachment; safe on parse failure | | `computeAlertInstantUtc(dateStr, leadDays, tz)` | vevent.ts | DST-correct 9 AM-local→UTC; probes offset at 9 AM to handle transitions before 9 AM | | `AlarmClassification` | vevent.ts | Union type: `{ kind:'none' } \| { kind:'preset'; leadMinutes } \| { kind:'offlist'; leadMinutes } \| { kind:'custom' }` | | `PRESET_MINUTES` | vevent.ts | `Set([0,5,10,15,30,60,120,1440,2880,10080])` — D-01/D-02 preset list | ## Interface Extensions `NewEventParams` in `vevent.ts` (after existing `dtstamp` field): ```typescript reminderLeadMinutes?: number | null; // null = no VALARM; 0 = same-day all-day; positive = timed lead valarms?: ICAL.Component[]; // pre-parsed preserve-on-edit components (D-08/CAL-14) allDayAlertInstantUtc?: Date; // 9 AM local on alert day in UTC (for buildAllDayValarm) ``` ## Key TRIGGER Assertions in Tests - `buildTimedValarm(30)` → ICS contains `TRIGGER:-PT30M`, does NOT contain `VALUE=TEXT` - `buildTimedValarm(120)` → ICS matches `/TRIGGER:-P(?:T2H|T120M)/`, no VALUE=TEXT - `buildAllDayValarm(new Date('2026-06-14T13:00:00Z'))` → ICS contains `20260614T130000Z` + `VALUE=DATE-TIME` - `buildVeventString({allDay:false, reminderLeadMinutes:15})` → ICS contains `TRIGGER:-PT15M`, no VALUE=TEXT - `buildVeventString({allDay:false, reminderLeadMinutes:0})` → NO `BEGIN:VALARM` (timed 0 = None, D-06) - `buildVeventString({allDay:true, reminderLeadMinutes:1440, allDayAlertInstantUtc:...})` → `VALUE=DATE-TIME` trigger - `buildVeventString({reminderLeadMinutes:null})` → NO `BEGIN:VALARM` - `buildVeventString({valarms:[60minAlarm], reminderLeadMinutes:15})` → only 60-min VALARM present (preserve wins) ## buildVeventString VALARM Branch Order 1. `params.valarms?.length > 0` → re-attach each via `addSubcomponent`; ignore `reminderLeadMinutes` 2. `params.reminderLeadMinutes != null && allDay && allDayAlertInstantUtc` → `buildAllDayValarm` 3. `params.reminderLeadMinutes != null && !allDay && reminderLeadMinutes > 0` → `buildTimedValarm` 4. Everything else → no VALARM ## computeAlertInstantUtc DST Tests | Input | Expected | Notes | |-------|----------|-------| | `('2026-06-15', 0, 'America/New_York')` | `2026-06-15T13:00:00.000Z` | 9 AM EDT (UTC-4) | | `('2026-06-15', 1, 'America/New_York')` | `2026-06-14T13:00:00.000Z` | 1 day before, EDT | | `('2026-01-15', 0, 'America/New_York')` | `2026-01-15T14:00:00.000Z` | 9 AM EST (UTC-5) | | `('2026-03-08', 0, 'America/New_York')` | `2026-03-08T13:00:00.000Z` | Spring-forward day, post-transition EDT | | `('2026-11-01', 0, 'America/New_York')` | `2026-11-01T14:00:00.000Z` | Fall-back day, post-transition EST | ## Verification Results - `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts`: 37/37 PASS - `pnpm --filter @familysync/api exec vitest run` (full suite): 296/296 PASS - `pnpm --filter @familysync/api exec tsc --noEmit`: CLEAN (0 errors) ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] ical.js TRIGGER duration normalization in round-trip test** - **Found during:** Task 2 GREEN - **Issue:** Test expected `TRIGGER:-PT1H` but ical.js re-serializes a parsed `-PT60M` duration as `-PT60M` (it does not normalize to hours). Both are RFC-valid. - **Fix:** Updated test to match `/TRIGGER:-P(?:T60M|T1H)/` accepting both forms. - **Files modified:** apps/api/tests/broker/vevent.test.ts - **Commit:** d9eb5c1 **2. [Rule 1 - Bug] DST probe at midnight vs at 9 AM** - **Found during:** Task 3 GREEN — spring-forward (2026-03-08) and fall-back (2026-11-01) tests failed - **Issue:** Original `getUtcOffsetMsForDate` computed the UTC offset at alertDate midnight. On spring-forward day (DST change at 2 AM), midnight is still in EST (UTC-5), but 9 AM is in EDT (UTC-4). Using the midnight offset gave the wrong UTC instant. - **Fix:** Replaced the midnight-based offset computation with a probe at `alertDateUtcMs + 9h` (naive 9 AM UTC), then asked Intl what local date+time that corresponds to and computed the adjustment. The probe is inherently near 9 AM so it captures the post-transition offset on DST days. - **Files modified:** apps/api/src/broker/vevent.ts - **Commit:** d9eb5c1 ## Known Stubs None. All five units are fully implemented and tested. No placeholder values or TODO markers in the produced code. ## Threat Flags None. This plan adds no new network endpoints, auth paths, or schema changes. The only external-input path (classifyValarms/extractValarms parsing stored rawVevent) is wrapped in try/catch per T-11-01 mitigation, matching the existing extractRruleString idiom. ## Self-Check: PASSED Files exist: - FOUND: apps/api/src/broker/vevent.ts - FOUND: apps/api/tests/broker/vevent.test.ts Commits exist: - 860c741: RED commit (test(11-01)) - d9eb5c1: GREEN commit (feat(11-01)) Exports verified: `grep -n 'export function\|export const\|export type' apps/api/src/broker/vevent.ts` confirms all 7 symbols exported.