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

+4 -3
View File
@@ -12,9 +12,10 @@ const waitingKey = (kind, reason) => (kind === 'FINISHED_TOPUP' ? WAITING_TOPUP.
/**
* What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it
* settles the points; a status change after the first load also shows the points toast.
* settles the points; a status change after the first load also shows the points toast. `refreshes` loads it again
* after the Steam refresh of the game page, which may have stored an unlock that settles points.
*/
export default function GamePointsLine({game}) {
export default function GamePointsLine({game, refreshes = 0}) {
const {t, i18n} = useTranslation();
const check = usePointsCheck();
const [points, setPoints] = useState(null);
@@ -30,7 +31,7 @@ export default function GamePointsLine({game}) {
return () => {
stale = true;
};
}, [check, game.appid, game.status]);
}, [check, game.appid, game.status, refreshes]);
if (!points) return null;
const earlySinceText = points.earlySince && formatDay(points.earlySince, i18n.language, {dateStyle: 'medium'});
+8 -9
View File
@@ -3,10 +3,8 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js';
import {PLAYING_GAMES, TOP_GAMES, UP_NEXT_GAMES} from '../utils/tiles.js';
// The games endpoint accepts only the page sizes 20, 40 and 80.
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
const DASHBOARD_GAMES_URL = `/api/library/dashboard-games?top=${TOP_GAMES.L}&playing=${PLAYING_GAMES.L}`;
const UP_NEXT_URL = `/api/library/backlog?limit=${UP_NEXT_GAMES.L}&released=true`;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
// A card whose request fails or has no JSON body stays empty.
async function loadInto(path, set, pick = (data) => data) {
@@ -30,8 +28,10 @@ export default function useDashboardData() {
const [error, setError] = useState(null);
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
const fetchGames = useCallback(() => loadInto(DASHBOARD_GAMES_URL, (data) => {
setTopGames(data.top ?? []);
setPlaying(data.playing ?? []);
}), []);
const fetchUpNext = useCallback(() => loadInto(UP_NEXT_URL, setUpNext, (data) => data.games ?? []), []);
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
@@ -50,8 +50,7 @@ export default function useDashboardData() {
const reloadLibrary = () => {
fetchSteamCount();
fetchTopGames();
fetchPlaying();
fetchGames();
fetchUpNext();
fetchSummary();
fetchActivity();
@@ -65,7 +64,7 @@ export default function useDashboardData() {
const withHistory = fetchSteamCount().then(async (counts) => {
if (counts == null || counts.canSync) await Promise.all([fetchActivity(), fetchAchievements()]);
});
const [{ok, status}] = await Promise.all([reloadUser(), withHistory, fetchTopGames(), fetchPlaying(),
const [{ok, status}] = await Promise.all([reloadUser(), withHistory, fetchGames(),
fetchUpNext(), fetchSummary()]);
if (!ok) {
if (status === 0) setError('INFO.ERROR.NETWORK_FAILURE');
@@ -75,7 +74,7 @@ export default function useDashboardData() {
};
fetchUserData();
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
}, [reloadUser, fetchSteamCount, fetchGames, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
return {
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
+23 -3
View File
@@ -2,11 +2,17 @@ import {useCallback, useEffect, useState} from 'react';
import {request} from '../utils/api.js';
import useAbortPrevious from './useAbortPrevious.js';
// What the Steam refresh changes. The rest of the refreshed answer was read before a save made during the wait.
const ACHIEVEMENT_FIELDS = ['hasAchievements', 'statsVisible', 'achievementsTotal', 'achievementsUnlocked',
'achievementsRemaining', 'completionPercent', 'achievements', 'achievementSets', 'platforms', 'achievementsStale'];
export default function useGame(appid) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [reloads, setReloads] = useState(0);
// Counts the finished Steam refreshes, so a reader of the stored achievements can look again.
const [refreshes, setRefreshes] = useState(0);
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
// cards keep what is typed.
@@ -21,19 +27,33 @@ export default function useGame(appid) {
const signal = nextSignal();
setIsLoading(true);
setError(null);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
(async () => {
const {ok, status, data: json} = await request(
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal});
const {ok, status, data: json} = await request(`${url}?refresh=false`, {signal});
if (signal.aborted) return;
setIsLoading(false);
if (status === 401) return;
if (status === 404) setError('GAME_DETAIL.NOT_FOUND');
else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD');
else setData(json);
if (!ok || !json?.achievementsStale) return;
// A failed refresh keeps the stored data, no error.
const refreshed = await request(url, {signal});
if (signal.aborted) return;
if (refreshed.ok && refreshed.data) {
const fresh = Object.fromEntries(ACHIEVEMENT_FIELDS.map((field) => [field, refreshed.data[field]]));
// The finish rule may have set the status during the refresh; keep a status the user changed since.
setData((d) => {
if (!d) return refreshed.data;
const unchanged = d.status === json.status;
return {...d, ...fresh, ...(unchanged && {status: refreshed.data.status, finishedHint: refreshed.data.finishedHint})};
});
}
setRefreshes((n) => n + 1);
})();
}, [nextSignal, appid, reloads]);
const reload = useCallback(() => setReloads((n) => n + 1), []);
return {data, setData, isLoading, error, reload};
return {data, setData, isLoading, error, reload, refreshes};
}
+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',