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
+18 -16
View File
@@ -1,4 +1,12 @@
const rtf = typeof Intl !== "undefined" && "RelativeTimeFormat" in Intl ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null;
import {
formatClock,
formatDate,
formatDayMonth,
formatFullDateTime,
formatMonthYear as fmtMonthYear,
formatWeekday,
relativeFormat,
} from "./datetime";
export function formatSize(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes)) return "";
@@ -22,24 +30,17 @@ export function formatListDate(iso: string | null | undefined, now = new Date())
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
if (isSameDay(d, now)) return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
if (isSameDay(d, now)) return formatClock(d);
if (d.getFullYear() === now.getFullYear()) return formatDayMonth(d);
return formatDate(d);
}
/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" */
/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" or "Sa., 22.08.2026 15:14" */
export function formatFullDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return formatFullDateTime(d);
}
export function formatRelative(iso: string | null | undefined, now = new Date()): string {
@@ -47,6 +48,7 @@ export function formatRelative(iso: string | null | undefined, now = new Date())
const d = new Date(iso);
const diff = (d.getTime() - now.getTime()) / 1000;
const abs = Math.abs(diff);
const rtf = relativeFormat();
if (!rtf) return formatListDate(iso, now);
if (abs < 60) return rtf.format(Math.round(diff), "second");
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
@@ -56,15 +58,15 @@ export function formatRelative(iso: string | null | undefined, now = new Date())
}
export function formatDateShort(d: Date): string {
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
return `${formatWeekday(d)}, ${formatDayMonth(d)}`;
}
export function formatTime(d: Date): string {
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
return formatClock(d);
}
export function formatMonthYear(d: Date): string {
return d.toLocaleDateString(undefined, { month: "long", year: "numeric" });
return fmtMonthYear(d);
}
export function plural(n: number, one: string, many = `${one}s`): string {