style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
* - .planning/phases/02-calendar-display/02-RESEARCH.md §Pattern 1 + §Code Examples
|
||||
*/
|
||||
|
||||
import ICAL from 'ical.js'
|
||||
import ICAL from 'ical.js';
|
||||
|
||||
/**
|
||||
* A concrete calendar event occurrence ready for UI consumption.
|
||||
@@ -36,36 +36,36 @@ import ICAL from 'ical.js'
|
||||
*/
|
||||
export interface CalendarOccurrence {
|
||||
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
|
||||
id: string
|
||||
uid: string
|
||||
calendarId: number
|
||||
calendarName: string
|
||||
id: string;
|
||||
uid: string;
|
||||
calendarId: number;
|
||||
calendarName: string;
|
||||
/**
|
||||
* The Fastmail user ID who owns the calendar this occurrence belongs to.
|
||||
* Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars.
|
||||
*/
|
||||
ownerUserId: number
|
||||
ownerUserId: number;
|
||||
/**
|
||||
* Display name of the calendar owner (users.displayName).
|
||||
* Null when the user has not set a display name.
|
||||
* The popover uses this to show the owner name for personal events;
|
||||
* falls back to calendarName when null.
|
||||
*/
|
||||
ownerName: string | null
|
||||
ownerName: string | null;
|
||||
/** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */
|
||||
color: string
|
||||
color: string;
|
||||
/** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */
|
||||
isShared: boolean
|
||||
title: string
|
||||
isShared: boolean;
|
||||
title: string;
|
||||
/** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T10:00:00-04:00[America/New_York]' */
|
||||
start: string
|
||||
start: string;
|
||||
/** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T11:00:00-04:00[America/New_York]' */
|
||||
end: string
|
||||
allDay: boolean
|
||||
location: string | null
|
||||
description: string | null
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
/** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
|
||||
hasRrule: boolean
|
||||
hasRrule: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,14 +73,14 @@ export interface CalendarOccurrence {
|
||||
* Carries color and ownership info from the calendars→users join.
|
||||
*/
|
||||
export interface OccurrenceMeta {
|
||||
calendarId: number
|
||||
calendarName: string
|
||||
ownerUserId: number
|
||||
calendarId: number;
|
||||
calendarName: string;
|
||||
ownerUserId: number;
|
||||
/** Display name of the calendar owner; null when users.displayName is not set */
|
||||
ownerName: string | null
|
||||
ownerName: string | null;
|
||||
/** Hex color: pre-computed by the route (users.color or shared-family constant) */
|
||||
color: string
|
||||
isShared: boolean
|
||||
color: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,9 +99,9 @@ export interface OccurrenceMeta {
|
||||
* across refetches/windows, so Schedule-X dedup and the popover id lookup keep matching.
|
||||
*/
|
||||
function makeOccurrenceId(uid: string, start: ICAL.Time): string {
|
||||
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
const epochMs = start.toJSDate().getTime()
|
||||
return `ev-${safeUid}-${epochMs}`
|
||||
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
const epochMs = start.toJSDate().getTime();
|
||||
return `ev-${safeUid}-${epochMs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +109,11 @@ function makeOccurrenceId(uid: string, start: ICAL.Time): string {
|
||||
* ICAL.Time.utcOffset() returns total seconds (positive = east of UTC).
|
||||
*/
|
||||
function formatUtcOffset(offsetSeconds: number): string {
|
||||
const sign = offsetSeconds < 0 ? '-' : '+'
|
||||
const abs = Math.abs(offsetSeconds)
|
||||
const hours = Math.floor(abs / 3600)
|
||||
const minutes = Math.floor((abs % 3600) / 60)
|
||||
return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
const sign = offsetSeconds < 0 ? '-' : '+';
|
||||
const abs = Math.abs(offsetSeconds);
|
||||
const hours = Math.floor(abs / 3600);
|
||||
const minutes = Math.floor((abs % 3600) / 60);
|
||||
return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,10 +128,10 @@ function formatUtcOffset(offsetSeconds: number): string {
|
||||
function serializeTime(t: ICAL.Time, allDay: boolean): string {
|
||||
if (allDay) {
|
||||
// All-day: return DATE-only string — never construct a datetime (Pitfall 2)
|
||||
const y = String(t.year)
|
||||
const m = String(t.month).padStart(2, '0')
|
||||
const d = String(t.day).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
const y = String(t.year);
|
||||
const m = String(t.month).padStart(2, '0');
|
||||
const d = String(t.day).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// Timed: build an IANA-annotated ISO string so the client can construct Temporal.ZonedDateTime.
|
||||
@@ -139,22 +139,22 @@ function serializeTime(t: ICAL.Time, allDay: boolean): string {
|
||||
// We always emit '...±HH:MM[IANA/Zone]' — the bracket is mandatory for ZonedDateTime.from().
|
||||
if (t.zone === ICAL.Timezone.utcTimezone) {
|
||||
// UTC zone: toString() produces '...Z'; strip the Z and emit +00:00[UTC].
|
||||
const base = t.toString().replace(/Z$/, '') // e.g. '2026-03-01T10:00:00'
|
||||
return `${base}+00:00[UTC]`
|
||||
const base = t.toString().replace(/Z$/, ''); // e.g. '2026-03-01T10:00:00'
|
||||
return `${base}+00:00[UTC]`;
|
||||
}
|
||||
|
||||
const tzid = t.zone?.tzid
|
||||
const tzid = t.zone?.tzid;
|
||||
// Floating zone (tzid === 'floating') has no real timezone — fall back to UTC.
|
||||
// This is a safe degradation: the event had no VTIMEZONE and no offset is knowable.
|
||||
if (!tzid || tzid === 'floating') {
|
||||
const base = t.toString() // no trailing Z for floating
|
||||
return `${base}+00:00[UTC]`
|
||||
const base = t.toString(); // no trailing Z for floating
|
||||
return `${base}+00:00[UTC]`;
|
||||
}
|
||||
|
||||
// Named IANA zone: combine base datetime + offset + IANA bracket.
|
||||
const base = t.toString() // e.g. '2026-03-01T10:00:00'
|
||||
const offsetSec = t.utcOffset()
|
||||
return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`
|
||||
const base = t.toString(); // e.g. '2026-03-01T10:00:00'
|
||||
const offsetSec = t.utcOffset();
|
||||
return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,53 +178,50 @@ export function expandOccurrences(
|
||||
isShared: boolean,
|
||||
): CalendarOccurrence[] {
|
||||
// --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
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)
|
||||
parsed = ICAL.parse(rawVevent);
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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 comp = new ICAL.Component(parsed);
|
||||
|
||||
// --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
|
||||
// Skipping this causes ical.js to fall back to UTC, producing ±1h DST errors (Pitfall 3).
|
||||
for (const vtz of comp.getAllSubcomponents('vtimezone')) {
|
||||
const tzid = vtz.getFirstPropertyValue('tzid') as string
|
||||
const tzid = vtz.getFirstPropertyValue('tzid') as string;
|
||||
if (tzid && !ICAL.TimezoneService.has(tzid)) {
|
||||
// TimezoneService.register(timezone, name?) — first arg is the Timezone object
|
||||
ICAL.TimezoneService.register(
|
||||
new ICAL.Timezone({ component: vtz, tzid }),
|
||||
tzid,
|
||||
)
|
||||
ICAL.TimezoneService.register(new ICAL.Timezone({ component: vtz, tzid }), tzid);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Get the VEVENT component ---
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) return []
|
||||
const vevent = comp.getFirstSubcomponent('vevent');
|
||||
if (!vevent) return [];
|
||||
|
||||
const event = new ICAL.Event(vevent)
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
|
||||
if (!dtstart) return []
|
||||
const event = new ICAL.Event(vevent);
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
|
||||
if (!dtstart) return [];
|
||||
|
||||
const allDay: boolean = dtstart.isDate
|
||||
const uid: string = event.uid ?? ''
|
||||
const allDay: boolean = dtstart.isDate;
|
||||
const uid: string = event.uid ?? '';
|
||||
|
||||
// useUTC=true: windowStart/windowEnd are absolute instants (midnight-UTC of the
|
||||
// requested dates). Interpreting them in UTC keeps the occurrence-window comparison
|
||||
// absolute. With useUTC=false ical.js used the SERVER's local timezone, shifting the
|
||||
// window by the server's offset and dropping evening occurrences near the window end
|
||||
// (e.g. a 17:45-04:00 event = 21:45Z fell past a day window whose end was shifted to 20:00).
|
||||
const rangeStart = ICAL.Time.fromJSDate(windowStart, true)
|
||||
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true)
|
||||
const rangeStart = ICAL.Time.fromJSDate(windowStart, true);
|
||||
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true);
|
||||
|
||||
const occurrences: CalendarOccurrence[] = []
|
||||
const occurrences: CalendarOccurrence[] = [];
|
||||
|
||||
// Capture once — used in both the non-recurring and recurring branches to populate hasRrule.
|
||||
const isRecurring = event.isRecurring()
|
||||
const isRecurring = event.isRecurring();
|
||||
|
||||
// --- 4. Non-recurring event: single occurrence check ---
|
||||
if (!isRecurring) {
|
||||
@@ -232,19 +229,19 @@ export function expandOccurrences(
|
||||
// Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
|
||||
// Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
|
||||
// producing zero-duration occurrences (BUG 1).
|
||||
let occEnd: ICAL.Time = event.endDate ?? dtstart
|
||||
let occEnd: ICAL.Time = event.endDate ?? dtstart;
|
||||
|
||||
// Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
|
||||
if (!allDay && occEnd.compare(dtstart) <= 0) {
|
||||
occEnd = dtstart.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'))
|
||||
occEnd = dtstart.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
|
||||
} else if (allDay && occEnd.compare(dtstart) <= 0) {
|
||||
occEnd = dtstart.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'))
|
||||
occEnd = dtstart.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'));
|
||||
}
|
||||
|
||||
const start = serializeTime(dtstart, allDay)
|
||||
const end = serializeTime(occEnd, allDay)
|
||||
const start = serializeTime(dtstart, allDay);
|
||||
const end = serializeTime(occEnd, allDay);
|
||||
occurrences.push({
|
||||
id: makeOccurrenceId(uid, dtstart),
|
||||
uid,
|
||||
@@ -261,36 +258,36 @@ export function expandOccurrences(
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
hasRrule: isRecurring, // always false in the non-recurring branch
|
||||
})
|
||||
});
|
||||
}
|
||||
return occurrences
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
// --- 5. Recurring event: use ICAL.RecurExpansion ---
|
||||
// RecurExpansion handles RRULE + RDATE + EXDATE internally — no manual EXDATE filtering (A1).
|
||||
// VTIMEZONE was registered in step 2 above, so DST occurrences get correct wall-clock time.
|
||||
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
|
||||
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
|
||||
|
||||
let next: ICAL.Time | null | undefined
|
||||
let next: ICAL.Time | null | undefined;
|
||||
while ((next = expand.next() as ICAL.Time | null | undefined) && next.compare(rangeEnd) < 0) {
|
||||
if (next.compare(rangeStart) < 0) continue
|
||||
if (next.compare(rangeStart) < 0) continue;
|
||||
|
||||
// Compute occurrence end from event duration
|
||||
const duration = event.duration
|
||||
let occEnd = next.clone()
|
||||
occEnd.addDuration(duration)
|
||||
const duration = event.duration;
|
||||
let occEnd = next.clone();
|
||||
occEnd.addDuration(duration);
|
||||
|
||||
// Positive-duration guard: same logic as non-recurring branch above.
|
||||
if (!allDay && occEnd.compare(next) <= 0) {
|
||||
occEnd = next.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'))
|
||||
occEnd = next.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
|
||||
} else if (allDay && occEnd.compare(next) <= 0) {
|
||||
occEnd = next.clone()
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'))
|
||||
occEnd = next.clone();
|
||||
occEnd.addDuration(ICAL.Duration.fromString('P1D'));
|
||||
}
|
||||
|
||||
const start = serializeTime(next, allDay)
|
||||
const end = serializeTime(occEnd, allDay)
|
||||
const start = serializeTime(next, allDay);
|
||||
const end = serializeTime(occEnd, allDay);
|
||||
|
||||
occurrences.push({
|
||||
id: makeOccurrenceId(uid, next),
|
||||
@@ -308,8 +305,8 @@ export function expandOccurrences(
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
hasRrule: isRecurring, // always true in the recurring branch
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return occurrences
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user