feat(stats): add chart form pickers to dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 23s

This commit is contained in:
lobo committed 2026-10-04 21:06:21 +02:00
1 parent 13b0d5e7e9
commit de199a20b7
19 files changed
+507 -98

No files matched your search

+1
View File
@@ -504,6 +504,7 @@
}, },
"TOP_GAMES": { "TOP_GAMES": {
"TITLE": "Top-Spiele", "TITLE": "Top-Spiele",
"SHARE": "Anteil an der Spielzeit dieser Spiele",
"EMPTY": "Noch keine Spielzeit.", "EMPTY": "Noch keine Spielzeit.",
"LOYAL": "Treuestes Spiel", "LOYAL": "Treuestes Spiel",
"MONTHS_one": "in {{count}} Monat gespielt", "MONTHS_one": "in {{count}} Monat gespielt",
+1
View File
@@ -504,6 +504,7 @@
}, },
"TOP_GAMES": { "TOP_GAMES": {
"TITLE": "Top games", "TITLE": "Top games",
"SHARE": "Share of the play time of these games",
"EMPTY": "No playtime yet.", "EMPTY": "No playtime yet.",
"LOYAL": "Most loyal game", "LOYAL": "Most loyal game",
"MONTHS_one": "played in {{count}} month", "MONTHS_one": "played in {{count}} month",
+54 -19
View File
@@ -5,8 +5,11 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import ChartPicker from './stats/ChartPicker.jsx';
import GamePlayChart from './stats/GamePlayChart.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {formatDay} from '../utils/format.js'; import {formatDay} from '../utils/format.js';
import {chartProps, linePoints, playChart} from '../utils/statCharts.js';
const CHART_HEIGHT = 80; const CHART_HEIGHT = 80;
const BAR_WIDTH = 10; const BAR_WIDTH = 10;
@@ -14,42 +17,66 @@ const BAR_GAP = 2;
/** /**
* @param activity the answer of GET /api/library/activity * @param activity the answer of GET /api/library/activity
* @param charts the chart form per group of the user
* @param onChart `(group, form)`: shows the pickers
*/ */
export default function ActivityCard({activity}) { export default function ActivityCard({activity, charts = null, onChart = null}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {days, totalMinutes, recentGames} = activity; const {days, totalMinutes, recentGames} = activity;
const max = Math.max(1, ...days.map((d) => d.minutes)); const max = Math.max(1, ...days.map((d) => d.minutes));
const width = days.length * (BAR_WIDTH + BAR_GAP); const width = days.length * (BAR_WIDTH + BAR_GAP);
const dayChart = chartProps(charts, onChart, 'ACTIVITY');
const gameChart = chartProps(charts, onChart, 'ACTIVITY_GAMES');
const line = dayChart.chart === 'LINE' && days.length > 1;
const points = line ? linePoints(days.map((d) => d.minutes), width, CHART_HEIGHT) : [];
const recent = recentGames.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtime2Weeks}));
const form = playChart(gameChart.chart, recent);
const dayTitle = (d) => `${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`;
return ( return (
<Card className="gap-5 p-5"> <Card className="gap-5 p-5">
<div className="flex items-baseline justify-between gap-4"> <div className="flex flex-wrap items-baseline justify-between gap-x-4">
<h2 className="flex items-center gap-2 font-semibold"> <h2 className="flex items-center gap-2 font-semibold">
<Activity className="size-4 text-primary"/> <Activity className="size-4 text-primary"/>
{t('DASHBOARD.ACTIVITY')} {t('DASHBOARD.ACTIVITY')}
</h2> </h2>
<span className="text-sm text-muted-foreground tabular-nums"> <div className="flex flex-wrap items-center gap-x-3">
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})} <span className="text-sm text-muted-foreground tabular-nums">
</span> {t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
</span>
{dayChart.onChart && <ChartPicker group="ACTIVITY" value={dayChart.chart} onChange={dayChart.onChart}/>}
</div>
</div> </div>
{totalMinutes === 0 ? ( {totalMinutes === 0 ? (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p> <p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p>
) : ( ) : (
<div> <div>
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full" <div className="relative">
role="img" aria-label={t('DASHBOARD.ACTIVITY')}> <svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
{days.map((d, i) => { role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
const height = (d.minutes / max) * CHART_HEIGHT; {line ? (
return ( <polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none"
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)} className="stroke-primary" strokeWidth={2} strokeLinejoin="round"
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2} vectorEffect="non-scaling-stroke"/>
className="fill-primary"> ) : days.map((d, i) => {
<title>{`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`}</title> const height = (d.minutes / max) * CHART_HEIGHT;
</rect> return (
); <rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
})} y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
</svg> className="fill-primary">
<title>{dayTitle(d)}</title>
</rect>
);
})}
</svg>
{/* Dots are HTML over the stretched svg, so they stay round and can show a tooltip. */}
{line && days.map((d, i) => (
<span key={d.day} title={dayTitle(d)} data-testid="activity-dot"
className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
style={{left: `${(points[i].x / width) * 100}%`, top: `${(points[i].y / CHART_HEIGHT) * 100}%`}}/>
))}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground"> <div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{formatDay(days[0].day, i18n.language)}</span> <span>{formatDay(days[0].day, i18n.language)}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}</span> <span>{formatDay(days[days.length - 1].day, i18n.language)}</span>
@@ -59,7 +86,14 @@ export default function ActivityCard({activity}) {
{recentGames.length > 0 && ( {recentGames.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span> <div className="flex flex-wrap items-center justify-between gap-x-3">
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
{gameChart.onChart && (
<ChartPicker group="ACTIVITY_GAMES" value={gameChart.chart} onChange={gameChart.onChart}
label={`${t('STATS.CHART.LABEL')}: ${t('DASHBOARD.RECENTLY_PLAYED')}`}/>
)}
</div>
{form !== 'LIST' ? <GamePlayChart games={recent} chart={form} linked/> : (
<ul className="space-y-2"> <ul className="space-y-2">
{recentGames.map((g) => ( {recentGames.map((g) => (
<li key={g.appid}> <li key={g.appid}>
@@ -73,6 +107,7 @@ export default function ActivityCard({activity}) {
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
)} )}
</Card> </Card>
+81 -2
View File
@@ -1,13 +1,15 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import ActivityCard from './ActivityCard.jsx'; import ActivityCard from './ActivityCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m})); const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m}));
const renderCard = (activity) => render(<MemoryRouter><ActivityCard activity={activity}/></MemoryRouter>); const renderCard = (activity, props) => render(<MemoryRouter><ActivityCard activity={activity} {...props}/></MemoryRouter>);
const recent = (n) => Array.from({length: n}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 900,
playtime2Weeks: 600 - i * 10, status: 'PLAYING'}));
describe('ActivityCard', () => { describe('ActivityCard', () => {
afterEach(cleanup); afterEach(cleanup);
@@ -35,4 +37,81 @@ describe('ActivityCard', () => {
expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy(); expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy();
}); });
it('drawsALineForTheStoredLineForm', () => {
const {container} = renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []},
{charts: {ACTIVITY: 'LINE'}});
expect(screen.queryAllByTestId('activity-bar')).toHaveLength(0);
const line = container.querySelector('polyline');
expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke');
const dots = screen.getAllByTestId('activity-dot');
expect(dots).toHaveLength(3);
expect(dots[1].getAttribute('title')).toMatch(/: 1\.0h$/);
expect(container.querySelectorAll('svg[role="img"] polyline')).toHaveLength(1);
});
it('drawsBarsForALineWithOneDay', () => {
renderCard({days: days([60]), totalMinutes: 60, recentGames: []}, {charts: {ACTIVITY: 'LINE'}});
expect(screen.getAllByTestId('activity-bar')).toHaveLength(1);
});
it('showsNoPickerWithoutOnChart', () => {
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)});
expect(screen.queryByRole('group')).toBeNull();
});
it('showsTwoPickersWithDifferentNamesWithOnChart', () => {
const onChart = vi.fn();
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, {onChart});
const day = screen.getByRole('group', {name: 'STATS.CHART.LABEL'});
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL: DASHBOARD.RECENTLY_PLAYED'})).toBeTruthy();
fireEvent.click(within(day).getByRole('button', {name: 'STATS.CHART.LINE'}));
expect(onChart).toHaveBeenCalledWith('ACTIVITY', 'LINE');
});
it('recentGamesPieLinksTheGamesAndOtherIsPlain', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(7)},
{charts: {ACTIVITY_GAMES: 'PIE'}});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
expect(links[0].getAttribute('href')).toBe('/library/100');
expect(screen.getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
expect(screen.getByText('9.8h')).toBeTruthy();
expect(screen.getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
});
it('recentGamesPieWithOneGameDrawsTheList', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)},
{charts: {ACTIVITY_GAMES: 'DONUT'}});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(0);
expect(screen.getByRole('link', {name: /Game 0/}).getAttribute('href')).toBe('/library/100');
});
it('recentGamesBarsHaveNoCovers', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)},
{charts: {ACTIVITY_GAMES: 'BARS'}});
expect(container.querySelectorAll('img')).toHaveLength(0);
expect(screen.getByRole('link', {name: /Game 1/}).getAttribute('href')).toBe('/library/101');
});
it('twoGamesWithTheSameNameGetTheirOwnSlice', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const games = [{appid: 1, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 300, status: 'PLAYING'},
{appid: 2, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 200, status: 'PLAYING'}];
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: games}, {charts: {ACTIVITY_GAMES: 'PIE'}});
const sameKey = error.mock.calls.some((args) => args.join(' ').includes('same key'));
error.mockRestore();
expect(sameKey).toBe(false);
expect(screen.getAllByRole('link', {name: /Prey/}).map((a) => a.getAttribute('href'))).toEqual(['/library/1', '/library/2']);
});
}); });
+18 -4
View File
@@ -4,13 +4,22 @@ import {ArrowRight, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import ChartPicker from './stats/ChartPicker.jsx';
import GamePlayChart from './stats/GamePlayChart.jsx';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js'; import {coverEffect} from '../utils/rewards.js';
import {playChart} from '../utils/statCharts.js';
export default function MostPlayedCard({games, syncing, equipped}) { /**
* @param chart LIST, BARS, PIE or DONUT
* @param onChart `(form)`: shows the picker
*/
export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) {
const {t} = useTranslation(); const {t} = useTranslation();
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtimeMinutes}));
const form = playChart(chart, mapped);
const maxMinutes = games[0]?.playtimeMinutes || 1; const maxMinutes = games[0]?.playtimeMinutes || 1;
return ( return (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
@@ -19,15 +28,20 @@ export default function MostPlayedCard({games, syncing, equipped}) {
<Trophy className="size-4 text-amber-500"/> <Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')} {t('DASHBOARD.MOST_PLAYED')}
</h2> </h2>
<Button asChild variant="ghost" size="sm"> <div className="flex flex-wrap items-center gap-x-3">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link> {onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}
</Button> <Button asChild variant="ghost" size="sm">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
</div> </div>
{games.length === 0 ? ( {games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground"> <p className="px-5 py-10 text-center text-sm text-muted-foreground">
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p> </p>
) : form !== 'LIST' ? (
<div className="px-5 py-4"><GamePlayChart games={mapped} chart={form} linked/></div>
) : ( ) : (
<ol className="divide-y"> <ol className="divide-y">
{games.map((g, index) => ( {games.map((g, index) => (
+3 -2
View File
@@ -8,11 +8,12 @@ import {STAT_CHARTS} from '../../utils/statCharts.js';
* *
* @param value the shown form * @param value the shown form
* @param onChange `(form)` * @param onChange `(form)`
* @param label the accessible name; the default fits a card with one picker
*/ */
export default function ChartPicker({group, value, onChange}) { export default function ChartPicker({group, value, onChange, label}) {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<div role="group" aria-label={t('STATS.CHART.LABEL')} className="flex flex-wrap"> <div role="group" aria-label={label ?? t('STATS.CHART.LABEL')} className="flex flex-wrap">
{STAT_CHARTS[group].map((form) => ( {STAT_CHARTS[group].map((form) => (
<Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm" <Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm"
className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form} className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form}
+45
View File
@@ -0,0 +1,45 @@
import {useTranslation} from 'react-i18next';
import {DrillRow} from './StatDrill.jsx';
import SliceChart from './SliceChart.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {gameHref} from '../../utils/statGames.js';
import {topSlices} from '../../utils/statCharts.js';
/**
* Play time per game as bars without covers, a pie or a donut. The list form stays in each card.
*
* @param games `[{appid, name, minutes}]`
* @param chart BARS, PIE or DONUT
* @param linked the games are the viewer's own: rows and slices open the detail page
*/
export default function GamePlayChart({games, chart, linked}) {
const {t} = useTranslation();
const named = games.map((g) => ({...g, name: g.name ?? `App ${g.appid}`}));
if (chart === 'BARS') {
const max = Math.max(1, ...named.map((g) => g.minutes));
return (
<ul className="space-y-2">
{named.map((g) => (
<li key={g.appid} className="text-sm">
<DrillRow to={linked ? gameHref(g.appid) : undefined}>
<div className="flex justify-between gap-2">
<span className="truncate" title={g.name}>{g.name}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{formatPlaytime(g.minutes)}</span>
</div>
<div aria-hidden="true" className="mt-1 h-2.5 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (g.minutes / max) * 100)}%`}}/>
</div>
</DrillRow>
</li>
))}
</ul>
);
}
return (
<SliceChart slices={topSlices(named, (g) => g.minutes)} donut={chart === 'DONUT'}
hrefOf={(slice) => (linked ? gameHref(slice.item.appid) : null)}
valueText={(slice) => formatPlaytime(slice.value)} note={t('STATS.TOP_GAMES.SHARE')}/>
);
}
+29 -17
View File
@@ -10,15 +10,23 @@ import {sliceColors, slicePaths} from '../../utils/statCharts.js';
const RADIUS = 50; const RADIUS = 50;
const HOLE = 30; const HOLE = 30;
const useSliceTarget = (slice, drillKey, label, linkOf) => useDrillTarget({ // Names are not unique (two games can share one); a game slice has an app id.
to: slice.other ? null : linkOf?.(slice.name), const sliceKey = (slice) => (slice.other ? 'other' : slice.item?.appid ?? slice.name);
params: slice.other ? null : {key: drillKey, arg: slice.name},
title: slice.other ? '' : label(slice.name),
count: slice.value,
});
function SlicePath({slice, drillKey, label, linkOf, d, color}) { const useSliceTarget = (slice, drillKey, label, linkOf, hrefOf) => {
const target = useSliceTarget(slice, drillKey, label, linkOf); const target = useDrillTarget({
to: slice.other ? null : linkOf?.(slice.name),
params: slice.other ? null : {key: drillKey, arg: slice.name},
title: slice.other ? '' : label(slice.name),
count: slice.value,
});
if (!hrefOf) return target;
const to = !slice.other && slice.value > 0 ? hrefOf(slice) : null;
return to ? {to} : null;
};
function SlicePath({slice, drillKey, label, linkOf, hrefOf, d, color}) {
const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
const navigate = useNavigate(); const navigate = useNavigate();
if (!d) return null; if (!d) return null;
return ( return (
@@ -27,18 +35,19 @@ function SlicePath({slice, drillKey, label, linkOf, d, color}) {
); );
} }
function SliceRow({slice, drillKey, label, linkOf, color}) { function SliceRow({slice, drillKey, label, linkOf, hrefOf, valueText, color}) {
const {t} = useTranslation(); const {t} = useTranslation();
const target = useSliceTarget(slice, drillKey, label, linkOf); const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
const games = t('STATS.TASTE.GAMES', {count: slice.value}); const games = t('STATS.TASTE.GAMES', {count: slice.value});
const text = slice.other ? t('STATS.CHART.OTHER') : label(slice.name);
return ( return (
<li className="text-sm"> <li className="text-sm">
<DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2"> <DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2">
<span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')} <span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')}
style={color ? {background: color} : undefined}/> style={color ? {background: color} : undefined}/>
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span> <span className="min-w-0 flex-1 truncate" title={text}>{text}</span>
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums"> <span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games} {valueText ? valueText(slice) : slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
</span> </span>
</DrillRow> </DrillRow>
</li> </li>
@@ -52,12 +61,14 @@ function SliceRow({slice, drillKey, label, linkOf, color}) {
* @param drillKey the key of the games request; the slice name is its argument * @param drillKey the key of the games request; the slice name is its argument
* @param label shown name of a slice name * @param label shown name of a slice name
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog * @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
* @param valueText `(slice) => string`: the text at the right of a legend row
* @param center shown in the hole of a donut * @param center shown in the hole of a donut
* @param note a line under the legend * @param note a line under the legend
* *
* A slice without games has a legend row only. * A slice without games has a legend row only.
*/ */
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, center, note}) { export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, center, note}) {
const colors = sliceColors(slices); const colors = sliceColors(slices);
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0); const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
return ( return (
@@ -66,8 +77,8 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
<div className="relative size-40 max-w-40 shrink-0"> <div className="relative size-40 max-w-40 shrink-0">
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full"> <svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full">
{slices.map((slice, index) => ( {slices.map((slice, index) => (
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey} <SlicePath key={sliceKey(slice)} slice={slice} drillKey={drillKey}
label={label} linkOf={linkOf} d={paths[index]} color={colors[index]}/> label={label} linkOf={linkOf} hrefOf={hrefOf} d={paths[index]} color={colors[index]}/>
))} ))}
</svg> </svg>
{donut && center && ( {donut && center && (
@@ -78,8 +89,9 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
</div> </div>
<ul className="w-full min-w-0 flex-1 space-y-1"> <ul className="w-full min-w-0 flex-1 space-y-1">
{slices.map((slice, index) => ( {slices.map((slice, index) => (
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey} <SliceRow key={sliceKey(slice)} slice={slice} drillKey={drillKey}
label={label} linkOf={linkOf} color={colors[index]}/> label={label} linkOf={linkOf} hrefOf={hrefOf} valueText={valueText}
color={colors[index]}/>
))} ))}
</ul> </ul>
</div> </div>
+13 -4
View File
@@ -3,26 +3,35 @@ import {Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import ChartPicker from './ChartPicker.jsx';
import GamePlayChart from './GamePlayChart.jsx';
import GameRow from './GameRow.jsx'; import GameRow from './GameRow.jsx';
import {useStatDrill} from './StatDrill.jsx'; import {useStatDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {playChart} from '../../utils/statCharts.js';
export default function TopGamesStats({topGames, linked = true}) { /**
* @param chart LIST, BARS, PIE or DONUT
* @param onChart `(form)`: shows the picker
*/
export default function TopGamesStats({topGames, linked = true, chart = 'LIST', onChart}) {
const {t} = useTranslation(); const {t} = useTranslation();
const drill = useStatDrill(); const drill = useStatDrill();
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes)); const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
const form = playChart(chart, topGames.mostPlayed);
return ( return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}> <StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}
action={onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}>
{topGames.mostPlayed.length === 0 ? ( {topGames.mostPlayed.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p> <p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
) : ( ) : form === 'LIST' ? (
<ol className="space-y-2"> <ol className="space-y-2">
{topGames.mostPlayed.map((g) => ( {topGames.mostPlayed.map((g) => (
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked} <li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}
share={g.minutes / max}/></li> share={g.minutes / max}/></li>
))} ))}
</ol> </ol>
)} ) : <GamePlayChart games={topGames.mostPlayed} chart={form} linked={linked}/>}
{drill && topGames.mostPlayed.length > 0 && ( {drill && topGames.mostPlayed.length > 0 && (
<Button variant="ghost" size="sm" className="self-start max-sm:min-h-11" <Button variant="ghost" size="sm" className="self-start max-sm:min-h-11"
onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}> onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}>
+8 -7
View File
@@ -6,7 +6,7 @@ import TasteStats from './TasteStats.jsx';
import HabitsStats from './HabitsStats.jsx'; import HabitsStats from './HabitsStats.jsx';
import FinishedStats from './FinishedStats.jsx'; import FinishedStats from './FinishedStats.jsx';
import PlatformStats from './PlatformStats.jsx'; import PlatformStats from './PlatformStats.jsx';
import {chartOf} from '../../utils/statCharts.js'; import {chartProps} 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 * One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
@@ -15,17 +15,18 @@ import {chartOf} from '../../utils/statCharts.js';
* @param stats the answer of GET /api/library/stats, or the stats of a public profile * @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 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 * @param charts the chart form per group of the user whose stats these are
* @param onChart `(group, form)`: shows the picker; null for visitors
*/ */
export default function statGroupTile(id, stats, linked = true, charts = null) { export default function statGroupTile(id, stats, linked = true, charts = null, onChart = null) {
switch (id) { switch (id) {
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null; 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_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} chart={chartOf(charts, 'TREND')}/> : null; case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} {...chartProps(charts, onChart, 'TREND')}/> : null;
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null; case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked} {...chartProps(charts, onChart, 'TOP_GAMES')}/> : null;
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null; case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} {...chartProps(charts, onChart, 'TASTE')}/> : null;
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null; case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} chart={chartOf(charts, 'FINISHED')}/> : null; case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} {...chartProps(charts, onChart, 'FINISHED')}/> : null;
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} chart={chartOf(charts, 'PLATFORMS')}/> : null; case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} {...chartProps(charts, onChart, 'PLATFORMS')}/> : null;
default: return null; default: return null;
} }
} }
+33
View File
@@ -0,0 +1,33 @@
import {useRef} from 'react';
import useSaveResponse from './useSaveResponse.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
/**
* The chart forms of the signed-in user and `change(group, form)`: shows the form at once, saves null for the
* default and puts the old form back when the save fails.
*/
export default function useStatCharts() {
const {user, updateUser} = useCurrentUserContext();
const runSave = useSaveResponse();
const charts = user?.settings?.statCharts;
const setChart = (group, form) => updateUser((u) => {
const next = {...u.settings?.statCharts};
if (form) next[group] = form;
else delete next[group];
return {...u, settings: {...u.settings, statCharts: next}};
});
const latest = useRef({});
const change = async (group, form) => {
const before = charts?.[group] ?? null;
const sent = form === STAT_CHARTS[group][0] ? null : form;
const id = (latest.current[group] ?? 0) + 1;
latest.current[group] = id;
setChart(group, sent);
// A failed older save must not undo a newer choice.
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
};
return {charts, change};
}
+10 -6
View File
@@ -23,6 +23,8 @@ import PageContainer from '../components/PageContainer.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import useStatCharts from '../hooks/useStatCharts.js';
import {chartProps} from '../utils/statCharts.js';
import TileGrid from '../components/TileGrid.jsx'; import TileGrid from '../components/TileGrid.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx';
@@ -42,6 +44,7 @@ const Dashboard = () => {
const [shuffling, setShuffling] = useState(false); const [shuffling, setShuffling] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const runSave = useSaveResponse(); const runSave = useSaveResponse();
const {charts, change} = useStatCharts();
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD); const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the // The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
// saved layout in /me. // saved layout in /me.
@@ -71,10 +74,11 @@ const Dashboard = () => {
stats.reload(); stats.reload();
}, },
}); });
const forceSync = useForceSync(sync.watch); const {watch} = sync;
const forceSync = useForceSync(watch);
useEffect(() => { useEffect(() => {
if (steamData.syncStarted) sync.watch(); if (steamData.syncStarted) watch();
}, [steamData, sync.watch]); }, [steamData, watch]);
if (isLoading) { if (isLoading) {
return <DashboardSkeleton/>; return <DashboardSkeleton/>;
@@ -101,7 +105,7 @@ const Dashboard = () => {
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>; case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
case 'MOST_PLAYED': case 'MOST_PLAYED':
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning} return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
equipped={equipped}/>; equipped={equipped} {...chartProps(charts, change, 'TOP_GAMES')}/>;
case 'PLAYING': case 'PLAYING':
return showLibrary && ( return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/> <CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
@@ -120,9 +124,9 @@ const Dashboard = () => {
</Button> </Button>
</Card> </Card>
); );
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>; case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>; case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts); default: return statGroupTile(id, stats.stats, true, charts, change);
} }
}; };
+42 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import Dashboard from './Dashboard.jsx'; import Dashboard from './Dashboard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -113,7 +113,47 @@ describe('Dashboard', () => {
expect(screen.getByRole('button', {name: /2024/})).toBeTruthy(); expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
expect(screen.getByText('2023: 0')).toBeTruthy(); expect(screen.getByText('2023: 0')).toBeTruthy();
expect(screen.queryByRole('button', {name: /2023/})).toBeNull(); expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull(); expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).toBeTruthy();
});
const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
it('aTasteTilePickerSavesAndRedraws', async () => {
fetchRoutes({
...dashboardRoutes({dashboardTiles: [{id: 'STATS_TASTE', size: 'M'}]}),
'GET /api/library/stats': json(200, {trend: null, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200},
{name: 'Puzzle', games: 1, minutes: 300}], moods: []}}),
'PUT /api/users/settings/stat-chart': json(204, {}),
});
const {container} = renderDashboard();
await screen.findByText('STATS.TASTE.TITLE');
fireEvent.click(within(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).getByRole('button', {name: 'STATS.CHART.PIE'}));
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
expect(putBodies()).toEqual([{group: 'TASTE', chart: 'PIE'}]);
});
it('theMostPlayedCardShowsTheTopGamesForm', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'L'}], statCharts: {TOP_GAMES: 'DONUT'}});
await screen.findByRole('group', {name: 'STATS.CHART.LABEL'});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
const card = container.querySelector('[data-tile="MOST_PLAYED"]');
expect(within(card).getAllByRole('link', {name: /Game/})).toHaveLength(5);
expect(within(card).getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
});
it('theActivityPickerSavesTheForm', async () => {
fetchRoutes({...dashboardRoutes(), 'PUT /api/users/settings/stat-chart': json(204, {})});
const {container} = renderDashboard();
await screen.findByText('DASHBOARD.ACTIVITY');
const card = container.querySelector('[data-tile="ACTIVITY"]');
fireEvent.click(within(card).getByRole('button', {name: 'STATS.CHART.LINE'}));
await waitFor(() => expect(container.querySelector('polyline')).not.toBeNull());
expect(putBodies()).toEqual([{group: 'ACTIVITY', chart: 'LINE'}]);
}); });
it('leaves out a stat tile whose data did not come', async () => { it('leaves out a stat tile whose data did not come', async () => {
+4 -1
View File
@@ -19,6 +19,7 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js'; import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import useStatCharts from '../hooks/useStatCharts.js';
import HeroBanner from '../components/HeroBanner.jsx'; import HeroBanner from '../components/HeroBanner.jsx';
import PageBackground from '../components/PageBackground.jsx'; import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
@@ -46,6 +47,7 @@ export default function PublicProfilePage() {
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
const viewer = useCurrentUser(useSession().active); const viewer = useCurrentUser(useSession().active);
const runSave = useSaveResponse(); const runSave = useSaveResponse();
const own = useStatCharts();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -144,7 +146,8 @@ export default function PublicProfilePage() {
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>; && <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED': case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>; return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
default: return statGroupTile(id, library.stats, owner, profile.style?.statCharts); default: return statGroupTile(id, library.stats, owner, (owner ? own.charts : null) ?? profile.style?.statCharts,
owner ? own.change : null);
} }
}; };
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load. // Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
+48
View File
@@ -408,6 +408,54 @@ describe('PublicProfilePage', () => {
} }
}); });
const tasteStats = {pile: null, trend: null, finished: null, habits: null, topGames: null, platforms: null,
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}, {name: 'Puzzle', games: 1, minutes: 300}], moods: []}};
it('theOwnerGetsThePickerOnATasteTile', async () => {
localStorage.setItem('tbp_session', '1');
library.stats = tasteStats;
try {
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {}}, style: {tiles: [{id: 'STATS_TASTE', size: 'M'}]},
extra: {'PUT /api/users/settings/stat-chart': json(204, {})}});
const {container} = renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'STATS.CHART.DONUT'}));
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
const bodies = fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
expect(bodies).toEqual([{group: 'TASTE', chart: 'DONUT'}]);
} finally {
delete library.stats;
localStorage.clear();
}
});
const twoGameStats = {...topGamesStats, topGames: {mostLoyal: null, mostPlayed: [
{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}, {appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}]}};
it('topGamesPieLinksForTheOwnerAndIsPlainForAVisitor', async () => {
localStorage.setItem('tbp_session', '1');
library.stats = twoGameStats;
try {
const style = {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}], statCharts: {TOP_GAMES: 'PIE'}};
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {statCharts: {TOP_GAMES: 'PIE'}}}, style});
const {unmount} = renderPage();
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
unmount();
cleanup();
localStorage.clear();
mockBackends({style});
renderPage();
await screen.findByText('Portal 2');
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
expect(screen.queryByRole('button', {name: /Portal 2/})).toBeNull();
} finally {
delete library.stats;
localStorage.clear();
}
});
it('showsBarsForAFormTheProfileDoesNotKnow', async () => { it('showsBarsForAFormTheProfileDoesNotKnow', async () => {
library.stats = platformStats; library.stats = platformStats;
try { try {
+9 -31
View File
@@ -1,4 +1,3 @@
import {useRef} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {RefreshCw} from 'lucide-react'; import {RefreshCw} from 'lucide-react';
@@ -15,37 +14,16 @@ import FinishedStats from '../components/stats/FinishedStats.jsx';
import PlatformStats from '../components/stats/PlatformStats.jsx'; import PlatformStats from '../components/stats/PlatformStats.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useStatCharts from '../hooks/useStatCharts.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {chartProps} from '../utils/statCharts.js';
import {chartOf, saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
const ALL_GROUPS = []; const ALL_GROUPS = [];
export default function StatsPage() { export default function StatsPage() {
const {t} = useTranslation(); const {t} = useTranslation();
const {stats, failed, reload} = useUserStats(ALL_GROUPS); const {stats, failed, reload} = useUserStats(ALL_GROUPS);
const {user, updateUser} = useCurrentUserContext(); const {charts, change} = useStatCharts();
const runSave = useSaveResponse(); const props = (group) => chartProps(charts, change, group);
const charts = user?.settings?.statCharts;
// The new form shows at once; a failed save puts only this group back.
const setChart = (group, form) => updateUser((u) => {
const next = {...u.settings?.statCharts};
if (form) next[group] = form;
else delete next[group];
return {...u, settings: {...u.settings, statCharts: next}};
});
const latest = useRef({});
const change = async (group, form) => {
const before = charts?.[group] ?? null;
const sent = form === STAT_CHARTS[group][0] ? null : form;
const id = (latest.current[group] ?? 0) + 1;
latest.current[group] = id;
setChart(group, sent);
// A failed older save must not undo a newer choice.
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
};
const chartProps = (group) => ({chart: chartOf(charts, group), onChart: (form) => change(group, form)});
return ( return (
<StatDrillProvider source="own"> <StatDrillProvider source="own">
@@ -73,12 +51,12 @@ export default function StatsPage() {
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div> <div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
<PileStats pile={stats.pile}/> <PileStats pile={stats.pile}/>
<TrendStats trend={stats.trend} {...chartProps('TREND')}/> <TrendStats trend={stats.trend} {...props('TREND')}/>
<TopGamesStats topGames={stats.topGames}/> <TopGamesStats topGames={stats.topGames} {...props('TOP_GAMES')}/>
<TasteStats taste={stats.taste} {...chartProps('TASTE')}/> <TasteStats taste={stats.taste} {...props('TASTE')}/>
<PlatformStats platforms={stats.platforms} {...chartProps('PLATFORMS')}/> <PlatformStats platforms={stats.platforms} {...props('PLATFORMS')}/>
<HabitsStats habits={stats.habits}/> <HabitsStats habits={stats.habits}/>
<FinishedStats finished={stats.finished} {...chartProps('FINISHED')}/> <FinishedStats finished={stats.finished} {...props('FINISHED')}/>
</div> </div>
)} )}
</PageContainer> </PageContainer>
+44 -2
View File
@@ -292,10 +292,10 @@ describe('StatsPage', () => {
await screen.findByText('STATS.PLATFORMS.TITLE'); await screen.findByText('STATS.PLATFORMS.TITLE');
expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0); expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0);
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND']) { for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND', 'TOP_GAMES']) {
expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1); expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1);
} }
expect(within(section('STATS.TOP_GAMES.TITLE')).queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull(); expect(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.LIST'}).getAttribute('aria-pressed')).toBe('true');
expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true'); expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
}); });
@@ -315,6 +315,48 @@ describe('StatsPage', () => {
expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null}); expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null});
}); });
const twoGames = () => stats({topGames: {
mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900},
{appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}],
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}}});
it('topGamesPieLinksEachGameAndShowsTheShareNote', async () => {
answerStats(json(200, twoGames()));
renderPage();
await screen.findByText('STATS.TOP_GAMES.TITLE');
fireEvent.click(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
await waitFor(() => expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(2));
expect(putBodies()).toEqual([{group: 'TOP_GAMES', chart: 'PIE'}]);
const card = section('STATS.TOP_GAMES.TITLE');
expect(within(card).getByRole('link', {name: /Half-Life 2/}).getAttribute('href')).toBe('/library/220');
expect(within(card).getByRole('link', {name: /Half-Life 2/}).textContent).toContain('5.0h');
expect(within(card).getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
expect(within(card).getByText('STATS.TOP_GAMES.LOYAL')).toBeTruthy();
});
it('topGamesPieWithOneGameDrawsTheList', async () => {
answerStats(json(200, stats()), {TOP_GAMES: 'PIE'});
renderPage();
await screen.findByText('STATS.TOP_GAMES.TITLE');
expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(0);
expect(section('STATS.TOP_GAMES.TITLE').querySelectorAll('img').length).toBeGreaterThan(0);
});
it('topGamesBarsHaveNoCoversAndLinkTheGame', async () => {
answerStats(json(200, twoGames()), {TOP_GAMES: 'BARS'});
renderPage();
await screen.findByText('STATS.TOP_GAMES.TITLE');
const card = section('STATS.TOP_GAMES.TITLE');
const bars = within(card).getAllByRole('link', {name: /Half-Life 2/});
expect(bars[0].getAttribute('href')).toBe('/library/220');
expect(bars[0].querySelector('img')).toBeNull();
expect(bars[0].querySelectorAll('.bg-primary')).toHaveLength(1);
});
it('showsTheStoredFormOfTheUser', async () => { it('showsTheStoredFormOfTheUser', async () => {
answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'}); answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'});
renderPage(); renderPage();
+21
View File
@@ -8,6 +8,9 @@ export const STAT_CHARTS = {
TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'], TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'],
FINISHED: ['BARS', 'LINE', 'LIST'], FINISHED: ['BARS', 'LINE', 'LIST'],
TREND: ['LINE', 'BARS'], TREND: ['LINE', 'BARS'],
TOP_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'],
ACTIVITY: ['BARS', 'LINE'],
ACTIVITY_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'],
}; };
/** /**
@@ -21,6 +24,24 @@ export const chartOf = (charts, group) => {
return forms.includes(stored) ? stored : forms[0]; return forms.includes(stored) ? stored : forms[0];
}; };
/**
* The `chart` and `onChart` props of a stat view.
*
* @param onChart `(group, form)`, or null: the view shows no picker
*/
export const chartProps = (charts, onChart, group) => ({
chart: chartOf(charts, group),
onChart: onChart ? (form) => onChart(group, form) : undefined,
});
/**
* The form to draw for play time per game: a pie or donut of fewer than two games with play time is a list.
*
* @param games `[{minutes}]`
*/
export const playChart = (chart, games) =>
(chart === 'PIE' || chart === 'DONUT') && games.filter((g) => g.minutes > 0).length < 2 ? 'LIST' : chart;
/** /**
* The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and * The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and
* one "other" slice with the rest. * one "other" slice with the rest.
+43 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {chartOf, linePoints, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js'; import {chartOf, chartProps, linePoints, playChart, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js';
import {GAME_STATUSES} from './limits.js'; import {GAME_STATUSES} from './limits.js';
describe('chartOf', () => { describe('chartOf', () => {
@@ -19,6 +19,48 @@ describe('chartOf', () => {
}); });
}); });
describe('chartOf for the play time groups', () => {
it('falls back to the first form', () => {
expect(chartOf(null, 'TOP_GAMES')).toBe('LIST');
expect(chartOf({TOP_GAMES: 'RADAR'}, 'TOP_GAMES')).toBe('LIST');
expect(chartOf({}, 'ACTIVITY')).toBe('BARS');
expect(chartOf({ACTIVITY: 'LINE'}, 'ACTIVITY')).toBe('LINE');
expect(chartOf({ACTIVITY_GAMES: 'PIE'}, 'ACTIVITY_GAMES')).toBe('PIE');
expect(chartOf({ACTIVITY_GAMES: 'LINE'}, 'ACTIVITY_GAMES')).toBe('LIST');
});
});
describe('chartProps', () => {
it('has no onChart without a handler', () => {
expect(chartProps({TASTE: 'PIE'}, null, 'TASTE')).toEqual({chart: 'PIE', onChart: undefined});
});
it('calls the handler with the group and the form', () => {
const calls = [];
const props = chartProps(null, (group, form) => calls.push([group, form]), 'TREND');
props.onChart('BARS');
expect(props.chart).toBe('LINE');
expect(calls).toEqual([['TREND', 'BARS']]);
});
});
describe('playChart', () => {
const game = (minutes) => ({appid: 1, name: 'G', minutes});
it('draws the list when a pie has fewer than two games with play time', () => {
expect(playChart('PIE', [game(10)])).toBe('LIST');
expect(playChart('DONUT', [game(0), game(10)])).toBe('LIST');
});
it('keeps the form otherwise', () => {
expect(playChart('PIE', [game(5), game(10)])).toBe('PIE');
expect(playChart('BARS', [])).toBe('BARS');
expect(playChart('LIST', [game(10)])).toBe('LIST');
});
});
describe('topSlices', () => { describe('topSlices', () => {
const item = (name, games) => ({name, games}); const item = (name, games) => ({name, games});
const games = (i) => i.games; const games = (i) => i.games;