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

+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>
);
}