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, ): 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, ): 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): 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, ): 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; 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 | 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, 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, 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, ): 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 { const used = new Set(); 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, ): 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, ): 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 { const merged: Record = { ...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; }