Files
silo-server/web/src/lib/datetime.ts
2e0e145721 feat(settings): per-user date and time display format settings (#304)
* feat(settings): per-user date and time display format settings

Add ui.date_format (auto, DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD) and
ui.time_format (auto, 12h, 24h) as validated user-scoped settings, a
shared preference-aware formatter module (web/src/lib/datetime.ts)
synced via DateTimeFormatProvider, a Date & time section in Appearance
settings, and convert all absolute date/time display call sites to the
shared formatters.

Closes #303

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): make loaded API settings authoritative for date/time formats

Address adversarial review: once the authenticated settings request
resolves, a missing ui.date_format/ui.time_format key means "auto"
instead of falling back to device-wide localStorage (which could carry
another user's preference), and failed saves roll back through the
query cache. Layout and AdminLayout subscribe to the format store so
all routed pages re-render live when the preference changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): reliable re-render and rollback for date/time format changes

ReactiveAppRoutes re-renders the routed page tree when the format
preference changes (a Layout-level subscription cannot re-render stable
children elements); memoized AdminLogs rows and the out-of-route
PlayingNextScreen subscribe directly. useSetSetting now rolls back only
the mutated key on error and invalidates the settings list on settle so
overlapping saves cannot resurrect stale values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): guard same-key rollback against newer optimistic saves

Roll back a failed setting save only while its optimistic value is
still current in the cache, invalidate the detail query on settle, and
add a regression test for overlapping same-key mutations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): owner-bind the datetime format warm start, pad 24h hours

Address PR review: the localStorage warm start is now tagged with the
user id that mirrored it and is ignored for a different authenticated
user, so a failed settings request can no longer leak another account's
format on a shared browser. The 24h branch of formatTime defaults to
2-digit hours ("09:04") since h23 alone does not guarantee padding in
every locale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:45:06 -04:00

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}`;
}