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

+17
View File
@@ -31,6 +31,10 @@ To Be Played is split into three repositories:
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in - **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods; years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
- **Click a number** (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year
opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own
views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime),
averages, rates and the moon trips. The list always uses the same rules as the number.
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and - **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings), banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
@@ -208,6 +212,19 @@ flowchart LR
with `src/test/fetchRoutes.js`, which fails a test on any request without a route. with `src/test/fetchRoutes.js`, which fails a test on any request without a route.
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
detail page) to Core. detail page) to Core.
- The games behind a number: pages wrap their content in `StatDrillProvider` (`source` own, profile, community or
points), which renders one `StatGamesDialog`. Numbers call `useStatDrill()` / `StatCount`; outside a provider they
are plain text. `src/utils/statGames.js` builds the four endpoint URLs.
```mermaid
flowchart LR
Number["Number, bar or row"] --> Provider["StatDrillProvider"]
Provider --> Dialog["StatGamesDialog"]
Dialog -->|own| Own["Core /api/library/stats/games"]
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
Dialog -->|community| Community["Core /api/stats/public/games"]
Dialog -->|points| Points["Core /api/rewards/.../games"]
```
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server. - The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
+22 -1
View File
@@ -496,7 +496,9 @@
}, },
"TREND": { "TREND": {
"TITLE": "Stapel-Verlauf", "TITLE": "Stapel-Verlauf",
"THIS_MONTH": "Diesen Monat: +{{added}} dazu, −{{done}} vom Stapel", "THIS_MONTH": "Diesen Monat:",
"THIS_MONTH_ADDED": "+{{added}} neu",
"THIS_MONTH_DONE": "−{{done}} vom Stapel",
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie." "STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
}, },
"TOP_GAMES": { "TOP_GAMES": {
@@ -557,6 +559,25 @@
"TACTICAL": "Taktik", "TACTICAL": "Taktik",
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie", "TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
"VISUAL_NOVEL": "Visual Novel" "VISUAL_NOVEL": "Visual Novel"
},
"DRILL": {
"SHOW_ALL": "Alle anzeigen",
"LOAD_MORE": "Mehr laden",
"EMPTY": "Hinter dieser Zahl stehen keine Spiele.",
"ERROR": "Die Spiele konnten nicht geladen werden.",
"NOT_SHARED": "Der Besitzer teilt diese Zahl nicht.",
"EXPIRED": "Diese Ansicht ist abgelaufen. Lade die Seite neu.",
"RELOAD": "Neu laden",
"IN_LIBRARIES_one": "in {{count}} Bibliothek",
"IN_LIBRARIES_other": "in {{count}} Bibliotheken",
"BEFORE_TRACKING": "Vor Beginn des Verlaufs durchgespielt",
"TREND_MOVES": "Stapel-Änderungen der letzten 90 Tage",
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
"NEW_IN_LIBRARY": "Neu",
"OPEN": "Im Steam-Shop öffnen",
"GAMES_one": "{{count}} Spiel",
"GAMES_other": "{{count}} Spiele"
} }
}, },
"ROLES": { "ROLES": {
+22 -1
View File
@@ -496,7 +496,9 @@
}, },
"TREND": { "TREND": {
"TITLE": "Pile trend", "TITLE": "Pile trend",
"THIS_MONTH": "This month: +{{added}} added, −{{done}} off the pile", "THIS_MONTH": "This month:",
"THIS_MONTH_ADDED": "+{{added}} added",
"THIS_MONTH_DONE": "−{{done}} off the pile",
"STARTS": "The trend starts today. Come back tomorrow for the first line." "STARTS": "The trend starts today. Come back tomorrow for the first line."
}, },
"TOP_GAMES": { "TOP_GAMES": {
@@ -557,6 +559,25 @@
"TACTICAL": "Tactics", "TACTICAL": "Tactics",
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy", "TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
"VISUAL_NOVEL": "Visual novel" "VISUAL_NOVEL": "Visual novel"
},
"DRILL": {
"SHOW_ALL": "Show all",
"LOAD_MORE": "Load more",
"EMPTY": "No games behind this number.",
"ERROR": "Could not load the games.",
"NOT_SHARED": "The owner does not share this number.",
"EXPIRED": "This view has expired. Reload the page.",
"RELOAD": "Reload",
"IN_LIBRARIES_one": "in {{count}} library",
"IN_LIBRARIES_other": "in {{count}} libraries",
"BEFORE_TRACKING": "Finished before the history started",
"TREND_MOVES": "Pile moves in the last 90 days",
"TREND_ADDED": "Added to the pile this month",
"TREND_DONE": "Off the pile this month",
"NEW_IN_LIBRARY": "New",
"OPEN": "Open in the Steam store",
"GAMES_one": "{{count}} game",
"GAMES_other": "{{count}} games"
} }
}, },
"ROLES": { "ROLES": {
+16 -4
View File
@@ -6,10 +6,12 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx'; import PileTrend from './stats/PileTrend.jsx';
import {StatCount} from './stats/StatDrill.jsx';
import {STATUS_META} from './GameStatusBadge.jsx'; import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare} from '../utils/backlog.js'; import {backlogShare} from '../utils/backlog.js';
import {GAME_STATUSES} from '../utils/limits.js'; import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.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 * @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>
<div className="flex items-baseline gap-2"> <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"> <span className="text-sm text-muted-foreground">
{t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})} {t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
</span> </span>
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> <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> </p>
{summary.backlogMinutes > 0 && ( {summary.backlogMinutes > 0 && (
<p className="text-sm text-muted-foreground"> <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> </p>
)} )}
</div> </div>
@@ -67,7 +77,9 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
<li key={s} className="flex items-center gap-1.5"> <li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/> <span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)} {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> </li>
))} ))}
</ul> </ul>
+16 -1
View File
@@ -1,8 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; 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 {MemoryRouter} from 'react-router-dom';
import PileOfShameCard from './PileOfShameCard.jsx'; 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')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -74,4 +76,17 @@ describe('PileOfShameCard', () => {
expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull(); 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 {useTranslation} from 'react-i18next';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {useDrillTarget} from './stats/StatDrill.jsx';
function useCountUp(target, durationMs = 1600) { function useCountUp(target, durationMs = 1600) {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
@@ -25,10 +26,14 @@ function useCountUp(target, durationMs = 1600) {
return value; 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 {i18n} = useTranslation();
const animated = useCountUp(value); const animated = useCountUp(value);
return ( const target = useDrillTarget({title: label, ...drill});
const card = (
<Card className="gap-3 p-5"> <Card className="gap-3 p-5">
{Icon && ( {Icon && (
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"> <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> <div className="text-sm text-muted-foreground">{label}</div>
</Card> </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"/> <Gamepad2 className="size-5"/>
</div> </div>
<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 className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
</div> </div>
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"> <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 {Flag} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; 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)); const max = Math.max(1, ...perYear.map((y) => y.games));
return ( return (
<ul className="space-y-2"> <ul className="space-y-2">
{perYear.map((y) => ( {perYear.map((y) => (
<li key={y.year} className="flex items-center gap-3 text-sm"> <li key={y.year} className="text-sm">
<span className="w-12 tabular-nums">{y.year}</span> <DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-muted"> <span className="w-12 tabular-nums">{y.year}</span>
<div className="h-full rounded-full bg-primary" <div className="h-2 flex-1 rounded-full bg-muted">
style={{width: `${(y.games / max) * 100}%`}}/> <div className="h-full rounded-full bg-primary"
</div> style={{width: `${(y.games / max) * 100}%`}}/>
<span className="w-8 text-right tabular-nums">{y.games}</span> </div>
<span className="w-8 text-right tabular-nums">{y.games}</span>
</DrillRow>
</li> </li>
))} ))}
</ul> </ul>
@@ -23,16 +29,20 @@ export function YearBars({perYear}) {
export default function FinishedStats({finished}) { export default function FinishedStats({finished}) {
const {t} = useTranslation(); const {t} = useTranslation();
const drill = useStatDrill();
return ( return (
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}> <StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
{finished.perYear.length === 0 ? ( {finished.perYear.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p> <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 && ( {finished.finishedBeforeTracking > 0 && (
<p className="text-xs text-muted-foreground"> <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> </p>
)} )}
</StatsSection> </StatsSection>
+17 -9
View File
@@ -1,23 +1,31 @@
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import GameCover from '../GameCover.jsx'; import GameCover from '../GameCover.jsx';
import FitText from '../FitText.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}) { export default function GameRow({game, value, linked = true}) {
const {t} = useTranslation();
const content = ( const content = (
<> <>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/> <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="@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 ? ( const className = 'flex items-center gap-3 hover:text-primary transition-colors';
<Link to={`/library/${game.appid}`} className="flex items-center gap-3 hover:text-primary transition-colors"> if (linked) return <Link to={gameHref(game.appid, true)} className={className}>{content}</Link>;
{content} const href = gameHref(game.appid, false);
</Link> if (href) {
) : ( return (
<div className="flex items-center gap-3">{content}</div> <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')}/> hint={t('STATS.HABITS.MOON_HINT')}/>
{habits.longestDay && ( {habits.longestDay && (
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)} <StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
hint={day}/> hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/>
)} )}
{habits.underOneHourShare != null && ( {habits.underOneHourShare != null && (
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')} <StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
value={formatPercent(habits.underOneHourShare, i18n.language)} 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> </div>
</StatsSection> </StatsSection>
+12 -4
View File
@@ -4,7 +4,9 @@ import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx'; import StatTile from './StatTile.jsx';
import {GAME_STATUSES} from '../../utils/limits.js'; import {GAME_STATUSES} from '../../utils/limits.js';
import {StatCount} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {libraryLink} from '../../utils/statGames.js';
export default function OverviewStats({overview}) { export default function OverviewStats({overview}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
@@ -12,16 +14,22 @@ export default function OverviewStats({overview}) {
return ( return (
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}> <StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}/> <StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}/> drill={{params: {key: 'PLAYED'}}}/>
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
drill={{params: {key: 'NEVER_STARTED'}}}/>
</div> </div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground"> <ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{GAME_STATUSES.map((s) => ( {GAME_STATUSES.map((s) => (
<li key={s}> <li key={s}>
{t(`BACKLOG.STATUS.${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> </li>
))} ))}
</ul> </ul>
+5 -2
View File
@@ -5,6 +5,7 @@ import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx'; import StatTile from './StatTile.jsx';
import GameRow from './GameRow.jsx'; import GameRow from './GameRow.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {libraryLink} from '../../utils/statGames.js';
import {formatDecimal} from '../../utils/stats.js'; import {formatDecimal} from '../../utils/stats.js';
import {formatDate} from '../../utils/format.js'; import {formatDate} from '../../utils/format.js';
@@ -16,9 +17,11 @@ export default function PileStats({pile, linked = true}) {
return ( return (
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}> <StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <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)} <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)}/> <StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
</div> </div>
{pile.normalMinutes > 0 && ( {pile.normalMinutes > 0 && (
+17 -6
View File
@@ -1,4 +1,5 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {StatCount} from './StatDrill.jsx';
import {formatDay} from '../../utils/format.js'; import {formatDay} from '../../utils/format.js';
import {trendPoints} from '../../utils/stats.js'; import {trendPoints} from '../../utils/stats.js';
@@ -16,15 +17,25 @@ export default function PileTrend({trend}) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm"> <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> </p>
{days.length > 1 ? ( {days.length > 1 ? (
<div> <div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full" <StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')}
role="img" aria-label={t('STATS.TREND.TITLE')}> label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none" <svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/> role="img" aria-label={t('STATS.TREND.TITLE')}>
</svg> <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"> <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[0].day, i18n.language)}: {days[0].backlogGames}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].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 StatsSection from './StatsSection.jsx';
import {TagBars} from './TasteStats.jsx'; import {TagBars} from './TasteStats.jsx';
import {useTagDrill} from './StatDrill.jsx';
export default function PlatformStats({platforms}) { export default function PlatformStats({platforms}) {
const {t} = useTranslation(); const {t} = useTranslation();
const select = useTagDrill();
return ( return (
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}> <StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
{platforms.perPlatform.length === 0 ? ( {platforms.perPlatform.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p> <p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
) : ( ) : (
<TagBars tags={platforms.perPlatform}/> <TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')}/>
)} )}
</StatsSection> </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}) { import {Link} from 'react-router-dom';
return (
<div className="rounded-lg border p-4"> 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-2xl font-bold tracking-tight tabular-nums">{value}</div>
<div className="text-sm text-muted-foreground">{label}</div> <div className="text-sm text-muted-foreground">{label}</div>
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</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 {Palette} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import {DrillRow, useTagDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {genreKey} from '../../utils/stats.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 {t} = useTranslation();
const max = Math.max(1, ...tags.map((tag) => tag.minutes)); const max = Math.max(1, ...tags.map((tag) => tag.minutes));
return ( return (
<ul className="space-y-2"> <ul className="space-y-2">
{tags.map((tag) => ( {tags.map((tag) => (
<li key={tag.name} className="text-sm"> <li key={tag.name} className="text-sm">
<div className="flex justify-between gap-2"> <DrillRow onSelect={onSelect && (() => onSelect(tag, label(tag.name)))}>
<span className="truncate">{label(tag.name)}</span> <div className="flex justify-between gap-2">
<span className="text-muted-foreground tabular-nums"> <span className="truncate">{label(tag.name)}</span>
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})} <span className="text-muted-foreground tabular-nums">
</span> {formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
</div> </span>
<div className="mt-1 h-1.5 rounded-full bg-muted"> </div>
<div className="h-full rounded-full bg-primary" <div className="mt-1 h-1.5 rounded-full bg-muted">
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/> <div className="h-full rounded-full bg-primary"
</div> style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
</div>
</DrillRow>
</li> </li>
))} ))}
</ul> </ul>
@@ -30,6 +36,7 @@ export function TagBars({tags, label = (name) => name}) {
export default function TasteStats({taste}) { export default function TasteStats({taste}) {
const {t} = useTranslation(); const {t} = useTranslation();
const select = useTagDrill();
if (taste.genres.length === 0 && taste.moods.length === 0) { if (taste.genres.length === 0 && taste.moods.length === 0) {
return ( return (
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}> <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="grid gap-6 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span> <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})}/> label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span> <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>
</div> </div>
</StatsSection> </StatsSection>
+9
View File
@@ -1,12 +1,15 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react'; import {Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import GameRow from './GameRow.jsx'; import GameRow from './GameRow.jsx';
import {useStatDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
export default function TopGamesStats({topGames, linked = true}) { export default function TopGamesStats({topGames, linked = true}) {
const {t} = useTranslation(); const {t} = useTranslation();
const drill = useStatDrill();
return ( return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}> <StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
{topGames.mostPlayed.length === 0 ? ( {topGames.mostPlayed.length === 0 ? (
@@ -18,6 +21,12 @@ export default function TopGamesStats({topGames, linked = true}) {
))} ))}
</ol> </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 && ( {topGames.mostLoyal && (
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span> <span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>
+14 -11
View File
@@ -23,6 +23,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import TileGrid from '../components/TileGrid.jsx'; import TileGrid from '../components/TileGrid.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx';
import { import {
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
@@ -122,18 +123,20 @@ const Dashboard = () => {
}; };
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <StatDrillProvider source="own">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl} <PageContainer className="p-4 md:p-8 space-y-6">
equipped={equipped} <DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/> equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/> defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/> <WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined} <DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/> trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
</PageContainer> </PageContainer>
</StatDrillProvider>
); );
}; };
+105 -70
View File
@@ -14,12 +14,16 @@ import Logo from '../components/Logo.jsx';
import StatCard from '../components/StatCard.jsx'; import StatCard from '../components/StatCard.jsx';
import {YearBars} from '../components/stats/FinishedStats.jsx'; import {YearBars} from '../components/stats/FinishedStats.jsx';
import {TagBars} from '../components/stats/TasteStats.jsx'; import {TagBars} from '../components/stats/TasteStats.jsx';
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {genreKey} from '../utils/stats.js'; import {genreKey} from '../utils/stats.js';
import {gameHref} from '../utils/statGames.js';
import usePublicStats from '../hooks/usePublicStats.js'; import usePublicStats from '../hooks/usePublicStats.js';
function RankedGames({title, games, formatValue}) { function RankedGames({title, games, formatValue, drillKey}) {
const {t} = useTranslation();
const drill = useStatDrill();
return ( return (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold"> <h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
@@ -31,15 +35,58 @@ function RankedGames({title, games, formatValue}) {
<li key={g.appid} className="flex items-center gap-3 px-5 py-3"> <li key={g.appid} className="flex items-center gap-3 px-5 py-3">
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span> <span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<span className="@container min-w-0 flex-1 font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></span> <GameName game={g}/>
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span> <span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
</li> </li>
))} ))}
</ol> </ol>
{drill && (
<Button variant="ghost" className="m-2 self-start max-sm:min-h-11"
onClick={() => drill.open(title, {key: drillKey})}>
{t('STATS.DRILL.SHOW_ALL')}
</Button>
)}
</Card> </Card>
); );
} }
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}
</span>
);
}
function CommunityBars({core}) {
const {t} = useTranslation();
const drill = useStatDrill();
const select = useTagDrill();
const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name});
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{core?.gamesPerPlatform?.length > 0 && (
<BarsCard title={t('LANDING.PLATFORMS')}>
<TagBars tags={core.gamesPerPlatform} onSelect={select('PLATFORM')}/>
</BarsCard>
)}
{core?.topGenres?.length > 0 && (
<BarsCard title={t('LANDING.TOP_GENRES')}>
<TagBars tags={core.topGenres} label={genreLabel} onSelect={select('GENRE')}/>
</BarsCard>
)}
{core?.finishedPerYear?.length > 0 && (
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
<YearBars perYear={core.finishedPerYear}
onSelect={drill && ((year) => drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
</BarsCard>
)}
</div>
);
}
function BarsCard({title, children}) { function BarsCard({title, children}) {
return ( return (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
@@ -60,81 +107,69 @@ export default function LandingPage() {
const stats = [ const stats = [
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users], [t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2], [t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2], [t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles], [t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks], [t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`], [t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library], [t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive], [t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
]; ];
return ( return (
<div className="grow"> <StatDrillProvider source="community">
<section className="relative overflow-hidden border-b"> <div className="grow">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/> <section className="relative overflow-hidden border-b">
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28"> <div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60"> <div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed <Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
</Badge> <span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1> </Badge>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p> <h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
<Button asChild size="lg" className="mt-10"> <p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
{active ? ( <Button asChild size="lg" className="mt-10">
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link> {active ? (
) : ( <Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link> ) : (
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
)}
</Button>
</div>
</section>
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{isLoading
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
: stats.map(([label, value, icon, suffix, key]) => (
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
drill={key && {params: {key}}}/>
))}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{core?.topPlayedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_PLAYED')}
games={core.topPlayedGames}
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
drillKey="MOST_PLAYED"
/>
)} )}
</Button> {core?.topOwnedGames?.length > 0 && (
</div> <RankedGames
</section> title={t('LANDING.TOP_OWNED')}
games={core.topOwnedGames}
formatValue={(count) => count.toLocaleString(i18n.language)}
drillKey="OWNED"
/>
)}
</div>
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16"> <CommunityBars core={core}/>
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2> </section>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> </StatDrillProvider>
{isLoading
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
: stats.map(([label, value, icon, suffix]) => (
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}/>
))}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{core?.topPlayedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_PLAYED')}
games={core.topPlayedGames}
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
/>
)}
{core?.topOwnedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_OWNED')}
games={core.topOwnedGames}
formatValue={(count) => count.toLocaleString(i18n.language)}
/>
)}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{core?.gamesPerPlatform?.length > 0 && (
<BarsCard title={t('LANDING.PLATFORMS')}>
<TagBars tags={core.gamesPerPlatform}/>
</BarsCard>
)}
{core?.topGenres?.length > 0 && (
<BarsCard title={t('LANDING.TOP_GENRES')}>
<TagBars tags={core.topGenres}
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
</BarsCard>
)}
{core?.finishedPerYear?.length > 0 && (
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
<YearBars perYear={core.finishedPerYear}/>
</BarsCard>
)}
</div>
</section>
</div>
); );
} }
+17 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import LandingPage from './LandingPage.jsx'; import LandingPage from './LandingPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
@@ -42,4 +42,20 @@ describe('LandingPage', () => {
expect(screen.queryByText(`LANDING.${title}`)).toBeNull(); expect(screen.queryByText(`LANDING.${title}`)).toBeNull();
} }
}); });
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
fetchRoutes({
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
'GET /api/stats/public/games': json(200, {content: [{appid: 3, name: 'Common', libraries: 2, minutes: 60}],
page: 0, size: 40, totalElements: 1, totalPages: 1}),
});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: /LANDING\.TOTAL_GAMES/}));
expect(await screen.findByText('Common')).toBeTruthy();
expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/public/games?key=ENTRIES&page=0');
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
});
}); });
+95 -79
View File
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
import EffectBackground from '../components/EffectBackground.jsx'; import EffectBackground from '../components/EffectBackground.jsx';
import FitText from '../components/FitText.jsx'; import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useInView from '../hooks/useInView.js'; import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
@@ -60,95 +61,103 @@ export default function PointsPage() {
); );
} }
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`); if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref; if (['MONTH', 'STREAK'].includes(entry.kind)) {
return (
<StatCount params={{kind: entry.kind, ref: entry.ref}} title={`${t(`POINTS.KINDS.${entry.kind}`)} ${entry.ref}`}>
{entry.ref}
</StatCount>
);
}
return null; return null;
}; };
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <StatDrillProvider source="points">
<div> <PageContainer className="p-4 md:p-8 space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1> <div>
<p className="mt-2 flex items-center gap-2 text-lg"> <h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
<Coins className="size-5 text-primary"/> <p className="mt-2 flex items-center gap-2 text-lg">
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span> <Coins className="size-5 text-primary"/>
</p> {t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
</div> </p>
</div>
<Card className="gap-2 p-5"> <Card className="gap-2 p-5">
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2> <h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground"> <ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)} {HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
</ul> </ul>
</Card> </Card>
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2> <h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
{SLOTS.map((slot) => ( {SLOTS.map((slot) => (
<Card key={slot} className="gap-3 p-5"> <Card key={slot} className="gap-3 p-5">
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3> <h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
<ul className="divide-y"> <ul className="divide-y">
{wallet.catalog.filter((item) => item.slot === slot).map((item) => { {wallet.catalog.filter((item) => item.slot === slot).map((item) => {
const owned = wallet.owned.includes(item.code); const owned = wallet.owned.includes(item.code);
const worn = wallet.equipped[slot] === item.code; const worn = wallet.equipped[slot] === item.code;
const locked = !owned && item.track; const locked = !owned && item.track;
return ( return (
<li key={item.code} className="flex items-center gap-3 py-3"> <li key={item.code} className="flex items-center gap-3 py-3">
<Preview code={item.code}/> <Preview code={item.code}/>
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span> <span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>} {worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
{owned ? ( {owned ? (
<Button size="sm" variant="outline" <Button size="sm" variant="outline"
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}> onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')} {t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
</Button>
) : locked ? (
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
) : (
<>
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
<Button size="sm" disabled={wallet.balance < item.price}
onClick={() => act(() => buyItem(item.code))}>
{t('POINTS.BUY')}
</Button> </Button>
</> ) : locked ? (
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
) : (
<>
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
<Button size="sm" disabled={wallet.balance < item.price}
onClick={() => act(() => buyItem(item.code))}>
{t('POINTS.BUY')}
</Button>
</>
)}
</li>
);
})}
</ul>
</Card>
))}
</section>
<Card className="gap-3 p-5">
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
{wallet.history.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
) : (
<ul className="divide-y">
{wallet.history.map((entry) => {
const label = entryLabel(entry);
return (
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
{formatDate(entry.eventAt, i18n.language)}
</span>
<span className="flex min-w-0 flex-1 gap-1">
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
{label && <span className="min-w-0 truncate">{label}</span>}
</span>
{entry.points !== 0 && (
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
{entry.points > 0 ? `+${entry.points}` : entry.points}
</span>
)} )}
</li> </li>
); );
})} })}
</ul> </ul>
</Card> )}
))} </Card>
</section> </PageContainer>
</StatDrillProvider>
<Card className="gap-3 p-5">
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
{wallet.history.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
) : (
<ul className="divide-y">
{wallet.history.map((entry) => {
const label = entryLabel(entry);
return (
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
{formatDate(entry.eventAt, i18n.language)}
</span>
<span className="flex min-w-0 flex-1 gap-1">
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
{label && <span className="min-w-0 truncate">{label}</span>}
</span>
{entry.points !== 0 && (
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
{entry.points > 0 ? `+${entry.points}` : entry.points}
</span>
)}
</li>
);
})}
</ul>
)}
</Card>
</PageContainer>
); );
} }
@@ -192,8 +201,15 @@ function LockedProgress({item, progress}) {
const {t} = useTranslation(); const {t} = useTranslation();
const count = Math.min(progress, item.target); const count = Math.min(progress, item.target);
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target}); 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}`),
});
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
const Wrapper = target ? 'button' : 'div';
return ( return (
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground"> <Wrapper {...(target && {type: 'button', onClick: target.onClick})}
className={cn(className, target && 'min-h-11 text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm')}>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<LockKeyhole className="size-3 shrink-0"/> <LockKeyhole className="size-3 shrink-0"/>
<span title={text}>{text}</span> <span title={text}>{text}</span>
@@ -202,6 +218,6 @@ function LockedProgress({item, progress}) {
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted"> aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/> <div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
</div> </div>
</div> </Wrapper>
); );
} }
+28
View File
@@ -90,6 +90,34 @@ describe('PointsPage', () => {
const bar = within(item).getByRole('progressbar'); const bar = within(item).getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('7'); expect(bar.getAttribute('aria-valuenow')).toBe('7');
expect(bar.getAttribute('aria-valuemax')).toBe('10'); expect(bar.getAttribute('aria-valuemax')).toBe('10');
expect(within(item).queryByRole('button', {name: 'POINTS.BUY'})).toBeNull();
});
it('opens the games behind a locked bar, and the history of a month', async () => {
const page = (appid) => json(200, {content: [{appid, name: `G${appid}`, status: 'FINISHED', at: '2026-09-01T10:00:00Z'}],
page: 0, size: 40, totalElements: 1, totalPages: 1, untracked: 3});
await renderPage({
'GET /api/rewards/progress/GAMES_FINISHED/games': page(1),
'GET /api/rewards/history/MONTH/2026-09/games': page(2),
});
const bar = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
fireEvent.click(within(bar).getByRole('button'));
expect(await screen.findByText('G1')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.BEFORE:3')).toBeTruthy();
fireEvent.keyDown(document.activeElement, {key: 'Escape'});
await waitFor(() => expect(screen.queryByText('G1')).toBeNull());
fireEvent.click(screen.getByRole('button', {name: '2026-09'}));
expect(await screen.findByText('G2')).toBeTruthy();
});
it('shows no button on a playtime-month bar', async () => {
await renderPage({'GET /api/rewards/wallet': json(200, wallet({
catalog: [{code: 'BADGE_MONTHS', slot: 'BADGE', price: 0, track: 'PLAY_MORE_MONTHS', target: 3}],
}))});
const item = screen.getByText('POINTS.ITEMS.BADGE_MONTHS').closest('li');
expect(within(item).queryByRole('button')).toBeNull(); expect(within(item).queryByRole('button')).toBeNull();
}); });
+18 -8
View File
@@ -24,6 +24,7 @@ import HeroBanner from '../components/HeroBanner.jsx';
import PageBackground from '../components/PageBackground.jsx'; import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import TileGrid from '../components/TileGrid.jsx'; import TileGrid from '../components/TileGrid.jsx';
import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx';
// Games per tile size; Core sends at most 8 playing and 12 finished games. // Games per tile size; Core sends at most 8 playing and 12 finished games.
@@ -177,8 +178,11 @@ export default function PublicProfilePage() {
</Button> </Button>
)} )}
</Card> </Card>
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={owner && editing} <StatDrillProvider source="profile" ticket={profile.ticket}>
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/> <TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
onCancel={() => setEditing(false)}/>
</StatDrillProvider>
</PageContainer> </PageContainer>
</div> </div>
); );
@@ -187,22 +191,25 @@ export default function PublicProfilePage() {
function Summary({library, compact}) { function Summary({library, compact}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const stats = [ const stats = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')], [Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES'), 'TOTAL', library.totalGames],
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')], [Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')], [CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames],
]; ];
if (library.averageCompletion != null) { if (library.averageCompletion != null) {
stats.push( stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')], [Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')], [Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
); );
} }
return ( return (
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}> <Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
{stats.map(([Icon, value, label]) => ( {stats.map(([Icon, value, label, key, count]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center"> <div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/> <Icon className="mx-auto mb-1 size-4 text-primary"/>
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div> <div className="text-xl md:text-2xl font-bold tabular-nums">
<StatCount params={count > 0 ? {key} : null} title={label}
label={`${label}: ${value}`}>{value}</StatCount>
</div>
<div className="text-xs text-muted-foreground">{label}</div> <div className="text-xs text-muted-foreground">{label}</div>
</div> </div>
))} ))}
@@ -225,7 +232,10 @@ function StatusBar({byStatus}) {
<li key={s} className="flex items-center gap-1.5"> <li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/> <span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)} {t(`BACKLOG.STATUS.${s}`)}
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span> <StatCount params={byStatus[s] > 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}>
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
</StatCount>
</li> </li>
))} ))}
</ul> </ul>
+31 -1
View File
@@ -15,12 +15,13 @@ const library = {
}; };
// `me` only for a test with a session: without one the page asks nothing about the viewer. // `me` only for a test with a session: without one the page asks nothing about the viewer.
function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) { function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200, extra = {}} = {}) {
fetchRoutes({ fetchRoutes({
[`GET /api/public/profiles/${encodeURIComponent(name)}`]: [`GET /api/public/profiles/${encodeURIComponent(name)}`]:
json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}), json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
'GET /api/public/profiles/library': json(libraryStatus, answer), 'GET /api/public/profiles/library': json(libraryStatus, answer),
'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}), 'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}),
...extra,
...(me && { ...(me && {
'GET /api/users/me': json(200, me), 'GET /api/users/me': json(200, me),
'PUT /api/users/settings/profile-tiles': json(204, {}), 'PUT /api/users/settings/profile-tiles': json(204, {}),
@@ -260,4 +261,33 @@ describe('PublicProfilePage', () => {
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy()); await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
}); });
it('opensTheGamesBehindAStatusCountWithTheTicket', async () => {
mockBackends({extra: {'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 {container} = renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
expect(await screen.findByText('Backlogged')).toBeTruthy();
const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games'));
expect(call[0]).toContain('key=STATUS&arg=BACKLOG');
expect(call[1].headers['X-Profile-Ticket']).toBe('the-ticket');
expect(container.querySelector('a[href^="/library"]')).toBeNull();
});
it('rendersNoButtonForAGroupTheOwnerDoesNotShare', async () => {
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
try {
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
renderPage();
await screen.findByText('STATS.HABITS.TITLE');
expect(screen.queryByRole('button', {name: /STATS\.PILE/})).toBeNull();
} finally {
delete library.stats;
}
});
}); });
+33 -30
View File
@@ -12,6 +12,7 @@ import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx'; import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx'; import FinishedStats from '../components/stats/FinishedStats.jsx';
import PlatformStats from '../components/stats/PlatformStats.jsx'; import PlatformStats from '../components/stats/PlatformStats.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
const ALL_GROUPS = []; const ALL_GROUPS = [];
@@ -21,38 +22,40 @@ export default function StatsPage() {
const {stats, failed, reload} = useUserStats(ALL_GROUPS); const {stats, failed, reload} = useUserStats(ALL_GROUPS);
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <StatDrillProvider source="own">
<div> <PageContainer className="p-4 md:p-8 space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1> <div>
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p> <h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
</div> <p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
{failed && (
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
{t('STATS.ERROR_LOAD')}
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
</div> </div>
)}
{!stats && !failed && ( {failed && (
<div className="grid gap-6 md:grid-cols-2"> <div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
<Skeleton className="h-48 rounded-xl"/> {t('STATS.ERROR_LOAD')}
<Skeleton className="h-48 rounded-xl"/> <Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
</div> </div>
)} )}
{stats && ( {!stats && !failed && (
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div> <Skeleton className="h-48 rounded-xl"/>
<PileStats pile={stats.pile}/> <Skeleton className="h-48 rounded-xl"/>
<TrendStats trend={stats.trend}/> </div>
<TopGamesStats topGames={stats.topGames}/> )}
<TasteStats taste={stats.taste}/>
<PlatformStats platforms={stats.platforms}/> {stats && (
<HabitsStats habits={stats.habits}/> <div className="grid gap-6 md:grid-cols-2">
<FinishedStats finished={stats.finished}/> <div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
</div> <PileStats pile={stats.pile}/>
)} <TrendStats trend={stats.trend}/>
</PageContainer> <TopGamesStats topGames={stats.topGames}/>
<TasteStats taste={stats.taste}/>
<PlatformStats platforms={stats.platforms}/>
<HabitsStats habits={stats.habits}/>
<FinishedStats finished={stats.finished}/>
</div>
)}
</PageContainer>
</StatDrillProvider>
); );
} }
+32
View File
@@ -89,4 +89,36 @@ describe('StatsPage', () => {
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
}); });
it('opensTheGamesBehindARowWithTheRawName', async () => {
fetchRoutes({
'GET /api/library/stats': json(200, stats()),
'GET /api/library/stats/games': json(200, {content: [{appid: 620, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 60}],
page: 0, size: 40, totalElements: 1, totalPages: 1}),
});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: /STATS\.GENRE/}));
expect(await screen.findByRole('dialog')).toBeTruthy();
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=GENRE&arg=Shooter&page=0$/));
});
it('linksAStatusCountToTheFilteredLibrary', async () => {
answerStats(json(200, stats()));
renderPage();
const link = await screen.findByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 5'});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
});
it('leavesTheMoonTripsAlone', async () => {
answerStats(json(200, stats()));
renderPage();
await screen.findByText('STATS.HABITS.MOON');
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
});
}); });
+54
View File
@@ -0,0 +1,54 @@
import {request} from './api.js';
const CORE = () => import.meta.env.VITE_CORE_API_URL;
const query = (params) => params
.filter(([, value]) => value != null && value !== '')
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
.join('&');
/**
* The URL of one page of the games behind a number.
*
* @param source own, profile, community or points
* @param track points: the unlock track of a locked bar
* @param kind points: the history kind (MONTH or STREAK) with its `ref`
*/
export function statGamesUrl({source, key, arg, page = 0, track, kind, ref}) {
const base = `${CORE()}/api`;
if (source === 'points') {
const path = track
? `progress/${encodeURIComponent(track)}`
: `history/${encodeURIComponent(kind)}/${encodeURIComponent(ref)}`;
return `${base}/rewards/${path}/games?${query([['page', page]])}`;
}
const path = {
own: 'library/stats/games',
profile: 'public/profiles/library/stats/games',
community: 'stats/public/games',
}[source];
return `${base}/${path}?${query([['key', key], ['arg', arg], ['page', page]])}`;
}
/**
* `{ok, status, data}` like `request`. The profile and the community list are public: no login, no refresh.
*/
export async function fetchStatGames(source, params, ticket) {
const url = statGamesUrl({source, ...params});
if (source !== 'profile' && source !== 'community') return request(url);
try {
const response = await fetch(url, source === 'profile' ? {headers: {'X-Profile-Ticket': ticket}} : undefined);
return {ok: response.ok, status: response.status, data: response.ok ? await response.json() : null};
} catch (err) {
console.error(`Request to ${url} failed:`, err);
return {ok: false, status: 0, data: null};
}
}
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`;
// A visitor has no game page of the owner; the Steam store is the only public one. Manual games have no appid there.
export const gameHref = (appid, own) => {
if (own) return `/library/${appid}`;
return appid > 0 ? `https://store.steampowered.com/app/${appid}` : null;
};
+35
View File
@@ -0,0 +1,35 @@
import {describe, expect, it} from 'vitest';
import {gameHref, libraryLink, statGamesUrl} from './statGames.js';
describe('statGamesUrl', () => {
it('own_EncodesTheArgAndPage', () => {
expect(statGamesUrl({source: 'own', key: 'GENRE', arg: 'Role-Playing & Co', page: 2}))
.toMatch(/\/api\/library\/stats\/games\?key=GENRE&arg=Role-Playing%20%26%20Co&page=2$/);
});
it('profile_And_Community_UseTheirPaths', () => {
expect(statGamesUrl({source: 'profile', key: 'PILE'})).toMatch(/\/api\/public\/profiles\/library\/stats\/games\?key=PILE&page=0$/);
expect(statGamesUrl({source: 'community', key: 'OWNED'})).toMatch(/\/api\/stats\/public\/games\?key=OWNED&page=0$/);
});
it('points_TrackAndHistory', () => {
expect(statGamesUrl({source: 'points', track: 'GAMES_FINISHED'})).toMatch(/\/api\/rewards\/progress\/GAMES_FINISHED\/games\?page=0$/);
expect(statGamesUrl({source: 'points', kind: 'MONTH', ref: '2026-09', page: 1}))
.toMatch(/\/api\/rewards\/history\/MONTH\/2026-09\/games\?page=1$/);
});
});
describe('libraryLink', () => {
it('addsTheStatusOnlyWhenGiven', () => {
expect(libraryLink()).toBe('/library?hideDuplicates=true');
expect(libraryLink('BACKLOG')).toBe('/library?hideDuplicates=true&status=BACKLOG');
});
});
describe('gameHref', () => {
it('own_LinksTheLibraryPage', () => expect(gameHref(5, true)).toBe('/library/5'));
it('visitor_LinksTheStoreOnlyForSteamApps', () => {
expect(gameHref(5, false)).toBe('https://store.steampowered.com/app/5');
expect(gameHref(-3, false)).toBeNull();
});
});