diff --git a/public/i18n/de.json b/public/i18n/de.json
index 899be1b..e69a44e 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -560,6 +560,16 @@
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
"VISUAL_NOVEL": "Visual Novel"
},
+ "CHART": {
+ "LABEL": "Diagramm",
+ "BARS": "Balken",
+ "PIE": "Torte",
+ "DONUT": "Ring",
+ "LINE": "Linie",
+ "LIST": "Liste",
+ "OTHER": "Andere",
+ "OVERLAP": "Anteil der Spiele, ein Spiel kann mehrfach zählen"
+ },
"DRILL": {
"SHOW_ALL": "Alle anzeigen",
"LOAD_MORE": "Mehr laden",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 634686a..0b4ce0a 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -560,6 +560,16 @@
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
"VISUAL_NOVEL": "Visual novel"
},
+ "CHART": {
+ "LABEL": "Chart",
+ "BARS": "Bars",
+ "PIE": "Pie",
+ "DONUT": "Donut",
+ "LINE": "Line",
+ "LIST": "List",
+ "OTHER": "Other",
+ "OVERLAP": "Share of games, a game can count more than once"
+ },
"DRILL": {
"SHOW_ALL": "Show all",
"LOAD_MORE": "Load more",
diff --git a/src/components/stats/ChartPicker.jsx b/src/components/stats/ChartPicker.jsx
new file mode 100644
index 0000000..ed55bc4
--- /dev/null
+++ b/src/components/stats/ChartPicker.jsx
@@ -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 (
+
+ {STAT_CHARTS[group].map((form) => (
+ onChange(form)}>
+ {t(`STATS.CHART.${form}`)}
+
+ ))}
+
+ );
+}
diff --git a/src/components/stats/FinishedStats.jsx b/src/components/stats/FinishedStats.jsx
index f75e11b..9969f8f 100644
--- a/src/components/stats/FinishedStats.jsx
+++ b/src/components/stats/FinishedStats.jsx
@@ -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 (
@@ -15,10 +18,12 @@ export function YearBars({perYear, onSelect}) {
onSelect(y.year))} className="flex items-center gap-3">
{y.year}
-
+ {bars ? (
+
+ ) : }
{y.games}
@@ -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 (
-
+ }>
{finished.perYear.length === 0 ? (
{t('STATS.FINISHED.EMPTY')}
+ ) : chart === 'LINE' && finished.perYear.length >= 2 ? (
+
) : (
- drill.open(`${t('STATS.FINISHED.TITLE')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
+ drill.open(`${title}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
)}
{finished.finishedBeforeTracking > 0 && (
diff --git a/src/components/stats/PileTrend.jsx b/src/components/stats/PileTrend.jsx
index 26a2bb4..d0367f5 100644
--- a/src/components/stats/PileTrend.jsx
+++ b/src/components/stats/PileTrend.jsx
@@ -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">
-
+ {chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
+
+ )) : (
+
+ )}
+
+
+ {days.map((d) => (
+
+ {formatDay(d.day, i18n.language)}
+ {d.backlogGames}
+
+ ))}
+
+
{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}
{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}
diff --git a/src/components/stats/PlatformStats.jsx b/src/components/stats/PlatformStats.jsx
index a73f8f2..e4d5d0f 100644
--- a/src/components/stats/PlatformStats.jsx
+++ b/src/components/stats/PlatformStats.jsx
@@ -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 (
-
+ }>
{platforms.perPlatform.length === 0 ? (
{t('STATS.PLATFORMS.EMPTY')}
+ ) : slices ? (
+ p.games)} donut={chart === 'DONUT'}
+ drillKey="PLATFORM"/>
) : (
-
+
)}
);
diff --git a/src/components/stats/SliceChart.jsx b/src/components/stats/SliceChart.jsx
new file mode 100644
index 0000000..1e12176
--- /dev/null
+++ b/src/components/stats/SliceChart.jsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+
+
+ {slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}
+
+ {slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+ {slices.map((slice, index) => (
+
+ ))}
+
+
+ {slices.map((slice, index) => (
+
+ ))}
+
+
+ {note &&
{note}
}
+
+ );
+}
diff --git a/src/components/stats/StatsSection.jsx b/src/components/stats/StatsSection.jsx
index eff92e4..8556e1f 100644
--- a/src/components/stats/StatsSection.jsx
+++ b/src/components/stats/StatsSection.jsx
@@ -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 (
-
-
- {title}
-
+
+
+
+ {title}
+
+ {action &&
{action}
}
+
{children}
);
diff --git a/src/components/stats/TasteStats.jsx b/src/components/stats/TasteStats.jsx
index 2d289ce..6da1626 100644
--- a/src/components/stats/TasteStats.jsx
+++ b/src/components/stats/TasteStats.jsx
@@ -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 (
{tags.map((tag) => (
- onSelect(tag, label(tag.name)))}>
+ 0 && (() => onSelect(tag, label(tag.name)))}>
{label(tag.name)}
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
-
+ {bars && (
+
+ )}
))}
@@ -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 && ;
if (taste.genres.length === 0 && taste.moods.length === 0) {
return (
-
+
{t('STATS.TASTE.EMPTY')}
);
}
+ const tagChart = (tags, drillKey, label) => {
+ if (chart !== 'PIE' && chart !== 'DONUT') {
+ return ;
+ }
+ const slices = topSlices(tags, (tag) => tag.games);
+ return slices.length > 0 && (
+
+ );
+ };
return (
-
+
{t('STATS.TASTE.GENRES')}
- t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
+ {tagChart(taste.genres, 'GENRE', (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name}))}
{t('STATS.TASTE.MOODS')}
- t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
+ {tagChart(taste.moods, 'MOOD', (name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name}))}
diff --git a/src/components/stats/TrendStats.jsx b/src/components/stats/TrendStats.jsx
index 1dfa954..ba16b03 100644
--- a/src/components/stats/TrendStats.jsx
+++ b/src/components/stats/TrendStats.jsx
@@ -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 (
-
-
+ }>
+
);
}
diff --git a/src/components/stats/YearLine.jsx b/src/components/stats/YearLine.jsx
new file mode 100644
index 0000000..402a8fe
--- /dev/null
+++ b/src/components/stats/YearLine.jsx
@@ -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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ `${p.x},${p.y}`).join(' ')} fill="none" strokeWidth={2}
+ className="stroke-primary" strokeLinejoin="round"/>
+ {series.map((y, i) => (
+
+ ))}
+
+
+ {series.map((y) => (
+
+
+ {y.year}: {y.games}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/stats/statGroupTile.jsx b/src/components/stats/statGroupTile.jsx
index fb011b4..d1b99ed 100644
--- a/src/components/stats/statGroupTile.jsx
+++ b/src/components/stats/statGroupTile.jsx
@@ -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 ? : null;
case 'STATS_PILE': return stats?.pile ? : null;
- case 'STATS_TREND': return stats?.trend ? : null;
+ case 'STATS_TREND': return stats?.trend ? : null;
case 'STATS_TOP_GAMES': return stats?.topGames ? : null;
- case 'STATS_TASTE': return stats?.taste ? : null;
+ case 'STATS_TASTE': return stats?.taste ? : null;
case 'STATS_HABITS': return stats?.habits ? : null;
- case 'STATS_FINISHED': return stats?.finished ? : null;
- case 'STATS_PLATFORMS': return stats?.platforms ? : null;
+ case 'STATS_FINISHED': return stats?.finished ? : null;
+ case 'STATS_PLATFORMS': return stats?.platforms ? : null;
default: return null;
}
}
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 382b545..ba4163d 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -118,7 +118,7 @@ const Dashboard = () => {
);
case 'ACTIVITY': return hasSteamId && activity && ;
case 'ACHIEVEMENTS': return hasSteamId && achievements && ;
- default: return statGroupTile(id, stats.stats);
+ default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts);
}
};
diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx
index a05b6de..3310e99 100644
--- a/src/pages/Dashboard.test.jsx
+++ b/src/pages/Dashboard.test.jsx
@@ -96,6 +96,21 @@ describe('Dashboard', () => {
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
});
+ it('showsAStatTileInTheChartFormOfTheUser', async () => {
+ fetchRoutes({
+ ...dashboardRoutes({dashboardTiles: [{id: 'STATS_FINISHED', size: 'M'}], statCharts: {FINISHED: 'LINE'}}),
+ 'GET /api/library/stats': json(200, {trend: null, finished: {finishedBeforeTracking: 0,
+ perYear: [{year: 2022, games: 3}, {year: 2024, games: 1}]}}),
+ });
+ renderDashboard();
+
+ expect(await screen.findByRole('button', {name: /2022/})).toBeTruthy();
+ expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
+ expect(screen.getByText('2023: 0')).toBeTruthy();
+ expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
+ expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
+ });
+
it('leaves out a stat tile whose data did not come', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx
index 293f82b..42a0a50 100644
--- a/src/pages/PublicProfilePage.jsx
+++ b/src/pages/PublicProfilePage.jsx
@@ -138,7 +138,7 @@ export default function PublicProfilePage() {
&& ;
case 'FINISHED':
return library.finished?.length > 0 && ;
- default: return statGroupTile(id, library.stats, owner);
+ default: return statGroupTile(id, library.stats, owner, profile.style?.statCharts);
}
};
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx
index 7eab23e..37d6250 100644
--- a/src/pages/PublicProfilePage.test.jsx
+++ b/src/pages/PublicProfilePage.test.jsx
@@ -339,6 +339,73 @@ describe('PublicProfilePage', () => {
}
});
+ const platformStats = {pile: null, trend: null, taste: null, finished: null, habits: null, topGames: null,
+ platforms: {perPlatform: [{name: 'Steam', games: 10, minutes: 5000}, {name: 'PS5', games: 2, minutes: 600}]}};
+ const platformTile = {tiles: [{id: 'STATS_PLATFORMS', size: 'M'}]};
+ const platformGames = {'GET /api/public/profiles/library/stats/games': json(200, {
+ content: [{appid: 7, name: 'Hades', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})};
+
+ it('showsTheOwnerChartFormWithoutAPicker', async () => {
+ library.stats = platformStats;
+ try {
+ mockBackends({style: {...platformTile, statCharts: {PLATFORMS: 'DONUT'}}});
+ const {container} = renderPage();
+
+ expect(await screen.findByText('Steam')).toBeTruthy();
+ expect(container.querySelectorAll('path.stroke-card')).toHaveLength(2);
+ expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
+ } finally {
+ delete library.stats;
+ }
+ });
+
+ it('showsBarsForAFormTheProfileDoesNotKnow', async () => {
+ library.stats = platformStats;
+ try {
+ mockBackends({style: {...platformTile, statCharts: {PLATFORMS: 'RADAR'}}});
+ const {container} = renderPage();
+
+ expect(await screen.findByText('Steam')).toBeTruthy();
+ expect(container.querySelector('path.stroke-card')).toBeNull();
+ } finally {
+ delete library.stats;
+ }
+ });
+
+ it('aChartLegendRowOfTheOwnerOpensTheDetailPage', async () => {
+ localStorage.setItem('tbp_session', '1');
+ library.stats = platformStats;
+ try {
+ mockBackends({me: {displayName: 'lobo', role: 'USER'}, style: {...platformTile, statCharts: {PLATFORMS: 'PIE'}},
+ extra: platformGames});
+ const {container} = renderPage();
+
+ await waitFor(() => expect(container.querySelectorAll('path.stroke-card')).toHaveLength(2));
+ fireEvent.click(await screen.findByRole('button', {name: /Steam/}));
+
+ expect((await screen.findByRole('link', {name: /Hades/})).getAttribute('href')).toBe('/library/7');
+ } finally {
+ delete library.stats;
+ localStorage.clear();
+ }
+ });
+
+ it('aChartLegendRowOfAVisitorShowsAPlainGameRow', async () => {
+ library.stats = platformStats;
+ try {
+ mockBackends({style: {...platformTile, statCharts: {PLATFORMS: 'PIE'}}, extra: platformGames});
+ const {container} = renderPage();
+
+ await waitFor(() => expect(container.querySelectorAll('path.stroke-card')).toHaveLength(2));
+ fireEvent.click(await screen.findByRole('button', {name: /Steam/}));
+
+ expect(await screen.findByText('Hades')).toBeTruthy();
+ expect(screen.queryByRole('link', {name: /Hades/})).toBeNull();
+ } finally {
+ delete library.stats;
+ }
+ });
+
it('opensTheGamesBehindPerfectGames', async () => {
library.perfectGames = 12;
library.averageCompletion = 64.5;
diff --git a/src/pages/StatsPage.jsx b/src/pages/StatsPage.jsx
index 0fc3877..6608a2f 100644
--- a/src/pages/StatsPage.jsx
+++ b/src/pages/StatsPage.jsx
@@ -1,3 +1,4 @@
+import {useRef} from 'react';
import {useTranslation} from 'react-i18next';
import {RefreshCw} from 'lucide-react';
@@ -14,12 +15,37 @@ import FinishedStats from '../components/stats/FinishedStats.jsx';
import PlatformStats from '../components/stats/PlatformStats.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import useUserStats from '../hooks/useUserStats.js';
+import useSaveResponse from '../hooks/useSaveResponse.js';
+import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
+import {chartOf, saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
const ALL_GROUPS = [];
export default function StatsPage() {
const {t} = useTranslation();
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
+ const {user, updateUser} = useCurrentUserContext();
+ const runSave = useSaveResponse();
+ 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 (
@@ -47,12 +73,12 @@ export default function StatsPage() {
)}
diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx
index edbf40b..c0dc982 100644
--- a/src/pages/StatsPage.test.jsx
+++ b/src/pages/StatsPage.test.jsx
@@ -1,14 +1,18 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
-import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
-import {MemoryRouter} from 'react-router-dom';
+import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import StatsPage from './StatsPage.jsx';
-import {PreferencesProvider} from '../context/PreferencesContext.jsx';
+import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
-const answerStats = (answer) => fetchRoutes({'GET /api/library/stats': answer});
+const me = (statCharts) => json(200, {email: 'a@b.de', settings: {statCharts}});
+const answerStats = (answer, statCharts) => fetchRoutes({
+ 'GET /api/library/stats': answer,
+ 'GET /api/users/me': me(statCharts),
+ 'PUT /api/users/settings/stat-chart': json(204, {}),
+});
const stats = (extra = {}) => ({
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
@@ -27,7 +31,7 @@ const stats = (extra = {}) => ({
...extra,
});
-const renderPage = () => render( );
+const renderPage = () => renderWithProviders( );
describe('StatsPage', () => {
beforeEach(() => {
@@ -44,8 +48,9 @@ describe('StatsPage', () => {
renderPage();
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
- expect(fetch).toHaveBeenCalledOnce();
- expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
+ const statsCalls = fetch.mock.calls.filter(([url]) => url.includes('/api/library/stats'));
+ expect(statsCalls).toHaveLength(1);
+ expect(statsCalls[0][0]).toMatch(/\/api\/library\/stats$/);
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED', 'PLATFORMS']) {
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
}
@@ -87,12 +92,13 @@ describe('StatsPage', () => {
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
- await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(fetch.mock.calls.filter(([url]) => url.includes('/api/library/stats'))).toHaveLength(2));
});
it('opensTheGamesBehindARowWithTheRawName', async () => {
fetchRoutes({
'GET /api/library/stats': json(200, stats()),
+ 'GET /api/users/me': me(),
'GET /api/library/stats/games': json(200, {content: [{appid: 620, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 60}],
page: 0, size: 40, totalElements: 1, totalPages: 1}),
});
@@ -149,4 +155,197 @@ describe('StatsPage', () => {
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
});
+
+ describe('chart forms', () => {
+ const section = (title) => screen.getByText(title).closest('[data-slot="card"]');
+ const picker = (title) => within(section(title)).getByRole('group', {name: 'STATS.CHART.LABEL'});
+ const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
+ const slices = (title) => section(title).querySelectorAll('path.stroke-card');
+
+ it('showsBarsAsTodayAndAPickerPerChartableGroup', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+
+ await screen.findByText('STATS.PLATFORMS.TITLE');
+
+ expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0);
+ for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND']) {
+ 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.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('savesThePickedFormAndSavesNullForTheDefault', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+ await screen.findByText('STATS.PLATFORMS.TITLE');
+
+ fireEvent.click(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
+
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(2));
+ expect(putBodies()).toEqual([{group: 'PLATFORMS', chart: 'PIE'}]);
+
+ fireEvent.click(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}));
+
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0));
+ expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null});
+ });
+
+ it('showsTheStoredFormOfTheUser', async () => {
+ answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'});
+ renderPage();
+
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(2));
+ expect(screen.queryByTestId('pile-trend')).toBeNull();
+ });
+
+ it('putsTheOldFormBackWhenTheSaveFails', async () => {
+ fetchRoutes({
+ 'GET /api/library/stats': json(200, stats()),
+ 'GET /api/users/me': me(),
+ 'PUT /api/users/settings/stat-chart': json(500, {}),
+ });
+ renderPage();
+ await screen.findByText('STATS.PLATFORMS.TITLE');
+
+ fireEvent.click(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
+
+ await waitFor(() => expect(putBodies()).toHaveLength(1));
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0));
+ expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('aLegendRowOpensTheGamesWithThePlatform', async () => {
+ fetchRoutes({
+ 'GET /api/library/stats': json(200, stats()),
+ 'GET /api/users/me': me({PLATFORMS: 'PIE'}),
+ 'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
+ });
+ renderPage();
+
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(2));
+ fireEvent.click(await within(section('STATS.PLATFORMS.TITLE')).findByRole('button', {name: /PS5/}));
+
+ expect(await screen.findByRole('dialog')).toBeTruthy();
+ await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=PLATFORM&arg=PS5&page=0$/));
+ });
+
+ it('theOtherSliceIsNotAButtonAndHoldsTheRest', async () => {
+ const perPlatform = Array.from({length: 8}, (_, i) => ({name: `P${i}`, games: 8 - i, minutes: 10}));
+ answerStats(json(200, stats({platforms: {perPlatform}})), {PLATFORMS: 'PIE'});
+ renderPage();
+
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(6));
+ const legend = within(section('STATS.PLATFORMS.TITLE'));
+ expect(legend.getAllByRole('button', {name: /P\d/})).toHaveLength(5);
+ expect(legend.getByText('STATS.CHART.OTHER').closest('button')).toBeNull();
+ expect(legend.getByText('STATS.TASTE.GAMES:6')).toBeTruthy();
+ });
+
+ it('aPlatformRowWithoutGamesIsNoButtonInBarsAndList', async () => {
+ const perPlatform = [{name: 'Steam', games: 3, minutes: 5}, {name: 'Switch', games: 0, minutes: 0}];
+ for (const form of ['BARS', 'LIST']) {
+ answerStats(json(200, stats({platforms: {perPlatform}})), {PLATFORMS: form});
+ renderPage();
+
+ const platforms = within(await screen.findByText('STATS.PLATFORMS.TITLE').then(() => section('STATS.PLATFORMS.TITLE')));
+ expect(platforms.getByRole('button', {name: /Steam/})).toBeTruthy();
+ expect(platforms.getByText('Switch')).toBeTruthy();
+ expect(platforms.queryByRole('button', {name: /Switch/})).toBeNull();
+ cleanup();
+ }
+ });
+
+ it('aPlatformWithoutGamesGetsNoSlice', async () => {
+ answerStats(json(200, stats({platforms: {perPlatform: [
+ {name: 'Steam', games: 3, minutes: 5}, {name: 'Switch', games: 0, minutes: 0}]}})), {PLATFORMS: 'PIE'});
+ renderPage();
+
+ await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(1));
+ expect(within(section('STATS.PLATFORMS.TITLE')).queryByText('Switch')).toBeNull();
+ });
+
+ it('showsTheEmptyTextForEveryForm', async () => {
+ answerStats(json(200, stats({platforms: {perPlatform: []}})), {PLATFORMS: 'PIE'});
+ renderPage();
+
+ expect(await screen.findByText('STATS.PLATFORMS.EMPTY')).toBeTruthy();
+ });
+
+ it('aLineYearOpensItsGamesAndAYearWithoutGamesIsNoButton', async () => {
+ fetchRoutes({
+ 'GET /api/library/stats': json(200, stats({finished: {finishedBeforeTracking: 0,
+ perYear: [{year: 2022, games: 3}, {year: 2024, games: 1}]}})),
+ 'GET /api/users/me': me({FINISHED: 'LINE'}),
+ 'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
+ });
+ renderPage();
+
+ const finished = within(await screen.findByText('STATS.FINISHED.TITLE').then(() => section('STATS.FINISHED.TITLE')));
+ await waitFor(() => expect(finished.getByRole('button', {name: /2022/})).toBeTruthy());
+ expect(finished.getByRole('button', {name: /2024/})).toBeTruthy();
+ expect(finished.queryByRole('button', {name: /2023/})).toBeNull();
+ expect(finished.getByText('2023: 0')).toBeTruthy();
+ const circles = [...section('STATS.FINISHED.TITLE').querySelectorAll('circle')];
+ expect(circles).toHaveLength(3);
+ expect(circles.filter((c) => c.getAttribute('class').includes('cursor-pointer'))).toHaveLength(2);
+
+ fireEvent.click(finished.getByRole('button', {name: /2022/}));
+
+ await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=FINISHED_YEAR&arg=2022&page=0$/));
+ });
+
+ it('aLineWithOneYearDrawsBars', async () => {
+ answerStats(json(200, stats()), {FINISHED: 'LINE'});
+ renderPage();
+
+ const finished = within(await screen.findByText('STATS.FINISHED.TITLE').then(() => section('STATS.FINISHED.TITLE')));
+ await waitFor(() => expect(finished.getByRole('button', {name: /2026/})).toBeTruthy());
+ expect(section('STATS.FINISHED.TITLE').querySelector('polyline')).toBeNull();
+ });
+
+ it('aDonutOfTheTasteOpensTheGenreWithItsRawNameAndShowsTheOverlapNote', async () => {
+ fetchRoutes({
+ 'GET /api/library/stats': json(200, stats()),
+ 'GET /api/users/me': me({TASTE: 'DONUT'}),
+ 'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
+ });
+ renderPage();
+
+ const taste = within(await screen.findByText('STATS.TASTE.TITLE').then(() => section('STATS.TASTE.TITLE')));
+ await waitFor(() => expect(taste.getAllByText('STATS.CHART.OVERLAP')).toHaveLength(2));
+ fireEvent.click(taste.getByRole('button', {name: /STATS\.GENRE/}));
+
+ await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=GENRE&arg=Shooter&page=0$/));
+ });
+
+ it('theTrendBarsStayOneClickTargetWithAListOfTheDays', async () => {
+ answerStats(json(200, stats()), {TREND: 'BARS'});
+ renderPage();
+
+ const trend = within(await screen.findByText('STATS.TREND.TITLE').then(() => section('STATS.TREND.TITLE')));
+ await waitFor(() => expect(section('STATS.TREND.TITLE').querySelectorAll('rect')).toHaveLength(2));
+ expect(trend.getAllByRole('button', {name: 'STATS.DRILL.TREND_MOVES'})).toHaveLength(1);
+ expect(section('STATS.TREND.TITLE').querySelectorAll('table tr')).toHaveLength(2);
+ });
+
+ // REVIEW-FINDING: the picker buttons sit inside the h2, so the heading name becomes "title + Chart Bars Pie ..."
+ it('reviewFinding_thePickerIsNotPartOfTheHeadingName', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+
+ expect(await screen.findByRole('heading', {name: 'STATS.PLATFORMS.TITLE'})).toBeTruthy();
+ });
+
+ // REVIEW-FINDING: an empty tag list in pie/donut form draws an empty chart box and the overlap note
+ it('reviewFinding_anEmptyMoodListInThePieShowsNoChartAndNoNote', async () => {
+ answerStats(json(200, stats({taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: []}})),
+ {TASTE: 'PIE'});
+ renderPage();
+
+ await waitFor(() => expect(slices('STATS.TASTE.TITLE')).toHaveLength(1));
+ expect(within(section('STATS.TASTE.TITLE')).getAllByText('STATS.CHART.OVERLAP')).toHaveLength(1);
+ });
+ });
});
diff --git a/src/utils/statCharts.js b/src/utils/statCharts.js
new file mode 100644
index 0000000..aeaec90
--- /dev/null
+++ b/src/utils/statCharts.js
@@ -0,0 +1,114 @@
+import {apiFetch} from './api.js';
+import {dayNumber} from './stats.js';
+
+// The chart forms of each stat group, the first is the default. Auth checks the same list.
+export const STAT_CHARTS = {
+ PLATFORMS: ['BARS', 'PIE', 'DONUT', 'LIST'],
+ TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'],
+ FINISHED: ['BARS', 'LINE', 'LIST'],
+ TREND: ['LINE', 'BARS'],
+};
+
+/**
+ * The chart form to draw: the stored one when the group offers it, else the group's default.
+ *
+ * @param charts group -> form, as the user settings hold it; may be missing
+ */
+export const chartOf = (charts, group) => {
+ const forms = STAT_CHARTS[group];
+ const stored = charts?.[group];
+ return forms.includes(stored) ? stored : forms[0];
+};
+
+/**
+ * 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.
+ *
+ * @returns `[{name, value, item, other}]`; the other slice has no name and no item
+ */
+export const topSlices = (items, valueOf, keep = 5) => {
+ const slices = items
+ .map((item) => ({name: item.name, value: valueOf(item), item, other: false}))
+ .filter((slice) => slice.value > 0)
+ .sort((a, b) => b.value - a.value);
+ if (slices.length <= keep + 1) return slices;
+ const rest = slices.slice(keep).reduce((sum, slice) => sum + slice.value, 0);
+ return [...slices.slice(0, keep), {name: null, value: rest, item: null, other: true}];
+};
+
+const round = (n) => Math.round(n * 1000) / 1000;
+
+/**
+ * SVG path data of the slices of a pie (`inner` 0) or a donut, in a square of `2 * radius`, the first slice at the
+ * top, clockwise. A slice that is the whole circle is drawn as two half arcs.
+ */
+export const slicePaths = (values, radius, inner = 0) => {
+ const total = values.reduce((sum, value) => sum + value, 0);
+ const point = (angle, r) => `${round(radius + r * Math.sin(angle))},${round(radius - r * Math.cos(angle))}`;
+ let start = 0;
+ return values.map((value) => {
+ if (value <= 0) return '';
+ const from = start;
+ start += value;
+ if (value === total) {
+ const ring = (r, sweep) => `M${radius},${radius - r} A${r},${r} 0 1 ${sweep} ${radius},${radius + r} A${r},${r} 0 1 ${sweep} ${radius},${radius - r} Z`;
+ return inner > 0 ? `${ring(radius, 1)} ${ring(inner, 0)}` : ring(radius, 1);
+ }
+ const a0 = (from / total) * 2 * Math.PI;
+ const a1 = (start / total) * 2 * Math.PI;
+ const large = a1 - a0 > Math.PI ? 1 : 0;
+ const outer = `A${radius},${radius} 0 ${large} 1 ${point(a1, radius)}`;
+ return inner > 0
+ ? `M${point(a0, radius)} ${outer} L${point(a1, inner)} A${inner},${inner} 0 ${large} 0 ${point(a0, inner)} Z`
+ : `M${radius},${radius} L${point(a0, radius)} ${outer} Z`;
+ });
+};
+
+/**
+ * The years from the first to the last one, a year without games has 0.
+ *
+ * @param perYear `[{year, games}]`
+ */
+export const yearSeries = (perYear) => {
+ if (perYear.length === 0) return [];
+ const years = perYear.map((y) => y.year);
+ const first = Math.min(...years);
+ const games = new Map(perYear.map((y) => [y.year, y.games]));
+ return Array.from({length: Math.max(...years) - first + 1},
+ (_, i) => ({year: first + i, games: games.get(first + i) ?? 0}));
+};
+
+/**
+ * Points of a line chart, spread over the width; the y axis starts at 0, so a small change looks small.
+ */
+export const linePoints = (values, width, height) => {
+ const max = Math.max(1, ...values);
+ return values.map((value, i) => ({
+ x: (i / (values.length - 1)) * width,
+ y: height - (value / max) * height,
+ }));
+};
+
+/**
+ * Bars for the pile per day, like trendPoints: x follows the date, so days without a snapshot keep their width.
+ */
+export const trendBars = (days, width, height) => {
+ if (days.length === 0) return [];
+ const max = Math.max(1, ...days.map((d) => d.backlogGames));
+ const first = dayNumber(days[0].day);
+ const slot = width / (dayNumber(days[days.length - 1].day) - first + 1);
+ return days.map((d) => {
+ const h = (d.backlogGames / max) * height;
+ return {x: (dayNumber(d.day) - first) * slot, y: height - h, w: slot * 0.8, h};
+ });
+};
+
+/**
+ * @param chart null puts the group back to its default form
+ */
+export const saveStatChart = (group, chart) =>
+ apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/stat-chart`, {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({group, chart}),
+ });
diff --git a/src/utils/statCharts.test.js b/src/utils/statCharts.test.js
new file mode 100644
index 0000000..4dc6083
--- /dev/null
+++ b/src/utils/statCharts.test.js
@@ -0,0 +1,94 @@
+import {describe, expect, it} from 'vitest';
+import {chartOf, linePoints, slicePaths, topSlices, trendBars, yearSeries} from './statCharts.js';
+
+describe('chartOf', () => {
+ it('gives the stored form of the group', () => {
+ expect(chartOf({PLATFORMS: 'PIE'}, 'PLATFORMS')).toBe('PIE');
+ });
+
+ it('falls back to the first form', () => {
+ expect(chartOf({}, 'PLATFORMS')).toBe('BARS');
+ expect(chartOf({}, 'TREND')).toBe('LINE');
+ expect(chartOf({TASTE: 'LINE'}, 'TASTE')).toBe('BARS');
+ expect(chartOf({PLATFORMS: 'RADAR'}, 'PLATFORMS')).toBe('BARS');
+ });
+
+ it('accepts a missing or odd settings value', () => {
+ for (const charts of [null, undefined, 'PIE', 5]) expect(chartOf(charts, 'FINISHED')).toBe('BARS');
+ });
+});
+
+describe('topSlices', () => {
+ const item = (name, games) => ({name, games});
+ const games = (i) => i.games;
+
+ it('keeps all items up to one more than keep, biggest first', () => {
+ const slices = topSlices([1, 2, 3, 4, 5, 6].map((n) => item(`P${n}`, n)), games);
+ expect(slices.map((s) => s.name)).toEqual(['P6', 'P5', 'P4', 'P3', 'P2', 'P1']);
+ expect(slices.some((s) => s.other)).toBe(false);
+ });
+
+ it('sums the rest into an other slice', () => {
+ const slices = topSlices([1, 2, 3, 4, 5, 6, 7, 8].map((n) => item(`P${n}`, n)), games);
+ expect(slices).toHaveLength(6);
+ expect(slices.slice(0, 5).map((s) => s.name)).toEqual(['P8', 'P7', 'P6', 'P5', 'P4']);
+ expect(slices[5]).toMatchObject({name: null, other: true, value: 3 + 2 + 1, item: null});
+ });
+
+ it('drops items without a value', () => {
+ expect(topSlices([item('A', 2), item('B', 0)], games).map((s) => s.name)).toEqual(['A']);
+ });
+});
+
+describe('slicePaths', () => {
+ it('draws one value as a full circle, not an empty arc', () => {
+ const [d] = slicePaths([4], 50, 0);
+ expect(d).toBe('M50,0 A50,50 0 1 1 50,100 A50,50 0 1 1 50,0 Z');
+ });
+
+ it('draws one value of a donut as a ring with a hole', () => {
+ const [d] = slicePaths([4], 50, 30);
+ expect(d.match(/M/g)).toHaveLength(2);
+ });
+
+ it('splits a circle in the order of the values, the first from the top', () => {
+ const [a, b] = slicePaths([1, 1], 50, 0);
+ expect(a).toBe('M50,50 L50,0 A50,50 0 0 1 50,100 Z');
+ expect(b).toBe('M50,50 L50,100 A50,50 0 0 1 50,0 Z');
+ });
+
+ it('gives a donut slice an inner arc', () => {
+ const [a] = slicePaths([1, 3], 50, 30);
+ expect(a).toContain('A30,30 0 0 0');
+ });
+});
+
+describe('yearSeries', () => {
+ it('fills the years between the first and the last with 0', () => {
+ expect(yearSeries([{year: 2024, games: 1}, {year: 2022, games: 3}]))
+ .toEqual([{year: 2022, games: 3}, {year: 2023, games: 0}, {year: 2024, games: 1}]);
+ expect(yearSeries([])).toEqual([]);
+ });
+});
+
+describe('linePoints', () => {
+ it('spreads the values over the width, the biggest at the top', () => {
+ expect(linePoints([2, 4, 0], 100, 40)).toEqual([{x: 0, y: 20}, {x: 50, y: 0}, {x: 100, y: 40}]);
+ });
+});
+
+describe('trendBars', () => {
+ const day = (date, backlogGames) => ({day: date, backlogGames});
+
+ it('puts each bar where its date is, a day without a snapshot keeps its width', () => {
+ const bars = trendBars([day('2026-09-01', 10), day('2026-09-02', 5), day('2026-09-05', 0)], 100, 40);
+ expect(bars.map((b) => b.x)).toEqual([0, 20, 80]);
+ expect(bars.map((b) => b.h)).toEqual([40, 20, 0]);
+ expect(bars[1].y).toBe(20);
+ });
+
+ it('draws a single day', () => {
+ expect(trendBars([day('2026-09-01', 3)], 100, 40)).toHaveLength(1);
+ expect(trendBars([], 100, 40)).toEqual([]);
+ });
+});
diff --git a/src/utils/stats.js b/src/utils/stats.js
index 8903231..96b63d1 100644
--- a/src/utils/stats.js
+++ b/src/utils/stats.js
@@ -19,7 +19,7 @@ export const formatPercent = (share, locale) =>
share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0});
const DAY_MS = 24 * 60 * 60 * 1000;
-const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
+export const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
/**
* Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot