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:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"> */
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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)}`)} />
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,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,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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user