mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
fix(settings): format the date format files prettier flagged
This commit is contained in:
@@ -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 (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{formatDate(new Date(v.publishedAt))}</span>}
|
||||
<span
|
||||
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
|
||||
style={fs(12.5)}
|
||||
>
|
||||
v{v.version}
|
||||
</span>
|
||||
{v.publishedAt && (
|
||||
<span className="text-content-faint" style={fs(11.5)}>
|
||||
{formatDate(new Date(v.publishedAt))}
|
||||
</span>
|
||||
)}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 <span className={className}>{formatDate(new Date(at))}</span>
|
||||
const { formatDate } = useFormatDate();
|
||||
if (!at) return null;
|
||||
return <span className={className}>{formatDate(new Date(at))}</span>;
|
||||
}
|
||||
|
||||
+139
-106
@@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Exclude<DateFormat, 'auto'>, DateLayout> = {
|
||||
@@ -27,16 +27,16 @@ const LAYOUTS: Record<Exclude<DateFormat, 'auto'>, 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<string, unknown>)[key] !== undefined)
|
||||
keys.some((key) => (opts as Record<string, unknown>)[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<string, unknown>)[key]
|
||||
if (value !== undefined) (time as Record<string, unknown>)[key] = value
|
||||
const value = (opts as Record<string, unknown>)[key];
|
||||
if (value !== undefined) (time as Record<string, unknown>)[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),
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user