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);
+ });
+});