From 462494d0e841e6eebb05cecd1c13d2e148cff5ae Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 9 Nov 2026 00:00:10 +0100 Subject: [PATCH 1/2] fix(gottext): write a bare calendar day with only the parts it is handed --- gottext/CHANGELOG.md | 1 + gottext/src/format.ts | 34 +++++++++++++++++++++++----- gottext/test/format.test.ts | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index 8f51e57..29e1fcf 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -11,6 +11,7 @@ from CI. - `mismatched` reads the tags a message carries, so a translation that adds, drops, renames or repeats one is named. - `formatTime` writes a bare wall clock time, such as `09:30`, as the time it names for every reader, instead of Invalid Date. +- `formatDate` writes a bare calendar day with only the parts it is handed, and keeps its year in a calendar of its own. ## [0.8.0] - 2026-11-02 diff --git a/gottext/src/format.ts b/gottext/src/format.ts index c80fa9d..166b79e 100644 --- a/gottext/src/format.ts +++ b/gottext/src/format.ts @@ -17,6 +17,29 @@ const DAY: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year /** CLOCK is how a time is written. */ const CLOCK: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' } +/** DATE_OPTIONS names every option a date format reads, other than its time zone. */ +const DATE_OPTIONS = [ + 'calendar', + 'dateStyle', + 'day', + 'dayPeriod', + 'era', + 'formatMatcher', + 'fractionalSecondDigits', + 'hour', + 'hour12', + 'hourCycle', + 'localeMatcher', + 'minute', + 'month', + 'numberingSystem', + 'second', + 'timeStyle', + 'timeZoneName', + 'weekday', + 'year', +] as const satisfies readonly (keyof Intl.DateTimeFormatOptions)[] + /** * Stores the locale dates, times, numbers and money are written in. * @param locale - The locale to write in, or undefined to forget it. @@ -45,18 +68,17 @@ function writeDay(at: Date | string, locale: string, options: Intl.DateTimeForma return '' } const day = typeof at === 'string' && CALENDAR_DAY.test(at) - return new Date(at).toLocaleDateString(locale, day ? inUTC(locale, options) : options) + return new Date(at).toLocaleDateString(locale, day ? inUTC(options) : options) } /** - * Returns the options a caller handed as a date format resolves them, showing UTC. - * @param locale - The locale the date is written in. + * Returns the date options a caller handed, showing UTC. * @param options - The options the caller handed. * @returns The options a bare calendar day is shown with. */ -function inUTC(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions { - const resolved = new Intl.DateTimeFormat(locale, options).resolvedOptions() - return { ...resolved, timeZone: 'UTC' } as Intl.DateTimeFormatOptions +function inUTC(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions { + const handed = Object.fromEntries(DATE_OPTIONS.map((name) => [name, options[name]])) + return { ...handed, timeZone: 'UTC' } as Intl.DateTimeFormatOptions } /** diff --git a/gottext/test/format.test.ts b/gottext/test/format.test.ts index 9299486..7433360 100644 --- a/gottext/test/format.test.ts +++ b/gottext/test/format.test.ts @@ -54,6 +54,50 @@ test('writes a date with the options it is handed in the format locale', () => { expect(formatDate(new Date(2026, 6, 30), { month: 'long' })).toBe('julio') }) +test('writes a bare calendar day with only the parts it is handed in the format locale', () => { + rememberFormatLocale('es-ES') + + expect(formatDate('2026-09-30', { month: 'long', year: 'numeric' })).toBe('septiembre de 2026') + expect(formatDate('2026-09-30', { month: 'short', year: 'numeric' })).toBe( + formatDate('2026-09-30T12:00:00Z', { month: 'short', year: 'numeric' }), + ) +}) + +test('keeps the year of a bare calendar day in a calendar of its own', () => { + rememberFormatLocale('es-ES-u-ca-chinese') + vi.stubEnv('TZ', 'UTC') + + expect(formatDate('2026-08-20')).toContain('2026') + expect(formatDate('2026-08-20')).toBe(formatDate('2026-08-20T12:00:00Z')) +}) + +test.each([ + { calendar: 'chinese' }, + { numberingSystem: 'arab' }, + { era: 'long', year: 'numeric' }, + { weekday: 'long', day: 'numeric' }, + { day: 'numeric', hour: 'numeric' }, + { day: 'numeric', hour: 'numeric', minute: '2-digit' }, + { day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit' }, + { day: 'numeric', second: '2-digit', fractionalSecondDigits: 3 }, + { day: 'numeric', hour: 'numeric', dayPeriod: 'long' }, + { day: 'numeric', hour: 'numeric', hour12: false }, + { day: 'numeric', hour: 'numeric', hourCycle: 'h23' }, + { day: 'numeric', timeZoneName: 'short' }, +])('writes a bare calendar day handed %o as midnight UTC on that day', (options) => { + rememberFormatLocale('en-US') + vi.stubEnv('TZ', 'UTC') + + expect(formatDate('2026-09-01', options)).toBe(formatDate('2026-09-01T00:00:00Z', options)) +}) + +test('refuses a time style on a bare calendar day as on a timestamp', () => { + rememberFormatLocale('en-US') + + expect(() => formatDate('2026-09-01', { timeStyle: 'short' })).toThrow(TypeError) + expect(() => formatDate('2026-09-01T00:00:00Z', { timeStyle: 'short' })).toThrow(TypeError) +}) + test('writes a time as two digit hours and minutes on the es-ES clock', () => { rememberFormatLocale('es-ES') vi.stubEnv('TZ', 'UTC') From 587ec8171fa707d5982ee2e102358c88771fce5b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 9 Nov 2026 12:24:08 +0100 Subject: [PATCH 2/2] test(gottext): pin the bare day options test to UTC --- gottext/test/format.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/gottext/test/format.test.ts b/gottext/test/format.test.ts index 7433360..53a7181 100644 --- a/gottext/test/format.test.ts +++ b/gottext/test/format.test.ts @@ -56,6 +56,7 @@ test('writes a date with the options it is handed in the format locale', () => { test('writes a bare calendar day with only the parts it is handed in the format locale', () => { rememberFormatLocale('es-ES') + vi.stubEnv('TZ', 'UTC') expect(formatDate('2026-09-30', { month: 'long', year: 'numeric' })).toBe('septiembre de 2026') expect(formatDate('2026-09-30', { month: 'short', year: 'numeric' })).toBe(