mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
607 lines
23 KiB
TypeScript
607 lines
23 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 {
|
||
if (!event.showWithoutTime && event.utcEnd) {
|
||
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;
|
||
}
|
||
|
||
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;
|
||
|
||
const clippedStart = eventStart > dayStart ? eventStart : dayStart;
|
||
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart;
|
||
const startMinutes = Math.max(
|
||
0,
|
||
Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000),
|
||
);
|
||
const endMinutes = Math.min(
|
||
1440,
|
||
Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000),
|
||
);
|
||
|
||
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];
|
||
}
|
||
|
||
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];
|
||
}
|