From a9090c7680dc549c8ee4785c371419e07c3376a7 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 05:18:15 +0100 Subject: [PATCH 1/2] Add locale-aware display date adapter --- .../src/tests/utils/displayDate.spec.ts | 94 +++++++++++++++ .../taskdeck-web/src/utils/displayDate.ts | 107 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts create mode 100644 frontend/taskdeck-web/src/utils/displayDate.ts diff --git a/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts b/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts new file mode 100644 index 000000000..5b7bad329 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + formatDisplayCalendarDate, + formatDisplayDate, + formatDisplayDateTime, + formatDisplayTime, +} from '../../utils/displayDate' + +const locales = ['en', 'it', 'es'] as const + +describe('display date adapter', () => { + it.each(locales)('formats an instant using the selected %s locale', (locale) => { + const value = '2024-02-29T23:30:00Z' + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + } + + const result = formatDisplayDate(value, locale, options) + const expected = new Intl.DateTimeFormat(locale, options).format(new Date(value)) + + expect(result).toBe(expected) + expect(result).not.toBeNull() + }) + + it('recomputes the label when the locale changes', () => { + const value = '2024-02-29T23:30:00Z' + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + } + + const english = formatDisplayDate(value, 'en', options) + const italian = formatDisplayDate(value, 'it', options) + const spanish = formatDisplayDate(value, 'es', options) + + expect(new Set([english, italian, spanish]).size).toBe(3) + }) + + it('keeps an instant near midnight in the explicitly selected timezone', () => { + const value = '2024-03-01T00:30:00Z' + const utcOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + } + const pacificOptions: Intl.DateTimeFormatOptions = { + ...utcOptions, + timeZone: 'America/Los_Angeles', + } + + expect(formatDisplayDate(value, 'en', utcOptions)) + .toBe(new Intl.DateTimeFormat('en', utcOptions).format(new Date(value))) + expect(formatDisplayDate(value, 'en', pacificOptions)) + .toBe(new Intl.DateTimeFormat('en', pacificOptions).format(new Date(value))) + expect(formatDisplayDate(value, 'en', utcOptions)) + .not.toBe(formatDisplayDate(value, 'en', pacificOptions)) + }) + + it('keeps calendar-only values on the calendar path', () => { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + + expect(formatDisplayCalendarDate('2024-02-29', 'en', options)) + .toBe(new Intl.DateTimeFormat('en', { ...options, timeZone: 'UTC' }).format(new Date('2024-02-29T00:00:00Z'))) + expect(formatDisplayDate('2024-02-29', 'en', options)).toBeNull() + }) + + it.each([ + null, + undefined, + '', + 'not-a-date', + new Date(Number.NaN), + ])('returns null for absent or invalid instant input: %s', (value) => { + expect(formatDisplayDateTime(value, 'en')).toBeNull() + expect(formatDisplayDate(value, 'en')).toBeNull() + expect(formatDisplayTime(value, 'en')).toBeNull() + }) + + it('returns null for invalid calendar-only values', () => { + expect(formatDisplayCalendarDate('2024-02-30', 'en')).toBeNull() + expect(formatDisplayCalendarDate('2024-02-29T00:00:00Z', 'en')).toBeNull() + }) +}) + diff --git a/frontend/taskdeck-web/src/utils/displayDate.ts b/frontend/taskdeck-web/src/utils/displayDate.ts new file mode 100644 index 000000000..620131126 --- /dev/null +++ b/frontend/taskdeck-web/src/utils/displayDate.ts @@ -0,0 +1,107 @@ +import { formatCalendarDate, isCalendarDateKey } from './dueDates' + +export type DisplayDateInput = string | Date | null | undefined + +const CALENDAR_DATE_KEY = /^\d{4}-\d{2}-\d{2}$/ + +const DISPLAY_DATE_DEFAULTS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', +} + +const DISPLAY_TIME_DEFAULTS: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', +} + +const DISPLAY_DATE_TIME_DEFAULTS: Intl.DateTimeFormatOptions = { + ...DISPLAY_DATE_DEFAULTS, + ...DISPLAY_TIME_DEFAULTS, +} + +function toInstantDate(value: DisplayDateInput): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value + } + + if (typeof value !== 'string') return null + + const normalized = value.trim() + if (!normalized || CALENDAR_DATE_KEY.test(normalized)) return null + + const date = new Date(normalized) + return Number.isNaN(date.getTime()) ? null : date +} + +function withDefaults( + defaults: Intl.DateTimeFormatOptions, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormatOptions { + if (options.dateStyle || options.timeStyle) return options + return { ...defaults, ...options } +} + +function formatInstant( + value: DisplayDateInput, + locale: string, + defaults: Intl.DateTimeFormatOptions, + options: Intl.DateTimeFormatOptions, +): string | null { + const date = toInstantDate(value) + if (!date) return null + + try { + return new Intl.DateTimeFormat(locale, withDefaults(defaults, options)).format(date) + } catch { + return null + } +} + +/** + * Format an instant with the active UI locale and caller-owned timezone policy. + * Plain YYYY-MM-DD values are rejected so calendar dates use the UTC-safe path. + */ +export function formatDisplayDate( + value: DisplayDateInput, + locale: string, + options: Intl.DateTimeFormatOptions = {}, +): string | null { + return formatInstant(value, locale, DISPLAY_DATE_DEFAULTS, options) +} + +/** Format an instant's date and time without retaining a module-scope locale. */ +export function formatDisplayDateTime( + value: DisplayDateInput, + locale: string, + options: Intl.DateTimeFormatOptions = {}, +): string | null { + return formatInstant(value, locale, DISPLAY_DATE_TIME_DEFAULTS, options) +} + +/** Format only the time portion of an instant. */ +export function formatDisplayTime( + value: DisplayDateInput, + locale: string, + options: Intl.DateTimeFormatOptions = {}, +): string | null { + return formatInstant(value, locale, DISPLAY_TIME_DEFAULTS, options) +} + +/** + * Format a persisted calendar-only YYYY-MM-DD key without projecting it through + * the browser timezone. The existing due-date utility owns the UTC semantics. + */ +export function formatDisplayCalendarDate( + value: string | null | undefined, + locale: string, + options: Intl.DateTimeFormatOptions = {}, +): string | null { + if (typeof value !== 'string' || !CALENDAR_DATE_KEY.test(value.trim()) || !isCalendarDateKey(value.trim())) return null + + try { + return formatCalendarDate(value, withDefaults(DISPLAY_DATE_DEFAULTS, options), locale) || null + } catch { + return null + } +} From f2082f8dab84c92a39490a1db4cd8c18465e8d43 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 08:26:13 +0100 Subject: [PATCH 2/2] test: remove date adapter spec trailing blank line --- frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts b/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts index 5b7bad329..e235b04d9 100644 --- a/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts +++ b/frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts @@ -91,4 +91,3 @@ describe('display date adapter', () => { expect(formatDisplayCalendarDate('2024-02-29T00:00:00Z', 'en')).toBeNull() }) }) -