feat(ui): make stat numbers clickable to open games
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 26s

StatDrill context holds one dialog for a page. StatCount wraps a number:
total and status counts link to the library filter, other numbers open
StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars
(genres, platforms, moods) and other stat displays. Dialog shows 40 games
per page with bottom-sheet layout on phones, handles pagination and errors
(403 not shared, 401 expired, 400 bad argument).
This commit is contained in:
lobo committed 2026-10-03 18:51:59 +02:00
1 parent ed5df8c40d
commit 777ba87fd6
31 files changed
+1026 -261

No files matched your search

+16 -4
View File
@@ -6,10 +6,12 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx';
import {StatCount} from './stats/StatDrill.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare} from '../utils/backlog.js';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
import {libraryLink} from '../utils/statGames.js';
/**
* @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing
@@ -34,17 +36,25 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
<div>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}}
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
</StatCount>
<span className="text-sm text-muted-foreground">
{t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
<StatCount params={summary.neverPlayed > 0 ? {key: 'NEVER_STARTED'} : null}
title={t('STATS.OVERVIEW.NEVER_STARTED')}>
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
</StatCount>
</p>
{summary.backlogMinutes > 0 && (
<p className="text-sm text-muted-foreground">
{t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})}
<StatCount params={{key: 'PILE_TIME_TO_BEAT'}} title={t('STATS.PILE.NORMAL')}>
{t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})}
</StatCount>
</p>
)}
</div>
@@ -67,7 +77,9 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
<li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)}
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
<StatCount to={libraryLink(s)} label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
</StatCount>
</li>
))}
</ul>
+16 -1
View File
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import PileOfShameCard from './PileOfShameCard.jsx';
import {StatDrillProvider} from './stats/StatDrill.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -74,4 +76,17 @@ describe('PileOfShameCard', () => {
expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull();
});
it('linksTheBacklogNumberToTheLibraryAndOpensTheNeverPlayedGames', async () => {
fetchRoutes({'GET /api/library/stats/games': json(200, {content: [{appid: 7, name: 'Untouched'}],
page: 0, size: 40, totalElements: 1, totalPages: 1})});
render(<MemoryRouter><StatDrillProvider source="own"><PileOfShameCard summary={summary(2)} onShuffle={() => {}}/></StatDrillProvider></MemoryRouter>);
expect(screen.getAllByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 2'})[0].getAttribute('href'))
.toBe('/library?hideDuplicates=true&status=BACKLOG');
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.NEVER_PLAYED:1'}));
expect(await screen.findByText('Untouched')).toBeTruthy();
await waitFor(() => expect(fetch.mock.calls[0][0]).toContain('key=NEVER_STARTED'));
});
});
+13 -2
View File
@@ -2,6 +2,7 @@ import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Card} from '@/components/ui/card';
import {useDrillTarget} from './stats/StatDrill.jsx';
function useCountUp(target, durationMs = 1600) {
const [value, setValue] = useState(0);
@@ -25,10 +26,14 @@ function useCountUp(target, durationMs = 1600) {
return value;
}
export default function StatCard({label, value, icon: Icon, suffix}) {
/**
* @param drill `{params}` of StatCount: the card is a button that opens the games behind the number
*/
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
const {i18n} = useTranslation();
const animated = useCountUp(value);
return (
const target = useDrillTarget({title: label, ...drill});
const card = (
<Card className="gap-3 p-5">
{Icon && (
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
@@ -41,4 +46,10 @@ export default function StatCard({label, value, icon: Icon, suffix}) {
<div className="text-sm text-muted-foreground">{label}</div>
</Card>
);
return target ? (
<button type="button" onClick={target.onClick}
className="block w-full rounded-xl text-left transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring">
{card}
</button>
) : card;
}
+1 -1
View File
@@ -33,7 +33,7 @@ const SteamGameCount = ({targetCount}) => {
<Gamepad2 className="size-5"/>
</div>
<div>
<div className="text-5xl font-bold tracking-tight tabular-nums">{count}</div>
<Link to="/library" className="block text-5xl font-bold tracking-tight tabular-nums hover:text-primary">{count}</Link>
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
</div>
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
+20 -10
View File
@@ -2,19 +2,25 @@ import {useTranslation} from 'react-i18next';
import {Flag} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx';
export function YearBars({perYear}) {
/**
* @param onSelect `(year)`: the rows are buttons
*/
export function YearBars({perYear, onSelect}) {
const max = Math.max(1, ...perYear.map((y) => y.games));
return (
<ul className="space-y-2">
{perYear.map((y) => (
<li key={y.year} className="flex items-center gap-3 text-sm">
<span className="w-12 tabular-nums">{y.year}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${(y.games / max) * 100}%`}}/>
</div>
<span className="w-8 text-right tabular-nums">{y.games}</span>
<li key={y.year} className="text-sm">
<DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
<span className="w-12 tabular-nums">{y.year}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${(y.games / max) * 100}%`}}/>
</div>
<span className="w-8 text-right tabular-nums">{y.games}</span>
</DrillRow>
</li>
))}
</ul>
@@ -23,16 +29,20 @@ export function YearBars({perYear}) {
export default function FinishedStats({finished}) {
const {t} = useTranslation();
const drill = useStatDrill();
return (
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
{finished.perYear.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
) : (
<YearBars perYear={finished.perYear}/>
<YearBars perYear={finished.perYear}
onSelect={drill && ((year) => drill.open(`${t('STATS.FINISHED.TITLE')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
)}
{finished.finishedBeforeTracking > 0 && (
<p className="text-xs text-muted-foreground">
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
<StatCount params={{key: 'FINISHED_UNTRACKED'}} title={t('STATS.DRILL.BEFORE_TRACKING')}>
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
</StatCount>
</p>
)}
</StatsSection>
+17 -9
View File
@@ -1,23 +1,31 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import GameCover from '../GameCover.jsx';
import FitText from '../FitText.jsx';
import {gameHref} from '../../utils/statGames.js';
/**
* A game with its cover and a value on the right; links to the game page only in the user's own library.
* A game with its cover and a value on the right; links to the game page in the user's own library, to the Steam
* store for a visitor.
*/
export default function GameRow({game, value, linked = true}) {
const {t} = useTranslation();
const content = (
<>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
<span className="text-sm text-muted-foreground tabular-nums">{value}</span>
<span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span>
</>
);
return linked ? (
<Link to={`/library/${game.appid}`} className="flex items-center gap-3 hover:text-primary transition-colors">
{content}
</Link>
) : (
<div className="flex items-center gap-3">{content}</div>
);
const className = 'flex items-center gap-3 hover:text-primary transition-colors';
if (linked) return <Link to={gameHref(game.appid, true)} className={className}>{content}</Link>;
const href = gameHref(game.appid, false);
if (href) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" title={t('STATS.DRILL.OPEN')} className={className}>
{content}
</a>
);
}
return <div className="flex items-center gap-3">{content}</div>;
}
+3 -2
View File
@@ -19,12 +19,13 @@ export default function HabitsStats({habits}) {
hint={t('STATS.HABITS.MOON_HINT')}/>
{habits.longestDay && (
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
hint={day}/>
hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/>
)}
{habits.underOneHourShare != null && (
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
value={formatPercent(habits.underOneHourShare, i18n.language)}
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}/>
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
drill={{params: {key: 'UNDER_ONE_HOUR'}}}/>
)}
</div>
</StatsSection>
+12 -4
View File
@@ -4,7 +4,9 @@ import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import {GAME_STATUSES} from '../../utils/limits.js';
import {StatCount} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {libraryLink} from '../../utils/statGames.js';
export default function OverviewStats({overview}) {
const {t, i18n} = useTranslation();
@@ -12,16 +14,22 @@ export default function OverviewStats({overview}) {
return (
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}/>
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
drill={{to: libraryLink(), params: {key: 'TOTAL'}}}/>
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}/>
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}/>
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
drill={{params: {key: 'PLAYED'}}}/>
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
drill={{params: {key: 'NEVER_STARTED'}}}/>
</div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{GAME_STATUSES.map((s) => (
<li key={s}>
{t(`BACKLOG.STATUS.${s}`)}{' '}
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
</StatCount>
</li>
))}
</ul>
+5 -2
View File
@@ -5,6 +5,7 @@ import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import GameRow from './GameRow.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {libraryLink} from '../../utils/statGames.js';
import {formatDecimal} from '../../utils/stats.js';
import {formatDate} from '../../utils/format.js';
@@ -16,9 +17,11 @@ export default function PileStats({pile, linked = true}) {
return (
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}/>
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}}}/>
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}/>
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}
drill={{params: {key: 'PILE_TIME_TO_BEAT'}}}/>
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
</div>
{pile.normalMinutes > 0 && (
+17 -6
View File
@@ -1,4 +1,5 @@
import {useTranslation} from 'react-i18next';
import {StatCount} from './StatDrill.jsx';
import {formatDay} from '../../utils/format.js';
import {trendPoints} from '../../utils/stats.js';
@@ -16,15 +17,25 @@ export default function PileTrend({trend}) {
return (
<div className="space-y-2">
<p className="text-sm">
{t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, done: thisMonth.done})}
<span>{t('STATS.TREND.THIS_MONTH')}</span>{' '}
<StatCount params={thisMonth.added > 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}>
{t('STATS.TREND.THIS_MONTH_ADDED', {added: thisMonth.added})}
</StatCount>
{', '}
<StatCount params={thisMonth.done > 0 ? {key: 'TREND_DONE'} : null} title={t('STATS.DRILL.TREND_DONE')}>
{t('STATS.TREND.THIS_MONTH_DONE', {done: thisMonth.done})}
</StatCount>
</p>
{days.length > 1 ? (
<div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
role="img" aria-label={t('STATS.TREND.TITLE')}>
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
</svg>
<StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')}
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
role="img" aria-label={t('STATS.TREND.TITLE')}>
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
</svg>
</StatCount>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}</span>
+3 -1
View File
@@ -3,15 +3,17 @@ import {MonitorSmartphone} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import {TagBars} from './TasteStats.jsx';
import {useTagDrill} from './StatDrill.jsx';
export default function PlatformStats({platforms}) {
const {t} = useTranslation();
const select = useTagDrill();
return (
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
{platforms.perPlatform.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
) : (
<TagBars tags={platforms.perPlatform}/>
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')}/>
)}
</StatsSection>
);
+90
View File
@@ -0,0 +1,90 @@
import {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {Link} from 'react-router-dom';
import {cn} from '@/lib/utils';
import StatGamesDialog from './StatGamesDialog.jsx';
const StatDrillContext = createContext(null);
// The dotted underline says "there is more behind this number".
const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max-sm:min-w-11 underline decoration-dotted '
+ 'underline-offset-4 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer';
/**
* Holds the dialog of the games behind a number, once for a page.
*
* @param source own, profile, community or points
* @param ticket profile: the ticket for the visitor's requests
*/
export function StatDrillProvider({source, ticket, children}) {
const [request, setRequest] = useState(null);
const opener = useRef(null);
useEffect(() => {
if (!request) opener.current?.focus?.();
}, [request]);
const value = useMemo(() => ({
source,
open: (title, params) => {
opener.current = document.activeElement;
setRequest({title, source, ticket, params});
},
}), [source, ticket]);
return (
<StatDrillContext.Provider value={value}>
{children}
<StatGamesDialog request={request} onClose={() => setRequest(null)}
onCloseAutoFocus={(event) => event.preventDefault()}/>
</StatDrillContext.Provider>
);
}
/** `{source, open(title, params)}`, or null outside a provider: the numbers are not clickable there. */
export const useStatDrill = () => useContext(StatDrillContext);
/**
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
*/
export function useDrillTarget({to, params, title}) {
const drill = useStatDrill();
if (to && drill?.source === 'own') return {to};
if (drill && params) return {onClick: () => drill.open(title, params)};
return null;
}
/** `(key) => onSelect` of TagBars for a key with the tag name as its argument; null outside a provider. */
export function useTagDrill() {
const drill = useStatDrill();
return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name}));
}
/** A bar row: a button when it has `onSelect`. */
export function DrillRow({onSelect, className, children}) {
return onSelect ? (
<button type="button" onClick={onSelect}
className={cn('block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring', className)}>
{children}
</button>
) : <div className={className}>{children}</div>;
}
export function DrillButton({onClick, label, className, children}) {
return (
<button type="button" onClick={onClick} aria-label={label} className={cn(DRILL_CLASS, className)}>
{children}
</button>
);
}
/**
* A number (or any inline content) that opens the games behind it. Plain content when it has no target.
*
* @param to library URL, used instead of the dialog in the user's own views
* @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request
*/
export function StatCount({to, params, title, label, className, children}) {
const target = useDrillTarget({to, params, title});
if (!target) return children;
return target.to
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
: <DrillButton onClick={target.onClick} label={label} className={className}>{children}</DrillButton>;
}
+111
View File
@@ -0,0 +1,111 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import {Skeleton} from '@/components/ui/skeleton';
import GameRow from './GameRow.jsx';
import GameStatusBadge from '../GameStatusBadge.jsx';
import {formatDate} from '../../utils/format.js';
import {formatPlaytime} from '../../utils/games.js';
import {fetchStatGames} from '../../utils/statGames.js';
const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
/**
* The games behind one number, 40 per page.
*
* @param request `{title, source, ticket, params}` or null (closed)
*/
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
const {t, i18n} = useTranslation();
const [state, setState] = useState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
const [status, setStatus] = useState('loading');
const openRequest = useRef(null);
const load = useCallback(async (page, current) => {
setStatus('loading');
const {ok, status: code, data} = await fetchStatGames(request.source, {...request.params, page}, request.ticket);
if (openRequest.current !== request) return;
if (!ok) {
setStatus(code);
return;
}
setState({
games: [...current, ...data.content], page, totalPages: data.totalPages, total: data.totalElements,
untracked: data.untracked ?? 0,
});
setStatus('ok');
}, [request]);
useEffect(() => {
openRequest.current = request;
if (!request) return;
setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
load(0, []);
}, [request, load]);
const value = (game) => {
if (request.source === 'community') {
return `${t('STATS.DRILL.IN_LIBRARIES', {count: game.libraries})} · ${formatPlaytime(game.minutes)}`;
}
const moved = game.fromStatus || game.toStatus;
return (
<>
{!moved && game.status && <GameStatusBadge status={game.status} compact/>}
{moved && (
<span>{game.fromStatus ? t(`BACKLOG.STATUS.${game.fromStatus}`) : t('STATS.DRILL.NEW_IN_LIBRARY')}
{' → '}{game.toStatus ? t(`BACKLOG.STATUS.${game.toStatus}`) : '–'}</span>
)}
<span>{game.at
? formatDate(game.at, i18n.language, {dateStyle: 'medium'})
: formatPlaytime(game.minutes ?? game.playtimeMinutes ?? 0)}</span>
</>
);
};
const failed = typeof status === 'number';
return (
<Dialog open={request != null} onOpenChange={(open) => !open && onClose()}>
<DialogContent onCloseAutoFocus={onCloseAutoFocus}
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
<DialogTitle className="pr-6">{request?.title}</DialogTitle>
<DialogDescription>{status === 'ok' ? t('STATS.DRILL.GAMES', {count: state.total}) : ' '}</DialogDescription>
{request && (
<ul className="space-y-2">
{state.games.map((game, index) => (
<li key={`${game.appid}-${index}`}>
<GameRow game={game} value={value(game)} linked={request.source === 'own'}/>
</li>
))}
</ul>
)}
{status === 'loading' && <Skeleton className="h-24 rounded-xl"/>}
{status === 'ok' && state.games.length === 0 && (
<p className="text-sm text-muted-foreground">{t('STATS.DRILL.EMPTY')}</p>
)}
{status === 'ok' && state.untracked > 0 && (
<p className="text-xs text-muted-foreground">{t('STATS.FINISHED.BEFORE', {count: state.untracked})}</p>
)}
{failed && (
<div className="flex flex-col items-start gap-2 text-sm text-muted-foreground">
<p>{t(ERRORS[status] ?? 'STATS.DRILL.ERROR')}</p>
{status === 401
? <Button variant="outline" size="sm" className="min-h-11" onClick={() => globalThis.location.reload()}>
{t('STATS.DRILL.RELOAD')}</Button>
: status !== 403 && (
<Button variant="outline" size="sm" className="min-h-11"
onClick={() => load(state.page + (state.games.length > 0 ? 1 : 0), state.games)}>
{t('DASHBOARD.RETRY')}</Button>
)}
</div>
)}
{status === 'ok' && state.page + 1 < state.totalPages && (
<Button variant="outline" className="min-h-11" onClick={() => load(state.page + 1, state.games)}>
{t('STATS.DRILL.LOAD_MORE')}
</Button>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import StatGamesDialog from './StatGamesDialog.jsx';
import {StatCount, StatDrillProvider} from './StatDrill.jsx';
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
const GAMES = '/api/library/stats/games';
const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => (
{appid: from + i, name: `Game ${from + i}`, status: 'BACKLOG', playtimeMinutes: 60, ...extra}));
const page = (content, index, totalPages, totalElements) =>
json(200, {content, page: index, size: 40, totalElements, totalPages});
const renderDialog = (request, onClose = () => {}) => render(
<MemoryRouter><StatGamesDialog request={{title: 'Shooter', source: 'own', params: {key: 'GENRE', arg: 'Shooter'}, ...request}}
onClose={onClose}/></MemoryRouter>);
describe('StatGamesDialog', () => {
afterEach(cleanup);
it('loadsPageZeroAndAppendsTheNextOne', async () => {
fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1')
? page(rows(41, 5), 1, 2, 45)
: page(rows(1, 40), 0, 2, 45)});
renderDialog();
expect(await screen.findByText('Game 40')).toBeTruthy();
expect(screen.getAllByRole('listitem')).toHaveLength(40);
expect(fetch.mock.calls[0][0]).toContain('key=GENRE&arg=Shooter&page=0');
fireEvent.click(screen.getByRole('button', {name: 'STATS.DRILL.LOAD_MORE'}));
expect(await screen.findByText('Game 45')).toBeTruthy();
expect(screen.getAllByRole('listitem')).toHaveLength(45);
expect(screen.queryByRole('button', {name: 'STATS.DRILL.LOAD_MORE'})).toBeNull();
});
it('closesOnEscape', async () => {
fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)});
const onClose = vi.fn();
renderDialog({}, onClose);
await screen.findByText('Game 1');
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
expect(onClose).toHaveBeenCalled();
});
it('offersARetryAfterAnError', async () => {
let fail = true;
fetchRoutes({[`GET ${GAMES}`]: () => (fail ? json(500, {}) : page(rows(1, 1), 0, 1, 1))});
renderDialog();
expect(await screen.findByText('STATS.DRILL.ERROR')).toBeTruthy();
fail = false;
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.RETRY'}));
expect(await screen.findByText('Game 1')).toBeTruthy();
});
it('saysSoWhenTheOwnerDoesNotShareTheNumber', async () => {
fetchRoutes({'GET /api/public/profiles/library/stats/games': json(403, {code: 'NOT_SHARED'})});
renderDialog({source: 'profile', ticket: 't'});
expect(await screen.findByText('STATS.DRILL.NOT_SHARED')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'DASHBOARD.RETRY'})).toBeNull();
});
it('asksForAReloadWhenTheTicketExpired', async () => {
const mock = fetchRoutes({'GET /api/public/profiles/library/stats/games': json(401, {code: 'INVALID_TICKET'})});
renderDialog({source: 'profile', ticket: 'old'});
expect(await screen.findByText('STATS.DRILL.EXPIRED')).toBeTruthy();
expect(screen.getByRole('button', {name: 'STATS.DRILL.RELOAD'})).toBeTruthy();
expect(mock.mock.calls[0][1].headers['X-Profile-Ticket']).toBe('old');
});
it('showsTheStatusMoveOfATrendRow', async () => {
fetchRoutes({[`GET ${GAMES}`]: page(
[{appid: 5, name: 'Moved', fromStatus: 'BACKLOG', toStatus: 'PLAYING', at: '2026-09-20T10:00:00Z'}], 0, 1, 1)});
renderDialog({params: {key: 'TREND_MOVES'}});
await screen.findByText('Moved');
expect(screen.getByText(/BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
});
it('showsTheLibraryCountOfACommunityRow', async () => {
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
renderDialog({source: 'community', params: {key: 'OWNED'}});
await screen.findByText('Shared');
expect(screen.getByText('STATS.DRILL.IN_LIBRARIES:3 · 2.0h')).toBeTruthy();
});
it('focusReturnsToTheClickedNumber', async () => {
fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)});
render(<MemoryRouter><StatDrillProvider source="own">
<StatCount params={{key: 'PLAYED'}} title="Played" label="Played: 1">1</StatCount>
</StatDrillProvider></MemoryRouter>);
const number = screen.getByRole('button', {name: 'Played: 1'});
number.focus();
fireEvent.click(number);
await screen.findByText('Game 1');
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
await act(async () => {});
expect(document.activeElement).toBe(number);
});
it('aLateAnswerOfAClosedListDoesNotFillTheOpenOne', async () => {
let answerOld;
fetchRoutes({[`GET ${GAMES}`]: (url) => (url.includes('arg=Old')
? new Promise((resolve) => { answerOld = resolve; })
: page([{appid: 2, name: 'New game', playtimeMinutes: 1}], 0, 1, 1))});
const request = (arg) => ({title: arg, source: 'own', params: {key: 'GENRE', arg}});
const view = render(<MemoryRouter><StatGamesDialog request={request('Old')} onClose={() => {}}/></MemoryRouter>);
view.rerender(<MemoryRouter><StatGamesDialog request={request('New')} onClose={() => {}}/></MemoryRouter>);
await screen.findByText('New game');
await act(async () => answerOld(page([{appid: 1, name: 'Old game', playtimeMinutes: 1}], 0, 1, 1)));
expect(screen.queryByText('Old game')).toBeNull();
expect(screen.getByText('New game')).toBeTruthy();
});
});
+19 -4
View File
@@ -1,9 +1,24 @@
export default function StatTile({label, value, hint}) {
return (
<div className="rounded-lg border p-4">
import {Link} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {useDrillTarget} from './StatDrill.jsx';
const CLICKABLE = 'block w-full text-left transition-colors hover:border-primary focus-visible:ring-2 focus-visible:ring-ring';
/**
* @param drill `{to, params, title}` of StatCount: the tile is a link or button when it has a target here
*/
export default function StatTile({label, value, hint, drill}) {
const target = useDrillTarget({title: label, ...drill});
const body = (
<>
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
<div className="text-sm text-muted-foreground">{label}</div>
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
</div>
</>
);
const className = 'rounded-lg border p-4';
if (target?.to) return <Link to={target.to} className={cn(className, CLICKABLE)}>{body}</Link>;
if (target) return <button type="button" onClick={target.onClick} className={cn(className, CLICKABLE)}>{body}</button>;
return <div className={className}>{body}</div>;
}
+20 -13
View File
@@ -2,26 +2,32 @@ import {useTranslation} from 'react-i18next';
import {Palette} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import {DrillRow, useTagDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {genreKey} from '../../utils/stats.js';
export function TagBars({tags, label = (name) => name}) {
/**
* @param onSelect `(tag, shownLabel)`: the rows are buttons
*/
export function TagBars({tags, label = (name) => name, onSelect}) {
const {t} = useTranslation();
const max = Math.max(1, ...tags.map((tag) => tag.minutes));
return (
<ul className="space-y-2">
{tags.map((tag) => (
<li key={tag.name} className="text-sm">
<div className="flex justify-between gap-2">
<span className="truncate">{label(tag.name)}</span>
<span className="text-muted-foreground tabular-nums">
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
</div>
<DrillRow onSelect={onSelect && (() => onSelect(tag, label(tag.name)))}>
<div className="flex justify-between gap-2">
<span className="truncate">{label(tag.name)}</span>
<span className="text-muted-foreground tabular-nums">
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
</div>
</DrillRow>
</li>
))}
</ul>
@@ -30,6 +36,7 @@ export function TagBars({tags, label = (name) => name}) {
export default function TasteStats({taste}) {
const {t} = useTranslation();
const select = useTagDrill();
if (taste.genres.length === 0 && taste.moods.length === 0) {
return (
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
@@ -42,12 +49,12 @@ export default function TasteStats({taste}) {
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span>
<TagBars tags={taste.genres}
<TagBars tags={taste.genres} onSelect={select('GENRE')}
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span>
<TagBars tags={taste.moods} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
<TagBars tags={taste.moods} onSelect={select('MOOD')} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
</div>
</div>
</StatsSection>
+9
View File
@@ -1,12 +1,15 @@
import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import StatsSection from './StatsSection.jsx';
import GameRow from './GameRow.jsx';
import {useStatDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js';
export default function TopGamesStats({topGames, linked = true}) {
const {t} = useTranslation();
const drill = useStatDrill();
return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
{topGames.mostPlayed.length === 0 ? (
@@ -18,6 +21,12 @@ export default function TopGamesStats({topGames, linked = true}) {
))}
</ol>
)}
{drill && topGames.mostPlayed.length > 0 && (
<Button variant="ghost" size="sm" className="self-start max-sm:min-h-11"
onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}>
{t('STATS.DRILL.SHOW_ALL')}
</Button>
)}
{topGames.mostLoyal && (
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>