Files
silo-server/web/src/hooks/useDateTimeFormat.tsx
T
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

146 lines
5.4 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import {
getDateTimeFormatPreferences,
parseDateFormatPreference,
parseTimeFormatPreference,
setDateTimeFormatPreferences,
subscribeDateTimeFormatPreferences,
} from "@/lib/datetime";
import type {
DateFormatPreference,
DateTimeFormatPreferences,
TimeFormatPreference,
} from "@/lib/datetime";
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
import { useOptionalAuth } from "@/hooks/useAuth";
import { storage } from "@/utils/storage";
export const DATE_FORMAT_SETTING_KEY = "ui.date_format";
export const TIME_FORMAT_SETTING_KEY = "ui.time_format";
// Seed the shared formatter state from localStorage at module load, before the
// first render, so an app booting straight into a date-heavy page doesn't
// paint in the wrong format while the settings request is in flight.
setDateTimeFormatPreferences({
dateFormat: parseDateFormatPreference(storage.get(storage.KEYS.UI_DATE_FORMAT)),
timeFormat: parseTimeFormatPreference(storage.get(storage.KEYS.UI_TIME_FORMAT)),
});
interface DateTimeFormatContextValue {
dateFormat: DateFormatPreference;
timeFormat: TimeFormatPreference;
setDateFormat: (value: DateFormatPreference) => void;
setTimeFormat: (value: TimeFormatPreference) => void;
}
const DateTimeFormatContext = createContext<DateTimeFormatContextValue | null>(null);
/**
* Syncs the persisted date/time format settings (profile-scoped, mirrored in
* localStorage like the theme preferences) into the shared formatter state in
* lib/datetime, and exposes setters for the settings UI.
*/
export function DateTimeFormatProvider({ children }: { children: ReactNode }) {
const auth = useOptionalAuth();
const authUserId = auth && !auth.loading && auth.user ? String(auth.user.id) : null;
const loadApiSettings = authUserId !== null;
const { data: apiSettings } = useSettings({ enabled: loadApiSettings });
const settingMutation = useSetSetting();
const local = useDateTimeFormat();
// Once the authenticated settings have loaded they are authoritative: a
// missing key means the user has no preference (auto), not "fall back to
// whatever this device saw last" — and rollback of a failed save flows back
// through the query cache. Until then (including when the settings request
// fails), the localStorage warm start is only trusted if it was mirrored for
// this same user; another account's device-local preference must not leak in.
const apiLoaded = loadApiSettings && apiSettings !== undefined;
const localTrusted =
authUserId === null || storage.get(storage.KEYS.UI_DATETIME_FORMAT_OWNER) === authUserId;
const dateFormat = apiLoaded
? parseDateFormatPreference(apiSettings[DATE_FORMAT_SETTING_KEY])
: localTrusted
? local.dateFormat
: "auto";
const timeFormat = apiLoaded
? parseTimeFormatPreference(apiSettings[TIME_FORMAT_SETTING_KEY])
: localTrusted
? local.timeFormat
: "auto";
useEffect(() => {
setDateTimeFormatPreferences({ dateFormat, timeFormat });
// Mirror the resolved values (tagged with their owner) so the next load on
// this device paints in the right format before the settings request
// resolves.
if (apiLoaded) {
storage.set(storage.KEYS.UI_DATE_FORMAT, dateFormat);
storage.set(storage.KEYS.UI_TIME_FORMAT, timeFormat);
if (authUserId !== null) {
storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId);
}
}
}, [dateFormat, timeFormat, apiLoaded, authUserId]);
const setDateFormat = useCallback(
(value: DateFormatPreference) => {
setDateTimeFormatPreferences({ ...getDateTimeFormatPreferences(), dateFormat: value });
storage.set(storage.KEYS.UI_DATE_FORMAT, value);
if (authUserId !== null) {
storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId);
}
settingMutation.mutate({ key: DATE_FORMAT_SETTING_KEY, value });
},
[settingMutation, authUserId],
);
const setTimeFormat = useCallback(
(value: TimeFormatPreference) => {
setDateTimeFormatPreferences({ ...getDateTimeFormatPreferences(), timeFormat: value });
storage.set(storage.KEYS.UI_TIME_FORMAT, value);
if (authUserId !== null) {
storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId);
}
settingMutation.mutate({ key: TIME_FORMAT_SETTING_KEY, value });
},
[settingMutation, authUserId],
);
return (
<DateTimeFormatContext
value={{
dateFormat,
timeFormat,
setDateFormat,
setTimeFormat,
}}
>
{children}
</DateTimeFormatContext>
);
}
/** Settings-page access to the persisted preferences and their setters. */
export function useDateTimeFormatSettings(): DateTimeFormatContextValue {
const ctx = useContext(DateTimeFormatContext);
if (!ctx) {
throw new Error("useDateTimeFormatSettings must be used within DateTimeFormatProvider");
}
return ctx;
}
/**
* Subscribe to the current date/time format preferences. Components that
* render dates via lib/datetime formatters should call this so they re-render
* when the preference changes.
*/
export function useDateTimeFormat(): DateTimeFormatPreferences {
return useSyncExternalStore(
subscribeDateTimeFormatPreferences,
getDateTimeFormatPreferences,
getDateTimeFormatPreferences,
);
}