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
@@ -2,6 +2,28 @@ import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
import {
browserLocale,
formatClock,
formatDate,
formatFullDateTime,
getServerLocale,
localeLabel,
localeOptions,
withPrefs,
type DateFormat,
} from "@/lib/datetime";
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
const SAMPLE = new Date(2025, 10, 22, 18, 23);
const DATE_FORMATS: Array<{ value: DateFormat; label: string }> = [
{ value: "auto", label: "Automatic" },
{ value: "dmy-dot", label: "Day.Month.Year" },
{ value: "dmy-slash", label: "Day/Month/Year" },
{ value: "mdy-slash", label: "Month/Day/Year" },
{ value: "ymd-dash", label: "Year-Month-Day (ISO 8601)" },
];
export function GeneralSettings() {
const s = useSettings((st) => st.settings);
@@ -9,6 +31,8 @@ export function GeneralSettings() {
const reset = useSettings((st) => st.reset);
const exportJson = useSettings((st) => st.exportJson);
const importJson = useSettings((st) => st.importJson);
const serverLocale = getServerLocale();
const autoLocale = serverLocale ?? browserLocale();
return (
<div>
@@ -100,6 +124,35 @@ export function GeneralSettings() {
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>Language &amp; region</label>
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
<option value="">Automatic ({localeLabel(autoLocale)})</option>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
</select>
<p className="hint">{serverLocale ? `Your mail server reports ${localeLabel(serverLocale)} (${serverLocale}).` : "Your mail server does not report a locale, so the browser's is used."} Dates, times and month names follow this choice.</p>
</div>
<div className="field">
<label>Date format</label>
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
{DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>
{f.label} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
</option>
))}
</select>
</div>
<div className="field">
<label>Time format</label>
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
<option value="auto">Automatic ({withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE))})</option>
<option value="24">24-hour clock (18:23)</option>
<option value="12">12-hour clock (6:23 PM)</option>
</select>
</div>
</div>
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
<h2>Backup</h2>
<div className="row wrap">
@@ -113,3 +166,4 @@ export function GeneralSettings() {
</div>
);
}
+19 -2
View File
@@ -3,6 +3,8 @@ import { useMail } from "@/store/mail";
import { Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
import { formatFullDateTime } from "@/lib/datetime";
import { dateTimeKey, useSettings } from "@/store/settings";
import { client, CAP } from "@/jmap/client";
export function VacationSettings() {
@@ -16,6 +18,13 @@ export function VacationSettings() {
const [to, setTo] = useState("");
const [busy, setBusy] = useState(false);
const available = client.hasCapability(CAP.vacation);
// The date pickers themselves are native controls and follow the browser's
// locale; echo the value back in the user's chosen format so there is no doubt.
useSettings((s) => dateTimeKey(s.settings));
const echo = (v: string) => {
const d = fromInputDateTime(v);
return v && !Number.isNaN(d.getTime()) ? formatFullDateTime(d) : "";
};
useEffect(() => {
void load();
@@ -56,8 +65,16 @@ export function VacationSettings() {
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
<div className="field-row mt-16">
<div className="field"><label>Starts (optional)</label><input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} /></div>
<div className="field"><label>Ends (optional)</label><input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} /></div>
<div className="field">
<label>Starts (optional)</label>
<input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} />
{echo(from) && <p className="hint">{echo(from)}</p>}
</div>
<div className="field">
<label>Ends (optional)</label>
<input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} />
{echo(to) && <p className="hint">{echo(to)}</p>}
</div>
</div>
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until and will reply when I'm back." /></div>