diff --git a/.planning/phases/11-per-event-reminders/11-PATTERNS.md b/.planning/phases/11-per-event-reminders/11-PATTERNS.md new file mode 100644 index 0000000..817e0d6 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-PATTERNS.md @@ -0,0 +1,485 @@ +# 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