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

+6 -3
View File
@@ -6,6 +6,7 @@ import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import {StatCount} from './stats/StatDrill.jsx'; import {StatCount} from './stats/StatDrill.jsx';
import StatMeter from './stats/StatMeter.jsx';
/** /**
* @param summary the answer of GET /api/library/achievements/summary * @param summary the answer of GET /api/library/achievements/summary
@@ -20,12 +21,13 @@ export default function AchievementsCard({summary}) {
// A private profile keeps games pending; the privacy hint explains that better than the pending count. // A private profile keeps games pending; the privacy hint explains that better than the pending count.
const steamHidesAll = hidden > 0 && started === 0; const steamHidesAll = hidden > 0 && started === 0;
const average = summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–';
const stats = [ const stats = [
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')], [(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'), [perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
{params: {key: 'PERFECT'}, count: perfect}], {params: {key: 'PERFECT'}, count: perfect}],
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–', [average, t('DASHBOARD.ACHIEVEMENTS.AVERAGE'), undefined,
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')], summary.averageCompletion != null ? {percent: summary.averageCompletion, valueText: average} : null],
]; ];
return ( return (
@@ -36,11 +38,12 @@ export default function AchievementsCard({summary}) {
</h2> </h2>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{stats.map(([value, label, drill]) => ( {stats.map(([value, label, drill, meter]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center"> <div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-xl md:text-2xl font-bold tabular-nums"> <div className="text-xl md:text-2xl font-bold tabular-nums">
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount> <StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
</div> </div>
{meter && <StatMeter {...meter} label={label}/>}
<div className="text-xs text-muted-foreground">{label}</div> <div className="text-xs text-muted-foreground">{label}</div>
</div> </div>
))} ))}
+16
View File
@@ -31,6 +31,22 @@ describe('AchievementsCard', () => {
expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull(); expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull();
}); });
it('showsTheAverageCompletionAsAMeter', () => {
renderCard(summary());
const meter = screen.getByRole('meter', {name: 'DASHBOARD.ACHIEVEMENTS.AVERAGE'});
expect(meter.getAttribute('aria-valuenow')).toBe('71');
expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %');
});
it('showsNoMeterWithoutAnAverage', () => {
renderCard(summary({averageCompletion: null}));
expect(screen.queryByRole('meter')).toBeNull();
expect(screen.getByText('–')).toBeTruthy();
});
it('says how many games are still syncing', () => { it('says how many games are still syncing', () => {
renderCard(summary({pendingGames: 7})); renderCard(summary({pendingGames: 7}));
+13 -13
View File
@@ -4,12 +4,12 @@ import {Dices, Inbox, Shuffle} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx'; import PileTrend from './stats/PileTrend.jsx';
import {StatCount} from './stats/StatDrill.jsx'; import {StatCount} from './stats/StatDrill.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare} from '../utils/backlog.js'; import {backlogShare} from '../utils/backlog.js';
import {GAME_STATUSES} from '../utils/limits.js'; import {sliceColors, statusSlices} from '../utils/statCharts.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {libraryLink} from '../utils/statGames.js'; import {libraryLink} from '../utils/statGames.js';
@@ -20,6 +20,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const backlog = summary.byStatus?.BACKLOG ?? 0; const backlog = summary.byStatus?.BACKLOG ?? 0;
const suggestion = summary.suggestion; const suggestion = summary.suggestion;
const slices = statusSlices(summary.byStatus);
const colors = sliceColors(slices);
if (syncing && summary.total === 0) { if (syncing && summary.total === 0) {
return ( return (
@@ -64,22 +66,20 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
{summary.total > 0 && ( {summary.total > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted"> <div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{GAME_STATUSES.map((s) => ( {slices.map((s, i) => s.value > 0 && (
<div <div key={s.name} className="border-card [&:not(:last-child)]:border-r-2"
key={s} style={{width: `${(s.value / summary.total) * 100}%`, background: colors[i]}}/>
className={STATUS_META[s].bar}
style={{width: `${(summary.byStatus[s] / summary.total) * 100}%`}}
/>
))} ))}
</div> </div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{GAME_STATUSES.map((s) => ( {slices.map(({name: s, value}, i) => (
<li key={s} className="flex items-center gap-1.5"> <li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/> <span className={cn('size-2 rounded-full', !colors[i] && 'bg-muted')}
style={colors[i] ? {background: colors[i]} : undefined}/>
{t(`BACKLOG.STATUS.${s}`)} {t(`BACKLOG.STATUS.${s}`)}
<StatCount to={libraryLink(s)} count={summary.byStatus[s]} <StatCount to={libraryLink(s)} count={value}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}> label={`${t(`BACKLOG.STATUS.${s}`)}: ${value}`}>
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span> <span className="tabular-nums text-foreground">{value}</span>
</StatCount> </StatCount>
</li> </li>
))} ))}
+10
View File
@@ -75,6 +75,16 @@ describe('PileOfShameCard', () => {
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy(); expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
}); });
it('colorsTheStatusBarLikeTheDonutAndSkipsStatusesWithoutGames', () => {
const {container} = renderCard({summary: summary(2)});
const segments = [...container.querySelector('.h-2\\.5').children];
expect(segments).toHaveLength(2);
expect(segments[0].getAttribute('style')).toContain('var(--chart-1)');
expect(segments[1].getAttribute('style')).toContain('var(--chart-2)');
});
it('shows the backlog hours when IGDB knows some', () => { it('shows the backlog hours when IGDB knows some', () => {
renderCard({summary: summary(2, {backlogMinutes: 7440})}); renderCard({summary: summary(2, {backlogMinutes: 7440})});
+12 -2
View File
@@ -6,12 +6,22 @@ import {gameHref} from '../../utils/statGames.js';
/** /**
* A game with its cover and a value on the right; links to the game page when the games are the viewer's own, plain * A game with its cover and a value on the right; links to the game page when the games are the viewer's own, plain
* otherwise. * otherwise.
*
* @param share 0 to 1: a bar under the name, as long as this share
*/ */
export default function GameRow({game, value, linked = true}) { export default function GameRow({game, value, linked = true, share}) {
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
const content = ( const content = (
<> <>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/> <GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span> {share == null ? <span className="@container min-w-0 flex-1 text-sm font-medium">{name}</span> : (
<div className="@container min-w-0 flex-1">
<span className="block text-sm font-medium">{name}</span>
<div aria-hidden="true" className="mt-1.5 h-1.5 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{width: `${Math.max(2, share * 100)}%`}}/>
</div>
</div>
)}
<span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span> <span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span>
</> </>
); );
+4 -3
View File
@@ -12,6 +12,7 @@ export default function HabitsStats({habits}) {
const day = habits.longestDay const day = habits.longestDay
? formatDay(habits.longestDay.day, i18n.language, {dateStyle: 'medium'}) ? formatDay(habits.longestDay.day, i18n.language, {dateStyle: 'medium'})
: null; : null;
const underOneHour = habits.underOneHourShare != null && formatPercent(habits.underOneHourShare, i18n.language);
return ( return (
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}> <StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
@@ -21,9 +22,9 @@ export default function HabitsStats({habits}) {
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)} <StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/> hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/>
)} )}
{habits.underOneHourShare != null && ( {underOneHour && (
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')} <StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')} value={underOneHour}
value={formatPercent(habits.underOneHourShare, i18n.language)} meter={{percent: habits.underOneHourShare * 100, valueText: underOneHour}}
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})} hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/> drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/>
)} )}
+9 -17
View File
@@ -3,37 +3,29 @@ import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx'; import StatTile from './StatTile.jsx';
import {GAME_STATUSES} from '../../utils/limits.js'; import StatusDonut from './StatusDonut.jsx';
import {StatCount} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {libraryLink} from '../../utils/statGames.js'; import {libraryLink} from '../../utils/statGames.js';
import {cn} from '@/lib/utils';
export default function OverviewStats({overview}) { export default function OverviewStats({overview}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const n = (value) => value.toLocaleString(i18n.language); const n = (value) => value.toLocaleString(i18n.language);
const empty = overview.totalGames === 0;
return ( return (
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}> <StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> {!empty && <StatusDonut byStatus={overview.byStatus}/>}
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)} <div className={cn('grid grid-cols-2 gap-3', empty ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/> {empty && (
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/>
)}
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/> <StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)} <StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/> drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/>
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)} <StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/> drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/>
</div> </div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{GAME_STATUSES.map((s) => (
<li key={s}>
{t(`BACKLOG.STATUS.${s}`)}{' '}
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
count={overview.byStatus[s] ?? 0}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
</StatCount>
</li>
))}
</ul>
</StatsSection> </StatsSection>
); );
} }
+37 -24
View File
@@ -1,40 +1,41 @@
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import {DrillRow, useDrillTarget} from './StatDrill.jsx'; import {DrillRow, useDrillTarget} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {slicePaths} from '../../utils/statCharts.js'; import {sliceColors, slicePaths} from '../../utils/statCharts.js';
const RADIUS = 50; const RADIUS = 50;
const HOLE = 30; 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, linkOf) => useDrillTarget({
to: slice.other ? null : linkOf?.(slice.name),
const useSliceTarget = (slice, drillKey, label) => useDrillTarget({
params: slice.other ? null : {key: drillKey, arg: slice.name}, params: slice.other ? null : {key: drillKey, arg: slice.name},
title: slice.other ? '' : label(slice.name), title: slice.other ? '' : label(slice.name),
count: slice.value, count: slice.value,
}); });
function SlicePath({slice, drillKey, label, d, index}) { function SlicePath({slice, drillKey, label, linkOf, d, color}) {
const target = useSliceTarget(slice, drillKey, label); const target = useSliceTarget(slice, drillKey, label, linkOf);
const navigate = useNavigate();
if (!d) return null;
return ( return (
<path d={d} fillRule="evenodd" onClick={target?.onClick} <path d={d} fillRule="evenodd" onClick={target && (target.onClick ?? (() => navigate(target.to)))}
className={`${slice.other ? 'fill-muted-foreground' : 'fill-primary'} stroke-card ${target ? 'cursor-pointer' : ''}`} className={cn('stroke-card', target && 'cursor-pointer')} style={{fill: color}} strokeWidth={1.5}/>
fillOpacity={opacityOf(slice, index)} strokeWidth={1.5}/>
); );
} }
function SliceRow({slice, drillKey, label, index}) { function SliceRow({slice, drillKey, label, linkOf, color}) {
const {t} = useTranslation(); const {t} = useTranslation();
const target = useSliceTarget(slice, drillKey, label); const target = useSliceTarget(slice, drillKey, label, linkOf);
const games = t('STATS.TASTE.GAMES', {count: slice.value}); const games = t('STATS.TASTE.GAMES', {count: slice.value});
return ( return (
<li className="text-sm"> <li className="text-sm">
<DrillRow onSelect={target?.onClick} className="flex items-center gap-2"> <DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2">
<span className={`size-3 shrink-0 rounded-sm ${slice.other ? 'bg-muted-foreground' : 'bg-primary'}`} <span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')}
style={{opacity: opacityOf(slice, index)}}/> style={color ? {background: color} : undefined}/>
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span> <span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span>
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums"> <span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games} {slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
@@ -50,23 +51,35 @@ function SliceRow({slice, drillKey, label, index}) {
* @param slices from `topSlices`, sized by `value` * @param slices from `topSlices`, sized by `value`
* @param drillKey the key of the games request; the slice name is its argument * @param drillKey the key of the games request; the slice name is its argument
* @param label shown name of a slice name * @param label shown name of a slice name
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
* @param center shown in the hole of a donut
* @param note a line under the legend * @param note a line under the legend
*
* A slice without games has a legend row only.
*/ */
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, note}) { export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, center, note}) {
const colors = sliceColors(slices);
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0); const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
return ( return (
<div className="@container space-y-2"> <div className="@container space-y-2">
<div className="flex flex-col items-center gap-4 @sm:flex-row"> <div className="flex flex-col items-center gap-4 @sm:flex-row">
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-40 max-w-40 shrink-0"> <div className="relative size-40 max-w-40 shrink-0">
{slices.map((slice, index) => ( <svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full">
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey} {slices.map((slice, index) => (
label={label} d={paths[index]} index={index}/> <SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
))} label={label} linkOf={linkOf} d={paths[index]} color={colors[index]}/>
</svg> ))}
</svg>
{donut && center && (
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="pointer-events-auto">{center}</div>
</div>
)}
</div>
<ul className="w-full min-w-0 flex-1 space-y-1"> <ul className="w-full min-w-0 flex-1 space-y-1">
{slices.map((slice, index) => ( {slices.map((slice, index) => (
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey} <SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
label={label} index={index}/> label={label} linkOf={linkOf} color={colors[index]}/>
))} ))}
</ul> </ul>
</div> </div>
+6 -6
View File
@@ -60,13 +60,13 @@ export function useTagDrill() {
return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name})); return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name}));
} }
/** A bar row: a button when it has `onSelect`. */ const ROW_CLASS = 'block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring';
export function DrillRow({onSelect, className, children}) {
/** 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 <Link to={to} className={cn(ROW_CLASS, className)}>{children}</Link>;
return onSelect ? ( return onSelect ? (
<button type="button" onClick={onSelect} <button type="button" onClick={onSelect} className={cn(ROW_CLASS, className)}>{children}</button>
className={cn('block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring', className)}>
{children}
</button>
) : <div className={className}>{children}</div>; ) : <div className={className}>{children}</div>;
} }
+17
View File
@@ -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 (
<div role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(clamped)}
aria-valuetext={valueText} aria-hidden={decorative || undefined} className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{width: `${clamped}%`}}/>
</div>
);
}
+27
View File
@@ -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(<StatMeter percent={71.3} label="Share" valueText="71,3 %"/>);
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(<StatMeter percent={130} label="Share" valueText="130 %"/>);
expect(bar().style.width).toBe('100%');
rerender(<StatMeter percent={-5} label="Share" valueText="-5 %"/>);
expect(bar().style.width).toBe('0%');
});
});
+4 -1
View File
@@ -2,18 +2,21 @@ import {Link} from 'react-router-dom';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useDrillTarget} from './StatDrill.jsx'; 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'; 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 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 target = useDrillTarget({title: label, ...drill});
const body = ( const body = (
<> <>
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div> <div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
<div className="text-sm text-muted-foreground">{label}</div> <div className="text-sm text-muted-foreground">{label}</div>
{meter && <StatMeter {...meter} label={label} decorative={!!target}/>}
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>} {hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
</> </>
); );
+32
View File
@@ -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 = (
<div className="text-center">
<StatCount to={libraryLink()} params={totalParams} count={total} title={games} label={`${games}: ${n(total)}`}>
<span className="text-xl font-bold tabular-nums">{n(total)}</span>
</StatCount>
<span className="block text-xs text-muted-foreground">{games}</span>
</div>
);
return (
<SliceChart slices={slices} donut drillKey="STATUS" label={(s) => t(`BACKLOG.STATUS.${s}`)}
linkOf={libraryLink} center={center}/>
);
}
+3 -1
View File
@@ -10,6 +10,7 @@ import {formatPlaytime} from '../../utils/games.js';
export default function TopGamesStats({topGames, linked = true}) { export default function TopGamesStats({topGames, linked = true}) {
const {t} = useTranslation(); const {t} = useTranslation();
const drill = useStatDrill(); const drill = useStatDrill();
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
return ( return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}> <StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
{topGames.mostPlayed.length === 0 ? ( {topGames.mostPlayed.length === 0 ? (
@@ -17,7 +18,8 @@ export default function TopGamesStats({topGames, linked = true}) {
) : ( ) : (
<ol className="space-y-2"> <ol className="space-y-2">
{topGames.mostPlayed.map((g) => ( {topGames.mostPlayed.map((g) => (
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}/></li> <li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}
share={g.minutes / max}/></li>
))} ))}
</ol> </ol>
)} )}
+15 -32
View File
@@ -9,11 +9,10 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.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 {formatDay} from '../utils/format.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js'; import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx'; import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js';
@@ -25,6 +24,8 @@ import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import TileGrid from '../components/TileGrid.jsx'; import TileGrid from '../components/TileGrid.jsx';
import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.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'; import statGroupTile from '../components/stats/statGroupTile.jsx';
// Games per tile size; Core sends at most 8 playing and 12 finished games. // 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 'SUMMARY': return library.totalGames != null && <Summary library={library} compact={size === 'S'}/>;
case 'STATUS': case 'STATUS':
return library.byStatus && Object.values(library.byStatus).some((count) => count > 0) 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': case 'PLAYING':
return library.playing?.length > 0 return library.playing?.length > 0
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>; && <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')], [Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames], [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( stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames], [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 ( return (
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}> <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"> <div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/> <Icon className="mx-auto mb-1 size-4 text-primary"/>
<div className="text-xl md:text-2xl font-bold tabular-nums"> <div className="text-xl md:text-2xl font-bold tabular-nums">
<StatCount params={count > 0 ? {key} : null} title={label} <StatCount params={count > 0 ? {key} : null} title={label}
label={`${label}: ${value}`}>{value}</StatCount> label={`${label}: ${value}`}>{value}</StatCount>
</div> </div>
{meter && <StatMeter {...meter} label={label}/>}
<div className="text-xs text-muted-foreground">{label}</div> <div className="text-xs text-muted-foreground">{label}</div>
</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}) { function Playing({games, compact}) {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
+53 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; 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 PublicProfilePage from './PublicProfilePage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -98,7 +98,7 @@ describe('PublicProfilePage', () => {
expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket'); expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket');
expect(screen.getByText('Lobo')).toBeTruthy(); expect(screen.getByText('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).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(); 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})}}); content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}});
const {container} = renderPage(); 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(); expect(await screen.findByText('Backlogged')).toBeTruthy();
const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games')); 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, { 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})}; 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, 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}}; 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}); mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute});
renderPage(); 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'); expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1');
} finally { } finally {
+126 -3
View File
@@ -114,7 +114,7 @@ describe('StatsPage', () => {
answerStats(json(200, stats())); answerStats(json(200, stats()));
renderPage(); 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'); expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
}); });
@@ -140,11 +140,11 @@ describe('StatsPage', () => {
await screen.findByText('STATS.PILE.GAMES'); 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('button', {name})).toBeNull();
expect(screen.queryByRole('link', {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 () => { it('leavesTheMoonTripsAlone', async () => {
@@ -156,6 +156,129 @@ describe('StatsPage', () => {
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull(); 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', () => { describe('chart forms', () => {
const section = (title) => screen.getByText(title).closest('[data-slot="card"]'); const section = (title) => screen.getByText(title).closest('[data-slot="card"]');
const picker = (title) => within(section(title)).getByRole('group', {name: 'STATS.CHART.LABEL'}); const picker = (title) => within(section(title)).getByRole('group', {name: 'STATS.CHART.LABEL'});
+19
View File
@@ -54,6 +54,25 @@
--ring: oklch(0.541 0.281 293.009); --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 <html>; the settings page also sets them on * Appearance presets (utils/appearance.js). Set as data attributes on <html>; 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. * its preview buttons, so each button shows its own preset. Violet repeats the defaults above for that reason.
+22
View File
@@ -1,4 +1,5 @@
import {apiFetch} from './api.js'; import {apiFetch} from './api.js';
import {GAME_STATUSES} from './limits.js';
import {dayNumber} from './stats.js'; import {dayNumber} from './stats.js';
// The chart forms of each stat group, the first is the default. Auth checks the same list. // 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}]; 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; const round = (n) => Math.round(n * 1000) / 1000;
/** /**
+37 -1
View File
@@ -1,5 +1,6 @@
import {describe, expect, it} from 'vitest'; 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', () => { describe('chartOf', () => {
it('gives the stored form of the group', () => { it('gives the stored form of the group', () => {
@@ -92,3 +93,38 @@ describe('trendBars', () => {
expect(trendBars([], 100, 40)).toEqual([]); 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]);
});
});