feat(stats): status donut, meters and fixed chart colors
Build, Test & Push Frontend / quality-check (push) Successful in 35s
Build, Test & Push Frontend / docker (push) Successful in 22s

Pie and donut charts use one fixed color-blind safe palette in every
skin and accent. The overview and the profile status tile show a status
donut with the total in the middle. Habits and achievements get meter
bars, top games get share bars, the pile of shame bar uses the palette.
This commit is contained in:
lobo committed 2026-10-04 12:03:52 +02:00
1 parent dabb0c941c
commit 8aa3181c37
20 files changed
+468 -110

No files matched your search

+15 -32
View File
@@ -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 && <Summary library={library} compact={size === 'S'}/>;
case 'STATUS':
return library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
&& <StatusBar byStatus={library.byStatus}/>;
&& (
<Card className="gap-4 p-5">
<StatusDonut byStatus={library.byStatus}
totalParams={library.totalGames != null ? {key: 'TOTAL'} : null}/>
</Card>
);
case 'PLAYING':
return library.playing?.length > 0
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
@@ -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 (
<Card className={cn('grid gap-3 p-5', stats.length > 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]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/>
<div className="text-xl md:text-2xl font-bold tabular-nums">
<StatCount params={count > 0 ? {key} : null} title={label}
label={`${label}: ${value}`}>{value}</StatCount>
</div>
{meter && <StatMeter {...meter} label={label}/>}
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
@@ -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 (
<Card className="gap-2 p-5">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{GAME_STATUSES.map((s) => (
<div key={s} className={STATUS_META[s].bar} style={{width: `${((byStatus[s] ?? 0) / total) * 100}%`}}/>
))}
</div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{GAME_STATUSES.map((s) => (
<li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)}
<StatCount params={byStatus[s] > 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}>
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
</StatCount>
</li>
))}
</ul>
</Card>
);
}
function Playing({games, compact}) {
const {t} = useTranslation();
return (
+53 -4
View File
@@ -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 {
+126 -3
View File
@@ -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'});