Public Access
feat(sync): follow the visit sync on the dashboard
Show the "Syncing with Steam" badge while a visit sync runs, then reload the sync changes banner and the library.
This commit is contained in:
1 parent
8aa3181c37
commit
e5091de50e
6 files changed
+97
-5
No files matched your search
@@ -15,7 +15,7 @@ async function loadInto(path, set, pick = (data) => data) {
|
|||||||
|
|
||||||
export default function useDashboardData() {
|
export default function useDashboardData() {
|
||||||
const {reload: reloadUser} = useCurrentUserContext();
|
const {reload: reloadUser} = useCurrentUserContext();
|
||||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
|
||||||
const [topGames, setTopGames] = useState([]);
|
const [topGames, setTopGames] = useState([]);
|
||||||
const [playing, setPlaying] = useState([]);
|
const [playing, setPlaying] = useState([]);
|
||||||
const [summary, setSummary] = useState(null);
|
const [summary, setSummary] = useState(null);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {RefreshCw} from 'lucide-react';
|
import {RefreshCw} from 'lucide-react';
|
||||||
@@ -72,6 +72,9 @@ const Dashboard = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const forceSync = useForceSync(sync.watch);
|
const forceSync = useForceSync(sync.watch);
|
||||||
|
useEffect(() => {
|
||||||
|
if (steamData.syncStarted) sync.watch();
|
||||||
|
}, [steamData, sync.watch]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton/>;
|
return <DashboardSkeleton/>;
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
const watchSync = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('../hooks/useLibrarySync.js', () => ({
|
||||||
|
default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false, watch: watchSync}),
|
||||||
|
}));
|
||||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||||
|
|
||||||
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
||||||
@@ -42,6 +45,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
watchSync.mockClear();
|
||||||
cleanup();
|
cleanup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
@@ -177,5 +181,22 @@ describe('Dashboard', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
it('watches the sync the server started for this visit', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
...dashboardRoutes(),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
||||||
|
});
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not watch a sync when the server started none', async () => {
|
||||||
|
fetchRoutes(dashboardRoutes());
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||||
|
expect(watchSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, screen} from '@testing-library/react';
|
||||||
|
import Dashboard from './Dashboard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const empty = {content: [], total: 0, byStatus: {}};
|
||||||
|
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||||
|
const calls = (path) => fetch.mock.calls.filter(([url]) => new URL(url, 'http://x').pathname.endsWith(path)).length;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({shouldAdvanceTime: true});
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dashboard sync on a visit', () => {
|
||||||
|
it('showsTheSyncingBadgeDuringTheVisitSyncAndReloadsBannerAndLibraryAfterIt', async () => {
|
||||||
|
let syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-03T10:00:00Z'};
|
||||||
|
let syncChanges = json(204, {});
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/library/summary': json(200, empty),
|
||||||
|
'GET /api/hints': json(200, {results: []}),
|
||||||
|
'GET /api/news': json(200, {results: []}),
|
||||||
|
'GET /api/library/sync-changes': () => syncChanges,
|
||||||
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}),
|
||||||
|
'GET /api/library/games': json(200, empty),
|
||||||
|
'GET /api/library/stats': json(200, {trend: null}),
|
||||||
|
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
|
||||||
|
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
||||||
|
'GET /api/library/sync-status': () => json(200, syncStatus),
|
||||||
|
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0,
|
||||||
|
averageCompletion: 0, pendingGames: 0, hiddenGames: 0, closest: []}),
|
||||||
|
});
|
||||||
|
renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
|
||||||
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||||
|
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
|
||||||
|
|
||||||
|
syncStatus = {lastSyncStatus: 'RUNNING', lastSyncedAt: '2026-10-03T10:00:00Z'};
|
||||||
|
await wait(3000);
|
||||||
|
expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy();
|
||||||
|
const gamesBefore = calls('/api/library/games');
|
||||||
|
const changesBefore = calls('/api/library/sync-changes');
|
||||||
|
|
||||||
|
syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-04T08:00:00Z'};
|
||||||
|
syncChanges = json(200, {syncedAt: '2026-10-04T08:00:00Z', startedBy: 'LOGIN', seen: false, importedGames: 0,
|
||||||
|
changes: [{kind: 'ADDED', appid: 1, name: 'New game', amount: 0}]});
|
||||||
|
await wait(3000);
|
||||||
|
|
||||||
|
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
|
||||||
|
expect(await screen.findByText('SYNC_CHANGES.ADDED:1')).toBeTruthy();
|
||||||
|
expect(calls('/api/library/sync-changes')).toBeGreaterThan(changesBefore);
|
||||||
|
expect(calls('/api/library/games')).toBeGreaterThan(gamesBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ export const markSyncChangesSeen = (syncedAt) =>
|
|||||||
/**
|
/**
|
||||||
* What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or
|
* What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or
|
||||||
* `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty
|
* `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty
|
||||||
* result of an automatic sync, show nothing.
|
* result of an automatic or a visit sync, show nothing.
|
||||||
*/
|
*/
|
||||||
export function bannerOf(result) {
|
export function bannerOf(result) {
|
||||||
if (!result || result.seen) return null;
|
if (!result || result.seen) return null;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ describe('bannerOf', () => {
|
|||||||
expect(bannerOf(result())).toBeNull();
|
expect(bannerOf(result())).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showsNothingForAnEmptyLoginSync', () => {
|
||||||
|
expect(bannerOf(result({startedBy: 'LOGIN'}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('saysNothingChangedForAnEmptyManualSync', () => {
|
it('saysNothingChangedForAnEmptyManualSync', () => {
|
||||||
expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'});
|
expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user