fix: format dates in the device region for the app language #75

This commit is contained in:
Linus Rath
2026-10-15 15:15:19 +02:00
parent 9cf1d3e849
commit e731357b89
6 changed files with 147 additions and 20 deletions
+73
View File
@@ -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/);
});
});
+8 -10
View File
@@ -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);
}
+1 -4
View File
@@ -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<T extends DatedEmail>(email: T): DisplayDate<T>
return sentAt as DisplayDate<T>;
}
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 '';
+59
View File
@@ -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<string, string>();
// 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<typeof getLocales>;
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();
}
+2 -2
View File
@@ -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',
+4 -4
View File
@@ -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<string, string> {
const now = new Date();
const locale = context.locale || 'en';
const intlLocale = locale === 'en' ? 'en-US' : locale;
const intlTag = intlLocale(context.locale);
const values: Record<string, string> = {};
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' });