Public Access
feat(stats): status donut, meters and fixed chart colors
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:
1 parent
dabb0c941c
commit
8aa3181c37
20 files changed
+468
-110
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'});
|
||||
|
||||
Reference in new issue
Block a user