* 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>
73 lines
2.7 KiB
TypeScript
73 lines
2.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { ReactNode } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
|
|
import { useSetSetting, type SettingsMap } from "./settings";
|
|
import { settingsKeys } from "./keys";
|
|
|
|
const apiMock = vi.hoisted(() => vi.fn());
|
|
vi.mock("@/api/client", () => ({ api: apiMock }));
|
|
|
|
function createHarness() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
return { queryClient, wrapper };
|
|
}
|
|
|
|
describe("useSetSetting overlapping mutations", () => {
|
|
it("does not roll back a newer successful save of the same key when an older save fails", async () => {
|
|
const { queryClient, wrapper } = createHarness();
|
|
queryClient.setQueryData<SettingsMap>(settingsKeys.list(), { "ui.date_format": "auto" });
|
|
|
|
let failFirst: (err: Error) => void = () => {};
|
|
let resolveSecond: (value: unknown) => void = () => {};
|
|
apiMock
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise((_resolve, reject) => {
|
|
failFirst = reject;
|
|
}),
|
|
)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSecond = resolve;
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useSetSetting(), { wrapper });
|
|
|
|
result.current.mutate({ key: "ui.date_format", value: "DD/MM/YYYY" });
|
|
result.current.mutate({ key: "ui.date_format", value: "YYYY-MM-DD" });
|
|
await waitFor(() => expect(apiMock).toHaveBeenCalledTimes(2));
|
|
|
|
resolveSecond(undefined);
|
|
failFirst(new Error("network"));
|
|
|
|
await waitFor(() => {
|
|
const list = queryClient.getQueryData<SettingsMap>(settingsKeys.list());
|
|
// The failed older save must not resurrect "auto" over the accepted value.
|
|
expect(list?.["ui.date_format"]).toBe("YYYY-MM-DD");
|
|
});
|
|
});
|
|
|
|
it("rolls back the optimistic value when a lone save fails", async () => {
|
|
const { queryClient, wrapper } = createHarness();
|
|
queryClient.setQueryData<SettingsMap>(settingsKeys.list(), { "ui.time_format": "12h" });
|
|
apiMock.mockImplementationOnce(() => Promise.reject(new Error("network")));
|
|
|
|
const { result } = renderHook(() => useSetSetting(), { wrapper });
|
|
result.current.mutate({ key: "ui.time_format", value: "24h" });
|
|
|
|
await waitFor(() => {
|
|
const list = queryClient.getQueryData<SettingsMap>(settingsKeys.list());
|
|
expect(list?.["ui.time_format"]).toBe("12h");
|
|
});
|
|
});
|
|
});
|