diff --git a/docs/screenshots/ux/pending.png b/docs/screenshots/ux/pending.png new file mode 100644 index 0000000..be3ab96 Binary files /dev/null and b/docs/screenshots/ux/pending.png differ diff --git a/src/components/common/ConnectionBadge/ConnectionBadge.jsx b/src/components/common/ConnectionBadge/ConnectionBadge.jsx index cee26c6..a02c0f0 100644 --- a/src/components/common/ConnectionBadge/ConnectionBadge.jsx +++ b/src/components/common/ConnectionBadge/ConnectionBadge.jsx @@ -1,4 +1,5 @@ import { useConnection, usePresence } from '../../../hooks/useConnection'; +import { usePendingCount } from '../../../hooks/usePendingSync'; import styles from './ConnectionBadge.module.scss'; const LABELS = { @@ -18,6 +19,7 @@ const TITLES = { const ConnectionBadge = () => { 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); + }); +});