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
This commit is contained in:
2026-08-23 12:32:11 -07:00
parent de1b33e2aa
commit d82ff15921
22 changed files with 920 additions and 58 deletions
@@ -3,6 +3,7 @@ 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";
@@ -48,7 +49,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
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 ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
<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)}`)} />
+4 -5
View File
@@ -2,9 +2,10 @@ import { useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
import { formatMonthYear } from "@/lib/format";
import { formatWeekday } from "@/lib/datetime";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
@@ -16,6 +17,7 @@ export function CalendarSidebar() {
const [location, navigate] = useLocation();
const cal = useCalendar();
const weekStart = useSettings((s) => s.settings.weekStart);
const locale = useSettings((s) => dateTimeKey(s.settings));
const parts = location.split("/");
const view = parts[2] || "week";
const dateStr = parts[3];
@@ -27,10 +29,7 @@ export function CalendarSidebar() {
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(null);
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
const dow = useMemo(() => {
const names = ["S", "M", "T", "W", "T", "F", "S"];
return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]);
}, [weekStart]);
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]);
if (!cal.available) return null;
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
+10 -9
View File
@@ -5,6 +5,7 @@ import { useCalendar, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
import { formatMonthYear, formatTime } from "@/lib/format";
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
import { Empty, useIsMobile } from "@/ui/misc";
import { keyboard } from "@/lib/keyboard";
import { EventPopover } from "./EventPopover";
@@ -95,9 +96,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
const title =
effectiveView === "month" ? formatMonthYear(anchor)
: effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`
: effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
: `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
: effectiveView === "week" ? `${formatDayMonth(range.start)} ${formatDate(addDays(range.end, -1))}`
: effectiveView === "day" ? formatWeekdayDate(anchor, true)
: `Agenda from ${formatDayMonth(anchor)}`;
const onEvent = (inst: EventInstance, el: Element) => {
const r = el.getBoundingClientRect();
@@ -152,7 +153,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" }));
const dow = weeks[0]!.map((d) => formatWeekday(d));
const maxPer = 4;
return (
<div className="month-grid">
@@ -165,7 +166,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
const shown = evs.slice(0, maxPer);
return (
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
</div>
@@ -244,7 +245,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
<div />
{days.map((d) => (
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
<div className="dow">{formatWeekday(d)}</div>
<div className="dnum">{d.getDate()}</div>
</div>
))}
@@ -260,7 +261,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
<div className="week-scroll" ref={scrollRef}>
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
<div className="time-col">
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
</div>
{days.map((d) => {
const evs = layoutOverlaps(timed(d), d);
@@ -400,8 +401,8 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
{byDay.map(({ day, items }) => (
<div key={day.toISOString()} className="agenda-day">
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
{day.toLocaleDateString(undefined, { weekday: "long" })}
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
{formatWeekday(day, "long")}
<small>{formatDateLong(day, false)}</small>
</div>
<div>
{items.map((i) => (
+4 -3
View File
@@ -10,6 +10,7 @@ import { ColorSwatches, Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { RecipientInput } from "../compose/RecipientInput";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
@@ -228,7 +229,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly on {start.toLocaleDateString(undefined, { weekday: "long" })}</option>
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
<option value="weekdays">Every weekday</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="yearly">Yearly</option>
@@ -282,7 +283,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {start.toLocaleDateString()}</div>
<div className="hint">Availability on {formatNumericDate(start)}</div>
{attendees.filter((a) => fb[a.email]).map((a) => (
<div key={a.email} className="fb-row">
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
@@ -291,7 +292,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
if (be <= bs) return null;
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${new Date(b.utcStart).toLocaleTimeString()} ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} ${formatClock(new Date(b.utcEnd))}`} />;
})}
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
</div>