fix(stats): open game detail pages from stat lists, perfect games clickable

Add perfect games click-through on the dashboard. Remove Steam store
links from game lists. Stat numbers with count 0 are not clickable.
Tests cover the perfect-games click, zero guards, and owner versus
visitor behavior.
This commit is contained in:
lobo committed 2026-10-04 10:07:27 +02:00
1 parent 777ba87fd6
commit 8b94a25844
27 files changed
+270 -51

No files matched your search

+1 -4
View File
@@ -17,7 +17,6 @@ import {TagBars} from '../components/stats/TasteStats.jsx';
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
import useSession from '../hooks/useSession.js';
import {genreKey} from '../utils/stats.js';
import {gameHref} from '../utils/statGames.js';
import usePublicStats from '../hooks/usePublicStats.js';
@@ -51,11 +50,9 @@ function RankedGames({title, games, formatValue, drillKey}) {
}
function GameName({game}) {
const href = gameHref(game.appid, false);
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
return (
<span className="@container min-w-0 flex-1 font-medium">
{href ? <a href={href} target="_blank" rel="noopener noreferrer" className="hover:text-primary">{name}</a> : name}
<FitText text={game.name ?? `App ${game.appid}`}/>
</span>
);
}
+18
View File
@@ -43,6 +43,15 @@ describe('LandingPage', () => {
}
});
it('topGamesHaveNoStoreLink', async () => {
answer({topPlayedGames: [{appid: 220, name: 'Half-Life 2', value: 600}]});
const {container} = renderPage();
await screen.findByText('Half-Life 2');
expect(container.querySelector('a[href*="steampowered"]')).toBeNull();
});
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
fetchRoutes({
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
@@ -58,4 +67,13 @@ describe('LandingPage', () => {
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
});
it('aZeroTotalIsNotClickable', async () => {
answer({totalLibraryEntries: 0});
renderPage();
await screen.findByText('LANDING.TOTAL_GAMES');
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull();
});
});
+1 -1
View File
@@ -203,7 +203,7 @@ function LockedProgress({item, progress}) {
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
// Playtime months compare two numbers: no list of games shows them.
const target = useDrillTarget({
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`),
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`), count,
});
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
const Wrapper = target ? 'button' : 'div';
+7
View File
@@ -121,6 +121,13 @@ describe('PointsPage', () => {
expect(within(item).queryByRole('button')).toBeNull();
});
it('shows no button on a locked bar without progress', async () => {
await renderPage({'GET /api/rewards/wallet': json(200, wallet({progress: {GAMES_FINISHED: 0}}))});
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
expect(within(item).queryByRole('button')).toBeNull();
});
it('caps the progress at the target', async () => {
await renderPage();
+2 -2
View File
@@ -138,7 +138,7 @@ export default function PublicProfilePage() {
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
default: return statGroupTile(id, library.stats, false);
default: return statGroupTile(id, library.stats, owner);
}
};
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
@@ -178,7 +178,7 @@ export default function PublicProfilePage() {
</Button>
)}
</Card>
<StatDrillProvider source="profile" ticket={profile.ticket}>
<StatDrillProvider source="profile" ticket={profile.ticket} linked={owner}>
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
onCancel={() => setEditing(false)}/>
+65
View File
@@ -290,4 +290,69 @@ describe('PublicProfilePage', () => {
delete library.stats;
}
});
const gamesRoute = {'GET /api/public/profiles/library/stats/games': json(200, {
content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})};
const topGamesStats = {pile: null, trend: null, taste: null, finished: null, habits: null,
topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}], mostLoyal: null}};
it('theOwnerOpensTheDetailPageFromTheList', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1');
} finally {
localStorage.clear();
}
});
it('theOwnerTileRowsOpenTheDetailPage', async () => {
localStorage.setItem('tbp_session', '1');
library.stats = topGamesStats;
try {
mockBackends({me: {displayName: 'lobo', role: 'USER'}, style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}});
renderPage();
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
} finally {
delete library.stats;
localStorage.clear();
}
});
it('aVisitorSeesTileRowsWithoutLinks', async () => {
library.stats = topGamesStats;
try {
mockBackends({style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}});
const {container} = renderPage();
await screen.findByText('Portal 2');
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
expect(container.querySelector('a[href*="steampowered"]')).toBeNull();
} finally {
delete library.stats;
}
});
it('opensTheGamesBehindPerfectGames', async () => {
library.perfectGames = 12;
library.averageCompletion = 64.5;
try {
mockBackends({extra: gamesRoute});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: /PROFILE\.PERFECT_GAMES: 12/}));
await screen.findByText('Backlogged');
expect(fetch.mock.calls.find(([url]) => url.includes('/stats/games'))[0]).toContain('key=PERFECT');
} finally {
delete library.perfectGames;
delete library.averageCompletion;
}
});
});
+28
View File
@@ -113,6 +113,34 @@ describe('StatsPage', () => {
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
});
it('aZeroTileIsNotClickable', async () => {
const base = stats();
answerStats(json(200, stats({overview: {...base.overview, neverStarted: 0}})));
renderPage();
await screen.findByText('STATS.OVERVIEW.NEVER_STARTED');
expect(screen.queryByRole('button', {name: /STATS\.OVERVIEW\.NEVER_STARTED/})).toBeNull();
});
it('zeroCountsAreNotClickableInPileHabitsAndStatuses', async () => {
const base = stats();
answerStats(json(200, stats({
overview: {...base.overview, byStatus: {...base.overview.byStatus, DROPPED: 0}},
pile: {...base.pile, backlogGames: 0, gamesWithTimeToBeat: 0},
habits: {...base.habits, underOneHourGames: 0},
})));
renderPage();
await screen.findByText('STATS.PILE.GAMES');
for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED: 0/]) {
expect(screen.queryByRole('button', {name})).toBeNull();
expect(screen.queryByRole('link', {name})).toBeNull();
}
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
});
it('leavesTheMoonTripsAlone', async () => {
answerStats(json(200, stats()));
renderPage();