Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/ux/pending.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions src/components/common/ConnectionBadge/ConnectionBadge.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useConnection, usePresence } from '../../../hooks/useConnection';
import { usePendingCount } from '../../../hooks/usePendingSync';
import styles from './ConnectionBadge.module.scss';

const LABELS = {
Expand All @@ -18,6 +19,7 @@ const TITLES = {
const ConnectionBadge = () => {
const status = useConnection();
const players = usePresence();
const pending = usePendingCount();
const showPresence = status === 'connected' && players > 0;
return (
<div className={`${styles.badge} ${styles[status]}`} title={TITLES[status]}>
Expand All @@ -31,6 +33,18 @@ const ConnectionBadge = () => {
{players}
</span>
)}
{pending > 0 && (
<span
className={styles.pending}
title={`${pending} ${pending === 1 ? 'change is' : 'changes are'} waiting to sync`}
aria-label={`${pending} unsynced ${pending === 1 ? 'change' : 'changes'}`}
>
<span className={styles.pendingGlyph} aria-hidden="true">
</span>
{pending}
</span>
)}
</div>
);
};
Expand Down
38 changes: 38 additions & 0 deletions src/components/common/ConnectionBadge/ConnectionBadge.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/components/common/ConnectionBadge/ConnectionBadge.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock('../../../socket/socket', () => ({
}));

import ConnectionBadge from './ConnectionBadge';
import { markDirty, markClean, __resetPending } from '../../../utils/pendingSync';

const setStatus = (next) => {
status = next;
Expand All @@ -36,6 +37,7 @@ const setPresence = (next) => {
beforeEach(() => {
status = 'connected';
presence = 0;
__resetPending();
});

describe('ConnectionBadge', () => {
Expand Down Expand Up @@ -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(<ConnectionBadge />);
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();
});
});
24 changes: 15 additions & 9 deletions src/hooks/useGameChannel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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);
}

Expand All @@ -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]
);
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/usePendingSync.js
Original file line number Diff line number Diff line change
@@ -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);
63 changes: 63 additions & 0 deletions src/utils/pendingSync.js
Original file line number Diff line number Diff line change
@@ -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();
};
87 changes: 87 additions & 0 deletions src/utils/pendingSync.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading