feat: add user-selectable regional date format

This commit is contained in:
honzup
2026-07-04 14:53:37 +02:00
committed by Linus Rath
parent a099ab442a
commit 14c2807f0a
23 changed files with 241 additions and 16 deletions
+53 -12
View File
@@ -5,6 +5,7 @@ import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
import { useLocaleStore } from "@/stores/locale-store";
import { useSettingsStore } from "@/stores/settings-store";
import type { DateLocale } from "@/stores/settings-store";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -42,6 +43,32 @@ export function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Resolve the Intl locale used to render NUMERIC date parts, honouring the
* user's regional `dateLocale` override while leaving weekday/month names on
* the UI language. `auto` returns `fallback` unchanged (prior behaviour); the
* explicit regions force a fixed numeric ordering (#456):
* - `iso` → `en-CA` (YYYY-MM-DD)
* - `en-GB` → `en-GB` (DD/MM/YYYY)
* - `en-US` → `en-US` (MM/DD/YYYY)
*/
function resolveDateLocale<T extends string | undefined>(
dateLocale: DateLocale,
fallback: T,
): string | T {
switch (dateLocale) {
case "iso":
return "en-CA";
case "en-GB":
return "en-GB";
case "en-US":
return "en-US";
case "auto":
default:
return fallback;
}
}
/**
* Formats a received-at date for the email list. The output style is
* controlled by the `dateFormat` user setting:
@@ -53,8 +80,13 @@ export function generateUUID(): string {
* - `relative` — legacy en-US relative format ("1h ago", "2d ago").
* - `full` — always the full locale date+time.
*
* Both the locale (from the language picker) and 12h/24h preference are
* read via `getState()` so this stays SSR-safe.
* The numeric date ordering is additionally governed by the `dateLocale`
* region setting (`auto` = follow the UI language, unchanged; or a fixed
* ISO / DD-MM / MM-DD ordering). Weekday and month names always follow the
* UI language.
*
* The locale (from the language picker), the region override and the 12h/24h
* preference are all read via `getState()` so this stays SSR-safe.
*/
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
@@ -62,10 +94,14 @@ export function formatDate(date: Date | string): string {
const localeRaw = useLocaleStore.getState().locale;
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
// `en` alone resolves to en-US in Intl; everything else uses the language
// subtag as-is and lets the runtime pick a sensible default region.
const intlLocale = locale === "en" ? "en-US" : locale;
const { dateFormat, timeFormat } = useSettingsStore.getState();
// Names (weekday, month) follow the UI language: `en` alone resolves to
// en-US in Intl; everything else uses the language subtag as-is and lets
// the runtime pick a sensible default region.
const uiLocale = locale === "en" ? "en-US" : locale;
const { dateFormat, dateLocale, timeFormat } = useSettingsStore.getState();
// Numeric dates additionally honour the regional `dateLocale` override
// (defaults to `auto` = the UI language, preserving prior behaviour). (#456)
const numericLocale = resolveDateLocale(dateLocale, uiLocale);
const hour12 = timeFormat === "12h";
if (dateFormat === "relative") {
@@ -77,7 +113,7 @@ export function formatDate(date: Date | string): string {
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString(intlLocale, {
return d.toLocaleDateString(uiLocale, {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
@@ -85,7 +121,7 @@ export function formatDate(date: Date | string): string {
}
if (dateFormat === "full") {
return d.toLocaleString(intlLocale, {
return d.toLocaleString(numericLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -96,7 +132,7 @@ export function formatDate(date: Date | string): string {
}
// 'smart' (default)
const timeStr = d.toLocaleTimeString(intlLocale, {
const timeStr = d.toLocaleTimeString(uiLocale, {
hour: "2-digit",
minute: "2-digit",
hour12,
@@ -113,12 +149,12 @@ export function formatDate(date: Date | string): string {
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
// strip it so the result reads cleanly next to the time.
const weekday = d
.toLocaleDateString(intlLocale, { weekday: "short" })
.toLocaleDateString(uiLocale, { weekday: "short" })
.replace(/\.$/, "");
return `${weekday} ${timeStr}`;
}
return d.toLocaleDateString(intlLocale, {
return d.toLocaleDateString(numericLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -144,6 +180,11 @@ export function formatDateTime(
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return typeof date === 'string' ? date : '';
// Honour the regional `dateLocale` override; `auto` keeps the previous
// `undefined` (runtime default) locale so existing behaviour is unchanged. (#456)
const { dateLocale } = useSettingsStore.getState();
const effectiveLocale = resolveDateLocale(dateLocale, undefined);
const localeOptions: Intl.DateTimeFormatOptions = {};
if (options?.weekday) localeOptions.weekday = options.weekday;
if (options?.year) localeOptions.year = options.year;
@@ -158,7 +199,7 @@ export function formatDateTime(
if (options?.timeZoneName) localeOptions.timeZoneName = options.timeZoneName;
}
return d.toLocaleString(undefined, localeOptions);
return d.toLocaleString(effectiveLocale, localeOptions);
}
// Marketing emails pad the preheader with whitespace, format chars (soft