mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: format dates in the device region for the app language #75
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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' });
|
||||
|
||||
Reference in New Issue
Block a user