classifyValarms: check sign of dur.toSeconds() before preset lookup.
Positive value = alarm fires after event (RFC 5545 TRIGGER:+PT15M or
TRIGGER;RELATED=END:PTNm) → return {kind:'custom'} for preserve path.
Compute leadMinutes as -seconds/60 (was Math.abs) for negative triggers.
Prevents alarm direction inversion: +PT15M was being stored as 15-min-before
lead and re-fired at dtstartUtc-15min — the opposite of the original intent.
478 lines
22 KiB
TypeScript
478 lines
22 KiB
TypeScript
/**
|
||
* VEVENT builder — constructs a valid VCALENDAR/VEVENT iCalendar string for PUT to Fastmail.
|
||
*
|
||
* D-13 contract (mirrors sync.ts parser in reverse):
|
||
* - All-day events: use ICAL.Time({ isDate: true }) → produces VALUE=DATE, no TZID, no time (D-13)
|
||
* - Timed events: use ICAL.Time.fromJSDate(date, true) (useUTC=true) → DTSTART:...Z suffix, no TZID
|
||
* - NEVER coerce DATE to DATETIME (Pitfall #3)
|
||
*
|
||
* T-03-03 (Tampering): ical.js ICAL.Component handles line-folding + escaping (commas, semicolons,
|
||
* newlines in summary/location/description). Never hand-roll ICS strings.
|
||
*
|
||
* Sources:
|
||
* - https://github.com/kewisch/ical.js/wiki/Convert-to-iCalendar-(rfc5545)
|
||
* - https://github.com/kewisch/ical.js/blob/main/lib/ical/component.js
|
||
* - https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js
|
||
*/
|
||
|
||
import ICAL from 'ical.js';
|
||
import { randomUUID } from 'crypto';
|
||
|
||
export interface NewEventParams {
|
||
uid?: string; // omit = generate new UUID (appended with @familysync)
|
||
summary: string;
|
||
allDay: boolean;
|
||
// All-day: YYYY-MM-DD string (or Date — only the date portion is used)
|
||
// Timed: JS Date representing a UTC instant
|
||
dtstart: string | Date;
|
||
dtend: string | Date;
|
||
location?: string;
|
||
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).
|
||
*
|
||
* IN-01 (v1 limitation — lossy round-trip): these presets map only to a BARE
|
||
* FREQ=DAILY|WEEKLY|MONTHLY|YEARLY. extractRruleString() preserves the FULL stored RECUR
|
||
* (which may carry BYDAY/INTERVAL/COUNT/UNTIL), and the preserve-on-edit path keeps that
|
||
* rich rule. But if a `recurrence` preset value is ever applied to a previously-rich rule,
|
||
* it COLLAPSES the rule to the bare preset — silently dropping BYDAY/INTERVAL/UNTIL/COUNT.
|
||
* This is acceptable in v1 only because the picker offers just these four bare presets and
|
||
* is disabled on edit (EventForm WR-01). When recurrence EDITING ships, do NOT replace the
|
||
* stored rule with a preset: parse the existing RECUR and modify it in place so qualifiers
|
||
* survive. See CR-01/WR-01 preserve paths for the precedent.
|
||
*/
|
||
export const RRULE_PRESETS: Record<string, string> = {
|
||
daily: 'FREQ=DAILY',
|
||
weekly: 'FREQ=WEEKLY',
|
||
monthly: 'FREQ=MONTHLY',
|
||
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 seconds = dur.toSeconds();
|
||
// WR-01 (Phase 11 Plan 05): positive seconds = alarm fires AFTER the event start
|
||
// (RFC 5545 TRIGGER:+PT15M or TRIGGER;RELATED=END:PT15M). This is a post-event alarm
|
||
// and cannot be expressed as a before-event lead. Classify as custom so the preserve
|
||
// path keeps the original VALARM rather than inverting the alarm direction.
|
||
if (seconds > 0) return { kind: 'custom' };
|
||
|
||
const leadMinutes = Math.round(-seconds / 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
|
||
* occurrence contract does not expose recurrence, D-03). Returns the RRULE value as a
|
||
* 'FREQ=…' string (e.g. 'FREQ=WEEKLY'), or undefined when the event has no RRULE or
|
||
* the input cannot be parsed.
|
||
*/
|
||
export function extractRruleString(rawVevent: string): string | undefined {
|
||
let parsed: ReturnType<typeof ICAL.parse>;
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||
parsed = ICAL.parse(rawVevent);
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||
const comp = new ICAL.Component(parsed);
|
||
const vevent = comp.getFirstSubcomponent('vevent');
|
||
if (!vevent) return undefined;
|
||
const rrule = vevent.getFirstPropertyValue('rrule');
|
||
if (!rrule) return undefined;
|
||
// ICAL.Recur#toString() yields the RECUR value, e.g. 'FREQ=WEEKLY'.
|
||
return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString();
|
||
}
|
||
|
||
/**
|
||
* Builds a VCALENDAR/VEVENT iCalendar string from form parameters.
|
||
*
|
||
* @returns { uid, icsString } — uid is the generated or provided UID;
|
||
* icsString is the full VCALENDAR string ready for PUT to Fastmail.
|
||
*/
|
||
export function buildVeventString(params: NewEventParams): { uid: string; icsString: string } {
|
||
const uid = params.uid ?? `${randomUUID()}@familysync`;
|
||
|
||
// --- VCALENDAR wrapper ---
|
||
const cal = new ICAL.Component(['vcalendar', [], []]);
|
||
cal.updatePropertyWithValue('version', '2.0');
|
||
cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN');
|
||
|
||
// --- VEVENT ---
|
||
const vevent = new ICAL.Component('vevent');
|
||
vevent.addPropertyWithValue('uid', uid);
|
||
vevent.addPropertyWithValue('summary', params.summary);
|
||
|
||
// DTSTAMP: always present (RFC 5545 §3.8.7.2 — required property)
|
||
const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true);
|
||
vevent.addPropertyWithValue('dtstamp', dtstamp);
|
||
|
||
if (params.allDay) {
|
||
// DATE value (not DATETIME) — isDate:true produces VALUE=DATE, no time component (D-13 contract)
|
||
const startStr =
|
||
typeof params.dtstart === 'string'
|
||
? params.dtstart
|
||
: params.dtstart.toISOString().slice(0, 10);
|
||
const endStr =
|
||
typeof params.dtend === 'string' ? params.dtend : params.dtend.toISOString().slice(0, 10);
|
||
|
||
const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number];
|
||
const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number];
|
||
|
||
// WR-04 (owning boundary): RFC-5545 §3.6.1 — DTEND for an all-day event is the
|
||
// EXCLUSIVE end date. Advance the user-entered inclusive end by one calendar day.
|
||
// Building a Date from UTC components ensures no DST ambiguity during the roll-over.
|
||
const endDate = new Date(Date.UTC(ey, em - 1, ed));
|
||
endDate.setUTCDate(endDate.getUTCDate() + 1);
|
||
const ey2 = endDate.getUTCFullYear();
|
||
const em2 = endDate.getUTCMonth() + 1;
|
||
const ed2 = endDate.getUTCDate();
|
||
|
||
// ICAL.Timezone.localTimezone is passed as the zone arg required by TS types.
|
||
// isDate:true suppresses any time/TZID output regardless of zone. (D-13)
|
||
const startTime = new ICAL.Time(
|
||
{ year: sy, month: sm, day: sd, isDate: true },
|
||
ICAL.Timezone.localTimezone,
|
||
);
|
||
const endTime = new ICAL.Time(
|
||
{ year: ey2, month: em2, day: ed2, isDate: true },
|
||
ICAL.Timezone.localTimezone,
|
||
);
|
||
vevent.addPropertyWithValue('dtstart', startTime);
|
||
vevent.addPropertyWithValue('dtend', endTime);
|
||
} else {
|
||
// DATETIME in UTC (useUTC=true → Z suffix; TZID is NOT added by ical.js) (D-13 contract)
|
||
const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true);
|
||
const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true);
|
||
vevent.addPropertyWithValue('dtstart', startTime);
|
||
vevent.addPropertyWithValue('dtend', endTime);
|
||
}
|
||
|
||
// Optional: RRULE (CAL-07 — whole-series recurring events only in v1, D-11)
|
||
// Must use ICAL.Recur.fromString + ICAL.Property to get correct serialization.
|
||
// addPropertyWithValue('rrule', string) serializes the string character-by-character
|
||
// instead of as a RECUR value type.
|
||
if (params.rruleString) {
|
||
const recur = ICAL.Recur.fromString(params.rruleString);
|
||
const rruleProp = new ICAL.Property('rrule');
|
||
rruleProp.setValue(recur);
|
||
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);
|
||
}
|
||
if (params.description) {
|
||
vevent.addPropertyWithValue('description', params.description);
|
||
}
|
||
|
||
cal.addSubcomponent(vevent);
|
||
|
||
return { uid, icsString: cal.toString() };
|
||
}
|