Public Access
feat(stats): add stats page and pile trend
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:
1 parent
83bbc2730d
commit
9d1c7bb40d
31 files changed
+978
-11
No files matched your search
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user