diff --git a/README.md b/README.md index 79c5251..9fab876 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a - Browse folders, upload (drag & drop), download, create folders, rename, move, delete **Settings** +- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits - Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings **Platform** @@ -64,10 +65,10 @@ browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ─ JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image ``` -- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates, vCard, …). +- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …). - `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos. -Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push. Features degrade gracefully when a capability is missing. +Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale). Features degrade gracefully when a capability is missing. ## Quick start (Docker) @@ -129,6 +130,8 @@ Verified against the mock server and, for the core mail flows, against a live St - **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server. - **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). +- Date **pickers** (`` in the event editor and out-of-office settings) are native browser controls and always follow the browser's own locale — no page can restyle them. The chosen format is echoed underneath the out-of-office fields so the entered instant is unambiguous. +- The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand. ## Roadmap / not yet diff --git a/server/src/app.ts b/server/src/app.ts index 78a7828..9fded67 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,6 +11,7 @@ import { expandTemplate, fetchUpstreamSession, forgetUpstreamSession, + getAccountLocale, getUpstreamSession, localizeSession, } from "./upstream.js"; @@ -170,7 +171,8 @@ export function createApp(): Hono { ip, }); setSessionCookie(c, cookie, session.remember); - return c.json(localizeSession(upstream, sessionExtras(session))); + const locale = await getAccountLocale(session.id, session.authorization, upstream); + return c.json(localizeSession(upstream, sessionExtras(session, locale))); } catch (err) { return upstreamFailure(c, err); } @@ -180,7 +182,8 @@ export function createApp(): Hono { const session = c.get("session"); try { const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1"); - return c.json(localizeSession(upstream, sessionExtras(session))); + const locale = await getAccountLocale(session.id, session.authorization, upstream); + return c.json(localizeSession(upstream, sessionExtras(session, locale))); } catch (err) { if (err instanceof UpstreamError && err.status === 401) { sessions.destroy(session.id); @@ -350,7 +353,7 @@ export function createApp(): Hono { return app; } -function sessionExtras(session: LiveSession) { +function sessionExtras(session: LiveSession, userLocale: string | null = null) { return { ihasmail: { appName: config.appName, @@ -359,6 +362,8 @@ function sessionExtras(session: LiveSession) { sessionId: session.id, loginName: session.username, remember: session.remember, + /** Locale configured for the account in Stalwart's directory, if readable. */ + userLocale, }, }; } diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index ca98d99..0740de1 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -9,6 +9,8 @@ import { randomUUID } from "node:crypto"; const PORT = Number(process.env.MOCK_PORT ?? 8788); const ACCOUNT = "a1"; const USER = process.env.MOCK_USER ?? "demo@example.com"; +/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ +const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; const PASS = process.env.MOCK_PASS ?? "demo"; type Obj = Record; @@ -254,6 +256,12 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { } const handlers: Record = { + // Stalwart's directory extension - the client reads the account locale from here. + "x:Account/get": (a) => { + const ids = (a.ids as string[] | null) ?? [ACCOUNT]; + const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null })); + return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) }; + }, "Mailbox/get": genericGet(mailboxes), "Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; }, "Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }), @@ -352,9 +360,9 @@ function readBody(req: IncomingMessage): Promise { } const session = () => ({ - capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} }, + capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} }, accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } }, - primaryAccounts: Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), + primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), "urn:stalwart:jmap": ACCOUNT }, username: USER, apiUrl: `http://127.0.0.1:${PORT}/jmap/`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, diff --git a/server/src/sessions.test.ts b/server/src/sessions.test.ts index 3a0bdd7..91ae914 100644 --- a/server/src/sessions.test.ts +++ b/server/src/sessions.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { SessionStore } from "./sessions.js"; +import { normalizeLocale } from "./upstream.js"; import { deriveKey, open, seal, sha256 } from "./crypto.js"; import { RateLimiter } from "./ratelimit.js"; import { randomBytes } from "node:crypto"; @@ -46,3 +47,19 @@ test("rate limiter blocks after max hits in window", () => { rl.reset("k"); assert.equal(rl.check("k"), true); }); + +test("normalizes Stalwart account locales to BCP-47 tags", () => { + assert.equal(normalizeLocale("de_DE"), "de-DE"); + assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE"); + assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES"); + assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS"); + assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ"); + assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU"); + assert.equal(normalizeLocale("en"), "en"); + assert.equal(normalizeLocale("POSIX"), null); + assert.equal(normalizeLocale("C"), null); + assert.equal(normalizeLocale(""), null); + assert.equal(normalizeLocale(undefined), null); + assert.equal(normalizeLocale({ locale: "de_DE" }), null); + assert.equal(normalizeLocale("../etc/passwd"), null); +}); diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 87fa0e3..9a12ed1 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -59,6 +59,100 @@ export async function getUpstreamSession(sessionId: string, authorization: strin export function forgetUpstreamSession(sessionId: string): void { sessionCache.delete(sessionId); + localeCache.delete(sessionId); +} + +/* ------------------------------------------------------------------ */ +/* Account locale */ +/* ------------------------------------------------------------------ */ + +const STALWART_CAP = "urn:stalwart:jmap"; +const JMAP_CORE = "urn:ietf:params:jmap:core"; +const localeCache = new Map(); +const LOCALE_CACHE_MS = 30 * 60_000; + +/** + * glibc modifiers that name a script rather than a dialect or a currency: + * "sr_RS@latin" is Latin Serbian (sr-Latn-RS), not sr-RS. Anything not listed + * here (@valencia, @saaho, @euro …) carries no script and is dropped. + */ +const SCRIPT_MODIFIERS: Record = { + latin: "Latn", + latn: "Latn", + cyrillic: "Cyrl", + cyrl: "Cyrl", + devanagari: "Deva", + iqtelif: "Latn", +}; + +/** + * Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag + * ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for + * anything that does not look like a language tag. + */ +export function normalizeLocale(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const [head, modifier] = raw.trim().split("@"); + const base = head!.split(".")[0]!.replace(/_/g, "-"); + if (!base || base === "C" || base.toUpperCase() === "POSIX") return null; + if (!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$/.test(base)) return null; + const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined; + try { + const [canonical] = Intl.getCanonicalLocales(base); + if (!canonical) return null; + if (!script) return canonical; + const loc = new Intl.Locale(canonical); + // Adding the script only helps when it differs from the one the locale + // already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU). + const implied = loc.script ?? loc.maximize().script; + return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString(); + } catch { + return null; + } +} + +/** + * Best-effort lookup of the locale configured for this account in Stalwart's + * directory (`x:Account/get`, Stalwart's JMAP extension). Servers that do not + * expose it — or that deny a regular user the `sysAccountGet` permission — + * simply yield null and the client falls back to the browser locale. + */ +async function fetchAccountLocale(authorization: string, session: UpstreamSession): Promise { + if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null; + const accountId = + session.primaryAccounts?.[STALWART_CAP] ?? + session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? + Object.keys(session.accounts ?? {})[0]; + if (!accountId) return null; + const res = await fetch(absoluteUpstream(session.apiUrl), { + method: "POST", + headers: { authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + using: [JMAP_CORE, STALWART_CAP], + methodCalls: [["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "l"]], + }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (!res.ok) return null; + const body = (await res.json()) as { methodResponses?: [string, Record, string][] }; + const call = body.methodResponses?.[0]; + if (!call || call[0] !== "x:Account/get") return null; + const list = call[1]?.list; + if (!Array.isArray(list) || !list.length) return null; + return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale); +} + +export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise { + const cached = localeCache.get(sessionId); + if (cached && Date.now() - cached.fetchedAt < LOCALE_CACHE_MS) return cached.locale; + let locale: string | null = null; + try { + locale = await fetchAccountLocale(authorization, session); + } catch { + /* the server locale is a nicety - never fail the session over it */ + } + localeCache.set(sessionId, { locale, fetchedAt: Date.now() }); + return locale; } /** diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 474dbd0..79b5bd1 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -30,6 +30,8 @@ export interface JmapSession { sessionId: string; loginName: string; remember: boolean; + /** Locale configured for the account in Stalwart, if the server exposes it. */ + userLocale?: string | null; }; } diff --git a/web/src/lib/__tests__/datetime.test.ts b/web/src/lib/__tests__/datetime.test.ts new file mode 100644 index 0000000..5c9f5e0 --- /dev/null +++ b/web/src/lib/__tests__/datetime.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + formatClock, + formatDate, + formatDateTime, + formatDayMonth, + formatFullDateTime, + formatHourLabel, + localeOptions, + normalizeLocale, + resolvedLocale, + setDateTimePrefs, + setServerLocale, + uses24Hour, + withPrefs, +} from "../datetime"; + +const SAMPLE = new Date(2025, 10, 22, 18, 23, 45); // Sat 22 Nov 2025, 18:23 local + +afterEach(() => { + setDateTimePrefs({ locale: "", dateFormat: "auto", timeFormat: "auto" }); + setServerLocale(null); +}); + +describe("normalizeLocale", () => { + it("converts POSIX locales to BCP-47", () => { + expect(normalizeLocale("de_DE")).toBe("de-DE"); + expect(normalizeLocale("de_DE.UTF-8")).toBe("de-DE"); + expect(normalizeLocale("ca_ES@valencia")).toBe("ca-ES"); + expect(normalizeLocale("en_US.UTF-8@euro")).toBe("en-US"); + }); + it("keeps script modifiers that change the locale", () => { + expect(normalizeLocale("sr_RS@latin")).toBe("sr-Latn-RS"); + expect(normalizeLocale("uz_UZ@cyrillic")).toBe("uz-Cyrl-UZ"); + expect(normalizeLocale("tt_RU@iqtelif")).toBe("tt-Latn-RU"); + // …and drops the ones that name a dialect, variant or currency instead. + expect(normalizeLocale("ca_ES@valencia")).toBe("ca-ES"); + expect(normalizeLocale("de_DE@euro")).toBe("de-DE"); + expect(normalizeLocale("aa_ER@saaho")).toBe("aa-ER"); + }); + it("does not add a script the locale already has", () => { + expect(normalizeLocale("ru_RU@cyrillic")).toBe("ru-RU"); + expect(normalizeLocale("de_DE@latin")).toBe("de-DE"); + }); + it("rejects locale-less and invalid values", () => { + expect(normalizeLocale("POSIX")).toBeNull(); + expect(normalizeLocale("C")).toBeNull(); + expect(normalizeLocale("")).toBeNull(); + expect(normalizeLocale(null)).toBeNull(); + expect(normalizeLocale("not a locale!")).toBeNull(); + }); +}); + +describe("locale resolution", () => { + it("prefers the explicit setting over the server locale", () => { + setServerLocale("de_DE"); + expect(resolvedLocale()).toBe("de-DE"); + setDateTimePrefs({ locale: "fr-FR" }); + expect(resolvedLocale()).toBe("fr-FR"); + }); + it("falls back to the browser when nothing is configured", () => { + expect(resolvedLocale()).toBeUndefined(); + }); +}); + +describe("explicit date formats", () => { + it("formats German dotted dates", () => { + setDateTimePrefs({ dateFormat: "dmy-dot", timeFormat: "24" }); + expect(formatDate(SAMPLE)).toBe("22.11.2025"); + expect(formatDayMonth(SAMPLE)).toBe("22.11."); + expect(formatClock(SAMPLE)).toBe("18:23"); + expect(formatDateTime(SAMPLE)).toBe("22.11.2025 18:23"); + }); + it("formats ISO 8601 dates", () => { + setDateTimePrefs({ dateFormat: "ymd-dash", timeFormat: "24" }); + expect(formatDate(SAMPLE)).toBe("2025-11-22"); + expect(formatDayMonth(SAMPLE)).toBe("11-22"); + expect(formatDateTime(SAMPLE)).toBe("2025-11-22 18:23"); + }); + it("formats day/month/year and month/day/year", () => { + setDateTimePrefs({ dateFormat: "dmy-slash" }); + expect(formatDate(SAMPLE)).toBe("22/11/2025"); + setDateTimePrefs({ dateFormat: "mdy-slash" }); + expect(formatDate(SAMPLE)).toBe("11/22/2025"); + }); + it("keeps the weekday in message headers", () => { + setDateTimePrefs({ locale: "en-GB", dateFormat: "dmy-dot", timeFormat: "24" }); + expect(formatFullDateTime(SAMPLE)).toBe("Sat, 22.11.2025 18:23"); + }); +}); + +describe("clock preference", () => { + it("honours 24-hour regardless of locale", () => { + setDateTimePrefs({ locale: "en-US", timeFormat: "24" }); + expect(formatClock(SAMPLE)).toBe("18:23"); + expect(uses24Hour()).toBe(true); + expect(formatHourLabel(13)).toBe("13"); + expect(formatHourLabel(9)).toBe("09"); + }); + it("honours 12-hour regardless of locale", () => { + setDateTimePrefs({ locale: "de-DE", timeFormat: "12" }); + expect(formatClock(SAMPLE)).toBe("6:23 PM"); + expect(uses24Hour()).toBe(false); + }); + it("follows the locale when set to automatic", () => { + setDateTimePrefs({ locale: "de-DE", timeFormat: "auto" }); + expect(uses24Hour()).toBe(true); + expect(formatClock(SAMPLE)).toBe("18:23"); + setDateTimePrefs({ locale: "en-US", timeFormat: "auto" }); + expect(uses24Hour()).toBe(false); + expect(formatClock(SAMPLE)).toMatch(/6:23\s?PM/); + }); +}); + +describe("automatic date format", () => { + it("follows the locale's own order", () => { + setDateTimePrefs({ locale: "de-DE", dateFormat: "auto" }); + expect(formatDate(SAMPLE)).toMatch(/22\.\s?Nov\.?\s?2025/); + setDateTimePrefs({ locale: "en-US", dateFormat: "auto" }); + expect(formatDate(SAMPLE)).toBe("Nov 22, 2025"); + }); + it("uses the server locale when no explicit choice is made", () => { + setServerLocale("de_DE"); + expect(formatDate(SAMPLE)).toMatch(/22\./); + }); +}); + +describe("withPrefs", () => { + it("formats a preview without leaking the override", () => { + setDateTimePrefs({ locale: "en-US", dateFormat: "mdy-slash" }); + expect(withPrefs({ dateFormat: "ymd-dash" }, () => formatDate(SAMPLE))).toBe("2025-11-22"); + expect(formatDate(SAMPLE)).toBe("11/22/2025"); + }); +}); + +describe("script variants render in their own script", () => { + it("distinguishes Latin from Cyrillic Serbian", () => { + setServerLocale("sr_RS@latin"); + const latin = formatDate(SAMPLE); + setServerLocale("sr_RS"); + const cyrillic = formatDate(SAMPLE); + expect(latin).toMatch(/[a-z]/i); + expect(cyrillic).toMatch(/[\u0400-\u04FF]/); + expect(latin).not.toBe(cyrillic); + }); + it("distinguishes Cyrillic from Latin Uzbek", () => { + setServerLocale("uz_UZ@cyrillic"); + expect(formatDate(SAMPLE)).toMatch(/[\u0400-\u04FF]/); + setServerLocale("uz_UZ"); + expect(formatDate(SAMPLE)).not.toMatch(/[\u0400-\u04FF]/); + }); +}); + +describe("digit systems stay consistent within one string", () => { + const ARABIC_INDIC = /[\u0660-\u0669]/; + const LATIN_DIGIT = /[0-9]/; + + it("uses the locale's own digits for locale date orders", () => { + setDateTimePrefs({ locale: "ar-EG", dateFormat: "dmy-dot", timeFormat: "24" }); + const out = formatDateTime(SAMPLE); + expect(out).toMatch(ARABIC_INDIC); + expect(out).not.toMatch(LATIN_DIGIT); + }); + + it("pins ISO 8601 to Latin digits, clock included", () => { + setDateTimePrefs({ locale: "ar-EG", dateFormat: "ymd-dash", timeFormat: "24" }); + const out = formatDateTime(SAMPLE); + expect(out).toContain("2025-11-22"); + expect(out).toContain("18:23"); + expect(out).not.toMatch(ARABIC_INDIC); + }); + + it("keeps calendar hour labels in the same digits as the dates", () => { + setDateTimePrefs({ locale: "ar-EG", dateFormat: "dmy-dot", timeFormat: "24" }); + expect(formatHourLabel(13)).toMatch(ARABIC_INDIC); + setDateTimePrefs({ locale: "ar-EG", dateFormat: "ymd-dash", timeFormat: "24" }); + expect(formatHourLabel(13)).toBe("13"); + }); +}); + +describe("locale options", () => { + it("offers every locale ICU has data for, named in its own language", () => { + const opts = localeOptions(); + expect(opts.length).toBeGreaterThan(500); + const tags = opts.map((o) => o.tag); + for (const tag of ["de-DE", "en-US", "sw-KE", "ka-GE", "yue-HK", "sr-Latn-RS", "uz-Cyrl-UZ"]) { + expect(tags).toContain(tag); + } + expect(opts.find((o) => o.tag === "de-DE")?.label).toBe("Deutsch (Deutschland)"); + expect(opts.every((o) => o.label && o.label !== o.tag)).toBe(true); + }); + + it("includes a server locale that is not in the generated list", () => { + setServerLocale("de_DE_u_ca_buddhist"); + const tags = localeOptions().map((o) => o.tag); + expect(tags).toContain("de-DE-u-ca-buddhist"); + }); +}); diff --git a/web/src/lib/dates.ts b/web/src/lib/dates.ts index 5b772a6..f083793 100644 --- a/web/src/lib/dates.ts +++ b/web/src/lib/dates.ts @@ -1,3 +1,5 @@ +import { formatClock, formatDayMonth, formatDayMonthTime, formatWeekdayDate } from "./datetime"; + export const DAY_MS = 86_400_000; export function startOfDay(d: Date): Date { @@ -219,14 +221,13 @@ export function listTimeZones(): string[] { export function formatTimeRange(start: Date, end: Date, allDay: boolean): string { if (allDay) { const lastDay = new Date(end.getTime() - 1); - if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); - return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`; + if (isSameDay(start, lastDay)) return formatWeekdayDate(start); + return `${formatDayMonth(start)} – ${formatDayMonth(lastDay)}`; } - const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); if (isSameDay(start, end)) { - return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} – ${t(end)}`; + return `${formatWeekdayDate(start)} · ${formatClock(start)} – ${formatClock(end)}`; } - return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} – ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`; + return `${formatDayMonthTime(start)} – ${formatDayMonthTime(end)}`; } /** For */ diff --git a/web/src/lib/datetime.ts b/web/src/lib/datetime.ts new file mode 100644 index 0000000..239c8c1 --- /dev/null +++ b/web/src/lib/datetime.ts @@ -0,0 +1,345 @@ +/** + * Locale-aware date and time formatting. + * + * Every user-visible date in the app goes through here so that a single set of + * preferences (language/region, date order, 12h vs 24h clock) controls all of + * them. The preferences live in the settings store; this module keeps a plain + * copy so formatting stays a synchronous, non-React call. + * + * `locale` is the explicit user choice; when it is empty we fall back to the + * locale Stalwart reports for the account, and finally to the browser's. + */ + +import { LOCALE_TAGS } from "./locales"; + +export type DateFormat = "auto" | "dmy-dot" | "dmy-slash" | "mdy-slash" | "ymd-dash"; +export type TimeFormat = "auto" | "12" | "24"; + +export interface DateTimePrefs { + /** BCP-47 tag, or "" for automatic (server → browser). */ + locale: string; + dateFormat: DateFormat; + timeFormat: TimeFormat; +} + +const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" }; + +let prefs: DateTimePrefs = DEFAULT_PREFS; +let serverLocale: string | null = null; + +export function setDateTimePrefs(p: Partial): void { + prefs = { ...prefs, ...p }; +} + +/** Run `fn` with temporarily overridden preferences — used to render previews. */ +export function withPrefs(over: Partial, fn: () => T): T { + const saved = prefs; + prefs = { ...prefs, ...over }; + try { + return fn(); + } finally { + prefs = saved; + } +} + +/** Locale reported by Stalwart for this account (normalised), or null. */ +export function setServerLocale(raw: string | null | undefined): void { + serverLocale = normalizeLocale(raw); +} + +export function getServerLocale(): string | null { + return serverLocale; +} + +/** + * glibc locale modifiers that name a script rather than a dialect or a + * currency: "sr_RS@latin" means Latin Serbian, which is a different tag + * (sr-Latn-RS) and not just sr-RS. Modifiers not listed here (@valencia, + * @saaho, @euro …) carry no script and are dropped. + */ +const SCRIPT_MODIFIERS: Record = { + latin: "Latn", + latn: "Latn", + cyrillic: "Cyrl", + cyrl: "Cyrl", + devanagari: "Deva", + iqtelif: "Latn", +}; + +/** + * Turn a POSIX-style locale ("de_DE.UTF-8@euro") or BCP-47 tag into a plain + * BCP-47 tag, or null when it is unusable ("POSIX", "C", garbage). + */ +export function normalizeLocale(raw: string | null | undefined): string | null { + if (!raw) return null; + const [head, modifier] = raw.trim().split("@"); + const base = head!.split(".")[0]!.replace(/_/g, "-"); + if (!base || base === "C" || base.toUpperCase() === "POSIX") return null; + const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined; + try { + const [canonical] = Intl.getCanonicalLocales(base); + if (!canonical) return null; + if (!script) return canonical; + const loc = new Intl.Locale(canonical); + // Adding the script only helps when it differs from the one the locale + // already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU). + const implied = loc.script ?? loc.maximize().script; + return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString(); + } catch { + return null; + } +} + +/** The locale Intl should use: explicit choice → server → browser default. */ +export function resolvedLocale(): string | undefined { + return prefs.locale || serverLocale || undefined; +} + +/** Where the effective locale came from — used to label the "Automatic" option. */ +export function localeSource(): "explicit" | "server" | "browser" { + if (prefs.locale) return "explicit"; + if (serverLocale) return "server"; + return "browser"; +} + +export function browserLocale(): string { + try { + return new Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + return "en-US"; + } +} + +const labelCache = new Map(); + +/** Human-readable name of a locale tag, in that locale ("Deutsch (Deutschland)"). */ +export function localeLabel(tag: string): string { + const hit = labelCache.get(tag); + if (hit) return hit; + let label = tag; + try { + label = new Intl.DisplayNames([tag], { type: "language" }).of(tag) ?? tag; + } catch { + /* keep the tag */ + } + labelCache.set(tag, label); + return label; +} + +/* ------------------------------------------------------------------ */ +/* Intl plumbing */ +/* ------------------------------------------------------------------ */ + +const cache = new Map(); +const numCache = new Map(); + +/** + * The locale the formatters actually run in. ISO 8601 is defined in Latin + * digits, so choosing it pins the numbering system for the clock too — a date + * and time in one line must not mix digit systems. + */ +function formattingLocale(): string | undefined { + const loc = resolvedLocale(); + if (prefs.dateFormat !== "ymd-dash") return loc; + try { + return new Intl.Locale(loc ?? browserLocale(), { numberingSystem: "latn" }).toString(); + } catch { + return loc; + } +} + +function intl(opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const loc = formattingLocale(); + const key = `${loc ?? "*"}|${JSON.stringify(opts)}`; + let f = cache.get(key); + if (!f) { + f = new Intl.DateTimeFormat(loc, opts); + cache.set(key, f); + } + return f; +} + +/** Zero-padded number in the locale's own digits (١٨ for ar-EG, 18 for de-DE). */ +function num(value: number, digits: number): string { + const loc = formattingLocale(); + const key = `${loc ?? "*"}|${digits}`; + let f = numCache.get(key); + if (!f) { + f = new Intl.NumberFormat(loc, { minimumIntegerDigits: digits, useGrouping: false }); + numCache.set(key, f); + } + return f.format(value); +} + +/** Time-of-day options honouring the 12h/24h preference. */ +export function timeOptions(): Intl.DateTimeFormatOptions { + switch (prefs.timeFormat) { + case "24": + return { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }; + case "12": + return { hour: "numeric", minute: "2-digit", hourCycle: "h12" }; + default: + return { hour: "numeric", minute: "2-digit" }; + } +} + +/** True when the effective clock is 24-hour (explicit setting, else locale). */ +export function uses24Hour(): boolean { + if (prefs.timeFormat === "24") return true; + if (prefs.timeFormat === "12") return false; + try { + const hc = new Intl.DateTimeFormat(formattingLocale(), { hour: "numeric" }).resolvedOptions().hourCycle; + return hc === "h23" || hc === "h24"; + } catch { + return false; + } +} + +export function isAutoDateFormat(): boolean { + return prefs.dateFormat === "auto"; +} + +/* ------------------------------------------------------------------ */ +/* Building blocks */ +/* ------------------------------------------------------------------ */ + +/** All-numeric date in the configured order (never used when dateFormat is "auto"). */ +function numeric(d: Date, withYear: boolean): string { + const dd = num(d.getDate(), 2); + const mm = num(d.getMonth() + 1, 2); + const yy = num(d.getFullYear(), 4); + switch (prefs.dateFormat) { + case "dmy-slash": + return withYear ? `${dd}/${mm}/${yy}` : `${dd}/${mm}`; + case "mdy-slash": + return withYear ? `${mm}/${dd}/${yy}` : `${mm}/${dd}`; + case "ymd-dash": + return withYear ? `${yy}-${mm}-${dd}` : `${mm}-${dd}`; + case "dmy-dot": + default: + return withYear ? `${dd}.${mm}.${yy}` : `${dd}.${mm}.`; + } +} + +/** "18:23" / "6:23 PM" */ +export function formatClock(d: Date): string { + return intl(timeOptions()).format(d); +} + +/** Hour gutter label in the calendar: "13" / "1 PM". */ +export function formatHourLabel(hour: number): string { + if (uses24Hour()) return num(hour, 2); + return intl({ hour: "numeric", hourCycle: "h12" }).format(new Date(2000, 0, 1, hour)); +} + +/** Day and month, no year: "22 Aug" / "22.08." / "08-22". */ +export function formatDayMonth(d: Date): string { + return isAutoDateFormat() ? intl({ month: "short", day: "numeric" }).format(d) : numeric(d, false); +} + +/** Day, month and year: "22 Aug 2026" / "22.08.2026" / "2026-08-22". */ +export function formatDate(d: Date): string { + return isAutoDateFormat() ? intl({ year: "numeric", month: "short", day: "numeric" }).format(d) : numeric(d, true); +} + +/** All-numeric date, even in "auto" mode: "8/22/2026" / "22.08.2026". */ +export function formatNumericDate(d: Date): string { + return isAutoDateFormat() ? intl({ year: "numeric", month: "numeric", day: "numeric" }).format(d) : numeric(d, true); +} + +/** Spelled-out month, no weekday: "22 August 2026" / "22.08.2026". */ +export function formatDateLong(d: Date, withYear = true): string { + if (isAutoDateFormat()) { + return intl({ month: "long", day: "numeric", ...(withYear ? { year: "numeric" as const } : {}) }).format(d); + } + return numeric(d, withYear); +} + +/** Long form for headings: "Saturday, 22 August" / "Saturday, 22.08.2026". */ +export function formatWeekdayDate(d: Date, withYear = false): string { + if (isAutoDateFormat()) { + return intl({ weekday: "long", month: "long", day: "numeric", ...(withYear ? { year: "numeric" as const } : {}) }).format(d); + } + return `${formatWeekday(d, "long")}, ${numeric(d, true)}`; +} + +export function formatWeekday(d: Date, style: "short" | "long" | "narrow" = "short"): string { + return intl({ weekday: style }).format(d); +} + +/** "August 2026" — month names are unambiguous, so this always follows the locale. */ +export function formatMonthYear(d: Date): string { + return intl({ month: "long", year: "numeric" }).format(d); +} + +/** Date plus time: "22 Aug 2026, 18:23" / "2026-08-22 18:23". */ +export function formatDateTime(d: Date): string { + if (isAutoDateFormat()) { + return intl({ year: "numeric", month: "short", day: "numeric", ...timeOptions() }).format(d); + } + return `${numeric(d, true)} ${formatClock(d)}`; +} + +/** Day/month plus time, no year: "22 Aug, 18:23" / "22.08. 18:23". */ +export function formatDayMonthTime(d: Date): string { + if (isAutoDateFormat()) { + return intl({ month: "short", day: "numeric", ...timeOptions() }).format(d); + } + return `${numeric(d, false)} ${formatClock(d)}`; +} + +/** Weekday, full date and time — the message header format. */ +export function formatFullDateTime(d: Date): string { + if (isAutoDateFormat()) { + return intl({ weekday: "short", year: "numeric", month: "short", day: "numeric", ...timeOptions() }).format(d); + } + return `${formatWeekday(d, "short")}, ${numeric(d, true)} ${formatClock(d)}`; +} + +/* ------------------------------------------------------------------ */ +/* Relative times */ +/* ------------------------------------------------------------------ */ + +let rtfLocale: string | undefined | null = null; +let rtfCached: Intl.RelativeTimeFormat | null = null; + +export function relativeFormat(): Intl.RelativeTimeFormat | null { + if (typeof Intl === "undefined" || !("RelativeTimeFormat" in Intl)) return null; + const loc = resolvedLocale(); + if (rtfCached && rtfLocale === loc) return rtfCached; + try { + rtfCached = new Intl.RelativeTimeFormat(loc, { numeric: "auto" }); + rtfLocale = loc; + return rtfCached; + } catch { + return null; + } +} + +/** Locales offered in settings, on top of "Automatic". */ +export interface LocaleOption { + tag: string; + /** The locale's own name for itself, e.g. "Deutsch (Deutschland)". */ + label: string; +} + +let optionsCache: LocaleOption[] | null = null; +let optionsExtras = ""; + +/** + * Every locale ICU has data for, named in its own language and sorted by that + * name, plus whatever the server reported or the user already chose (so a tag + * outside the generated list is still selectable). + */ +export function localeOptions(): LocaleOption[] { + const extras = `${serverLocale ?? ""}|${prefs.locale}`; + if (optionsCache && optionsExtras === extras) return optionsCache; + const tags = new Set(LOCALE_TAGS); + if (serverLocale) tags.add(serverLocale); + if (prefs.locale) tags.add(prefs.locale); + const list = [...tags].map((tag) => ({ tag, label: localeLabel(tag) })); + list.sort((a, b) => a.label.localeCompare(b.label, resolvedLocale()) || a.tag.localeCompare(b.tag)); + optionsCache = list; + optionsExtras = extras; + return list; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index f97a2b2..c8db0a9 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -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 { diff --git a/web/src/lib/locales.ts b/web/src/lib/locales.ts new file mode 100644 index 0000000..20ffa3d --- /dev/null +++ b/web/src/lib/locales.ts @@ -0,0 +1,88 @@ +/** + * Every locale ICU/CLDR has real data for — generated by probing + * `Intl.DateTimeFormat(tag).resolvedOptions()` over the language, script and + * region subtag space and keeping the tags the resolver does not fold into a + * parent. Script variants appear only where they differ from the region's + * default script (so `sr-RS` and `sr-Latn-RS` are both listed, but + * `sr-Cyrl-RS` — the same thing as `sr-RS` — is not). + */ +export const LOCALE_TAGS: readonly string[] = [ + "af-NA", "af-ZA", "agq-CM", "ak-GH", "am-ET", "ar-AE", "ar-BH", "ar-DJ", + "ar-DZ", "ar-EG", "ar-EH", "ar-ER", "ar-IL", "ar-IQ", "ar-JO", "ar-KM", + "ar-KW", "ar-LB", "ar-LY", "ar-MA", "ar-MR", "ar-OM", "ar-PS", "ar-QA", + "ar-SA", "ar-SD", "ar-SO", "ar-SS", "ar-SY", "ar-TD", "ar-TN", "ar-YE", + "ars", "as-IN", "asa-TZ", "ast-ES", "az-AZ", "az-Cyrl-AZ", "ba-RU", "bas-CM", + "be-BY", "bem-ZM", "bez-TZ", "bg-BG", "bgc-IN", "bho-IN", "blo-BJ", "bm-ML", + "bn-BD", "bn-IN", "bo-CN", "bo-IN", "br-FR", "brx-IN", "bs-BA", "bs-Cyrl-BA", + "bua-RU", "ca-AD", "ca-ES", "ca-FR", "ca-IT", "ccp-BD", "ccp-IN", "ce-RU", + "ceb-PH", "cgg-UG", "chr-US", "ckb-IQ", "ckb-IR", "cs-CZ", "csw-CA", "cv-RU", + "cy-GB", "da-DK", "da-GL", "dav-KE", "de-AT", "de-BE", "de-CH", "de-DE", + "de-IT", "de-LI", "de-LU", "dje-NE", "doi-IN", "dsb-DE", "dua-CM", "dyo-SN", + "dz-BT", "ebu-KE", "ee-GH", "ee-TG", "el-CY", "el-GR", "en-AE", "en-AG", + "en-AI", "en-AS", "en-AT", "en-AU", "en-BB", "en-BE", "en-BI", "en-BM", + "en-BS", "en-BW", "en-BZ", "en-CA", "en-CC", "en-CH", "en-CK", "en-CM", + "en-CX", "en-CY", "en-CZ", "en-DE", "en-DG", "en-DK", "en-DM", "en-EE", + "en-ER", "en-ES", "en-FI", "en-FJ", "en-FK", "en-FM", "en-FR", "en-GB", + "en-GD", "en-GE", "en-GG", "en-GH", "en-GI", "en-GM", "en-GS", "en-GU", + "en-GY", "en-HK", "en-HU", "en-ID", "en-IE", "en-IL", "en-IM", "en-IN", + "en-IO", "en-IT", "en-JE", "en-JM", "en-JP", "en-KE", "en-KI", "en-KN", + "en-KY", "en-LC", "en-LR", "en-LS", "en-LT", "en-LV", "en-MG", "en-MH", + "en-MO", "en-MP", "en-MS", "en-MT", "en-MU", "en-MV", "en-MW", "en-MY", + "en-NA", "en-NF", "en-NG", "en-NL", "en-NO", "en-NR", "en-NU", "en-NZ", + "en-PG", "en-PH", "en-PK", "en-PL", "en-PN", "en-PR", "en-PT", "en-PW", + "en-RO", "en-RW", "en-SB", "en-SC", "en-SD", "en-SE", "en-SG", "en-SH", + "en-SI", "en-SK", "en-SL", "en-SS", "en-SX", "en-SZ", "en-TC", "en-TK", + "en-TO", "en-TT", "en-TV", "en-TZ", "en-UA", "en-UG", "en-UM", "en-US", + "en-VC", "en-VG", "en-VI", "en-VU", "en-WS", "en-ZA", "en-ZM", "en-ZW", + "eo", "es-AR", "es-BO", "es-BR", "es-BZ", "es-CL", "es-CO", "es-CR", + "es-CU", "es-DO", "es-EA", "es-EC", "es-ES", "es-GQ", "es-GT", "es-HN", + "es-IC", "es-MX", "es-NI", "es-PA", "es-PE", "es-PH", "es-PR", "es-PY", + "es-SV", "es-US", "es-UY", "es-VE", "et-EE", "eu-ES", "ewo-CM", "fa-AF", + "fa-IR", "ff-Adlm-BF", "ff-Adlm-CM", "ff-Adlm-GH", "ff-Adlm-GM", "ff-Adlm-GN", "ff-Adlm-GW", "ff-Adlm-LR", + "ff-Adlm-MR", "ff-Adlm-NE", "ff-Adlm-NG", "ff-Adlm-SL", "ff-Adlm-SN", "ff-BF", "ff-CM", "ff-GH", + "ff-GM", "ff-GN", "ff-GW", "ff-LR", "ff-MR", "ff-NE", "ff-NG", "ff-SL", + "ff-SN", "fi-FI", "fil-PH", "fo-DK", "fo-FO", "fr-BE", "fr-BF", "fr-BI", + "fr-BJ", "fr-BL", "fr-CA", "fr-CD", "fr-CF", "fr-CG", "fr-CH", "fr-CI", + "fr-CM", "fr-DJ", "fr-DZ", "fr-FR", "fr-GA", "fr-GF", "fr-GN", "fr-GP", + "fr-GQ", "fr-HT", "fr-KM", "fr-LU", "fr-MA", "fr-MC", "fr-MF", "fr-MG", + "fr-ML", "fr-MQ", "fr-MR", "fr-MU", "fr-NC", "fr-NE", "fr-PF", "fr-PM", + "fr-RE", "fr-RW", "fr-SC", "fr-SN", "fr-SY", "fr-TD", "fr-TG", "fr-TN", + "fr-VU", "fr-WF", "fr-YT", "fur-IT", "fy-NL", "ga-GB", "ga-IE", "gaa-GH", + "gd-GB", "gl-ES", "gsw-CH", "gsw-FR", "gsw-LI", "gu-IN", "guz-KE", "gv-IM", + "ha-GH", "ha-NE", "ha-NG", "haw-US", "he-IL", "hi-IN", "hi-Latn-IN", "hr-BA", + "hr-HR", "hsb-DE", "hu-HU", "hy-AM", "ia", "id-ID", "ie-EE", "ig-NG", + "ii-CN", "is-IS", "it-CH", "it-IT", "it-SM", "it-VA", "ja-JP", "jgo-CM", + "jmc-TZ", "jv-ID", "ka-GE", "kab-DZ", "kam-KE", "kde-TZ", "kea-CV", "kgp-BR", + "khq-ML", "ki-KE", "kk-CN", "kk-KZ", "kkj-CM", "kl-GL", "kln-KE", "km-KH", + "kn-IN", "ko-CN", "ko-KP", "ko-KR", "kok-IN", "kok-Latn-IN", "ks-Deva-IN", "ks-IN", + "ksb-TZ", "ksf-CM", "ksh-DE", "ku-IQ", "ku-Latn-IQ", "ku-SY", "ku-TR", "kw-GB", + "kxv-Deva-IN", "kxv-IN", "kxv-Orya-IN", "kxv-Telu-IN", "ky-KG", "lag-TZ", "lb-LU", "lg-UG", + "lij-IT", "lkt-US", "lmo-IT", "ln-AO", "ln-CD", "ln-CF", "ln-CG", "lo-LA", + "lrc-IQ", "lrc-IR", "lt-LT", "lu-CD", "luo-KE", "luy-KE", "lv-LV", "mai-IN", + "mas-KE", "mas-TZ", "mer-KE", "mfe-MU", "mg-MG", "mgh-MZ", "mgo-CM", "mi-NZ", + "mk-MK", "ml-IN", "mn-MN", "mni-IN", "mr-IN", "ms-BN", "ms-ID", "ms-MY", + "ms-SG", "mt-MT", "mua-CM", "my-MM", "mzn-IR", "naq-NA", "nb", "nd-ZW", + "nds-DE", "nds-NL", "ne-IN", "ne-NP", "nl-AW", "nl-BE", "nl-BQ", "nl-CW", + "nl-NL", "nl-SR", "nl-SX", "nmg-CM", "nn-NO", "nnh-CM", "no-NO", "nqo-GN", + "nso-ZA", "nus-SS", "nyn-UG", "oc-ES", "oc-FR", "om-ET", "om-KE", "or-IN", + "os-GE", "os-RU", "pa-IN", "pa-PK", "pcm-NG", "pl-PL", "pms-IT", "prg-PL", + "ps-AF", "ps-PK", "pt-AO", "pt-BR", "pt-CH", "pt-CV", "pt-GQ", "pt-GW", + "pt-LU", "pt-MO", "pt-MZ", "pt-PT", "pt-ST", "pt-TL", "qu-BO", "qu-EC", + "qu-PE", "raj-IN", "rm-CH", "rn-BI", "ro-MD", "ro-RO", "rof-TZ", "ru-BY", + "ru-KG", "ru-KZ", "ru-MD", "ru-RU", "ru-UA", "rw-RW", "rwk-TZ", "sa-IN", + "sah-RU", "saq-KE", "sat-IN", "sbp-TZ", "sc-IT", "scn-IT", "sd-IN", "sd-PK", + "se-FI", "se-NO", "se-SE", "seh-MZ", "ses-ML", "sg-CF", "shi-Latn-MA", "shi-MA", + "shn-MM", "shn-TH", "si-LK", "sk-SK", "sl-SI", "smn-FI", "sn-ZW", "so-DJ", + "so-ET", "so-KE", "so-SO", "sq-AL", "sq-MK", "sq-XK", "sr-BA", "sr-Cyrl-ME", + "sr-Latn-BA", "sr-Latn-RS", "sr-Latn-XK", "sr-ME", "sr-RS", "sr-XK", "st-LS", "st-ZA", + "su-ID", "sv-AX", "sv-FI", "sv-SE", "sw-CD", "sw-KE", "sw-TZ", "sw-UG", + "syr-IQ", "syr-SY", "szl-PL", "ta-IN", "ta-LK", "ta-MY", "ta-SG", "te-IN", + "teo-KE", "teo-UG", "tg-TJ", "th-TH", "ti-ER", "ti-ET", "tk-TM", "tn-BW", + "tn-ZA", "to-TO", "tok", "tr-CY", "tr-TR", "tt-RU", "twq-NE", "tyv-RU", + "tzm-MA", "ug-CN", "uk-UA", "ur-IN", "ur-PK", "uz-AF", "uz-Cyrl-UZ", "uz-UZ", + "vai-LR", "vai-Latn-LR", "vec-IT", "vi-VN", "vmw-MZ", "vun-TZ", "wae-CH", "wo-SN", + "xh-ZA", "xnr-IN", "xog-UG", "yav-CM", "yi-UA", "yo-BJ", "yo-NG", "yrl-BR", + "yrl-CO", "yrl-VE", "yue-CN", "yue-HK", "yue-Hant-CN", "yue-MO", "za-CN", "zgh-MA", + "zh-CN", "zh-HK", "zh-Hans-HK", "zh-Hans-MO", "zh-Hant-MY", "zh-MO", "zh-MY", "zh-SG", + "zh-TW", "zu-ZA", +]; diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 80f59ad..7b528f9 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -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((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((set, get) => ({ try { const s = await apiFetch("/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((set, get) => ({ function applySession(s: JmapSession, set: (p: Partial) => 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 }); } diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 162357d..eb02b24 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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", DEFAULT_SETTINGS); +applyDateTimePrefs(initialSettings); + export const useSettings = create((set, get) => ({ - settings: loadJson("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((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}`; diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 072e20e..ca61a5b 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -664,7 +664,7 @@ img { max-width: 100%; } .month-cell:last-child { border-right: 0; } .month-cell.other { background: var(--bg-sunken); color: var(--fg-faint); } .month-cell:hover { background: var(--bg-hover); } -.month-cell .day-num { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: .9em; font-weight: 500; align-self: flex-start; } +.month-cell .day-num { min-width: 26px; height: 26px; padding: 0 4px; display: flex; align-items: center; justify-content: center; border-radius: 999px; font-size: .9em; font-weight: 500; align-self: flex-start; white-space: nowrap; } .month-cell.today .day-num { background: var(--accent); color: var(--accent-fg); font-weight: 700; } .month-cell .more { font-size: .78em; color: var(--fg-muted); padding-left: 4px; font-weight: 600; } .ev-chip { display: flex; align-items: center; gap: 4px; padding: 1px 6px; border-radius: 4px; font-size: .8em; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; color: #fff; font-weight: 500; flex: 0 0 auto; border: 1px solid transparent; } diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index 41f8188..e5af553 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -3,6 +3,7 @@ import { useLocation } from "wouter"; import type { CalendarEvent } from "@/jmap/types"; import { useCalendar, type EventInstance } from "@/store/calendar"; import { useSettings } from "@/store/settings"; +import { formatDayMonth } from "@/lib/datetime"; import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover"; import { CALENDAR_COLORS } from "@/ui/misc"; import { confirmDialog } from "@/ui/dialog"; @@ -48,7 +49,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: const { start, end, allDay } = ctx; return ( - } label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} /> + } label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} /> {!allDay && } label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />} } label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} /> diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 525fc44..5110665 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -2,9 +2,10 @@ import { useMemo, useState } from "react"; import { useLocation } from "wouter"; import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react"; import { useCalendar } from "@/store/calendar"; -import { useSettings } from "@/store/settings"; +import { dateTimeKey, useSettings } from "@/store/settings"; import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; import { formatMonthYear } from "@/lib/format"; +import { formatWeekday } from "@/lib/datetime"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; @@ -16,6 +17,7 @@ export function CalendarSidebar() { const [location, navigate] = useLocation(); const cal = useCalendar(); const weekStart = useSettings((s) => s.settings.weekStart); + const locale = useSettings((s) => dateTimeKey(s.settings)); const parts = location.split("/"); const view = parts[2] || "week"; const dateStr = parts[3]; @@ -27,10 +29,7 @@ export function CalendarSidebar() { const [editCal, setEditCal] = useState | null>(null); const [share, setShare] = useState(null); const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000)); - const dow = useMemo(() => { - const names = ["S", "M", "T", "W", "T", "F", "S"]; - return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]); - }, [weekStart]); + const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]); if (!cal.available) return null; const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index d00e41b..9ba6522 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -5,6 +5,7 @@ import { useCalendar, type EventInstance } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates"; import { formatMonthYear, formatTime } from "@/lib/format"; +import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime"; import { Empty, useIsMobile } from "@/ui/misc"; import { keyboard } from "@/lib/keyboard"; import { EventPopover } from "./EventPopover"; @@ -95,9 +96,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: const title = effectiveView === "month" ? formatMonthYear(anchor) - : effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}` - : effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }) - : `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`; + : effectiveView === "week" ? `${formatDayMonth(range.start)} – ${formatDate(addDays(range.end, -1))}` + : effectiveView === "day" ? formatWeekdayDate(anchor, true) + : `Agenda from ${formatDayMonth(anchor)}`; const onEvent = (inst: EventInstance, el: Element) => { const r = el.getBoundingClientRect(); @@ -152,7 +153,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1)); const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7)); - const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" })); + const dow = weeks[0]!.map((d) => formatWeekday(d)); const maxPer = 4; return (
@@ -165,7 +166,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo const shown = evs.slice(0, maxPer); return (
onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}> - { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()} + { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()} {shown.map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)} {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more}
@@ -244,7 +245,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
{days.map((d) => (
onDayHeader(d)}> -
{d.toLocaleDateString(undefined, { weekday: "short" })}
+
{formatWeekday(d)}
{d.getDate()}
))} @@ -260,7 +261,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
- {[...Array(24)].map((_, h) => h > 0 && {new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })})} + {[...Array(24)].map((_, h) => h > 0 && {formatHourLabel(h)})}
{days.map((d) => { const evs = layoutOverlaps(timed(d), d); @@ -400,8 +401,8 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent: {byDay.map(({ day, items }) => (
- {day.toLocaleDateString(undefined, { weekday: "long" })} - {day.toLocaleDateString(undefined, { month: "long", day: "numeric" })} + {formatWeekday(day, "long")} + {formatDateLong(day, false)}
{items.map((i) => ( diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index ee5ef9c..c365e79 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -10,6 +10,7 @@ import { ColorSwatches, Switch } from "@/ui/misc"; import { toast } from "@/ui/toast"; import { RecipientInput } from "../compose/RecipientInput"; import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates"; +import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { newKey } from "@/lib/contacts"; @@ -228,7 +229,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
+
+
+ + +

{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.

+
+
+ + +
+
+ + +
+
+

Preview: {formatFullDateTime(SAMPLE)}

Backup

@@ -113,3 +166,4 @@ export function GeneralSettings() {
); } + diff --git a/web/src/views/settings/VacationSettings.tsx b/web/src/views/settings/VacationSettings.tsx index c44b32c..6bbed54 100644 --- a/web/src/views/settings/VacationSettings.tsx +++ b/web/src/views/settings/VacationSettings.tsx @@ -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() {

Automatically reply to people who email you while you're away. Each sender gets at most one reply.

-
setFrom(e.target.value)} />
-
setTo(e.target.value)} />
+
+ + setFrom(e.target.value)} /> + {echo(from) &&

{echo(from)}

} +
+
+ + setTo(e.target.value)} /> + {echo(to) &&

{echo(to)}

} +
setSubject(e.target.value)} placeholder="Out of office" />