20 KiB
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).
// 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):
// 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):
// 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:
// 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):
// 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):
// 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):
// 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):
// 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):
// 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:
// 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):
// 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):
// 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):
// 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):
// 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):
// 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):
// 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:
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.
// 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:
id="event-reminder", label text"Reminder"(UI-SPEC).- NOT disabled on edit — reminder IS editable.
- Options are allDay-conditional:
{allDay ? <allDayOptions/> : <timedOptions/>}. - Off-list / Custom (kept) synthetic options appended dynamically.
onChangeresets synthetic option once a preset is chosen.- 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):
// 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):
// 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
// 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
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
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)
// 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)
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
// 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