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:
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
export type Density = "comfortable" | "cozy" | "compact";
|
||||
@@ -36,14 +37,16 @@ export interface Settings {
|
||||
notificationSound: boolean;
|
||||
attachmentReminder: boolean;
|
||||
weekStart: 0 | 1 | 6;
|
||||
timeFormat: "12" | "24" | "auto";
|
||||
/** "" = follow the mail server's locale, then the browser's. */
|
||||
locale: string;
|
||||
dateFormat: DateFormat;
|
||||
timeFormat: TimeFormat;
|
||||
calendarDefaultView: "month" | "week" | "day" | "agenda";
|
||||
workDayStart: number;
|
||||
workDayEnd: number;
|
||||
defaultEventDuration: number; // minutes
|
||||
defaultAlertMinutes: number;
|
||||
timeZone: string | null; // null = browser
|
||||
language: string;
|
||||
labelsSidebar: boolean;
|
||||
fontSize: "small" | "medium" | "large";
|
||||
templates: Template[];
|
||||
@@ -87,6 +90,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
notificationSound: false,
|
||||
attachmentReminder: true,
|
||||
weekStart: 1,
|
||||
locale: "",
|
||||
dateFormat: "auto",
|
||||
timeFormat: "auto",
|
||||
calendarDefaultView: "week",
|
||||
workDayStart: 8,
|
||||
@@ -94,7 +99,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
defaultEventDuration: 60,
|
||||
defaultAlertMinutes: 10,
|
||||
timeZone: null,
|
||||
language: "en",
|
||||
labelsSidebar: true,
|
||||
fontSize: "medium",
|
||||
templates: [],
|
||||
@@ -126,18 +130,23 @@ interface SettingsState {
|
||||
importJson(json: string): boolean;
|
||||
}
|
||||
|
||||
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
|
||||
applyDateTimePrefs(initialSettings);
|
||||
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
settings: loadJson<Settings>("settings", DEFAULT_SETTINGS),
|
||||
settings: initialSettings,
|
||||
update(patch) {
|
||||
const settings = { ...get().settings, ...patch };
|
||||
saveJson("settings", settings);
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
applyDateTimePrefs(settings);
|
||||
},
|
||||
reset() {
|
||||
saveJson("settings", DEFAULT_SETTINGS);
|
||||
set({ settings: DEFAULT_SETTINGS });
|
||||
applyTheme(DEFAULT_SETTINGS);
|
||||
applyDateTimePrefs(DEFAULT_SETTINGS);
|
||||
},
|
||||
exportJson() {
|
||||
return JSON.stringify(get().settings, null, 2);
|
||||
@@ -153,6 +162,10 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function applyDateTimePrefs(s: Settings): void {
|
||||
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||
}
|
||||
|
||||
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
||||
const root = document.documentElement;
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||
@@ -171,3 +184,9 @@ if (typeof window !== "undefined") {
|
||||
}
|
||||
|
||||
export const settings = () => useSettings.getState().settings;
|
||||
|
||||
/**
|
||||
* Primitive that changes whenever a date/time preference does, so memoised
|
||||
* components that render dates re-render when the format is switched.
|
||||
*/
|
||||
export const dateTimeKey = (s: Settings): string => `${s.locale}|${s.dateFormat}|${s.timeFormat}`;
|
||||
|
||||
Reference in New Issue
Block a user