From 24beaf3131588a9da2ba60dab0056a7ae64678c1 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 18:22:28 -0800 Subject: [PATCH] feat: add cloud save timestamp and force sync button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a relative time label (e.g. "☁️ 2m ago") in the resource bar seeded from the server on load and updated after every auto-save or manual save. A 💾 button lets players trigger an immediate cloud save outside the 30-second auto-save cycle, with a ⏳ spinner and disabled state while the request is in-flight. --- apps/web/src/components/game/GameLayout.tsx | 5 +++- apps/web/src/components/ui/ResourceBar.tsx | 30 +++++++++++++++++++ apps/web/src/context/GameContext.tsx | 32 ++++++++++++++++++++- apps/web/src/styles.css | 32 +++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index 210b57a..1110ebc 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -27,7 +27,7 @@ const TABS: { id: Tab; label: string }[] = [ ]; export const GameLayout = (): React.JSX.Element => { - const { state, isLoading, error, battleResult, dismissBattle } = useGame(); + const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync } = useGame(); const [activeTab, setActiveTab] = useState("adventurers"); const [editingProfile, setEditingProfile] = useState(false); @@ -58,6 +58,9 @@ export const GameLayout = (): React.JSX.Element => { prestigeCount={state.prestige.count} profileUrl={profileUrl} onEditProfile={() => { setEditingProfile(true); }} + lastSavedAt={lastSavedAt} + isSyncing={isSyncing} + onForceSync={forceSync} /> diff --git a/apps/web/src/components/ui/ResourceBar.tsx b/apps/web/src/components/ui/ResourceBar.tsx index 993a7a8..1885c76 100644 --- a/apps/web/src/components/ui/ResourceBar.tsx +++ b/apps/web/src/components/ui/ResourceBar.tsx @@ -6,13 +6,29 @@ interface ResourceBarProps { prestigeCount: number; profileUrl: string; onEditProfile: () => void; + lastSavedAt: number | null; + isSyncing: boolean; + onForceSync: () => Promise; } +const formatRelativeTime = (timestamp: number): string => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +}; + export const ResourceBar = ({ resources, prestigeCount, profileUrl, onEditProfile, + lastSavedAt, + isSyncing, + onForceSync, }: ResourceBarProps): React.JSX.Element => (
@@ -41,6 +57,20 @@ export const ResourceBar = ({
)}
+ {lastSavedAt !== null && ( + + ☁️ {formatRelativeTime(lastSavedAt)} + + )} + void; /** Reload state from the server */ reload: () => Promise; + /** Unix timestamp of the last successful cloud save (null until first save response) */ + lastSavedAt: number | null; + /** True whilst a forced save is in-flight */ + isSyncing: boolean; + /** Immediately save to the server and reset the auto-save timer */ + forceSync: () => Promise; /** Offline gold earned on login */ offlineGold: number; /** Dismiss the offline gold notification */ @@ -60,8 +67,11 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const [offlineGold, setOfflineGold] = useState(0); const [battleResult, setBattleResult] = useState(null); const [newAchievements, setNewAchievements] = useState([]); + const [lastSavedAt, setLastSavedAt] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); const stateRef = useRef(null); const lastSaveRef = useRef(Date.now()); + const isSyncingRef = useRef(false); const rafRef = useRef(null); const newlyUnlockedRef = useRef([]); @@ -73,6 +83,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React try { const data = await loadGame(); setState(data.state); + setLastSavedAt(data.state.player.lastSavedAt); if (data.offlineGold > 0) { setOfflineGold(data.offlineGold); } @@ -119,7 +130,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) { lastSaveRef.current = Date.now(); if (stateRef.current) { - void saveGame({ state: stateRef.current }); + void saveGame({ state: stateRef.current }).then((response) => { + setLastSavedAt(response.savedAt); + }); } } @@ -135,6 +148,20 @@ 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 forceSync = useCallback(async () => { + if (!stateRef.current || isSyncingRef.current) return; + isSyncingRef.current = true; + setIsSyncing(true); + try { + const response = await saveGame({ state: stateRef.current }); + setLastSavedAt(response.savedAt); + lastSaveRef.current = Date.now(); + } finally { + isSyncingRef.current = false; + setIsSyncing(false); + } + }, []); + const handleClick = useCallback(() => { setState((prev) => { if (!prev) return prev; @@ -395,6 +422,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React challengeBoss, equipItem, reload, + lastSavedAt, + isSyncing, + forceSync, offlineGold, dismissOfflineGold, battleResult, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 713734e..37c74aa 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1171,6 +1171,38 @@ body { color: var(--colour-text); } +.save-status { + color: var(--colour-text-muted); + font-size: 0.75rem; + white-space: nowrap; +} + +.force-save-button { + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 50%; + color: var(--colour-text); + cursor: pointer; + display: flex; + font-size: 0.9rem; + height: 2rem; + justify-content: center; + padding: 0; + transition: all 0.2s; + width: 2rem; +} + +.force-save-button:hover:not(:disabled) { + background: rgba(147, 51, 234, 0.2); + border-color: var(--colour-accent-light); +} + +.force-save-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + /* ── Public Profile Page ────────────────────────────────────────────────── */ .profile-page {