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,
|
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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user