# 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; 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(); // 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 ` 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, }} > ... {eventFormMode === 'edit' && (
...helper text...
)} ``` **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 ? : }`. 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 <> ) : ( // D-01 timed presets <> )} ``` **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