Public Access
Compare commits
2
Commits
777ba87fd6
...
e054e13a59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e054e13a59 | ||
|
|
8b94a25844 |
No files matched your search
+10
-1
@@ -560,6 +560,16 @@
|
|||||||
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
|
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
|
||||||
"VISUAL_NOVEL": "Visual Novel"
|
"VISUAL_NOVEL": "Visual Novel"
|
||||||
},
|
},
|
||||||
|
"CHART": {
|
||||||
|
"LABEL": "Diagramm",
|
||||||
|
"BARS": "Balken",
|
||||||
|
"PIE": "Torte",
|
||||||
|
"DONUT": "Ring",
|
||||||
|
"LINE": "Linie",
|
||||||
|
"LIST": "Liste",
|
||||||
|
"OTHER": "Andere",
|
||||||
|
"OVERLAP": "Anteil der Spiele, ein Spiel kann mehrfach zählen"
|
||||||
|
},
|
||||||
"DRILL": {
|
"DRILL": {
|
||||||
"SHOW_ALL": "Alle anzeigen",
|
"SHOW_ALL": "Alle anzeigen",
|
||||||
"LOAD_MORE": "Mehr laden",
|
"LOAD_MORE": "Mehr laden",
|
||||||
@@ -575,7 +585,6 @@
|
|||||||
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
||||||
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
||||||
"NEW_IN_LIBRARY": "Neu",
|
"NEW_IN_LIBRARY": "Neu",
|
||||||
"OPEN": "Im Steam-Shop öffnen",
|
|
||||||
"GAMES_one": "{{count}} Spiel",
|
"GAMES_one": "{{count}} Spiel",
|
||||||
"GAMES_other": "{{count}} Spiele"
|
"GAMES_other": "{{count}} Spiele"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -560,6 +560,16 @@
|
|||||||
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
|
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
|
||||||
"VISUAL_NOVEL": "Visual novel"
|
"VISUAL_NOVEL": "Visual novel"
|
||||||
},
|
},
|
||||||
|
"CHART": {
|
||||||
|
"LABEL": "Chart",
|
||||||
|
"BARS": "Bars",
|
||||||
|
"PIE": "Pie",
|
||||||
|
"DONUT": "Donut",
|
||||||
|
"LINE": "Line",
|
||||||
|
"LIST": "List",
|
||||||
|
"OTHER": "Other",
|
||||||
|
"OVERLAP": "Share of games, a game can count more than once"
|
||||||
|
},
|
||||||
"DRILL": {
|
"DRILL": {
|
||||||
"SHOW_ALL": "Show all",
|
"SHOW_ALL": "Show all",
|
||||||
"LOAD_MORE": "Load more",
|
"LOAD_MORE": "Load more",
|
||||||
@@ -575,7 +585,6 @@
|
|||||||
"TREND_ADDED": "Added to the pile this month",
|
"TREND_ADDED": "Added to the pile this month",
|
||||||
"TREND_DONE": "Off the pile this month",
|
"TREND_DONE": "Off the pile this month",
|
||||||
"NEW_IN_LIBRARY": "New",
|
"NEW_IN_LIBRARY": "New",
|
||||||
"OPEN": "Open in the Steam store",
|
|
||||||
"GAMES_one": "{{count}} game",
|
"GAMES_one": "{{count}} game",
|
||||||
"GAMES_other": "{{count}} games"
|
"GAMES_other": "{{count}} games"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {Trophy} from 'lucide-react';
|
|||||||
import {Card} from '@/components/ui/card';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param summary the answer of GET /api/library/achievements/summary
|
* @param summary the answer of GET /api/library/achievements/summary
|
||||||
@@ -15,12 +16,14 @@ export default function AchievementsCard({summary}) {
|
|||||||
const started = summary.startedGames ?? 0;
|
const started = summary.startedGames ?? 0;
|
||||||
const pending = summary.pendingGames ?? 0;
|
const pending = summary.pendingGames ?? 0;
|
||||||
const hidden = summary.hiddenGames ?? 0;
|
const hidden = summary.hiddenGames ?? 0;
|
||||||
|
const perfect = summary.perfectGames ?? 0;
|
||||||
// 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 stats = [
|
const stats = [
|
||||||
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
||||||
[(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')],
|
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
|
||||||
|
{params: {key: 'PERFECT'}, count: perfect}],
|
||||||
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
||||||
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
||||||
];
|
];
|
||||||
@@ -33,9 +36,11 @@ 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]) => (
|
{stats.map(([value, label, drill]) => (
|
||||||
<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">{value}</div>
|
<div className="text-xl md:text-2xl font-bold tabular-nums">
|
||||||
|
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, render, screen} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import AchievementsCard from './AchievementsCard.jsx';
|
import AchievementsCard from './AchievementsCard.jsx';
|
||||||
|
import {StatDrillProvider} from './stats/StatDrill.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
@@ -49,4 +51,22 @@ describe('AchievementsCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opensThePerfectGamesInTheOwnLibrary', async () => {
|
||||||
|
fetchRoutes({'GET /api/library/stats/games': json(200, {
|
||||||
|
content: [{appid: -7, name: 'Manual Game'}], page: 0, size: 40, totalElements: 1, totalPages: 1})});
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary()}/></StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.ACHIEVEMENTS.PERFECT: 2'}));
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: /Manual Game/});
|
||||||
|
expect(fetch.mock.calls[0][0]).toContain('key=PERFECT&page=0');
|
||||||
|
expect(link.getAttribute('href')).toBe('/library/-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('perfectGamesAreNotClickableAtZero', () => {
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary({perfectGames: 0})}/></StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -36,7 +36,7 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}}
|
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}} count={backlog}
|
||||||
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
|
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
|
||||||
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
@@ -77,7 +77,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
|||||||
<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={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||||
{t(`BACKLOG.STATUS.${s}`)}
|
{t(`BACKLOG.STATUS.${s}`)}
|
||||||
<StatCount to={libraryLink(s)} label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[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>
|
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ describe('PileOfShameCard', () => {
|
|||||||
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeroCountsAreNotClickable', () => {
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own">
|
||||||
|
<PileOfShameCard onShuffle={() => {}} summary={summary(0)}/>
|
||||||
|
</StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 0'})).toBeNull();
|
||||||
|
expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.FINISHED: 0'})).toBeNull();
|
||||||
|
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
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})});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
|
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
|
||||||
const {i18n} = useTranslation();
|
const {i18n} = useTranslation();
|
||||||
const animated = useCountUp(value);
|
const animated = useCountUp(value);
|
||||||
const target = useDrillTarget({title: label, ...drill});
|
const target = useDrillTarget({title: label, count: value, ...drill});
|
||||||
const card = (
|
const card = (
|
||||||
<Card className="gap-3 p-5">
|
<Card className="gap-3 p-5">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
|
||||||
const DURATION_MS = 1000;
|
const DURATION_MS = 1000;
|
||||||
|
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
||||||
|
|
||||||
const SteamGameCount = ({targetCount}) => {
|
const SteamGameCount = ({targetCount}) => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -33,7 +34,9 @@ const SteamGameCount = ({targetCount}) => {
|
|||||||
<Gamepad2 className="size-5"/>
|
<Gamepad2 className="size-5"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Link to="/library" className="block text-5xl font-bold tracking-tight tabular-nums hover:text-primary">{count}</Link>
|
{targetCount > 0
|
||||||
|
? <Link to="/library" className={`${COUNT_CLASS} hover:text-primary`}>{count}</Link>
|
||||||
|
: <span className={COUNT_CLASS}>{count}</span>}
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
|
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import SteamGameCount from './SteamGameCount.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
describe('SteamGameCount', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
||||||
|
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
||||||
|
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(1);
|
||||||
|
|
||||||
|
rerender(<MemoryRouter><SteamGameCount targetCount={5}/></MemoryRouter>);
|
||||||
|
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
||||||
|
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {STAT_CHARTS} from '../../utils/statCharts.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chart forms of a stat group as buttons.
|
||||||
|
*
|
||||||
|
* @param value the shown form
|
||||||
|
* @param onChange `(form)`
|
||||||
|
*/
|
||||||
|
export default function ChartPicker({group, value, onChange}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={t('STATS.CHART.LABEL')} className="flex flex-wrap">
|
||||||
|
{STAT_CHARTS[group].map((form) => (
|
||||||
|
<Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm"
|
||||||
|
className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form}
|
||||||
|
onClick={() => onChange(form)}>
|
||||||
|
{t(`STATS.CHART.${form}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {Flag} from 'lucide-react';
|
import {Flag} from 'lucide-react';
|
||||||
|
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
|
import ChartPicker from './ChartPicker.jsx';
|
||||||
|
import YearLine from './YearLine.jsx';
|
||||||
import {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx';
|
import {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param onSelect `(year)`: the rows are buttons
|
* @param onSelect `(year)`: the rows are buttons
|
||||||
|
* @param bars false: a plain list, without the bars
|
||||||
*/
|
*/
|
||||||
export function YearBars({perYear, onSelect}) {
|
export function YearBars({perYear, onSelect, bars = true}) {
|
||||||
const max = Math.max(1, ...perYear.map((y) => y.games));
|
const max = Math.max(1, ...perYear.map((y) => y.games));
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -15,10 +18,12 @@ export function YearBars({perYear, onSelect}) {
|
|||||||
<li key={y.year} className="text-sm">
|
<li key={y.year} className="text-sm">
|
||||||
<DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
|
<DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
|
||||||
<span className="w-12 tabular-nums">{y.year}</span>
|
<span className="w-12 tabular-nums">{y.year}</span>
|
||||||
|
{bars ? (
|
||||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||||
<div className="h-full rounded-full bg-primary"
|
<div className="h-full rounded-full bg-primary"
|
||||||
style={{width: `${(y.games / max) * 100}%`}}/>
|
style={{width: `${(y.games / max) * 100}%`}}/>
|
||||||
</div>
|
</div>
|
||||||
|
) : <span className="flex-1"/>}
|
||||||
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
||||||
</DrillRow>
|
</DrillRow>
|
||||||
</li>
|
</li>
|
||||||
@@ -27,16 +32,24 @@ export function YearBars({perYear, onSelect}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedStats({finished}) {
|
/**
|
||||||
|
* @param chart BARS, LINE or LIST; a line needs two years, fewer draw the bars
|
||||||
|
* @param onChart `(form)`: shows the picker
|
||||||
|
*/
|
||||||
|
export default function FinishedStats({finished, chart = 'BARS', onChart}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const drill = useStatDrill();
|
const drill = useStatDrill();
|
||||||
|
const title = t('STATS.FINISHED.TITLE');
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
|
<StatsSection title={title} icon={Flag}
|
||||||
|
action={onChart && <ChartPicker group="FINISHED" value={chart} onChange={onChart}/>}>
|
||||||
{finished.perYear.length === 0 ? (
|
{finished.perYear.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
|
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
|
||||||
|
) : chart === 'LINE' && finished.perYear.length >= 2 ? (
|
||||||
|
<YearLine perYear={finished.perYear} title={title}/>
|
||||||
) : (
|
) : (
|
||||||
<YearBars perYear={finished.perYear}
|
<YearBars perYear={finished.perYear} bars={chart !== 'LIST'}
|
||||||
onSelect={drill && ((year) => drill.open(`${t('STATS.FINISHED.TITLE')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
onSelect={drill && ((year) => drill.open(`${title}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||||
)}
|
)}
|
||||||
{finished.finishedBeforeTracking > 0 && (
|
{finished.finishedBeforeTracking > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import GameCover from '../GameCover.jsx';
|
import GameCover from '../GameCover.jsx';
|
||||||
import FitText from '../FitText.jsx';
|
import FitText from '../FitText.jsx';
|
||||||
import {gameHref} from '../../utils/statGames.js';
|
import {gameHref} from '../../utils/statGames.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A game with its cover and a value on the right; links to the game page in the user's own library, to the Steam
|
* 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
|
||||||
* store for a visitor.
|
* otherwise.
|
||||||
*/
|
*/
|
||||||
export default function GameRow({game, value, linked = true}) {
|
export default function GameRow({game, value, linked = true}) {
|
||||||
const {t} = useTranslation();
|
|
||||||
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"/>
|
||||||
@@ -17,14 +15,11 @@ export default function GameRow({game, value, linked = true}) {
|
|||||||
<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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const className = 'flex items-center gap-3 hover:text-primary transition-colors';
|
if (linked) {
|
||||||
if (linked) return <Link to={gameHref(game.appid, true)} className={className}>{content}</Link>;
|
|
||||||
const href = gameHref(game.appid, false);
|
|
||||||
if (href) {
|
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" title={t('STATS.DRILL.OPEN')} className={className}>
|
<Link to={gameHref(game.appid)} className="flex items-center gap-3 hover:text-primary transition-colors">
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div className="flex items-center gap-3">{content}</div>;
|
return <div className="flex items-center gap-3">{content}</div>;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function HabitsStats({habits}) {
|
|||||||
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
||||||
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
||||||
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'}}}/>
|
drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
|
|||||||
@@ -15,18 +15,19 @@ export default function OverviewStats({overview}) {
|
|||||||
<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">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
||||||
drill={{to: libraryLink(), params: {key: 'TOTAL'}}}/>
|
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'}}}/>
|
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'}}}/>
|
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">
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
{GAME_STATUSES.map((s) => (
|
{GAME_STATUSES.map((s) => (
|
||||||
<li key={s}>
|
<li key={s}>
|
||||||
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
||||||
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={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)}`}>
|
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
|
||||||
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export default function PileStats({pile, linked = true}) {
|
|||||||
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}
|
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}
|
||||||
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}}}/>
|
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}, count: pile.backlogGames}}/>
|
||||||
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
||||||
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}
|
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}
|
||||||
drill={{params: {key: 'PILE_TIME_TO_BEAT'}}}/>
|
drill={{params: {key: 'PILE_TIME_TO_BEAT'}, count: pile.gamesWithTimeToBeat}}/>
|
||||||
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
||||||
</div>
|
</div>
|
||||||
{pile.normalMinutes > 0 && (
|
{pile.normalMinutes > 0 && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {StatCount} from './StatDrill.jsx';
|
import {StatCount} from './StatDrill.jsx';
|
||||||
import {formatDay} from '../../utils/format.js';
|
import {formatDay} from '../../utils/format.js';
|
||||||
import {trendPoints} from '../../utils/stats.js';
|
import {trendPoints} from '../../utils/stats.js';
|
||||||
|
import {trendBars} from '../../utils/statCharts.js';
|
||||||
|
|
||||||
const WIDTH = 300;
|
const WIDTH = 300;
|
||||||
const HEIGHT = 60;
|
const HEIGHT = 60;
|
||||||
@@ -10,8 +11,9 @@ const HEIGHT = 60;
|
|||||||
* The pile size over the last 90 days and the moves of this month.
|
* The pile size over the last 90 days and the moves of this month.
|
||||||
*
|
*
|
||||||
* @param trend the TREND group of GET /api/library/stats
|
* @param trend the TREND group of GET /api/library/stats
|
||||||
|
* @param chart LINE or BARS; both are one click target, the days are not
|
||||||
*/
|
*/
|
||||||
export default function PileTrend({trend}) {
|
export default function PileTrend({trend, chart = 'LINE'}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {days, thisMonth} = trend;
|
const {days, thisMonth} = trend;
|
||||||
return (
|
return (
|
||||||
@@ -32,10 +34,24 @@ export default function PileTrend({trend}) {
|
|||||||
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
|
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
|
||||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
||||||
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
||||||
|
{chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
|
||||||
|
<rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className="fill-rose-500"/>
|
||||||
|
)) : (
|
||||||
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
||||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
|
<table className="sr-only">
|
||||||
|
<tbody>
|
||||||
|
{days.map((d) => (
|
||||||
|
<tr key={d.day}>
|
||||||
|
<td>{formatDay(d.day, i18n.language)}</td>
|
||||||
|
<td>{d.backlogGames}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}</span>
|
<span>{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}</span>
|
||||||
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}</span>
|
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}</span>
|
||||||
|
|||||||
@@ -2,18 +2,30 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {MonitorSmartphone} from 'lucide-react';
|
import {MonitorSmartphone} from 'lucide-react';
|
||||||
|
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
|
import ChartPicker from './ChartPicker.jsx';
|
||||||
|
import SliceChart from './SliceChart.jsx';
|
||||||
import {TagBars} from './TasteStats.jsx';
|
import {TagBars} from './TasteStats.jsx';
|
||||||
import {useTagDrill} from './StatDrill.jsx';
|
import {useTagDrill} from './StatDrill.jsx';
|
||||||
|
import {topSlices} from '../../utils/statCharts.js';
|
||||||
|
|
||||||
export default function PlatformStats({platforms}) {
|
/**
|
||||||
|
* @param chart BARS, PIE, DONUT or LIST
|
||||||
|
* @param onChart `(form)`: shows the picker
|
||||||
|
*/
|
||||||
|
export default function PlatformStats({platforms, chart = 'BARS', onChart}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const select = useTagDrill();
|
const select = useTagDrill();
|
||||||
|
const slices = chart === 'PIE' || chart === 'DONUT';
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
|
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}
|
||||||
|
action={onChart && <ChartPicker group="PLATFORMS" value={chart} onChange={onChart}/>}>
|
||||||
{platforms.perPlatform.length === 0 ? (
|
{platforms.perPlatform.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
|
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
|
||||||
|
) : slices ? (
|
||||||
|
<SliceChart slices={topSlices(platforms.perPlatform, (p) => p.games)} donut={chart === 'DONUT'}
|
||||||
|
drillKey="PLATFORM"/>
|
||||||
) : (
|
) : (
|
||||||
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')}/>
|
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')} bars={chart !== 'LIST'}/>
|
||||||
)}
|
)}
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {DrillRow, useDrillTarget} from './StatDrill.jsx';
|
||||||
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
|
import {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({
|
||||||
|
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);
|
||||||
|
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}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SliceRow({slice, drillKey, label, index}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const target = useSliceTarget(slice, drillKey, label);
|
||||||
|
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)}}/>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
</DrillRow>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pie or donut with a legend. The legend rows do what the slices do, so the keyboard reaches every slice.
|
||||||
|
*
|
||||||
|
* @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 note a line under the legend
|
||||||
|
*/
|
||||||
|
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, note}) {
|
||||||
|
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">
|
||||||
|
{slices.map((slice, index) => (
|
||||||
|
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
|
||||||
|
label={label} d={paths[index]} index={index}/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<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}/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,9 @@ const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max
|
|||||||
*
|
*
|
||||||
* @param source own, profile, community or points
|
* @param source own, profile, community or points
|
||||||
* @param ticket profile: the ticket for the visitor's requests
|
* @param ticket profile: the ticket for the visitor's requests
|
||||||
|
* @param linked the games are the viewer's own: rows link to the detail page
|
||||||
*/
|
*/
|
||||||
export function StatDrillProvider({source, ticket, children}) {
|
export function StatDrillProvider({source, ticket, linked = false, children}) {
|
||||||
const [request, setRequest] = useState(null);
|
const [request, setRequest] = useState(null);
|
||||||
const opener = useRef(null);
|
const opener = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,9 +27,9 @@ export function StatDrillProvider({source, ticket, children}) {
|
|||||||
source,
|
source,
|
||||||
open: (title, params) => {
|
open: (title, params) => {
|
||||||
opener.current = document.activeElement;
|
opener.current = document.activeElement;
|
||||||
setRequest({title, source, ticket, params});
|
setRequest({title, source, ticket, linked, params});
|
||||||
},
|
},
|
||||||
}), [source, ticket]);
|
}), [source, ticket, linked]);
|
||||||
return (
|
return (
|
||||||
<StatDrillContext.Provider value={value}>
|
<StatDrillContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
@@ -43,9 +44,11 @@ export const useStatDrill = () => useContext(StatDrillContext);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
|
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
|
||||||
|
* Nothing at `count` 0; `count` undefined means no count is known.
|
||||||
*/
|
*/
|
||||||
export function useDrillTarget({to, params, title}) {
|
export function useDrillTarget({to, params, title, count}) {
|
||||||
const drill = useStatDrill();
|
const drill = useStatDrill();
|
||||||
|
if (count === 0) return null;
|
||||||
if (to && drill?.source === 'own') return {to};
|
if (to && drill?.source === 'own') return {to};
|
||||||
if (drill && params) return {onClick: () => drill.open(title, params)};
|
if (drill && params) return {onClick: () => drill.open(title, params)};
|
||||||
return null;
|
return null;
|
||||||
@@ -80,9 +83,10 @@ export function DrillButton({onClick, label, className, children}) {
|
|||||||
*
|
*
|
||||||
* @param to library URL, used instead of the dialog in the user's own views
|
* @param to library URL, used instead of the dialog in the user's own views
|
||||||
* @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request
|
* @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request
|
||||||
|
* @param count the number shown; 0 is never clickable
|
||||||
*/
|
*/
|
||||||
export function StatCount({to, params, title, label, className, children}) {
|
export function StatCount({to, params, title, label, count, className, children}) {
|
||||||
const target = useDrillTarget({to, params, title});
|
const target = useDrillTarget({to, params, title, count});
|
||||||
if (!target) return children;
|
if (!target) return children;
|
||||||
return target.to
|
return target.to
|
||||||
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
|
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {StatCount, StatDrillProvider} from './StatDrill.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const renderCount = (count) => render(
|
||||||
|
<MemoryRouter><StatDrillProvider source="own">
|
||||||
|
<StatCount params={{key: 'PLAYED'}} count={count}>{count}</StatCount>
|
||||||
|
</StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
describe('StatCount', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('aZeroCountIsNotClickable', () => {
|
||||||
|
renderCount(0);
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
renderCount(3);
|
||||||
|
expect(screen.getByRole('button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
|
|||||||
/**
|
/**
|
||||||
* The games behind one number, 40 per page.
|
* The games behind one number, 40 per page.
|
||||||
*
|
*
|
||||||
* @param request `{title, source, ticket, params}` or null (closed)
|
* @param request `{title, source, ticket, linked, params}` or null (closed)
|
||||||
*/
|
*/
|
||||||
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
@@ -75,7 +75,7 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{state.games.map((game, index) => (
|
{state.games.map((game, index) => (
|
||||||
<li key={`${game.appid}-${index}`}>
|
<li key={`${game.appid}-${index}`}>
|
||||||
<GameRow game={game} value={value(game)} linked={request.source === 'own'}/>
|
<GameRow game={game} value={value(game)} linked={request.source === 'own' || request.linked === true}/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -128,4 +128,31 @@ describe('StatGamesDialog', () => {
|
|||||||
expect(screen.queryByText('Old game')).toBeNull();
|
expect(screen.queryByText('Old game')).toBeNull();
|
||||||
expect(screen.getByText('New game')).toBeTruthy();
|
expect(screen.getByText('New game')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aProfileRowIsPlainForAVisitor', async () => {
|
||||||
|
fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)});
|
||||||
|
renderDialog({source: 'profile', ticket: 't'});
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aProfileRowOpensTheDetailPageForTheOwner', async () => {
|
||||||
|
fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)});
|
||||||
|
renderDialog({source: 'profile', ticket: 't', linked: true});
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: /Game 1/});
|
||||||
|
|
||||||
|
expect(link.getAttribute('href')).toBe('/library/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aCommunityRowIsPlain', async () => {
|
||||||
|
fetchRoutes({'GET /api/stats/public/games': page(rows(1, 1, {libraries: 2, minutes: 60}), 0, 1, 1)});
|
||||||
|
renderDialog({source: 'community'});
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
|
||||||
export default function StatsSection({title, icon: Icon, children}) {
|
/**
|
||||||
|
* @param action shown at the right of the title, e.g. a ChartPicker
|
||||||
|
*/
|
||||||
|
export default function StatsSection({title, icon: Icon, action, children}) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-4 p-5">
|
<Card className="gap-4 p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="flex items-center gap-2 font-semibold">
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
<Icon className="size-4 text-primary"/>
|
<Icon className="size-4 text-primary"/>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
{action && <div className="ml-auto font-normal">{action}</div>}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,31 +2,37 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {Palette} from 'lucide-react';
|
import {Palette} from 'lucide-react';
|
||||||
|
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
|
import ChartPicker from './ChartPicker.jsx';
|
||||||
|
import SliceChart from './SliceChart.jsx';
|
||||||
import {DrillRow, useTagDrill} from './StatDrill.jsx';
|
import {DrillRow, useTagDrill} from './StatDrill.jsx';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
import {genreKey} from '../../utils/stats.js';
|
import {genreKey} from '../../utils/stats.js';
|
||||||
|
import {topSlices} from '../../utils/statCharts.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param onSelect `(tag, shownLabel)`: the rows are buttons
|
* @param onSelect `(tag, shownLabel)`: the rows are buttons, except a row with 0 games
|
||||||
|
* @param bars false: a plain list, without the bars
|
||||||
*/
|
*/
|
||||||
export function TagBars({tags, label = (name) => name, onSelect}) {
|
export function TagBars({tags, label = (name) => name, onSelect, bars = true}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const max = Math.max(1, ...tags.map((tag) => tag.minutes));
|
const max = Math.max(1, ...tags.map((tag) => tag.minutes));
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<li key={tag.name} className="text-sm">
|
<li key={tag.name} className="text-sm">
|
||||||
<DrillRow onSelect={onSelect && (() => onSelect(tag, label(tag.name)))}>
|
<DrillRow onSelect={onSelect && tag.games > 0 && (() => onSelect(tag, label(tag.name)))}>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span className="truncate">{label(tag.name)}</span>
|
<span className="truncate">{label(tag.name)}</span>
|
||||||
<span className="text-muted-foreground tabular-nums">
|
<span className="text-muted-foreground tabular-nums">
|
||||||
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
|
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{bars && (
|
||||||
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||||
<div className="h-full rounded-full bg-primary"
|
<div className="h-full rounded-full bg-primary"
|
||||||
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</DrillRow>
|
</DrillRow>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -34,27 +40,41 @@ export function TagBars({tags, label = (name) => name, onSelect}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TasteStats({taste}) {
|
/**
|
||||||
|
* @param chart BARS, PIE, DONUT or LIST; the pie and donut size a slice by games, and a game can count in several
|
||||||
|
* @param onChart `(form)`: shows the picker
|
||||||
|
*/
|
||||||
|
export default function TasteStats({taste, chart = 'BARS', onChart}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const select = useTagDrill();
|
const select = useTagDrill();
|
||||||
|
const action = onChart && <ChartPicker group="TASTE" value={chart} onChange={onChart}/>;
|
||||||
if (taste.genres.length === 0 && taste.moods.length === 0) {
|
if (taste.genres.length === 0 && taste.moods.length === 0) {
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette} action={action}>
|
||||||
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
|
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const tagChart = (tags, drillKey, label) => {
|
||||||
|
if (chart !== 'PIE' && chart !== 'DONUT') {
|
||||||
|
return <TagBars tags={tags} onSelect={select(drillKey)} label={label} bars={chart !== 'LIST'}/>;
|
||||||
|
}
|
||||||
|
const slices = topSlices(tags, (tag) => tag.games);
|
||||||
|
return slices.length > 0 && (
|
||||||
|
<SliceChart slices={slices} donut={chart === 'DONUT'} drillKey={drillKey} label={label}
|
||||||
|
note={t('STATS.CHART.OVERLAP')}/>
|
||||||
|
);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette} action={action}>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span>
|
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span>
|
||||||
<TagBars tags={taste.genres} onSelect={select('GENRE')}
|
{tagChart(taste.genres, 'GENRE', (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name}))}
|
||||||
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span>
|
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span>
|
||||||
<TagBars tags={taste.moods} onSelect={select('MOOD')} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
|
{tagChart(taste.moods, 'MOOD', (name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name}))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {TrendingDown} from 'lucide-react';
|
import {TrendingDown} from 'lucide-react';
|
||||||
|
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
|
import ChartPicker from './ChartPicker.jsx';
|
||||||
import PileTrend from './PileTrend.jsx';
|
import PileTrend from './PileTrend.jsx';
|
||||||
|
|
||||||
export default function TrendStats({trend}) {
|
/**
|
||||||
|
* @param chart LINE or BARS
|
||||||
|
* @param onChart `(form)`: shows the picker
|
||||||
|
*/
|
||||||
|
export default function TrendStats({trend, chart = 'LINE', onChart}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}>
|
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}
|
||||||
<PileTrend trend={trend}/>
|
action={onChart && <ChartPicker group="TREND" value={chart} onChange={onChart}/>}>
|
||||||
|
<PileTrend trend={trend} chart={chart}/>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {StatCount, useDrillTarget} from './StatDrill.jsx';
|
||||||
|
import {linePoints, yearSeries} from '../../utils/statCharts.js';
|
||||||
|
|
||||||
|
const WIDTH = 300;
|
||||||
|
const HEIGHT = 80;
|
||||||
|
|
||||||
|
function YearPoint({point, year, games, title}) {
|
||||||
|
const target = useDrillTarget({params: {key: 'FINISHED_YEAR', arg: year}, title: `${title}: ${year}`, count: games});
|
||||||
|
return (
|
||||||
|
<circle cx={point.x} cy={point.y} r={4} onClick={target?.onClick}
|
||||||
|
className={`fill-primary stroke-card ${target ? 'cursor-pointer' : ''}`} strokeWidth={1.5}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finished games per year as a line. The year labels under it do what the points do.
|
||||||
|
*
|
||||||
|
* @param perYear `[{year, games}]`
|
||||||
|
* @param title the group title, the start of the dialog title
|
||||||
|
*/
|
||||||
|
export default function YearLine({perYear, title}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const series = yearSeries(perYear);
|
||||||
|
const points = linePoints(series.map((y) => y.games), WIDTH, HEIGHT);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<svg viewBox={`-8 -8 ${WIDTH + 16} ${HEIGHT + 16}`} aria-hidden="true" className="h-24 w-full">
|
||||||
|
<polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" strokeWidth={2}
|
||||||
|
className="stroke-primary" strokeLinejoin="round"/>
|
||||||
|
{series.map((y, i) => (
|
||||||
|
<YearPoint key={y.year} point={points[i]} year={y.year} games={y.games} title={title}/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<ul className="flex flex-wrap justify-between gap-x-3 gap-y-1 text-xs tabular-nums">
|
||||||
|
{series.map((y) => (
|
||||||
|
<li key={y.year}>
|
||||||
|
<StatCount params={{key: 'FINISHED_YEAR', arg: y.year}} count={y.games} title={`${title}: ${y.year}`}
|
||||||
|
label={`${y.year}: ${t('STATS.TASTE.GAMES', {count: y.games})}`}>
|
||||||
|
{y.year}: {y.games}
|
||||||
|
</StatCount>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,24 +6,26 @@ import TasteStats from './TasteStats.jsx';
|
|||||||
import HabitsStats from './HabitsStats.jsx';
|
import HabitsStats from './HabitsStats.jsx';
|
||||||
import FinishedStats from './FinishedStats.jsx';
|
import FinishedStats from './FinishedStats.jsx';
|
||||||
import PlatformStats from './PlatformStats.jsx';
|
import PlatformStats from './PlatformStats.jsx';
|
||||||
|
import {chartOf} from '../../utils/statCharts.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
|
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
|
||||||
* null tile, and an element is never null.
|
* null tile, and an element is never null.
|
||||||
*
|
*
|
||||||
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
||||||
* @param linked the game rows link to the game pages; false on the public profile
|
* @param linked true for the user's own games: the dashboard and the owner on the profile
|
||||||
|
* @param charts the chart form per group of the user whose stats these are
|
||||||
*/
|
*/
|
||||||
export default function statGroupTile(id, stats, linked = true) {
|
export default function statGroupTile(id, stats, linked = true, charts = null) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
|
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
|
||||||
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
|
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
|
||||||
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend}/> : null;
|
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} chart={chartOf(charts, 'TREND')}/> : null;
|
||||||
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
|
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
|
||||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null;
|
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null;
|
||||||
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
|
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
|
||||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
|
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} chart={chartOf(charts, 'FINISHED')}/> : null;
|
||||||
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms}/> : null;
|
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} chart={chartOf(charts, 'PLATFORMS')}/> : null;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
|
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
|
||||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||||
default: return statGroupTile(id, stats.stats);
|
default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ describe('Dashboard', () => {
|
|||||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
|
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showsAStatTileInTheChartFormOfTheUser', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
...dashboardRoutes({dashboardTiles: [{id: 'STATS_FINISHED', size: 'M'}], statCharts: {FINISHED: 'LINE'}}),
|
||||||
|
'GET /api/library/stats': json(200, {trend: null, finished: {finishedBeforeTracking: 0,
|
||||||
|
perYear: [{year: 2022, games: 3}, {year: 2024, games: 1}]}}),
|
||||||
|
});
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', {name: /2022/})).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
|
||||||
|
expect(screen.getByText('2023: 0')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
|
||||||
|
expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves out a stat tile whose data did not come', async () => {
|
it('leaves out a stat tile whose data did not come', async () => {
|
||||||
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
|
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {TagBars} from '../components/stats/TasteStats.jsx';
|
|||||||
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
||||||
import useSession from '../hooks/useSession.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
import {gameHref} from '../utils/statGames.js';
|
|
||||||
|
|
||||||
import usePublicStats from '../hooks/usePublicStats.js';
|
import usePublicStats from '../hooks/usePublicStats.js';
|
||||||
|
|
||||||
@@ -51,11 +50,9 @@ function RankedGames({title, games, formatValue, drillKey}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GameName({game}) {
|
function GameName({game}) {
|
||||||
const href = gameHref(game.appid, false);
|
|
||||||
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
|
|
||||||
return (
|
return (
|
||||||
<span className="@container min-w-0 flex-1 font-medium">
|
<span className="@container min-w-0 flex-1 font-medium">
|
||||||
{href ? <a href={href} target="_blank" rel="noopener noreferrer" className="hover:text-primary">{name}</a> : name}
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ describe('LandingPage', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('topGamesHaveNoStoreLink', async () => {
|
||||||
|
answer({topPlayedGames: [{appid: 220, name: 'Half-Life 2', value: 600}]});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
|
||||||
|
expect(container.querySelector('a[href*="steampowered"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
|
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
|
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
|
||||||
@@ -58,4 +67,13 @@ describe('LandingPage', () => {
|
|||||||
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
|
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
|
||||||
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aZeroTotalIsNotClickable', async () => {
|
||||||
|
answer({totalLibraryEntries: 0});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('LANDING.TOTAL_GAMES');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -203,7 +203,7 @@ function LockedProgress({item, progress}) {
|
|||||||
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
||||||
// Playtime months compare two numbers: no list of games shows them.
|
// Playtime months compare two numbers: no list of games shows them.
|
||||||
const target = useDrillTarget({
|
const target = useDrillTarget({
|
||||||
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`),
|
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`), count,
|
||||||
});
|
});
|
||||||
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
||||||
const Wrapper = target ? 'button' : 'div';
|
const Wrapper = target ? 'button' : 'div';
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ describe('PointsPage', () => {
|
|||||||
expect(within(item).queryByRole('button')).toBeNull();
|
expect(within(item).queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows no button on a locked bar without progress', async () => {
|
||||||
|
await renderPage({'GET /api/rewards/wallet': json(200, wallet({progress: {GAMES_FINISHED: 0}}))});
|
||||||
|
|
||||||
|
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||||
|
expect(within(item).queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('caps the progress at the target', async () => {
|
it('caps the progress at the target', async () => {
|
||||||
await renderPage();
|
await renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default function PublicProfilePage() {
|
|||||||
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
||||||
case 'FINISHED':
|
case 'FINISHED':
|
||||||
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
|
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
|
||||||
default: return statGroupTile(id, library.stats, false);
|
default: return statGroupTile(id, library.stats, owner, profile.style?.statCharts);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
||||||
@@ -178,7 +178,7 @@ export default function PublicProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<StatDrillProvider source="profile" ticket={profile.ticket}>
|
<StatDrillProvider source="profile" ticket={profile.ticket} linked={owner}>
|
||||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
||||||
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
||||||
onCancel={() => setEditing(false)}/>
|
onCancel={() => setEditing(false)}/>
|
||||||
|
|||||||
@@ -290,4 +290,136 @@ describe('PublicProfilePage', () => {
|
|||||||
delete library.stats;
|
delete library.stats;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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})};
|
||||||
|
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}};
|
||||||
|
|
||||||
|
it('theOwnerOpensTheDetailPageFromTheList', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
try {
|
||||||
|
mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1');
|
||||||
|
} finally {
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theOwnerTileRowsOpenTheDetailPage', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
library.stats = topGamesStats;
|
||||||
|
try {
|
||||||
|
mockBackends({me: {displayName: 'lobo', role: 'USER'}, style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aVisitorSeesTileRowsWithoutLinks', async () => {
|
||||||
|
library.stats = topGamesStats;
|
||||||
|
try {
|
||||||
|
mockBackends({style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Portal 2');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
|
||||||
|
expect(container.querySelector('a[href*="steampowered"]')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformStats = {pile: null, trend: null, taste: null, finished: null, habits: null, topGames: null,
|
||||||
|
platforms: {perPlatform: [{name: 'Steam', games: 10, minutes: 5000}, {name: 'PS5', games: 2, minutes: 600}]}};
|
||||||
|
const platformTile = {tiles: [{id: 'STATS_PLATFORMS', size: 'M'}]};
|
||||||
|
const platformGames = {'GET /api/public/profiles/library/stats/games': json(200, {
|
||||||
|
content: [{appid: 7, name: 'Hades', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})};
|
||||||
|
|
||||||
|
it('showsTheOwnerChartFormWithoutAPicker', async () => {
|
||||||
|
library.stats = platformStats;
|
||||||
|
try {
|
||||||
|
mockBackends({style: {...platformTile, statCharts: {PLATFORMS: 'DONUT'}}});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Steam')).toBeTruthy();
|
||||||
|
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(2);
|
||||||
|
expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsBarsForAFormTheProfileDoesNotKnow', async () => {
|
||||||
|
library.stats = platformStats;
|
||||||
|
try {
|
||||||
|
mockBackends({style: {...platformTile, statCharts: {PLATFORMS: 'RADAR'}}});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Steam')).toBeTruthy();
|
||||||
|
expect(container.querySelector('path.stroke-card')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aChartLegendRowOfTheOwnerOpensTheDetailPage', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
library.stats = platformStats;
|
||||||
|
try {
|
||||||
|
mockBackends({me: {displayName: 'lobo', role: 'USER'}, style: {...platformTile, statCharts: {PLATFORMS: 'PIE'}},
|
||||||
|
extra: platformGames});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(container.querySelectorAll('path.stroke-card')).toHaveLength(2));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /Steam/}));
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: /Hades/})).getAttribute('href')).toBe('/library/7');
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aChartLegendRowOfAVisitorShowsAPlainGameRow', async () => {
|
||||||
|
library.stats = platformStats;
|
||||||
|
try {
|
||||||
|
mockBackends({style: {...platformTile, statCharts: {PLATFORMS: 'PIE'}}, extra: platformGames});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(container.querySelectorAll('path.stroke-card')).toHaveLength(2));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /Steam/}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('link', {name: /Hades/})).toBeNull();
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opensTheGamesBehindPerfectGames', async () => {
|
||||||
|
library.perfectGames = 12;
|
||||||
|
library.averageCompletion = 64.5;
|
||||||
|
try {
|
||||||
|
mockBackends({extra: gamesRoute});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /PROFILE\.PERFECT_GAMES: 12/}));
|
||||||
|
|
||||||
|
await screen.findByText('Backlogged');
|
||||||
|
expect(fetch.mock.calls.find(([url]) => url.includes('/stats/games'))[0]).toContain('key=PERFECT');
|
||||||
|
} finally {
|
||||||
|
delete library.perfectGames;
|
||||||
|
delete library.averageCompletion;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+30
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import {useRef} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {RefreshCw} from 'lucide-react';
|
import {RefreshCw} from 'lucide-react';
|
||||||
|
|
||||||
@@ -14,12 +15,37 @@ import FinishedStats from '../components/stats/FinishedStats.jsx';
|
|||||||
import PlatformStats from '../components/stats/PlatformStats.jsx';
|
import PlatformStats from '../components/stats/PlatformStats.jsx';
|
||||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||||
import useUserStats from '../hooks/useUserStats.js';
|
import useUserStats from '../hooks/useUserStats.js';
|
||||||
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {chartOf, saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
|
||||||
|
|
||||||
const ALL_GROUPS = [];
|
const ALL_GROUPS = [];
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||||
|
const {user, updateUser} = useCurrentUserContext();
|
||||||
|
const runSave = useSaveResponse();
|
||||||
|
const charts = user?.settings?.statCharts;
|
||||||
|
|
||||||
|
// The new form shows at once; a failed save puts only this group back.
|
||||||
|
const setChart = (group, form) => updateUser((u) => {
|
||||||
|
const next = {...u.settings?.statCharts};
|
||||||
|
if (form) next[group] = form;
|
||||||
|
else delete next[group];
|
||||||
|
return {...u, settings: {...u.settings, statCharts: next}};
|
||||||
|
});
|
||||||
|
const latest = useRef({});
|
||||||
|
const change = async (group, form) => {
|
||||||
|
const before = charts?.[group] ?? null;
|
||||||
|
const sent = form === STAT_CHARTS[group][0] ? null : form;
|
||||||
|
const id = (latest.current[group] ?? 0) + 1;
|
||||||
|
latest.current[group] = id;
|
||||||
|
setChart(group, sent);
|
||||||
|
// A failed older save must not undo a newer choice.
|
||||||
|
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
|
||||||
|
};
|
||||||
|
const chartProps = (group) => ({chart: chartOf(charts, group), onChart: (form) => change(group, form)});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatDrillProvider source="own">
|
<StatDrillProvider source="own">
|
||||||
@@ -47,12 +73,12 @@ export default function StatsPage() {
|
|||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||||
<PileStats pile={stats.pile}/>
|
<PileStats pile={stats.pile}/>
|
||||||
<TrendStats trend={stats.trend}/>
|
<TrendStats trend={stats.trend} {...chartProps('TREND')}/>
|
||||||
<TopGamesStats topGames={stats.topGames}/>
|
<TopGamesStats topGames={stats.topGames}/>
|
||||||
<TasteStats taste={stats.taste}/>
|
<TasteStats taste={stats.taste} {...chartProps('TASTE')}/>
|
||||||
<PlatformStats platforms={stats.platforms}/>
|
<PlatformStats platforms={stats.platforms} {...chartProps('PLATFORMS')}/>
|
||||||
<HabitsStats habits={stats.habits}/>
|
<HabitsStats habits={stats.habits}/>
|
||||||
<FinishedStats finished={stats.finished}/>
|
<FinishedStats finished={stats.finished} {...chartProps('FINISHED')}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
|
||||||
import StatsPage from './StatsPage.jsx';
|
import StatsPage from './StatsPage.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
const answerStats = (answer) => fetchRoutes({'GET /api/library/stats': answer});
|
const me = (statCharts) => json(200, {email: 'a@b.de', settings: {statCharts}});
|
||||||
|
const answerStats = (answer, statCharts) => fetchRoutes({
|
||||||
|
'GET /api/library/stats': answer,
|
||||||
|
'GET /api/users/me': me(statCharts),
|
||||||
|
'PUT /api/users/settings/stat-chart': json(204, {}),
|
||||||
|
});
|
||||||
|
|
||||||
const stats = (extra = {}) => ({
|
const stats = (extra = {}) => ({
|
||||||
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
|
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
|
||||||
@@ -27,7 +31,7 @@ const stats = (extra = {}) => ({
|
|||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderPage = () => render(<MemoryRouter><PreferencesProvider><StatsPage/></PreferencesProvider></MemoryRouter>);
|
const renderPage = () => renderWithProviders(<StatsPage/>);
|
||||||
|
|
||||||
describe('StatsPage', () => {
|
describe('StatsPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,8 +48,9 @@ describe('StatsPage', () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
|
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
|
||||||
expect(fetch).toHaveBeenCalledOnce();
|
const statsCalls = fetch.mock.calls.filter(([url]) => url.includes('/api/library/stats'));
|
||||||
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
|
expect(statsCalls).toHaveLength(1);
|
||||||
|
expect(statsCalls[0][0]).toMatch(/\/api\/library\/stats$/);
|
||||||
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED', 'PLATFORMS']) {
|
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED', 'PLATFORMS']) {
|
||||||
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
|
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
|
||||||
}
|
}
|
||||||
@@ -87,12 +92,13 @@ describe('StatsPage', () => {
|
|||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
|
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
|
||||||
|
|
||||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(fetch.mock.calls.filter(([url]) => url.includes('/api/library/stats'))).toHaveLength(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opensTheGamesBehindARowWithTheRawName', async () => {
|
it('opensTheGamesBehindARowWithTheRawName', async () => {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/library/stats': json(200, stats()),
|
'GET /api/library/stats': json(200, stats()),
|
||||||
|
'GET /api/users/me': me(),
|
||||||
'GET /api/library/stats/games': json(200, {content: [{appid: 620, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 60}],
|
'GET /api/library/stats/games': json(200, {content: [{appid: 620, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 60}],
|
||||||
page: 0, size: 40, totalElements: 1, totalPages: 1}),
|
page: 0, size: 40, totalElements: 1, totalPages: 1}),
|
||||||
});
|
});
|
||||||
@@ -113,6 +119,34 @@ describe('StatsPage', () => {
|
|||||||
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aZeroTileIsNotClickable', async () => {
|
||||||
|
const base = stats();
|
||||||
|
answerStats(json(200, stats({overview: {...base.overview, neverStarted: 0}})));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('STATS.OVERVIEW.NEVER_STARTED');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: /STATS\.OVERVIEW\.NEVER_STARTED/})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeroCountsAreNotClickableInPileHabitsAndStatuses', async () => {
|
||||||
|
const base = stats();
|
||||||
|
answerStats(json(200, stats({
|
||||||
|
overview: {...base.overview, byStatus: {...base.overview.byStatus, DROPPED: 0}},
|
||||||
|
pile: {...base.pile, backlogGames: 0, gamesWithTimeToBeat: 0},
|
||||||
|
habits: {...base.habits, underOneHourGames: 0},
|
||||||
|
})));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
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/]) {
|
||||||
|
expect(screen.queryByRole('button', {name})).toBeNull();
|
||||||
|
expect(screen.queryByRole('link', {name})).toBeNull();
|
||||||
|
}
|
||||||
|
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('leavesTheMoonTripsAlone', async () => {
|
it('leavesTheMoonTripsAlone', async () => {
|
||||||
answerStats(json(200, stats()));
|
answerStats(json(200, stats()));
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -121,4 +155,197 @@ describe('StatsPage', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
|
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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'});
|
||||||
|
const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||||
|
const slices = (title) => section(title).querySelectorAll('path.stroke-card');
|
||||||
|
|
||||||
|
it('showsBarsAsTodayAndAPickerPerChartableGroup', async () => {
|
||||||
|
answerStats(json(200, stats()));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('STATS.PLATFORMS.TITLE');
|
||||||
|
|
||||||
|
expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0);
|
||||||
|
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND']) {
|
||||||
|
expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1);
|
||||||
|
}
|
||||||
|
expect(within(section('STATS.TOP_GAMES.TITLE')).queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||||
|
expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('savesThePickedFormAndSavesNullForTheDefault', async () => {
|
||||||
|
answerStats(json(200, stats()));
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('STATS.PLATFORMS.TITLE');
|
||||||
|
|
||||||
|
fireEvent.click(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(2));
|
||||||
|
expect(putBodies()).toEqual([{group: 'PLATFORMS', chart: 'PIE'}]);
|
||||||
|
|
||||||
|
fireEvent.click(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0));
|
||||||
|
expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheStoredFormOfTheUser', async () => {
|
||||||
|
answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(2));
|
||||||
|
expect(screen.queryByTestId('pile-trend')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('putsTheOldFormBackWhenTheSaveFails', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/library/stats': json(200, stats()),
|
||||||
|
'GET /api/users/me': me(),
|
||||||
|
'PUT /api/users/settings/stat-chart': json(500, {}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('STATS.PLATFORMS.TITLE');
|
||||||
|
|
||||||
|
fireEvent.click(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(putBodies()).toHaveLength(1));
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0));
|
||||||
|
expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aLegendRowOpensTheGamesWithThePlatform', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/library/stats': json(200, stats()),
|
||||||
|
'GET /api/users/me': me({PLATFORMS: 'PIE'}),
|
||||||
|
'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(2));
|
||||||
|
fireEvent.click(await within(section('STATS.PLATFORMS.TITLE')).findByRole('button', {name: /PS5/}));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=PLATFORM&arg=PS5&page=0$/));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theOtherSliceIsNotAButtonAndHoldsTheRest', async () => {
|
||||||
|
const perPlatform = Array.from({length: 8}, (_, i) => ({name: `P${i}`, games: 8 - i, minutes: 10}));
|
||||||
|
answerStats(json(200, stats({platforms: {perPlatform}})), {PLATFORMS: 'PIE'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(6));
|
||||||
|
const legend = within(section('STATS.PLATFORMS.TITLE'));
|
||||||
|
expect(legend.getAllByRole('button', {name: /P\d/})).toHaveLength(5);
|
||||||
|
expect(legend.getByText('STATS.CHART.OTHER').closest('button')).toBeNull();
|
||||||
|
expect(legend.getByText('STATS.TASTE.GAMES:6')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aPlatformRowWithoutGamesIsNoButtonInBarsAndList', async () => {
|
||||||
|
const perPlatform = [{name: 'Steam', games: 3, minutes: 5}, {name: 'Switch', games: 0, minutes: 0}];
|
||||||
|
for (const form of ['BARS', 'LIST']) {
|
||||||
|
answerStats(json(200, stats({platforms: {perPlatform}})), {PLATFORMS: form});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const platforms = within(await screen.findByText('STATS.PLATFORMS.TITLE').then(() => section('STATS.PLATFORMS.TITLE')));
|
||||||
|
expect(platforms.getByRole('button', {name: /Steam/})).toBeTruthy();
|
||||||
|
expect(platforms.getByText('Switch')).toBeTruthy();
|
||||||
|
expect(platforms.queryByRole('button', {name: /Switch/})).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aPlatformWithoutGamesGetsNoSlice', async () => {
|
||||||
|
answerStats(json(200, stats({platforms: {perPlatform: [
|
||||||
|
{name: 'Steam', games: 3, minutes: 5}, {name: 'Switch', games: 0, minutes: 0}]}})), {PLATFORMS: 'PIE'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(1));
|
||||||
|
expect(within(section('STATS.PLATFORMS.TITLE')).queryByText('Switch')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheEmptyTextForEveryForm', async () => {
|
||||||
|
answerStats(json(200, stats({platforms: {perPlatform: []}})), {PLATFORMS: 'PIE'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('STATS.PLATFORMS.EMPTY')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aLineYearOpensItsGamesAndAYearWithoutGamesIsNoButton', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/library/stats': json(200, stats({finished: {finishedBeforeTracking: 0,
|
||||||
|
perYear: [{year: 2022, games: 3}, {year: 2024, games: 1}]}})),
|
||||||
|
'GET /api/users/me': me({FINISHED: 'LINE'}),
|
||||||
|
'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const finished = within(await screen.findByText('STATS.FINISHED.TITLE').then(() => section('STATS.FINISHED.TITLE')));
|
||||||
|
await waitFor(() => expect(finished.getByRole('button', {name: /2022/})).toBeTruthy());
|
||||||
|
expect(finished.getByRole('button', {name: /2024/})).toBeTruthy();
|
||||||
|
expect(finished.queryByRole('button', {name: /2023/})).toBeNull();
|
||||||
|
expect(finished.getByText('2023: 0')).toBeTruthy();
|
||||||
|
const circles = [...section('STATS.FINISHED.TITLE').querySelectorAll('circle')];
|
||||||
|
expect(circles).toHaveLength(3);
|
||||||
|
expect(circles.filter((c) => c.getAttribute('class').includes('cursor-pointer'))).toHaveLength(2);
|
||||||
|
|
||||||
|
fireEvent.click(finished.getByRole('button', {name: /2022/}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=FINISHED_YEAR&arg=2022&page=0$/));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aLineWithOneYearDrawsBars', async () => {
|
||||||
|
answerStats(json(200, stats()), {FINISHED: 'LINE'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const finished = within(await screen.findByText('STATS.FINISHED.TITLE').then(() => section('STATS.FINISHED.TITLE')));
|
||||||
|
await waitFor(() => expect(finished.getByRole('button', {name: /2026/})).toBeTruthy());
|
||||||
|
expect(section('STATS.FINISHED.TITLE').querySelector('polyline')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aDonutOfTheTasteOpensTheGenreWithItsRawNameAndShowsTheOverlapNote', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/library/stats': json(200, stats()),
|
||||||
|
'GET /api/users/me': me({TASTE: 'DONUT'}),
|
||||||
|
'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const taste = within(await screen.findByText('STATS.TASTE.TITLE').then(() => section('STATS.TASTE.TITLE')));
|
||||||
|
await waitFor(() => expect(taste.getAllByText('STATS.CHART.OVERLAP')).toHaveLength(2));
|
||||||
|
fireEvent.click(taste.getByRole('button', {name: /STATS\.GENRE/}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=GENRE&arg=Shooter&page=0$/));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theTrendBarsStayOneClickTargetWithAListOfTheDays', async () => {
|
||||||
|
answerStats(json(200, stats()), {TREND: 'BARS'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const trend = within(await screen.findByText('STATS.TREND.TITLE').then(() => section('STATS.TREND.TITLE')));
|
||||||
|
await waitFor(() => expect(section('STATS.TREND.TITLE').querySelectorAll('rect')).toHaveLength(2));
|
||||||
|
expect(trend.getAllByRole('button', {name: 'STATS.DRILL.TREND_MOVES'})).toHaveLength(1);
|
||||||
|
expect(section('STATS.TREND.TITLE').querySelectorAll('table tr')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// REVIEW-FINDING: the picker buttons sit inside the h2, so the heading name becomes "title + Chart Bars Pie ..."
|
||||||
|
it('reviewFinding_thePickerIsNotPartOfTheHeadingName', async () => {
|
||||||
|
answerStats(json(200, stats()));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', {name: 'STATS.PLATFORMS.TITLE'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// REVIEW-FINDING: an empty tag list in pie/donut form draws an empty chart box and the overlap note
|
||||||
|
it('reviewFinding_anEmptyMoodListInThePieShowsNoChartAndNoNote', async () => {
|
||||||
|
answerStats(json(200, stats({taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: []}})),
|
||||||
|
{TASTE: 'PIE'});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(slices('STATS.TASTE.TITLE')).toHaveLength(1));
|
||||||
|
expect(within(section('STATS.TASTE.TITLE')).getAllByText('STATS.CHART.OVERLAP')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
import {dayNumber} from './stats.js';
|
||||||
|
|
||||||
|
// The chart forms of each stat group, the first is the default. Auth checks the same list.
|
||||||
|
export const STAT_CHARTS = {
|
||||||
|
PLATFORMS: ['BARS', 'PIE', 'DONUT', 'LIST'],
|
||||||
|
TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'],
|
||||||
|
FINISHED: ['BARS', 'LINE', 'LIST'],
|
||||||
|
TREND: ['LINE', 'BARS'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chart form to draw: the stored one when the group offers it, else the group's default.
|
||||||
|
*
|
||||||
|
* @param charts group -> form, as the user settings hold it; may be missing
|
||||||
|
*/
|
||||||
|
export const chartOf = (charts, group) => {
|
||||||
|
const forms = STAT_CHARTS[group];
|
||||||
|
const stored = charts?.[group];
|
||||||
|
return forms.includes(stored) ? stored : forms[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and
|
||||||
|
* one "other" slice with the rest.
|
||||||
|
*
|
||||||
|
* @returns `[{name, value, item, other}]`; the other slice has no name and no item
|
||||||
|
*/
|
||||||
|
export const topSlices = (items, valueOf, keep = 5) => {
|
||||||
|
const slices = items
|
||||||
|
.map((item) => ({name: item.name, value: valueOf(item), item, other: false}))
|
||||||
|
.filter((slice) => slice.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
if (slices.length <= keep + 1) return slices;
|
||||||
|
const rest = slices.slice(keep).reduce((sum, slice) => sum + slice.value, 0);
|
||||||
|
return [...slices.slice(0, keep), {name: null, value: rest, item: null, other: true}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (n) => Math.round(n * 1000) / 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG path data of the slices of a pie (`inner` 0) or a donut, in a square of `2 * radius`, the first slice at the
|
||||||
|
* top, clockwise. A slice that is the whole circle is drawn as two half arcs.
|
||||||
|
*/
|
||||||
|
export const slicePaths = (values, radius, inner = 0) => {
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
const point = (angle, r) => `${round(radius + r * Math.sin(angle))},${round(radius - r * Math.cos(angle))}`;
|
||||||
|
let start = 0;
|
||||||
|
return values.map((value) => {
|
||||||
|
if (value <= 0) return '';
|
||||||
|
const from = start;
|
||||||
|
start += value;
|
||||||
|
if (value === total) {
|
||||||
|
const ring = (r, sweep) => `M${radius},${radius - r} A${r},${r} 0 1 ${sweep} ${radius},${radius + r} A${r},${r} 0 1 ${sweep} ${radius},${radius - r} Z`;
|
||||||
|
return inner > 0 ? `${ring(radius, 1)} ${ring(inner, 0)}` : ring(radius, 1);
|
||||||
|
}
|
||||||
|
const a0 = (from / total) * 2 * Math.PI;
|
||||||
|
const a1 = (start / total) * 2 * Math.PI;
|
||||||
|
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||||
|
const outer = `A${radius},${radius} 0 ${large} 1 ${point(a1, radius)}`;
|
||||||
|
return inner > 0
|
||||||
|
? `M${point(a0, radius)} ${outer} L${point(a1, inner)} A${inner},${inner} 0 ${large} 0 ${point(a0, inner)} Z`
|
||||||
|
: `M${radius},${radius} L${point(a0, radius)} ${outer} Z`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The years from the first to the last one, a year without games has 0.
|
||||||
|
*
|
||||||
|
* @param perYear `[{year, games}]`
|
||||||
|
*/
|
||||||
|
export const yearSeries = (perYear) => {
|
||||||
|
if (perYear.length === 0) return [];
|
||||||
|
const years = perYear.map((y) => y.year);
|
||||||
|
const first = Math.min(...years);
|
||||||
|
const games = new Map(perYear.map((y) => [y.year, y.games]));
|
||||||
|
return Array.from({length: Math.max(...years) - first + 1},
|
||||||
|
(_, i) => ({year: first + i, games: games.get(first + i) ?? 0}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points of a line chart, spread over the width; the y axis starts at 0, so a small change looks small.
|
||||||
|
*/
|
||||||
|
export const linePoints = (values, width, height) => {
|
||||||
|
const max = Math.max(1, ...values);
|
||||||
|
return values.map((value, i) => ({
|
||||||
|
x: (i / (values.length - 1)) * width,
|
||||||
|
y: height - (value / max) * height,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bars for the pile per day, like trendPoints: x follows the date, so days without a snapshot keep their width.
|
||||||
|
*/
|
||||||
|
export const trendBars = (days, width, height) => {
|
||||||
|
if (days.length === 0) return [];
|
||||||
|
const max = Math.max(1, ...days.map((d) => d.backlogGames));
|
||||||
|
const first = dayNumber(days[0].day);
|
||||||
|
const slot = width / (dayNumber(days[days.length - 1].day) - first + 1);
|
||||||
|
return days.map((d) => {
|
||||||
|
const h = (d.backlogGames / max) * height;
|
||||||
|
return {x: (dayNumber(d.day) - first) * slot, y: height - h, w: slot * 0.8, h};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param chart null puts the group back to its default form
|
||||||
|
*/
|
||||||
|
export const saveStatChart = (group, chart) =>
|
||||||
|
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/stat-chart`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({group, chart}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import {chartOf, linePoints, slicePaths, topSlices, trendBars, yearSeries} from './statCharts.js';
|
||||||
|
|
||||||
|
describe('chartOf', () => {
|
||||||
|
it('gives the stored form of the group', () => {
|
||||||
|
expect(chartOf({PLATFORMS: 'PIE'}, 'PLATFORMS')).toBe('PIE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first form', () => {
|
||||||
|
expect(chartOf({}, 'PLATFORMS')).toBe('BARS');
|
||||||
|
expect(chartOf({}, 'TREND')).toBe('LINE');
|
||||||
|
expect(chartOf({TASTE: 'LINE'}, 'TASTE')).toBe('BARS');
|
||||||
|
expect(chartOf({PLATFORMS: 'RADAR'}, 'PLATFORMS')).toBe('BARS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a missing or odd settings value', () => {
|
||||||
|
for (const charts of [null, undefined, 'PIE', 5]) expect(chartOf(charts, 'FINISHED')).toBe('BARS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('topSlices', () => {
|
||||||
|
const item = (name, games) => ({name, games});
|
||||||
|
const games = (i) => i.games;
|
||||||
|
|
||||||
|
it('keeps all items up to one more than keep, biggest first', () => {
|
||||||
|
const slices = topSlices([1, 2, 3, 4, 5, 6].map((n) => item(`P${n}`, n)), games);
|
||||||
|
expect(slices.map((s) => s.name)).toEqual(['P6', 'P5', 'P4', 'P3', 'P2', 'P1']);
|
||||||
|
expect(slices.some((s) => s.other)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums the rest into an other slice', () => {
|
||||||
|
const slices = topSlices([1, 2, 3, 4, 5, 6, 7, 8].map((n) => item(`P${n}`, n)), games);
|
||||||
|
expect(slices).toHaveLength(6);
|
||||||
|
expect(slices.slice(0, 5).map((s) => s.name)).toEqual(['P8', 'P7', 'P6', 'P5', 'P4']);
|
||||||
|
expect(slices[5]).toMatchObject({name: null, other: true, value: 3 + 2 + 1, item: null});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops items without a value', () => {
|
||||||
|
expect(topSlices([item('A', 2), item('B', 0)], games).map((s) => s.name)).toEqual(['A']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slicePaths', () => {
|
||||||
|
it('draws one value as a full circle, not an empty arc', () => {
|
||||||
|
const [d] = slicePaths([4], 50, 0);
|
||||||
|
expect(d).toBe('M50,0 A50,50 0 1 1 50,100 A50,50 0 1 1 50,0 Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws one value of a donut as a ring with a hole', () => {
|
||||||
|
const [d] = slicePaths([4], 50, 30);
|
||||||
|
expect(d.match(/M/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a circle in the order of the values, the first from the top', () => {
|
||||||
|
const [a, b] = slicePaths([1, 1], 50, 0);
|
||||||
|
expect(a).toBe('M50,50 L50,0 A50,50 0 0 1 50,100 Z');
|
||||||
|
expect(b).toBe('M50,50 L50,100 A50,50 0 0 1 50,0 Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a donut slice an inner arc', () => {
|
||||||
|
const [a] = slicePaths([1, 3], 50, 30);
|
||||||
|
expect(a).toContain('A30,30 0 0 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('yearSeries', () => {
|
||||||
|
it('fills the years between the first and the last with 0', () => {
|
||||||
|
expect(yearSeries([{year: 2024, games: 1}, {year: 2022, games: 3}]))
|
||||||
|
.toEqual([{year: 2022, games: 3}, {year: 2023, games: 0}, {year: 2024, games: 1}]);
|
||||||
|
expect(yearSeries([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('linePoints', () => {
|
||||||
|
it('spreads the values over the width, the biggest at the top', () => {
|
||||||
|
expect(linePoints([2, 4, 0], 100, 40)).toEqual([{x: 0, y: 20}, {x: 50, y: 0}, {x: 100, y: 40}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trendBars', () => {
|
||||||
|
const day = (date, backlogGames) => ({day: date, backlogGames});
|
||||||
|
|
||||||
|
it('puts each bar where its date is, a day without a snapshot keeps its width', () => {
|
||||||
|
const bars = trendBars([day('2026-09-01', 10), day('2026-09-02', 5), day('2026-09-05', 0)], 100, 40);
|
||||||
|
expect(bars.map((b) => b.x)).toEqual([0, 20, 80]);
|
||||||
|
expect(bars.map((b) => b.h)).toEqual([40, 20, 0]);
|
||||||
|
expect(bars[1].y).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a single day', () => {
|
||||||
|
expect(trendBars([day('2026-09-01', 3)], 100, 40)).toHaveLength(1);
|
||||||
|
expect(trendBars([], 100, 40)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,8 +47,4 @@ export async function fetchStatGames(source, params, ticket) {
|
|||||||
|
|
||||||
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`;
|
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`;
|
||||||
|
|
||||||
// A visitor has no game page of the owner; the Steam store is the only public one. Manual games have no appid there.
|
export const gameHref = (appid) => `/library/${appid}`;
|
||||||
export const gameHref = (appid, own) => {
|
|
||||||
if (own) return `/library/${appid}`;
|
|
||||||
return appid > 0 ? `https://store.steampowered.com/app/${appid}` : null;
|
|
||||||
};
|
|
||||||
@@ -27,9 +27,8 @@ describe('libraryLink', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('gameHref', () => {
|
describe('gameHref', () => {
|
||||||
it('own_LinksTheLibraryPage', () => expect(gameHref(5, true)).toBe('/library/5'));
|
it('linksTheLibraryPage', () => {
|
||||||
it('visitor_LinksTheStoreOnlyForSteamApps', () => {
|
expect(gameHref(5)).toBe('/library/5');
|
||||||
expect(gameHref(5, false)).toBe('https://store.steampowered.com/app/5');
|
expect(gameHref(-3)).toBe('/library/-3');
|
||||||
expect(gameHref(-3, false)).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+1
-1
@@ -19,7 +19,7 @@ export const formatPercent = (share, locale) =>
|
|||||||
share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0});
|
share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0});
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
|
export const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot
|
* Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot
|
||||||
|
|||||||
Reference in new issue
Block a user