docs(11): add pattern map for per-event reminders

This commit is contained in:
Lucas Berger
2026-06-13 21:20:55 -04:00
parent cfeb8d8660
commit 6109d4ca49
@@ -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 848885) | 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 149153):
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 149153 (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 6379):
```typescript
// EXISTING — extractRruleString (lines 6379) — 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 2133):
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 425466):
```typescript
// EXISTING — lines 425426: template for hasExplicitReminder
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
// ...
// EXISTING — lines 464466: 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 483492):
```typescript
// EXISTING call (lines 483492) — 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 7395):
```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, 141188):
```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 141188) — 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 84107):
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 84107) — 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 193197):
```typescript
// EXISTING prune (lines 193197) — 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 144170):
```typescript
// EXISTING .values() call (lines 144157) — 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 123142):
```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 3769):
```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 101120):
```typescript
// EXISTING schema (lines 101120) — 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 112119) 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 175198):
```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 108134):
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 848885):
This is the exact structural template for the reminder picker. Copy the entire block structure.
```tsx
// EXISTING Recurrence picker (lines 848885) — 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 149153 (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 6479 (`extractRruleString`); sync.ts lines 89101
**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 209215; outboxWorker.ts lines 897901
**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 181187 (per-event), lines 167175 (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 1200), expand.ts (lines 180), events.ts (lines 1130), client.ts (lines 100215), EventForm.tsx (lines 840889), vevent.test.ts (lines 160), reminderScheduler.test.ts (lines 180)
**Pattern extraction date:** 2026-06-13