From e731357b89096ea41ee3ffb43ad9a78fdeb3eff1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:15:19 +0200 Subject: [PATCH] fix: format dates in the device region for the app language #75 --- src/lib/__tests__/intl-locale.test.ts | 73 +++++++++++++++++++++++++++ src/lib/date-format.ts | 18 +++---- src/lib/email-date.ts | 5 +- src/lib/intl-locale.ts | 59 ++++++++++++++++++++++ src/lib/quote-header.ts | 4 +- src/lib/template-utils.ts | 8 +-- 6 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 src/lib/__tests__/intl-locale.test.ts create mode 100644 src/lib/intl-locale.ts diff --git a/src/lib/__tests__/intl-locale.test.ts b/src/lib/__tests__/intl-locale.test.ts new file mode 100644 index 00000000..bb5bee65 --- /dev/null +++ b/src/lib/__tests__/intl-locale.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +type DeviceLocale = { languageTag: string; languageCode: string; regionCode?: string }; +const device = vi.hoisted(() => ({ locales: [] as DeviceLocale[] })); +vi.mock('expo-localization', () => ({ + getLocales: () => device.locales, + getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }], +})); + +import { intlLocale, resetIntlLocaleCache } from '../intl-locale'; +import { formatCalendarDate, formatListDate } from '../date-format'; +import { formatHeaderDate } from '../email-date'; + +function setDevice(...tags: string[]) { + device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] })); + resetIntlLocaleCache(); +} + +describe('intlLocale', () => { + beforeEach(() => setDevice()); + + it('uses the device region for the app language (#75)', () => { + setDevice('en-AU', 'en-US'); + expect(intlLocale('en')).toBe('en-AU'); + setDevice('de-CH'); + expect(intlLocale('de')).toBe('de-CH'); + }); + + it('picks the first device locale in the app language', () => { + setDevice('de-DE', 'en-GB'); + expect(intlLocale('en')).toBe('en-GB'); + }); + + it('falls back to en-US and the bare subtag without a matching device locale', () => { + setDevice('de-DE'); + expect(intlLocale('en')).toBe('en-US'); + expect(intlLocale(undefined)).toBe('en-US'); + expect(intlLocale('fr')).toBe('fr'); + }); + + it('drops Android regional-preference extensions', () => { + setDevice('en-AU-u-fw-mon-mu-celsius'); + expect(intlLocale('en')).toBe('en-AU'); + }); + + it('keeps a locale that already names a region', () => { + setDevice('zh-Hant-HK'); + expect(intlLocale('zh-TW')).toBe('zh-TW'); + }); + + it('maps a Norwegian device to the nb catalog', () => { + setDevice('no-NO'); + expect(intlLocale('nb')).toBe('no-NO'); + }); +}); + +describe('date formatters with an en-AU device', () => { + beforeEach(() => setDevice('en-AU', 'en-US')); + + it('writes the day first in the mail list', () => { + const old = new Date(Date.now() - 400 * 86400000); + const out = formatListDate(old, { dateFormat: 'smart', timeFormat: '24h', locale: 'en' }); + const expected = new Intl.DateTimeFormat('en-AU', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(old); + expect(out).toBe(expected); + expect(out).toMatch(new RegExp(`^${String(old.getDate()).padStart(2, '0')}/`)); + }); + + it('writes the day before the month in calendar and header dates', () => { + const d = new Date(2026, 9, 15, 12); + expect(formatCalendarDate(d, 'en', 'weekdayMonthDay')).toMatch(/15 Oct/); + expect(formatHeaderDate(d.toISOString(), 'en')).toMatch(/15 Oct/); + }); +}); diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts index 95e52604..e903882d 100644 --- a/src/lib/date-format.ts +++ b/src/lib/date-format.ts @@ -1,4 +1,5 @@ import type { DateFormat, TimeFormat } from '../stores/settings-store'; +import { intlLocale } from './intl-locale'; /** * Formats a received-at date for the email list. Mirrors the webmail @@ -87,9 +88,7 @@ export function formatListDate( const { dateFormat, timeFormat } = opts; const localeRaw = opts.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 intlTag = intlLocale(locale); const hour12 = timeFormat === '12h'; if (dateFormat === 'relative') { @@ -101,15 +100,15 @@ export function formatListDate( if (minutes < 60) return relativeLabel(opts.t, 'minute', minutes); if (hours < 24) return relativeLabel(opts.t, 'hour', hours); if (days < 7) return relativeLabel(opts.t, 'day', days); - return formatWith(d, intlLocale, d.getFullYear() !== now.getFullYear() ? 'monthDayYear' : 'monthDay'); + return formatWith(d, intlTag, d.getFullYear() !== now.getFullYear() ? 'monthDayYear' : 'monthDay'); } if (dateFormat === 'full') { - return formatWith(d, intlLocale, hour12 ? 'full12' : 'full24'); + return formatWith(d, intlTag, hour12 ? 'full12' : 'full24'); } // 'smart' (default) - const timeStr = formatWith(d, intlLocale, hour12 ? 'time12' : 'time24'); + const timeStr = formatWith(d, intlTag, hour12 ? 'time12' : 'time24'); const isSameDay = d.getFullYear() === now.getFullYear() && @@ -121,11 +120,11 @@ export function formatListDate( if (daysAgo < 7) { // German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`; // strip it so the result reads cleanly next to the time. - const weekday = formatWith(d, intlLocale, 'weekday').replace(/\.$/, ''); + const weekday = formatWith(d, intlTag, 'weekday').replace(/\.$/, ''); return `${weekday} ${timeStr}`; } - return formatWith(d, intlLocale, 'date'); + return formatWith(d, intlTag, 'date'); } export type CalendarDateStyle = 'weekdayMonthDay' | 'longWeekdayMonthDayYear' | 'monthDay' | 'monthDayYear'; @@ -138,6 +137,5 @@ export type CalendarDateStyle = 'weekdayMonthDay' | 'longWeekdayMonthDayYear' | */ export function formatCalendarDate(date: Date, locale: string | undefined, style: CalendarDateStyle): string { syncFormatterZone(new Date()); - const tag = !locale || locale === 'en' ? 'en-US' : locale; - return formatWith(date, tag, style); + return formatWith(date, intlLocale(locale), style); } diff --git a/src/lib/email-date.ts b/src/lib/email-date.ts index 90fb5aff..682189ef 100644 --- a/src/lib/email-date.ts +++ b/src/lib/email-date.ts @@ -15,6 +15,7 @@ */ import type { TimeFormat } from '../stores/settings-store'; +import { intlLocale } from './intl-locale'; /** How far ahead of `receivedAt` a `Date` header may be before it is ignored. */ export const MAX_FUTURE_SENT_AT_MS = 24 * 60 * 60 * 1000; @@ -39,10 +40,6 @@ export function emailDisplayDate(email: T): DisplayDate return sentAt as DisplayDate; } -function intlLocale(locale?: string): string { - return !locale || locale === 'en' ? 'en-US' : locale; -} - /** "Mon, 3 Jun 2026" in the app locale. */ export function formatHeaderDate(iso: string | undefined, locale?: string): string { if (!iso) return ''; diff --git a/src/lib/intl-locale.ts b/src/lib/intl-locale.ts new file mode 100644 index 00000000..c768559a --- /dev/null +++ b/src/lib/intl-locale.ts @@ -0,0 +1,59 @@ +// The app language is a bare language subtag ("en", "de"), which is right for +// picking a translation catalog but drops the device region that decides date +// order: en-AU writes 15/10/2026, en-US 10/15/2026. intlLocale() hands the +// date formatters the device's full tag when its language matches the app +// language, so an Australian or British phone gets its own dates (#75). +import { getLocales } from 'expo-localization'; +import { resolveLocaleTag } from '../i18n'; + +const cache = new Map(); + +// Android 14 appends regional preferences such as `-u-fw-mon-mu-celsius`. +// The app has its own time-format and week-start settings, so keep only the +// language, script and region. +function baseTag(tag: string): string { + return tag.split(/-[ux]-/i)[0]; +} + +function isUsable(tag: string): boolean { + try { + new Intl.DateTimeFormat(tag); + return true; + } catch { + return false; + } +} + +function deviceTagFor(locale: string): string | null { + let devices: ReturnType; + try { + devices = getLocales(); + } catch { + return null; + } + for (const l of devices) { + if (!l.languageTag || resolveLocaleTag(l) !== locale) continue; + const tag = baseTag(l.languageTag); + // A tag without a region adds nothing over the language subtag. + if (tag.includes('-') && isUsable(tag)) return tag; + } + return null; +} + +/** The Intl locale for formatting dates in app language `locale`. */ +export function intlLocale(locale: string | null | undefined): string { + const lang = locale || 'en'; + const hit = cache.get(lang); + if (hit) return hit; + // A locale that already names a region (zh-TW) is used as it is. + const resolved = lang.includes('-') + ? lang + : deviceTagFor(lang) ?? (lang === 'en' ? 'en-US' : lang); + cache.set(lang, resolved); + return resolved; +} + +/** Test hook: forget the resolved tags so a changed device locale is seen. */ +export function resetIntlLocaleCache(): void { + cache.clear(); +} diff --git a/src/lib/quote-header.ts b/src/lib/quote-header.ts index 7b62edcc..048a2cec 100644 --- a/src/lib/quote-header.ts +++ b/src/lib/quote-header.ts @@ -8,6 +8,7 @@ import { escapeHtml } from './email-html'; import type { TimeFormat } from '../stores/settings-store'; +import { intlLocale } from './intl-locale'; /** Localized label set the caller passes in (from the locale catalog). */ export interface QuoteHeaderLabels { @@ -65,9 +66,8 @@ export function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; - const intlLocale = !locale || locale === 'en' ? 'en-US' : locale; try { - return d.toLocaleString(intlLocale, { + return d.toLocaleString(intlLocale(locale), { weekday: 'short', year: 'numeric', month: 'short', diff --git a/src/lib/template-utils.ts b/src/lib/template-utils.ts index 2fea0181..cf61d392 100644 --- a/src/lib/template-utils.ts +++ b/src/lib/template-utils.ts @@ -4,6 +4,7 @@ import type { EmailTemplate } from '../stores/templates-store'; import { generateUUID } from './uuid'; +import { intlLocale } from './intl-locale'; export const BUILT_IN_PLACEHOLDERS = [ 'recipient_name', @@ -63,13 +64,12 @@ export function getAutoFilledPlaceholders( context: AutoFillContext, ): Record { const now = new Date(); - const locale = context.locale || 'en'; - const intlLocale = locale === 'en' ? 'en-US' : locale; + const intlTag = intlLocale(context.locale); const values: Record = {}; try { - values.date = now.toLocaleDateString(intlLocale, { year: 'numeric', month: 'long', day: 'numeric' }); - values.day_of_week = now.toLocaleDateString(intlLocale, { weekday: 'long' }); + values.date = now.toLocaleDateString(intlTag, { year: 'numeric', month: 'long', day: 'numeric' }); + values.day_of_week = now.toLocaleDateString(intlTag, { weekday: 'long' }); } catch { values.date = now.toDateString(); values.day_of_week = now.toLocaleDateString(undefined, { weekday: 'long' });