From 5ac6b6ce956cacf7dcf778372f02660a8597b6f2 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Sun, 7 Jun 2026 19:40:53 -0400 Subject: [PATCH] feat: enhance useSettings hook with pending changes tracking and improved save status management --- src/app/settings.ts | 64 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/app/settings.ts b/src/app/settings.ts index 9170499..c7375f9 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -22,8 +22,18 @@ import { export const SETTINGS_PATH = '/.sys/config.json'; 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; @@ -56,17 +66,27 @@ export async function writeSettings(config: SettingsConfig): Promise { 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 { config: SettingsConfig; setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void; /** Whether the initial load from the server has completed. */ loaded: boolean; - /** Current save status. `'saving'` means a write is in flight. */ + /** Current save status (see {@link 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. */ reload: () => Promise; + /** Flush any pending changes immediately, ignoring the idle timer. */ + flushNow: () => Promise; } /** @@ -88,6 +108,7 @@ export function useSettings(): UseSettingsResult { const [config, setConfigState] = useState(bundledConfig as SettingsConfig); const [loaded, setLoaded] = useState(false); const [saveStatus, setSaveStatus] = useState('loading'); + const [pendingCount, setPendingCount] = useState(0); // Refs so the debounced writer always sees the latest values without // needing to re-subscribe on every render. @@ -95,6 +116,8 @@ export function useSettings(): UseSettingsResult { const loadedRef = useRef(false); const timerRef = useRef(null); const dirtyRef = useRef(false); + const pendingRef = useRef(0); + const savedTimerRef = useRef(null); const load = useCallback(async () => { setSaveStatus('loading'); @@ -117,17 +140,28 @@ export function useSettings(): UseSettingsResult { window.clearTimeout(timerRef.current); timerRef.current = null; } + if (savedTimerRef.current !== null) { + window.clearTimeout(savedTimerRef.current); + savedTimerRef.current = null; + } if (!dirtyRef.current) return; dirtyRef.current = false; + const pending = pendingRef.current; + pendingRef.current = 0; + setPendingCount(0); setSaveStatus('saving'); try { await writeSettings(configRef.current); setSaveStatus('saved'); // 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)); - }, 1500); + }, SAVED_INDICATOR_MS); } catch (e) { + // Roll back: the change is still pending. + dirtyRef.current = true; + pendingRef.current = pending; + setPendingCount(pending); setSaveStatus('error'); // Re-throw so the caller can handle it if they want. throw e; @@ -166,18 +200,30 @@ export function useSettings(): UseSettingsResult { configRef.current = value; setConfigState(value); dirtyRef.current = true; - setSaveStatus('saving'); + pendingRef.current += 1; + setPendingCount(pendingRef.current); + setSaveStatus('unsaved'); if (timerRef.current !== null) { 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(() => { void flushNow(); - }, SAVE_DEBOUNCE_MS); + }, SAVE_IDLE_MS); }, [flushNow], ); - return { config, setConfig, loaded, saveStatus, reload: load }; + return { + config, + setConfig, + loaded, + saveStatus, + pendingCount, + reload: load, + flushNow, + }; } function absoluteSettingsUrl(): string {