perf(library): one dashboard call, stored game detail first
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 28s

The Dashboard loads the top and playing tiles from
GET /api/library/dashboard-games in one call. The game page shows the
stored game first (refresh=false) and calls again only when
achievementsStale is true. The second answer merges the achievement
fields; it takes status and finishedHint only when the user did not
change the status. The points check runs again after the refresh.

Refs #47
This commit is contained in:
lobo committed 2026-10-12 09:25:49 +02:00
1 parent 73ad4fd3b8
commit 0924600d95
10 files changed
+179 -36

No files matched your search

+13 -6
View File
@@ -15,7 +15,6 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
const empty = {content: [], total: 0, byStatus: {}};
const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.includes('status=');
const quietRoutes = {
'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}),
@@ -27,7 +26,7 @@ const quietRoutes = {
const dashboardRoutes = (settings) => ({
...quietRoutes,
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings}),
'GET /api/library/games': (url) => json(200, mostPlayed(url) ? {content: games, total: 12} : empty),
'GET /api/library/dashboard-games': json(200, {top: games, playing: []}),
'GET /api/library/stats': json(200, {trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}),
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: avatar}),
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
@@ -91,13 +90,23 @@ describe('Dashboard', () => {
fetchRoutes({...routes, 'GET /api/users/me': () => meAnswer});
const container = renderDashboard().container;
await waitFor(() => expect(asked('/api/library/games')).toBe(true));
await waitFor(() => expect(asked('/api/library/dashboard-games')).toBe(true));
expect(asked('/api/library/summary')).toBe(true);
answerMe(me);
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
});
it('asks both game tiles in one call', async () => {
const container = mockDashboard();
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
const gameCalls = fetch.mock.calls.map(([url]) => url).filter((url) => url.includes('dashboard-games'));
expect(gameCalls).toHaveLength(1);
expect(gameCalls[0]).toContain('top=10&playing=8');
expect(asked('/api/library/games?')).toBe(false);
});
it('shows today\'s tiles without a saved layout', async () => {
const container = mockDashboard();
@@ -231,9 +240,7 @@ describe('Dashboard', () => {
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/backlog': json(200, {games: []}),
'GET /api/library/games': (url) => (mostPlayed(url)
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
: json(200, empty)),
'GET /api/library/dashboard-games': {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }},
});
renderDashboard();
+3 -3
View File
@@ -32,7 +32,7 @@ describe('Dashboard sync on a visit', () => {
'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/dashboard-games': json(200, {top: [], playing: []}),
'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: []}),
@@ -49,7 +49,7 @@ describe('Dashboard sync on a visit', () => {
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 gamesBefore = calls('/api/library/dashboard-games');
const changesBefore = calls('/api/library/sync-changes');
syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-04T08:00:00Z'};
@@ -60,6 +60,6 @@ describe('Dashboard sync on a visit', () => {
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);
expect(calls('/api/library/dashboard-games')).toBeGreaterThan(gamesBefore);
});
});
+2 -2
View File
@@ -33,7 +33,7 @@ export default function GameDetail() {
const {appid} = useParams();
const navigate = useNavigate();
const {data, setData, isLoading, error, reload} = useGame(appid);
const {data, setData, isLoading, error, reload, refreshes} = useGame(appid);
const [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false);
const [linking, setLinking] = useState(false);
@@ -96,7 +96,7 @@ export default function GameDetail() {
</p>
)}
<GamePointsLine key={`points-${data.appid}`} game={data}/>
<GamePointsLine key={`points-${data.appid}`} game={data} refreshes={refreshes}/>
{/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
+43
View File
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {cleanup, screen, waitFor} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 0, source: 'STEAM', status: 'FINISHED',
rating: null, notes: null, priority: null, hasAchievements: true, statsVisible: true, achievements: [],
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('checksThePointsAgainAfterTheAchievementRefresh', async () => {
let finishRefresh;
const answers = [json(200, {...game, achievementsStale: true, achievementsUnlocked: 3}),
new Promise((resolve) => { finishRefresh = resolve; })];
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/rewards/games/220': json(200, {balance: 0, missing: {FINISHED: 'NO_PROOF'}, paid: []}),
'GET /api/library/games/220': () => answers.shift(),
});
const pointsCalls = () => fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/220')).length;
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
await screen.findByText('Half-Life 2');
await waitFor(() => expect(pointsCalls()).toBe(1));
// The refresh stored the ending achievement: Core would settle the finish now (the old single call did).
finishRefresh(json(200, {...game, achievementsStale: false, achievementsUnlocked: 5}));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(pointsCalls()).toBe(2);
});
+79 -6
View File
@@ -17,15 +17,17 @@ const game = {
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/library/games/220')).length;
const gameUrls = () => fetch.mock.calls.map(([url]) => url).filter((url) => new URL(url, 'http://x').pathname.endsWith('/api/library/games/220'));
const gameLoads = () => gameUrls().length;
const gameRoutes = (gameAnswer) => fetchRoutes({
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': gameAnswer,
});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': json(200, game),
});
gameRoutes(json(200, game));
});
afterEach(() => {
@@ -42,3 +44,74 @@ it('keeps unsaved notes while the game loads again after a correction', async ()
await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'));
});
it('shows the stored game, then the refreshed one when the achievements are stale', async () => {
const answers = [json(200, {...game, achievementsStale: true}),
json(200, {...game, hasAchievements: true, statsVisible: true, achievementsTotal: 1, achievementsUnlocked: 1,
achievements: [{apiName: 'A', displayName: 'Refreshed achievement', unlocked: true, kind: 'STEAM'}],
achievementSets: [{id: 'steam', kind: 'STEAM', total: 1, unlocked: 1, perfect: true, visible: true, listed: true}],
achievementsStale: false})];
gameRoutes(() => answers.shift());
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByText('Refreshed achievement')).toBeTruthy();
expect(gameUrls()).toHaveLength(2);
expect(gameUrls()[0]).toContain('refresh=false');
expect(gameUrls()[1]).not.toContain('refresh');
});
it('keeps the stored game when the refreshing call fails', async () => {
const answers = [json(200, {...game, achievementsStale: true}), json(500, {})];
gameRoutes(() => answers.shift());
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByText('Half-Life 2')).toBeTruthy();
await waitFor(() => expect(gameUrls()).toHaveLength(2));
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
});
it('asks once when the achievements are fresh', async () => {
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByText('Half-Life 2')).toBeTruthy();
expect(gameUrls()).toHaveLength(1);
expect(gameUrls()[0]).toContain('refresh=false');
});
// The refreshing call answers the status read before a save made in its wait; only its achievements are taken.
it('keepsAStatusSavedWhileTheAchievementsRefresh', async () => {
let finishRefresh;
const answers = [json(200, {...game, achievementsStale: true}),
new Promise((resolve) => { finishRefresh = resolve; })];
fetchRoutes({
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': () => answers.shift(),
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
});
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
// Core read the game (BACKLOG) before the save, then waited for Steam.
finishRefresh(json(200, {...game, achievementsStale: false}));
await waitFor(() => expect(gameLoads()).toBe(2));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull();
});
// The finish rule (ending achievement unlocked) sets the status during the refresh.
it('showsTheStatusTheRefreshSetWhenTheUserChangedNothing', async () => {
let finishRefresh;
const answers = [json(200, {...game, achievementsStale: true}),
new Promise((resolve) => { finishRefresh = resolve; })];
gameRoutes(() => answers.shift());
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeTruthy();
finishRefresh(json(200, {...game, status: 'FINISHED', achievementsStale: false}));
await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
});
+1 -1
View File
@@ -18,7 +18,7 @@ vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// The last column: what the page loads besides GET /api/users/me of the provider.
const pages = [
['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE',
['/api/library/games', '/api/library/games-count', '/api/library/backlog', '/api/library/summary',
['/api/library/dashboard-games', '/api/library/games-count', '/api/library/backlog', '/api/library/summary',
'/api/library/activity', '/api/library/achievements/summary']],
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD', ['/api/library/games/220']],
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD',