diff --git a/client/src/components/Admin/plugins/VersionLine.tsx b/client/src/components/Admin/plugins/VersionLine.tsx index 154bf9b9e..069d1c06d 100644 --- a/client/src/components/Admin/plugins/VersionLine.tsx +++ b/client/src/components/Admin/plugins/VersionLine.tsx @@ -1,16 +1,25 @@ -import { ShieldCheck } from 'lucide-react' -import { useFormatDate } from '../../../hooks/useDateFormat' -import { fs } from '../../shared/DialogShell' -import type { VersionInfo } from './pluginModel' +import { ShieldCheck } from 'lucide-react'; +import { useFormatDate } from '../../../hooks/useDateFormat'; +import { fs } from '../../shared/DialogShell'; +import type { VersionInfo } from './pluginModel'; /** A version number with its date and, when the author signed it, a quiet tick. */ export default function VersionLine({ v }: { v: VersionInfo }) { - const { formatDate } = useFormatDate() + const { formatDate } = useFormatDate(); return ( - v{v.version} - {v.publishedAt && {formatDate(new Date(v.publishedAt))}} + + v{v.version} + + {v.publishedAt && ( + + {formatDate(new Date(v.publishedAt))} + + )} {v.signed && } - ) + ); } diff --git a/client/src/components/Admin/useBackupAdmin.ts b/client/src/components/Admin/useBackupAdmin.ts index 1c43726c2..d126bbaee 100644 --- a/client/src/components/Admin/useBackupAdmin.ts +++ b/client/src/components/Admin/useBackupAdmin.ts @@ -1,8 +1,8 @@ import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react'; import { backupApi } from '../../api/client'; -import { useTranslation } from '../../i18n'; import { useFormatDate } from '../../hooks/useDateFormat'; +import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { getApiErrorMessage } from '../../types'; import { useToast } from '../shared/Toast'; diff --git a/client/src/components/Budget/costsModel.ts b/client/src/components/Budget/costsModel.ts index 5b605cc18..cf0126b96 100644 --- a/client/src/components/Budget/costsModel.ts +++ b/client/src/components/Budget/costsModel.ts @@ -1,9 +1,9 @@ import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared'; +import { readDateFormat } from '../../hooks/useDateFormat'; import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates'; import type { BudgetItem } from '../../types'; -import { readDateFormat } from '../../hooks/useDateFormat'; -import { currencyDecimals } from '../../utils/formatters'; import { formatDisplayDate } from '../../utils/dateFormat'; +import { currencyDecimals } from '../../utils/formatters'; import { currenciesWith, SYMBOLS } from './BudgetPanel.constants'; import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers'; import { catMeta, COST_CATEGORY_LIST } from './costsCategories'; @@ -380,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil const fmtDate = (iso: string) => { if (!iso) return ''; try { - return formatDisplayDate(new Date(iso + 'T00:00:00Z'), opts.locale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - timeZone: 'UTC', - }, readDateFormat()); + return formatDisplayDate( + new Date(iso + 'T00:00:00Z'), + opts.locale, + { + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', + }, + readDateFormat() + ); } catch { return iso; } diff --git a/client/src/components/Roadtrip/ChargingInfo.tsx b/client/src/components/Roadtrip/ChargingInfo.tsx index 51f39b75f..b354215ea 100644 --- a/client/src/components/Roadtrip/ChargingInfo.tsx +++ b/client/src/components/Roadtrip/ChargingInfo.tsx @@ -1,8 +1,8 @@ import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared'; import { ChevronDown, Coins, PlugZap } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { useTranslation } from '../../i18n/TranslationContext'; import { useFormatDate } from '../../hooks/useDateFormat'; +import { useTranslation } from '../../i18n/TranslationContext'; import { chargingRepo } from '../../repo/chargingRepo'; import { useTripStore } from '../../store/tripStore'; import { isEffectivelyOffline } from '../../sync/networkMode'; diff --git a/client/src/components/Settings/useAccountSettings.ts b/client/src/components/Settings/useAccountSettings.ts index 49fdf7292..c31d0de65 100644 --- a/client/src/components/Settings/useAccountSettings.ts +++ b/client/src/components/Settings/useAccountSettings.ts @@ -2,11 +2,11 @@ import { escapeHtml } from '@trek/shared'; import React, { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { adminApi, authApi } from '../../api/client'; -import { useTranslation } from '../../i18n'; import { useDateFormat } from '../../hooks/useDateFormat'; -import { formatDisplayDateTime } from '../../utils/dateFormat'; +import { useTranslation } from '../../i18n'; import { useAuthStore } from '../../store/authStore'; import { getApiErrorMessage, type UserWithOidc } from '../../types'; +import { formatDisplayDateTime } from '../../utils/dateFormat'; import { passwordErrorKey } from '../../utils/passwordError'; import { useToast } from '../shared/Toast'; diff --git a/client/src/components/Settings/usePasskeys.ts b/client/src/components/Settings/usePasskeys.ts index b2f0a3472..4dc2a2ba9 100644 --- a/client/src/components/Settings/usePasskeys.ts +++ b/client/src/components/Settings/usePasskeys.ts @@ -1,11 +1,11 @@ import { startRegistration } from '@simplewebauthn/browser'; import { useEffect, useState } from 'react'; import { authApi, type PasskeyCredential } from '../../api/client'; +import { readDateFormat } from '../../hooks/useDateFormat'; import { useTranslation } from '../../i18n'; import { getApiErrorMessage } from '../../types'; -import { useToast } from '../shared/Toast'; -import { readDateFormat } from '../../hooks/useDateFormat'; import { formatDisplayDate } from '../../utils/dateFormat'; +import { useToast } from '../shared/Toast'; /** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */ export function fmtDate(ts: string | null): string | null { diff --git a/client/src/components/Settings/usePluginActivity.ts b/client/src/components/Settings/usePluginActivity.ts index 56aa1774f..19af5122e 100644 --- a/client/src/components/Settings/usePluginActivity.ts +++ b/client/src/components/Settings/usePluginActivity.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { pluginsApi } from '../../api/client'; -import { useTranslation } from '../../i18n'; import { useDateFormat } from '../../hooks/useDateFormat'; +import { useTranslation } from '../../i18n'; import { formatDisplayDateTime } from '../../utils/dateFormat'; export interface ActivityRow { diff --git a/client/src/components/Tours/wanderer/WandererBrowserRail.tsx b/client/src/components/Tours/wanderer/WandererBrowserRail.tsx index bb662dee6..a8e842355 100644 --- a/client/src/components/Tours/wanderer/WandererBrowserRail.tsx +++ b/client/src/components/Tours/wanderer/WandererBrowserRail.tsx @@ -18,8 +18,8 @@ import { } from 'lucide-react'; import type { ReactNode } from 'react'; import { Link } from 'react-router'; -import { useTranslation } from '../../../i18n'; import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { formatDisplayDate } from '../../../utils/dateFormat'; import { formatDistance, formatElevation } from '../../../utils/units'; diff --git a/client/src/hooks/useDateFormat.ts b/client/src/hooks/useDateFormat.ts index a1c126a8d..625d092fc 100644 --- a/client/src/hooks/useDateFormat.ts +++ b/client/src/hooks/useDateFormat.ts @@ -1,17 +1,17 @@ -import { DEFAULT_DATE_FORMAT, type DateFormat } from '@trek/shared' -import { useCallback } from 'react' -import { useTranslation } from '../i18n' -import { useSettingsStore } from '../store/settingsStore' -import { formatDisplayDate, formatDisplayDateTime } from '../utils/dateFormat' +import { DEFAULT_DATE_FORMAT, type DateFormat } from '@trek/shared'; +import { useCallback } from 'react'; +import { useTranslation } from '../i18n'; +import { useSettingsStore } from '../store/settingsStore'; +import { formatDisplayDate, formatDisplayDateTime } from '../utils/dateFormat'; /** The current user's date format (#2971); 'auto' follows the interface language. */ export function useDateFormat(): DateFormat { - return useSettingsStore((s) => s.settings.date_format) ?? DEFAULT_DATE_FORMAT + return useSettingsStore((s) => s.settings.date_format) ?? DEFAULT_DATE_FORMAT; } /** The same value outside React (stores, exports, plain helpers), read when called. */ export function readDateFormat(): DateFormat { - return useSettingsStore.getState().settings.date_format ?? DEFAULT_DATE_FORMAT + return useSettingsStore.getState().settings.date_format ?? DEFAULT_DATE_FORMAT; } /** @@ -19,15 +19,15 @@ export function readDateFormat(): DateFormat { * the user's date format, for components that write dates. */ export function useFormatDate() { - const { locale } = useTranslation() - const format = useDateFormat() + const { locale } = useTranslation(); + const format = useDateFormat(); const date = useCallback( (value: Date, opts?: Intl.DateTimeFormatOptions) => formatDisplayDate(value, locale, opts, format), [locale, format] - ) + ); const dateTime = useCallback( (value: Date, opts?: Intl.DateTimeFormatOptions) => formatDisplayDateTime(value, locale, opts, format), [locale, format] - ) - return { formatDate: date, formatDateTime: dateTime, locale, dateFormat: format } + ); + return { formatDate: date, formatDateTime: dateTime, locale, dateFormat: format }; } diff --git a/client/src/mobile/screens/admin/MPluginVersionDate.tsx b/client/src/mobile/screens/admin/MPluginVersionDate.tsx index 0cfb846c5..a924ed942 100644 --- a/client/src/mobile/screens/admin/MPluginVersionDate.tsx +++ b/client/src/mobile/screens/admin/MPluginVersionDate.tsx @@ -1,8 +1,8 @@ -import { useFormatDate } from '../../../hooks/useDateFormat' +import { useFormatDate } from '../../../hooks/useDateFormat'; /** The day a plugin version was published, in the user's date format (#2971). */ export default function MPluginVersionDate({ at, className }: { at?: string | null; className?: string }) { - const { formatDate } = useFormatDate() - if (!at) return null - return {formatDate(new Date(at))} + const { formatDate } = useFormatDate(); + if (!at) return null; + return {formatDate(new Date(at))}; } diff --git a/client/src/utils/dateFormat.test.ts b/client/src/utils/dateFormat.test.ts index 9a7b82e1a..881debc1a 100644 --- a/client/src/utils/dateFormat.test.ts +++ b/client/src/utils/dateFormat.test.ts @@ -1,5 +1,5 @@ // FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-020 -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest'; import { dateFieldOrder, dateFormatOptions, @@ -8,12 +8,12 @@ import { formatDisplayDate, formatDisplayDateTime, isNumericDate, -} from './dateFormat' +} from './dateFormat'; // 31 December 2026, 15:04 UTC: no day of it reads as a month, so every layout is unambiguous. -const NYE = new Date(Date.UTC(2026, 11, 31, 15, 4)) -const UTC = { timeZone: 'UTC' } as const -const NUMERIC: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' } +const NYE = new Date(Date.UTC(2026, 11, 31, 15, 4)); +const UTC = { timeZone: 'UTC' } as const; +const NUMERIC: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }; describe('dateFormat (#2971)', () => { describe('formatDisplayDate', () => { @@ -24,47 +24,54 @@ describe('dateFormat (#2971)', () => { ['de-DE', NUMERIC], ['ko-KR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }], ['en-GB', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }], - ] + ]; for (const [locale, opts] of cases) { - const expected = NYE.toLocaleDateString(locale, opts) - expect(formatDisplayDate(NYE, locale, opts, 'auto')).toBe(expected) - expect(formatDisplayDate(NYE, locale, opts, 'DD-MM-YY')).toBe(expected) - expect(formatDisplayDate(NYE, locale, opts, null)).toBe(expected) - expect(formatDisplayDate(NYE, locale, opts)).toBe(expected) + const expected = NYE.toLocaleDateString(locale, opts); + expect(formatDisplayDate(NYE, locale, opts, 'auto')).toBe(expected); + expect(formatDisplayDate(NYE, locale, opts, 'DD-MM-YY')).toBe(expected); + expect(formatDisplayDate(NYE, locale, opts, null)).toBe(expected); + expect(formatDisplayDate(NYE, locale, opts)).toBe(expected); } - }) + }); it('FE-UTIL-DATEFMT-002: each format writes 31 December 2026 in its own layout', () => { - expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY-MM-DD')).toBe('2026-12-31') - expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY/MM/DD')).toBe('2026/12/31') - expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD.MM.YYYY')).toBe('31.12.2026') - expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe('31/12/2026') - expect(formatDisplayDate(NYE, 'de-DE', UTC, 'MM/DD/YYYY')).toBe('12/31/2026') - }) + expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY-MM-DD')).toBe('2026-12-31'); + expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY/MM/DD')).toBe('2026/12/31'); + expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD.MM.YYYY')).toBe('31.12.2026'); + expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe('31/12/2026'); + expect(formatDisplayDate(NYE, 'de-DE', UTC, 'MM/DD/YYYY')).toBe('12/31/2026'); + }); it('FE-UTIL-DATEFMT-003: the language does not change a chosen format', () => { for (const locale of ['en-US', 'de-DE', 'ko-KR', 'ar', 'fr-FR']) { - expect(formatDisplayDate(NYE, locale, NUMERIC, 'YYYY-MM-DD')).toBe('2026-12-31') + expect(formatDisplayDate(NYE, locale, NUMERIC, 'YYYY-MM-DD')).toBe('2026-12-31'); } - }) + }); it('FE-UTIL-DATEFMT-004: single-digit days and months are padded', () => { - const march = new Date(Date.UTC(2026, 2, 5)) - expect(formatDisplayDate(march, 'en-US', { day: 'numeric', month: 'numeric', year: 'numeric', timeZone: 'UTC' }, 'DD.MM.YYYY')).toBe('05.03.2026') - }) + const march = new Date(Date.UTC(2026, 2, 5)); + expect( + formatDisplayDate( + march, + 'en-US', + { day: 'numeric', month: 'numeric', year: 'numeric', timeZone: 'UTC' }, + 'DD.MM.YYYY' + ) + ).toBe('05.03.2026'); + }); it('FE-UTIL-DATEFMT-005: year 2-digit keeps two digits of the year', () => { - const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } - expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12.26') - expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('26-12-31') - }) + const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }; + expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12.26'); + expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('26-12-31'); + }); it('FE-UTIL-DATEFMT-006: day and month without a year write only those two, in layout order', () => { - const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'numeric', timeZone: 'UTC' } - expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12') - expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('12-31') - expect(formatDisplayDate(NYE, 'en-US', opts, 'MM/DD/YYYY')).toBe('12/31') - }) + const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'numeric', timeZone: 'UTC' }; + expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12'); + expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('12-31'); + expect(formatDisplayDate(NYE, 'en-US', opts, 'MM/DD/YYYY')).toBe('12/31'); + }); it('FE-UTIL-DATEFMT-007: a month name or a long dateStyle is left to Intl', () => { const named: Intl.DateTimeFormatOptions[] = [ @@ -72,112 +79,135 @@ describe('dateFormat (#2971)', () => { { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }, { month: 'long', year: 'numeric', timeZone: 'UTC' }, { dateStyle: 'medium', timeZone: 'UTC' }, - ] + ]; for (const opts of named) { - expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleDateString('de-DE', opts)) + expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleDateString('de-DE', opts)); } - }) + }); it('FE-UTIL-DATEFMT-021: a weekday beside a numeric date keeps the language, the date takes the format', () => { - const opts: Intl.DateTimeFormatOptions = { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' } - expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(`${NYE.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' })}, 2026-12-31`) - expect(formatDisplayDate(NYE, 'de-DE', opts, 'auto')).toBe(NYE.toLocaleDateString('de-DE', opts)) - }) + const opts: Intl.DateTimeFormatOptions = { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', + }; + expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe( + `${NYE.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' })}, 2026-12-31` + ); + expect(formatDisplayDate(NYE, 'de-DE', opts, 'auto')).toBe(NYE.toLocaleDateString('de-DE', opts)); + }); it('FE-UTIL-DATEFMT-008: dateStyle short counts as numeric', () => { - expect(formatDisplayDate(NYE, 'en-US', { dateStyle: 'short', timeZone: 'UTC' }, 'DD/MM/YYYY')).toBe('31/12/2026') - }) + expect(formatDisplayDate(NYE, 'en-US', { dateStyle: 'short', timeZone: 'UTC' }, 'DD/MM/YYYY')).toBe('31/12/2026'); + }); it('FE-UTIL-DATEFMT-009: an invalid date falls back to toLocaleDateString', () => { - const invalid = new Date('nope') - expect(formatDisplayDate(invalid, 'en-US', NUMERIC, 'YYYY-MM-DD')).toBe(invalid.toLocaleDateString('en-US', NUMERIC)) - }) - }) + const invalid = new Date('nope'); + expect(formatDisplayDate(invalid, 'en-US', NUMERIC, 'YYYY-MM-DD')).toBe( + invalid.toLocaleDateString('en-US', NUMERIC) + ); + }); + }); describe('formatDisplayDateTime', () => { it('FE-UTIL-DATEFMT-010: auto, an unknown value or none is exactly toLocaleString', () => { - const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' } + const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' }; for (const locale of ['en-US', 'de-DE']) { - expect(formatDisplayDateTime(NYE, locale, opts, 'auto')).toBe(NYE.toLocaleString(locale, opts)) - expect(formatDisplayDateTime(NYE, locale, opts, 'nope')).toBe(NYE.toLocaleString(locale, opts)) - expect(formatDisplayDateTime(NYE, locale, UTC)).toBe(NYE.toLocaleString(locale, UTC)) + expect(formatDisplayDateTime(NYE, locale, opts, 'auto')).toBe(NYE.toLocaleString(locale, opts)); + expect(formatDisplayDateTime(NYE, locale, opts, 'nope')).toBe(NYE.toLocaleString(locale, opts)); + expect(formatDisplayDateTime(NYE, locale, UTC)).toBe(NYE.toLocaleString(locale, UTC)); } - }) + }); it('FE-UTIL-DATEFMT-011: the date follows the format, the time of day stays Intl', () => { - const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' } - const time = NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) - expect(formatDisplayDateTime(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe(`2026-12-31 ${time}`) - const hour12 = { ...opts, hour12: false } - const time24 = NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' }) - expect(formatDisplayDateTime(NYE, 'en-US', hour12, 'DD.MM.YYYY')).toBe(`31.12.2026 ${time24}`) - }) + const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' }; + const time = NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }); + expect(formatDisplayDateTime(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe(`2026-12-31 ${time}`); + const hour12 = { ...opts, hour12: false }; + const time24 = NYE.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + }); + expect(formatDisplayDateTime(NYE, 'en-US', hour12, 'DD.MM.YYYY')).toBe(`31.12.2026 ${time24}`); + }); it('FE-UTIL-DATEFMT-012: without options it writes the date and the default time', () => { expect(formatDisplayDateTime(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe( `31/12/2026 ${NYE.toLocaleTimeString('en-US', UTC)}` - ) - }) + ); + }); it('FE-UTIL-DATEFMT-013: time-only options are passed through untouched', () => { - const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' } - expect(formatDisplayDateTime(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('de-DE', opts)) - }) + const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }; + expect(formatDisplayDateTime(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('de-DE', opts)); + }); it('FE-UTIL-DATEFMT-014: date-only options give just the date, named dates stay Intl', () => { - expect(formatDisplayDateTime(NYE, 'en-US', NUMERIC, 'YYYY/MM/DD')).toBe('2026/12/31') - const named: Intl.DateTimeFormatOptions = { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', timeZone: 'UTC' } - expect(formatDisplayDateTime(NYE, 'en-US', named, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('en-US', named)) - }) - }) + expect(formatDisplayDateTime(NYE, 'en-US', NUMERIC, 'YYYY/MM/DD')).toBe('2026/12/31'); + const named: Intl.DateTimeFormatOptions = { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + timeZone: 'UTC', + }; + expect(formatDisplayDateTime(NYE, 'en-US', named, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('en-US', named)); + }); + }); describe('displayDateFormatter', () => { it('FE-UTIL-DATEFMT-015: auto behaves like Intl.DateTimeFormat', () => { - const intl = new Intl.DateTimeFormat('en-US', NUMERIC) - expect(displayDateFormatter('en-US', NUMERIC, 'auto').format(NYE)).toBe(intl.format(NYE)) - expect(displayDateFormatter('en-US', NUMERIC).format(NYE.getTime())).toBe(intl.format(NYE)) - }) + const intl = new Intl.DateTimeFormat('en-US', NUMERIC); + expect(displayDateFormatter('en-US', NUMERIC, 'auto').format(NYE)).toBe(intl.format(NYE)); + expect(displayDateFormatter('en-US', NUMERIC).format(NYE.getTime())).toBe(intl.format(NYE)); + }); it('FE-UTIL-DATEFMT-016: numeric dates follow the format, for Date and timestamp alike', () => { - const f = displayDateFormatter('en-US', NUMERIC, 'DD.MM.YYYY') - expect(f.format(NYE)).toBe('31.12.2026') - expect(f.format(NYE.getTime())).toBe('31.12.2026') - const withTime = displayDateFormatter('en-US', { ...NUMERIC, hour: '2-digit', minute: '2-digit' }, 'YYYY-MM-DD') + const f = displayDateFormatter('en-US', NUMERIC, 'DD.MM.YYYY'); + expect(f.format(NYE)).toBe('31.12.2026'); + expect(f.format(NYE.getTime())).toBe('31.12.2026'); + const withTime = displayDateFormatter('en-US', { ...NUMERIC, hour: '2-digit', minute: '2-digit' }, 'YYYY-MM-DD'); expect(withTime.format(NYE)).toBe( `2026-12-31 ${NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })}` - ) - }) + ); + }); it('FE-UTIL-DATEFMT-017: a named month is left to Intl', () => { - const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', timeZone: 'UTC' } - expect(displayDateFormatter('de-DE', opts, 'YYYY-MM-DD').format(NYE)).toBe(new Intl.DateTimeFormat('de-DE', opts).format(NYE)) - }) - }) + const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', timeZone: 'UTC' }; + expect(displayDateFormatter('de-DE', opts, 'YYYY-MM-DD').format(NYE)).toBe( + new Intl.DateTimeFormat('de-DE', opts).format(NYE) + ); + }); + }); describe('dateFieldOrder, dateLayout and isNumericDate', () => { it('FE-UTIL-DATEFMT-018: a chosen format sets the order, auto asks the language', () => { - expect(dateFieldOrder('en-US', 'DD.MM.YYYY')).toEqual(['day', 'month', 'year']) - expect(dateFieldOrder('de-DE', 'MM/DD/YYYY')).toEqual(['month', 'day', 'year']) - expect(dateFieldOrder('en-US', 'YYYY/MM/DD')).toEqual(['year', 'month', 'day']) - expect(dateFieldOrder('de-DE', 'auto')).toEqual(['day', 'month', 'year']) - expect(dateFieldOrder('en-US')).toEqual(['month', 'day', 'year']) - expect(dateFieldOrder('ko-KR', null)).toEqual(['year', 'month', 'day']) - }) + expect(dateFieldOrder('en-US', 'DD.MM.YYYY')).toEqual(['day', 'month', 'year']); + expect(dateFieldOrder('de-DE', 'MM/DD/YYYY')).toEqual(['month', 'day', 'year']); + expect(dateFieldOrder('en-US', 'YYYY/MM/DD')).toEqual(['year', 'month', 'day']); + expect(dateFieldOrder('de-DE', 'auto')).toEqual(['day', 'month', 'year']); + expect(dateFieldOrder('en-US')).toEqual(['month', 'day', 'year']); + expect(dateFieldOrder('ko-KR', null)).toEqual(['year', 'month', 'day']); + }); it('FE-UTIL-DATEFMT-019: dateLayout knows the five formats only, isNumericDate spots names', () => { - expect(dateLayout('DD/MM/YYYY')).toEqual({ order: ['day', 'month', 'year'], separator: '/' }) - expect(dateLayout('auto')).toBeNull() - expect(dateLayout('nope')).toBeNull() - expect(dateLayout(undefined)).toBeNull() - expect(isNumericDate()).toBe(true) - expect(isNumericDate({ month: '2-digit' })).toBe(true) - expect(isNumericDate({ month: 'short' })).toBe(false) - expect(isNumericDate({ weekday: 'long' })).toBe(false) - expect(isNumericDate({ era: 'short' })).toBe(false) - expect(isNumericDate({ dateStyle: 'short' })).toBe(true) - expect(isNumericDate({ dateStyle: 'long' })).toBe(false) - }) - }) + expect(dateLayout('DD/MM/YYYY')).toEqual({ order: ['day', 'month', 'year'], separator: '/' }); + expect(dateLayout('auto')).toBeNull(); + expect(dateLayout('nope')).toBeNull(); + expect(dateLayout(undefined)).toBeNull(); + expect(isNumericDate()).toBe(true); + expect(isNumericDate({ month: '2-digit' })).toBe(true); + expect(isNumericDate({ month: 'short' })).toBe(false); + expect(isNumericDate({ weekday: 'long' })).toBe(false); + expect(isNumericDate({ era: 'short' })).toBe(false); + expect(isNumericDate({ dateStyle: 'short' })).toBe(true); + expect(isNumericDate({ dateStyle: 'long' })).toBe(false); + }); + }); describe('dateFormatOptions', () => { it('FE-UTIL-DATEFMT-020: every choice is labelled by how it writes 31 December, auto with its example', () => { @@ -188,8 +218,11 @@ describe('dateFormat (#2971)', () => { { value: 'DD.MM.YYYY', label: '31.12.2026' }, { value: 'DD/MM/YYYY', label: '31/12/2026' }, { value: 'MM/DD/YYYY', label: '12/31/2026' }, - ]) - expect(dateFormatOptions('de-DE', 'Automatisch')[0]).toEqual({ value: 'auto', label: 'Automatisch (31.12.2026)' }) - }) - }) -}) + ]); + expect(dateFormatOptions('de-DE', 'Automatisch')[0]).toEqual({ + value: 'auto', + label: 'Automatisch (31.12.2026)', + }); + }); + }); +}); diff --git a/client/src/utils/dateFormat.ts b/client/src/utils/dateFormat.ts index bf244624c..ce562d38f 100644 --- a/client/src/utils/dateFormat.ts +++ b/client/src/utils/dateFormat.ts @@ -1,4 +1,4 @@ -import { DATE_FORMAT_VALUES, type DateFormat } from '@trek/shared' +import { DATE_FORMAT_VALUES, type DateFormat } from '@trek/shared'; /** * The user's date format (#2971), applied wherever a date is written in numbers. @@ -14,11 +14,11 @@ import { DATE_FORMAT_VALUES, type DateFormat } from '@trek/shared' * setting answers "how do I read 03/11", not "what is November called". */ -export type DateField = 'day' | 'month' | 'year' +export type DateField = 'day' | 'month' | 'year'; interface DateLayout { - order: DateField[] - separator: string + order: DateField[]; + separator: string; } const LAYOUTS: Record, DateLayout> = { @@ -27,16 +27,16 @@ const LAYOUTS: Record, DateLayout> = { 'DD.MM.YYYY': { order: ['day', 'month', 'year'], separator: '.' }, 'DD/MM/YYYY': { order: ['day', 'month', 'year'], separator: '/' }, 'MM/DD/YYYY': { order: ['month', 'day', 'year'], separator: '/' }, -} +}; /** The order and separator of a chosen format; null for 'auto' or anything unknown. */ export function dateLayout(format: string | null | undefined): DateLayout | null { - if (!format || format === 'auto') return null - return LAYOUTS[format as keyof typeof LAYOUTS] ?? null + if (!format || format === 'auto') return null; + return LAYOUTS[format as keyof typeof LAYOUTS] ?? null; } -const DATE_KEYS = ['day', 'month', 'year'] as const -const TIME_KEYS = ['hour', 'minute', 'second', 'dayPeriod', 'fractionalSecondDigits'] as const +const DATE_KEYS = ['day', 'month', 'year'] as const; +const TIME_KEYS = ['hour', 'minute', 'second', 'dayPeriod', 'fractionalSecondDigits'] as const; /** * Whether these options write the date in numbers only: no weekday, no month @@ -44,42 +44,42 @@ const TIME_KEYS = ['hour', 'minute', 'second', 'dayPeriod', 'fractionalSecondDig * styles name the month. */ export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}): boolean { - if (opts.weekday || opts.era) return false - if (opts.dateStyle) return opts.dateStyle === 'short' - return !opts.month || opts.month === 'numeric' || opts.month === '2-digit' + if (opts.weekday || opts.era) return false; + if (opts.dateStyle) return opts.dateStyle === 'short'; + return !opts.month || opts.month === 'numeric' || opts.month === '2-digit'; } const hasAny = (opts: Intl.DateTimeFormatOptions, keys: readonly string[]) => - keys.some((key) => (opts as Record)[key] !== undefined) + keys.some((key) => (opts as Record)[key] !== undefined); /** The date in the layout's order, each field the options ask for (all three when they name none). */ function numericDate(date: Date, opts: Intl.DateTimeFormatOptions, layout: DateLayout): string { - const named = opts.dateStyle ? DATE_KEYS : DATE_KEYS.filter((key) => opts[key] !== undefined) - const fields = named.length > 0 ? named : DATE_KEYS + const named = opts.dateStyle ? DATE_KEYS : DATE_KEYS.filter((key) => opts[key] !== undefined); + const fields = named.length > 0 ? named : DATE_KEYS; const parts = new Intl.DateTimeFormat('en-US', { timeZone: opts.timeZone, year: 'numeric', month: '2-digit', day: '2-digit', numberingSystem: 'latn', - }).formatToParts(date) - const value = (type: DateField) => parts.find((part) => part.type === type)?.value ?? '' - const shortYear = opts.year === '2-digit' + }).formatToParts(date); + const value = (type: DateField) => parts.find((part) => part.type === type)?.value ?? ''; + const shortYear = opts.year === '2-digit'; return layout.order .filter((field) => (fields as readonly string[]).includes(field)) .map((field) => (field === 'year' && shortYear ? value('year').slice(-2) : value(field))) - .join(layout.separator) + .join(layout.separator); } /** Only the time-of-day options, for the clock part beside a numeric date. */ function timeOptions(opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions { - const time: Intl.DateTimeFormatOptions = {} + const time: Intl.DateTimeFormatOptions = {}; for (const key of [...TIME_KEYS, 'hour12', 'hourCycle', 'timeZone', 'timeZoneName'] as const) { - const value = (opts as Record)[key] - if (value !== undefined) (time as Record)[key] = value + const value = (opts as Record)[key]; + if (value !== undefined) (time as Record)[key] = value; } - if (opts.timeStyle) time.timeStyle = opts.timeStyle - return time + if (opts.timeStyle) time.timeStyle = opts.timeStyle; + return time; } /** @@ -92,20 +92,20 @@ export function formatDisplayDate( opts: Intl.DateTimeFormatOptions = {}, format?: string | null ): string { - const layout = dateLayout(format) - if (!layout || Number.isNaN(date.getTime())) return date.toLocaleDateString(locale, opts) + const layout = dateLayout(format); + if (!layout || Number.isNaN(date.getTime())) return date.toLocaleDateString(locale, opts); if (opts.weekday && isNumericWithWeekday(opts)) { - const weekday = date.toLocaleDateString(locale, { weekday: opts.weekday, timeZone: opts.timeZone }) - return `${weekday}, ${numericDate(date, opts, layout)}` + const weekday = date.toLocaleDateString(locale, { weekday: opts.weekday, timeZone: opts.timeZone }); + return `${weekday}, ${numericDate(date, opts, layout)}`; } - if (!isNumericDate(opts)) return date.toLocaleDateString(locale, opts) - return numericDate(date, opts, layout) + if (!isNumericDate(opts)) return date.toLocaleDateString(locale, opts); + return numericDate(date, opts, layout); } /** A weekday beside a date that is otherwise all numbers ("Tue, 03.11.2026"). */ function isNumericWithWeekday(opts: Intl.DateTimeFormatOptions): boolean { - if (opts.era || opts.dateStyle) return false - return (opts.month === 'numeric' || opts.month === '2-digit') && opts.day !== undefined + if (opts.era || opts.dateStyle) return false; + return (opts.month === 'numeric' || opts.month === '2-digit') && opts.day !== undefined; } /** @@ -119,15 +119,15 @@ export function formatDisplayDateTime( opts: Intl.DateTimeFormatOptions = {}, format?: string | null ): string { - const layout = dateLayout(format) - const named = hasAny(opts, DATE_KEYS) || !!opts.dateStyle - const timed = hasAny(opts, TIME_KEYS) || !!opts.timeStyle - if (!layout || !isNumericDate(opts) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts) - if (!named && timed) return date.toLocaleString(locale, opts) - const day = numericDate(date, opts, layout) - if (named && !timed) return day - const time = date.toLocaleTimeString(locale, timed ? timeOptions(opts) : { timeZone: opts.timeZone }) - return `${day} ${time}` + const layout = dateLayout(format); + const named = hasAny(opts, DATE_KEYS) || !!opts.dateStyle; + const timed = hasAny(opts, TIME_KEYS) || !!opts.timeStyle; + if (!layout || !isNumericDate(opts) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts); + if (!named && timed) return date.toLocaleString(locale, opts); + const day = numericDate(date, opts, layout); + if (named && !timed) return day; + const time = date.toLocaleTimeString(locale, timed ? timeOptions(opts) : { timeZone: opts.timeZone }); + return `${day} ${time}`; } /** @@ -139,41 +139,44 @@ export function displayDateFormatter( opts: Intl.DateTimeFormatOptions = {}, format?: string | null ): { format: (date: Date | number) => string } { - const intl = new Intl.DateTimeFormat(locale, opts) - const layout = dateLayout(format) - if (!layout || !isNumericDate(opts)) return { format: (date) => intl.format(date) } + const intl = new Intl.DateTimeFormat(locale, opts); + const layout = dateLayout(format); + if (!layout || !isNumericDate(opts)) return { format: (date) => intl.format(date) }; return { format: (date) => { - const value = typeof date === 'number' ? new Date(date) : date + const value = typeof date === 'number' ? new Date(date) : date; return hasAny(opts, TIME_KEYS) || opts.timeStyle ? formatDisplayDateTime(value, locale, opts, format) - : formatDisplayDate(value, locale, opts, format) + : formatDisplayDate(value, locale, opts, format); }, - } + }; } /** The order the fields of a typed date are read in: the chosen format's, or the language's. */ export function dateFieldOrder(locale: string | undefined, format?: string | null): DateField[] { - const layout = dateLayout(format) - if (layout) return layout.order + const layout = dateLayout(format); + if (layout) return layout.order; return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'numeric', year: 'numeric' }) .formatToParts(new Date(2001, 5, 15)) .filter((part) => part.type === 'day' || part.type === 'month' || part.type === 'year') - .map((part) => part.type as DateField) + .map((part) => part.type as DateField); } /** 31 December: no day of it reads as a month, so every option's example is unambiguous. */ -const SAMPLE = new Date(Date.UTC(2026, 11, 31)) +const SAMPLE = new Date(Date.UTC(2026, 11, 31)); /** * The choices of the setting, each labelled by how it writes 31 December, and * 'auto' by `autoLabel` with the language's own way in brackets. */ -export function dateFormatOptions(locale: string | undefined, autoLabel: string): { value: DateFormat; label: string }[] { +export function dateFormatOptions( + locale: string | undefined, + autoLabel: string +): { value: DateFormat; label: string }[] { const example = (format: DateFormat) => - formatDisplayDate(SAMPLE, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, format) + formatDisplayDate(SAMPLE, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, format); return DATE_FORMAT_VALUES.map((value) => ({ value, label: value === 'auto' ? `${autoLabel} (${example(value)})` : example(value), - })) + })); }