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
+17 -4
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
import type { DateFormat, DateLocale, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
import { formatDate } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
@@ -13,7 +13,7 @@ export function LanguageSettings() {
const t = useTranslations('settings.language_region');
const tDays = useTranslations('calendar.days');
const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
const { dateFormat, dateLocale, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
// Subscribe to locale changes so the preview re-renders on language switch
// (formatDate reads it via getState() and would otherwise stay stale).
@@ -23,7 +23,7 @@ export function LanguageSettings() {
// Build sample timestamps for each bucket so users see what their pick
// will look like in practice. Use offsets relative to "now" so the
// bucketing is stable even though the wall-clock keeps moving.
void locale; void dateFormat; void timeFormat;
void locale; void dateFormat; void dateLocale; void timeFormat;
const now = new Date();
const today = new Date(now);
today.setHours(15, 31, 0, 0);
@@ -38,7 +38,7 @@ export function LanguageSettings() {
thisWeek: formatDate(thisWeek),
older: formatDate(older),
};
}, [locale, dateFormat, timeFormat]);
}, [locale, dateFormat, dateLocale, timeFormat]);
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -74,6 +74,19 @@ export function LanguageSettings() {
</div>
</SettingItem>
<SettingItem label={t('date_locale.label')} description={t('date_locale.description')}>
<Select
value={dateLocale}
onChange={(value) => updateSetting('dateLocale', value as DateLocale)}
options={[
{ value: 'auto', label: t('date_locale.auto') },
{ value: 'iso', label: t('date_locale.iso') },
{ value: 'en-GB', label: t('date_locale.dmy') },
{ value: 'en-US', label: t('date_locale.mdy') },
]}
/>
</SettingItem>
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
<RadioGroup
value={timeFormat}
+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
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Tento týden:",
"preview_older": "Starší:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Formát času",
"description": "Vyberte 12hodinový nebo 24hodinový formát času",
+8
View File
@@ -1050,6 +1050,14 @@
"preview_this_week": "Denne uge:",
"preview_older": "Ældre:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Tidsformat",
"description": "Vælg mellem 12-timers eller 24-timers ur",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Diese Woche:",
"preview_older": "Älter:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Zeitformat",
"description": "Wählen Sie zwischen 12-Stunden- oder 24-Stunden-Anzeige",
+8
View File
@@ -1050,6 +1050,14 @@
"preview_this_week": "This week:",
"preview_older": "Older:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Time Format",
"description": "Choose between 12-hour or 24-hour clock",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Esta semana:",
"preview_older": "Más antiguo:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Formato de Hora",
"description": "Elija entre reloj de 12 o 24 horas",
+8
View File
@@ -1050,6 +1050,14 @@
"preview_this_week": "این هفته:",
"preview_older": "قدیمی‌تر:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "فرمت زمان",
"description": "۱۲ یا ۲۴ ساعته",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Cette semaine :",
"preview_older": "Plus ancien :"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Format d'heure",
"description": "Choisissez entre 12 heures ou 24 heures",
+8
View File
@@ -1050,6 +1050,14 @@
"preview_this_week": "Ezen a héten:",
"preview_older": "Régebbi:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Idő formátum",
"description": "Válassz a 12 vagy 24 órás időformátum között",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Questa settimana:",
"preview_older": "Più vecchio:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Formato ora",
"description": "Scegli tra formato 12 o 24 ore",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "今週:",
"preview_older": "それ以前:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "時刻形式",
"description": "12時間制または24時間制を選択",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "이번 주:",
"preview_older": "이전:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "시간 형식",
"description": "12시간제 또는 24시간제를 선택해 주세요",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Šajā nedēļā:",
"preview_older": "Vecāks:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Laika formāts",
"description": "Izvēlieties starp 12 vai 24 stundu formātu",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Deze week:",
"preview_older": "Ouder:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Tijdnotatie",
"description": "Kies tussen 12-uurs of 24-uurs klok",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "W tym tygodniu:",
"preview_older": "Starsze:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Format czasu",
"description": "Wybierz zegar 12-godzinny lub 24-godzinny",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Esta semana:",
"preview_older": "Mais antigo:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Formato de Hora",
"description": "Escolha entre relógio de 12 ou 24 horas",
+8
View File
@@ -1050,6 +1050,14 @@
"preview_this_week": "În această săptămână:",
"preview_older": "Versiune anterioară:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Formatul orei",
"description": "Alegeți între formatul de 12 ore sau cel de 24 de ore",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "На этой неделе:",
"preview_older": "Старее:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Формат времени",
"description": "Выберите формат 12- или 24-часовой нотации",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Bu hafta:",
"preview_older": "Daha eski:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Saat Biçimi",
"description": "12 saatlik veya 24 saatlik saat arasında seçin",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "Цього тижня:",
"preview_older": "Старіше:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "Формат часу",
"description": "Виберіть 12-годинний або 24-годинний формат годинника",
+8
View File
@@ -1047,6 +1047,14 @@
"preview_this_week": "本周:",
"preview_older": "更早:"
},
"date_locale": {
"label": "Date format region",
"description": "How numeric dates are ordered (day, month, year)",
"auto": "Automatic (match language)",
"iso": "ISO 8601 (YYYY-MM-DD)",
"dmy": "Day/Month/Year",
"mdy": "Month/Day/Year"
},
"time_format": {
"label": "时间格式",
"description": "选择 12 小时制或 24 小时制",
+11
View File
@@ -39,6 +39,14 @@ export type SignaturePosition = 'above_quote' | 'below_quote';
/** How to handle an incoming Disposition-Notification-To (read-receipt) request. */
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
export type DateFormat = 'smart' | 'relative' | 'full';
/**
* Regional ordering of numeric dates, independent of the `DateFormat` style.
* - `auto` follow the UI language (today's behaviour).
* - `iso` ISO 8601, `YYYY-MM-DD`.
* - `en-GB` Day/Month/Year (`DD/MM/YYYY`).
* - `en-US` Month/Day/Year (`MM/DD/YYYY`).
*/
export type DateLocale = 'auto' | 'iso' | 'en-GB' | 'en-US';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
@@ -135,6 +143,7 @@ interface SettingsState {
// Language & Region
dateFormat: DateFormat;
dateLocale: DateLocale;
timeFormat: TimeFormat;
firstDayOfWeek: FirstDayOfWeek;
@@ -346,6 +355,7 @@ const DEFAULT_SETTINGS = {
// Language & Region
dateFormat: 'smart' as DateFormat,
dateLocale: 'auto' as DateLocale,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
@@ -561,6 +571,7 @@ export const useSettingsStore = create<SettingsState>()(
density: state.density,
animationsEnabled: state.animationsEnabled,
dateFormat: state.dateFormat,
dateLocale: state.dateLocale,
timeFormat: state.timeFormat,
firstDayOfWeek: state.firstDayOfWeek,
markAsReadDelay: state.markAsReadDelay,