From e5091de50e71a2d42f2f599dfa116d410f2a639e Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 4 Oct 2026 19:05:23 +0200 Subject: [PATCH] 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. --- src/hooks/useDashboardData.js | 2 +- src/pages/Dashboard.jsx | 5 +- src/pages/Dashboard.test.jsx | 25 +++++++++- src/pages/Dashboard.visitSync.test.jsx | 64 ++++++++++++++++++++++++++ src/utils/syncChanges.js | 2 +- src/utils/syncChanges.test.js | 4 ++ 6 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/pages/Dashboard.visitSync.test.jsx diff --git a/src/hooks/useDashboardData.js b/src/hooks/useDashboardData.js index ee67de8..b8287bc 100644 --- a/src/hooks/useDashboardData.js +++ b/src/hooks/useDashboardData.js @@ -15,7 +15,7 @@ async function loadInto(path, set, pick = (data) => data) { export default function useDashboardData() { 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 [playing, setPlaying] = useState([]); const [summary, setSummary] = useState(null); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 4109e49..5759498 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {RefreshCw} from 'lucide-react'; @@ -72,6 +72,9 @@ const Dashboard = () => { }, }); const forceSync = useForceSync(sync.watch); + useEffect(() => { + if (steamData.syncStarted) sync.watch(); + }, [steamData, sync.watch]); if (isLoading) { return ; diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index 4808349..f8f93f8 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -6,7 +6,10 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {renderWithProviders} from '../test/renderWithProviders.jsx'; 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})})); const avatar = 'https://avatars.steamstatic.com/abc_full.jpg'; @@ -42,6 +45,7 @@ beforeEach(() => { }); afterEach(() => { + watchSync.mockClear(); cleanup(); localStorage.clear(); }); @@ -177,5 +181,22 @@ describe('Dashboard', () => { 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(); + }); +}); diff --git a/src/pages/Dashboard.visitSync.test.jsx b/src/pages/Dashboard.visitSync.test.jsx new file mode 100644 index 0000000..eb8bba6 --- /dev/null +++ b/src/pages/Dashboard.visitSync.test.jsx @@ -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(, {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); + }); +}); diff --git a/src/utils/syncChanges.js b/src/utils/syncChanges.js index 21b4f91..d281a1a 100644 --- a/src/utils/syncChanges.js +++ b/src/utils/syncChanges.js @@ -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 * `{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) { if (!result || result.seen) return null; diff --git a/src/utils/syncChanges.test.js b/src/utils/syncChanges.test.js index 6331ec3..f6e23c7 100644 --- a/src/utils/syncChanges.test.js +++ b/src/utils/syncChanges.test.js @@ -16,6 +16,10 @@ describe('bannerOf', () => { expect(bannerOf(result())).toBeNull(); }); + it('showsNothingForAnEmptyLoginSync', () => { + expect(bannerOf(result({startedBy: 'LOGIN'}))).toBeNull(); + }); + it('saysNothingChangedForAnEmptyManualSync', () => { expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'}); });