# Phase 11: Per-Event Reminders - Pattern Map

**Mapped:** 2026-06-13
**Files analyzed:** 8 modified + 2 extended test files
**Analogs found:** 10 / 10 (all modify-existing — no greenfield files)

---

## File Classification

| Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `apps/api/src/broker/vevent.ts` | utility / builder | transform | self (extend `extractRruleString`) | exact |
| `apps/api/src/broker/outboxWorker.ts` | worker | event-driven | self (extend `hasExplicitRecurrence` + `resolveFinalRrule`) | exact |
| `apps/api/src/broker/reminderScheduler.ts` | scheduler | event-driven | self (extend `runReminderCheck`) | exact |
| `apps/api/src/broker/sync.ts` | service | CRUD | self (extend upsert at line 144) | exact |
| `apps/api/src/broker/expand.ts` | utility | transform | self (extend `CalendarOccurrence` interface) | exact |
| `apps/api/src/routes/events.ts` | route/controller | request-response | self (extend `eventFieldsSchema`) | exact |
| `apps/pwa/src/api/client.ts` | client utility | request-response | self (extend `CreateEventPayload`) | exact |
| `apps/pwa/src/components/EventForm.tsx` | component | request-response | self (Recurrence picker at lines 848–885) | exact |
| `apps/api/tests/broker/vevent.test.ts` | test | — | self (extend existing describe blocks) | exact |
| `apps/api/tests/broker/reminderScheduler.test.ts` | test | — | self (extend existing describe blocks) | exact |

---

## Pattern Assignments

### `apps/api/src/broker/vevent.ts` (utility/builder, transform)

**Changes:** (1) Add `reminderLeadMinutes`, `valarms`, `allDayAlertInstantUtc` to `NewEventParams`. (2) Add VALARM emission block after the RRULE block. (3) Add `classifyValarms()` and `extractValarms()` exported functions.

**Analog pattern — RRULE property construction** (vevent.ts lines 149–153):
The RRULE block is the template for VALARM emission. It uses `new ICAL.Property` + `resetType`/`setValue` to avoid string-serialization bugs — the same technique required for TRIGGER to avoid `VALUE=TEXT` (Pitfall 2).

```typescript
// RRULE analog — lines 149–153 (DO NOT COPY verbatim; adapt for VALARM)
if (params.rruleString) {
  const recur = ICAL.Recur.fromString(params.rruleString);
  const rruleProp = new ICAL.Property('rrule');
  rruleProp.setValue(recur);
  vevent.addProperty(rruleProp);
}
```

**Adapt for VALARM emission** (insert after line 153, before optional fields):
```typescript
// VALARM — preserve path (edit with no picker change)
if (params.valarms && params.valarms.length > 0) {
  for (const alarm of params.valarms) {
    vevent.addSubcomponent(alarm);
  }
}
// VALARM — new alarm path (create, or user changed picker)
else if (params.reminderLeadMinutes != null) {
  if (params.allDay && params.allDayAlertInstantUtc) {
    // Absolute DATE-TIME trigger for all-day events
    const valarm = new ICAL.Component('valarm');
    valarm.addPropertyWithValue('action', 'DISPLAY');
    valarm.addPropertyWithValue('description', 'Reminder');
    const triggerProp = new ICAL.Property('trigger');
    triggerProp.resetType('date-time');
    triggerProp.setValue(ICAL.Time.fromJSDate(params.allDayAlertInstantUtc, true));
    valarm.addProperty(triggerProp);
    vevent.addSubcomponent(valarm);
  } else if (!params.allDay && params.reminderLeadMinutes > 0) {
    // Relative DURATION trigger for timed events
    const valarm = new ICAL.Component('valarm');
    valarm.addPropertyWithValue('action', 'DISPLAY');
    valarm.addPropertyWithValue('description', 'Reminder');
    const triggerProp = new ICAL.Property('trigger');
    triggerProp.resetType('duration'); // critical: prevents VALUE=TEXT (Pitfall 2)
    triggerProp.setValue(ICAL.Duration.fromSeconds(-params.reminderLeadMinutes * 60));
    valarm.addProperty(triggerProp);
    vevent.addSubcomponent(valarm);
  }
  // allDay=false && reminderLeadMinutes===0: no VALARM (timed 0 = None per D-06)
}
```

