# 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>
## 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 `<select>` 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.

</user_constraints>

<phase_requirements>
## 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 |

</phase_requirements>

---

## 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 `<select>` 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:<YYYYMMDDTHHMMSSZ>` 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<typeof ICAL.parse>;
  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 <select>]
  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 <select>, 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)
