docs(11): add pattern map for per-event reminders
This commit is contained in:
@@ -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<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
|
||||
Reference in New Issue
Block a user