From 67eab17c4970fa2f7da05ca3dab65aec826ea82f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 07:35:48 +0000 Subject: [PATCH] Show unsynced edits and replay them on reconnect Edits made while the link is down now mark their channel dirty; the connection badge shows a count of changes waiting to sync, and a pendingSync store re-posts each dirty channel automatically once the connection returns. --- docs/screenshots/ux/pending.png | Bin 0 -> 36865 bytes .../ConnectionBadge/ConnectionBadge.jsx | 14 +++ .../ConnectionBadge.module.scss | 38 ++++++++ .../ConnectionBadge/ConnectionBadge.test.jsx | 19 ++++ src/hooks/useGameChannel.js | 24 +++-- src/hooks/usePendingSync.js | 7 ++ src/utils/pendingSync.js | 63 +++++++++++++ src/utils/pendingSync.test.js | 87 ++++++++++++++++++ 8 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 docs/screenshots/ux/pending.png create mode 100644 src/hooks/usePendingSync.js create mode 100644 src/utils/pendingSync.js create mode 100644 src/utils/pendingSync.test.js diff --git a/docs/screenshots/ux/pending.png b/docs/screenshots/ux/pending.png new file mode 100644 index 0000000000000000000000000000000000000000..be3ab966d38bdce66fbb7619954676cd290e4188 GIT binary patch literal 36865 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 { const status = useConnection(); const players = usePresence(); + const pending = usePendingCount(); const showPresence = status === 'connected' && players > 0; return (
@@ -31,6 +33,18 @@ const ConnectionBadge = () => { {players} )} + {pending > 0 && ( + + + {pending} + + )}
); }; diff --git a/src/components/common/ConnectionBadge/ConnectionBadge.module.scss b/src/components/common/ConnectionBadge/ConnectionBadge.module.scss index e35b7f6..16a3e7a 100644 --- a/src/components/common/ConnectionBadge/ConnectionBadge.module.scss +++ b/src/components/common/ConnectionBadge/ConnectionBadge.module.scss @@ -47,6 +47,44 @@ background: currentColor; } +.pending { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 1.1rem; + height: 1.1rem; + padding: 0 5px; + margin-left: -2px; + + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0; + + color: #f0d28a; + background: rgba(240, 169, 154, 0.16); + border: 1px solid rgba(240, 200, 120, 0.7); + border-radius: 999px; +} + +.pendingGlyph { + display: inline-block; + font-size: 0.7rem; + line-height: 1; + animation: pending-spin 1.6s linear infinite; +} + +@keyframes pending-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .pendingGlyph { + animation: none; + } +} + .connected { color: #b9e2a0; border-color: rgba(185, 226, 160, 0.6); diff --git a/src/components/common/ConnectionBadge/ConnectionBadge.test.jsx b/src/components/common/ConnectionBadge/ConnectionBadge.test.jsx index 1c9a24a..636ec18 100644 --- a/src/components/common/ConnectionBadge/ConnectionBadge.test.jsx +++ b/src/components/common/ConnectionBadge/ConnectionBadge.test.jsx @@ -22,6 +22,7 @@ vi.mock('../../../socket/socket', () => ({ })); import ConnectionBadge from './ConnectionBadge'; +import { markDirty, markClean, __resetPending } from '../../../utils/pendingSync'; const setStatus = (next) => { status = next; @@ -36,6 +37,7 @@ const setPresence = (next) => { beforeEach(() => { status = 'connected'; presence = 0; + __resetPending(); }); describe('ConnectionBadge', () => { @@ -64,4 +66,21 @@ describe('ConnectionBadge', () => { expect(screen.queryByLabelText(/player.* online/)).not.toBeInTheDocument(); expect(screen.getByText('Offline')).toBeInTheDocument(); }); + + it('shows an unsynced-changes count while edits are pending', () => { + render(); + expect(screen.queryByLabelText(/unsynced/)).not.toBeInTheDocument(); + + act(() => markDirty('gold', '/api/gold', { gold: 5 })); + expect(screen.getByLabelText('1 unsynced change')).toHaveTextContent('1'); + + act(() => markDirty('fame', '/api/fame', { fame: 2 })); + expect(screen.getByLabelText('2 unsynced changes')).toHaveTextContent('2'); + + act(() => { + markClean('gold'); + markClean('fame'); + }); + expect(screen.queryByLabelText(/unsynced/)).not.toBeInTheDocument(); + }); }); diff --git a/src/hooks/useGameChannel.js b/src/hooks/useGameChannel.js index 2802425..7be7ff8 100644 --- a/src/hooks/useGameChannel.js +++ b/src/hooks/useGameChannel.js @@ -3,6 +3,7 @@ import { debounce } from 'lodash'; import { get, post } from '../utils/networkUtils'; import { getSocket, getClientId } from '../socket/socket'; import { cacheGet, cacheSet } from '../utils/localStorageUtil'; +import { markDirty, markClean } from '../utils/pendingSync'; import { toast } from '../components/common/Toast/toastStore'; // One hook to own a single game "channel" (gold, fame, heroes, storyPoints): @@ -81,14 +82,19 @@ export const useGameChannel = ({ const debouncedPostRef = useRef(); if (!debouncedPostRef.current) { - debouncedPostRef.current = debounce((targetPath, payload) => { - post(targetPath, payload).catch(() => { - toast.error( - 'Could not save', - 'Your change is shown here but did not reach the archive.', - 'save-error' - ); - }); + debouncedPostRef.current = debounce((targetPath, payload, ch) => { + post(targetPath, payload) + .then(() => markClean(ch)) + .catch(() => { + // Remember the unsynced edit so the pending indicator can show it and + // the next reconnect can replay it automatically. + markDirty(ch, targetPath, payload); + toast.error( + 'Could not save', + 'Your change is kept here and will sync when the link returns.', + 'save-error' + ); + }); }, 400); } @@ -105,7 +111,7 @@ export const useGameChannel = ({ setValue(resolved); const payload = toServerRef.current(resolved); cacheSet(channel, payload); - debouncedPostRef.current(path, payload); + debouncedPostRef.current(path, payload, channel); }, [channel, path] ); diff --git a/src/hooks/usePendingSync.js b/src/hooks/usePendingSync.js new file mode 100644 index 0000000..10db754 --- /dev/null +++ b/src/hooks/usePendingSync.js @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from 'react'; +import { subscribePending, getPendingCount } from '../utils/pendingSync'; + +// Number of channels with edits not yet confirmed by the server (i.e. made +// while offline). Zero when everything is in sync. +export const usePendingCount = () => + useSyncExternalStore(subscribePending, getPendingCount, getPendingCount); diff --git a/src/utils/pendingSync.js b/src/utils/pendingSync.js new file mode 100644 index 0000000..14929c6 --- /dev/null +++ b/src/utils/pendingSync.js @@ -0,0 +1,63 @@ +import { post } from './networkUtils'; +import { subscribeConnectionStatus, getConnectionStatus } from '../socket/socket'; + +// Tracks channels whose latest edit has not yet reached the server — the edits +// a player makes while the realtime link is down. A small pub/sub store (not +// React context) so the data hook can report into it without prop drilling. +// +// When the connection returns, every still-dirty channel is re-posted once, so +// offline edits sync themselves instead of waiting for the next manual change. + +let pending = new Map(); // channel -> { path, payload } +const listeners = new Set(); +let retryWired = false; + +const emit = () => listeners.forEach((listener) => listener(pending.size)); + +const flush = () => { + for (const [channel, { path, payload }] of [...pending]) { + post(path, payload) + .then(() => markClean(channel)) + .catch(() => { + /* still unreachable — keep it dirty for the next reconnect */ + }); + } +}; + +// Lazily watch the connection so importing this module has no side effects +// (and tests can drive it explicitly). Re-flush on each rising edge to +// 'connected'. +const wireRetry = () => { + if (retryWired) return; + retryWired = true; + let previous = getConnectionStatus(); + subscribeConnectionStatus((status) => { + const was = previous; + previous = status; + if (status === 'connected' && was !== 'connected') flush(); + }); +}; + +export const markDirty = (channel, path, payload) => { + wireRetry(); + pending.set(channel, { path, payload }); + emit(); +}; + +export const markClean = (channel) => { + if (pending.delete(channel)) emit(); +}; + +export const getPendingCount = () => pending.size; + +export const subscribePending = (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +// Test-only: drop all state so specs don't bleed into one another. +export const __resetPending = () => { + pending = new Map(); + retryWired = false; + emit(); +}; diff --git a/src/utils/pendingSync.test.js b/src/utils/pendingSync.test.js new file mode 100644 index 0000000..8fc88b8 --- /dev/null +++ b/src/utils/pendingSync.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +let statusListener; +let currentStatus = 'disconnected'; +const post = vi.fn(() => Promise.resolve()); + +vi.mock('../socket/socket', () => ({ + subscribeConnectionStatus: (listener) => { + statusListener = listener; + return () => {}; + }, + getConnectionStatus: () => currentStatus, +})); +vi.mock('./networkUtils', () => ({ + post: (...args) => post(...args), +})); + +import { + markDirty, + markClean, + getPendingCount, + subscribePending, + __resetPending, +} from './pendingSync'; + +beforeEach(() => { + __resetPending(); + post.mockClear(); + post.mockResolvedValue(undefined); + currentStatus = 'disconnected'; + statusListener = undefined; +}); + +describe('pendingSync', () => { + it('counts dirty channels and dedupes by channel', () => { + markDirty('gold', '/api/gold', { gold: 1 }); + markDirty('gold', '/api/gold', { gold: 2 }); + markDirty('fame', '/api/fame', { fame: 1 }); + expect(getPendingCount()).toBe(2); + }); + + it('clears a channel once it syncs', () => { + markDirty('gold', '/api/gold', { gold: 1 }); + markClean('gold'); + expect(getPendingCount()).toBe(0); + }); + + it('notifies subscribers of the current count', () => { + const listener = vi.fn(); + const unsubscribe = subscribePending(listener); + markDirty('gold', '/api/gold', { gold: 1 }); + expect(listener).toHaveBeenLastCalledWith(1); + markClean('gold'); + expect(listener).toHaveBeenLastCalledWith(0); + unsubscribe(); + }); + + it('replays dirty channels when the connection returns', async () => { + markDirty('gold', '/api/gold', { gold: 7 }); + markDirty('fame', '/api/fame', { fame: 3 }); + expect(getPendingCount()).toBe(2); + + // Simulate the link coming back. + currentStatus = 'connected'; + statusListener('connected'); + + // Let the resolved post promises settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(post).toHaveBeenCalledTimes(2); + expect(post).toHaveBeenCalledWith('/api/gold', { gold: 7 }); + expect(getPendingCount()).toBe(0); + }); + + it('keeps a channel dirty if the replay post fails', async () => { + post.mockRejectedValue(new Error('still offline')); + markDirty('gold', '/api/gold', { gold: 7 }); + + currentStatus = 'connected'; + statusListener('connected'); + await Promise.resolve(); + await Promise.resolve(); + + expect(getPendingCount()).toBe(1); + }); +});