**`extractRruleString` as pattern for `extractValarms`** (vevent.ts lines 63–79):
```typescript
// EXISTING — extractRruleString (lines 63–79) — copy structure for extractValarms
export function extractRruleString(rawVevent: string): string | undefined {
  let parsed: ReturnType<typeof ICAL.parse>;
  try {
    parsed = ICAL.parse(rawVevent);
  } catch {
    return undefined;
  }
  const comp = new ICAL.Component(parsed);
  const vevent = comp.getFirstSubcomponent('vevent');
  if (!vevent) return undefined;
  const rrule = vevent.getFirstPropertyValue('rrule');
  if (!rrule) return undefined;
  return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString();
}
```

**`NewEventParams` interface extension** (vevent.ts lines 21–33):
Add three optional fields to the existing interface:
```typescript
// ADD to NewEventParams (after line 32, before closing brace):
reminderLeadMinutes?: number | null; // null = no VALARM; 0 = same-day all-day; positive = timed lead
valarms?: ICAL.Component[];           // pre-parsed VALARMs from rawVevent (preserve-on-edit, D-08)
allDayAlertInstantUtc?: Date;         // 9 AM local on alert day in UTC (computed by worker for all-day)
```

---

### `apps/api/src/broker/outboxWorker.ts` (worker, event-driven)

**Changes:** (1) Add `reminderLeadMinutes` to `outboxPayloadSchema`. (2) In update branch: add `hasExplicitReminder` check mirroring `hasExplicitRecurrence`. (3) Pass `valarms`/`reminderLeadMinutes`/`allDayAlertInstantUtc` to `buildVeventString`. (4) Same additions in create branch.

**Key analog — `hasExplicitRecurrence` pattern** (outboxWorker.ts lines 425–466):
```typescript
// EXISTING — lines 425–426: template for hasExplicitReminder
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
// ...
// EXISTING — lines 464–466: template for VALARM preserve-on-no-change
if (!hasExplicitRecurrence && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
  preservedRrule = extractRruleString(freshEtagRows[0].rawVevent);
}
```

**Adapt for VALARM** (insert adjacent to the RRULE preserve block in the update branch):
```typescript
// ADD alongside the hasExplicitRecurrence block:
const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes');
let valarmsToPreserve: ICAL.Component[] = [];
let allDayAlertInstantUtc: Date | undefined;

if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
  // D-08: user did not touch the picker — preserve existing VALARM verbatim
  valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent);
} else if (hasExplicitReminder && fields.reminderLeadMinutes != null && fields.allDay) {
  // All-day: compute 9 AM local UTC instant for the absolute DATE-TIME trigger
  allDayAlertInstantUtc = computeAlertInstantUtc(
    fields.start, // 'YYYY-MM-DD'
    fields.reminderLeadMinutes / 1440, // leadDays
    process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
  );
}
```

**`buildVeventString` call extension** (outboxWorker.ts lines 483–492):
```typescript
// EXISTING call (lines 483–492) — add three new params:
const { icsString } = buildVeventString({
  uid: row.uid,
  summary: fields.title,
  allDay: fields.allDay,
  dtstart: fields.allDay ? fields.start : new Date(fields.start),
  dtend: fields.allDay ? fields.end : new Date(fields.end),
  location: fields.location,
  description: fields.description,
  rruleString: finalRruleString,
  // ADD:
  reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined,
  valarms: valarmsToPreserve,
  allDayAlertInstantUtc,
});
```

**`outboxPayloadSchema` extension** (outboxWorker.ts lines 73–95):
```typescript
// EXISTING schema — add reminderLeadMinutes after recurrenceCount (line 93):
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
// Semantics: absent=no-change (D-08), null=clear VALARM, 0=same-day all-day, positive=timed lead
```

---

### `apps/api/src/broker/reminderScheduler.ts` (scheduler, event-driven)

**Changes:** (1) Change dedup key from bare `uid` to `uid:${dtstartMs}`. (2) Replace fixed `(now, now+16min]` window with per-event variable-lead query. (3) Drop `isShared=true` restriction. (4) Add all-day 9 AM UTC branch. (5) Replace hardcoded body string with `humanizeLeadMinutes()`.

