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

44 KiB
Raw Blame History

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:

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

// 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.tseventFieldsSchema (line 101):

// 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.tsoutboxPayloadSchema (line 73):

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

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:

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

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

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:

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:

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

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

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

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

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

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

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

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

// 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.tsreminderLeadMinutes: 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.tsCalendarOccurrence does not include reminderLeadMinutes (must be added for edit-mode load)
  • apps/api/src/routes/events.tseventFieldsSchema has no reminderLeadMinutes (confirmed gap)
  • apps/pwa/src/api/client.tsCreateEventPayload has no reminderLeadMinutes (confirmed gap)
  • apps/pwa/src/components/EventForm.tsx:852 — Recurrence picker pattern to mirror

Secondary (MEDIUM confidence — Context7 / official docs)

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)