Files
ihasmail/web/src/views/calendar/CalendarContextMenu.tsx
T
jcoffey-dev d82ff15921 Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by
three settings (Settings > General > Locale):

- Language & region: automatic, or any of the 618 locales CLDR has data for,
  each named in its own language and script (web/src/lib/locales.ts, generated
  by probing Intl over the subtag space).
- Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025,
  or ISO 8601 2025-11-22.
- Time format: automatic (locale), 24-hour, or 12-hour.

Automatic takes the locale Stalwart has for the account, read best-effort at
login via x:Account/get (urn:stalwart:jmap) and passed to the client in the
session; servers without the capability, or that deny sysAccountGet to a
regular user, fall back to the browser locale. POSIX forms are normalised
(de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS,
uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are
dropped and a script the locale already implies is not appended.

Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under
ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so
one line never mixes digit systems.

Rewired: message list and headers, quoted reply headers, calendar (titles,
weekday and hour gutters, mini calendar, agenda, popovers, invite cards,
free/busy), contacts, files, sessions. No raw toLocale*String date calls are
left in web/src.

Native <input type="datetime-local"> pickers always follow the browser locale
and cannot be restyled by a page, so the out-of-office fields echo the entered
instant in the chosen format underneath.

Also: month-grid day labels no longer wrap when they hold a date, and the mock
server serves x:Account/get (MOCK_LOCALE, default en_US).

Closes #1
2026-08-23 12:32:11 -07:00

131 lines
6.9 KiB
TypeScript

import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
import { useLocation } from "wouter";
import type { CalendarEvent } from "@/jmap/types";
import { useCalendar, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { formatDayMonth } from "@/lib/datetime";
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
import { CALENDAR_COLORS } from "@/ui/misc";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { toLocalDateOnly } from "@/lib/dates";
import { formatTime } from "@/lib/format";
export type CalendarContext =
| { kind: "event"; inst: EventInstance; anchor: Anchor }
| { kind: "slot"; start: Date; end: Date; allDay: boolean; anchor: Anchor };
interface Props {
ctx: CalendarContext;
onClose: () => void;
onOpen: (inst: EventInstance, anchor: Anchor) => void;
onEdit: (inst: EventInstance) => void;
onCreate: (start: Date, end: Date, allDay: boolean) => void;
}
/** Resolve the display colour of an event: explicit colour → category colour → calendar colour. */
export function eventColor(ev: CalendarEvent, calendarColor: string | null | undefined, categories: Array<{ name: string; color: string }>): string {
if (ev.color) return ev.color;
const cat = categoryOf(ev, categories);
if (cat) return cat.color;
return calendarColor ?? "var(--accent)";
}
export function categoryOf(ev: CalendarEvent, categories: Array<{ name: string; color: string }>): { name: string; color: string } | undefined {
const names = Object.keys(ev.categories ?? {});
for (const n of names) {
const c = categories.find((x) => x.name.toLowerCase() === n.toLowerCase());
if (c) return c;
}
return undefined;
}
export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: Props) {
const cal = useCalendar();
const [, navigate] = useLocation();
const categories = useSettings((s) => s.settings.eventCategories);
if (ctx.kind === "slot") {
const { start, end, allDay } = ctx;
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
<MenuSep />
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label="Go to week" onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
</Popover>
);
}
const { inst } = ctx;
const ev = inst.event;
const baseId = ev.baseEventId ?? ev.id;
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
const currentCat = categoryOf(ev, categories);
const participants = Object.keys(ev.participants ?? {}).length;
const patch = async (p: Record<string, unknown>, msg: string) => {
try {
await cal.updateEvent(baseId, p, false);
toast.success(msg);
} catch (err) {
toast.error((err as Error).message);
}
};
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Colour reset");
const setCategory = (cat: { name: string; color: string } | null) => {
const categoriesPatch = cat ? { [cat.name]: true } : null;
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
};
const duplicate = async () => {
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
try {
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success("Event duplicated");
} catch (err) {
toast.error((err as Error).message);
}
};
const del = async () => {
onClose();
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
try {
await cal.destroyEvent(baseId, participants > 1);
toast.success("Event deleted");
} catch (err) {
toast.error((err as Error).message);
}
};
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
{canEdit && (
<>
<MenuSep />
<MenuTitle><span className="row gap-4"><Tag size={12} /> Category</span></MenuTitle>
{categories.map((c) => (
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
))}
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button key={c} type="button" style={{ background: c, width: 26, height: 26, outline: ev.color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }} aria-label={c} onClick={() => { onClose(); setColor(c); }} />
))}
</div>
{ev.color && <MenuItem icon={<X size={16} />} label="Use calendar colour" onClick={() => { onClose(); setColor(null); }} />}
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
</>
)}
</Popover>
);
}