**Key analog — existing dedup map and dispatch loop** (reminderScheduler.ts lines 48, 141–188):
```typescript
// EXISTING dedup map (line 48) — change value type comment; key changes to uid:dtstartMs
const sentReminders = new Map<string, number>(); // key: uid:dtstartMs

// EXISTING dispatch loop pattern (lines 141–188) — preserve structure, change:
// 1. dedup key: uid → `${uid}:${event.dtstartUtc.getTime()}`
// 2. body: `Starts in ${minutes} min` → humanizeLeadMinutes(event.reminderLeadMinutes)
// 3. post-dispatch sentReminders.set now uses compound key

// EXISTING body string (line 157) — REPLACE:
body: `Starts in ${minutes} min`,
// WITH:
body: humanizeLeadMinutes(event.reminderLeadMinutes),
```

**SQL query changes** (reminderScheduler.ts lines 84–107):
The existing query is the structural template. Remove `eq(calendars.isShared, true)` and `eq(calendarEvents.allDay, false)`. Add `reminder_lead_minutes IS NOT NULL`. Split into two sub-queries (timed and all-day) or use a single query fetching all events with non-null leads and filter in JS:

```typescript
// EXISTING query structure (lines 84–107) — adapt WHERE clause:
// REMOVE: eq(calendars.isShared, true)  — NOTIF-05: all events, not just shared
// REMOVE: eq(calendarEvents.allDay, false)  — all-day events now supported (D-02)
// ADD:    sql`${calendarEvents.reminderLeadMinutes} IS NOT NULL`
// ADD:    reminderLeadMinutes to .select()

// For timed events: keep dtstartUtc window logic (variable per-event lead)
// For all-day events: fetch with non-null leads, compute 9 AM UTC in JS, filter in-memory
```

**Existing prune pattern** (reminderScheduler.ts lines 193–197):
```typescript
// EXISTING prune (lines 193–197) — key name changes to uid:dtstartMs but structure identical
for (const [key, dtstartMs] of sentReminders) {
  if (dtstartMs <= now.getTime()) {
    sentReminders.delete(key);
  }
}
```

---

### `apps/api/src/broker/sync.ts` (service, CRUD)

**Changes:** Add VALARM parsing in the per-object loop to extract `reminderLeadMinutes`, then write it in the `onDuplicateKeyUpdate` call.

**Key analog — existing upsert values block** (sync.ts lines 144–170):
```typescript
// EXISTING .values() call (lines 144–157) — add reminderLeadMinutes:
await db
  .insert(calendarEvents)
  .values({
    calendarId: cal.id,
    uid,
    etag: obj.etag ?? null,
    objectUrl: obj.url ?? null,
    rawVevent: obj.data as string,
    title: titleValue,
    dtstartUtc: dtstartUtcValue,
    dtstartDate: dtstartDateValue,
    allDay,
    hasRrule: isRecurring,
    // ADD:
    reminderLeadMinutes: reminderLeadMinutesValue, // null when no VALARM
  })
  .onDuplicateKeyUpdate({
    set: {
      // ... existing fields ...
      // ADD:
      reminderLeadMinutes: reminderLeadMinutesValue,
      updatedAt: new Date(),
    },
  });
```

**Extraction pattern** (mirrors `extractRruleString` from vevent.ts — use `classifyValarms`):
Insert before the `await db.insert(...)` call, alongside the existing field extractions (lines 123–142):
```typescript
// ADD after titleValue / locationValue extraction:
// Extract reminderLeadMinutes from VALARM (if any) — written to DB for scheduler ground truth
const alarmClass = classifyValarms(obj.data as string);
const reminderLeadMinutesValue: number | null =
  alarmClass.kind === 'preset' || alarmClass.kind === 'offlist'
    ? alarmClass.leadMinutes
    : null; // 'custom' (absolute/multi) and 'none' both map to null
```

---

### `apps/api/src/broker/expand.ts` (utility, transform)

**Change:** Add `reminderLeadMinutes` field to `CalendarOccurrence` interface so edit mode can pre-populate the picker.

**Key analog — existing `CalendarOccurrence` interface** (expand.ts lines 37–69):
```typescript
// EXISTING interface — add after hasRrule (line 68):
/**
 * Per-event reminder lead in minutes. NULL = no reminder. 0 = same-day all-day.
 * Positive integer = N minutes before event start.
 * D-06: NULL and 0 are semantically distinct.
 */
reminderLeadMinutes: number | null;
```

