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:
@@ -5,6 +5,7 @@ import { useContacts } from "@/store/contacts";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import type { AddressBook, ContactCard } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
||||
import { formatDate, formatDateLong } from "@/lib/datetime";
|
||||
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
@@ -237,14 +238,14 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
|
||||
</div>
|
||||
)}
|
||||
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
|
||||
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {new Date(c.updated).toLocaleDateString()}</p>}
|
||||
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {formatDate(new Date(c.updated))}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPartial(d: { year?: number; month?: number; day?: number; utc?: string }): string {
|
||||
if (d.utc) return new Date(d.utc).toLocaleDateString();
|
||||
if (d.year && d.month && d.day) return new Date(d.year, d.month - 1, d.day).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
if (d.month && d.day) return new Date(2000, d.month - 1, d.day).toLocaleDateString(undefined, { month: "long", day: "numeric" });
|
||||
if (d.utc) return formatDate(new Date(d.utc));
|
||||
if (d.year && d.month && d.day) return formatDateLong(new Date(d.year, d.month - 1, d.day));
|
||||
if (d.month && d.day) return formatDateLong(new Date(2000, d.month - 1, d.day), false);
|
||||
return [d.year, d.month, d.day].filter(Boolean).join("-");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user