feat(stats): add platform statistics and landing blocks

Add PlatformStats component to stats page. Extract YearBars from
FinishedStats for reuse on landing. Add three blocks to landing: games
per platform, top genres, finished per year. Update translations.
This commit is contained in:
lobo committed 2026-10-01 11:10:59 +02:00
1 parent b129f6a6e4
commit 22633a9e45
12 files changed
+143 -18

No files matched your search

+3 -1
View File
@@ -30,7 +30,9 @@ To Be Played is split into three repositories:
repeats, and "Start playing" in one click. repeats, and "Start playing" in one click.
- **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. trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
+8 -1
View File
@@ -271,7 +271,10 @@
"BIGGEST_LIBRARY": "Größte Bibliothek", "BIGGEST_LIBRARY": "Größte Bibliothek",
"BIGGEST_BACKLOG": "Größter Backlog", "BIGGEST_BACKLOG": "Größter Backlog",
"TOP_PLAYED": "Meistgespielte Spiele", "TOP_PLAYED": "Meistgespielte Spiele",
"TOP_OWNED": "Meistbesessene Spiele" "TOP_OWNED": "Meistbesessene Spiele",
"PLATFORMS": "Spiele pro Plattform",
"TOP_GENRES": "Top-Genres",
"FINISHED_PER_YEAR": "Abgeschlossen pro Jahr"
}, },
"MAIN": { "MAIN": {
"WELCOME": "Dein Game-Backlog unter Kontrolle", "WELCOME": "Dein Game-Backlog unter Kontrolle",
@@ -476,6 +479,10 @@
"BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.", "BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.",
"BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt." "BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt."
}, },
"PLATFORMS": {
"TITLE": "Spiele pro Plattform",
"EMPTY": "Noch keine Spiele."
},
"GENRE": { "GENRE": {
"ADVENTURE": "Adventure", "ADVENTURE": "Adventure",
"ARCADE": "Arcade", "ARCADE": "Arcade",
+8 -1
View File
@@ -271,7 +271,10 @@
"BIGGEST_LIBRARY": "Biggest library", "BIGGEST_LIBRARY": "Biggest library",
"BIGGEST_BACKLOG": "Biggest backlog", "BIGGEST_BACKLOG": "Biggest backlog",
"TOP_PLAYED": "Most played games", "TOP_PLAYED": "Most played games",
"TOP_OWNED": "Most owned games" "TOP_OWNED": "Most owned games",
"PLATFORMS": "Games per platform",
"TOP_GENRES": "Top genres",
"FINISHED_PER_YEAR": "Finished per year"
}, },
"MAIN": { "MAIN": {
"WELCOME": "Control your game backlog", "WELCOME": "Control your game backlog",
@@ -476,6 +479,10 @@
"BEFORE_one": "{{count}} more game was finished before the history started.", "BEFORE_one": "{{count}} more game was finished before the history started.",
"BEFORE_other": "{{count}} more games were finished before the history started." "BEFORE_other": "{{count}} more games were finished before the history started."
}, },
"PLATFORMS": {
"TITLE": "Games per platform",
"EMPTY": "No games yet."
},
"GENRE": { "GENRE": {
"ADVENTURE": "Adventure", "ADVENTURE": "Adventure",
"ARCADE": "Arcade", "ARCADE": "Arcade",
+19 -13
View File
@@ -3,26 +3,32 @@ import {Flag} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
export function YearBars({perYear}) {
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>
))}
</ul>
);
}
export default function FinishedStats({finished}) { export default function FinishedStats({finished}) {
const {t} = useTranslation(); const {t} = useTranslation();
const max = Math.max(1, ...finished.perYear.map((y) => y.games));
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>
) : ( ) : (
<ul className="space-y-2"> <YearBars perYear={finished.perYear}/>
{finished.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>
))}
</ul>
)} )}
{finished.finishedBeforeTracking > 0 && ( {finished.finishedBeforeTracking > 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
+18
View File
@@ -0,0 +1,18 @@
import {useTranslation} from 'react-i18next';
import {MonitorSmartphone} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import {TagBars} from './TasteStats.jsx';
export default function PlatformStats({platforms}) {
const {t} = useTranslation();
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}/>
)}
</StatsSection>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import StatsSection from './StatsSection.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';
function TagBars({tags, label}) { export function TagBars({tags, label = (name) => name}) {
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 (
+2
View File
@@ -5,6 +5,7 @@ import TopGamesStats from './TopGamesStats.jsx';
import TasteStats from './TasteStats.jsx'; import TasteStats from './TasteStats.jsx';
import HabitsStats from './HabitsStats.jsx'; import HabitsStats from './HabitsStats.jsx';
import FinishedStats from './FinishedStats.jsx'; import FinishedStats from './FinishedStats.jsx';
import PlatformStats from './PlatformStats.jsx';
/** /**
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a * One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
@@ -22,6 +23,7 @@ export default function statGroupTile(id, stats, linked = true) {
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null; case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null;
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null; case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null; case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms}/> : null;
default: return null; default: return null;
} }
} }
+31
View File
@@ -11,7 +11,10 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import Logo from '../components/Logo.jsx'; 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 {TagBars} from '../components/stats/TasteStats.jsx';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {genreKey} from '../utils/stats.js';
import usePublicStats from '../hooks/usePublicStats.js'; import usePublicStats from '../hooks/usePublicStats.js';
@@ -36,6 +39,15 @@ function RankedGames({title, games, formatValue}) {
); );
} }
function BarsCard({title, children}) {
return (
<Card className="gap-0 py-0">
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">{title}</h3>
<div className="px-5 py-4">{children}</div>
</Card>
);
}
export default function LandingPage() { export default function LandingPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {data, isLoading} = usePublicStats(); const {data, isLoading} = usePublicStats();
@@ -102,6 +114,25 @@ export default function LandingPage() {
/> />
)} )}
</div> </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> </section>
</div> </div>
); );
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import LandingPage from './LandingPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useSession.js', () => ({default: () => ({active: false})}));
// Auth and Core answer the same path; one object serves both shapes.
const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], ...core})});
const renderPage = () => render(<MemoryRouter><LandingPage/></MemoryRouter>);
describe('LandingPage', () => {
afterEach(cleanup);
it('shows games per platform, top genres and finished per year', async () => {
answer({
gamesPerPlatform: [{name: 'PS5', games: 3, minutes: 600}],
topGenres: [{name: 'Shooter', games: 2, minutes: 1200}],
finishedPerYear: [{year: 2025, games: 4}],
});
renderPage();
expect(await screen.findByText('LANDING.PLATFORMS')).toBeTruthy();
expect(screen.getByText('PS5')).toBeTruthy();
expect(screen.getByText('LANDING.TOP_GENRES')).toBeTruthy();
expect(screen.getByText('STATS.GENRE.SHOOTER')).toBeTruthy();
expect(screen.getByText('LANDING.FINISHED_PER_YEAR')).toBeTruthy();
expect(screen.getByText('2025')).toBeTruthy();
});
it('hides the three blocks while their lists are empty', async () => {
answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []});
renderPage();
await screen.findByText('LANDING.STATS_TITLE');
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
for (const title of ['PLATFORMS', 'TOP_GENRES', 'FINISHED_PER_YEAR']) {
expect(screen.queryByText(`LANDING.${title}`)).toBeNull();
}
});
});
+2
View File
@@ -11,6 +11,7 @@ import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx'; 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 useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
const ALL_GROUPS = []; const ALL_GROUPS = [];
@@ -47,6 +48,7 @@ export default function StatsPage() {
<TrendStats trend={stats.trend}/> <TrendStats trend={stats.trend}/>
<TopGamesStats topGames={stats.topGames}/> <TopGamesStats topGames={stats.topGames}/>
<TasteStats taste={stats.taste}/> <TasteStats taste={stats.taste}/>
<PlatformStats platforms={stats.platforms}/>
<HabitsStats habits={stats.habits}/> <HabitsStats habits={stats.habits}/>
<FinishedStats finished={stats.finished}/> <FinishedStats finished={stats.finished}/>
</div> </div>
+5 -1
View File
@@ -21,6 +21,7 @@ const stats = (extra = {}) => ({
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: [{name: 'CHILL', games: 1, minutes: 900}]}, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: [{name: 'CHILL', games: 1, minutes: 900}]},
habits: {moonTrips: 0.5, longestDay: {day: '2026-07-01', minutes: 200}, underOneHourGames: 1, underOneHourShare: 0.125}, habits: {moonTrips: 0.5, longestDay: {day: '2026-07-01', minutes: 200}, underOneHourGames: 1, underOneHourShare: 0.125},
finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1}, finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1},
platforms: {perPlatform: [{name: 'Steam', games: 10, minutes: 5000}, {name: 'PS5', games: 2, minutes: 600}]},
trend: {days: [{day: '2026-09-26', backlogGames: 6, neverPlayed: 4, backlogMinutes: 5000}, trend: {days: [{day: '2026-09-26', backlogGames: 6, neverPlayed: 4, backlogMinutes: 5000},
{day: '2026-09-27', backlogGames: 5, neverPlayed: 4, backlogMinutes: 4200}], thisMonth: {added: 1, done: 2}}, {day: '2026-09-27', backlogGames: 5, neverPlayed: 4, backlogMinutes: 4200}], thisMonth: {added: 1, done: 2}},
...extra, ...extra,
@@ -45,11 +46,12 @@ describe('StatsPage', () => {
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy(); expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
expect(fetch).toHaveBeenCalledOnce(); expect(fetch).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/); expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED']) { for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED', 'PLATFORMS']) {
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy(); expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
} }
expect(screen.getAllByText('Portal 2')).toHaveLength(2); expect(screen.getAllByText('Portal 2')).toHaveLength(2);
expect(screen.getByText('Half-Life 2')).toBeTruthy(); expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('PS5')).toBeTruthy();
expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy(); expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy(); expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy();
expect(screen.getByTestId('pile-trend')).toBeTruthy(); expect(screen.getByTestId('pile-trend')).toBeTruthy();
@@ -64,6 +66,7 @@ describe('StatsPage', () => {
taste: {genres: [], moods: []}, taste: {genres: [], moods: []},
habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null}, habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null},
finished: {perYear: [], finishedBeforeTracking: 0}, finished: {perYear: [], finishedBeforeTracking: 0},
platforms: {perPlatform: []},
trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}}, trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}},
})); }));
renderPage(); renderPage();
@@ -72,6 +75,7 @@ describe('StatsPage', () => {
expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.PLATFORMS.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy(); expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy();
expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull(); expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull();
expect(screen.queryByTestId('pile-trend')).toBeNull(); expect(screen.queryByTestId('pile-trend')).toBeNull();
+1
View File
@@ -16,6 +16,7 @@ const STAT_TILES = {
STATS_TASTE: {M: 6, L: 12}, STATS_TASTE: {M: 6, L: 12},
STATS_HABITS: {M: 6, L: 12}, STATS_HABITS: {M: 6, L: 12},
STATS_FINISHED: {M: 6, L: 12}, STATS_FINISHED: {M: 6, L: 12},
STATS_PLATFORMS: {M: 6, L: 12},
}; };
export const DASHBOARD_TILES = { export const DASHBOARD_TILES = {