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
93 changes: 93 additions & 0 deletions frontend/taskdeck-web/src/tests/utils/displayDate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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()
})
})
107 changes: 107 additions & 0 deletions frontend/taskdeck-web/src/utils/displayDate.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading