Files
native/src/lib/__tests__/date-format.test.ts
T
Linus Rath cb9a741244 fix: reuse the list's date formatters instead of building them per row
Every mail row formatted its date through `toLocaleTimeString` and
`toLocaleDateString`, which build a new Intl formatter on each call; a
row from the last week built two, on every render. On Hermes that goes
through ICU over JNI and is the costly part of formatting a date.

`formatListDate` now keeps one `Intl.DateTimeFormat` per locale and
format in a module-level map. The output is unchanged, since those
methods format exactly as `Intl.DateTimeFormat` with the same options;
a test checks the two against each other across locales, formats and
ages. The cache starts over when the device's UTC offset changes, so a
new time zone still shows as it did when every call picked it up.
2026-09-24 19:10:43 +02:00

160 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatListDate } from '../date-format';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('formatListDate', () => {
it('returns empty string for an invalid date', () => {
expect(formatListDate('not-a-date', { dateFormat: 'smart', timeFormat: '24h', locale: 'en' })).toBe('');
});
it('relative: minutes / hours / days ago', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
const opts = { dateFormat: 'relative' as const, timeFormat: '24h' as const, locale: 'en' };
expect(formatListDate(new Date(Date.now() - 5 * 60000), opts)).toBe('5m ago');
expect(formatListDate(new Date(Date.now() - 3 * 3600000), opts)).toBe('3h ago');
expect(formatListDate(new Date(Date.now() - 2 * 86400000), opts)).toBe('2d ago');
});
it('relative: "Just now" under a minute', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
expect(
formatListDate(new Date(Date.now() - 10_000), { dateFormat: 'relative', timeFormat: '24h', locale: 'en' }),
).toBe('Just now');
});
it('smart: same-day shows time only (24h, no AM/PM)', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
const out = formatListDate(new Date(), { dateFormat: 'smart', timeFormat: '24h', locale: 'en' });
expect(out).not.toMatch(/AM|PM/);
expect(out).toMatch(/^\d{1,2}:\d{2}$/);
});
it('smart: older than a week shows a numeric date', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
const out = formatListDate(new Date('2026-01-01T09:00:00Z'), {
dateFormat: 'smart',
timeFormat: '24h',
locale: 'en',
});
expect(out).toContain('2026');
});
it('full: always includes the year', () => {
const out = formatListDate('2026-04-28T15:31:00Z', { dateFormat: 'full', timeFormat: '24h', locale: 'en' });
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);
});
});