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

+2
View File
@@ -12,6 +12,7 @@ import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner';
@@ -67,6 +68,7 @@ function App() {
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<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 GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js';
import {formatDay, formatPlaytime} from '../utils/games.js';
const CHART_HEIGHT = 80;
const BAR_WIDTH = 10;
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
*/
+2 -1
View File
@@ -1,6 +1,6 @@
import {Link, NavLink, useLocation} from 'react-router-dom';
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 {
@@ -22,6 +22,7 @@ const NAV_ITEMS = [
// The wishlist is a tab of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
];
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 {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare, GAME_STATUSES} from '../utils/backlog.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 backlog = summary.byStatus?.BACKLOG ?? 0;
const suggestion = summary.suggestion;
@@ -44,6 +48,8 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
)}
</div>
{trend && <PileTrend trend={trend}/>}
{summary.total > 0 && (
<div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
+15
View File
@@ -42,6 +42,21 @@ describe('PileOfShameCard', () => {
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', () => {
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 SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js';
import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx';
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 JSON_HEADERS = {'Content-Type': 'application/json'};
const TREND_GROUPS = ['TREND'];
const Dashboard = () => {
const {t} = useTranslation();
@@ -51,6 +53,8 @@ const Dashboard = () => {
const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
const reloadTrend = pileTrend.reload;
const fetchSteamCount = useCallback(async () => {
try {
@@ -168,7 +172,8 @@ const Dashboard = () => {
fetchSummary();
fetchActivity();
fetchAchievements();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
reloadTrend();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
useEffect(() => {
const fetchUserData = async () => {
@@ -344,7 +349,7 @@ const Dashboard = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
{summary && (
<PileOfShameCard summary={summary} syncing={sync.isRunning}
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
)}
{!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 {formatPlaytime} from '../utils/games.js';
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.
@@ -181,6 +187,22 @@ export default function PublicProfilePage() {
</ul>
</Card>
)}
{library.stats && <SharedStats stats={library.stats}/>}
</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 () => {
mockBackends({profileStatus: 404});
renderPage();
+31
View File
@@ -16,12 +16,14 @@ import {Skeleton} from '@/components/ui/skeleton';
import {Switch} from '@/components/ui/switch';
import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js';
import {saveProfileStats} from '../utils/stats.js';
import {cn} from '@/lib/utils';
import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
@@ -59,6 +61,7 @@ export default function SettingsPage() {
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [savingProfileStats, setSavingProfileStats] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again.
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) => {
try {
await navigator.clipboard.writeText(link);
@@ -429,6 +457,9 @@ export default function SettingsPage() {
</Button>
</div>
)}
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
disabled={!publicProfile} saving={savingProfileStats}
onChange={handleProfileStatsChange}/>
</div>
</CardContent>
</Card>
+51
View File
@@ -55,6 +55,57 @@ describe('SettingsPage public profile', () => {
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 () => {
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`;
};
// 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.
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('');
});
});