chore: archive v1.1 phase directories to milestones/v1.1-phases/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a2890d1542
commit
c7955a46b9
@@ -0,0 +1,769 @@
|
||||
# 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 144–170) 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 `120–1439 minutes → N hours`. But 90 minutes is in the 60–119 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)
|
||||
Reference in New Issue
Block a user