feat(stats): add chart form selection and pie, donut, line charts
Build, Test & Push Frontend / quality-check (push) Successful in 30s
Build, Test & Push Frontend / docker (push) Successful in 22s

Tag rows with 0 games are no longer clickable.
This commit is contained in:
lobo committed 2026-10-04 10:43:02 +02:00
1 parent 8b94a25844
commit e054e13a59
21 files changed
+815 -56

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {STAT_CHARTS} from '../../utils/statCharts.js';
/**
* The chart forms of a stat group as buttons.
*
* @param value the shown form
* @param onChange `(form)`
*/
export default function ChartPicker({group, value, onChange}) {
const {t} = useTranslation();
return (
<div role="group" aria-label={t('STATS.CHART.LABEL')} className="flex flex-wrap">
{STAT_CHARTS[group].map((form) => (
<Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm"
className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form}
onClick={() => onChange(form)}>
{t(`STATS.CHART.${form}`)}
</Button>
))}
</div>
);
}
+22 -9
View File
@@ -2,12 +2,15 @@ import {useTranslation} from 'react-i18next';
import {Flag} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import ChartPicker from './ChartPicker.jsx';
import YearLine from './YearLine.jsx';
import {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx';
/**
* @param onSelect `(year)`: the rows are buttons
* @param bars false: a plain list, without the bars
*/
export function YearBars({perYear, onSelect}) {
export function YearBars({perYear, onSelect, bars = true}) {
const max = Math.max(1, ...perYear.map((y) => y.games));
return (
<ul className="space-y-2">
@@ -15,10 +18,12 @@ export function YearBars({perYear, onSelect}) {
<li key={y.year} className="text-sm">
<DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
<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>
{bars ? (
<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="flex-1"/>}
<span className="w-8 text-right tabular-nums">{y.games}</span>
</DrillRow>
</li>
@@ -27,16 +32,24 @@ export function YearBars({perYear, onSelect}) {
);
}
export default function FinishedStats({finished}) {
/**
* @param chart BARS, LINE or LIST; a line needs two years, fewer draw the bars
* @param onChart `(form)`: shows the picker
*/
export default function FinishedStats({finished, chart = 'BARS', onChart}) {
const {t} = useTranslation();
const drill = useStatDrill();
const title = t('STATS.FINISHED.TITLE');
return (
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
<StatsSection title={title} icon={Flag}
action={onChart && <ChartPicker group="FINISHED" value={chart} onChange={onChart}/>}>
{finished.perYear.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
) : chart === 'LINE' && finished.perYear.length >= 2 ? (
<YearLine perYear={finished.perYear} title={title}/>
) : (
<YearBars perYear={finished.perYear}
onSelect={drill && ((year) => drill.open(`${t('STATS.FINISHED.TITLE')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
<YearBars perYear={finished.perYear} bars={chart !== 'LIST'}
onSelect={drill && ((year) => drill.open(`${title}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
)}
{finished.finishedBeforeTracking > 0 && (
<p className="text-xs text-muted-foreground">
+19 -3
View File
@@ -2,6 +2,7 @@ import {useTranslation} from 'react-i18next';
import {StatCount} from './StatDrill.jsx';
import {formatDay} from '../../utils/format.js';
import {trendPoints} from '../../utils/stats.js';
import {trendBars} from '../../utils/statCharts.js';
const WIDTH = 300;
const HEIGHT = 60;
@@ -10,8 +11,9 @@ 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
* @param chart LINE or BARS; both are one click target, the days are not
*/
export default function PileTrend({trend}) {
export default function PileTrend({trend, chart = 'LINE'}) {
const {t, i18n} = useTranslation();
const {days, thisMonth} = trend;
return (
@@ -32,10 +34,24 @@ export default function PileTrend({trend}) {
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
<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"/>
{chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
<rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className="fill-rose-500"/>
)) : (
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
)}
</svg>
</StatCount>
<table className="sr-only">
<tbody>
{days.map((d) => (
<tr key={d.day}>
<td>{formatDay(d.day, i18n.language)}</td>
<td>{d.backlogGames}</td>
</tr>
))}
</tbody>
</table>
<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>
+15 -3
View File
@@ -2,18 +2,30 @@ import {useTranslation} from 'react-i18next';
import {MonitorSmartphone} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import ChartPicker from './ChartPicker.jsx';
import SliceChart from './SliceChart.jsx';
import {TagBars} from './TasteStats.jsx';
import {useTagDrill} from './StatDrill.jsx';
import {topSlices} from '../../utils/statCharts.js';
export default function PlatformStats({platforms}) {
/**
* @param chart BARS, PIE, DONUT or LIST
* @param onChart `(form)`: shows the picker
*/
export default function PlatformStats({platforms, chart = 'BARS', onChart}) {
const {t} = useTranslation();
const select = useTagDrill();
const slices = chart === 'PIE' || chart === 'DONUT';
return (
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}
action={onChart && <ChartPicker group="PLATFORMS" value={chart} onChange={onChart}/>}>
{platforms.perPlatform.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
) : slices ? (
<SliceChart slices={topSlices(platforms.perPlatform, (p) => p.games)} donut={chart === 'DONUT'}
drillKey="PLATFORM"/>
) : (
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')}/>
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')} bars={chart !== 'LIST'}/>
)}
</StatsSection>
);
+76
View File
@@ -0,0 +1,76 @@
import {useTranslation} from 'react-i18next';
import {DrillRow, useDrillTarget} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {slicePaths} from '../../utils/statCharts.js';
const RADIUS = 50;
const HOLE = 30;
// Opacity steps of the accent, so every skin and dark mode work.
const OPACITY = [1, 0.8, 0.62, 0.46, 0.32];
const opacityOf = (slice, index) => (slice.other ? 1 : OPACITY[index] ?? 0.2);
const useSliceTarget = (slice, drillKey, label) => useDrillTarget({
params: slice.other ? null : {key: drillKey, arg: slice.name},
title: slice.other ? '' : label(slice.name),
count: slice.value,
});
function SlicePath({slice, drillKey, label, d, index}) {
const target = useSliceTarget(slice, drillKey, label);
return (
<path d={d} fillRule="evenodd" onClick={target?.onClick}
className={`${slice.other ? 'fill-muted-foreground' : 'fill-primary'} stroke-card ${target ? 'cursor-pointer' : ''}`}
fillOpacity={opacityOf(slice, index)} strokeWidth={1.5}/>
);
}
function SliceRow({slice, drillKey, label, index}) {
const {t} = useTranslation();
const target = useSliceTarget(slice, drillKey, label);
const games = t('STATS.TASTE.GAMES', {count: slice.value});
return (
<li className="text-sm">
<DrillRow onSelect={target?.onClick} className="flex items-center gap-2">
<span className={`size-3 shrink-0 rounded-sm ${slice.other ? 'bg-muted-foreground' : 'bg-primary'}`}
style={{opacity: opacityOf(slice, index)}}/>
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span>
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
</span>
</DrillRow>
</li>
);
}
/**
* A pie or donut with a legend. The legend rows do what the slices do, so the keyboard reaches every slice.
*
* @param slices from `topSlices`, sized by `value`
* @param drillKey the key of the games request; the slice name is its argument
* @param label shown name of a slice name
* @param note a line under the legend
*/
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, note}) {
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
return (
<div className="@container space-y-2">
<div className="flex flex-col items-center gap-4 @sm:flex-row">
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-40 max-w-40 shrink-0">
{slices.map((slice, index) => (
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
label={label} d={paths[index]} index={index}/>
))}
</svg>
<ul className="w-full min-w-0 flex-1 space-y-1">
{slices.map((slice, index) => (
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
label={label} index={index}/>
))}
</ul>
</div>
{note && <p className="text-xs text-muted-foreground">{note}</p>}
</div>
);
}
+11 -5
View File
@@ -1,12 +1,18 @@
import {Card} from '@/components/ui/card';
export default function StatsSection({title, icon: Icon, children}) {
/**
* @param action shown at the right of the title, e.g. a ChartPicker
*/
export default function StatsSection({title, icon: Icon, action, 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>
<div className="flex flex-wrap items-center gap-2">
<h2 className="flex items-center gap-2 font-semibold">
<Icon className="size-4 text-primary"/>
{title}
</h2>
{action && <div className="ml-auto font-normal">{action}</div>}
</div>
{children}
</Card>
);
+33 -13
View File
@@ -2,31 +2,37 @@ import {useTranslation} from 'react-i18next';
import {Palette} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import ChartPicker from './ChartPicker.jsx';
import SliceChart from './SliceChart.jsx';
import {DrillRow, useTagDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {genreKey} from '../../utils/stats.js';
import {topSlices} from '../../utils/statCharts.js';
/**
* @param onSelect `(tag, shownLabel)`: the rows are buttons
* @param onSelect `(tag, shownLabel)`: the rows are buttons, except a row with 0 games
* @param bars false: a plain list, without the bars
*/
export function TagBars({tags, label = (name) => name, onSelect}) {
export function TagBars({tags, label = (name) => name, onSelect, bars = true}) {
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">
<DrillRow onSelect={onSelect && (() => onSelect(tag, label(tag.name)))}>
<DrillRow onSelect={onSelect && tag.games > 0 && (() => onSelect(tag, label(tag.name)))}>
<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>
{bars && (
<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>
)}
</DrillRow>
</li>
))}
@@ -34,27 +40,41 @@ export function TagBars({tags, label = (name) => name, onSelect}) {
);
}
export default function TasteStats({taste}) {
/**
* @param chart BARS, PIE, DONUT or LIST; the pie and donut size a slice by games, and a game can count in several
* @param onChart `(form)`: shows the picker
*/
export default function TasteStats({taste, chart = 'BARS', onChart}) {
const {t} = useTranslation();
const select = useTagDrill();
const action = onChart && <ChartPicker group="TASTE" value={chart} onChange={onChart}/>;
if (taste.genres.length === 0 && taste.moods.length === 0) {
return (
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette} action={action}>
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
</StatsSection>
);
}
const tagChart = (tags, drillKey, label) => {
if (chart !== 'PIE' && chart !== 'DONUT') {
return <TagBars tags={tags} onSelect={select(drillKey)} label={label} bars={chart !== 'LIST'}/>;
}
const slices = topSlices(tags, (tag) => tag.games);
return slices.length > 0 && (
<SliceChart slices={slices} donut={chart === 'DONUT'} drillKey={drillKey} label={label}
note={t('STATS.CHART.OVERLAP')}/>
);
};
return (
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette} action={action}>
<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} onSelect={select('GENRE')}
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
{tagChart(taste.genres, 'GENRE', (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} onSelect={select('MOOD')} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
{tagChart(taste.moods, 'MOOD', (name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name}))}
</div>
</div>
</StatsSection>
+9 -3
View File
@@ -2,13 +2,19 @@ import {useTranslation} from 'react-i18next';
import {TrendingDown} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import ChartPicker from './ChartPicker.jsx';
import PileTrend from './PileTrend.jsx';
export default function TrendStats({trend}) {
/**
* @param chart LINE or BARS
* @param onChart `(form)`: shows the picker
*/
export default function TrendStats({trend, chart = 'LINE', onChart}) {
const {t} = useTranslation();
return (
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}>
<PileTrend trend={trend}/>
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}
action={onChart && <ChartPicker group="TREND" value={chart} onChange={onChart}/>}>
<PileTrend trend={trend} chart={chart}/>
</StatsSection>
);
}
+48
View File
@@ -0,0 +1,48 @@
import {useTranslation} from 'react-i18next';
import {StatCount, useDrillTarget} from './StatDrill.jsx';
import {linePoints, yearSeries} from '../../utils/statCharts.js';
const WIDTH = 300;
const HEIGHT = 80;
function YearPoint({point, year, games, title}) {
const target = useDrillTarget({params: {key: 'FINISHED_YEAR', arg: year}, title: `${title}: ${year}`, count: games});
return (
<circle cx={point.x} cy={point.y} r={4} onClick={target?.onClick}
className={`fill-primary stroke-card ${target ? 'cursor-pointer' : ''}`} strokeWidth={1.5}/>
);
}
/**
* Finished games per year as a line. The year labels under it do what the points do.
*
* @param perYear `[{year, games}]`
* @param title the group title, the start of the dialog title
*/
export default function YearLine({perYear, title}) {
const {t} = useTranslation();
const series = yearSeries(perYear);
const points = linePoints(series.map((y) => y.games), WIDTH, HEIGHT);
return (
<div className="space-y-2">
<svg viewBox={`-8 -8 ${WIDTH + 16} ${HEIGHT + 16}`} aria-hidden="true" className="h-24 w-full">
<polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" strokeWidth={2}
className="stroke-primary" strokeLinejoin="round"/>
{series.map((y, i) => (
<YearPoint key={y.year} point={points[i]} year={y.year} games={y.games} title={title}/>
))}
</svg>
<ul className="flex flex-wrap justify-between gap-x-3 gap-y-1 text-xs tabular-nums">
{series.map((y) => (
<li key={y.year}>
<StatCount params={{key: 'FINISHED_YEAR', arg: y.year}} count={y.games} title={`${title}: ${y.year}`}
label={`${y.year}: ${t('STATS.TASTE.GAMES', {count: y.games})}`}>
{y.year}: {y.games}
</StatCount>
</li>
))}
</ul>
</div>
);
}
+7 -5
View File
@@ -6,6 +6,7 @@ import TasteStats from './TasteStats.jsx';
import HabitsStats from './HabitsStats.jsx';
import FinishedStats from './FinishedStats.jsx';
import PlatformStats from './PlatformStats.jsx';
import {chartOf} from '../../utils/statCharts.js';
/**
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
@@ -13,17 +14,18 @@ import PlatformStats from './PlatformStats.jsx';
*
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
* @param linked true for the user's own games: the dashboard and the owner on the profile
* @param charts the chart form per group of the user whose stats these are
*/
export default function statGroupTile(id, stats, linked = true) {
export default function statGroupTile(id, stats, linked = true, charts = null) {
switch (id) {
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend}/> : null;
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} chart={chartOf(charts, 'TREND')}/> : null;
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null;
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null;
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} chart={chartOf(charts, 'FINISHED')}/> : null;
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} chart={chartOf(charts, 'PLATFORMS')}/> : null;
default: return null;
}
}