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 `` pattern used for Recurrence).
+- Exact humanized-unit thresholds/wording for D-09.
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Per-occurrence reminder override (RECURRENCE-ID).
+- Reminder snooze / notification-preferences UI.
+
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CAL-13 | User can choose a reminder lead time when creating or editing an event from a preset list (None/5m/10m/15m/30m/1h/2h/1d/2d), default None; serialized as VALARM on event written back to Fastmail. Extended by CONTEXT.md D-02: all-day gets day-granularity presets. | VALARM DURATION construction via ICAL.Duration.fromSeconds; outboxPayloadSchema extension for reminderLeadMinutes; all-day guard change in buildVeventString |
+| CAL-14 | Editing an event preserves any existing reminder/VALARM set in another client — reminders never silently stripped on round-trip. | VALARM extraction from rawVevent via ical.js; off-list classifier; preserve-on-no-change sentinel in outboxPayloadSchema |
+| NOTIF-04 | Event reminder push fires at event's chosen lead time, not hardcoded 15-min lead. | Scheduler variable-window query using reminderLeadMinutes from DB |
+| NOTIF-05 | Event with no reminder set produces no reminder push (no default 15-min fire). | NULL guard in scheduler query; drop isShared-only restriction; per-event reminderLeadMinutes IS NULL excludes from scan |
+| NOTIF-06 | All-day event reminder fires at 9 AM local on alert day; exactly-once across catch-up scans and rescheduled events. | 9 AM local computation; uid:dtstartMs dedup key |
+
+
+
+---
+
+## Summary
+
+This phase is a targeted delta on an already-working push reminder system. The dispatch pipeline (`dispatchPush`), the `reminder_lead_minutes` DB column, and the scheduler infrastructure are all live from Phase 10. The work is: (1) add a reminder `` 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 ]
+ allDay=false → timed presets
+ allDay=true → day presets (swapped)
+ edit mode: classify rawVevent VALARM →
+ none → select "None"
+ preset → select matching option
+ offlist → add synthetic option
+ custom → disabled "Custom (kept)"
+ |
+ | reminderLeadMinutes: number | null | undefined (absent=no-change)
+ v
+POST /api/events/create OR PATCH /api/events/:uid/edit
+ eventFieldsSchema (zod) validates:
+ reminderLeadMinutes: z.number().int().min(0).nullable().optional()
+ |
+ v
+calendar_outbox.payload (JSON)
+ { ...eventFields, reminderLeadMinutes: N | null | undefined }
+ |
+ v
+outboxWorker.ts (update branch)
+ hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')
+ if !hasExplicitReminder → extractValarms(rawVevent) → valarmsToPreserve
+ if hasExplicitReminder && null → no valarms
+ if hasExplicitReminder && N → buildAbsoluteTrigger (allDay) or buildRelativeTrigger (timed)
+ |
+ v
+buildVeventString(params: NewEventParams)
+ valarms: ICAL.Component[] → vevent.addSubcomponent(alarm) for each
+ reminderLeadMinutes + allDay → emit VALARM sub-component
+ |
+ v
+Fastmail CalDAV PUT (VCALENDAR with VALARM)
+ |
+ v (post-write re-sync)
+sync.ts upsert
+ parse VALARM from rawVevent → write reminderLeadMinutes to calendar_events
+ |
+ v
+reminderScheduler.ts (every 60s via setInterval)
+ SELECT events WHERE reminder_lead_minutes IS NOT NULL
+ AND reminder_lead_minutes > 0 (timed) OR (all_day=true AND reminder_lead_minutes >= 0)
+ AND alert_time IN (now, now+1min]
+ uid:dtstartMs dedup
+ dispatch push with humanized body
+```
+
+### Recommended Project Structure (unchanged — no new files beyond tests)
+
+```
+apps/api/src/broker/
+ vevent.ts # add buildVeventString VALARM support + classifyValarms + extractValarms
+ outboxWorker.ts # update branch: hasExplicitReminder pattern; pass valarms to buildVeventString
+ reminderScheduler.ts # variable-lead window, uid:dtstartMs dedup, all-day 9AM, humanized body
+ sync.ts # upsert reminderLeadMinutes from parsed VALARM
+apps/api/src/routes/
+ events.ts # extend eventFieldsSchema with reminderLeadMinutes
+apps/pwa/src/components/
+ EventForm.tsx # add 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)