diff --git a/src/lib/__tests__/date-format.test.ts b/src/lib/__tests__/date-format.test.ts index c43b8d7f..1d983b73 100644 --- a/src/lib/__tests__/date-format.test.ts +++ b/src/lib/__tests__/date-format.test.ts @@ -3,6 +3,7 @@ import { formatListDate } from '../date-format'; afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); describe('formatListDate', () => { @@ -51,3 +52,108 @@ describe('formatListDate', () => { expect(out).toContain('2026'); }); }); + +// `formatListDate` as it was before it cached its Intl formatters: every call +// went through `toLocale*String`, which builds a new formatter each time. The +// cached version has to format exactly like it. +function formatListDateUncached( + date: Date, + opts: { dateFormat: 'smart' | 'relative' | 'full'; timeFormat: '12h' | '24h'; locale: string }, +): string { + const d = date; + const now = new Date(); + const locale = opts.locale && opts.locale.length > 0 ? opts.locale : 'en'; + const intlLocale = locale === 'en' ? 'en-US' : locale; + const hour12 = opts.timeFormat === '12h'; + if (opts.dateFormat === 'relative') { + const diff = now.getTime() - d.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + if (minutes < 1) return 'Just now'; + 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, { + month: 'short', + day: 'numeric', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); + } + if (opts.dateFormat === 'full') { + return d.toLocaleString(intlLocale, { + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12, + }); + } + const timeStr = d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit', hour12 }); + const isSameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (isSameDay) return timeStr; + const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000); + if (daysAgo < 7) { + const weekday = d.toLocaleDateString(intlLocale, { weekday: 'short' }).replace(/\.$/, ''); + return `${weekday} ${timeStr}`; + } + return d.toLocaleDateString(intlLocale, { year: 'numeric', month: '2-digit', day: '2-digit' }); +} + +describe('formatListDate with cached formatters', () => { + const NOW = new Date('2026-05-30T12:00:00Z'); + const HOUR = 3600000; + const DAY = 24 * HOUR; + const ages = [ + 30_000, 20 * 60000, 2 * HOUR, 11 * HOUR, 20 * HOUR, DAY, 3 * DAY, 6.9 * DAY, 8 * DAY, 40 * DAY, + 160 * DAY, 400 * DAY, -DAY, + ]; + const locales = ['en', '', 'de', 'fr', 'es', 'nl', 'pt-BR', 'ru', 'ja', 'zh', 'ko', 'ar', 'he', 'hi']; + + it('formats exactly as the per-call toLocale*String methods did', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + for (const locale of locales) { + for (const dateFormat of ['smart', 'relative', 'full'] as const) { + for (const timeFormat of ['12h', '24h'] as const) { + for (const age of ages) { + const d = new Date(NOW.getTime() - age); + const opts = { dateFormat, timeFormat, locale }; + expect(formatListDate(d, opts), `${locale} ${dateFormat} ${timeFormat} ${age}`) + .toBe(formatListDateUncached(d, opts)); + } + } + } + } + }); + + it('builds each formatter once per locale and format', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const ctor = vi.spyOn(Intl, 'DateTimeFormat'); + const opts = { dateFormat: 'smart' as const, timeFormat: '24h' as const, locale: 'sv' }; + const threeDaysAgo = new Date(NOW.getTime() - 3 * DAY); + // A weekday row needs the time and the weekday formatter. + const first = formatListDate(threeDaysAgo, opts); + expect(ctor).toHaveBeenCalledTimes(2); + for (let i = 0; i < 50; i++) expect(formatListDate(threeDaysAgo, opts)).toBe(first); + expect(ctor).toHaveBeenCalledTimes(2); + // Another locale gets its own. + formatListDate(threeDaysAgo, { ...opts, locale: 'da' }); + expect(ctor).toHaveBeenCalledTimes(4); + }); + + it('builds them again when the UTC offset changes', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const opts = { dateFormat: 'smart' as const, timeFormat: '24h' as const, locale: 'fi' }; + const earlier = new Date(NOW.getTime() - HOUR); + formatListDate(earlier, opts); + const ctor = vi.spyOn(Intl, 'DateTimeFormat'); + formatListDate(earlier, opts); + expect(ctor).not.toHaveBeenCalled(); + const offset = NOW.getTimezoneOffset(); + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(offset + 60); + formatListDate(earlier, opts); + expect(ctor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts index e6dc6c7f..51bd439d 100644 --- a/src/lib/date-format.ts +++ b/src/lib/date-format.ts @@ -16,6 +16,50 @@ import type { DateFormat, TimeFormat } from '../stores/settings-store'; */ type Translate = (key: string, fallback?: string, params?: Record) => string; +type FormatName = + | 'time12' | 'time24' | 'full12' | 'full24' | 'weekday' | 'date' | 'monthDay' | 'monthDayYear'; + +const FORMATS: Record = { + time12: { hour: '2-digit', minute: '2-digit', hour12: true }, + time24: { hour: '2-digit', minute: '2-digit', hour12: false }, + full12: { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: true }, + full24: { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }, + weekday: { weekday: 'short' }, + date: { year: 'numeric', month: '2-digit', day: '2-digit' }, + monthDay: { month: 'short', day: 'numeric' }, + monthDayYear: { month: 'short', day: 'numeric', year: 'numeric' }, +}; + +// Building an Intl formatter is the expensive part of formatting a date (on +// Hermes it goes through ICU over JNI), and `toLocaleString`, +// `toLocaleDateString` and `toLocaleTimeString` build a new one on every +// call. The list formats a date in every row render, so keep one formatter +// per locale and format. The output is the same: when the options name at +// least one field, those methods format exactly as `Intl.DateTimeFormat` +// does with the same options. +const formatters = new Map(); +// A formatter fixes the device time zone when it is built, where +// `toLocale*String` picked it up on every call. Start over when the UTC offset +// moves (travel, a DST switch) so a changed zone still shows. +let formattersOffset: number | undefined; + +function syncFormatterZone(now: Date): void { + const offset = now.getTimezoneOffset(); + if (offset === formattersOffset) return; + formatters.clear(); + formattersOffset = offset; +} + +function formatWith(d: Date, locale: string, name: FormatName): string { + const key = `${name}|${locale}`; + let formatter = formatters.get(key); + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale, FORMATS[name]); + formatters.set(key, formatter); + } + return formatter.format(d); +} + // Relative strings ("Just now", "5m ago") through the locale catalog when a // translate function is supplied; the compact English form otherwise. function relativeLabel( @@ -35,6 +79,7 @@ export function formatListDate( const d = typeof date === 'string' ? new Date(date) : date; if (isNaN(d.getTime())) return ''; const now = new Date(); + syncFormatterZone(now); const { dateFormat, timeFormat } = opts; const localeRaw = opts.locale; @@ -53,30 +98,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 d.toLocaleDateString(intlLocale, { - month: 'short', - day: 'numeric', - year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, - }); + return formatWith(d, intlLocale, d.getFullYear() !== now.getFullYear() ? 'monthDayYear' : 'monthDay'); } if (dateFormat === 'full') { - return d.toLocaleString(intlLocale, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12, - }); + return formatWith(d, intlLocale, hour12 ? 'full12' : 'full24'); } // 'smart' (default) - const timeStr = d.toLocaleTimeString(intlLocale, { - hour: '2-digit', - minute: '2-digit', - hour12, - }); + const timeStr = formatWith(d, intlLocale, hour12 ? 'time12' : 'time24'); const isSameDay = d.getFullYear() === now.getFullYear() && @@ -88,15 +118,9 @@ 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 = d - .toLocaleDateString(intlLocale, { weekday: 'short' }) - .replace(/\.$/, ''); + const weekday = formatWith(d, intlLocale, 'weekday').replace(/\.$/, ''); return `${weekday} ${timeStr}`; } - return d.toLocaleDateString(intlLocale, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); + return formatWith(d, intlLocale, 'date'); }