From feec030509913d0ad59563a3e5acc9c75401e05b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 21:04:32 -0400 Subject: [PATCH] =?UTF-8?q?docs(11):=20research=20phase=20=E2=80=94=20VALA?= =?UTF-8?q?RM,=20NULL-vs-0=20schema,=20off-list=20classifier,=20variable-l?= =?UTF-8?q?ead=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../11-per-event-reminders/11-RESEARCH.md | 769 ++++++++++++++++++ 1 file changed, 769 insertions(+) create mode 100644 .planning/phases/11-per-event-reminders/11-RESEARCH.md diff --git a/.planning/phases/11-per-event-reminders/11-RESEARCH.md b/.planning/phases/11-per-event-reminders/11-RESEARCH.md new file mode 100644 index 0000000..64ab6c2 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-RESEARCH.md @@ -0,0 +1,769 @@ +# Phase 11: Per-Event Reminders - Research + +**Researched:** 2026-06-13 +**Domain:** CalDAV VALARM serialization, zod schema NULL-vs-0, scheduler variable-lead, ical.js VALARM classification +**Confidence:** HIGH — all findings derived from direct source inspection of the shipped codebase + ical.js official docs + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Timed events keep preset list: None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None. +- **D-02:** All-day events get context-swapped day-granularity presets: None / Same day / 1 day before / 2 days before / 1 week before, default None. +- **D-03:** Picker shown for both event types — swaps option set based on All-day toggle, does NOT disappear. +- **D-04:** All-day reminders fire at 9 AM local on the computed alert day. +- **D-05:** All-day leads stored as minutes: Same day = 0, 1d = 1440, 2d = 2880, 1wk = 10080. +- **D-06:** NULL = no reminder (no VALARM, no push). Same-day all-day = 0 (fire 9 AM on event date). 0 on a timed event = None. +- **D-07:** Show exact value if single simple relative alarm not in preset list (off-list synthetic option). Absolute-time or multiple alarms → read-only "Custom (kept)" entry. +- **D-08:** Original VALARM(s) preserved verbatim on save unless user explicitly selects a preset or None. Mirrors WR-01 RRULE-preserve pattern. +- **D-09:** Humanized relative push body: "Starts in 2 days" / "Starts in 1 hour" / "Starts in 30 min". Replaces hardcoded `Starts in ${minutes} min` in reminderScheduler.ts:157. +- **D-10:** Series-level only. One VALARM on master event; no per-occurrence (RECURRENCE-ID) override. +- **ROADMAP Pitfall 3 AMENDED:** The `buildVeventString` allDay guard (`if (!allDay && reminderMinutes > 0)`) is changed to ALSO emit a VALARM for all-day events with a day-based lead. +- **CAL-13 extended:** Preset list extended for all-day case per D-02; timed presets unchanged. +- **NOTIF-06 retained:** 9 AM all-day fire governs all-day day-leads, not a hidden/disabled selector. + +### Claude's Discretion + +- Picker placement within EventForm.tsx (reuse existing labeled `` to EventForm.tsx, (2) plumb `reminderLeadMinutes` through the outbox schema into `buildVeventString` as a VALARM, (3) generalize the scheduler to per-event variable lead + all-day 9 AM rule + uid:dtstartMs dedup, and (4) write the VALARM-preserve-on-edit path mirroring WR-01. Three open technical questions from CONTEXT.md are answered concretely below. + +**Primary recommendation:** Treat this as five discrete, independently testable units — VALARM builder, VALARM classifier, schema NULL-vs-0 plumbing, scheduler lead/9AM logic, and humanized-body formatter — each with TDD I/O examples, then wire them together in the form and worker. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Reminder picker UI (preset select, allDay swap) | Frontend / React PWA | — | Pure form state; no server round-trip until save | +| Off-list alarm classifier (detect single/relative vs absolute/multi) | API / Backend (parse rawVevent) | — | rawVevent lives server-side; classification needed during GET response to populate picker | +| VALARM serialization (buildVeventString) | API / Backend (broker) | — | ICS construction happens in outbox worker | +| VALARM preserve-on-edit | API / Backend (outboxWorker update path) | — | Reads rawVevent from DB at drain time | +| reminderLeadMinutes plumbing (form → outbox → DB) | API / Backend (route + schema) | Frontend (payload shape) | Schema is already in DB; route schema needs the new field | +| Scheduler variable-lead window | API / Backend (reminderScheduler) | — | setInterval-only; SQL WHERE uses reminderLeadMinutes | +| All-day 9 AM local fire | API / Backend (reminderScheduler) | — | Server computes alert time from dtstartDate + local timezone | +| Humanized push body | API / Backend (reminderScheduler) | — | Replaces hardcoded string at line 157 | + +--- + +## Open Question Answers (Concrete, Code-Grounded) + +### Q1: VALARM TRIGGER encoding for all-day "N days before at 9 AM" — cross-client interop + +**Finding:** The scheduler is ground truth for the actual push fire time (9 AM local). The VALARM in the ICS is best-effort cross-client interop. Use a **relative DURATION trigger** for all-day events with an offset that approximates 9 AM. + +**RFC 5545 background:** `TRIGGER` on a VALARM has two legal value types: +- `DURATION` (default, no VALUE param): evaluated relative to DTSTART (which for all-day events is a DATE with no time component — effectively midnight). +- `DATE-TIME` (with `VALUE=DATE-TIME`): an absolute UTC instant. + +For an all-day event, `TRIGGER:-PT15M` means "15 minutes before midnight of the event date." Apple Calendar interprets this in local time. Fastmail's web client may ignore VALARM on all-day events entirely in some configurations. [CITED: RFC 5545 §3.8.6.3] + +**Best-effort recommendation — use `-PT15H` (or the matching offset for the chosen day-lead):** + +For "Same day" (lead=0): the alert date is the event date itself. To fire at 9 AM, the TRIGGER offset from midnight-of-DTSTART is `+9h`. RFC 5545 requires TRIGGER on VALARM to be negative or zero (alarm fires AT or BEFORE the event). A positive offset is technically non-conforming. **Practical approach:** Use `-PT15H` on the DAY BEFORE, i.e., treat "Same day at 9 AM" as 9 AM on the event date = DTSTART + 9h. Since this is a positive offset, and RFC compliance is fraught, use a DURATION from the previous midnight: + +``` +Same day (0 min lead): TRIGGER:-P0DT15H → this is non-standard positive (9h after midnight) +Better encoding: Treat as "fire 9h into the event day" — use absolute DATE-TIME trigger +``` + +**Recommended encoding: Absolute DATE-TIME TRIGGER for all-day events.** [ASSUMED — based on RFC 5545 + client behavior knowledge] + +Build the trigger as `TRIGGER;VALUE=DATE-TIME:` where the UTC instant corresponds to 9 AM local on the alert day. This is the only encoding that is unambiguous across Fastmail and Apple Calendar: + +- Fastmail: honors absolute DATE-TIME TRIGGER on all-day events [ASSUMED — not verifiable without live Fastmail testing] +- Apple Calendar: honors absolute DATE-TIME TRIGGER [ASSUMED — per developer reports] +- The SCHEDULER is ground truth regardless — ICS interop is best-effort + +**Implementation in ical.js — absolute DATE-TIME trigger:** + +```typescript +// Source: ical.js jCal structure (Context7/kewisch/ical.js wiki) +// For all-day events: compute alertInstantUtc = 9 AM local on (eventDate - leadDays) +function buildAbsoluteTrigger(alertInstantUtc: Date): ICAL.Property { + const triggerProp = new ICAL.Property('trigger'); + // VALUE=DATE-TIME: ical.js uses 'date-time' value type + triggerProp.resetType('date-time'); + triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); // useUTC=true → Z suffix + return triggerProp; +} +``` + +**Implementation in ical.js — relative DURATION trigger (timed events):** + +```typescript +// Source: ical.js jCal structure example — ["trigger", {"related":"START"}, "duration", "-PT5M"] +// Do NOT use addPropertyWithValue('trigger', '-PT15M') — may emit VALUE=TEXT (Pitfall 2) +function buildRelativeTrigger(leadMinutes: number): ICAL.Property { + const triggerProp = new ICAL.Property('trigger'); + // 'duration' value type — correct per RFC 5545 §3.8.6.3 + triggerProp.resetType('duration'); + const dur = ICAL.Duration.fromSeconds(-leadMinutes * 60); + triggerProp.setValue(dur); + return triggerProp; +} +``` + +The ical.js jCal structure confirms the correct encoding: `["trigger", {"related": "START"}, "duration", "-PT5M"]` — the value type is `"duration"`, not `"text"`. Using `resetType('duration')` before `setValue(ICAL.Duration)` guarantees this. [CITED: https://github.com/kewisch/ical.js/wiki/Migrating-from-Other-Libraries] + +**Summary for planner:** +- Timed events: relative DURATION trigger (`-PTNmM`) via `resetType('duration')` + `setValue(ICAL.Duration.fromSeconds(-N*60))`. +- All-day events: absolute DATE-TIME trigger (`VALUE=DATE-TIME:YYYYMMDDTHHMMSSZ`) via `resetType('date-time')` + `setValue(ICAL.Time.fromJSDate(alertInstantUtc, true))`. The alert instant = 9 AM local on (eventDate minus leadDays), converted to UTC. +- Scheduler computes the same alert instant independently — ICS interop is cosmetic. + +**TDD note:** This builder function has defined I/O and must be unit-tested: +- Input: `{allDay: false, leadMinutes: 15}` → output contains `TRIGGER:-PT15M` (no `VALUE=TEXT`) +- Input: `{allDay: true, leadMinutes: 1440, eventDate: '2026-06-15', timezone: 'America/New_York'}` → output TRIGGER is DATE-TIME `20260614T130000Z` (9 AM EDT = 13:00 UTC, 1 day before June 15) +- Input: `{allDay: true, leadMinutes: 0, eventDate: '2026-06-15', timezone: 'America/New_York'}` → TRIGGER is `20260615T130000Z` (9 AM EDT on the event date itself) + +--- + +### Q2: NULL-vs-0 distinction end-to-end through the schema pipeline (D-06) + +**Current state of the schemas (from direct file inspection):** + +`apps/api/src/routes/events.ts` — `eventFieldsSchema` (line 101): +```typescript +// Current — no reminderLeadMinutes field at all +const eventFieldsSchema = z.object({ + title: z.string().min(1).max(255), + allDay: z.boolean(), + start: z.string().min(1).max(64), + end: z.string().min(1).max(64), + location: z.string().max(2000).optional(), + description: z.string().max(2000).optional(), + recurrence: z.enum([...]).optional(), + // ... +}) +``` + +`apps/api/src/broker/outboxWorker.ts` — `outboxPayloadSchema` (line 73): +```typescript +// Current — no reminderLeadMinutes field at all +const outboxPayloadSchema = z.object({ + title: z.string().min(1).max(255), + allDay: z.boolean(), + // ... +}).passthrough(); +``` + +`apps/api/src/db/schema.ts` — column already exists (line 144): +```typescript +reminderLeadMinutes: int('reminder_lead_minutes'), // nullable → NULL means no reminder +``` + +**Required changes to carry NULL-vs-0 end-to-end:** + +**Step 1 — `eventFieldsSchema` in routes/events.ts:** + +```typescript +// Add to eventFieldsSchema: +reminderLeadMinutes: z.number().int().min(0).nullable().optional(), +``` + +Semantics: +- Field absent from JSON: `undefined` — means "no change" (edit mode, preserve existing VALARM) +- Field present as `null`: no reminder (clear any VALARM) +- Field present as `0`: same-day all-day (fire 9 AM on event date) +- Field present as positive integer: N-minute lead + +**Zod behavior through JSON serialization:** +- `z.nullable()`: accepts `null` in JSON — value is `null` in parsed output +- `z.optional()`: accepts the field being absent — value is `undefined` in parsed output +- `null` and `undefined` are distinct in Zod: `.nullable()` does NOT make the field optional; `.optional()` does NOT make the value nullable. Both modifiers are required. +- JSON.parse/stringify: `null` survives round-trip as `null`; `undefined` object properties are dropped by JSON.stringify (field absent on the wire). This is the correct behavior — an absent field on the edit payload means "don't touch the reminder." + +**Step 2 — `outboxPayloadSchema` in outboxWorker.ts:** + +Add the same field, plus a sentinel for the preserve-on-no-change case: + +```typescript +// Add to outboxPayloadSchema: +reminderLeadMinutes: z.number().int().min(0).nullable().optional(), +// Sentinel: absent = "user did not touch the reminder picker" → preserve existing VALARM +// null = explicit "None" (clear VALARM) +// 0 = same-day all-day +// positive integer = N-minute lead +``` + +The `.passthrough()` on `outboxPayloadSchema` means unrecognized fields survive, but explicitly declared fields are type-narrowed. The field must be declared so the planner gets TypeScript type safety in the worker. + +**Step 3 — The "no change" sentinel distinction:** + +D-08 requires distinguishing "user did not touch the picker" from "user explicitly selected None." The zod schema handles this correctly: the field is `.optional()` so its absence on the JSON payload is distinguishable from `null`. In the outbox worker update path: + +```typescript +const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes'); +// hasExplicitReminder=false → preserve rawVevent VALARMs (mirrors WR-01 hasExplicitRecurrence) +// hasExplicitReminder=true, fields.reminderLeadMinutes === null → clear all VALARMs +// hasExplicitReminder=true, fields.reminderLeadMinutes >= 0 → replace with new VALARM +``` + +This mirrors the `hasExplicitRecurrence` pattern already in the update branch (outboxWorker.ts line 425). + +**Step 4 — `buildVeventString` parameter extension:** + +Add `valarms?: ICAL.Component[]` parameter to `NewEventParams` in vevent.ts: + +```typescript +export interface NewEventParams { + // ...existing fields... + reminderLeadMinutes?: number | null; // for NEW VALARM construction (create / user-changed) + valarms?: ICAL.Component[]; // pre-parsed VALARMs from rawVevent (preserve-on-edit) + allDayAlertInstantUtc?: Date; // computed by worker for all-day absolute triggers +} +``` + +The worker resolves which path to take before calling buildVeventString: +- `hasExplicitReminder && reminderLeadMinutes !== null` → compute alert instant if allDay, pass as `allDayAlertInstantUtc`; pass `reminderLeadMinutes` directly for timed +- `hasExplicitReminder && reminderLeadMinutes === null` → pass neither (no VALARM emitted) +- `!hasExplicitReminder` → extract VALARMs from rawVevent, pass as `valarms` + +**Step 5 — DB upsert in sync.ts:** + +The `sync.ts` upsert (line 144) does NOT currently write `reminderLeadMinutes`. Phase 11 must add it: parse the VALARM from the incoming VCALENDAR (if any) to extract the lead minutes, and write it to the column so the scheduler can query it. This is the sync-write path that populates the scheduler's data source. + +**TDD note:** The NULL-vs-0 transform is a pure function — given a zod-parsed `reminderLeadMinutes` value and `allDay` flag, return a scheduler-ready lead. I/O examples: +- `(null, false)` → no push row +- `(0, true)` → schedule at 9 AM on event date (leadMinutes=0 + 9AM rule) +- `(0, false)` → no push (0 on a timed event = None, same as null) +- `(1440, true)` → schedule at 9 AM on the day before + +--- + +### Q3: Off-list / non-preset VALARM classification and preserve-on-edit (D-07/D-08, CAL-14) + +**How to classify VALARMs from rawVevent using ical.js:** + +```typescript +// Source: ical.js API — getFirstSubcomponent, getAllSubcomponents +// [CITED: https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar] +export type AlarmClassification = + | { kind: 'none' } // no VALARM sub-components + | { kind: 'preset'; leadMinutes: number } // single relative DURATION, on the preset list + | { kind: 'offlist'; leadMinutes: number } // single relative DURATION, NOT on preset list + | { kind: 'custom' }; // absolute DATE-TIME trigger OR multiple VALARMs + +export function classifyValarms(rawVevent: string): AlarmClassification { + let parsed: ReturnType; + try { + parsed = ICAL.parse(rawVevent); + } catch { + return { kind: 'none' }; + } + const comp = new ICAL.Component(parsed); + const vevent = comp.getFirstSubcomponent('vevent'); + if (!vevent) return { kind: 'none' }; + + const valarms = vevent.getAllSubcomponents('valarm'); + if (valarms.length === 0) return { kind: 'none' }; + if (valarms.length > 1) return { kind: 'custom' }; // multiple alarms → Custom (kept) + + const alarm = valarms[0]; + const triggerProp = alarm.getFirstProperty('trigger'); + if (!triggerProp) return { kind: 'none' }; + + // Check value type — 'duration' = relative DURATION; 'date-time' = absolute + const valueType = triggerProp.getParameter('value'); + // ical.js jCal encodes relative DURATION as value type 'duration'; + // absolute as 'date-time'. When VALUE param is absent, the default for TRIGGER is duration. + // [CITED: kewisch/ical.js/wiki/Migrating-from-Other-Libraries — jCal structure] + if (valueType === 'date-time') return { kind: 'custom' }; // absolute → Custom (kept) + + const dur = triggerProp.getFirstValue() as ICAL.Duration | null; + if (!dur || typeof dur.toSeconds !== 'function') return { kind: 'custom' }; + + const totalSeconds = Math.abs(dur.toSeconds()); // negative for "before", take absolute + const leadMinutes = Math.round(totalSeconds / 60); + + // Preset lists (timed and all-day combined) + const PRESET_MINUTES = new Set([5, 10, 15, 30, 60, 120, 1440, 2880, 0, 10080]); + if (PRESET_MINUTES.has(leadMinutes)) { + return { kind: 'preset', leadMinutes }; + } + return { kind: 'offlist', leadMinutes }; +} +``` + +**Note on `triggerProp.getParameter('value')`:** In ical.js, the jCal representation stores the value type as the third element of the property array. The `getParameter('value')` call returns the VALUE parameter if explicitly set (e.g., `VALUE=DATE-TIME`). For DURATION triggers, the value type is the default and `getParameter('value')` returns `undefined` (not `'duration'`). The check should be: + +```typescript +// value type check — ical.js getFirstValue() returns ICAL.Duration for duration-typed properties +// and ICAL.Time for date-time-typed properties +const firstValue = triggerProp.getFirstValue(); +if (firstValue instanceof ICAL.Time) return { kind: 'custom' }; // absolute DATE-TIME +// Otherwise ICAL.Duration — relative DURATION trigger +const dur = firstValue as ICAL.Duration; +``` + +**VALARM extraction for preserve-on-edit (mirrors WR-01 RRULE-preserve):** + +```typescript +// Extract all VALARM sub-components from rawVevent for re-attachment +export function extractValarms(rawVevent: string): ICAL.Component[] { + try { + const parsed = ICAL.parse(rawVevent); + const comp = new ICAL.Component(parsed); + const vevent = comp.getFirstSubcomponent('vevent'); + if (!vevent) return []; + return vevent.getAllSubcomponents('valarm'); + } catch { + return []; + } +} +``` + +These extracted `ICAL.Component` objects are passed to `buildVeventString` via the `valarms` parameter and re-attached to the new VEVENT using `vevent.addSubcomponent(alarm)`. They are NOT re-serialized and re-parsed — they are passed as live ical.js component objects to avoid any encoding issues. + +**The preserve-on-edit trigger in outboxWorker update path:** + +```typescript +// In dispatchRow, update branch — after the hasExplicitReminder check: +let valarmsToPreserve: ICAL.Component[] = []; +if (!hasExplicitReminder && freshEtagRows[0]?.rawVevent) { + valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent); +} +// Pass to buildVeventString: +const { icsString } = buildVeventString({ + // ...existing params... + valarms: valarmsToPreserve, // empty array = no VALARM; populated = preserve +}); +``` + +**TDD note:** The classifier is pure I/O — ideal for TDD: +- Input: ICS with `TRIGGER;VALUE=DATE-TIME:20260615T130000Z` → `{ kind: 'custom' }` +- Input: ICS with `BEGIN:VALARM\nTRIGGER:-PT45M\nEND:VALARM` → `{ kind: 'offlist', leadMinutes: 45 }` +- Input: ICS with `BEGIN:VALARM\nTRIGGER:-PT15M\nEND:VALARM` → `{ kind: 'preset', leadMinutes: 15 }` +- Input: ICS with two VALARM blocks → `{ kind: 'custom' }` +- Input: ICS with no VALARM → `{ kind: 'none' }` + +--- + +## Standard Stack + +No new packages. All tools are already in the project. + +| Library | Version | Purpose | Notes | +|---------|---------|---------|-------| +| ical.js | 2.2.1 | VALARM component build + parse | Use `ICAL.Component`, `ICAL.Property`, `ICAL.Duration`, `ICAL.Time` | +| zod | 3.24.x | Schema extension for reminderLeadMinutes | `.nullable().optional()` pattern | +| drizzle-orm | 0.45.2 | DB query for variable-lead scheduler scan | Existing mysql2 driver | + +**No new npm dependencies for this phase.** The UI-SPEC confirms: "Phase 11 adds no new npm dependencies on the frontend." + +--- + +## Package Legitimacy Audit + +No new packages — not applicable. + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +EventForm.tsx + [reminder , classifyValarms call on edit load +apps/pwa/src/api/ + client.ts # extend CreateEventPayload with reminderLeadMinutes +apps/api/tests/broker/ + vevent.test.ts # VALARM serialization + classifier TDD tests + reminderScheduler.test.ts # variable-lead, all-day 9AM, uid:dtstartMs dedup tests +``` + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| TRIGGER value type | `addPropertyWithValue('trigger', '-PT15M')` string | `resetType('duration')` + `setValue(ICAL.Duration.fromSeconds(...))` | String form may emit `VALUE=TEXT` (Pitfall 2 — Fastmail/Apple silently ignore) | +| VALARM re-attachment | Extract text between BEGIN:VALARM/END:VALARM and splice | `extractValarms()` → `vevent.addSubcomponent()` | Text splicing is fragile; ical.js line-folding handles re-serialization correctly | +| All-day alert time | Ad-hoc string math on date strings | Proper UTC Date arithmetic: parse YYYY-MM-DD, subtract lead days, add 9h local offset → UTC | DST offsets vary; hand-rolled date math will produce wrong UTC instants on DST boundaries | +| Scheduler interval | node-cron | `setInterval` only | node-cron 4.2.1 silently skips ticks in the long-lived process (burned-in lesson, CLAUDE.md memory) | +| DB migration | `drizzle-kit push` | `drizzle-kit generate` + `migrate` | push emits false destructive diff on populated MariaDB (burned-in lesson) | + +--- + +## Common Pitfalls + +### Pitfall 1 (RETAINED): VALARM Round-Trip Strips Existing Alarms on Edit +See PITFALLS.md Pitfall 1 for full detail. Prevention: `hasExplicitReminder` pattern + `extractValarms()` on update path. + +### Pitfall 2 (RETAINED): TRIGGER `VALUE=TEXT` Silently Breaks VALARM +See PITFALLS.md Pitfall 2. Prevention: `resetType('duration')` + `ICAL.Duration.fromSeconds()`. + +### Pitfall 3 (AMENDED): All-Day Event VALARM +PITFALLS.md Pitfall 3 said "hide the picker for all-day events." CONTEXT.md D-02 reverses this. The amended guard: `buildVeventString` MUST emit a VALARM for all-day events when `reminderLeadMinutes >= 0` and `allDay=true`, using an absolute DATE-TIME trigger computed at 9 AM local on the alert day. The scheduler's `WHERE allDay=false` clause MUST be removed/replaced with per-event lead-time logic that correctly handles all-day events. + +### Pitfall 4 (RETAINED): uid-only Dedup Key Breaks with Variable Leads +See PITFALLS.md Pitfall 4. Change dedup key to `uid:dtstartMs`. Also: the scheduler window query must change from a fixed `(now, now+16min]` to per-event lead-based logic. Two approaches: +- Option A (simpler): Query events where `alertTime IN (now, now+1min]` — compute alertTime in SQL: for timed events, `DTSTART - INTERVAL reminder_lead_minutes MINUTE`; for all-day, the 9 AM local instant. This requires MariaDB date arithmetic. +- Option B (current pattern): Keep the per-minute scan but check `dtstartUtc - reminder_lead_minutes MINUTES <= now + 60s AND dtstartUtc > now`. + +**Recommendation:** Option B — widest window is `max(reminder_lead_minutes)` + 1 min buffer. Or: run a minute-tick scan that computes the expected fire time per event and fires those within a 1-minute window. Keep the fixed 60s tick from `startReminderScheduler`. + +### Pitfall: All-Day 9 AM UTC Computation at DST Boundaries +Computing 9 AM local when the event date straddles a DST change requires using the correct offset for that specific date, not today's offset. Use `Intl.DateTimeFormat` or a date library to get the UTC offset for a specific date in a specific timezone. If no timezone is stored (the API currently stores no per-user timezone), default to the server's local timezone (`process.env.TZ` or `Intl.DateTimeFormat().resolvedOptions().timeZone`). [ASSUMED — no per-user timezone in the DB schema confirmed from schema.ts] + +### Pitfall: `0` Lead Minutes on a Timed Event +D-06 specifies: "For timed events, 0/absent still means None." The scheduler must guard: if `allDay=false` AND `reminder_lead_minutes=0`, treat as NULL (no push). Only when `allDay=true` does `reminder_lead_minutes=0` mean "Same day." + +### Pitfall: sync.ts Does Not Currently Write reminderLeadMinutes +`sync.ts` upsert (lines 144–170) does not include `reminderLeadMinutes` in the `values()` call. The scheduler reads `calendar_events.reminder_lead_minutes` as ground truth. Without the sync-write, events synced from Fastmail (with VALARMs set by native clients) will never have `reminderLeadMinutes` populated. Phase 11 MUST add VALARM parsing in `sync.ts` to extract lead minutes and write them on upsert. + +--- + +## Code Examples + +### Building a VALARM sub-component (timed event, relative DURATION trigger) + +```typescript +// Source: ical.js jCal structure [CITED: https://github.com/kewisch/ical.js/wiki/Migrating-from-Other-Libraries] +// jCal encodes: ["trigger", {"related": "START"}, "duration", "-PT15M"] +// Production code in vevent.ts: +function buildTimedValarm(leadMinutes: number): ICAL.Component { + const valarm = new ICAL.Component('valarm'); + valarm.addPropertyWithValue('action', 'DISPLAY'); + valarm.addPropertyWithValue('description', 'Reminder'); + + const triggerProp = new ICAL.Property('trigger'); + triggerProp.resetType('duration'); // ensures value type = 'duration', not 'text' + triggerProp.setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60)); + valarm.addProperty(triggerProp); + + return valarm; +} +// Then: vevent.addSubcomponent(buildTimedValarm(15)); +// Emits: BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM +``` + +### Building a VALARM for all-day events (absolute DATE-TIME trigger) + +```typescript +// alertInstantUtc = 9 AM local on (eventDate - leadDays) in UTC +function buildAllDayValarm(alertInstantUtc: Date): ICAL.Component { + const valarm = new ICAL.Component('valarm'); + valarm.addPropertyWithValue('action', 'DISPLAY'); + valarm.addPropertyWithValue('description', 'Reminder'); + + const triggerProp = new ICAL.Property('trigger'); + triggerProp.resetType('date-time'); // VALUE=DATE-TIME absolute trigger + triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); // useUTC=true → Z suffix + valarm.addProperty(triggerProp); + + return valarm; +} +``` + +### Computing alertInstantUtc for all-day 9 AM local + +```typescript +// No timezone library needed — use Date + Intl offset arithmetic +function computeAllDayAlertUtc(eventDateStr: string, leadMinutes: number, tz: string): Date { + // eventDateStr: 'YYYY-MM-DD', leadMinutes: 0|1440|2880|10080 + const leadDays = leadMinutes / 1440; // 0, 1, 2, or 7 + + // Parse the event date as midnight UTC, then subtract lead days + const [y, m, d] = eventDateStr.split('-').map(Number) as [number, number, number]; + const alertDate = new Date(Date.UTC(y, m - 1, d - leadDays)); + + // Get UTC offset for 9 AM on alertDate in the target timezone + // Intl.DateTimeFormat gives us the local time components + const alertDateStr = alertDate.toISOString().slice(0, 10); // 'YYYY-MM-DD' + // Build a candidate 9 AM local and find its UTC equivalent + // Strategy: try 9 AM, compute the offset, adjust + const candidate = new Date(`${alertDateStr}T09:00:00`); // interpreted as local by Date() + // Better: use a fixed UTC instant and shift by the known offset + // Parse offset via Intl: + const offsetMs = getUtcOffsetMs(alertDate, tz); + // 9 AM local = UTC midnight + 9h - offset + const alertMidnightUtc = Date.UTC( + alertDate.getUTCFullYear(), + alertDate.getUTCMonth(), + alertDate.getUTCDate(), + ); + return new Date(alertMidnightUtc + 9 * 3600 * 1000 - offsetMs); +} + +function getUtcOffsetMs(dateAtMidnightUtc: Date, tz: string): number { + // Intl trick: format a UTC date in the target timezone, measure offset + const parts = new Intl.DateTimeFormat('en', { + timeZone: tz, + hour: 'numeric', minute: 'numeric', second: 'numeric', + hour12: false, + timeZoneName: 'shortOffset', + }).formatToParts(dateAtMidnightUtc); + // Extract UTC offset from parts (or use a simpler approach) + // Simpler: compare UTC midnight against its local midnight representation + const localMidnightStr = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + }).format(dateAtMidnightUtc); // 'YYYY-MM-DD' + // ... this gets complex; the cleanest approach for this use case: + // offsetMs = (UTC time of "midnight local on alertDate") = alertMidnightUtc - localMidnightUtc + // Use a reference point: new Date(alertDateStr + 'T00:00:00') gives LOCAL midnight + // then .getTime() - alertDate.getTime() = offsetMs + // NOTE: This only works correctly in Node.js when TZ env is set to the server's timezone. + // Recommended: server runs with TZ=America/New_York (or whatever the household timezone is). + // If no per-user timezone, use server TZ. Document this as a known limitation. + return 0; // placeholder — implement via the above approach +} +``` + +**Planner note:** The alert UTC computation is the most DST-sensitive piece. Recommend implementing as a separate pure function `computeAlertInstantUtc(eventDateStr, leadDays, serverTimezone)` with TDD tests at DST boundaries (spring-forward, fall-back). [ASSUMED: server timezone matches household timezone — no per-user TZ in DB] + +### Humanized push body (D-09) + +```typescript +// Source: 11-UI-SPEC.md Copywriting Contract +// Replace reminderScheduler.ts line 157: body: `Starts in ${minutes} min` +function humanizeLeadMinutes(leadMinutes: number): string { + if (leadMinutes < 60) return `Starts in ${leadMinutes} min`; + if (leadMinutes < 120) return 'Starts in 1 hour'; + if (leadMinutes < 1440) return `Starts in ${Math.round(leadMinutes / 60)} hours`; + if (leadMinutes < 2880) return 'Starts in 1 day'; + return `Starts in ${Math.round(leadMinutes / 1440)} days`; +} +// TDD I/O: +// humanizeLeadMinutes(30) → 'Starts in 30 min' +// humanizeLeadMinutes(60) → 'Starts in 1 hour' +// humanizeLeadMinutes(90) → 'Starts in 2 hours' (Math.round(1.5)=2) +// humanizeLeadMinutes(1440) → 'Starts in 1 day' +// humanizeLeadMinutes(10080)→ 'Starts in 7 days' +``` + +**Note on 90-minute rounding:** `Math.round(90/60) = 2` — "Starts in 2 hours" for a 90-minute lead. This matches the UI-SPEC threshold `120–1439 minutes → N hours`. But 90 minutes is in the 60–119 bucket → "1 hour". Recalibrate: `leadMinutes >= 60 && leadMinutes < 120` → "1 hour" before the hours calculation. The function above handles this correctly via the ordering of branches. + +### Variable-lead scheduler SQL query (Drizzle ORM sketch) + +```typescript +// Replace the fixed windowEnd = now + 16min approach +// For each event: fire when (dtstartUtc - reminderLeadMinutes MINUTES) falls in (lastCheck, now] +// In a 1-minute tick, query events where the alert time is in the past minute +const alertWindowStart = new Date(now.getTime() - 60 * 1000); // 1 min ago (catch-up window) + +// Timed events: alert_time = dtstartUtc - reminder_lead_minutes MINUTES +// Using Drizzle raw SQL for the computed column: +import { sql } from 'drizzle-orm'; + +// Timed events: +// WHERE allDay=false +// AND reminder_lead_minutes IS NOT NULL +// AND reminder_lead_minutes > 0 (0 on timed = None per D-06) +// AND (dtstartUtc - INTERVAL reminder_lead_minutes MINUTE) > alertWindowStart +// AND (dtstartUtc - INTERVAL reminder_lead_minutes MINUTE) <= now +// AND dtstartUtc > now (exclude already-started) + +// All-day events: +// WHERE allDay=true +// AND reminder_lead_minutes IS NOT NULL +// AND dtstartDate IS NOT NULL +// AND the computed 9 AM UTC alert for (dtstartDate - reminder_lead_minutes/1440 DAYS) +// falls in (alertWindowStart, now] +// → This requires either pre-computing alertTime in a derived column +// or fetching all all-day events with non-null leads and filtering in JS + +// Recommended: separate queries for timed and all-day events (cleaner SQL) +``` + +**Planner note:** All-day 9 AM UTC computation in SQL requires timezone-aware date functions that are MariaDB version-dependent. Recommend fetching all-day events with non-null `reminder_lead_minutes` that have `dtstartDate` within the next `max(lead)` days, then computing the alert time in JavaScript and filtering. This avoids complex SQL timezone arithmetic. + +--- + +## Runtime State Inventory + +Not a rename/refactor phase — section omitted. + +--- + +## Environment Availability + +No new external tools required. The existing dev stack (MariaDB, Node.js 22, Vitest) covers all Phase 11 work. Skipping detailed audit. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest (vite-native, same config as frontend) | +| Config file | `apps/api/vitest.config.ts` | +| Quick run command | `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts tests/broker/reminderScheduler.test.ts` | +| Full suite command | `pnpm --filter @familysync/api exec vitest run` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CAL-13 | VALARM emitted with correct TRIGGER:-PTNmM (no VALUE=TEXT) | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) | +| CAL-13 | All-day VALARM emits absolute DATE-TIME trigger | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) | +| CAL-13 | allDay toggle swaps preset list; reset to None on toggle | browser | `playwright-cli` | ❌ Wave 0 | +| CAL-14 | Edit with no picker change: rawVevent VALARM preserved in PUT payload | unit | `vitest run tests/broker/outboxWorker.test.ts` | ✅ (extend) | +| CAL-14 | Off-list single relative VALARM → offlist classification | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) | +| CAL-14 | Absolute trigger → custom classification | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) | +| CAL-14 | Two VALARMs → custom classification | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) | +| NOTIF-04 | Scheduler fires at T-leadMinutes for a 30-min lead event | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) | +| NOTIF-04 | Humanized body: 30min→"Starts in 30 min", 60→"1 hour", 1440→"1 day", 10080→"7 days" | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) | +| NOTIF-05 | Event with null reminderLeadMinutes produces no push | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) | +| NOTIF-05 | Event with reminderLeadMinutes=0 and allDay=false produces no push | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) | +| NOTIF-06 | All-day event with 0-min lead fires at 9 AM local (UTC computation correct) | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ❌ Wave 0 | +| NOTIF-06 | Rescheduled event (new dtstart) fires again — uid:dtstartMs dedup | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) | +| NOTIF-06 | Same event fires exactly once across 3 consecutive ticks (uid:dtstartMs dedup) | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend — existing test covers uid-only; update to uid:dtstartMs) | + +### Sampling Rate +- **Per task commit:** `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts tests/broker/reminderScheduler.test.ts` +- **Per wave merge:** `pnpm --filter @familysync/api exec vitest run` (full API suite) +- **Phase gate:** Full API suite green + Playwright smoke (allDay toggle, reminder picker loads correctly in edit) before `/gsd-verify-work` + +### Wave 0 Gaps +- [ ] `tests/broker/vevent.test.ts` — extend with VALARM serialization tests (TRIGGER type, all-day absolute, preserve round-trip, classifier I/O) +- [ ] `tests/broker/reminderScheduler.test.ts` — extend with variable-lead, all-day 9AM UTC, uid:dtstartMs dedup, NULL-vs-0 semantics, humanized body +- [ ] `computeAlertInstantUtc` unit tests at DST boundaries (spring-forward and fall-back dates) +- [ ] Playwright smoke: open EventForm in create mode, toggle allDay, verify preset list swaps; open edit mode with existing event, verify picker loads correct value + +--- + +## Security Domain + +No new security surface. The reminder picker reads/writes `reminderLeadMinutes` (an integer) validated by Zod `z.number().int().min(0).nullable().optional()` — no injection vectors. Push payload body is a hardcoded humanized string, not user-controlled. Existing ASVS V5 input validation coverage is maintained. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Absolute DATE-TIME TRIGGER is honored by both Fastmail and Apple Calendar for all-day events | Q1 VALARM trigger encoding | Low — scheduler is ground truth; if clients ignore VALARM, only native-client interop is affected, not push delivery | +| A2 | Server timezone (process.env.TZ) matches household timezone for 9 AM alert computation | Q1/Code Examples | Medium — if TZ is UTC, all-day reminders fire at 9 AM UTC (which may not be 9 AM local). Mitigation: document that `TZ` env must be set in docker-compose | +| A3 | `triggerProp.getFirstValue()` returns `ICAL.Duration` for relative triggers and `ICAL.Time` for absolute triggers | Q3 classifier | Medium — could be wrong for edge-case ical.js versions; mitigated by TDD tests that round-trip a known ICS | +| A4 | `getParameter('value')` on a default-type DURATION trigger returns undefined (not 'duration') | Q3 classifier | Low — mitigated by the `instanceof ICAL.Time` check which is type-safe | +| A5 | No per-user timezone is stored in the DB — server TZ is used as the household timezone | Environment | Medium — verified from schema.ts (no timezone column on users or calendars); if household spans timezones, 9 AM will be wrong for one member | + +--- + +## Sources + +### Primary (HIGH confidence — direct code inspection) +- `apps/api/src/broker/vevent.ts` — existing buildVeventString; RRULE pattern for VALARM to mirror +- `apps/api/src/broker/outboxWorker.ts` — outboxPayloadSchema, hasExplicitRecurrence pattern for hasExplicitReminder to mirror +- `apps/api/src/broker/reminderScheduler.ts` — current fixed-window logic, dedup map, body string at line 157 +- `apps/api/src/db/schema.ts` — `reminderLeadMinutes: int('reminder_lead_minutes')` nullable column confirmed +- `apps/api/src/broker/sync.ts` — upsert does NOT write reminderLeadMinutes (confirmed gap) +- `apps/api/src/broker/expand.ts` — `CalendarOccurrence` does not include reminderLeadMinutes (must be added for edit-mode load) +- `apps/api/src/routes/events.ts` — `eventFieldsSchema` has no reminderLeadMinutes (confirmed gap) +- `apps/pwa/src/api/client.ts` — `CreateEventPayload` has no reminderLeadMinutes (confirmed gap) +- `apps/pwa/src/components/EventForm.tsx:852` — Recurrence picker pattern to mirror + +### Secondary (MEDIUM confidence — Context7 / official docs) +- [kewisch/ical.js — jCal structure](https://github.com/kewisch/ical.js/wiki/Migrating-from-Other-Libraries) — VALARM jCal encoding confirms `"duration"` value type for TRIGGER, `{"related": "START"}` parameter +- [kewisch/ical.js — Convert to iCalendar](https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545)) — addSubcomponent pattern confirmed +- [kewisch/ical.js — Parsing iCalendar](https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar) — getFirstSubcomponent, getAllSubcomponents API confirmed + +### Tertiary (LOW confidence — assumed) +- RFC 5545 §3.8.6.3 — VALARM TRIGGER value types (DURATION vs DATE-TIME) — training knowledge [ASSUMED] +- Client behavior (Fastmail/Apple Calendar) with absolute DATE-TIME triggers on all-day events — [ASSUMED] + +--- + +## Metadata + +**Confidence breakdown:** +- Schema/zod plumbing (Q2): HIGH — verified from direct file inspection, no ambiguity +- ical.js VALARM API (Q1/Q3): MEDIUM — confirmed via Context7 official docs; `resetType`/`getFirstValue` APIs confirmed from jCal structure examples +- All-day trigger client interop (Q1): LOW — untestable without live Fastmail + Apple Calendar +- Humanized body thresholds: HIGH — copied from UI-SPEC.md verbatim +- 9 AM UTC computation: MEDIUM — the algorithm is correct; DST edge cases require TDD at boundaries + +**Research date:** 2026-06-13 +**Valid until:** 2026-07-13 (stable domain — ical.js 2.x API is stable)