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,
onForceSync,
}: ResourceBarProps): React.JSX.Element => {
const { formatNumber } = useGame();
const { formatNumber, syncError } = useGame();
const anyFull = Object.values(resources).some((v) => v >= RESOURCE_CAP);
return (
<>
@@ -68,11 +68,15 @@ export const ResourceBar = ({
</div>
)}
<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()}>
{formatRelativeTime(lastSavedAt)}
</span>
)}
) : null}
<button
className="force-save-button"
disabled={isSyncing}
+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,
+5
View File
@@ -1201,6 +1201,11 @@ body {
white-space: nowrap;
}
.save-error {
color: #ef4444;
font-weight: 600;
}
.force-save-button {
align-items: center;
background: rgba(255, 255, 255, 0.06);