feat: enhance useSettings hook with pending changes tracking and improved save status management

This commit is contained in:
Jaime Idolpx 2026-06-07 19:40:53 -04:00
parent 1331033b81
commit 5ac6b6ce95

View File

@ -22,8 +22,18 @@ import {
export const SETTINGS_PATH = '/.sys/config.json'; export const SETTINGS_PATH = '/.sys/config.json';
const SETTINGS_DIR = '/.sys'; const SETTINGS_DIR = '/.sys';
/** How long to wait after the last change before writing to disk. */ /**
const SAVE_DEBOUNCE_MS = 400; * How long to wait after the last change before writing to disk.
*
* The debounce timer is reset on every change, so this is effectively
* "the number of milliseconds the user must be idle before the change
* is committed". Bump this up to give the user more time to make
* multiple related edits; lower it to write changes more eagerly.
*/
export const SAVE_IDLE_MS = 3000;
/** How long the "Saved" indicator lingers before fading out. */
const SAVED_INDICATOR_MS = 1500;
export type SettingsConfig = Record<string, any>; export type SettingsConfig = Record<string, any>;
@ -56,17 +66,27 @@ export async function writeSettings(config: SettingsConfig): Promise<void> {
await putFileContents(SETTINGS_PATH, json); await putFileContents(SETTINGS_PATH, json);
} }
export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error'; export type SaveStatus =
| 'idle' // nothing to do
| 'loading' // initial fetch from server
| 'unsaved' // one or more changes pending; waiting for the idle timer
| 'saving' // a write is currently in flight
| 'saved' // last write succeeded (fades back to 'idle' shortly)
| 'error'; // last write failed; will retry on the next change
export interface UseSettingsResult { export interface UseSettingsResult {
config: SettingsConfig; config: SettingsConfig;
setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void; setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void;
/** Whether the initial load from the server has completed. */ /** Whether the initial load from the server has completed. */
loaded: boolean; loaded: boolean;
/** Current save status. `'saving'` means a write is in flight. */ /** Current save status (see {@link SaveStatus}). */
saveStatus: SaveStatus; saveStatus: SaveStatus;
/** Number of pending (uncommitted) changes since the last successful save. */
pendingCount: number;
/** Force a re-fetch from the server and replace local state. */ /** Force a re-fetch from the server and replace local state. */
reload: () => Promise<void>; reload: () => Promise<void>;
/** Flush any pending changes immediately, ignoring the idle timer. */
flushNow: () => Promise<void>;
} }
/** /**
@ -88,6 +108,7 @@ export function useSettings(): UseSettingsResult {
const [config, setConfigState] = useState<SettingsConfig>(bundledConfig as SettingsConfig); const [config, setConfigState] = useState<SettingsConfig>(bundledConfig as SettingsConfig);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('loading'); const [saveStatus, setSaveStatus] = useState<SaveStatus>('loading');
const [pendingCount, setPendingCount] = useState(0);
// Refs so the debounced writer always sees the latest values without // Refs so the debounced writer always sees the latest values without
// needing to re-subscribe on every render. // needing to re-subscribe on every render.
@ -95,6 +116,8 @@ export function useSettings(): UseSettingsResult {
const loadedRef = useRef<boolean>(false); const loadedRef = useRef<boolean>(false);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const dirtyRef = useRef<boolean>(false); const dirtyRef = useRef<boolean>(false);
const pendingRef = useRef<number>(0);
const savedTimerRef = useRef<number | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setSaveStatus('loading'); setSaveStatus('loading');
@ -117,17 +140,28 @@ export function useSettings(): UseSettingsResult {
window.clearTimeout(timerRef.current); window.clearTimeout(timerRef.current);
timerRef.current = null; timerRef.current = null;
} }
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current);
savedTimerRef.current = null;
}
if (!dirtyRef.current) return; if (!dirtyRef.current) return;
dirtyRef.current = false; dirtyRef.current = false;
const pending = pendingRef.current;
pendingRef.current = 0;
setPendingCount(0);
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
await writeSettings(configRef.current); await writeSettings(configRef.current);
setSaveStatus('saved'); setSaveStatus('saved');
// Drop the "saved" badge after a short delay so it doesn't linger. // Drop the "saved" badge after a short delay so it doesn't linger.
window.setTimeout(() => { savedTimerRef.current = window.setTimeout(() => {
setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
}, 1500); }, SAVED_INDICATOR_MS);
} catch (e) { } catch (e) {
// Roll back: the change is still pending.
dirtyRef.current = true;
pendingRef.current = pending;
setPendingCount(pending);
setSaveStatus('error'); setSaveStatus('error');
// Re-throw so the caller can handle it if they want. // Re-throw so the caller can handle it if they want.
throw e; throw e;
@ -166,18 +200,30 @@ export function useSettings(): UseSettingsResult {
configRef.current = value; configRef.current = value;
setConfigState(value); setConfigState(value);
dirtyRef.current = true; dirtyRef.current = true;
setSaveStatus('saving'); pendingRef.current += 1;
setPendingCount(pendingRef.current);
setSaveStatus('unsaved');
if (timerRef.current !== null) { if (timerRef.current !== null) {
window.clearTimeout(timerRef.current); window.clearTimeout(timerRef.current);
} }
// Reset the idle timer: save `SAVE_IDLE_MS` after the most recent
// change. Every additional change resets the timer again.
timerRef.current = window.setTimeout(() => { timerRef.current = window.setTimeout(() => {
void flushNow(); void flushNow();
}, SAVE_DEBOUNCE_MS); }, SAVE_IDLE_MS);
}, },
[flushNow], [flushNow],
); );
return { config, setConfig, loaded, saveStatus, reload: load }; return {
config,
setConfig,
loaded,
saveStatus,
pendingCount,
reload: load,
flushNow,
};
} }
function absoluteSettingsUrl(): string { function absoluteSettingsUrl(): string {