feat: show save errors in the UI instead of console

Add syncError state to GameContext. forceSync now catches errors
and displays them in the ResourceBar for 5 seconds, replacing the
cloud save timestamp with ' Save failed'. Signature mismatches
are also cleared from localStorage so the next save can proceed.
Auto-save silently self-heals bad signatures without surfacing an error.
This commit is contained in:
2026-03-06 19:18:29 -08:00
committed by Naomi Carrigan
parent 50fe905610
commit 5aae3eb389
3 changed files with 40 additions and 4 deletions
+28 -1
View File
@@ -43,6 +43,8 @@ interface GameContextValue {
isSyncing: boolean;
/** Immediately save to the server and reset the auto-save timer */
forceSync: () => Promise<void>;
/** Error message from the last failed cloud save (null when no error) */
syncError: string | null;
/** Offline gold earned on login */
offlineGold: number;
/** Dismiss the offline gold notification */
@@ -76,6 +78,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const syncErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix");
const stateRef = useRef<GameState | null>(null);
const lastSaveRef = useRef<number>(Date.now());
@@ -163,6 +167,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
signatureRef.current = response.signature;
localStorage.setItem("elysium_save_signature", response.signature);
}
}).catch((err: unknown) => {
// Silently clear a bad signature so the next auto-save can proceed
if (err instanceof Error && err.message.includes("signature mismatch")) {
signatureRef.current = null;
localStorage.removeItem("elysium_save_signature");
}
});
}
}
@@ -179,6 +189,17 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available
}, [state !== null]);
const showSyncError = useCallback((message: string) => {
setSyncError(message);
if (syncErrorTimerRef.current) clearTimeout(syncErrorTimerRef.current);
syncErrorTimerRef.current = setTimeout(() => { setSyncError(null); }, 5000);
}, []);
const clearBadSignature = useCallback(() => {
signatureRef.current = null;
localStorage.removeItem("elysium_save_signature");
}, []);
const forceSync = useCallback(async () => {
if (!stateRef.current || isSyncingRef.current) return;
isSyncingRef.current = true;
@@ -188,17 +209,22 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
state: stateRef.current,
signature: signatureRef.current ?? undefined,
});
setSyncError(null);
setLastSavedAt(response.savedAt);
lastSaveRef.current = Date.now();
if (response.signature) {
signatureRef.current = response.signature;
localStorage.setItem("elysium_save_signature", response.signature);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Save failed";
showSyncError(message);
if (message.includes("signature mismatch")) clearBadSignature();
} finally {
isSyncingRef.current = false;
setIsSyncing(false);
}
}, []);
}, [showSyncError, clearBadSignature]);
const handleClick = useCallback(() => {
setState((prev) => {
@@ -469,6 +495,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
lastSavedAt,
isSyncing,
forceSync,
syncError,
offlineGold,
dismissOfflineGold,
battleResult,