generated from nhcarrigan/template
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user