Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gottext/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 28 additions & 6 deletions gottext/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

/**
Expand Down
45 changes: 45 additions & 0 deletions gottext/test/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,51 @@ 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')
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(
formatDate('2026-09-30T12:00:00Z', { month: 'short', year: 'numeric' }),
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
})

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<Intl.DateTimeFormatOptions>([
{ 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')
Expand Down
Loading