Files
native/src/lib/calendar-utils.ts
T

651 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
addDays,
differenceInCalendarDays,
parseISO,
startOfDay,
subMilliseconds,
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../api/types';
import { colors } from '../theme/tokens';
import type { TranslateFn } from '../stores/locale-store';
import { zonedWallTimeToUtc } from './recurrence-expansion';
import { getEffectiveTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
// ─── Duration ────────────────────────────────────────────
// Parse an ISO 8601 duration ("PT1H30M", "P2D", "PT45M") to milliseconds.
// Mobile's webmail equivalent lives in components/calendar/event-card; we
// inline it here so this lib has no UI deps.
export function parseDuration(iso: string | undefined): number {
if (!iso) return 0;
const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(iso);
if (!match) return 0;
const [, d, h, m, s] = match;
const days = d ? parseInt(d, 10) : 0;
const hours = h ? parseInt(h, 10) : 0;
const minutes = m ? parseInt(m, 10) : 0;
const seconds = s ? parseFloat(s) : 0;
return ((days * 24 + hours) * 3600 + minutes * 60 + seconds) * 1000;
}
// ─── Local-time parsing ──────────────────────────────────
// JSCalendar `start` strings are floating local time ("2026-04-20T09:00:00", no Z).
// Use parseISO so the returned Date represents the wall-clock value in local tz.
export function parseLocalDateTime(iso: string): Date {
return parseISO(iso);
}
// ─── Event time-range ────────────────────────────────────
/**
* Start of an event as a *display date* (calendar-timezone): a Date whose
* local getters read as the wall clock in the calendar's time zone, which is
* what the grid math, the detail sheet and the editor work with. The real
* instant while the calendar zone is the device zone; convert a picked time
* back with `fromDisplayDate`. All-day events are calendar dates and stay as
* they are. Mirrors the webmail's getEventStartDate.
*/
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime' | 'timeZone'>,
): Date {
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable — a malformed utcStart used to surface as an
// Invalid Date that silently dropped the event from every view (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
}
// Without utcStart, a start in a zone of its own is converted here; a
// floating one is a wall clock in the calendar's zone already.
if (!event.showWithoutTime && event.timeZone) {
const instant = localDateTimeToInstant(event.start, event.timeZone);
if (instant) return toDisplayDate(instant);
}
return parseISO(event.start);
}
// ─── Task due ────────────────────────────────────────────
// A task's `due` is a wall-clock time in its `timeZone` (a CalDAV DUE with a
// TZID keeps it). Resolve it to the instant, as utcStart does for events, so
// a 17:00 Europe/Berlin due shows at the viewer's local time. A floating
// timed due (no zone) is read in the calendar's time zone, the way Stalwart
// computes utcStart for floating events (every query carries that zone), so
// a floating task and a floating event at 17:00 land at the same instant.
// Date-only and all-day dues are calendar dates and stay as they are.
// This is the real instant (reminders fire at it); what the calendar shows
// is getTaskDueDisplayDate.
export function getTaskDueDate(
task: Pick<CalendarEvent, 'due' | 'timeZone' | 'showWithoutTime'>,
): Date | null {
if (!task.due) return null;
const wall = parseISO(task.due);
if (isNaN(wall.getTime())) return null;
if (isDateOnlyDue(task)) return wall;
return localDateTimeToInstant(task.due, task.timeZone || getEffectiveTimeZone())
?? zonedWallTimeToUtc(wall, task.timeZone || getEffectiveTimeZone())
?? wall;
}
/** A due without a time of day: a date-only value or an all-day task. */
export function isDateOnlyDue(task: Pick<CalendarEvent, 'due' | 'showWithoutTime'>): boolean {
return !!task.showWithoutTime || /^\d{4}-\d{2}-\d{2}$/.test(task.due ?? '');
}
/** A task's due as a display date (see getEventStartDate), for the tasks list and editor. */
export function getTaskDueDisplayDate(
task: Pick<CalendarEvent, 'due' | 'timeZone' | 'showWithoutTime'>,
): Date | null {
const due = getTaskDueDate(task);
if (!due || isDateOnlyDue(task)) return due;
return toDisplayDate(due);
}
/**
* A JSCalendar LocalDateTime moved by whole calendar days, wall clock kept:
* the same time of day in the same zone (duplicating an event one day
* later). Returned unchanged when it doesn't parse.
*/
export function addDaysToLocalDateTime(value: string, days: number): string {
const m = /^(\d{4})-(\d{2})-(\d{2})(.*)$/.exec(value);
if (!m) return value;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + days));
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}${m[4]}`;
}
/** End of an event as a display date - see getEventStartDate. */
export function getEventEndDate(event: CalendarEvent): Date {
// A timed event without a duration lasts zero seconds (RFC 8984's default
// PT0S). Stalwart stores a DTEND equal to DTSTART as no duration and gives
// the event a utcEnd of 23:59:59 on its start day, so it filled the rest
// of the day in the grid (#837).
if (!event.showWithoutTime && event.utcEnd && event.duration) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
}
const start = getEventStartDate(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDuration(event.duration));
}
export function getEventDisplayEndDate(event: CalendarEvent): Date {
const end = getEventEndDate(event);
const start = getEventStartDate(event);
if (!event.showWithoutTime || end.getTime() <= start.getTime()) {
return end;
}
return subMilliseconds(end, 1);
}
// ─── Time-of-day formatting ──────────────────────────────
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
// thread the `calendarTimeFormat` setting through so the calendar renders
// times in the same format the webmail does.
export type TimeFormat = '12h' | '24h';
export function timePattern(timeFormat: TimeFormat | undefined): string {
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
}
export interface EventTimeRange {
start: Date;
end: Date;
allDay: boolean;
}
export function eventTimeRange(event: CalendarEvent): EventTimeRange {
return {
start: getEventStartDate(event),
end: getEventEndDate(event),
allDay: !!event.showWithoutTime,
};
}
// ─── Day overlap ─────────────────────────────────────────
export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
const dayStart = startOfDay(day);
const nextDayStart = addDays(dayStart, 1);
return events.filter((event) => {
const start = getEventStartDate(event);
const end = getEventDisplayEndDate(event);
return end > dayStart && start < nextDayStart;
});
}
// ─── Day index (O(1) per-day lookup) ─────────────────────
// A month view touches 42 day cells. Calling `eventsOnDay` on each cell costs
// O(days × events) with two parseISO calls per event per day. For ~200 events
// that's ~16k parseISO calls per month render, redone on every swipe.
// `buildEventDayIndex` parses each event's start/end once, then walks only the
// days that event actually touches - typically 1 day. Result is a map from
// local-date key (yyyy-MM-dd) to the events on that day.
export type EventDayIndex = Map<string, CalendarEvent[]>;
export function dayKey(day: Date): string {
const y = day.getFullYear();
const m = (day.getMonth() + 1).toString().padStart(2, '0');
const d = day.getDate().toString().padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
const idx: EventDayIndex = new Map();
for (const event of events) {
const start = getEventStartDate(event);
const end = getEventDisplayEndDate(event);
let day = startOfDay(start);
// Cap at 366 days: matches the original semantics (end > dayStart) - we
// include day while day < end. Safety bound guards against malformed
// multi-year events that would otherwise blow up the loop.
let safety = 0;
while (day < end && safety < 366) {
const key = dayKey(day);
const arr = idx.get(key);
if (arr) arr.push(event);
else idx.set(key, [event]);
day = addDays(day, 1);
safety++;
}
}
// Sort each day's bucket: all-day events first, then timed events by start.
// Without this, events from different calendars come out in arrival order
// (roughly grouped by calendar) rather than chronologically.
for (const arr of idx.values()) {
arr.sort(compareEventsForDay);
}
return idx;
}
function compareEventsForDay(a: CalendarEvent, b: CalendarEvent): number {
const aAllDay = !!a.showWithoutTime;
const bAllDay = !!b.showWithoutTime;
if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
const aStart = getEventStartDate(a).getTime();
const bStart = getEventStartDate(b).getTime();
if (aStart !== bStart) return aStart - bStart;
// Tie-breaker on end (shorter events first), then title for stability.
const aEnd = getEventEndDate(a).getTime();
const bEnd = getEventEndDate(b).getTime();
if (aEnd !== bEnd) return aEnd - bEnd;
return (a.title ?? '').localeCompare(b.title ?? '');
}
export function eventsOnDayFromIndex(
index: EventDayIndex,
day: Date,
): CalendarEvent[] {
return index.get(dayKey(day)) ?? [];
}
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
return {
startDay: startOfDay(getEventStartDate(event)),
endDay: startOfDay(getEventDisplayEndDate(event)),
};
}
// ─── All-day duration helpers ────────────────────────────
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
if (!duration) return undefined;
const totalMs = parseDuration(duration);
const totalDays = Math.max(1, Math.ceil(totalMs / (24 * 60 * 60 * 1000)));
return `P${totalDays}D`;
}
export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
const dayCount = Math.max(
1,
differenceInCalendarDays(startOfDay(inclusiveEnd), startOfDay(start)) + 1,
);
return `P${dayCount}D`;
}
// ─── Per-day timed bounds (for week/day grids) ───────────
export interface TimedDayBounds {
startMinutes: number;
endMinutes: number;
continuesBefore: boolean;
continuesAfter: boolean;
}
function wallClockMinutes(date: Date): number {
return (
date.getHours() * 60 +
date.getMinutes() +
date.getSeconds() / 60 +
date.getMilliseconds() / 60000
);
}
export function getTimedEventBoundsForDay(
event: CalendarEvent,
day: Date,
): TimedDayBounds | null {
if (event.showWithoutTime) return null;
const eventStart = getEventStartDate(event);
const eventEnd = getEventEndDate(event);
const dayStart = startOfDay(day);
const nextDayStart = addDays(dayStart, 1);
if (eventEnd <= dayStart || eventStart >= nextDayStart) return null;
// Wall-clock minutes, not elapsed time since midnight: on a DST change day
// the day has 23 or 25 hours, and elapsed time would shift every event
// after the change by an hour.
const startMinutes =
eventStart > dayStart ? Math.floor(wallClockMinutes(eventStart)) : 0;
const endMinutes =
eventEnd < nextDayStart
? Math.max(startMinutes, Math.ceil(wallClockMinutes(eventEnd)))
: 1440;
return {
startMinutes,
endMinutes,
continuesBefore: eventStart < dayStart,
continuesAfter: eventEnd > nextDayStart,
};
}
// ─── Timed events that fill the day ──────────────────────
// Events shown in the timed grid that effectively span 00:00–24:00 on the
// given day get promoted to the all-day strip in the week/day views (matches
// webmail behavior - keeps the timed grid usable when an event covers the day).
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
}
// ─── Multi-day all-day segment packing (week strip) ──────
// Webmail draws multi-day all-day events as a single bar across the days they
// span. RN was previously rendering one chip per day, which broke continuity.
export interface CalendarWeekSegment {
event: CalendarEvent;
startIndex: number; // first day index in weekDays this segment covers
span: number; // number of days
row: number; // assigned row after packing
continuesBefore: boolean;
continuesAfter: boolean;
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
}
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || '').localeCompare(right.event.title || '');
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
}
export function buildWeekSegmentsRaw(
events: CalendarEvent[],
weekDays: Date[],
): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]);
const weekEnd = startOfDay(weekDays[weekDays.length - 1]);
return events.flatMap((event) => {
const { startDay, endDay } = getEventDayBounds(event);
if (endDay < weekStart || startDay > weekEnd) return [];
const segmentStart = startDay < weekStart ? weekStart : startDay;
const segmentEnd = endDay > weekEnd ? weekEnd : endDay;
const startIndex = differenceInCalendarDays(segmentStart, weekStart);
const span = differenceInCalendarDays(segmentEnd, segmentStart) + 1;
return [{
event,
startIndex,
span,
row: -1,
continuesBefore: startDay < weekStart,
continuesAfter: endDay > weekEnd,
} satisfies CalendarWeekSegment];
});
}
// Timed events that fill whole days of `weekDays` (consecutive days), as
// segments. Each event is only tested on the days it spans, not on every
// day: the freely scrolling week and day grids hand in months of days (#759).
export function buildTimedFullDayWeekSegments(
events: CalendarEvent[],
weekDays: Date[],
): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const firstDay = startOfDay(weekDays[0]);
const lastIndex = weekDays.length - 1;
const segments: CalendarWeekSegment[] = [];
const pushSegment = (event: CalendarEvent, startIndex: number, endIndex: number) => {
segments.push({
event,
startIndex,
span: endIndex - startIndex + 1,
row: -1,
continuesBefore: isTimedEventFullDayOnDate(event, addDays(weekDays[startIndex], -1)),
continuesAfter: isTimedEventFullDayOnDate(event, addDays(weekDays[endIndex], 1)),
});
};
for (const event of events) {
if (event.showWithoutTime) continue;
const from = Math.max(0, differenceInCalendarDays(getEventStartDate(event), firstDay));
const to = Math.min(lastIndex, differenceInCalendarDays(getEventEndDate(event), firstDay));
if (!(from <= to)) continue;
let runStart = -1;
for (let index = from; index <= to; index++) {
if (isTimedEventFullDayOnDate(event, weekDays[index])) {
if (runStart < 0) runStart = index;
} else if (runStart >= 0) {
pushSegment(event, runStart, index - 1);
runStart = -1;
}
}
if (runStart >= 0) pushSegment(event, runStart, to);
}
return segments;
}
// ─── Overlapping timed-event layout (cluster-based packing) ──
// Webmail rewrote this to track clusters: a new cluster starts when an event
// begins after every prior event has ended. Within a cluster every event gets
// the same `totalColumns`; across clusters, events get to use the full width
// they actually need. The previous global packing forced an event with no
// neighbors at 17:00 to share columns with a 9am cluster - visible squish.
export interface TimedEventLayout {
event: CalendarEvent;
column: number;
totalColumns: number;
startMinutes: number;
endMinutes: number;
continuesBefore: boolean;
continuesAfter: boolean;
}
export function layoutOverlappingEvents(
events: CalendarEvent[],
day: Date,
): TimedEventLayout[] {
const layoutInputs = events.flatMap((event) => {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds ? [{ event, ...bounds }] : [];
});
const sorted = layoutInputs.sort((a, b) => {
const diff = a.startMinutes - b.startMinutes;
if (diff !== 0) return diff;
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const result: TimedEventLayout[] = [];
let columns: { end: number }[][] = [];
let clusterStart = 0;
let clusterMaxEnd = 0;
const flushCluster = () => {
const total = columns.length;
for (let i = clusterStart; i < result.length; i++) {
result[i].totalColumns = total;
}
};
for (const item of sorted) {
if (columns.length > 0 && item.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every((e) => e.end <= item.startMinutes)) {
columns[col].push({ end: item.endMinutes });
result.push({ ...item, column: col, totalColumns: 0 });
placed = true;
break;
}
}
if (!placed) {
columns.push([{ end: item.endMinutes }]);
result.push({ ...item, column: columns.length - 1, totalColumns: 0 });
}
clusterMaxEnd = Math.max(clusterMaxEnd, item.endMinutes);
}
flushCluster();
return result;
}
// ─── Calendar color resolution ───────────────────────────
// Falls back to a deterministic palette slice when `color` is missing,
// mirroring webmail's behavior.
const CALENDAR_PALETTE = [
colors.calendar.blue,
colors.calendar.green,
colors.calendar.purple,
colors.calendar.orange,
colors.calendar.red,
colors.calendar.pink,
colors.calendar.teal,
colors.calendar.indigo,
] as const;
export const CALENDAR_COLOR_PALETTE = CALENDAR_PALETTE;
/**
* The name of a palette color ("Blue") for screen readers; any other color
* as it is written.
*/
export function calendarColorName(color: string, t: TranslateFn): string {
const index = CALENDAR_PALETTE.findIndex((c) => c.toLowerCase() === color.toLowerCase());
if (index < 0) return color;
// In palette order.
const names = [
t('email_viewer.color_tag.blue', 'Blue'),
t('email_viewer.color_tag.green', 'Green'),
t('email_viewer.color_tag.purple', 'Purple'),
t('email_viewer.color_tag.orange', 'Orange'),
t('email_viewer.color_tag.red', 'Red'),
t('email_viewer.color_tag.pink', 'Pink'),
t('calendar.colors.teal', 'Teal'),
t('calendar.colors.indigo', 'Indigo'),
];
return names[index];
}
function hashString(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
export function getCalendarColor(calendar: Pick<Calendar, 'id' | 'color'> | undefined): string {
if (!calendar) return CALENDAR_PALETTE[0];
if (calendar.color) return calendar.color;
return CALENDAR_PALETTE[hashString(calendar.id) % CALENDAR_PALETTE.length];
}
/**
* The fields of a calendar edit that differ from the calendar, and only
* those. Stalwart 1.0 accepts a colour only as #rgb, #rrggbb or a CSS name,
* so re-sending one another client wrote - Apple Calendar's 8-digit hex -
* failed every rename. The colour is compared with the one the editor
* started from (getCalendarColor).
*/
export function changedCalendarFields(
calendar: Pick<Calendar, 'id' | 'name' | 'color' | 'description'>,
values: { name: string; color: string; description: string },
): { name?: string; color?: string; description?: string | null } {
const changes: { name?: string; color?: string; description?: string | null } = {};
if (values.name !== calendar.name) changes.name = values.name;
if (values.color.toLowerCase() !== getCalendarColor(calendar).toLowerCase()) changes.color = values.color;
if (values.description !== (calendar.description ?? '')) changes.description = values.description || null;
return changes;
}
export function getEventColor(
event: Pick<CalendarEvent, 'calendarIds' | 'color'>,
calendars: Calendar[],
): string {
const calendarId = Object.keys(event.calendarIds || {})[0];
const cal = calendars.find((c) => c.id === calendarId);
// A local color override on a shared calendar wins over per-event colors,
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
if (cal?.colorIsLocalOverride && cal.color) return cal.color;
if (event.color) return event.color;
return getCalendarColor(cal);
}
// ─── Per-viewer colors for shared calendars (#345) ───────
// Shared calendars are recolored locally only: the viewer usually can't write
// the owner's calendar, and doing so would recolor it for everyone. Overrides
// live in the settings store keyed by sharedCalendarColorKey().
/**
* Stable key for a shared calendar's local color override. Built from the
* owning JMAP account + the calendar id so it stays unique across accounts.
*/
export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'accountId' | 'originalId'>,
): string {
// Keyed on the raw server id (like webmail) so the key doesn't depend on
// the store's `${accountId}:${id}` namespacing format.
return `${cal.accountId ?? ''}|${cal.originalId ?? cal.id}`;
}
/**
* Pick a random palette color not present in `usedColors`. Once every palette
* entry is taken, fall back to a random palette color (collisions are
* unavoidable past CALENDAR_PALETTE.length calendars).
*/
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const used = new Set<string>();
for (const c of usedColors) {
if (c) used.add(c.toLowerCase());
}
const available = CALENDAR_PALETTE.filter((c) => !used.has(c.toLowerCase()));
const pool = available.length > 0 ? available : CALENDAR_PALETTE;
return pool[Math.floor(Math.random() * pool.length)];
}
/**
* Apply each shared calendar's local color override (per-viewer recolor).
* The override replaces the calendar's color and wins over per-event colors
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
* are passed through untouched.
*/
export function applySharedCalendarColors(
calendars: Calendar[],
overrides: Record<string, string>,
): Calendar[] {
return calendars.map((cal) => {
if (!cal.isShared) return cal;
const override = overrides[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
});
}
export function getPrimaryCalendarId(
event: Pick<CalendarEvent, 'calendarIds'>,
): string | undefined {
return Object.keys(event.calendarIds || {})[0];
}
/**
* The event as the editor last had it: the stored event with the unsaved
* edits on top, a field cleared in the editor (sent as `null`) removed. Used
* to reopen the editor when the recurrence-scope question is cancelled.
*/
export function reopenedEdit(event: CalendarEvent, updates: Partial<CalendarEvent>): CalendarEvent {
const merged: Record<string, unknown> = { ...event };
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete merged[key];
else if (value !== undefined) merged[key] = value;
}
return merged as unknown as CalendarEvent;
}