172 lines
5.5 KiB
TypeScript
172 lines
5.5 KiB
TypeScript
/**
|
|||
|
|
* Preference-aware date/time display formatting.
|
||
|
|
*
|
||
|
|
* All UI code that renders an absolute date or clock time should go through
|
||
|
|
* these helpers instead of calling `toLocale*String` directly, so the
|
||
|
|
* per-user date/time format settings (issue #303) apply consistently.
|
||
|
|
*
|
||
|
|
* Preferences live in module state so plain (non-React) helpers can format
|
||
|
|
* correctly; `DateTimeFormatProvider` keeps this state in sync with the
|
||
|
|
* persisted settings, and `useDateTimeFormat` lets components subscribe.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export const DATE_FORMAT_PREFERENCES = ["auto", "DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"] as const;
|
||
|
|
export const TIME_FORMAT_PREFERENCES = ["auto", "12h", "24h"] as const;
|
||
|
|
|
||
|
|
export type DateFormatPreference = (typeof DATE_FORMAT_PREFERENCES)[number];
|
||
|
|
export type TimeFormatPreference = (typeof TIME_FORMAT_PREFERENCES)[number];
|
||
|
|
|
||
|
|
export interface DateTimeFormatPreferences {
|
||
|
|
dateFormat: DateFormatPreference;
|
||
|
|
timeFormat: TimeFormatPreference;
|
||
|
|
}
|
||
|
|
|
||
|
|
const DEFAULT_PREFERENCES: DateTimeFormatPreferences = {
|
||
|
|
dateFormat: "auto",
|
||
|
|
timeFormat: "auto",
|
||
|
|
};
|
||
|
|
|
||
|
|
export function parseDateFormatPreference(value: string | null | undefined): DateFormatPreference {
|
||
|
|
return DATE_FORMAT_PREFERENCES.includes(value as DateFormatPreference)
|
||
|
|
? (value as DateFormatPreference)
|
||
|
|
: "auto";
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseTimeFormatPreference(value: string | null | undefined): TimeFormatPreference {
|
||
|
|
return TIME_FORMAT_PREFERENCES.includes(value as TimeFormatPreference)
|
||
|
|
? (value as TimeFormatPreference)
|
||
|
|
: "auto";
|
||
|
|
}
|
||
|
|
|
||
|
|
let preferences: DateTimeFormatPreferences = DEFAULT_PREFERENCES;
|
||
|
|
const listeners = new Set<() => void>();
|
||
|
|
|
||
|
|
export function getDateTimeFormatPreferences(): DateTimeFormatPreferences {
|
||
|
|
return preferences;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function setDateTimeFormatPreferences(next: DateTimeFormatPreferences): void {
|
||
|
|
if (next.dateFormat === preferences.dateFormat && next.timeFormat === preferences.timeFormat) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
preferences = { dateFormat: next.dateFormat, timeFormat: next.timeFormat };
|
||
|
|
for (const listener of listeners) {
|
||
|
|
listener();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function subscribeDateTimeFormatPreferences(listener: () => void): () => void {
|
||
|
|
listeners.add(listener);
|
||
|
|
return () => listeners.delete(listener);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Locale whose composed date formats ("Jun 5, 2026" vs "5 Jun 2026") match the
|
||
|
|
* preferred day/month order. Returns undefined for "auto" (browser locale) and
|
||
|
|
* for ISO, which has no month-name form and falls back to browser ordering.
|
||
|
|
* Use for fragments like `d.toLocaleDateString(preferredDateLocale(), { month: "short", day: "numeric" })`.
|
||
|
|
*/
|
||
|
|
export function preferredDateLocale(): string | undefined {
|
||
|
|
switch (preferences.dateFormat) {
|
||
|
|
case "DD/MM/YYYY":
|
||
|
|
return "en-GB";
|
||
|
|
case "MM/DD/YYYY":
|
||
|
|
return "en-US";
|
||
|
|
default:
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function toDate(value: Date | string | number): Date | null {
|
||
|
|
const date = value instanceof Date ? value : new Date(value);
|
||
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
||
|
|
}
|
||
|
|
|
||
|
|
function pad2(value: number): string {
|
||
|
|
return String(value).padStart(2, "0");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a calendar date.
|
||
|
|
* - "numeric": all-digit form, honoring the exact preferred pattern.
|
||
|
|
* - "medium": abbreviated month name ("Jun 5, 2026" / "5 Jun 2026"); the
|
||
|
|
* YYYY-MM-DD preference always renders ISO digits since it has no
|
||
|
|
* month-name form.
|
||
|
|
*/
|
||
|
|
export function formatDate(
|
||
|
|
value: Date | string | number,
|
||
|
|
style: "numeric" | "medium" = "numeric",
|
||
|
|
): string {
|
||
|
|
const date = toDate(value);
|
||
|
|
if (!date) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
const { dateFormat } = preferences;
|
||
|
|
if (dateFormat === "YYYY-MM-DD") {
|
||
|
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||
|
|
}
|
||
|
|
if (style === "numeric") {
|
||
|
|
switch (dateFormat) {
|
||
|
|
case "DD/MM/YYYY":
|
||
|
|
return `${pad2(date.getDate())}/${pad2(date.getMonth() + 1)}/${date.getFullYear()}`;
|
||
|
|
case "MM/DD/YYYY":
|
||
|
|
return `${pad2(date.getMonth() + 1)}/${pad2(date.getDate())}/${date.getFullYear()}`;
|
||
|
|
default:
|
||
|
|
return date.toLocaleDateString();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return date.toLocaleDateString(preferredDateLocale(), {
|
||
|
|
month: "short",
|
||
|
|
day: "numeric",
|
||
|
|
year: "numeric",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a clock time, honoring the 12h/24h preference. Extra Intl options
|
||
|
|
* (e.g. `{ second: "2-digit" }`) are merged over the `hour`/`minute` defaults.
|
||
|
|
*/
|
||
|
|
export function formatTime(
|
||
|
|
value: Date | string | number,
|
||
|
|
options?: Intl.DateTimeFormatOptions,
|
||
|
|
): string {
|
||
|
|
const date = toDate(value);
|
||
|
|
if (!date) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
const merged: Intl.DateTimeFormatOptions = {
|
||
|
|
hour: "numeric",
|
||
|
|
minute: "2-digit",
|
||
|
|
...options,
|
||
|
|
};
|
||
|
|
const { timeFormat } = preferences;
|
||
|
|
if (timeFormat === "12h") {
|
||
|
|
merged.hourCycle = "h12";
|
||
|
|
} else if (timeFormat === "24h") {
|
||
|
|
merged.hourCycle = "h23";
|
||
|
|
// h23 alone doesn't guarantee zero-padding in every locale; 24h convention
|
||
|
|
// is "09:04". Callers may still override with an explicit hour option.
|
||
|
|
if (!options?.hour) {
|
||
|
|
merged.hour = "2-digit";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return date.toLocaleTimeString(undefined, merged);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a date plus clock time ("05/06/2026, 15:04:05"). Defaults mirror the
|
||
|
|
* bare `toLocaleString()` this replaces: numeric date with seconds.
|
||
|
|
*/
|
||
|
|
export function formatDateTime(
|
||
|
|
value: Date | string | number,
|
||
|
|
options?: { dateStyle?: "numeric" | "medium"; seconds?: boolean },
|
||
|
|
): string {
|
||
|
|
const date = toDate(value);
|
||
|
|
if (!date) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
const datePart = formatDate(date, options?.dateStyle ?? "numeric");
|
||
|
|
const timePart = formatTime(date, (options?.seconds ?? true) ? { second: "2-digit" } : undefined);
|
||
|
|
return `${datePart}, ${timePart}`;
|
||
|
|
}
|