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 626a48e678
commit be893ef482
22 changed files with 920 additions and 58 deletions
+5 -2
View File
@@ -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** (`<input type="datetime-local">` 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
+8 -3
View File
@@ -11,6 +11,7 @@ import {
expandTemplate,
fetchUpstreamSession,
forgetUpstreamSession,
getAccountLocale,
getUpstreamSession,
localizeSession,
} from "./upstream.js";
@@ -170,7 +171,8 @@ export function createApp(): Hono<Env> {
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<Env> {
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<Env> {
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,
},
};
}
+10 -2
View File
@@ -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 ?? "[email protected]";
/** 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<string, unknown>;
@@ -254,6 +256,12 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
}
const handlers: Record<string, Handler> = {
// 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<Buffer> {
}
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}`,
+17
View File
@@ -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);
});
+94
View File
@@ -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<string, { locale: string | null; fetchedAt: number }>();
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<string, string> = {
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<string | null> {
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, unknown>, 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<string | null> {
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;
}
/**
+2
View File
@@ -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;
};
}
+198
View File
@@ -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");
});
});
+6 -5
View File
@@ -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 <input type="datetime-local"> */
+345
View File
@@ -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<DateTimePrefs>): void {
prefs = { ...prefs, ...p };
}
/** Run `fn` with temporarily overridden preferences — used to render previews. */
export function withPrefs<T>(over: Partial<DateTimePrefs>, 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<string, string> = {
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<string, string>();
/** 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<string, Intl.DateTimeFormat>();
const numCache = new Map<string, Intl.NumberFormat>();
/**
* 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<string>(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;
}
+18 -16
View File
@@ -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 {
+88
View File
@@ -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",
];
+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 });
}
+23 -4
View File
@@ -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}`;
+1 -1
View File
@@ -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; }
@@ -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 (
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
{!allDay && <MenuItem icon={<CalendarDays size={16} />} 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); }} />}
<MenuSep />
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
+4 -5
View File
@@ -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<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(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));
+10 -9
View File
@@ -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 (
<div className="month-grid">
@@ -165,7 +166,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
const shown = evs.slice(0, maxPer);
return (
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
</div>
@@ -244,7 +245,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
<div />
{days.map((d) => (
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
<div className="dow">{formatWeekday(d)}</div>
<div className="dnum">{d.getDate()}</div>
</div>
))}
@@ -260,7 +261,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
<div className="week-scroll" ref={scrollRef}>
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
<div className="time-col">
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
</div>
{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 }) => (
<div key={day.toISOString()} className="agenda-day">
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
{day.toLocaleDateString(undefined, { weekday: "long" })}
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
{formatWeekday(day, "long")}
<small>{formatDateLong(day, false)}</small>
</div>
<div>
{items.map((i) => (
+4 -3
View File
@@ -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
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly on {start.toLocaleDateString(undefined, { weekday: "long" })}</option>
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
<option value="weekdays">Every weekday</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="yearly">Yearly</option>
@@ -282,7 +283,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {start.toLocaleDateString()}</div>
<div className="hint">Availability on {formatNumericDate(start)}</div>
{attendees.filter((a) => fb[a.email]).map((a) => (
<div key={a.email} className="fb-row">
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
@@ -291,7 +292,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
if (be <= bs) return null;
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${new Date(b.utcStart).toLocaleTimeString()} ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} ${formatClock(new Date(b.utcEnd))}`} />;
})}
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
</div>
+5 -4
View File
@@ -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("-");
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react";
import { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { dateTimeKey, useSettings } from "@/store/settings";
import type { Email, Id } from "@/jmap/types";
import { formatListDate } from "@/lib/format";
import { displayName, shortName } from "@/lib/address";
@@ -327,6 +327,8 @@ interface RowProps {
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) {
const labels = useSettings((s) => s.settings.labels);
// Subscribed purely so the row re-renders when the date format changes.
useSettings((s) => dateTimeKey(s.settings));
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
const scope = inScope.length ? inScope : [e];
const unread = scope.some((x) => !x.keywords.$seen);
@@ -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>