Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19

Merged
luckberg merged 41 commits from gsd/phase-11-per-event-reminders into main 2026-06-14 14:06:45 -04:00
2 changed files with 310 additions and 1 deletions
Showing only changes of commit d9eb5c1875 - Show all commits
+307
View File
@@ -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 (5120 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<string, string> = {
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:<YYYYMMDDTHHMMSSZ> — 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<typeof ICAL.parse>;
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')); // 023 (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);
+3 -1
View File
@@ -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)/);
});
});