Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 8aa3181c37 feat(stats): status donut, meters and fixed chart colors
Build, Test & Push Frontend / docker (push) Successful in 22s
Build, Test & Push Frontend / quality-check (push) Successful in 35s
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.
2026-10-04 12:03:52 +02:00
lobo dabb0c941c feat(sync): show sync changes in banner and details dialog 2026-10-04 11:36:09 +02:00
29 changed files with 887 additions and 104 deletions

No files matched your search

+26
View File
@@ -1134,5 +1134,31 @@
},
"UNSAVED_CHANGES": {
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
},
"SYNC_CHANGES": {
"TITLE": "Änderungen im letzten Sync",
"NOTHING": "Im letzten Sync hat sich nichts geändert.",
"IMPORTED_one": "{{count}} Spiel importiert",
"IMPORTED_other": "{{count}} Spiele importiert",
"ADDED_one": "{{count}} neues Spiel",
"ADDED_other": "{{count}} neue Spiele",
"REMOVED_one": "{{count}} Spiel entfernt",
"REMOVED_other": "{{count}} Spiele entfernt",
"PLAYTIME": "{{time}} gespielt",
"ACHIEVEMENTS_one": "{{count}} neuer Erfolg",
"ACHIEVEMENTS_other": "{{count}} neue Erfolge",
"MOVES_one": "{{count}} Statuswechsel",
"MOVES_other": "{{count}} Statuswechsel",
"DETAILS": "Details",
"CLOSE": "Schließen",
"EMPTY": "Keine Änderungen zum Anzeigen.",
"ERROR": "Die Änderungen konnten nicht geladen werden.",
"HEADING": {
"ADDED": "Neue Spiele",
"REMOVED": "Entfernte Spiele",
"PLAYTIME": "Spielzeit",
"ACHIEVEMENTS": "Neue Erfolge",
"STATUS": "Statuswechsel"
}
}
}
+26
View File
@@ -1134,5 +1134,31 @@
},
"UNSAVED_CHANGES": {
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
},
"SYNC_CHANGES": {
"TITLE": "Changes in the last sync",
"NOTHING": "Nothing changed in the last sync.",
"IMPORTED_one": "{{count}} game imported",
"IMPORTED_other": "{{count}} games imported",
"ADDED_one": "{{count}} new game",
"ADDED_other": "{{count}} new games",
"REMOVED_one": "{{count}} game removed",
"REMOVED_other": "{{count}} games removed",
"PLAYTIME": "{{time}} played",
"ACHIEVEMENTS_one": "{{count}} new achievement",
"ACHIEVEMENTS_other": "{{count}} new achievements",
"MOVES_one": "{{count}} status change",
"MOVES_other": "{{count}} status changes",
"DETAILS": "Details",
"CLOSE": "Close",
"EMPTY": "No changes to show.",
"ERROR": "The changes could not be loaded.",
"HEADING": {
"ADDED": "New games",
"REMOVED": "Removed games",
"PLAYTIME": "Playtime",
"ACHIEVEMENTS": "New achievements",
"STATUS": "Status changes"
}
}
}
+6 -3
View File
@@ -6,6 +6,7 @@ import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {StatCount} from './stats/StatDrill.jsx';
import StatMeter from './stats/StatMeter.jsx';
/**
* @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.
const steamHidesAll = hidden > 0 && started === 0;
const average = summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–';
const stats = [
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
{params: {key: 'PERFECT'}, count: perfect}],
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
[average, t('DASHBOARD.ACHIEVEMENTS.AVERAGE'), undefined,
summary.averageCompletion != null ? {percent: summary.averageCompletion, valueText: average} : null],
];
return (
@@ -36,11 +38,12 @@ export default function AchievementsCard({summary}) {
</h2>
<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 className="text-xl md:text-2xl font-bold tabular-nums">
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
</div>
{meter && <StatMeter {...meter} label={label}/>}
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
+16
View File
@@ -31,6 +31,22 @@ describe('AchievementsCard', () => {
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', () => {
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 {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx';
import {StatCount} from './stats/StatDrill.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
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 {libraryLink} from '../utils/statGames.js';
@@ -20,6 +20,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
const {t, i18n} = useTranslation();
const backlog = summary.byStatus?.BACKLOG ?? 0;
const suggestion = summary.suggestion;
const slices = statusSlices(summary.byStatus);
const colors = sliceColors(slices);
if (syncing && summary.total === 0) {
return (
@@ -64,22 +66,20 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
{summary.total > 0 && (
<div className="space-y-2">
<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: `${(summary.byStatus[s] / summary.total) * 100}%`}}
/>
{slices.map((s, i) => s.value > 0 && (
<div key={s.name} className="border-card [&:not(:last-child)]:border-r-2"
style={{width: `${(s.value / summary.total) * 100}%`, background: colors[i]}}/>
))}
</div>
<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">
<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}`)}
<StatCount to={libraryLink(s)} count={summary.byStatus[s]}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
<StatCount to={libraryLink(s)} count={value}
label={`${t(`BACKLOG.STATUS.${s}`)}: ${value}`}>
<span className="tabular-nums text-foreground">{value}</span>
</StatCount>
</li>
))}
+10
View File
@@ -75,6 +75,16 @@ describe('PileOfShameCard', () => {
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', () => {
renderCard({summary: summary(2, {backlogMinutes: 7440})});
+77
View File
@@ -0,0 +1,77 @@
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {X} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import SyncChangesDialog from './SyncChangesDialog.jsx';
import {formatPlaytime} from '../utils/games.js';
import {bannerOf, fetchSyncChanges, markSyncChangesSeen} from '../utils/syncChanges.js';
const keepShown = (prev, body, markedAt) =>
prev && body && !prev.seen && body.seen && body.syncedAt === prev.syncedAt && body.syncedAt === markedAt;
/**
* What the last sync changed, in one line with the details in a dialog. The X marks the result as seen on every
* device. A manual sync without changes says so once.
*
* @param lastSyncedAt the end of the last sync: a new value loads the result again
*/
export default function SyncChangesBanner({lastSyncedAt}) {
const {t} = useTranslation();
const [result, setResult] = useState(null);
const [dismissed, setDismissed] = useState(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const marked = useRef(null);
const banner = result && dismissed !== result.syncedAt ? bannerOf(result) : null;
const nothing = banner?.mode === 'nothing';
useEffect(() => {
let current = true;
// An error, a 204 and a 404 (server without the endpoint) show nothing.
fetchSyncChanges()
// The "nothing changed" line was marked seen at once: its own result, now seen, must not hide it.
.then((body) => current && setResult((prev) => keepShown(prev, body, marked.current) ? prev : body))
.catch(() => undefined);
return () => {
current = false;
};
}, [lastSyncedAt]);
useEffect(() => {
if (!nothing || marked.current === result.syncedAt) return;
marked.current = result.syncedAt;
markSyncChangesSeen(result.syncedAt).catch(() => undefined);
}, [nothing, result]);
if (!banner) return null;
const close = () => {
setDismissed(result.syncedAt);
markSyncChangesSeen(result.syncedAt).catch(() => undefined);
};
const parts = nothing ? [t('SYNC_CHANGES.NOTHING')] : [
banner.imported > 0 && t('SYNC_CHANGES.IMPORTED', {count: banner.imported}),
banner.added > 0 && t('SYNC_CHANGES.ADDED', {count: banner.added}),
banner.removed > 0 && t('SYNC_CHANGES.REMOVED', {count: banner.removed}),
banner.minutes > 0 && t('SYNC_CHANGES.PLAYTIME', {time: formatPlaytime(banner.minutes)}),
banner.achievements > 0 && t('SYNC_CHANGES.ACHIEVEMENTS', {count: banner.achievements}),
banner.moves > 0 && t('SYNC_CHANGES.MOVES', {count: banner.moves}),
].filter(Boolean);
return (
<Card className="flex-row flex-wrap items-center gap-x-3 gap-y-2 p-3 text-sm">
<span className="min-w-0 flex-1">{parts.join(' · ')}</span>
{!nothing && (
<Button variant="outline" size="sm" className="min-h-11" onClick={() => setDetailsOpen(true)}>
{t('SYNC_CHANGES.DETAILS')}
</Button>
)}
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
<X/>
</Button>
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
</Card>
);
}
+125
View File
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import SyncChangesBanner from './SyncChangesBanner.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const SYNCED_AT = '2026-10-04T10:00:00.123456Z';
const CHANGES = '/api/library/sync-changes';
const SEEN = '/api/library/sync-changes/seen';
const result = (extra = {}) => ({syncedAt: SYNCED_AT, startedBy: 'AUTO', seen: false, importedGames: 0, changes: [], ...extra});
const changed = result({
changes: [
{kind: 'ADDED', appid: 11, name: 'New Game'},
{kind: 'PLAYTIME', appid: 22, name: 'Played Game', amount: 90},
{kind: 'STATUS', appid: 22, name: 'Played Game', fromStatus: 'BACKLOG', toStatus: 'PLAYING'},
],
});
const renderBanner = () => render(<MemoryRouter><SyncChangesBanner lastSyncedAt="a"/></MemoryRouter>);
const puts = (mock) => mock.mock.calls.filter(([, init]) => init?.method === 'PUT');
describe('SyncChangesBanner', () => {
afterEach(cleanup);
it('showsTheSummary', async () => {
fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)});
renderBanner();
expect(await screen.findByText('SYNC_CHANGES.ADDED:1 · SYNC_CHANGES.PLAYTIME · SYNC_CHANGES.MOVES:1')).toBeTruthy();
});
it('closesWithXAndMarksTheResultSeen', async () => {
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, changed), [`PUT ${SEEN}`]: json(204, {})});
renderBanner();
await screen.findByText(/SYNC_CHANGES.ADDED/);
fireEvent.click(screen.getByRole('button', {name: 'SYNC_CHANGES.CLOSE'}));
expect(screen.queryByText(/SYNC_CHANGES.ADDED/)).toBeNull();
expect(puts(mock)).toHaveLength(1);
expect(JSON.parse(puts(mock)[0][1].body)).toEqual({syncedAt: SYNCED_AT});
});
it('saysNothingChangedOnceForAManualSyncAndMarksItSeenAtOnce', async () => {
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, result({startedBy: 'MANUAL'})), [`PUT ${SEEN}`]: json(204, {})});
renderBanner();
expect(await screen.findByText('SYNC_CHANGES.NOTHING')).toBeTruthy();
await waitFor(() => expect(puts(mock)).toHaveLength(1));
expect(screen.queryByRole('button', {name: 'SYNC_CHANGES.DETAILS'})).toBeNull();
});
it('nothingChangedStaysWhenLastSyncedAtArrivesLater', async () => {
let seen = false;
const mock = fetchRoutes({
[`GET ${CHANGES}`]: () => json(200, result({startedBy: 'MANUAL', seen})),
[`PUT ${SEEN}`]: () => {
seen = true;
return json(204, {});
},
});
// The dashboard renders before its first sync-status answer: lastSyncedAt is null, then the real value.
const {rerender} = render(<MemoryRouter><SyncChangesBanner lastSyncedAt={null}/></MemoryRouter>);
await screen.findByText('SYNC_CHANGES.NOTHING');
await waitFor(() => expect(puts(mock)).toHaveLength(1));
rerender(<MemoryRouter><SyncChangesBanner lastSyncedAt="a"/></MemoryRouter>);
await waitFor(() => expect(mock.mock.calls.filter(([url]) => url.endsWith(CHANGES))).toHaveLength(2));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.queryByText('SYNC_CHANGES.NOTHING')).toBeTruthy();
});
it('showsNothingForAnEmptyAutomaticSync', async () => {
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, result())});
const {container} = renderBanner();
await waitFor(() => expect(mock).toHaveBeenCalled());
expect(container.textContent).toBe('');
});
it('showsNothingWithoutAResult', async () => {
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(204, {})});
const {container} = renderBanner();
await waitFor(() => expect(mock).toHaveBeenCalled());
expect(container.textContent).toBe('');
});
it('showsNothingWhenCoreHasNoEndpointYet', async () => {
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(404, {})});
const {container} = renderBanner();
await waitFor(() => expect(mock).toHaveBeenCalled());
expect(container.textContent).toBe('');
});
it('listsTheChangesGroupedByKindWithLinksToTheGames', async () => {
fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)});
renderBanner();
fireEvent.click(await screen.findByRole('button', {name: 'SYNC_CHANGES.DETAILS'}));
expect(await screen.findByText('SYNC_CHANGES.HEADING.ADDED')).toBeTruthy();
expect(screen.getByText('SYNC_CHANGES.HEADING.PLAYTIME')).toBeTruthy();
expect(screen.getByText('SYNC_CHANGES.HEADING.STATUS')).toBeTruthy();
expect(screen.queryByText('SYNC_CHANGES.HEADING.REMOVED')).toBeNull();
const link = screen.getAllByRole('link').find((a) => a.getAttribute('href') === '/library/11');
expect(link).toBeTruthy();
expect(screen.getByText('+1.5h')).toBeTruthy();
expect(screen.getByText('BACKLOG.STATUS.BACKLOG → BACKLOG.STATUS.PLAYING')).toBeTruthy();
});
it('loadsTheResultAgainWhenTheDetailsOpen', async () => {
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)});
renderBanner();
fireEvent.click(await screen.findByRole('button', {name: 'SYNC_CHANGES.DETAILS'}));
await screen.findByText('SYNC_CHANGES.HEADING.ADDED');
expect(mock.mock.calls.filter(([url]) => url.endsWith(CHANGES))).toHaveLength(2);
});
});
+66
View File
@@ -0,0 +1,66 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import {Skeleton} from '@/components/ui/skeleton';
import GameRow from './stats/GameRow.jsx';
import {formatPlaytime} from '../utils/games.js';
import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
/**
* The changes of the last sync, grouped by kind. Loads the result again when it opens: the achievements of a sync
* arrive after its games.
*/
export default function SyncChangesDialog({onClose}) {
const {t} = useTranslation();
const [changes, setChanges] = useState(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let current = true;
fetchSyncChanges()
.then((body) => {
if (!current) return;
if (body) setChanges(body.changes ?? []);
else setFailed(true);
})
.catch(() => current && setFailed(true));
return () => {
current = false;
};
}, []);
const value = (change) => {
switch (change.kind) {
case 'PLAYTIME': return `+${formatPlaytime(change.amount)}`;
case 'ACHIEVEMENTS': return `+${change.amount}`;
case 'STATUS': return `${t(`BACKLOG.STATUS.${change.fromStatus}`)} → ${t(`BACKLOG.STATUS.${change.toStatus}`)}`;
default: return null;
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
<DialogTitle className="pr-6">{t('SYNC_CHANGES.TITLE')}</DialogTitle>
<DialogDescription className="sr-only">{t('SYNC_CHANGES.TITLE')}</DialogDescription>
{changes === null && !failed && <Skeleton className="h-24 rounded-xl"/>}
{failed && <p className="text-sm text-muted-foreground">{t('SYNC_CHANGES.ERROR')}</p>}
{changes && changes.length === 0 && <p className="text-sm text-muted-foreground">{t('SYNC_CHANGES.EMPTY')}</p>}
{changes && groupByKind(changes).map(({kind, changes: rows}) => (
<section key={kind} className="space-y-2">
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
<ul className="space-y-2">
{rows.map((change, index) => (
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
<GameRow game={change} value={value(change)} linked/>
</li>
))}
</ul>
</section>
))}
</DialogContent>
</Dialog>
);
}
+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
* 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 = (
<>
<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>
</>
);
+4 -3
View File
@@ -12,6 +12,7 @@ export default function HabitsStats({habits}) {
const day = habits.longestDay
? formatDay(habits.longestDay.day, i18n.language, {dateStyle: 'medium'})
: null;
const underOneHour = habits.underOneHourShare != null && formatPercent(habits.underOneHourShare, i18n.language);
return (
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
<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)}
hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/>
)}
{habits.underOneHourShare != null && (
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
value={formatPercent(habits.underOneHourShare, i18n.language)}
{underOneHour && (
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')} value={underOneHour}
meter={{percent: habits.underOneHourShare * 100, valueText: underOneHour}}
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/>
)}
+7 -15
View File
@@ -3,37 +3,29 @@ import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import {GAME_STATUSES} from '../../utils/limits.js';
import {StatCount} from './StatDrill.jsx';
import StatusDonut from './StatusDonut.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {libraryLink} from '../../utils/statGames.js';
import {cn} from '@/lib/utils';
export default function OverviewStats({overview}) {
const {t, i18n} = useTranslation();
const n = (value) => value.toLocaleString(i18n.language);
const empty = overview.totalGames === 0;
return (
<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}/>}
<div className={cn('grid grid-cols-2 gap-3', empty ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
{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.PLAYED')} value={n(overview.playedGames)}
drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/>
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/>
</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>
);
}
+33 -20
View File
@@ -1,40 +1,41 @@
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import {DrillRow, useDrillTarget} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {slicePaths} from '../../utils/statCharts.js';
import {sliceColors, slicePaths} from '../../utils/statCharts.js';
const RADIUS = 50;
const HOLE = 30;
// Opacity steps of the accent, so every skin and dark mode work.
const OPACITY = [1, 0.8, 0.62, 0.46, 0.32];
const opacityOf = (slice, index) => (slice.other ? 1 : OPACITY[index] ?? 0.2);
const useSliceTarget = (slice, drillKey, label) => useDrillTarget({
const useSliceTarget = (slice, drillKey, label, linkOf) => useDrillTarget({
to: slice.other ? null : linkOf?.(slice.name),
params: slice.other ? null : {key: drillKey, arg: slice.name},
title: slice.other ? '' : label(slice.name),
count: slice.value,
});
function SlicePath({slice, drillKey, label, d, index}) {
const target = useSliceTarget(slice, drillKey, label);
function SlicePath({slice, drillKey, label, linkOf, d, color}) {
const target = useSliceTarget(slice, drillKey, label, linkOf);
const navigate = useNavigate();
if (!d) return null;
return (
<path d={d} fillRule="evenodd" onClick={target?.onClick}
className={`${slice.other ? 'fill-muted-foreground' : 'fill-primary'} stroke-card ${target ? 'cursor-pointer' : ''}`}
fillOpacity={opacityOf(slice, index)} strokeWidth={1.5}/>
<path d={d} fillRule="evenodd" onClick={target && (target.onClick ?? (() => navigate(target.to)))}
className={cn('stroke-card', target && 'cursor-pointer')} style={{fill: color}} strokeWidth={1.5}/>
);
}
function SliceRow({slice, drillKey, label, index}) {
function SliceRow({slice, drillKey, label, linkOf, color}) {
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});
return (
<li className="text-sm">
<DrillRow onSelect={target?.onClick} className="flex items-center gap-2">
<span className={`size-3 shrink-0 rounded-sm ${slice.other ? 'bg-muted-foreground' : 'bg-primary'}`}
style={{opacity: opacityOf(slice, index)}}/>
<DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2">
<span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')}
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="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
{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 drillKey the key of the games request; the slice name is its argument
* @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
*
* 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);
return (
<div className="@container space-y-2">
<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">
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full">
{slices.map((slice, index) => (
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
label={label} d={paths[index]} index={index}/>
label={label} linkOf={linkOf} d={paths[index]} color={colors[index]}/>
))}
</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">
{slices.map((slice, index) => (
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
label={label} index={index}/>
label={label} linkOf={linkOf} color={colors[index]}/>
))}
</ul>
</div>
+6 -6
View File
@@ -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 <Link to={to} className={cn(ROW_CLASS, className)}>{children}</Link>;
return onSelect ? (
<button type="button" onClick={onSelect}
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>
<button type="button" onClick={onSelect} className={cn(ROW_CLASS, className)}>{children}</button>
) : <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 {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 = (
<>
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</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>}
</>
);
+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}) {
const {t} = useTranslation();
const drill = useStatDrill();
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
{topGames.mostPlayed.length === 0 ? (
@@ -17,7 +18,8 @@ export default function TopGamesStats({topGames, linked = true}) {
) : (
<ol className="space-y-2">
{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>
)}
+2
View File
@@ -14,6 +14,7 @@ import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
import MostPlayedCard from '../components/MostPlayedCard.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount';
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
import useDashboardData from '../hooks/useDashboardData.js';
import useLibrarySync from '../hooks/useLibrarySync.js';
@@ -128,6 +129,7 @@ const Dashboard = () => {
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
{hasSteamId && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
+1
View File
@@ -17,6 +17,7 @@ const quietRoutes = {
'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/sync-changes': json(204, {}),
};
// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
+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'});
+19
View File
@@ -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 <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.
+22
View File
@@ -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;
/**
+37 -1
View File
@@ -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]);
});
});
+53
View File
@@ -0,0 +1,53 @@
import {apiFetch} from './api.js';
const CORE = () => import.meta.env.VITE_CORE_API_URL;
// The order of the groups in the details.
const KINDS = ['ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
/**
* `{syncedAt, startedBy, seen, importedGames, changes: [{kind, appid, name, imageUrl, amount, fromStatus, toStatus}]}`,
* or null when the user has no result (204) or the answer is not 200. PLAYTIME: amount in minutes, ACHIEVEMENTS:
* amount is the number of new unlocks.
*/
export async function fetchSyncChanges() {
const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
return response.status === 200 ? response.json() : null;
}
/**
* The result with this `syncedAt` stays seen on every device. A newer result is not touched.
*/
export const markSyncChangesSeen = (syncedAt) =>
apiFetch(`${CORE()}/api/library/sync-changes/seen`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({syncedAt}),
});
/**
* What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or
* `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty
* result of an automatic sync, show nothing.
*/
export function bannerOf(result) {
if (!result || result.seen) return null;
const changes = result.changes ?? [];
const imported = result.importedGames ?? 0;
if (changes.length === 0 && imported === 0) {
return result.startedBy === 'MANUAL' ? {mode: 'nothing'} : null;
}
const of = (kind) => changes.filter((change) => change.kind === kind);
const sum = (kind) => of(kind).reduce((total, change) => total + (change.amount ?? 0), 0);
return {
mode: 'changes', imported, added: of('ADDED').length, removed: of('REMOVED').length,
minutes: sum('PLAYTIME'), achievements: sum('ACHIEVEMENTS'), moves: of('STATUS').length,
};
}
/**
* `[{kind, changes}]` for the kinds that have changes, in the order of KINDS.
*/
export const groupByKind = (changes) => KINDS
.map((kind) => ({kind, changes: changes.filter((change) => change.kind === kind)}))
.filter((group) => group.changes.length > 0);
+49
View File
@@ -0,0 +1,49 @@
import {describe, expect, it} from 'vitest';
import {bannerOf, groupByKind} from './syncChanges.js';
const result = (extra = {}) => ({syncedAt: '2026-10-04T10:00:00Z', startedBy: 'AUTO', seen: false, importedGames: 0, changes: [], ...extra});
describe('bannerOf', () => {
it('showsNothingWithoutAResult', () => {
expect(bannerOf(null)).toBeNull();
});
it('showsNothingForASeenResult', () => {
expect(bannerOf(result({seen: true, changes: [{kind: 'ADDED', appid: 1}]}))).toBeNull();
});
it('showsNothingForAnEmptyAutomaticSync', () => {
expect(bannerOf(result())).toBeNull();
});
it('saysNothingChangedForAnEmptyManualSync', () => {
expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'});
});
it('countsTheGamesOfAnImport', () => {
expect(bannerOf(result({importedGames: 12}))).toMatchObject({mode: 'changes', imported: 12, added: 0});
});
it('sumsMinutesAndUnlocksAndCountsTheRest', () => {
const changes = [
{kind: 'ADDED', appid: 1}, {kind: 'ADDED', appid: 2}, {kind: 'REMOVED', appid: 3},
{kind: 'PLAYTIME', appid: 4, amount: 30}, {kind: 'PLAYTIME', appid: 5, amount: 45},
{kind: 'ACHIEVEMENTS', appid: 4, amount: 2}, {kind: 'ACHIEVEMENTS', appid: 5, amount: 3},
{kind: 'STATUS', appid: 5, fromStatus: 'BACKLOG', toStatus: 'PLAYING'},
];
expect(bannerOf(result({changes}))).toEqual(
{mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1});
});
});
describe('groupByKind', () => {
it('putsTheGroupsInTheFixedOrderAndLeavesOutEmptyOnes', () => {
const changes = [{kind: 'STATUS', appid: 1}, {kind: 'ADDED', appid: 2}, {kind: 'PLAYTIME', appid: 3}, {kind: 'ADDED', appid: 4}];
const groups = groupByKind(changes);
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]);
});
});