Files
familysync/.planning/phases/11-per-event-reminders/11-RESEARCH.md
T

770 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 11: Per-Event Reminders - Research
**Researched:** 2026-06-13
**Domain:** CalDAV VALARM serialization, zod schema NULL-vs-0, scheduler variable-lead, ical.js VALARM classification
**Confidence:** HIGH — all findings derived from direct source inspection of the shipped codebase + ical.js official docs
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Timed events keep preset list: None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None.
- **D-02:** All-day events get context-swapped day-granularity presets: None / Same day / 1 day before / 2 days before / 1 week before, default None.
- **D-03:** Picker shown for both event types — swaps option set based on All-day toggle, does NOT disappear.
- **D-04:** All-day reminders fire at 9 AM local on the computed alert day.
- **D-05:** All-day leads stored as minutes: Same day = 0, 1d = 1440, 2d = 2880, 1wk = 10080.
- **D-06:** NULL = no reminder (no VALARM, no push). Same-day all-day = 0 (fire 9 AM on event date). 0 on a timed event = None.
- **D-07:** Show exact value if single simple relative alarm not in preset list (off-list synthetic option). Absolute-time or multiple alarms → read-only "Custom (kept)" entry.
- **D-08:** Original VALARM(s) preserved verbatim on save unless user explicitly selects a preset or None. Mirrors WR-01 RRULE-preserve pattern.
- **D-09:** Humanized relative push body: "Starts in 2 days" / "Starts in 1 hour" / "Starts in 30 min". Replaces hardcoded `Starts in ${minutes} min` in reminderScheduler.ts:157.
- **D-10:** Series-level only. One VALARM on master event; no per-occurrence (RECURRENCE-ID) override.
- **ROADMAP Pitfall 3 AMENDED:** The `buildVeventString` allDay guard (`if (!allDay && reminderMinutes > 0)`) is changed to ALSO emit a VALARM for all-day events with a day-based lead.
- **CAL-13 extended:** Preset list extended for all-day case per D-02; timed presets unchanged.
- **NOTIF-06 retained:** 9 AM all-day fire governs all-day day-leads, not a hidden/disabled selector.
### Claude's Discretion
- Picker placement within EventForm.tsx (reuse existing labeled `<select>` pattern used for Recurrence).
- Exact humanized-unit thresholds/wording for D-09.
### Deferred Ideas (OUT OF SCOPE)
- Per-occurrence reminder override (RECURRENCE-ID).
- Reminder snooze / notification-preferences UI.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| CAL-13 | User can choose a reminder lead time when creating or editing an event from a preset list (None/5m/10m/15m/30m/1h/2h/1d/2d), default None; serialized as VALARM on event written back to Fastmail. Extended by CONTEXT.md D-02: all-day gets day-granularity presets. | VALARM DURATION construction via ICAL.Duration.fromSeconds; outboxPayloadSchema extension for reminderLeadMinutes; all-day guard change in buildVeventString |
| CAL-14 | Editing an event preserves any existing reminder/VALARM set in another client — reminders never silently stripped on round-trip. | VALARM extraction from rawVevent via ical.js; off-list classifier; preserve-on-no-change sentinel in outboxPayloadSchema |
| NOTIF-04 | Event reminder push fires at event's chosen lead time, not hardcoded 15-min lead. | Scheduler variable-window query using reminderLeadMinutes from DB |
| NOTIF-05 | Event with no reminder set produces no reminder push (no default 15-min fire). | NULL guard in scheduler query; drop isShared-only restriction; per-event reminderLeadMinutes IS NULL excludes from scan |
| NOTIF-06 | All-day event reminder fires at 9 AM local on alert day; exactly-once across catch-up scans and rescheduled events. | 9 AM local computation; uid:dtstartMs dedup key |
</phase_requirements>
---
## Summary
This phase is a targeted delta on an already-working push reminder system. The dispatch pipeline (`dispatchPush`), the `reminder_lead_minutes` DB column, and the scheduler infrastructure are all live from Phase 10. The work is: (1) add a reminder `<select>` to EventForm.tsx, (2) plumb `reminderLeadMinutes` through the outbox schema into `buildVeventString` as a VALARM, (3) generalize the scheduler to per-event variable lead + all-day 9 AM rule + uid:dtstartMs dedup, and (4) write the VALARM-preserve-on-edit path mirroring WR-01. Three open technical questions from CONTEXT.md are answered concretely below.
**Primary recommendation:** Treat this as five discrete, independently testable units — VALARM builder, VALARM classifier, schema NULL-vs-0 plumbing, scheduler lead/9AM logic, and humanized-body formatter — each with TDD I/O examples, then wire them together in the form and worker.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Reminder picker UI (preset select, allDay swap) | Frontend / React PWA | — | Pure form state; no server round-trip until save |
| Off-list alarm classifier (detect single/relative vs absolute/multi) | API / Backend (parse rawVevent) | — | rawVevent lives server-side; classification needed during GET response to populate picker |
| VALARM serialization (buildVeventString) | API / Backend (broker) | — | ICS construction happens in outbox worker |
| VALARM preserve-on-edit | API / Backend (outboxWorker update path) | — | Reads rawVevent from DB at drain time |
| reminderLeadMinutes plumbing (form → outbox → DB) | API / Backend (route + schema) | Frontend (payload shape) | Schema is already in DB; route schema needs the new field |
| Scheduler variable-lead window | API / Backend (reminderScheduler) | — | setInterval-only; SQL WHERE uses reminderLeadMinutes |
| All-day 9 AM local fire | API / Backend (reminderScheduler) | — | Server computes alert time from dtstartDate + local timezone |
| Humanized push body | API / Backend (reminderScheduler) | — | Replaces hardcoded string at line 157 |
---
## Open Question Answers (Concrete, Code-Grounded)
### Q1: VALARM TRIGGER encoding for all-day "N days before at 9 AM" — cross-client interop
**Finding:** The scheduler is ground truth for the actual push fire time (9 AM local). The VALARM in the ICS is best-effort cross-client interop. Use a **relative DURATION trigger** for all-day events with an offset that approximates 9 AM.
**RFC 5545 background:** `TRIGGER` on a VALARM has two legal value types:
- `DURATION` (default, no VALUE param): evaluated relative to DTSTART (which for all-day events is a DATE with no time component — effectively midnight).
- `DATE-TIME` (with `VALUE=DATE-TIME`): an absolute UTC instant.
For an all-day event, `TRIGGER:-PT15M` means "15 minutes before midnight of the event date." Apple Calendar interprets this in local time. Fastmail's web client may ignore VALARM on all-day events entirely in some configurations. [CITED: RFC 5545 §3.8.6.3]
**Best-effort recommendation — use `-PT15H` (or the matching offset for the chosen day-lead):**
For "Same day" (lead=0): the alert date is the event date itself. To fire at 9 AM, the TRIGGER offset from midnight-of-DTSTART is `+9h`. RFC 5545 requires TRIGGER on VALARM to be negative or zero (alarm fires AT or BEFORE the event). A positive offset is technically non-conforming. **Practical approach:** Use `-PT15H` on the DAY BEFORE, i.e., treat "Same day at 9 AM" as 9 AM on the event date = DTSTART + 9h. Since this is a positive offset, and RFC compliance is fraught, use a DURATION from the previous midnight:
```
Same day (0 min lead): TRIGGER:-P0DT15H → this is non-standard positive (9h after midnight)
Better encoding: Treat as "fire 9h into the event day" — use absolute DATE-TIME trigger
```
**Recommended encoding: Absolute DATE-TIME TRIGGER for all-day events.** [ASSUMED — based on RFC 5545 + client behavior knowledge]
Build the trigger as `TRIGGER;VALUE=DATE-TIME:<YYYYMMDDTHHMMSSZ>` where the UTC instant corresponds to 9 AM local on the alert day. This is the only encoding that is unambiguous across Fastmail and Apple Calendar:
- Fastmail: honors absolute DATE-TIME TRIGGER on all-day events [ASSUMED — not verifiable without live Fastmail testing]
- Apple Calendar: honors absolute DATE-TIME TRIGGER [ASSUMED — per developer reports]
- The SCHEDULER is ground truth regardless — ICS interop is best-effort
**Implementation in ical.js — absolute DATE-TIME trigger:**
```typescript
// Source: ical.js jCal structure (Context7/kewisch/ical.js wiki)
// For all-day events: compute alertInstantUtc = 9 AM local on (eventDate - leadDays)
function buildAbsoluteTrigger(alertInstantUtc: Date): ICAL.Property {
const triggerProp = new ICAL.Property('trigger');
// VALUE=DATE-TIME: ical.js uses 'date-time' value type
triggerProp.resetType('date-time');
triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); // useUTC=true → Z suffix
return triggerProp;
}
```
**Implementation in ical.js — relative DURATION trigger (timed events):**
```typescript
// Source: ical.js jCal structure example — ["trigger", {"related":"START"}, "duration", "-PT5M"]
// Do NOT use addPropertyWithValue('trigger', '-PT15M') — may emit VALUE=TEXT (Pitfall 2)
function buildRelativeTrigger(leadMinutes: number): ICAL.Property {
const triggerProp = new ICAL.Property('trigger');
// 'duration' value type — correct per RFC 5545 §3.8.6.3
triggerProp.resetType('duration');
const dur = ICAL.Duration.fromSeconds(-leadMinutes * 60);
triggerProp.setValue(dur);
return triggerProp;
}
```
The ical.js jCal structure confirms the correct encoding: `["trigger", {"related": "START"}, "duration", "-PT5M"]` — the value type is `"duration"`, not `"text"`. Using `resetType('duration')` before `setValue(ICAL.Duration)` guarantees this. [CITED: https://github.com/kewisch/ical.js/wiki/Migrating-from-Other-Libraries]
**Summary for planner:**
- Timed events: relative DURATION trigger (`-PTNmM`) via `resetType('duration')` + `setValue(ICAL.Duration.fromSeconds(-N*60))`.
- All-day events: absolute DATE-TIME trigger (`VALUE=DATE-TIME:YYYYMMDDTHHMMSSZ`) via `resetType('date-time')` + `setValue(ICAL.Time.fromJSDate(alertInstantUtc, true))`. The alert instant = 9 AM local on (eventDate minus leadDays), converted to UTC.
- Scheduler computes the same alert instant independently — ICS interop is cosmetic.
**TDD note:** This builder function has defined I/O and must be unit-tested:
- Input: `{allDay: false, leadMinutes: 15}` → output contains `TRIGGER:-PT15M` (no `VALUE=TEXT`)
- Input: `{allDay: true, leadMinutes: 1440, eventDate: '2026-06-15', timezone: 'America/New_York'}` → output TRIGGER is DATE-TIME `20260614T130000Z` (9 AM EDT = 13:00 UTC, 1 day before June 15)
- Input: `{allDay: true, leadMinutes: 0, eventDate: '2026-06-15', timezone: 'America/New_York'}` → TRIGGER is `20260615T130000Z` (9 AM EDT on the event date itself)
---
### Q2: NULL-vs-0 distinction end-to-end through the schema pipeline (D-06)
**Current state of the schemas (from direct file inspection):**
`apps/api/src/routes/events.ts``eventFieldsSchema` (line 101):
```typescript
// Current — no reminderLeadMinutes field at all
const eventFieldsSchema = z.object({
title: z.string().min(1).max(255),
allDay: z.boolean(),
start: z.string().min(1).max(64),
end: z.string().min(1).max(64),
location: z.string().max(2000).optional(),
description: z.string().max(2000).optional(),
recurrence: z.enum([...]).optional(),
// ...
})
```
`apps/api/src/broker/outboxWorker.ts``outboxPayloadSchema` (line 73):
```typescript
// Current — no reminderLeadMinutes field at all
const outboxPayloadSchema = z.object({
title: z.string().min(1).max(255),
allDay: z.boolean(),
// ...
}).passthrough();
```
`apps/api/src/db/schema.ts` — column already exists (line 144):
```typescript
reminderLeadMinutes: int('reminder_lead_minutes'), // nullable → NULL means no reminder
```
**Required changes to carry NULL-vs-0 end-to-end:**
**Step 1 — `eventFieldsSchema` in routes/events.ts:**
```typescript
// Add to eventFieldsSchema:
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
```
Semantics:
- Field absent from JSON: `undefined` — means "no change" (edit mode, preserve existing VALARM)
- Field present as `null`: no reminder (clear any VALARM)
- Field present as `0`: same-day all-day (fire 9 AM on event date)
- Field present as positive integer: N-minute lead
**Zod behavior through JSON serialization:**
- `z.nullable()`: accepts `null` in JSON — value is `null` in parsed output
- `z.optional()`: accepts the field being absent — value is `undefined` in parsed output
- `null` and `undefined` are distinct in Zod: `.nullable()` does NOT make the field optional; `.optional()` does NOT make the value nullable. Both modifiers are required.
- JSON.parse/stringify: `null` survives round-trip as `null`; `undefined` object properties are dropped by JSON.stringify (field absent on the wire). This is the correct behavior — an absent field on the edit payload means "don't touch the reminder."
**Step 2 — `outboxPayloadSchema` in outboxWorker.ts:**
Add the same field, plus a sentinel for the preserve-on-no-change case:
```typescript
// Add to outboxPayloadSchema:
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
// Sentinel: absent = "user did not touch the reminder picker" → preserve existing VALARM
// null = explicit "None" (clear VALARM)
// 0 = same-day all-day
// positive integer = N-minute lead
```
The `.passthrough()` on `outboxPayloadSchema` means unrecognized fields survive, but explicitly declared fields are type-narrowed. The field must be declared so the planner gets TypeScript type safety in the worker.
**Step 3 — The "no change" sentinel distinction:**
D-08 requires distinguishing "user did not touch the picker" from "user explicitly selected None." The zod schema handles this correctly: the field is `.optional()` so its absence on the JSON payload is distinguishable from `null`. In the outbox worker update path:
```typescript
const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes');
// hasExplicitReminder=false → preserve rawVevent VALARMs (mirrors WR-01 hasExplicitRecurrence)
// hasExplicitReminder=true, fields.reminderLeadMinutes === null → clear all VALARMs
// hasExplicitReminder=true, fields.reminderLeadMinutes >= 0 → replace with new VALARM
```
This mirrors the `hasExplicitRecurrence` pattern already in the update branch (outboxWorker.ts line 425).
**Step 4 — `buildVeventString` parameter extension:**
Add `valarms?: ICAL.Component[]` parameter to `NewEventParams` in vevent.ts:
```typescript
export interface NewEventParams {
// ...existing fields...
reminderLeadMinutes?: number | null; // for NEW VALARM construction (create / user-changed)
valarms?: ICAL.Component[]; // pre-parsed VALARMs from rawVevent (preserve-on-edit)
allDayAlertInstantUtc?: Date; // computed by worker for all-day absolute triggers
}
```
The worker resolves which path to take before calling buildVeventString:
- `hasExplicitReminder && reminderLeadMinutes !== null` → compute alert instant if allDay, pass as `allDayAlertInstantUtc`; pass `reminderLeadMinutes` directly for timed
- `hasExplicitReminder && reminderLeadMinutes === null` → pass neither (no VALARM emitted)
- `!hasExplicitReminder` → extract VALARMs from rawVevent, pass as `valarms`
**Step 5 — DB upsert in sync.ts:**
The `sync.ts` upsert (line 144) does NOT currently write `reminderLeadMinutes`. Phase 11 must add it: parse the VALARM from the incoming VCALENDAR (if any) to extract the lead minutes, and write it to the column so the scheduler can query it. This is the sync-write path that populates the scheduler's data source.
**TDD note:** The NULL-vs-0 transform is a pure function — given a zod-parsed `reminderLeadMinutes` value and `allDay` flag, return a scheduler-ready lead. I/O examples:
- `(null, false)` → no push row
- `(0, true)` → schedule at 9 AM on event date (leadMinutes=0 + 9AM rule)
- `(0, false)` → no push (0 on a timed event = None, same as null)
- `(1440, true)` → schedule at 9 AM on the day before
---
### Q3: Off-list / non-preset VALARM classification and preserve-on-edit (D-07/D-08, CAL-14)
**How to classify VALARMs from rawVevent using ical.js:**
```typescript
// Source: ical.js API — getFirstSubcomponent, getAllSubcomponents
// [CITED: https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar]
export type AlarmClassification =
| { kind: 'none' } // no VALARM sub-components
| { kind: 'preset'; leadMinutes: number } // single relative DURATION, on the preset list
| { kind: 'offlist'; leadMinutes: number } // single relative DURATION, NOT on preset list
| { kind: 'custom' }; // absolute DATE-TIME trigger OR multiple VALARMs
export function classifyValarms(rawVevent: string): AlarmClassification {
let parsed: ReturnType<typeof ICAL.parse>;
try {
parsed = ICAL.parse(rawVevent);
} catch {
return { kind: 'none' };
}
const comp = new ICAL.Component(parsed);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return { kind: 'none' };
const valarms = vevent.getAllSubcomponents('valarm');
if (valarms.length === 0) return { kind: 'none' };
if (valarms.length > 1) return { kind: 'custom' }; // multiple alarms → Custom (kept)
const alarm = valarms[0];
const triggerProp = alarm.getFirstProperty('trigger');
if (!triggerProp) return { kind: 'none' };
// Check value type — 'duration' = relative DURATION; 'date-time' = absolute
const valueType = triggerProp.getParameter('value');
// ical.js jCal encodes relative DURATION as value type 'duration';
// absolute as 'date-time'. When VALUE param is absent, the default for TRIGGER is duration.
// [CITED: kewisch/ical.js/wiki/Migrating-from-Other-Libraries — jCal structure]
if (valueType === 'date-time') return { kind: 'custom' }; // absolute → Custom (kept)
const dur = triggerProp.getFirstValue() as ICAL.Duration | null;
if (!dur || typeof dur.toSeconds !== 'function') return { kind: 'custom' };
const totalSeconds = Math.abs(dur.toSeconds()); // negative for "before", take absolute
const leadMinutes = Math.round(totalSeconds / 60);
// Preset lists (timed and all-day combined)
const PRESET_MINUTES = new Set([5, 10, 15, 30, 60, 120, 1440, 2880, 0, 10080]);
if (PRESET_MINUTES.has(leadMinutes)) {
return { kind: 'preset', leadMinutes };
}
return { kind: 'offlist', leadMinutes };
}
```
**Note on `triggerProp.getParameter('value')`:** In ical.js, the jCal representation stores the value type as the third element of the property array. The `getParameter('value')` call returns the VALUE parameter if explicitly set (e.g., `VALUE=DATE-TIME`). For DURATION triggers, the value type is the default and `getParameter('value')` returns `undefined` (not `'duration'`). The check should be:
```typescript
// value type check — ical.js getFirstValue() returns ICAL.Duration for duration-typed properties
// and ICAL.Time for date-time-typed properties
const firstValue = triggerProp.getFirstValue();
if (firstValue instanceof ICAL.Time) return { kind: 'custom' }; // absolute DATE-TIME
// Otherwise ICAL.Duration — relative DURATION trigger
const dur = firstValue as ICAL.Duration;
```
**VALARM extraction for preserve-on-edit (mirrors WR-01 RRULE-preserve):**
```typescript
// Extract all VALARM sub-components from rawVevent for re-attachment
export function extractValarms(rawVevent: string): ICAL.Component[] {
try {
const parsed = ICAL.parse(rawVevent);
const comp = new ICAL.Component(parsed);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return [];
return vevent.getAllSubcomponents('valarm');
} catch {
return [];
}
}
```
These extracted `ICAL.Component` objects are passed to `buildVeventString` via the `valarms` parameter and re-attached to the new VEVENT using `vevent.addSubcomponent(alarm)`. They are NOT re-serialized and re-parsed — they are passed as live ical.js component objects to avoid any encoding issues.
**The preserve-on-edit trigger in outboxWorker update path:**
```typescript
// In dispatchRow, update branch — after the hasExplicitReminder check:
let valarmsToPreserve: ICAL.Component[] = [];
if (!hasExplicitReminder && freshEtagRows[0]?.rawVevent) {
valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent);
}
// Pass to buildVeventString:
const { icsString } = buildVeventString({
// ...existing params...
valarms: valarmsToPreserve, // empty array = no VALARM; populated = preserve
});
```
**TDD note:** The classifier is pure I/O — ideal for TDD:
- Input: ICS with `TRIGGER;VALUE=DATE-TIME:20260615T130000Z``{ kind: 'custom' }`
- Input: ICS with `BEGIN:VALARM\nTRIGGER:-PT45M\nEND:VALARM``{ kind: 'offlist', leadMinutes: 45 }`
- Input: ICS with `BEGIN:VALARM\nTRIGGER:-PT15M\nEND:VALARM``{ kind: 'preset', leadMinutes: 15 }`
- Input: ICS with two VALARM blocks → `{ kind: 'custom' }`
- Input: ICS with no VALARM → `{ kind: 'none' }`
---
## Standard Stack
No new packages. All tools are already in the project.
| Library | Version | Purpose | Notes |
|---------|---------|---------|-------|
| ical.js | 2.2.1 | VALARM component build + parse | Use `ICAL.Component`, `ICAL.Property`, `ICAL.Duration`, `ICAL.Time` |
| zod | 3.24.x | Schema extension for reminderLeadMinutes | `.nullable().optional()` pattern |
| drizzle-orm | 0.45.2 | DB query for variable-lead scheduler scan | Existing mysql2 driver |
**No new npm dependencies for this phase.** The UI-SPEC confirms: "Phase 11 adds no new npm dependencies on the frontend."
---
## Package Legitimacy Audit
No new packages — not applicable.
---
## Architecture Patterns
### System Architecture Diagram
```
EventForm.tsx
[reminder <select>]
allDay=false → timed presets
allDay=true → day presets (swapped)
edit mode: classify rawVevent VALARM →
none → select "None"
preset → select matching option
offlist → add synthetic option
custom → disabled "Custom (kept)"
|
| reminderLeadMinutes: number | null | undefined (absent=no-change)
v
POST /api/events/create OR PATCH /api/events/:uid/edit
eventFieldsSchema (zod) validates:
reminderLeadMinutes: z.number().int().min(0).nullable().optional()
|
v
calendar_outbox.payload (JSON)
{ ...eventFields, reminderLeadMinutes: N | null | undefined }
|
v
outboxWorker.ts (update branch)
hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')
if !hasExplicitReminder → extractValarms(rawVevent) → valarmsToPreserve
if hasExplicitReminder && null → no valarms
if hasExplicitReminder && N → buildAbsoluteTrigger (allDay) or buildRelativeTrigger (timed)
|
v
buildVeventString(params: NewEventParams)
valarms: ICAL.Component[] → vevent.addSubcomponent(alarm) for each
reminderLeadMinutes + allDay → emit VALARM sub-component
|
v
Fastmail CalDAV PUT (VCALENDAR with VALARM)
|
v (post-write re-sync)
sync.ts upsert
parse VALARM from rawVevent → write reminderLeadMinutes to calendar_events
|
v
reminderScheduler.ts (every 60s via setInterval)
SELECT events WHERE reminder_lead_minutes IS NOT NULL
AND reminder_lead_minutes > 0 (timed) OR (all_day=true AND reminder_lead_minutes >= 0)
AND alert_time IN (now, now+1min]
uid:dtstartMs dedup
dispatch push with humanized body
```
### Recommended Project Structure (unchanged — no new files beyond tests)
```
apps/api/src/broker/
vevent.ts # add buildVeventString VALARM support + classifyValarms + extractValarms
outboxWorker.ts # update branch: hasExplicitReminder pattern; pass valarms to buildVeventString
reminderScheduler.ts # variable-lead window, uid:dtstartMs dedup, all-day 9AM, humanized body
sync.ts # upsert reminderLeadMinutes from parsed VALARM
apps/api/src/routes/
events.ts # extend eventFieldsSchema with reminderLeadMinutes
apps/pwa/src/components/
EventForm.tsx # add reminder <select>, classifyValarms call on edit load
apps/pwa/src/api/
client.ts # extend CreateEventPayload with reminderLeadMinutes
apps/api/tests/broker/
vevent.test.ts # VALARM serialization + classifier TDD tests
reminderScheduler.test.ts # variable-lead, all-day 9AM, uid:dtstartMs dedup tests
```
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| TRIGGER value type | `addPropertyWithValue('trigger', '-PT15M')` string | `resetType('duration')` + `setValue(ICAL.Duration.fromSeconds(...))` | String form may emit `VALUE=TEXT` (Pitfall 2 — Fastmail/Apple silently ignore) |
| VALARM re-attachment | Extract text between BEGIN:VALARM/END:VALARM and splice | `extractValarms()``vevent.addSubcomponent()` | Text splicing is fragile; ical.js line-folding handles re-serialization correctly |
| All-day alert time | Ad-hoc string math on date strings | Proper UTC Date arithmetic: parse YYYY-MM-DD, subtract lead days, add 9h local offset → UTC | DST offsets vary; hand-rolled date math will produce wrong UTC instants on DST boundaries |
| Scheduler interval | node-cron | `setInterval` only | node-cron 4.2.1 silently skips ticks in the long-lived process (burned-in lesson, CLAUDE.md memory) |
| DB migration | `drizzle-kit push` | `drizzle-kit generate` + `migrate` | push emits false destructive diff on populated MariaDB (burned-in lesson) |
---
## Common Pitfalls
### Pitfall 1 (RETAINED): VALARM Round-Trip Strips Existing Alarms on Edit
See PITFALLS.md Pitfall 1 for full detail. Prevention: `hasExplicitReminder` pattern + `extractValarms()` on update path.
### Pitfall 2 (RETAINED): TRIGGER `VALUE=TEXT` Silently Breaks VALARM
See PITFALLS.md Pitfall 2. Prevention: `resetType('duration')` + `ICAL.Duration.fromSeconds()`.
### Pitfall 3 (AMENDED): All-Day Event VALARM
PITFALLS.md Pitfall 3 said "hide the picker for all-day events." CONTEXT.md D-02 reverses this. The amended guard: `buildVeventString` MUST emit a VALARM for all-day events when `reminderLeadMinutes >= 0` and `allDay=true`, using an absolute DATE-TIME trigger computed at 9 AM local on the alert day. The scheduler's `WHERE allDay=false` clause MUST be removed/replaced with per-event lead-time logic that correctly handles all-day events.
### Pitfall 4 (RETAINED): uid-only Dedup Key Breaks with Variable Leads
See PITFALLS.md Pitfall 4. Change dedup key to `uid:dtstartMs`. Also: the scheduler window query must change from a fixed `(now, now+16min]` to per-event lead-based logic. Two approaches:
- Option A (simpler): Query events where `alertTime IN (now, now+1min]` — compute alertTime in SQL: for timed events, `DTSTART - INTERVAL reminder_lead_minutes MINUTE`; for all-day, the 9 AM local instant. This requires MariaDB date arithmetic.
- Option B (current pattern): Keep the per-minute scan but check `dtstartUtc - reminder_lead_minutes MINUTES <= now + 60s AND dtstartUtc > now`.
**Recommendation:** Option B — widest window is `max(reminder_lead_minutes)` + 1 min buffer. Or: run a minute-tick scan that computes the expected fire time per event and fires those within a 1-minute window. Keep the fixed 60s tick from `startReminderScheduler`.
### Pitfall: All-Day 9 AM UTC Computation at DST Boundaries
Computing 9 AM local when the event date straddles a DST change requires using the correct offset for that specific date, not today's offset. Use `Intl.DateTimeFormat` or a date library to get the UTC offset for a specific date in a specific timezone. If no timezone is stored (the API currently stores no per-user timezone), default to the server's local timezone (`process.env.TZ` or `Intl.DateTimeFormat().resolvedOptions().timeZone`). [ASSUMED — no per-user timezone in the DB schema confirmed from schema.ts]
### Pitfall: `0` Lead Minutes on a Timed Event
D-06 specifies: "For timed events, 0/absent still means None." The scheduler must guard: if `allDay=false` AND `reminder_lead_minutes=0`, treat as NULL (no push). Only when `allDay=true` does `reminder_lead_minutes=0` mean "Same day."
### Pitfall: sync.ts Does Not Currently Write reminderLeadMinutes
`sync.ts` upsert (lines 144170) does not include `reminderLeadMinutes` in the `values()` call. The scheduler reads `calendar_events.reminder_lead_minutes` as ground truth. Without the sync-write, events synced from Fastmail (with VALARMs set by native clients) will never have `reminderLeadMinutes` populated. Phase 11 MUST add VALARM parsing in `sync.ts` to extract lead minutes and write them on upsert.
---
## Code Examples
### Building a VALARM sub-component (timed event, relative DURATION trigger)
```typescript
// Source: ical.js jCal structure [CITED: https://github.com/kewisch/ical.js/wiki/Migrating-from-Other-Libraries]
// jCal encodes: ["trigger", {"related": "START"}, "duration", "-PT15M"]
// Production code in vevent.ts:
function buildTimedValarm(leadMinutes: number): ICAL.Component {
const valarm = new ICAL.Component('valarm');
valarm.addPropertyWithValue('action', 'DISPLAY');
valarm.addPropertyWithValue('description', 'Reminder');
const triggerProp = new ICAL.Property('trigger');
triggerProp.resetType('duration'); // ensures value type = 'duration', not 'text'
triggerProp.setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60));
valarm.addProperty(triggerProp);
return valarm;
}
// Then: vevent.addSubcomponent(buildTimedValarm(15));
// Emits: BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM
```
### Building a VALARM for all-day events (absolute DATE-TIME trigger)
```typescript
// alertInstantUtc = 9 AM local on (eventDate - leadDays) in UTC
function buildAllDayValarm(alertInstantUtc: Date): ICAL.Component {
const valarm = new ICAL.Component('valarm');
valarm.addPropertyWithValue('action', 'DISPLAY');
valarm.addPropertyWithValue('description', 'Reminder');
const triggerProp = new ICAL.Property('trigger');
triggerProp.resetType('date-time'); // VALUE=DATE-TIME absolute trigger
triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); // useUTC=true → Z suffix
valarm.addProperty(triggerProp);
return valarm;
}
```
### Computing alertInstantUtc for all-day 9 AM local
```typescript
// No timezone library needed — use Date + Intl offset arithmetic
function computeAllDayAlertUtc(eventDateStr: string, leadMinutes: number, tz: string): Date {
// eventDateStr: 'YYYY-MM-DD', leadMinutes: 0|1440|2880|10080
const leadDays = leadMinutes / 1440; // 0, 1, 2, or 7
// Parse the event date as midnight UTC, then subtract lead days
const [y, m, d] = eventDateStr.split('-').map(Number) as [number, number, number];
const alertDate = new Date(Date.UTC(y, m - 1, d - leadDays));
// Get UTC offset for 9 AM on alertDate in the target timezone
// Intl.DateTimeFormat gives us the local time components
const alertDateStr = alertDate.toISOString().slice(0, 10); // 'YYYY-MM-DD'
// Build a candidate 9 AM local and find its UTC equivalent
// Strategy: try 9 AM, compute the offset, adjust
const candidate = new Date(`${alertDateStr}T09:00:00`); // interpreted as local by Date()
// Better: use a fixed UTC instant and shift by the known offset
// Parse offset via Intl:
const offsetMs = getUtcOffsetMs(alertDate, tz);
// 9 AM local = UTC midnight + 9h - offset
const alertMidnightUtc = Date.UTC(
alertDate.getUTCFullYear(),
alertDate.getUTCMonth(),
alertDate.getUTCDate(),
);
return new Date(alertMidnightUtc + 9 * 3600 * 1000 - offsetMs);
}
function getUtcOffsetMs(dateAtMidnightUtc: Date, tz: string): number {
// Intl trick: format a UTC date in the target timezone, measure offset
const parts = new Intl.DateTimeFormat('en', {
timeZone: tz,
hour: 'numeric', minute: 'numeric', second: 'numeric',
hour12: false,
timeZoneName: 'shortOffset',
}).formatToParts(dateAtMidnightUtc);
// Extract UTC offset from parts (or use a simpler approach)
// Simpler: compare UTC midnight against its local midnight representation
const localMidnightStr = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
}).format(dateAtMidnightUtc); // 'YYYY-MM-DD'
// ... this gets complex; the cleanest approach for this use case:
// offsetMs = (UTC time of "midnight local on alertDate") = alertMidnightUtc - localMidnightUtc
// Use a reference point: new Date(alertDateStr + 'T00:00:00') gives LOCAL midnight
// then .getTime() - alertDate.getTime() = offsetMs
// NOTE: This only works correctly in Node.js when TZ env is set to the server's timezone.
// Recommended: server runs with TZ=America/New_York (or whatever the household timezone is).
// If no per-user timezone, use server TZ. Document this as a known limitation.
return 0; // placeholder — implement via the above approach
}
```
**Planner note:** The alert UTC computation is the most DST-sensitive piece. Recommend implementing as a separate pure function `computeAlertInstantUtc(eventDateStr, leadDays, serverTimezone)` with TDD tests at DST boundaries (spring-forward, fall-back). [ASSUMED: server timezone matches household timezone — no per-user TZ in DB]
### Humanized push body (D-09)
```typescript
// Source: 11-UI-SPEC.md Copywriting Contract
// Replace reminderScheduler.ts line 157: body: `Starts in ${minutes} min`
function humanizeLeadMinutes(leadMinutes: number): string {
if (leadMinutes < 60) return `Starts in ${leadMinutes} min`;
if (leadMinutes < 120) return 'Starts in 1 hour';
if (leadMinutes < 1440) return `Starts in ${Math.round(leadMinutes / 60)} hours`;
if (leadMinutes < 2880) return 'Starts in 1 day';
return `Starts in ${Math.round(leadMinutes / 1440)} days`;
}
// TDD I/O:
// humanizeLeadMinutes(30) → 'Starts in 30 min'
// humanizeLeadMinutes(60) → 'Starts in 1 hour'
// humanizeLeadMinutes(90) → 'Starts in 2 hours' (Math.round(1.5)=2)
// humanizeLeadMinutes(1440) → 'Starts in 1 day'
// humanizeLeadMinutes(10080)→ 'Starts in 7 days'
```
**Note on 90-minute rounding:** `Math.round(90/60) = 2` — "Starts in 2 hours" for a 90-minute lead. This matches the UI-SPEC threshold `1201439 minutes → N hours`. But 90 minutes is in the 60119 bucket → "1 hour". Recalibrate: `leadMinutes >= 60 && leadMinutes < 120` → "1 hour" before the hours calculation. The function above handles this correctly via the ordering of branches.
### Variable-lead scheduler SQL query (Drizzle ORM sketch)
```typescript
// Replace the fixed windowEnd = now + 16min approach
// For each event: fire when (dtstartUtc - reminderLeadMinutes MINUTES) falls in (lastCheck, now]
// In a 1-minute tick, query events where the alert time is in the past minute
const alertWindowStart = new Date(now.getTime() - 60 * 1000); // 1 min ago (catch-up window)
// Timed events: alert_time = dtstartUtc - reminder_lead_minutes MINUTES
// Using Drizzle raw SQL for the computed column:
import { sql } from 'drizzle-orm';
// Timed events:
// WHERE allDay=false
// AND reminder_lead_minutes IS NOT NULL
// AND reminder_lead_minutes > 0 (0 on timed = None per D-06)
// AND (dtstartUtc - INTERVAL reminder_lead_minutes MINUTE) > alertWindowStart
// AND (dtstartUtc - INTERVAL reminder_lead_minutes MINUTE) <= now
// AND dtstartUtc > now (exclude already-started)
// All-day events:
// WHERE allDay=true
// AND reminder_lead_minutes IS NOT NULL
// AND dtstartDate IS NOT NULL
// AND the computed 9 AM UTC alert for (dtstartDate - reminder_lead_minutes/1440 DAYS)
// falls in (alertWindowStart, now]
// → This requires either pre-computing alertTime in a derived column
// or fetching all all-day events with non-null leads and filtering in JS
// Recommended: separate queries for timed and all-day events (cleaner SQL)
```
**Planner note:** All-day 9 AM UTC computation in SQL requires timezone-aware date functions that are MariaDB version-dependent. Recommend fetching all-day events with non-null `reminder_lead_minutes` that have `dtstartDate` within the next `max(lead)` days, then computing the alert time in JavaScript and filtering. This avoids complex SQL timezone arithmetic.
---
## Runtime State Inventory
Not a rename/refactor phase — section omitted.
---
## Environment Availability
No new external tools required. The existing dev stack (MariaDB, Node.js 22, Vitest) covers all Phase 11 work. Skipping detailed audit.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest (vite-native, same config as frontend) |
| Config file | `apps/api/vitest.config.ts` |
| Quick run command | `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts tests/broker/reminderScheduler.test.ts` |
| Full suite command | `pnpm --filter @familysync/api exec vitest run` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CAL-13 | VALARM emitted with correct TRIGGER:-PTNmM (no VALUE=TEXT) | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) |
| CAL-13 | All-day VALARM emits absolute DATE-TIME trigger | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) |
| CAL-13 | allDay toggle swaps preset list; reset to None on toggle | browser | `playwright-cli` | ❌ Wave 0 |
| CAL-14 | Edit with no picker change: rawVevent VALARM preserved in PUT payload | unit | `vitest run tests/broker/outboxWorker.test.ts` | ✅ (extend) |
| CAL-14 | Off-list single relative VALARM → offlist classification | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) |
| CAL-14 | Absolute trigger → custom classification | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) |
| CAL-14 | Two VALARMs → custom classification | unit | `vitest run tests/broker/vevent.test.ts` | ✅ (extend) |
| NOTIF-04 | Scheduler fires at T-leadMinutes for a 30-min lead event | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) |
| NOTIF-04 | Humanized body: 30min→"Starts in 30 min", 60→"1 hour", 1440→"1 day", 10080→"7 days" | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) |
| NOTIF-05 | Event with null reminderLeadMinutes produces no push | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) |
| NOTIF-05 | Event with reminderLeadMinutes=0 and allDay=false produces no push | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) |
| NOTIF-06 | All-day event with 0-min lead fires at 9 AM local (UTC computation correct) | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ❌ Wave 0 |
| NOTIF-06 | Rescheduled event (new dtstart) fires again — uid:dtstartMs dedup | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend) |
| NOTIF-06 | Same event fires exactly once across 3 consecutive ticks (uid:dtstartMs dedup) | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ✅ (extend — existing test covers uid-only; update to uid:dtstartMs) |
### Sampling Rate
- **Per task commit:** `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts tests/broker/reminderScheduler.test.ts`
- **Per wave merge:** `pnpm --filter @familysync/api exec vitest run` (full API suite)
- **Phase gate:** Full API suite green + Playwright smoke (allDay toggle, reminder picker loads correctly in edit) before `/gsd-verify-work`
### Wave 0 Gaps
- [ ] `tests/broker/vevent.test.ts` — extend with VALARM serialization tests (TRIGGER type, all-day absolute, preserve round-trip, classifier I/O)
- [ ] `tests/broker/reminderScheduler.test.ts` — extend with variable-lead, all-day 9AM UTC, uid:dtstartMs dedup, NULL-vs-0 semantics, humanized body
- [ ] `computeAlertInstantUtc` unit tests at DST boundaries (spring-forward and fall-back dates)
- [ ] Playwright smoke: open EventForm in create mode, toggle allDay, verify preset list swaps; open edit mode with existing event, verify picker loads correct value
---
## Security Domain
No new security surface. The reminder picker reads/writes `reminderLeadMinutes` (an integer) validated by Zod `z.number().int().min(0).nullable().optional()` — no injection vectors. Push payload body is a hardcoded humanized string, not user-controlled. Existing ASVS V5 input validation coverage is maintained.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Absolute DATE-TIME TRIGGER is honored by both Fastmail and Apple Calendar for all-day events | Q1 VALARM trigger encoding | Low — scheduler is ground truth; if clients ignore VALARM, only native-client interop is affected, not push delivery |
| A2 | Server timezone (process.env.TZ) matches household timezone for 9 AM alert computation | Q1/Code Examples | Medium — if TZ is UTC, all-day reminders fire at 9 AM UTC (which may not be 9 AM local). Mitigation: document that `TZ` env must be set in docker-compose |
| A3 | `triggerProp.getFirstValue()` returns `ICAL.Duration` for relative triggers and `ICAL.Time` for absolute triggers | Q3 classifier | Medium — could be wrong for edge-case ical.js versions; mitigated by TDD tests that round-trip a known ICS |
| A4 | `getParameter('value')` on a default-type DURATION trigger returns undefined (not 'duration') | Q3 classifier | Low — mitigated by the `instanceof ICAL.Time` check which is type-safe |
| A5 | No per-user timezone is stored in the DB — server TZ is used as the household timezone | Environment | Medium — verified from schema.ts (no timezone column on users or calendars); if household spans timezones, 9 AM will be wrong for one member |
---
## Sources
### Primary (HIGH confidence — direct code inspection)
- `apps/api/src/broker/vevent.ts` — existing buildVeventString; RRULE pattern for VALARM to mirror
- `apps/api/src/broker/outboxWorker.ts` — outboxPayloadSchema, hasExplicitRecurrence pattern for hasExplicitReminder to mirror
- `apps/api/src/broker/reminderScheduler.ts` — current fixed-window logic, dedup map, body string at line 157
- `apps/api/src/db/schema.ts``reminderLeadMinutes: int('reminder_lead_minutes')` nullable column confirmed
- `apps/api/src/broker/sync.ts` — upsert does NOT write reminderLeadMinutes (confirmed gap)
- `apps/api/src/broker/expand.ts``CalendarOccurrence` does not include reminderLeadMinutes (must be added for edit-mode load)
- `apps/api/src/routes/events.ts``eventFieldsSchema` has no reminderLeadMinutes (confirmed gap)
- `apps/pwa/src/api/client.ts``CreateEventPayload` has no reminderLeadMinutes (confirmed gap)
- `apps/pwa/src/components/EventForm.tsx:852` — Recurrence picker pattern to mirror
### Secondary (MEDIUM confidence — Context7 / official docs)
- [kewisch/ical.js — jCal structure](https://github.com/kewisch/ical.js/wiki/Migrating-from-Other-Libraries) — VALARM jCal encoding confirms `"duration"` value type for TRIGGER, `{"related": "START"}` parameter
- [kewisch/ical.js — Convert to iCalendar](https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545)) — addSubcomponent pattern confirmed
- [kewisch/ical.js — Parsing iCalendar](https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar) — getFirstSubcomponent, getAllSubcomponents API confirmed
### Tertiary (LOW confidence — assumed)
- RFC 5545 §3.8.6.3 — VALARM TRIGGER value types (DURATION vs DATE-TIME) — training knowledge [ASSUMED]
- Client behavior (Fastmail/Apple Calendar) with absolute DATE-TIME triggers on all-day events — [ASSUMED]
---
## Metadata
**Confidence breakdown:**
- Schema/zod plumbing (Q2): HIGH — verified from direct file inspection, no ambiguity
- ical.js VALARM API (Q1/Q3): MEDIUM — confirmed via Context7 official docs; `resetType`/`getFirstValue` APIs confirmed from jCal structure examples
- All-day trigger client interop (Q1): LOW — untestable without live Fastmail + Apple Calendar
- Humanized body thresholds: HIGH — copied from UI-SPEC.md verbatim
- 9 AM UTC computation: MEDIUM — the algorithm is correct; DST edge cases require TDD at boundaries
**Research date:** 2026-06-13
**Valid until:** 2026-07-13 (stable domain — ical.js 2.x API is stable)