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
+7 -3
View File
@@ -33,7 +33,7 @@ export const ResourceBar = ({
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProps): React.JSX.Element => { }: ResourceBarProps): React.JSX.Element => {
const { formatNumber } = useGame(); const { formatNumber, syncError } = useGame();
const anyFull = Object.values(resources).some((v) => v >= RESOURCE_CAP); const anyFull = Object.values(resources).some((v) => v >= RESOURCE_CAP);
return ( return (
<> <>
@@ -68,11 +68,15 @@ export const ResourceBar = ({
</div> </div>
)} )}
<div className="profile-buttons"> <div className="profile-buttons">
{lastSavedAt !== null && ( {syncError !== null ? (
<span className="save-status save-error" title={syncError}>
Save failed
</span>
) : lastSavedAt !== null ? (
<span className="save-status" title={new Date(lastSavedAt).toLocaleString()}> <span className="save-status" title={new Date(lastSavedAt).toLocaleString()}>
{formatRelativeTime(lastSavedAt)} {formatRelativeTime(lastSavedAt)}
</span> </span>
)} ) : null}
<button <button
className="force-save-button" className="force-save-button"
disabled={isSyncing} disabled={isSyncing}
+28 -1
View File
@@ -43,6 +43,8 @@ interface GameContextValue {
isSyncing: boolean; isSyncing: boolean;
/** Immediately save to the server and reset the auto-save timer */ /** Immediately save to the server and reset the auto-save timer */
forceSync: () => Promise<void>; forceSync: () => Promise<void>;
/** Error message from the last failed cloud save (null when no error) */
syncError: string | null;
/** Offline gold earned on login */ /** Offline gold earned on login */
offlineGold: number; offlineGold: number;
/** Dismiss the offline gold notification */ /** Dismiss the offline gold notification */
@@ -76,6 +78,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]); const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null); const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [isSyncing, setIsSyncing] = useState(false); 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 [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix");
const stateRef = useRef<GameState | null>(null); const stateRef = useRef<GameState | null>(null);
const lastSaveRef = useRef<number>(Date.now()); const lastSaveRef = useRef<number>(Date.now());
@@ -163,6 +167,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
signatureRef.current = response.signature; signatureRef.current = response.signature;
localStorage.setItem("elysium_save_signature", 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available
}, [state !== null]); }, [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 () => { const forceSync = useCallback(async () => {
if (!stateRef.current || isSyncingRef.current) return; if (!stateRef.current || isSyncingRef.current) return;
isSyncingRef.current = true; isSyncingRef.current = true;
@@ -188,17 +209,22 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
state: stateRef.current, state: stateRef.current,
signature: signatureRef.current ?? undefined, signature: signatureRef.current ?? undefined,
}); });
setSyncError(null);
setLastSavedAt(response.savedAt); setLastSavedAt(response.savedAt);
lastSaveRef.current = Date.now(); lastSaveRef.current = Date.now();
if (response.signature) { if (response.signature) {
signatureRef.current = response.signature; signatureRef.current = response.signature;
localStorage.setItem("elysium_save_signature", 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 { } finally {
isSyncingRef.current = false; isSyncingRef.current = false;
setIsSyncing(false); setIsSyncing(false);
} }
}, []); }, [showSyncError, clearBadSignature]);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setState((prev) => { setState((prev) => {
@@ -469,6 +495,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
lastSavedAt, lastSavedAt,
isSyncing, isSyncing,
forceSync, forceSync,
syncError,
offlineGold, offlineGold,
dismissOfflineGold, dismissOfflineGold,
battleResult, battleResult,
+5
View File
@@ -1201,6 +1201,11 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.save-error {
color: #ef4444;
font-weight: 600;
}
.force-save-button { .force-save-button {
align-items: center; align-items: center;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);