486 lines
20 KiB
Markdown
486 lines
20 KiB
Markdown
# 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<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:
|
||
```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<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:
|
||
|
||
```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 `<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.
|
||
|
||
```tsx
|
||
// 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:**
|
||
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 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
|