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

7.8 KiB

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 01 api/broker
valarm
ical.js
tdd
reminders
dst
calendar
requires provides affects
buildTimedValarm
buildAllDayValarm
classifyValarms
extractValarms
computeAlertInstantUtc
AlarmClassification
PRESET_MINUTES
NewEventParams.reminderLeadMinutes
NewEventParams.valarms
NewEventParams.allDayAlertInstantUtc
apps/api/src/broker/outboxWorker.ts (Plan 03 consumer)
apps/api/src/broker/reminderScheduler.ts (Plan 02 consumer)
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
created modified
apps/api/src/broker/vevent.ts
apps/api/tests/broker/vevent.test.ts
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
duration_minutes completed_date tasks_completed files_modified
7 2026-06-14 3 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):

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 && allDayAlertInstantUtcbuildAllDayValarm
  3. params.reminderLeadMinutes != null && !allDay && reminderLeadMinutes > 0buildTimedValarm
  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.