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
+4
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types";
import { push } from "@/jmap/push";
import { setServerLocale } from "@/lib/datetime";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -49,6 +50,7 @@ export const useSession = create<SessionState>((set, get) => ({
async logout() {
push.stop();
setServerLocale(null);
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
@@ -62,6 +64,7 @@ export const useSession = create<SessionState>((set, get) => ({
try {
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
set({ session: s });
} catch {
/* ignore */
@@ -83,6 +86,7 @@ export const useSession = create<SessionState>((set, get) => ({
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null });
}