* 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>
132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
formatDate,
|
|
formatDateTime,
|
|
formatTime,
|
|
getDateTimeFormatPreferences,
|
|
parseDateFormatPreference,
|
|
parseTimeFormatPreference,
|
|
preferredDateLocale,
|
|
setDateTimeFormatPreferences,
|
|
subscribeDateTimeFormatPreferences,
|
|
} from "./datetime";
|
|
|
|
// June 5, 2026 15:04:05 in local time — unambiguous day/month for order checks.
|
|
const sample = new Date(2026, 5, 5, 15, 4, 5);
|
|
|
|
afterEach(() => {
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "auto" });
|
|
});
|
|
|
|
describe("parse helpers", () => {
|
|
it("accepts known values and falls back to auto", () => {
|
|
expect(parseDateFormatPreference("DD/MM/YYYY")).toBe("DD/MM/YYYY");
|
|
expect(parseDateFormatPreference("YYYY-MM-DD")).toBe("YYYY-MM-DD");
|
|
expect(parseDateFormatPreference("bogus")).toBe("auto");
|
|
expect(parseDateFormatPreference(null)).toBe("auto");
|
|
expect(parseTimeFormatPreference("24h")).toBe("24h");
|
|
expect(parseTimeFormatPreference("12H")).toBe("auto");
|
|
expect(parseTimeFormatPreference(undefined)).toBe("auto");
|
|
});
|
|
});
|
|
|
|
describe("formatDate", () => {
|
|
it("honors explicit numeric patterns", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "DD/MM/YYYY", timeFormat: "auto" });
|
|
expect(formatDate(sample)).toBe("05/06/2026");
|
|
|
|
setDateTimeFormatPreferences({ dateFormat: "MM/DD/YYYY", timeFormat: "auto" });
|
|
expect(formatDate(sample)).toBe("06/05/2026");
|
|
|
|
setDateTimeFormatPreferences({ dateFormat: "YYYY-MM-DD", timeFormat: "auto" });
|
|
expect(formatDate(sample)).toBe("2026-06-05");
|
|
});
|
|
|
|
it("uses the browser locale in auto mode", () => {
|
|
expect(formatDate(sample)).toBe(sample.toLocaleDateString());
|
|
});
|
|
|
|
it("orders medium (month-name) dates to match the preference", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "MM/DD/YYYY", timeFormat: "auto" });
|
|
expect(formatDate(sample, "medium")).toBe("Jun 5, 2026");
|
|
|
|
setDateTimeFormatPreferences({ dateFormat: "DD/MM/YYYY", timeFormat: "auto" });
|
|
expect(formatDate(sample, "medium")).toBe("5 Jun 2026");
|
|
|
|
setDateTimeFormatPreferences({ dateFormat: "YYYY-MM-DD", timeFormat: "auto" });
|
|
expect(formatDate(sample, "medium")).toBe("2026-06-05");
|
|
});
|
|
|
|
it("accepts ISO strings and returns empty for invalid input", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "YYYY-MM-DD", timeFormat: "auto" });
|
|
expect(formatDate(sample.toISOString())).toBe("2026-06-05");
|
|
expect(formatDate("not-a-date")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("formatTime", () => {
|
|
it("honors the 12h/24h preference", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "24h" });
|
|
expect(formatTime(sample)).toBe("15:04");
|
|
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "12h" });
|
|
expect(formatTime(sample)).toMatch(/^3:04\sPM$/i);
|
|
});
|
|
|
|
it("zero-pads single-digit hours in 24h mode", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "24h" });
|
|
const morning = new Date(2026, 5, 5, 9, 4, 5);
|
|
expect(formatTime(morning)).toBe("09:04");
|
|
});
|
|
|
|
it("merges extra options such as seconds", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "24h" });
|
|
expect(formatTime(sample, { second: "2-digit" })).toBe("15:04:05");
|
|
});
|
|
|
|
it("returns empty for invalid input", () => {
|
|
expect(formatTime("nope")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("formatDateTime", () => {
|
|
it("combines the preferred date and time with seconds by default", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "DD/MM/YYYY", timeFormat: "24h" });
|
|
expect(formatDateTime(sample)).toBe("05/06/2026, 15:04:05");
|
|
});
|
|
|
|
it("supports medium dates without seconds", () => {
|
|
setDateTimeFormatPreferences({ dateFormat: "MM/DD/YYYY", timeFormat: "24h" });
|
|
expect(formatDateTime(sample, { dateStyle: "medium", seconds: false })).toBe(
|
|
"Jun 5, 2026, 15:04",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("preference store", () => {
|
|
it("exposes the preferred locale for composed fragments", () => {
|
|
expect(preferredDateLocale()).toBeUndefined();
|
|
setDateTimeFormatPreferences({ dateFormat: "DD/MM/YYYY", timeFormat: "auto" });
|
|
expect(preferredDateLocale()).toBe("en-GB");
|
|
setDateTimeFormatPreferences({ dateFormat: "MM/DD/YYYY", timeFormat: "auto" });
|
|
expect(preferredDateLocale()).toBe("en-US");
|
|
setDateTimeFormatPreferences({ dateFormat: "YYYY-MM-DD", timeFormat: "auto" });
|
|
expect(preferredDateLocale()).toBeUndefined();
|
|
});
|
|
|
|
it("notifies subscribers only on actual changes", () => {
|
|
let calls = 0;
|
|
const unsubscribe = subscribeDateTimeFormatPreferences(() => {
|
|
calls += 1;
|
|
});
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "auto" });
|
|
expect(calls).toBe(0);
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "24h" });
|
|
expect(calls).toBe(1);
|
|
expect(getDateTimeFormatPreferences()).toEqual({ dateFormat: "auto", timeFormat: "24h" });
|
|
unsubscribe();
|
|
setDateTimeFormatPreferences({ dateFormat: "auto", timeFormat: "12h" });
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|