mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
651 lines
25 KiB
TypeScript
651 lines
25 KiB
TypeScript
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;
|
||
}
|