diff --git a/apps/api/src/broker/vevent.ts b/apps/api/src/broker/vevent.ts index 38609d4..9304b33 100644 --- a/apps/api/src/broker/vevent.ts +++ b/apps/api/src/broker/vevent.ts @@ -30,8 +30,54 @@ export interface NewEventParams { description?: string; rruleString?: string; // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07) dtstamp?: Date; // omit = now() + /** + * Per-event reminder lead in minutes (Phase 11, CAL-13). + * null — explicit "None" → no VALARM emitted + * 0 — same-day all-day (fire 9 AM on event date); 0 on timed = None (D-06) + * positive — N minutes before event start (timed) or N/1440 days before (all-day) + * undefined — field absent; use valarms preserve path if provided + */ + reminderLeadMinutes?: number | null; + /** + * Pre-parsed VALARM components from rawVevent (preserve-on-edit, D-08 / CAL-14). + * When non-empty, these are re-attached verbatim and reminderLeadMinutes is ignored. + */ + valarms?: ICAL.Component[]; + /** + * UTC instant for all-day event absolute DATE-TIME trigger (9 AM local on alert day). + * Computed by caller (outboxWorker / computeAlertInstantUtc) for the specific alert date. + * Required when allDay=true and reminderLeadMinutes != null. + */ + allDayAlertInstantUtc?: Date; } +/** + * Preset reminder lead times (minutes) that map to distinct UI options. + * Includes both timed (5–120 min) and all-day (0, 1440, 2880, 10080) presets. + * Used by classifyValarms to distinguish preset from offlist single-DURATION triggers. + * + * D-01/D-02: timed presets = 0,5,10,15,30,60,120; all-day = 0,1440,2880,10080. + * 0 is included for "Same day" all-day; it is also the "None" sentinel for timed (D-06). + */ +export const PRESET_MINUTES = new Set([0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080]); + +/** + * Classification of VALARM components found in a stored raw VEVENT (CAL-14, D-07). + * + * none — no VALARM sub-component present + * preset — single relative DURATION trigger whose lead is in PRESET_MINUTES + * offlist — single relative DURATION trigger with a lead NOT in PRESET_MINUTES + * custom — absolute DATE-TIME trigger OR multiple VALARM sub-components + * + * Off-list alarms get a synthetic read-only option in the picker showing the real minutes. + * Custom alarms get a disabled "Custom (kept)" entry. + */ +export type AlarmClassification = + | { kind: 'none' } + | { kind: 'preset'; leadMinutes: number } + | { kind: 'offlist'; leadMinutes: number } + | { kind: 'custom' }; + /** * Simple RRULE preset strings for whole-series recurring events (D-11 / CAL-07). * "weekly on Monday" example: 'FREQ=WEEKLY;BYDAY=MO' (not a key in this map; compose manually). @@ -53,6 +99,239 @@ export const RRULE_PRESETS: Record = { yearly: 'FREQ=YEARLY', }; +/** + * Build a VALARM sub-component with a relative DURATION trigger for timed events. + * + * Uses resetType('duration') + setValue(ICAL.Duration) to guarantee the correct + * jCal value type — preventing the VALUE=TEXT pitfall that occurs when using + * addPropertyWithValue('trigger', '-PTNmM') (Pitfall 2). + * + * @param leadMinutes — minutes before event start (e.g. 15 → TRIGGER:-PT15M) + */ +export 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'); + // resetType('duration') ensures the jCal value type is 'duration', not 'text' + triggerProp.resetType('duration'); + triggerProp.setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60)); + valarm.addProperty(triggerProp); + + return valarm; +} + +/** + * Build a VALARM sub-component with an absolute DATE-TIME trigger for all-day events. + * + * RFC 5545 §3.8.6.3: TRIGGER;VALUE=DATE-TIME: — an absolute UTC instant. + * Uses resetType('date-time') + setValue(ICAL.Time) to guarantee VALUE=DATE-TIME, no DURATION. + * + * @param alertInstantUtc — the UTC instant (9 AM local on the computed alert day) + */ +export 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'); + // resetType('date-time') ensures VALUE=DATE-TIME absolute trigger + triggerProp.resetType('date-time'); + // useUTC=true → Z suffix (UTC instant, not local) + triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); + valarm.addProperty(triggerProp); + + return valarm; +} + +/** + * Classify the VALARM(s) in a stored raw VEVENT string (CAL-14, D-07). + * + * Safe on parse failure — returns { kind: 'none' } for any unparseable input. + * This matches the threat-model mitigation T-11-01: no eval, no string splicing; + * ical.js handles line-folding + escaping. + * + * @param rawVevent — full VCALENDAR or VEVENT iCalendar string + */ +export function classifyValarms(rawVevent: string): AlarmClassification { + let parsed: ReturnType; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any' + parsed = ICAL.parse(rawVevent); + } catch { + return { kind: 'none' }; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any' + 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' }; + + const alarm = valarms[0]; + const triggerProp = alarm.getFirstProperty('trigger'); + if (!triggerProp) return { kind: 'none' }; + + // getFirstValue() returns ICAL.Duration for relative triggers and ICAL.Time for absolute. + // The 'instanceof ICAL.Time' check distinguishes the two without relying on VALUE param. + const firstValue = triggerProp.getFirstValue() as unknown; + if (firstValue instanceof ICAL.Time) return { kind: 'custom' }; + + // Relative DURATION trigger — extract lead minutes + const dur = firstValue as ICAL.Duration; + if (!dur || typeof dur.toSeconds !== 'function') return { kind: 'custom' }; + + const leadMinutes = Math.round(Math.abs(dur.toSeconds()) / 60); + return PRESET_MINUTES.has(leadMinutes) + ? { kind: 'preset', leadMinutes } + : { kind: 'offlist', leadMinutes }; +} + +/** + * Extract all VALARM sub-components from a stored raw VEVENT string (CAL-14, D-08). + * + * Returns live ICAL.Component objects that can be re-attached to a new VEVENT via + * addSubcomponent() — no re-serialization/re-parsing, preserving exact encoding. + * + * Safe on parse failure — returns [] for any unparseable input (T-11-01). + * + * @param rawVevent — full VCALENDAR or VEVENT iCalendar string + */ +export function extractValarms(rawVevent: string): ICAL.Component[] { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any' + const parsed = ICAL.parse(rawVevent); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any' + const comp = new ICAL.Component(parsed); + const vevent = comp.getFirstSubcomponent('vevent'); + if (!vevent) return []; + return vevent.getAllSubcomponents('valarm'); + } catch { + return []; + } +} + +/** + * Compute the UTC instant for 9:00 AM local time on the alert day (Phase 11, NOTIF-06). + * + * The alert day = eventDate minus leadDays. The UTC offset is computed for 9 AM + * specifically on the alert date — not midnight — so DST transitions that occur before + * 9 AM on the alert date (e.g. spring-forward at 2 AM) use the post-transition offset. + * + * D-04: all-day reminders fire at 9 AM local on the alert day. + * Assumption A2/A5: caller passes the server/household timezone (process.env.TZ or + * Intl.DateTimeFormat().resolvedOptions().timeZone). + * + * @param eventDateStr — event start date as 'YYYY-MM-DD' + * @param leadDays — days before event date (0 = same day, 1 = 1 day before, etc.) + * @param tz — IANA timezone identifier (e.g. 'America/New_York') + */ +export function computeAlertInstantUtc( + eventDateStr: string, + leadDays: number, + tz: string, +): Date { + // Parse event date as UTC midnight, then subtract lead days to get alert date + const [y, m, d] = eventDateStr.split('-').map(Number) as [number, number, number]; + const alertDateUtcMs = Date.UTC(y, m - 1, d - leadDays); + const alertDateStr = new Date(alertDateUtcMs).toISOString().slice(0, 10); // 'YYYY-MM-DD' + + // Strategy: compute the UTC offset at 9 AM on the alert date (not at midnight). + // This correctly handles DST transitions that occur before 9 AM (e.g. spring-forward + // at 2 AM on the alert date means 9 AM uses the post-transition offset). + // + // Algorithm: + // 1. Take a "naive" 9 AM UTC estimate: alertDateUtcMs + 9h (as if tz=UTC). + // 2. Ask Intl: what local date+time is this naive instant in `tz`? + // 3. The difference between the desired 9 AM local and the actual local time at the + // naive instant tells us the UTC offset. + // 4. Adjust: target_utc = naive_9am_utc - offset_ms. + // + // This is a one-pass computation that works across DST boundaries because we probe + // the offset near 9 AM, not at midnight. + + const naive9amUtcMs = alertDateUtcMs + 9 * 3600 * 1000; + + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + // What local time does our naive UTC instant correspond to in tz? + const parts = formatter.formatToParts(new Date(naive9amUtcMs)); + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0'; + + const localYear = Number(get('year')); + const localMonth = Number(get('month')); + const localDay = Number(get('day')); + const localHour = Number(get('hour')); // 0–23 (hour12:false) + const localMin = Number(get('minute')); + const localSec = Number(get('second')); + + // The local time at naive_9am_utc in tz. We want this to be 09:00:00 on alertDate. + // Compute how many ms the local time deviates from 09:00:00 on alertDate. + // deviation = (local - desired) in ms, where desired = alertDate at 09:00:00 + + // Build the local "desired" as a date string for comparison + const [ay, am, ad] = alertDateStr.split('-').map(Number) as [number, number, number]; + const isSameDay = localYear === ay && localMonth === am && localDay === ad; + + // Total ms since start of local day at the naive instant + const localMsSinceMidnight = (localHour * 3600 + localMin * 60 + localSec) * 1000; + + // Desired ms since start of local day = 9 AM = 9 * 3600 * 1000 + const desired9amMs = 9 * 3600 * 1000; + + let deviationMs: number; + if (isSameDay) { + // Same local date: deviation = localMsSinceMidnight - desired9amMs + deviationMs = localMsSinceMidnight - desired9amMs; + } else { + // Different local day: the local clock is on the previous or next day. + // Since we're probing near 9 AM UTC+0 and typical offsets are ±12h, the local + // time is either earlier in the day (offset negative = west) or earlier the prev day. + // Day-before case (west of UTC, e.g. UTC-12): local shows the previous day. + // deviation = -(24h - localMsSinceMidnight) - desired9amMs ... complex + // Simpler: compute deviation as the ms difference between naive_9am_utc and the + // UTC instant for the desired 9 AM local. + // Since we know local is offset: local_midnight_utc = naive_9am_utc - localMsSinceMidnight + // + (isSameDay ? 0 : 24h) if local day is next day (east of UTC past 15h offset) + // In practice for UTC-12 to UTC+14, the offset is ±14h. At naive 9 AM UTC: + // UTC-12 → local = 9 AM - 12h = previous day 9 PM → 21h = local midnight +21h + // UTC+14 → local = 9 AM + 14h = next day 23 AM → not relevant for this use case + // Household timezones are typically within ±12h. Handle common case: + // If local date is the PREVIOUS day relative to alertDate, local is west of UTC + // (negative offset). The local time displayed (e.g. 21:00 for UTC-12) means we + // are 21h into the previous local day. The local midnight of alertDate is at + // naive_9am_utc - localMsSinceMidnight + (we haven't reached alertDate midnight yet) + // Actually: deviation = localMsSinceMidnight + 24h - desired9amMs when day is before alert + // deviation = localMsSinceMidnight - 24h - desired9amMs when day is after alert + + // Check if local date is day before or day after alertDate + const localDateMs = Date.UTC(localYear, localMonth - 1, localDay); + const alertDateMsForCompare = Date.UTC(ay, am - 1, ad); + const dayDiffDays = (alertDateMsForCompare - localDateMs) / (24 * 3600 * 1000); + + // dayDiff > 0: local day is before alert date (west of UTC, negative offset) + // dayDiff < 0: local day is after alert date (east of UTC, large positive offset) + deviationMs = localMsSinceMidnight + dayDiffDays * 24 * 3600 * 1000 - desired9amMs; + } + + // target_utc = naive_9am_utc - deviation + // (deviation > 0: local is ahead of desired, shift target earlier in UTC) + return new Date(naive9amUtcMs - deviationMs); +} + /** * WR-01: Extract the existing RRULE string from a stored VCALENDAR/VEVENT, so the * outbox worker can preserve recurrence on an edit whose payload omits it (the PWA @@ -153,6 +432,34 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr vevent.addProperty(rruleProp); } + // Optional: VALARM (Phase 11, CAL-13/CAL-14 — per-event reminders) + // + // Branch order: + // 1. Preserve path (D-08/CAL-14): if valarms[] non-empty, re-attach verbatim. + // reminderLeadMinutes is ignored — preserve wins over synthesize. + // 2. All-day new alarm: if allDay + reminderLeadMinutes != null + allDayAlertInstantUtc. + // Emits absolute DATE-TIME trigger (RFC 5545 §3.8.6.3). + // 3. Timed new alarm: if !allDay + reminderLeadMinutes > 0. + // Emits relative DURATION trigger. 0 on timed = None (D-06). + // + // No VALARM is emitted when: reminderLeadMinutes is null/undefined, or + // timed with leadMinutes=0, or no valarms and no explicit reminder. + if (params.valarms && params.valarms.length > 0) { + // Preserve path: re-attach existing VALARMs from rawVevent verbatim (D-08) + for (const alarm of params.valarms) { + vevent.addSubcomponent(alarm); + } + } else if (params.reminderLeadMinutes != null) { + if (params.allDay && params.allDayAlertInstantUtc) { + // All-day: absolute DATE-TIME trigger at 9 AM local on the computed alert day + vevent.addSubcomponent(buildAllDayValarm(params.allDayAlertInstantUtc)); + } else if (!params.allDay && params.reminderLeadMinutes > 0) { + // Timed: relative DURATION trigger; 0 = None (D-06) + vevent.addSubcomponent(buildTimedValarm(params.reminderLeadMinutes)); + } + // allDay=false && reminderLeadMinutes===0: no VALARM (timed 0 = None per D-06) + } + // Optional fields — included only when provided (no empty properties in ICS) if (params.location) { vevent.addPropertyWithValue('location', params.location); diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts index 0ed35f8..e94dec7 100644 --- a/apps/api/tests/broker/vevent.test.ts +++ b/apps/api/tests/broker/vevent.test.ts @@ -426,7 +426,9 @@ describe('extractValarms', () => { // Should be usable as a component — attach it to a new vevent const vevent = new ICAL.Component('vevent'); expect(() => vevent.addSubcomponent(alarm)).not.toThrow(); - expect(vevent.toString()).toContain('TRIGGER:-PT1H'); + // ical.js may normalize -PT60M to -PT1H or keep -PT60M — both are RFC-valid + const serialized = vevent.toString(); + expect(serialized).toMatch(/TRIGGER:-P(?:T60M|T1H)/); }); });