-
- {slices.map((slice, index) => (
-
- ))}
-
+
+
+ {slices.map((slice, index) => (
+
+ ))}
+
+ {donut && center && (
+
+ )}
+
{slices.map((slice, index) => (
+ label={label} linkOf={linkOf} color={colors[index]}/>
))}
diff --git a/src/components/stats/StatDrill.jsx b/src/components/stats/StatDrill.jsx
index 04fdc52..e186ff9 100644
--- a/src/components/stats/StatDrill.jsx
+++ b/src/components/stats/StatDrill.jsx
@@ -60,13 +60,13 @@ export function useTagDrill() {
return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name}));
}
-/** A bar row: a button when it has `onSelect`. */
-export function DrillRow({onSelect, className, children}) {
+const ROW_CLASS = 'block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring';
+
+/** A bar row: a link when it has `to`, a button when it has `onSelect`. */
+export function DrillRow({onSelect, to, className, children}) {
+ if (to) return
{children};
return onSelect ? (
-
- {children}
-
+
{children}
) :
{children}
;
}
diff --git a/src/components/stats/StatMeter.jsx b/src/components/stats/StatMeter.jsx
new file mode 100644
index 0000000..a4ad9fb
--- /dev/null
+++ b/src/components/stats/StatMeter.jsx
@@ -0,0 +1,17 @@
+/**
+ * A share as a bar.
+ *
+ * @param percent 0 to 100, kept inside
+ * @param label the accessible name
+ * @param valueText the text shown for the value, e.g. "71,3 %"
+ * @param decorative hide it from assistive tech, when the parent already names the value
+ */
+export default function StatMeter({percent, label, valueText, decorative}) {
+ const clamped = Math.min(100, Math.max(0, percent));
+ return (
+
+ );
+}
diff --git a/src/components/stats/StatMeter.test.jsx b/src/components/stats/StatMeter.test.jsx
new file mode 100644
index 0000000..ae36529
--- /dev/null
+++ b/src/components/stats/StatMeter.test.jsx
@@ -0,0 +1,27 @@
+// @vitest-environment jsdom
+import {afterEach, describe, expect, it} from 'vitest';
+import {cleanup, render, screen} from '@testing-library/react';
+import StatMeter from './StatMeter.jsx';
+
+describe('StatMeter', () => {
+ afterEach(cleanup);
+
+ const bar = () => screen.getByRole('meter', {name: 'Share'}).firstChild;
+
+ it('exposesTheRoundedValueAndTheShownText', () => {
+ render(
);
+
+ const meter = screen.getByRole('meter', {name: 'Share'});
+ expect(meter.getAttribute('aria-valuenow')).toBe('71');
+ expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %');
+ expect(bar().style.width).toBe('71.3%');
+ });
+
+ it('keepsThePercentBetween0And100', () => {
+ const {rerender} = render(
);
+ expect(bar().style.width).toBe('100%');
+
+ rerender(
);
+ expect(bar().style.width).toBe('0%');
+ });
+});
diff --git a/src/components/stats/StatTile.jsx b/src/components/stats/StatTile.jsx
index ccef8d6..a3f7610 100644
--- a/src/components/stats/StatTile.jsx
+++ b/src/components/stats/StatTile.jsx
@@ -2,18 +2,21 @@ import {Link} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {useDrillTarget} from './StatDrill.jsx';
+import StatMeter from './StatMeter.jsx';
const CLICKABLE = 'block w-full text-left transition-colors hover:border-primary focus-visible:ring-2 focus-visible:ring-ring';
/**
* @param drill `{to, params, title}` of StatCount: the tile is a link or button when it has a target here
+ * @param meter `{percent, valueText}`: a bar under the value
*/
-export default function StatTile({label, value, hint, drill}) {
+export default function StatTile({label, value, hint, drill, meter}) {
const target = useDrillTarget({title: label, ...drill});
const body = (
<>
{value}
{label}
+ {meter &&
}
{hint && {hint}
}
>
);
diff --git a/src/components/stats/StatusDonut.jsx b/src/components/stats/StatusDonut.jsx
new file mode 100644
index 0000000..453a5f2
--- /dev/null
+++ b/src/components/stats/StatusDonut.jsx
@@ -0,0 +1,32 @@
+import {useTranslation} from 'react-i18next';
+
+import SliceChart from './SliceChart.jsx';
+import {StatCount} from './StatDrill.jsx';
+import {libraryLink} from '../../utils/statGames.js';
+import {statusSlices} from '../../utils/statCharts.js';
+
+/**
+ * The games per status as a donut with the total in the hole.
+ *
+ * @param byStatus status -> games
+ * @param totalParams games request of the total; null when the total is not clickable
+ */
+export default function StatusDonut({byStatus, totalParams = {key: 'TOTAL'}}) {
+ const {t, i18n} = useTranslation();
+ const slices = statusSlices(byStatus);
+ const total = slices.reduce((sum, slice) => sum + slice.value, 0);
+ const n = (value) => value.toLocaleString(i18n.language);
+ const games = t('STATS.OVERVIEW.GAMES');
+ const center = (
+
+
+ {n(total)}
+
+ {games}
+
+ );
+ return (
+ t(`BACKLOG.STATUS.${s}`)}
+ linkOf={libraryLink} center={center}/>
+ );
+}
diff --git a/src/components/stats/TopGamesStats.jsx b/src/components/stats/TopGamesStats.jsx
index ac8f6b4..4937ad3 100644
--- a/src/components/stats/TopGamesStats.jsx
+++ b/src/components/stats/TopGamesStats.jsx
@@ -10,6 +10,7 @@ import {formatPlaytime} from '../../utils/games.js';
export default function TopGamesStats({topGames, linked = true}) {
const {t} = useTranslation();
const drill = useStatDrill();
+ const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
return (
{topGames.mostPlayed.length === 0 ? (
@@ -17,7 +18,8 @@ export default function TopGamesStats({topGames, linked = true}) {
) : (
{topGames.mostPlayed.map((g) => (
-
+
))}
)}
diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx
index 42a0a50..aef8484 100644
--- a/src/pages/PublicProfilePage.jsx
+++ b/src/pages/PublicProfilePage.jsx
@@ -9,11 +9,10 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
-import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
+import GameStatusBadge from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
-import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
import useSession from '../hooks/useSession.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
@@ -25,6 +24,8 @@ import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx';
import TileGrid from '../components/TileGrid.jsx';
import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.jsx';
+import StatMeter from '../components/stats/StatMeter.jsx';
+import StatusDonut from '../components/stats/StatusDonut.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx';
// Games per tile size; Core sends at most 8 playing and 12 finished games.
@@ -132,7 +133,12 @@ export default function PublicProfilePage() {
case 'SUMMARY': return library.totalGames != null && ;
case 'STATUS':
return library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
- && ;
+ && (
+
+
+
+ );
case 'PLAYING':
return library.playing?.length > 0
&& ;
@@ -195,21 +201,24 @@ function Summary({library, compact}) {
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames],
];
- if (library.averageCompletion != null) {
+ const average = library.averageCompletion != null ? `${library.averageCompletion.toLocaleString(i18n.language)} %` : null;
+ if (average) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames],
- [Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
+ [Percent, average, t('PROFILE.AVERAGE_COMPLETION'), undefined, undefined,
+ {percent: library.averageCompletion, valueText: average}],
);
}
return (
3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
- {stats.map(([Icon, value, label, key, count]) => (
+ {stats.map(([Icon, value, label, key, count, meter]) => (
0 ? {key} : null} title={label}
label={`${label}: ${value}`}>{value}
+ {meter &&
}
{label}
))}
@@ -217,32 +226,6 @@ function Summary({library, compact}) {
);
}
-function StatusBar({byStatus}) {
- const {t} = useTranslation();
- const total = GAME_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0);
- return (
-
-
- {GAME_STATUSES.map((s) => (
-
- ))}
-
-
- {GAME_STATUSES.map((s) => (
-
-
- {t(`BACKLOG.STATUS.${s}`)}
- 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)}
- label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}>
- {byStatus[s] ?? 0}
-
-
- ))}
-
-
- );
-}
-
function Playing({games, compact}) {
const {t} = useTranslation();
return (
diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx
index 37d6250..b304aa7 100644
--- a/src/pages/PublicProfilePage.test.jsx
+++ b/src/pages/PublicProfilePage.test.jsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, 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 PublicProfilePage from './PublicProfilePage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -98,7 +98,7 @@ describe('PublicProfilePage', () => {
expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket');
expect(screen.getByText('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
- expect(screen.getByText('233')).toBeTruthy();
+ expect(screen.getAllByText('233')).toHaveLength(2);
expect(screen.queryByText('PROFILE.PERFECT_GAMES')).toBeNull();
});
@@ -267,7 +267,7 @@ describe('PublicProfilePage', () => {
content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}});
const {container} = renderPage();
- fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
+ fireEvent.click(await screen.findByRole('button', {name: /BACKLOG\.STATUS\.BACKLOG/}));
expect(await screen.findByText('Backlogged')).toBeTruthy();
const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games'));
@@ -293,6 +293,55 @@ describe('PublicProfilePage', () => {
const gamesRoute = {'GET /api/public/profiles/library/stats/games': json(200, {
content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})};
+ it('drawsTheStatusTileAsADonut', async () => {
+ mockBackends();
+ const {container} = renderPage();
+
+ await screen.findByText('Portal 2');
+
+ expect(container.querySelectorAll('[data-tile="STATUS"] path.stroke-card')).toHaveLength(5);
+ });
+
+ it('showsTheTotalInTheDonutAsTextWithoutTheSummarySection', async () => {
+ mockBackends({answer: {...library, totalGames: null}});
+ const {container} = renderPage();
+
+ await screen.findByText('Portal 2');
+ const tile = container.querySelector('[data-tile="STATUS"]');
+
+ expect(within(tile).getByText('233')).toBeTruthy();
+ expect(within(tile).queryByRole('button', {name: /STATS\.OVERVIEW\.GAMES/})).toBeNull();
+ });
+
+ it('opensAllGamesFromTheDonutTotalWhenTheSummaryIsShared', async () => {
+ mockBackends({extra: gamesRoute});
+ const {container} = renderPage();
+
+ await screen.findByText('Portal 2');
+ fireEvent.click(within(container.querySelector('[data-tile="STATUS"]')).getByRole('button', {name: 'STATS.OVERVIEW.GAMES: 233'}));
+
+ await screen.findByText('Backlogged');
+ expect(fetch.mock.calls.find(([url]) => url.includes('/stats/games'))[0]).toContain('key=TOTAL');
+ });
+
+ it('showsTheAverageCompletionAsAMeter', async () => {
+ mockBackends({answer: {...library, averageCompletion: 71.3, perfectGames: 2}});
+ renderPage();
+
+ const meter = await screen.findByRole('meter', {name: 'PROFILE.AVERAGE_COMPLETION'});
+
+ expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %');
+ });
+
+ it('showsNoMeterWithoutAnAverageCompletion', async () => {
+ mockBackends();
+ renderPage();
+
+ await screen.findByText('PROFILE.GAMES');
+
+ expect(screen.queryByRole('meter')).toBeNull();
+ });
+
const topGamesStats = {pile: null, trend: null, taste: null, finished: null, habits: null,
topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}], mostLoyal: null}};
@@ -302,7 +351,7 @@ describe('PublicProfilePage', () => {
mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute});
renderPage();
- fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
+ fireEvent.click(await screen.findByRole('button', {name: /BACKLOG\.STATUS\.BACKLOG/}));
expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1');
} finally {
diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx
index c0dc982..db0bf90 100644
--- a/src/pages/StatsPage.test.jsx
+++ b/src/pages/StatsPage.test.jsx
@@ -114,7 +114,7 @@ describe('StatsPage', () => {
answerStats(json(200, stats()));
renderPage();
- const link = await screen.findByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 5'});
+ const link = await screen.findByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
});
@@ -140,11 +140,11 @@ describe('StatsPage', () => {
await screen.findByText('STATS.PILE.GAMES');
- for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED: 0/]) {
+ for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED/]) {
expect(screen.queryByRole('button', {name})).toBeNull();
expect(screen.queryByRole('link', {name})).toBeNull();
}
- expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
+ expect(screen.getByRole('link', {name: /BACKLOG\.STATUS\.PLAYING/})).toBeTruthy();
});
it('leavesTheMoonTripsAlone', async () => {
@@ -156,6 +156,129 @@ describe('StatsPage', () => {
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
});
+ describe('overview donut', () => {
+ const overview = () => screen.getByText('STATS.OVERVIEW.TITLE').closest('[data-slot="card"]');
+ const paths = () => overview().querySelectorAll('path.stroke-card');
+
+ it('drawsOneColoredSlicePerStatus', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+
+ await screen.findByText('STATS.OVERVIEW.TITLE');
+
+ expect(paths()).toHaveLength(5);
+ expect(paths()[0].getAttribute('style')).toContain('var(--chart-1)');
+ expect(paths()[4].getAttribute('style')).toContain('var(--chart-5)');
+ });
+
+ it('putsTheTotalInTheHoleAsALink', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+
+ const link = await within(await screen.findByText('STATS.OVERVIEW.TITLE').then(overview))
+ .findByRole('link', {name: 'STATS.OVERVIEW.GAMES: 12'});
+
+ expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true');
+ });
+
+ it('linksALegendRowToTheStatusLibrary', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+
+ await screen.findByText('STATS.OVERVIEW.TITLE');
+
+ expect(within(overview()).getByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}).getAttribute('href'))
+ .toBe('/library?hideDuplicates=true&status=BACKLOG');
+ });
+
+ it('leavesAStatusWithoutGamesWithoutSliceAndTarget', async () => {
+ const base = stats();
+ answerStats(json(200, stats({overview: {...base.overview, byStatus: {...base.overview.byStatus, DROPPED: 0}}})));
+ renderPage();
+
+ await screen.findByText('STATS.OVERVIEW.TITLE');
+
+ expect(paths()).toHaveLength(4);
+ const row = within(overview()).getByText('BACKLOG.STATUS.DROPPED');
+ expect(row.closest('a, button')).toBeNull();
+ });
+
+ it('opensTheStatusLibraryOnASliceClick', async () => {
+ answerStats(json(200, stats()));
+ const {router} = renderPage();
+ await screen.findByText('STATS.OVERVIEW.TITLE');
+
+ fireEvent.click(paths()[1]);
+
+ await waitFor(() => expect(router.state.location.pathname + router.state.location.search)
+ .toBe('/library?hideDuplicates=true&status=PLAYING'));
+ });
+
+ it('showsTheGamesTileInsteadOfTheDonutForAnEmptyLibrary', async () => {
+ const base = stats();
+ answerStats(json(200, stats({overview: {...base.overview, totalGames: 0, playedGames: 0, neverStarted: 0,
+ byStatus: {BACKLOG: 0, PLAYING: 0, FINISHED: 0, COMPLETED: 0, DROPPED: 0}}})));
+ renderPage();
+
+ await screen.findByText('STATS.OVERVIEW.TITLE');
+
+ expect(paths()).toHaveLength(0);
+ expect(within(overview()).getByText('STATS.OVERVIEW.GAMES')).toBeTruthy();
+ expect(within(overview()).queryByRole('link')).toBeNull();
+ });
+ });
+
+ it('usesThePaletteAndNotTheAccentInAPickedPie', async () => {
+ answerStats(json(200, stats()), {PLATFORMS: 'PIE'});
+ renderPage();
+
+ const card = (await screen.findByText('STATS.PLATFORMS.TITLE')).closest('[data-slot="card"]');
+ await waitFor(() => expect(card.querySelectorAll('path.stroke-card')).toHaveLength(2));
+ const [first, second] = card.querySelectorAll('path.stroke-card');
+
+ expect(first.getAttribute('style')).toContain('var(--chart-1)');
+ expect(second.getAttribute('style')).toContain('var(--chart-2)');
+ });
+
+ it('showsTheUnderOneHourShareAsAMeterThatStaysAButton', async () => {
+ answerStats(json(200, stats()));
+ renderPage();
+
+ const meter = (await screen.findAllByRole('meter', {hidden: true}))[0];
+
+ expect(meter.getAttribute('aria-valuenow')).toBe('13');
+ expect(meter.getAttribute('aria-label')).toBe('STATS.HABITS.UNDER_ONE_HOUR');
+ expect(meter.getAttribute('aria-hidden')).toBe('true');
+ expect(screen.getByRole('button', {name: /STATS\.HABITS\.UNDER_ONE_HOUR/})).toBeTruthy();
+ });
+
+ it('keepsTheMeterOfAnUnderOneHourShareWithoutGamesButDropsTheButton', async () => {
+ const base = stats();
+ answerStats(json(200, stats({habits: {...base.habits, underOneHourGames: 0, underOneHourShare: 0}})));
+ renderPage();
+
+ expect(await screen.findByRole('meter', {name: 'STATS.HABITS.UNDER_ONE_HOUR'})).toBeTruthy();
+ expect(screen.queryByRole('button', {name: /STATS\.HABITS\.UNDER_ONE_HOUR/})).toBeNull();
+ });
+
+ it('drawsABarUnderEachMostPlayedGame', async () => {
+ fetchRoutes({
+ 'GET /api/library/stats': json(200, stats()),
+ 'GET /api/users/me': me(),
+ 'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
+ });
+ renderPage();
+
+ const card = (await screen.findByText('STATS.TOP_GAMES.TITLE')).closest('[data-slot="card"]');
+ const [ranked, loyal] = within(card).getAllByRole('link', {name: /Portal 2/});
+
+ expect(ranked.getAttribute('href')).toBe('/library/620');
+ expect(ranked.querySelectorAll('[aria-hidden="true"] > .bg-primary')).toHaveLength(1);
+ expect(loyal.querySelectorAll('[aria-hidden="true"] > .bg-primary')).toHaveLength(0);
+ fireEvent.click(within(card).getByRole('button', {name: 'STATS.DRILL.SHOW_ALL'}));
+ expect(await screen.findByRole('dialog')).toBeTruthy();
+ });
+
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'});
diff --git a/src/style.css b/src/style.css
index 52a0555..b5633b8 100644
--- a/src/style.css
+++ b/src/style.css
@@ -54,6 +54,25 @@
--ring: oklch(0.541 0.281 293.009);
}
+/* Chart palette: the same for every skin and accent; the order keeps neighbor slices color-blind safe. */
+:root {
+ --chart-1: #2a78d6;
+ --chart-2: #eb6834;
+ --chart-3: #199e70;
+ --chart-4: #c98500;
+ --chart-5: #4a3aa7;
+ --chart-6: #e34948;
+}
+
+.dark {
+ --chart-1: #3987e5;
+ --chart-2: #d95926;
+ --chart-3: #199e70;
+ --chart-4: #c98500;
+ --chart-5: #b955b9;
+ --chart-6: #e66767;
+}
+
/*
* Appearance presets (utils/appearance.js). Set as data attributes on ; the settings page also sets them on
* its preview buttons, so each button shows its own preset. Violet repeats the defaults above for that reason.
diff --git a/src/utils/statCharts.js b/src/utils/statCharts.js
index aeaec90..e9f6bb4 100644
--- a/src/utils/statCharts.js
+++ b/src/utils/statCharts.js
@@ -1,4 +1,5 @@
import {apiFetch} from './api.js';
+import {GAME_STATUSES} from './limits.js';
import {dayNumber} from './stats.js';
// The chart forms of each stat group, the first is the default. Auth checks the same list.
@@ -36,6 +37,27 @@ export const topSlices = (items, valueOf, keep = 5) => {
return [...slices.slice(0, keep), {name: null, value: rest, item: null, other: true}];
};
+export const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6)'];
+
+/**
+ * The fill of each slice. The colors come in a fixed order and repeat after the last one; only slices with games
+ * take one. A slice without games gets null, the "other" slice gray.
+ *
+ * @returns `(string|null)[]`, one per slice
+ */
+export const sliceColors = (slices) => {
+ let next = 0;
+ return slices.map((slice) => {
+ if (slice.value <= 0) return null;
+ if (slice.other) return 'var(--muted-foreground)';
+ return SLICE_COLORS[next++ % SLICE_COLORS.length];
+ });
+};
+
+/** One slice per game status in the fixed status order; a status without games stays in, with value 0. */
+export const statusSlices = (byStatus) =>
+ GAME_STATUSES.map((s) => ({name: s, value: byStatus?.[s] ?? 0, item: null, other: false}));
+
const round = (n) => Math.round(n * 1000) / 1000;
/**
diff --git a/src/utils/statCharts.test.js b/src/utils/statCharts.test.js
index 4dc6083..072b134 100644
--- a/src/utils/statCharts.test.js
+++ b/src/utils/statCharts.test.js
@@ -1,5 +1,6 @@
import {describe, expect, it} from 'vitest';
-import {chartOf, linePoints, slicePaths, topSlices, trendBars, yearSeries} from './statCharts.js';
+import {chartOf, linePoints, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js';
+import {GAME_STATUSES} from './limits.js';
describe('chartOf', () => {
it('gives the stored form of the group', () => {
@@ -92,3 +93,38 @@ describe('trendBars', () => {
expect(trendBars([], 100, 40)).toEqual([]);
});
});
+
+describe('sliceColors', () => {
+ const slice = (value, other = false) => ({name: other ? null : 'x', value, item: null, other});
+
+ it('gives the colors in a fixed order', () => {
+ expect(sliceColors([slice(3), slice(2), slice(1)])).toEqual(['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)']);
+ });
+
+ it('gives a slice without games no color and no slot', () => {
+ expect(sliceColors([slice(3), slice(0), slice(1)])).toEqual(['var(--chart-1)', null, 'var(--chart-2)']);
+ });
+
+ it('keeps the other slice gray and out of the slots', () => {
+ expect(sliceColors([slice(3), slice(2, true), slice(1)])).toEqual(['var(--chart-1)', 'var(--muted-foreground)', 'var(--chart-2)']);
+ });
+
+ it('starts again after the sixth color', () => {
+ const colors = sliceColors(Array.from({length: 7}, () => slice(1)));
+ expect(colors[6]).toBe('var(--chart-1)');
+ expect(colors[5]).toBe('var(--chart-6)');
+ });
+});
+
+describe('statusSlices', () => {
+ it('has one slice per status in the fixed order', () => {
+ const slices = statusSlices({PLAYING: 2, BACKLOG: 5});
+ expect(slices.map((s) => s.name)).toEqual(GAME_STATUSES);
+ expect(slices.map((s) => s.value)).toEqual(GAME_STATUSES.map((s) => ({BACKLOG: 5, PLAYING: 2})[s] ?? 0));
+ expect(slices.every((s) => !s.other && s.item === null)).toBe(true);
+ });
+
+ it('gives five empty slices without data', () => {
+ expect(statusSlices(null).map((s) => s.value)).toEqual([0, 0, 0, 0, 0]);
+ });
+});