The corresponding DB select in the events route must also include `reminderLeadMinutes` in the join result passed to `expandOccurrences`. The `OccurrenceMeta` interface (expand.ts line 75+) may also need `reminderLeadMinutes` depending on how expansion propagates it.

---

### `apps/api/src/routes/events.ts` (route, request-response)

**Change:** Add `reminderLeadMinutes` to `eventFieldsSchema`.

**Key analog — existing `eventFieldsSchema`** (events.ts lines 101–120):
```typescript
// EXISTING schema (lines 101–120) — add after recurrenceCount (line 119):
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
// absent = no-change (edit, D-08); null = clear VALARM; 0 = same-day all-day; positive = timed lead
```

The schema comment pattern (lines 112–119) documents the security rationale inline — follow the same style for the new field.

---

### `apps/pwa/src/api/client.ts` (client utility, request-response)

**Change:** Add `reminderLeadMinutes` to `CreateEventPayload` and to `CalendarOccurrence`.

**Key analog — `CreateEventPayload` interface** (client.ts lines 175–198):
```typescript
// EXISTING interface — add after description (line 196):
/**
 * Per-event reminder lead in minutes.
 * absent/undefined = no-change (edit omits it so server preserves existing VALARM, D-08)
 * null = explicit "None" (clear any VALARM)
 * 0 = same-day all-day (fire 9 AM on event date, D-05)
 * positive integer = N minutes before event start
 */
reminderLeadMinutes?: number | null;
```

**`CalendarOccurrence` extension** (client.ts lines 108–134):
Add after `hasRrule` (line 133), mirroring the server-side expand.ts change:
```typescript
reminderLeadMinutes: number | null; // mirrors expand.ts CalendarOccurrence (atomic mirror, Plan 06-05)
```

---

### `apps/pwa/src/components/EventForm.tsx` (component, request-response)

**Change:** Add reminder `<select>` after the Recurrence picker block (line 885), with allDay-aware option swap and edit-mode pre-population via `classifyValarms`.

**Key analog — Recurrence picker block** (EventForm.tsx lines 848–885):
This is the exact structural template for the reminder picker. Copy the entire block structure.

```tsx
// EXISTING Recurrence picker (lines 848–885) — reminder picker follows IDENTICAL structure:
<div style={fieldStyle}>
  <label htmlFor="event-recurrence" style={labelStyle}>
    Repeat
  </label>
  <select
    id="event-recurrence"
    value={recurrence}
    disabled={eventFormMode === 'edit'}
    onChange={(e) => setRecurrence(e.target.value as RecurrencePreset)}
    style={{
      ...inputStyle,
      padding: '0 var(--space-3)',
      cursor: eventFormMode === 'edit' ? 'not-allowed' : 'pointer',
      opacity: eventFormMode === 'edit' ? 0.6 : 1,
    }}
  >
    <option value="none">None</option>
    ...
  </select>
  {eventFormMode === 'edit' && (
    <div style={{ fontSize: 'var(--text-label-size)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-1)' }}>
      ...helper text...
    </div>
  )}
</div>
```

**Reminder picker differences from Recurrence picker:**
1. `id="event-reminder"`, label text `"Reminder"` (UI-SPEC).
2. NOT disabled on edit — reminder IS editable.
3. Options are allDay-conditional: `{allDay ? <allDayOptions/> : <timedOptions/>}`.
4. Off-list / Custom (kept) synthetic options appended dynamically.
5. `onChange` resets synthetic option once a preset is chosen.
6. Helper text shown when `alarmClass.kind === 'custom'` regardless of mode.

**allDay-conditional option swap pattern** (mirrors existing `{!allDay && (...)}` conditionals already in EventForm.tsx for time fields):
```tsx
// Use the same allDay state variable already present in EventForm.tsx:
{allDay ? (
  // D-02 all-day presets
  <>
    <option value="__none__">None</option>
    <option value="0">Same day (9 AM)</option>
    <option value="1440">1 day before (9 AM)</option>
    <option value="2880">2 days before (9 AM)</option>
    <option value="10080">1 week before (9 AM)</option>
  </>
) : (
  // D-01 timed presets
  <>
    <option value="__none__">None</option>
    <option value="5">5 minutes before</option>
    <option value="10">10 minutes before</option>
    <option value="15">15 minutes before</option>
    <option value="30">30 minutes before</option>
    <option value="60">1 hour before</option>
    <option value="120">2 hours before</option>
    <option value="1440">1 day before</option>
    <option value="2880">2 days before</option>
  </>
)}
```

**allDay toggle reset pattern** (D-03 — reset picker to None when allDay changes):
```tsx
// In the existing allDay onChange handler, also reset reminderLeadMinutes state:
setReminderValue('__none__'); // reset to None on allDay toggle
```

---

## Shared Patterns

### ICAL.Property + resetType + setValue (Pitfall 2 prevention)
**Source:** vevent.ts lines 149–153 (RRULE) — same technique for TRIGGER
**Apply to:** `vevent.ts` VALARM emission, `vevent.ts` `buildRelativeTrigger`/`buildAbsoluteTrigger`
```typescript
// CORRECT — avoids VALUE=TEXT:
const prop = new ICAL.Property('trigger');
prop.resetType('duration');
prop.setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60));
// WRONG — may emit VALUE=TEXT:
// vevent.addPropertyWithValue('trigger', '-PT15M');
```

### `hasExplicitX` sentinel (absent-vs-null distinction, D-06/D-08)
**Source:** outboxWorker.ts line 425
**Apply to:** outboxWorker.ts `hasExplicitReminder` in both update and create branches
```typescript
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
// Copy: const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes');
```

### ICAL.parse try/catch + getFirstSubcomponent (sync.ts / vevent.ts)
**Source:** vevent.ts lines 64–79 (`extractRruleString`); sync.ts lines 89–101
**Apply to:** `extractValarms()` and `classifyValarms()` in vevent.ts
```typescript
try {
  parsed = ICAL.parse(rawVevent);
} catch {
  return /* safe default */;
}
const comp = new ICAL.Component(parsed);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return /* safe default */;
```

### setInterval scheduling (no node-cron)
**Source:** reminderScheduler.ts lines 209–215; outboxWorker.ts lines 897–901
**Apply to:** `startReminderScheduler` (no change needed — already setInterval)
```typescript
// node-cron 4.2.1 silently skips ticks in the long-lived process — do not reintroduce
setInterval(() => {
  runReminderCheck().catch((err: unknown) => { ... });
}, 60 * 1000);
```

### Zod `.nullable().optional()` for NULL-vs-absent distinction
**Source:** RESEARCH.md Q2 (no existing analog — first use of this pattern in the codebase)
**Apply to:** `eventFieldsSchema` (events.ts), `outboxPayloadSchema` (outboxWorker.ts), `CreateEventPayload` (client.ts)
```typescript
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
// absent (undefined) = no-change; null = clear; 0 = same-day; positive = lead
```

### Per-item try/catch error isolation
**Source:** reminderScheduler.ts lines 181–187 (per-event), lines 167–175 (per-sub)
**Apply to:** Preserve in modified `runReminderCheck` — both per-event and per-sub catches remain
```typescript
// Per-event:
} catch (err) {
  console.error(`[broker/reminderScheduler] Error processing event uid=${uid}:`, ...);
}
// Per-sub:
} catch (err) {
  console.error(`[broker/reminderScheduler] Error dispatching reminder to sub id=${sub.id}:`, ...);
}
```

---

## No Analog Found

No files in this phase lack an analog. All changes are targeted modifications of existing files. The `classifyValarms`, `extractValarms`, `computeAlertInstantUtc`, and `humanizeLeadMinutes` functions are new but live inside existing files; their patterns are documented in RESEARCH.md Code Examples and the RRULE-analog sections above.

---

## Metadata

**Analog search scope:** `apps/api/src/broker/`, `apps/api/src/routes/`, `apps/api/src/db/`, `apps/pwa/src/components/`, `apps/pwa/src/api/`, `apps/api/tests/broker/`
**Files read:** vevent.ts, outboxWorker.ts, reminderScheduler.ts, sync.ts (lines 1–200), expand.ts (lines 1–80), events.ts (lines 1–130), client.ts (lines 100–215), EventForm.tsx (lines 840–889), vevent.test.ts (lines 1–60), reminderScheduler.test.ts (lines 1–80)
**Pattern extraction date:** 2026-06-13
