Files
familysync/.planning/milestones/v1.1-phases/11-per-event-reminders/11-PATTERNS.md
T
2026-06-18 22:21:38 -04:00

20 KiB
Raw Blame History

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).

// 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):

// 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):

// 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:

// 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):

// 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):

// 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):

// 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):

// 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):

// 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:

// 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):

// 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):

// 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):

// 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):

// 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):

// 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):

// 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:

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.

// 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):

// 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 149153 (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 6479 (extractRruleString); sync.ts lines 89101 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 209215; outboxWorker.ts lines 897901 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 181187 (per-event), lines 167175 (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 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