feat(stats): add stats page and pile trend
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s

Show the stat groups on /stats, the pile trend on the dashboard card,
one switch per group in the settings, and the shared groups on the
public profile.
This commit is contained in:
lobo committed 2026-09-27 07:16:10 +02:00
1 parent 83bbc2730d
commit 9d1c7bb40d
31 files changed
+978 -11

No files matched your search

+6 -1
View File
@@ -19,12 +19,16 @@ To Be Played is split into three repositories:
## Features ## Features
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion, - **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
days ("this month: +N added, -N off the pile") and a random suggestion,
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
total, perfect games, average completion and the 5 games closest to 100 %. total, perfect games, average completion and the 5 games closest to 100 %.
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood - **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without
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
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.
- **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,
@@ -43,6 +47,7 @@ To Be Played is split into three repositories:
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
verify it; unlinking is possible. verify it; unlinking is possible.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion. - **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
The user picks in the settings which stat groups of "Your stats" it shows (none by default).
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
+90 -1
View File
@@ -34,6 +34,9 @@
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STATS": "Statistik im öffentlichen Profil",
"PROFILE_STATS_HINT": "Wähle, was Besucher sehen. Deine Summen und Status sind immer da.",
"PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.",
"TITLE": "Einstellungen", "TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …", "LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto", "ACCOUNT": "Konto",
@@ -257,7 +260,8 @@
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"DARK_MODE": "Dunkles Design", "DARK_MODE": "Dunkles Design",
"LIGHT_MODE": "Helles Design", "LIGHT_MODE": "Helles Design",
"PLAY_NEXT": "Nächstes Spiel" "PLAY_NEXT": "Nächstes Spiel",
"STATS": "Statistik"
}, },
"BACKLOG": { "BACKLOG": {
"TITLE": "Mein Backlog", "TITLE": "Mein Backlog",
@@ -369,5 +373,90 @@
"REMOVE_NO": "Abbrechen", "REMOVE_NO": "Abbrechen",
"REMOVED": "Spiel entfernt.", "REMOVED": "Spiel entfernt.",
"NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele." "NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele."
},
"STATS": {
"TITLE": "Deine Statistik",
"SUBTITLE": "Zahlen zu deiner Bibliothek. Ausgeblendete Spiele zählen nicht.",
"ERROR_LOAD": "Deine Statistik konnte nicht geladen werden.",
"OVERVIEW": {
"TITLE": "Überblick",
"GAMES": "Spiele",
"PLAYTIME": "Spielzeit gesamt",
"PLAYED": "Angefangen",
"NEVER_STARTED": "Nie gestartet"
},
"PILE": {
"TITLE": "Pile of Shame",
"GAMES": "Spiele auf dem Stapel",
"NORMAL": "Zum Durchspielen",
"COMPLETIONIST": "Für 100 %",
"KNOWN_one": "IGDB kennt {{count}} davon",
"KNOWN_other": "IGDB kennt {{count}} davon",
"PACE": "In deinem Tempo ({{perWeek}} pro Woche in den letzten 90 Tagen) sind das {{weeks}} Wochen.",
"PACE_NONE": "Keine Spielzeit in den letzten 90 Tagen, daher noch kein Tempo.",
"WORK_YEARS": "Das sind mindestens {{years}} Jahre Vollzeitarbeit (40 Stunden pro Woche).",
"OLDEST": "Ältestes ungespieltes Spiel",
"SINCE": "seit {{date}}"
},
"TREND": {
"TITLE": "Stapel-Verlauf",
"THIS_MONTH": "Diesen Monat: +{{added}} dazu, −{{done}} vom Stapel",
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
},
"TOP_GAMES": {
"TITLE": "Top-Spiele",
"EMPTY": "Noch keine Spielzeit.",
"LOYAL": "Treuestes Spiel",
"MONTHS_one": "in {{count}} Monat gespielt",
"MONTHS_other": "in {{count}} Monaten gespielt"
},
"TASTE": {
"TITLE": "Dein Geschmack",
"GENRES": "Genres",
"MOODS": "Stimmungen",
"GAMES_one": "{{count}} Spiel",
"GAMES_other": "{{count}} Spiele",
"EMPTY": "Noch keine Genres. Sie kommen von IGDB und füllen sich in den nächsten Stunden."
},
"HABITS": {
"TITLE": "Fun Facts",
"MOON": "Flüge zum Mond und zurück",
"MOON_HINT": "Apollo 11 brauchte 195 Stunden.",
"LONGEST_DAY": "Längster Spieltag",
"UNDER_ONE_HOUR": "Angefangen, aber unter 1 Stunde",
"UNDER_ONE_HOUR_HINT_one": "{{count}} Spiel",
"UNDER_ONE_HOUR_HINT_other": "{{count}} Spiele"
},
"FINISHED": {
"TITLE": "Durchgespielt pro Jahr",
"EMPTY": "Seit Beginn des Verlaufs noch kein Spiel durchgespielt.",
"BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.",
"BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt."
},
"GENRE": {
"ADVENTURE": "Adventure",
"ARCADE": "Arcade",
"CARD_BOARD_GAME": "Karten- & Brettspiel",
"FIGHTING": "Beat 'em up",
"HACK_AND_SLASH_BEAT_EM_UP": "Hack and Slash",
"INDIE": "Indie",
"MOBA": "MOBA",
"MUSIC": "Musik",
"PINBALL": "Flipper",
"PLATFORM": "Jump 'n' Run",
"POINT_AND_CLICK": "Point & Click",
"PUZZLE": "Rätsel",
"QUIZ_TRIVIA": "Quiz",
"RACING": "Rennspiel",
"REAL_TIME_STRATEGY_RTS": "Echtzeitstrategie",
"ROLE_PLAYING_RPG": "Rollenspiel",
"SHOOTER": "Shooter",
"SIMULATOR": "Simulation",
"SPORT": "Sport",
"STRATEGY": "Strategie",
"TACTICAL": "Taktik",
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
"VISUAL_NOVEL": "Visual Novel"
}
} }
} }
+90 -1
View File
@@ -34,6 +34,9 @@
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STATS": "Stats on the public profile",
"PROFILE_STATS_HINT": "Pick what visitors see. Your totals and statuses are always there.",
"PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.",
"TITLE": "Settings", "TITLE": "Settings",
"LOADING": "Loading settings …", "LOADING": "Loading settings …",
"ACCOUNT": "Account", "ACCOUNT": "Account",
@@ -257,7 +260,8 @@
"LANGUAGE": "Language", "LANGUAGE": "Language",
"DARK_MODE": "Dark mode", "DARK_MODE": "Dark mode",
"LIGHT_MODE": "Light mode", "LIGHT_MODE": "Light mode",
"PLAY_NEXT": "Play next" "PLAY_NEXT": "Play next",
"STATS": "Stats"
}, },
"BACKLOG": { "BACKLOG": {
"TITLE": "My backlog", "TITLE": "My backlog",
@@ -369,5 +373,90 @@
"REMOVE_NO": "Cancel", "REMOVE_NO": "Cancel",
"REMOVED": "Game removed.", "REMOVED": "Game removed.",
"NO_ACHIEVEMENTS": "Achievements exist only for Steam games." "NO_ACHIEVEMENTS": "Achievements exist only for Steam games."
},
"STATS": {
"TITLE": "Your stats",
"SUBTITLE": "Numbers about your library. Hidden games do not count.",
"ERROR_LOAD": "Could not load your stats.",
"OVERVIEW": {
"TITLE": "Overview",
"GAMES": "Games",
"PLAYTIME": "Total playtime",
"PLAYED": "Started",
"NEVER_STARTED": "Never started"
},
"PILE": {
"TITLE": "Pile of Shame",
"GAMES": "Games on the pile",
"NORMAL": "To finish them",
"COMPLETIONIST": "To 100 % them",
"KNOWN_one": "IGDB knows {{count}} of them",
"KNOWN_other": "IGDB knows {{count}} of them",
"PACE": "At your pace ({{perWeek}} a week in the last 90 days) that is {{weeks}} weeks.",
"PACE_NONE": "No playtime in the last 90 days, so no pace yet.",
"WORK_YEARS": "That is at least {{years}} years of full-time work (40 hours a week).",
"OLDEST": "Oldest unplayed game",
"SINCE": "since {{date}}"
},
"TREND": {
"TITLE": "Pile trend",
"THIS_MONTH": "This month: +{{added}} added, −{{done}} off the pile",
"STARTS": "The trend starts today. Come back tomorrow for the first line."
},
"TOP_GAMES": {
"TITLE": "Top games",
"EMPTY": "No playtime yet.",
"LOYAL": "Most loyal game",
"MONTHS_one": "played in {{count}} month",
"MONTHS_other": "played in {{count}} months"
},
"TASTE": {
"TITLE": "Your taste",
"GENRES": "Genres",
"MOODS": "Moods",
"GAMES_one": "{{count}} game",
"GAMES_other": "{{count}} games",
"EMPTY": "No genres yet. They come from IGDB and fill in over the next hours."
},
"HABITS": {
"TITLE": "Fun facts",
"MOON": "Trips to the moon and back",
"MOON_HINT": "Apollo 11 took 195 hours.",
"LONGEST_DAY": "Longest gaming day",
"UNDER_ONE_HOUR": "Started, but under 1 hour",
"UNDER_ONE_HOUR_HINT_one": "{{count}} game",
"UNDER_ONE_HOUR_HINT_other": "{{count}} games"
},
"FINISHED": {
"TITLE": "Finished per year",
"EMPTY": "No finished game since 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."
},
"GENRE": {
"ADVENTURE": "Adventure",
"ARCADE": "Arcade",
"CARD_BOARD_GAME": "Card & board game",
"FIGHTING": "Fighting",
"HACK_AND_SLASH_BEAT_EM_UP": "Hack and slash",
"INDIE": "Indie",
"MOBA": "MOBA",
"MUSIC": "Music",
"PINBALL": "Pinball",
"PLATFORM": "Platformer",
"POINT_AND_CLICK": "Point-and-click",
"PUZZLE": "Puzzle",
"QUIZ_TRIVIA": "Quiz",
"RACING": "Racing",
"REAL_TIME_STRATEGY_RTS": "Real-time strategy",
"ROLE_PLAYING_RPG": "Role-playing",
"SHOOTER": "Shooter",
"SIMULATOR": "Simulation",
"SPORT": "Sports",
"STRATEGY": "Strategy",
"TACTICAL": "Tactics",
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
"VISUAL_NOVEL": "Visual novel"
}
} }
} }
+2
View File
@@ -12,6 +12,7 @@ import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx'; import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner'; import {Toaster} from '@/components/ui/sonner';
@@ -67,6 +68,7 @@ function App() {
<Route path="/library/:appid" element={<GameDetail/>}/> <Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/> <Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/> <Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/> <Route path="/impressum" element={<ImprintPage/>}/>
+1 -4
View File
@@ -4,15 +4,12 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatDay, formatPlaytime} from '../utils/games.js';
const CHART_HEIGHT = 80; const CHART_HEIGHT = 80;
const BAR_WIDTH = 10; const BAR_WIDTH = 10;
const BAR_GAP = 2; const BAR_GAP = 2;
const formatDay = (day, locale) =>
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
/** /**
* @param activity the answer of GET /api/library/activity * @param activity the answer of GET /api/library/activity
*/ */
+2 -1
View File
@@ -1,6 +1,6 @@
import {Link, NavLink, useLocation} from 'react-router-dom'; import {Link, NavLink, useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react'; import {ChartColumn, Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import { import {
@@ -22,6 +22,7 @@ const NAV_ITEMS = [
// The wishlist is a tab of the library. // The wishlist is a tab of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']}, {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
]; ];
const navLinkClass = (isActive) => cn( const navLinkClass = (isActive) => cn(
+7 -1
View File
@@ -5,11 +5,15 @@ import {Dices, Inbox, Shuffle} from 'lucide-react';
import {Button} from '@/components/ui/button'; 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 {STATUS_META} from './GameStatusBadge.jsx'; import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js'; import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) { /**
* @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing
*/
export default function PileOfShameCard({summary, trend, syncing, onShuffle, shuffling}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const backlog = summary.byStatus?.BACKLOG ?? 0; const backlog = summary.byStatus?.BACKLOG ?? 0;
const suggestion = summary.suggestion; const suggestion = summary.suggestion;
@@ -44,6 +48,8 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
)} )}
</div> </div>
{trend && <PileTrend trend={trend}/>}
{summary.total > 0 && ( {summary.total > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted"> <div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
+15
View File
@@ -42,6 +42,21 @@ describe('PileOfShameCard', () => {
expect(onShuffle).toHaveBeenCalledOnce(); expect(onShuffle).toHaveBeenCalledOnce();
}); });
it('shows the pile trend and the moves of this month once they are loaded', () => {
const trend = {days: [{day: '2026-09-26', backlogGames: 3}, {day: '2026-09-27', backlogGames: 2}],
thisMonth: {added: 1, done: 2}};
renderCard({summary: summary(2), trend});
expect(screen.getByText('STATS.TREND.THIS_MONTH')).toBeTruthy();
expect(screen.getByTestId('pile-trend')).toBeTruthy();
});
it('shows no trend while it is not loaded', () => {
renderCard({summary: summary(2)});
expect(screen.queryByText('STATS.TREND.THIS_MONTH')).toBeNull();
});
it('says the backlog is empty when there is no suggestion', () => { it('says the backlog is empty when there is no suggestion', () => {
renderCard({summary: summary(0)}); renderCard({summary: summary(0)});
+34
View File
@@ -0,0 +1,34 @@
import {useTranslation} from 'react-i18next';
import {Flag} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
export default function FinishedStats({finished}) {
const {t} = useTranslation();
const max = Math.max(1, ...finished.perYear.map((y) => y.games));
return (
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
{finished.perYear.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
) : (
<ul className="space-y-2">
{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 && (
<p className="text-xs text-muted-foreground">
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
</p>
)}
</StatsSection>
);
}
+22
View File
@@ -0,0 +1,22 @@
import {Link} from 'react-router-dom';
import GameCover from '../GameCover.jsx';
/**
* A game with its cover and a value on the right; links to the game page only in the user's own library.
*/
export default function GameRow({game, value, linked = true}) {
const content = (
<>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
<span className="text-sm text-muted-foreground tabular-nums">{value}</span>
</>
);
return linked ? (
<Link to={`/library/${game.appid}`} className="flex items-center gap-3 hover:text-primary transition-colors">
{content}
</Link>
) : (
<div className="flex items-center gap-3">{content}</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import {useTranslation} from 'react-i18next';
import {Rocket} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {formatDecimal, formatPercent} from '../../utils/stats.js';
export default function HabitsStats({habits}) {
const {t, i18n} = useTranslation();
const day = habits.longestDay
? new Date(`${habits.longestDay.day}T12:00:00`).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
: null;
return (
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatTile label={t('STATS.HABITS.MOON')} value={formatDecimal(habits.moonTrips, i18n.language)}
hint={t('STATS.HABITS.MOON_HINT')}/>
{habits.longestDay && (
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
hint={day}/>
)}
{habits.underOneHourShare != null && (
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
value={formatPercent(habits.underOneHourShare, i18n.language)}
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}/>
)}
</div>
</StatsSection>
);
}
+30
View File
@@ -0,0 +1,30 @@
import {useTranslation} from 'react-i18next';
import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import {GAME_STATUSES} from '../../utils/backlog.js';
import {formatPlaytime} from '../../utils/games.js';
export default function OverviewStats({overview}) {
const {t, i18n} = useTranslation();
const n = (value) => value.toLocaleString(i18n.language);
return (
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}/>
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}/>
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}/>
</div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{GAME_STATUSES.map((s) => (
<li key={s}>
{t(`BACKLOG.STATUS.${s}`)}{' '}
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
</li>
))}
</ul>
</StatsSection>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {useTranslation} from 'react-i18next';
import {Inbox} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import GameRow from './GameRow.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {formatDecimal} from '../../utils/stats.js';
export default function PileStats({pile, linked = true}) {
const {t, i18n} = useTranslation();
const since = pile.oldestUnplayed
? new Date(pile.oldestUnplayed.inLibrarySince).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
: null;
return (
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}/>
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}/>
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
</div>
{pile.normalMinutes > 0 && (
<ul className="space-y-1 text-sm">
<li>
{pile.paceWeeks == null
? t('STATS.PILE.PACE_NONE')
: t('STATS.PILE.PACE', {
weeks: formatDecimal(pile.paceWeeks, i18n.language),
perWeek: formatPlaytime(pile.minutesPerWeek),
})}
</li>
<li>{t('STATS.PILE.WORK_YEARS', {years: formatDecimal(pile.workYears, i18n.language, 2)})}</li>
</ul>
)}
{pile.oldestUnplayed && (
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.PILE.OLDEST')}</span>
<GameRow game={pile.oldestUnplayed} value={t('STATS.PILE.SINCE', {date: since})} linked={linked}/>
</div>
)}
</StatsSection>
);
}
+38
View File
@@ -0,0 +1,38 @@
import {useTranslation} from 'react-i18next';
import {formatDay} from '../../utils/games.js';
import {trendPoints} from '../../utils/stats.js';
const WIDTH = 300;
const HEIGHT = 60;
/**
* The pile size over the last 90 days and the moves of this month.
*
* @param trend the TREND group of GET /api/library/stats
*/
export default function PileTrend({trend}) {
const {t, i18n} = useTranslation();
const {days, thisMonth} = trend;
return (
<div className="space-y-2">
<p className="text-sm">
{t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, done: thisMonth.done})}
</p>
{days.length > 1 ? (
<div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
role="img" aria-label={t('STATS.TREND.TITLE')}>
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
</svg>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}</span>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">{t('STATS.TREND.STARTS')}</p>
)}
</div>
);
}
@@ -0,0 +1,40 @@
import {useTranslation} from 'react-i18next';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {PROFILE_STAT_GROUPS} from '../../utils/stats.js';
/**
* One switch per stat group the public profile can show.
*
* @param disabled the profile is private
* @param saving a save runs; the switches wait for it
* @param onChange gets the whole new list of groups
*/
export default function ProfileStatsSettings({selected, disabled, saving, onChange}) {
const {t} = useTranslation();
const toggle = (group) => onChange(selected.includes(group)
? selected.filter((g) => g !== group)
: PROFILE_STAT_GROUPS.filter((g) => g === group || selected.includes(g)));
return (
<div className="space-y-3">
<div className="space-y-1">
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STATS')}</span>
<p className="text-xs text-muted-foreground">
{disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')}
</p>
</div>
<ul className="space-y-2">
{PROFILE_STAT_GROUPS.map((group) => (
<li key={group} className="flex items-center justify-between gap-4">
<Label htmlFor={`profileStats-${group}`}>{t(`STATS.${group}.TITLE`)}</Label>
<Switch id={`profileStats-${group}`} checked={selected.includes(group)}
disabled={disabled || saving}
onCheckedChange={() => toggle(group)}/>
</li>
))}
</ul>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
export default function StatTile({label, value, hint}) {
return (
<div className="rounded-lg border p-4">
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
<div className="text-sm text-muted-foreground">{label}</div>
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import {Card} from '@/components/ui/card';
export default function StatsSection({title, icon: Icon, children}) {
return (
<Card className="gap-4 p-5">
<h2 className="flex items-center gap-2 font-semibold">
<Icon className="size-4 text-primary"/>
{title}
</h2>
{children}
</Card>
);
}
+55
View File
@@ -0,0 +1,55 @@
import {useTranslation} from 'react-i18next';
import {Palette} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {genreKey} from '../../utils/stats.js';
function TagBars({tags, label}) {
const {t} = useTranslation();
const max = Math.max(1, ...tags.map((tag) => tag.minutes));
return (
<ul className="space-y-2">
{tags.map((tag) => (
<li key={tag.name} className="text-sm">
<div className="flex justify-between gap-2">
<span className="truncate">{label(tag.name)}</span>
<span className="text-muted-foreground tabular-nums">
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
</div>
</li>
))}
</ul>
);
}
export default function TasteStats({taste}) {
const {t} = useTranslation();
if (taste.genres.length === 0 && taste.moods.length === 0) {
return (
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
</StatsSection>
);
}
return (
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span>
<TagBars tags={taste.genres}
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span>
<TagBars tags={taste.moods} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
</div>
</div>
</StatsSection>
);
}
+30
View File
@@ -0,0 +1,30 @@
import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import GameRow from './GameRow.jsx';
import {formatPlaytime} from '../../utils/games.js';
export default function TopGamesStats({topGames, linked = true}) {
const {t} = useTranslation();
return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
{topGames.mostPlayed.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
) : (
<ol className="space-y-2">
{topGames.mostPlayed.map((g) => (
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}/></li>
))}
</ol>
)}
{topGames.mostLoyal && (
<div className="space-y-2">
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>
<GameRow game={topGames.mostLoyal} linked={linked}
value={t('STATS.TOP_GAMES.MONTHS', {count: topGames.mostLoyal.months})}/>
</div>
)}
</StatsSection>
);
}
+14
View File
@@ -0,0 +1,14 @@
import {useTranslation} from 'react-i18next';
import {TrendingDown} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import PileTrend from './PileTrend.jsx';
export default function TrendStats({trend}) {
const {t} = useTranslation();
return (
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}>
<PileTrend trend={trend}/>
</StatsSection>
);
}
+39
View File
@@ -0,0 +1,39 @@
import {useCallback, useEffect, useState} from 'react';
import {apiFetch} from '../utils/api.js';
import {statsUrl} from '../utils/stats.js';
/**
* GET /api/library/stats for the given groups (none = all). stats is null while loading and after an error.
*
* @param onUnauthorized called on a 401, e.g. useSessionExpired
*/
export default function useUserStats(groups, onUnauthorized, enabled = true) {
const [stats, setStats] = useState(null);
const [failed, setFailed] = useState(false);
const key = groups.join(',');
const load = useCallback(async () => {
setFailed(false);
try {
const response = await apiFetch(statsUrl(key));
if (response.status === 401) {
onUnauthorized();
return;
}
if (!response.ok) {
setFailed(true);
return;
}
setStats(await response.json());
} catch (err) {
console.error('Stats load error:', err);
setFailed(true);
}
}, [key, onUnauthorized]);
useEffect(() => {
if (enabled) load();
}, [enabled, load]);
return {stats, failed, reload: load};
}
+7 -2
View File
@@ -23,6 +23,7 @@ import PageContainer from '../components/PageContainer.jsx';
import HeroArt from '../components/HeroArt.jsx'; import HeroArt from '../components/HeroArt.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx'; import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
@@ -35,6 +36,7 @@ const PLAYING_GAMES = 4;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const JSON_HEADERS = {'Content-Type': 'application/json'}; const JSON_HEADERS = {'Content-Type': 'application/json'};
const TREND_GROUPS = ['TREND'];
const Dashboard = () => { const Dashboard = () => {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -51,6 +53,8 @@ const Dashboard = () => {
const [shuffling, setShuffling] = useState(false); const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
const reloadTrend = pileTrend.reload;
const fetchSteamCount = useCallback(async () => { const fetchSteamCount = useCallback(async () => {
try { try {
@@ -168,7 +172,8 @@ const Dashboard = () => {
fetchSummary(); fetchSummary();
fetchActivity(); fetchActivity();
fetchAchievements(); fetchAchievements();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); reloadTrend();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
@@ -344,7 +349,7 @@ const Dashboard = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/> <CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
{summary && ( {summary && (
<PileOfShameCard summary={summary} syncing={sync.isRunning} <PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
onShuffle={shuffleSuggestion} shuffling={shuffling}/> onShuffle={shuffleSuggestion} shuffling={shuffling}/>
)} )}
{!summary && summaryFailed && ( {!summary && summaryFailed && (
+22
View File
@@ -10,6 +10,12 @@ import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import PileStats from '../components/stats/PileStats.jsx';
import TrendStats from '../components/stats/TrendStats.jsx';
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx';
/** /**
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it. * Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
@@ -181,6 +187,22 @@ export default function PublicProfilePage() {
</ul> </ul>
</Card> </Card>
)} )}
{library.stats && <SharedStats stats={library.stats}/>}
</PageContainer> </PageContainer>
); );
} }
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
function SharedStats({stats}) {
return (
<div className="grid gap-6 md:grid-cols-2">
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
{stats.trend && <TrendStats trend={stats.trend}/>}
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
{stats.taste && <TasteStats taste={stats.taste}/>}
{stats.habits && <HabitsStats habits={stats.habits}/>}
{stats.finished && <FinishedStats finished={stats.finished}/>}
</div>
);
}
+12
View File
@@ -62,6 +62,18 @@ describe('PublicProfilePage', () => {
} }
}); });
it('shows only the stat groups the user shares, without links', async () => {
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
mockBackends();
renderPage();
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
expect(screen.queryByText('STATS.PILE.TITLE')).toBeNull();
expect(screen.queryByText('STATS.TOP_GAMES.TITLE')).toBeNull();
delete library.stats;
});
it('says the profile is unknown or private on a 404', async () => { it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404}); mockBackends({profileStatus: 404});
renderPage(); renderPage();
+31
View File
@@ -16,12 +16,14 @@ import {Skeleton} from '@/components/ui/skeleton';
import {Switch} from '@/components/ui/switch'; import {Switch} from '@/components/ui/switch';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx';
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
import {settingsSchema} from '../utils/validation.js'; import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js'; import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {saveProfileStats} from '../utils/stats.js';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {apiFetch, refreshSession} from '../utils/api.js'; import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
@@ -59,6 +61,7 @@ export default function SettingsPage() {
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const [savingProfileStats, setSavingProfileStats] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again. // Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam')); const [steamOutcome] = useState(() => searchParams.get('steam'));
@@ -197,6 +200,31 @@ export default function SettingsPage() {
} }
}; };
// Shown at once; the switches wait for the answer, so the saves reach Auth one after the other.
const handleProfileStatsChange = async (groups) => {
const previous = profile?.settings?.profileStats ?? [];
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}}));
showProfileStats(groups);
setSavingProfileStats(true);
try {
const response = await saveProfileStats(groups);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
showProfileStats(previous);
toast.error(t('SETTINGS.ERROR_SAVE'));
}
} catch (err) {
console.error('Profile stats save error:', err);
showProfileStats(previous);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setSavingProfileStats(false);
}
};
const copyProfileLink = async (link) => { const copyProfileLink = async (link) => {
try { try {
await navigator.clipboard.writeText(link); await navigator.clipboard.writeText(link);
@@ -429,6 +457,9 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
)} )}
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
disabled={!publicProfile} saving={savingProfileStats}
onChange={handleProfileStatsChange}/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+51
View File
@@ -55,6 +55,57 @@ describe('SettingsPage public profile', () => {
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull()); await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
}); });
it('saves the stat groups the public profile shows, in a fixed order', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
.toBe('true'));
});
it('waits for a save before the next switch click', async () => {
const answers = [];
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
: Promise.resolve({ok: true, status: 200, json: async () => me})));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
answers.forEach((answer) => answer());
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
});
it('switches the group back when the save fails', async () => {
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: false, status: 500, json: async () => ({})}
: {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
.toBe('false'));
});
it('offers the stat groups only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
});
it('needs a display name first', async () => { it('needs a display name first', async () => {
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
+67
View File
@@ -0,0 +1,67 @@
import {useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {RefreshCw} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Skeleton} from '@/components/ui/skeleton';
import PageContainer from '../components/PageContainer.jsx';
import OverviewStats from '../components/stats/OverviewStats.jsx';
import PileStats from '../components/stats/PileStats.jsx';
import TrendStats from '../components/stats/TrendStats.jsx';
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useUserStats from '../hooks/useUserStats.js';
import {hasSession} from '../utils/auth.js';
const ALL_GROUPS = [];
export default function StatsPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const loggedIn = hasSession();
const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn);
useEffect(() => {
if (!loggedIn) navigate('/login');
}, [loggedIn, navigate]);
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
</div>
{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>
)}
{!stats && !failed && (
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="h-48 rounded-xl"/>
<Skeleton className="h-48 rounded-xl"/>
</div>
)}
{stats && (
<div className="grid gap-6 md:grid-cols-2">
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
<PileStats pile={stats.pile}/>
<TrendStats trend={stats.trend}/>
<TopGamesStats topGames={stats.topGames}/>
<TasteStats taste={stats.taste}/>
<HabitsStats habits={stats.habits}/>
<FinishedStats finished={stats.finished}/>
</div>
)}
</PageContainer>
);
}
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import StatsPage from './StatsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body});
const stats = (extra = {}) => ({
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
byStatus: {BACKLOG: 5, PLAYING: 3, FINISHED: 2, COMPLETED: 1, DROPPED: 1}},
pile: {backlogGames: 5, normalMinutes: 4200, completionistMinutes: 9000, gamesWithTimeToBeat: 4,
minutesPerWeek: 420, paceWeeks: 10, workYears: 0.03,
oldestUnplayed: {appid: 220, name: 'Half-Life 2', imageUrl: null, inLibrarySince: '2026-01-01T00:00:00Z'}},
topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}],
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, 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},
finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1},
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}},
...extra,
});
const renderPage = () => render(<MemoryRouter><PreferencesProvider><StatsPage/></PreferencesProvider></MemoryRouter>);
describe('StatsPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('asks for every group once and shows each of them', async () => {
globalThis.fetch = vi.fn(async () => json(stats()));
renderPage();
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
expect(fetch).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED']) {
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
}
expect(screen.getAllByText('Portal 2')).toHaveLength(2);
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy();
expect(screen.getByTestId('pile-trend')).toBeTruthy();
});
it('shows the empty texts of a new library', async () => {
const empty = stats();
globalThis.fetch = vi.fn(async () => json({
...empty,
pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null},
topGames: {mostPlayed: [], mostLoyal: null},
taste: {genres: [], moods: []},
habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null},
finished: {perYear: [], finishedBeforeTracking: 0},
trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}},
}));
renderPage();
expect(await screen.findByText('STATS.PILE.PACE_NONE')).toBeTruthy();
expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy();
expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull();
expect(screen.queryByTestId('pile-trend')).toBeNull();
});
it('offers a retry when the stats could not be loaded', async () => {
globalThis.fetch = vi.fn(async () => json({}, 500));
renderPage();
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
});
});
+4
View File
@@ -10,6 +10,10 @@ export const formatPlaytime = (minutes) => {
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`; return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
}; };
// A LocalDate from Core ("2026-09-27") as a short day; noon, so no time zone moves it to the day before.
export const formatDay = (day, locale) =>
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
// 460x215 store header. Not every app has one; callers need an onError fallback. // 460x215 store header. Not every app has one; callers need an onError fallback.
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
+52
View File
@@ -0,0 +1,52 @@
import {apiFetch} from './api.js';
// The groups a user can show on the public profile; the overview numbers are always there.
export const PROFILE_STAT_GROUPS = ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED'];
/**
* PUT /api/users/settings/profile-stats on Auth. The caller handles the response status.
*/
export const saveProfileStats = (groups) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-stats`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({groups}),
});
/**
* @param groups comma separated stat groups of Core (StatGroup); empty for all
*/
export const statsUrl = (groups) => {
const query = groups ? `?groups=${groups}` : '';
return `${import.meta.env.VITE_CORE_API_URL}/api/library/stats${query}`;
};
/**
* The i18n key of an IGDB genre name, e.g. "Role-playing (RPG)" -> "ROLE_PLAYING_RPG". Callers fall back to the
* name, so a genre IGDB adds later still shows.
*/
export const genreKey = (name) => name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, '');
export const formatDecimal = (value, locale, digits = 1) =>
value.toLocaleString(locale, {minimumFractionDigits: 0, maximumFractionDigits: digits});
export const formatPercent = (share, locale) =>
share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0});
const DAY_MS = 24 * 60 * 60 * 1000;
const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
/**
* Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot
* keep their width. The y axis starts at 0, so a small change looks small. One day gives a flat line over the
* whole width.
*/
export const trendPoints = (days, width, height) => {
if (days.length === 0) return '';
const max = Math.max(1, ...days.map((d) => d.backlogGames));
const y = (d) => height - (d.backlogGames / max) * height;
if (days.length === 1) return `0,${y(days[0])} ${width},${y(days[0])}`;
const first = dayNumber(days[0].day);
const span = dayNumber(days[days.length - 1].day) - first;
return days.map((d) => `${((dayNumber(d.day) - first) / span) * width},${y(d)}`).join(' ');
};
+33
View File
@@ -0,0 +1,33 @@
import {describe, expect, it} from 'vitest';
import {genreKey, trendPoints} from './stats.js';
describe('genreKey', () => {
it('turns an IGDB genre name into an i18n key', () => {
expect(genreKey('Role-playing (RPG)')).toBe('ROLE_PLAYING_RPG');
expect(genreKey("Hack and slash/Beat 'em up")).toBe('HACK_AND_SLASH_BEAT_EM_UP');
expect(genreKey('Card & Board Game')).toBe('CARD_BOARD_GAME');
});
});
describe('trendPoints', () => {
const day = (date, backlogGames) => ({day: date, backlogGames});
it('spreads the days over the width, the biggest pile at the top', () => {
expect(trendPoints([day('2026-09-01', 10), day('2026-09-02', 5), day('2026-09-03', 0)], 100, 40))
.toBe('0,0 50,20 100,40');
});
it('keeps the width of days without a snapshot', () => {
expect(trendPoints([day('2026-09-01', 4), day('2026-09-04', 4), day('2026-09-05', 4)], 100, 40))
.toBe('0,0 75,0 100,0');
});
it('draws one day as a flat line', () => {
expect(trendPoints([day('2026-09-01', 3)], 100, 40)).toBe('0,0 100,0');
});
it('draws an empty pile at the bottom and nothing without days', () => {
expect(trendPoints([day('2026-09-01', 0), day('2026-09-02', 0)], 100, 40)).toBe('0,40 100,40');
expect(trendPoints([], 100, 40)).toBe('');
});
});