Public Access
feat(ui): make stat numbers clickable to open games
StatDrill context holds one dialog for a page. StatCount wraps a number: total and status counts link to the library filter, other numbers open StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars (genres, platforms, moods) and other stat displays. Dialog shows 40 games per page with bottom-sheet layout on phones, handles pagination and errors (403 not shared, 401 expired, 400 bad argument).
This commit is contained in:
1 parent
ed5df8c40d
commit
777ba87fd6
31 files changed
+1026
-261
No files matched your search
@@ -31,6 +31,10 @@ To Be Played is split into three repositories:
|
|||||||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||||||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||||
|
- **Click a number** (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year
|
||||||
|
opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own
|
||||||
|
views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime),
|
||||||
|
averages, rates and the moon trips. The list always uses the same rules as the number.
|
||||||
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
||||||
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
||||||
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
|
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
|
||||||
@@ -208,6 +212,19 @@ flowchart LR
|
|||||||
with `src/test/fetchRoutes.js`, which fails a test on any request without a route.
|
with `src/test/fetchRoutes.js`, which fails a test on any request without a route.
|
||||||
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||||
detail page) to Core.
|
detail page) to Core.
|
||||||
|
- The games behind a number: pages wrap their content in `StatDrillProvider` (`source` own, profile, community or
|
||||||
|
points), which renders one `StatGamesDialog`. Numbers call `useStatDrill()` / `StatCount`; outside a provider they
|
||||||
|
are plain text. `src/utils/statGames.js` builds the four endpoint URLs.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Number["Number, bar or row"] --> Provider["StatDrillProvider"]
|
||||||
|
Provider --> Dialog["StatGamesDialog"]
|
||||||
|
Dialog -->|own| Own["Core /api/library/stats/games"]
|
||||||
|
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
|
||||||
|
Dialog -->|community| Community["Core /api/stats/public/games"]
|
||||||
|
Dialog -->|points| Points["Core /api/rewards/.../games"]
|
||||||
|
```
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||||||
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
|
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
|
||||||
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
||||||
|
|||||||
+22
-1
@@ -496,7 +496,9 @@
|
|||||||
},
|
},
|
||||||
"TREND": {
|
"TREND": {
|
||||||
"TITLE": "Stapel-Verlauf",
|
"TITLE": "Stapel-Verlauf",
|
||||||
"THIS_MONTH": "Diesen Monat: +{{added}} dazu, −{{done}} vom Stapel",
|
"THIS_MONTH": "Diesen Monat:",
|
||||||
|
"THIS_MONTH_ADDED": "+{{added}} neu",
|
||||||
|
"THIS_MONTH_DONE": "−{{done}} vom Stapel",
|
||||||
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
|
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
|
||||||
},
|
},
|
||||||
"TOP_GAMES": {
|
"TOP_GAMES": {
|
||||||
@@ -557,6 +559,25 @@
|
|||||||
"TACTICAL": "Taktik",
|
"TACTICAL": "Taktik",
|
||||||
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
|
"TURN_BASED_STRATEGY_TBS": "Rundenstrategie",
|
||||||
"VISUAL_NOVEL": "Visual Novel"
|
"VISUAL_NOVEL": "Visual Novel"
|
||||||
|
},
|
||||||
|
"DRILL": {
|
||||||
|
"SHOW_ALL": "Alle anzeigen",
|
||||||
|
"LOAD_MORE": "Mehr laden",
|
||||||
|
"EMPTY": "Hinter dieser Zahl stehen keine Spiele.",
|
||||||
|
"ERROR": "Die Spiele konnten nicht geladen werden.",
|
||||||
|
"NOT_SHARED": "Der Besitzer teilt diese Zahl nicht.",
|
||||||
|
"EXPIRED": "Diese Ansicht ist abgelaufen. Lade die Seite neu.",
|
||||||
|
"RELOAD": "Neu laden",
|
||||||
|
"IN_LIBRARIES_one": "in {{count}} Bibliothek",
|
||||||
|
"IN_LIBRARIES_other": "in {{count}} Bibliotheken",
|
||||||
|
"BEFORE_TRACKING": "Vor Beginn des Verlaufs durchgespielt",
|
||||||
|
"TREND_MOVES": "Stapel-Änderungen der letzten 90 Tage",
|
||||||
|
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
||||||
|
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
||||||
|
"NEW_IN_LIBRARY": "Neu",
|
||||||
|
"OPEN": "Im Steam-Shop öffnen",
|
||||||
|
"GAMES_one": "{{count}} Spiel",
|
||||||
|
"GAMES_other": "{{count}} Spiele"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ROLES": {
|
"ROLES": {
|
||||||
|
|||||||
+22
-1
@@ -496,7 +496,9 @@
|
|||||||
},
|
},
|
||||||
"TREND": {
|
"TREND": {
|
||||||
"TITLE": "Pile trend",
|
"TITLE": "Pile trend",
|
||||||
"THIS_MONTH": "This month: +{{added}} added, −{{done}} off the pile",
|
"THIS_MONTH": "This month:",
|
||||||
|
"THIS_MONTH_ADDED": "+{{added}} added",
|
||||||
|
"THIS_MONTH_DONE": "−{{done}} off the pile",
|
||||||
"STARTS": "The trend starts today. Come back tomorrow for the first line."
|
"STARTS": "The trend starts today. Come back tomorrow for the first line."
|
||||||
},
|
},
|
||||||
"TOP_GAMES": {
|
"TOP_GAMES": {
|
||||||
@@ -557,6 +559,25 @@
|
|||||||
"TACTICAL": "Tactics",
|
"TACTICAL": "Tactics",
|
||||||
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
|
"TURN_BASED_STRATEGY_TBS": "Turn-based strategy",
|
||||||
"VISUAL_NOVEL": "Visual novel"
|
"VISUAL_NOVEL": "Visual novel"
|
||||||
|
},
|
||||||
|
"DRILL": {
|
||||||
|
"SHOW_ALL": "Show all",
|
||||||
|
"LOAD_MORE": "Load more",
|
||||||
|
"EMPTY": "No games behind this number.",
|
||||||
|
"ERROR": "Could not load the games.",
|
||||||
|
"NOT_SHARED": "The owner does not share this number.",
|
||||||
|
"EXPIRED": "This view has expired. Reload the page.",
|
||||||
|
"RELOAD": "Reload",
|
||||||
|
"IN_LIBRARIES_one": "in {{count}} library",
|
||||||
|
"IN_LIBRARIES_other": "in {{count}} libraries",
|
||||||
|
"BEFORE_TRACKING": "Finished before the history started",
|
||||||
|
"TREND_MOVES": "Pile moves in the last 90 days",
|
||||||
|
"TREND_ADDED": "Added to the pile this month",
|
||||||
|
"TREND_DONE": "Off the pile this month",
|
||||||
|
"NEW_IN_LIBRARY": "New",
|
||||||
|
"OPEN": "Open in the Steam store",
|
||||||
|
"GAMES_one": "{{count}} game",
|
||||||
|
"GAMES_other": "{{count}} games"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ROLES": {
|
"ROLES": {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import PileTrend from './stats/PileTrend.jsx';
|
import PileTrend from './stats/PileTrend.jsx';
|
||||||
|
import {StatCount} from './stats/StatDrill.jsx';
|
||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {backlogShare} from '../utils/backlog.js';
|
import {backlogShare} from '../utils/backlog.js';
|
||||||
import {GAME_STATUSES} from '../utils/limits.js';
|
import {GAME_STATUSES} from '../utils/limits.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
import {libraryLink} from '../utils/statGames.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing
|
* @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing
|
||||||
@@ -34,17 +36,25 @@ 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">
|
||||||
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}}
|
||||||
|
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
|
||||||
|
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
||||||
|
</StatCount>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
|
{t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
|
<StatCount params={summary.neverPlayed > 0 ? {key: 'NEVER_STARTED'} : null}
|
||||||
|
title={t('STATS.OVERVIEW.NEVER_STARTED')}>
|
||||||
|
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
|
||||||
|
</StatCount>
|
||||||
</p>
|
</p>
|
||||||
{summary.backlogMinutes > 0 && (
|
{summary.backlogMinutes > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})}
|
<StatCount params={{key: 'PILE_TIME_TO_BEAT'}} title={t('STATS.PILE.NORMAL')}>
|
||||||
|
{t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})}
|
||||||
|
</StatCount>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +77,9 @@ 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}`)}
|
||||||
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
<StatCount to={libraryLink(s)} label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
|
||||||
|
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
||||||
|
</StatCount>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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, fireEvent, render, screen} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import PileOfShameCard from './PileOfShameCard.jsx';
|
import PileOfShameCard from './PileOfShameCard.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'));
|
||||||
|
|
||||||
@@ -74,4 +76,17 @@ describe('PileOfShameCard', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull();
|
expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('linksTheBacklogNumberToTheLibraryAndOpensTheNeverPlayedGames', async () => {
|
||||||
|
fetchRoutes({'GET /api/library/stats/games': json(200, {content: [{appid: 7, name: 'Untouched'}],
|
||||||
|
page: 0, size: 40, totalElements: 1, totalPages: 1})});
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own"><PileOfShameCard summary={summary(2)} onShuffle={() => {}}/></StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 2'})[0].getAttribute('href'))
|
||||||
|
.toBe('/library?hideDuplicates=true&status=BACKLOG');
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.NEVER_PLAYED:1'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Untouched')).toBeTruthy();
|
||||||
|
await waitFor(() => expect(fetch.mock.calls[0][0]).toContain('key=NEVER_STARTED'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import {useEffect, useState} from 'react';
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {useDrillTarget} from './stats/StatDrill.jsx';
|
||||||
|
|
||||||
function useCountUp(target, durationMs = 1600) {
|
function useCountUp(target, durationMs = 1600) {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
@@ -25,10 +26,14 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatCard({label, value, icon: Icon, suffix}) {
|
/**
|
||||||
|
* @param drill `{params}` of StatCount: the card is a button that opens the games behind the number
|
||||||
|
*/
|
||||||
|
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
|
||||||
const {i18n} = useTranslation();
|
const {i18n} = useTranslation();
|
||||||
const animated = useCountUp(value);
|
const animated = useCountUp(value);
|
||||||
return (
|
const target = useDrillTarget({title: label, ...drill});
|
||||||
|
const card = (
|
||||||
<Card className="gap-3 p-5">
|
<Card className="gap-3 p-5">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -41,4 +46,10 @@ export default function StatCard({label, value, icon: Icon, suffix}) {
|
|||||||
<div className="text-sm text-muted-foreground">{label}</div>
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
return target ? (
|
||||||
|
<button type="button" onClick={target.onClick}
|
||||||
|
className="block w-full rounded-xl text-left transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
{card}
|
||||||
|
</button>
|
||||||
|
) : card;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ const SteamGameCount = ({targetCount}) => {
|
|||||||
<Gamepad2 className="size-5"/>
|
<Gamepad2 className="size-5"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-5xl font-bold tracking-tight tabular-nums">{count}</div>
|
<Link to="/library" className="block text-5xl font-bold tracking-tight tabular-nums hover:text-primary">{count}</Link>
|
||||||
<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">
|
||||||
|
|||||||
@@ -2,19 +2,25 @@ 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 {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx';
|
||||||
|
|
||||||
export function YearBars({perYear}) {
|
/**
|
||||||
|
* @param onSelect `(year)`: the rows are buttons
|
||||||
|
*/
|
||||||
|
export function YearBars({perYear, onSelect}) {
|
||||||
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">
|
||||||
{perYear.map((y) => (
|
{perYear.map((y) => (
|
||||||
<li key={y.year} className="flex items-center gap-3 text-sm">
|
<li key={y.year} className="text-sm">
|
||||||
<span className="w-12 tabular-nums">{y.year}</span>
|
<DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
|
||||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
<span className="w-12 tabular-nums">{y.year}</span>
|
||||||
<div className="h-full rounded-full bg-primary"
|
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||||
style={{width: `${(y.games / max) * 100}%`}}/>
|
<div className="h-full rounded-full bg-primary"
|
||||||
</div>
|
style={{width: `${(y.games / max) * 100}%`}}/>
|
||||||
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
</div>
|
||||||
|
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
||||||
|
</DrillRow>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -23,16 +29,20 @@ export function YearBars({perYear}) {
|
|||||||
|
|
||||||
export default function FinishedStats({finished}) {
|
export default function FinishedStats({finished}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const drill = useStatDrill();
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
|
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
|
||||||
{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>
|
||||||
) : (
|
) : (
|
||||||
<YearBars perYear={finished.perYear}/>
|
<YearBars perYear={finished.perYear}
|
||||||
|
onSelect={drill && ((year) => drill.open(`${t('STATS.FINISHED.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">
|
||||||
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
|
<StatCount params={{key: 'FINISHED_UNTRACKED'}} title={t('STATS.DRILL.BEFORE_TRACKING')}>
|
||||||
|
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
|
||||||
|
</StatCount>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A game with its cover and a value on the right; links to the game page only in the user's own library.
|
* 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
|
||||||
|
* store for a visitor.
|
||||||
*/
|
*/
|
||||||
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"/>
|
||||||
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
|
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
|
||||||
<span className="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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return linked ? (
|
const className = 'flex items-center gap-3 hover:text-primary transition-colors';
|
||||||
<Link to={`/library/${game.appid}`} className="flex items-center gap-3 hover:text-primary transition-colors">
|
if (linked) return <Link to={gameHref(game.appid, true)} className={className}>{content}</Link>;
|
||||||
{content}
|
const href = gameHref(game.appid, false);
|
||||||
</Link>
|
if (href) {
|
||||||
) : (
|
return (
|
||||||
<div className="flex items-center gap-3">{content}</div>
|
<a href={href} target="_blank" rel="noopener noreferrer" title={t('STATS.DRILL.OPEN')} className={className}>
|
||||||
);
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="flex items-center gap-3">{content}</div>;
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,13 @@ export default function HabitsStats({habits}) {
|
|||||||
hint={t('STATS.HABITS.MOON_HINT')}/>
|
hint={t('STATS.HABITS.MOON_HINT')}/>
|
||||||
{habits.longestDay && (
|
{habits.longestDay && (
|
||||||
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
|
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
|
||||||
hint={day}/>
|
hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/>
|
||||||
)}
|
)}
|
||||||
{habits.underOneHourShare != null && (
|
{habits.underOneHourShare != null && (
|
||||||
<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'}}}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {Gamepad2} from 'lucide-react';
|
|||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
import StatTile from './StatTile.jsx';
|
import StatTile from './StatTile.jsx';
|
||||||
import {GAME_STATUSES} from '../../utils/limits.js';
|
import {GAME_STATUSES} from '../../utils/limits.js';
|
||||||
|
import {StatCount} from './StatDrill.jsx';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
|
import {libraryLink} from '../../utils/statGames.js';
|
||||||
|
|
||||||
export default function OverviewStats({overview}) {
|
export default function OverviewStats({overview}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
@@ -12,16 +14,22 @@ export default function OverviewStats({overview}) {
|
|||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<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'}}}/>
|
||||||
<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)}
|
||||||
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}/>
|
drill={{params: {key: 'PLAYED'}}}/>
|
||||||
|
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
|
||||||
|
drill={{params: {key: 'NEVER_STARTED'}}}/>
|
||||||
</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}`)}{' '}
|
||||||
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
|
||||||
|
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
|
||||||
|
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||||
|
</StatCount>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import StatsSection from './StatsSection.jsx';
|
|||||||
import StatTile from './StatTile.jsx';
|
import StatTile from './StatTile.jsx';
|
||||||
import GameRow from './GameRow.jsx';
|
import GameRow from './GameRow.jsx';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
|
import {libraryLink} from '../../utils/statGames.js';
|
||||||
import {formatDecimal} from '../../utils/stats.js';
|
import {formatDecimal} from '../../utils/stats.js';
|
||||||
import {formatDate} from '../../utils/format.js';
|
import {formatDate} from '../../utils/format.js';
|
||||||
|
|
||||||
@@ -16,9 +17,11 @@ export default function PileStats({pile, linked = true}) {
|
|||||||
return (
|
return (
|
||||||
<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'}}}/>
|
||||||
<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'}}}/>
|
||||||
<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 && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -16,15 +17,25 @@ export default function PileTrend({trend}) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, done: thisMonth.done})}
|
<span>{t('STATS.TREND.THIS_MONTH')}</span>{' '}
|
||||||
|
<StatCount params={thisMonth.added > 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}>
|
||||||
|
{t('STATS.TREND.THIS_MONTH_ADDED', {added: thisMonth.added})}
|
||||||
|
</StatCount>
|
||||||
|
{', '}
|
||||||
|
<StatCount params={thisMonth.done > 0 ? {key: 'TREND_DONE'} : null} title={t('STATS.DRILL.TREND_DONE')}>
|
||||||
|
{t('STATS.TREND.THIS_MONTH_DONE', {done: thisMonth.done})}
|
||||||
|
</StatCount>
|
||||||
</p>
|
</p>
|
||||||
{days.length > 1 ? (
|
{days.length > 1 ? (
|
||||||
<div>
|
<div>
|
||||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
<StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')}
|
||||||
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
|
||||||
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
||||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
||||||
</svg>
|
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
||||||
|
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
||||||
|
</svg>
|
||||||
|
</StatCount>
|
||||||
<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>
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import {MonitorSmartphone} from 'lucide-react';
|
|||||||
|
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
import {TagBars} from './TasteStats.jsx';
|
import {TagBars} from './TasteStats.jsx';
|
||||||
|
import {useTagDrill} from './StatDrill.jsx';
|
||||||
|
|
||||||
export default function PlatformStats({platforms}) {
|
export default function PlatformStats({platforms}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const select = useTagDrill();
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
|
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
|
||||||
{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>
|
||||||
) : (
|
) : (
|
||||||
<TagBars tags={platforms.perPlatform}/>
|
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')}/>
|
||||||
)}
|
)}
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import StatGamesDialog from './StatGamesDialog.jsx';
|
||||||
|
|
||||||
|
const StatDrillContext = createContext(null);
|
||||||
|
|
||||||
|
// The dotted underline says "there is more behind this number".
|
||||||
|
const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max-sm:min-w-11 underline decoration-dotted '
|
||||||
|
+ 'underline-offset-4 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the dialog of the games behind a number, once for a page.
|
||||||
|
*
|
||||||
|
* @param source own, profile, community or points
|
||||||
|
* @param ticket profile: the ticket for the visitor's requests
|
||||||
|
*/
|
||||||
|
export function StatDrillProvider({source, ticket, children}) {
|
||||||
|
const [request, setRequest] = useState(null);
|
||||||
|
const opener = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!request) opener.current?.focus?.();
|
||||||
|
}, [request]);
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
source,
|
||||||
|
open: (title, params) => {
|
||||||
|
opener.current = document.activeElement;
|
||||||
|
setRequest({title, source, ticket, params});
|
||||||
|
},
|
||||||
|
}), [source, ticket]);
|
||||||
|
return (
|
||||||
|
<StatDrillContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<StatGamesDialog request={request} onClose={() => setRequest(null)}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}/>
|
||||||
|
</StatDrillContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{source, open(title, params)}`, or null outside a provider: the numbers are not clickable there. */
|
||||||
|
export const useStatDrill = () => useContext(StatDrillContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
|
||||||
|
*/
|
||||||
|
export function useDrillTarget({to, params, title}) {
|
||||||
|
const drill = useStatDrill();
|
||||||
|
if (to && drill?.source === 'own') return {to};
|
||||||
|
if (drill && params) return {onClick: () => drill.open(title, params)};
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `(key) => onSelect` of TagBars for a key with the tag name as its argument; null outside a provider. */
|
||||||
|
export function useTagDrill() {
|
||||||
|
const drill = useStatDrill();
|
||||||
|
return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bar row: a button when it has `onSelect`. */
|
||||||
|
export function DrillRow({onSelect, className, children}) {
|
||||||
|
return onSelect ? (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
className={cn('block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring', className)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
) : <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrillButton({onClick, label, className, children}) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} aria-label={label} className={cn(DRILL_CLASS, className)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number (or any inline content) that opens the games behind it. Plain content when it has no target.
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
export function StatCount({to, params, title, label, className, children}) {
|
||||||
|
const target = useDrillTarget({to, params, title});
|
||||||
|
if (!target) return children;
|
||||||
|
return target.to
|
||||||
|
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
|
||||||
|
: <DrillButton onClick={target.onClick} label={label} className={className}>{children}</DrillButton>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
|
||||||
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import GameRow from './GameRow.jsx';
|
||||||
|
import GameStatusBadge from '../GameStatusBadge.jsx';
|
||||||
|
import {formatDate} from '../../utils/format.js';
|
||||||
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
|
import {fetchStatGames} from '../../utils/statGames.js';
|
||||||
|
|
||||||
|
const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The games behind one number, 40 per page.
|
||||||
|
*
|
||||||
|
* @param request `{title, source, ticket, params}` or null (closed)
|
||||||
|
*/
|
||||||
|
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const [state, setState] = useState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
||||||
|
const [status, setStatus] = useState('loading');
|
||||||
|
const openRequest = useRef(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (page, current) => {
|
||||||
|
setStatus('loading');
|
||||||
|
const {ok, status: code, data} = await fetchStatGames(request.source, {...request.params, page}, request.ticket);
|
||||||
|
if (openRequest.current !== request) return;
|
||||||
|
if (!ok) {
|
||||||
|
setStatus(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
games: [...current, ...data.content], page, totalPages: data.totalPages, total: data.totalElements,
|
||||||
|
untracked: data.untracked ?? 0,
|
||||||
|
});
|
||||||
|
setStatus('ok');
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
openRequest.current = request;
|
||||||
|
if (!request) return;
|
||||||
|
setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
||||||
|
load(0, []);
|
||||||
|
}, [request, load]);
|
||||||
|
|
||||||
|
const value = (game) => {
|
||||||
|
if (request.source === 'community') {
|
||||||
|
return `${t('STATS.DRILL.IN_LIBRARIES', {count: game.libraries})} · ${formatPlaytime(game.minutes)}`;
|
||||||
|
}
|
||||||
|
const moved = game.fromStatus || game.toStatus;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!moved && game.status && <GameStatusBadge status={game.status} compact/>}
|
||||||
|
{moved && (
|
||||||
|
<span>{game.fromStatus ? t(`BACKLOG.STATUS.${game.fromStatus}`) : t('STATS.DRILL.NEW_IN_LIBRARY')}
|
||||||
|
{' → '}{game.toStatus ? t(`BACKLOG.STATUS.${game.toStatus}`) : '–'}</span>
|
||||||
|
)}
|
||||||
|
<span>{game.at
|
||||||
|
? formatDate(game.at, i18n.language, {dateStyle: 'medium'})
|
||||||
|
: formatPlaytime(game.minutes ?? game.playtimeMinutes ?? 0)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const failed = typeof status === 'number';
|
||||||
|
return (
|
||||||
|
<Dialog open={request != null} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent onCloseAutoFocus={onCloseAutoFocus}
|
||||||
|
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
|
||||||
|
<DialogTitle className="pr-6">{request?.title}</DialogTitle>
|
||||||
|
<DialogDescription>{status === 'ok' ? t('STATS.DRILL.GAMES', {count: state.total}) : ' '}</DialogDescription>
|
||||||
|
{request && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{state.games.map((game, index) => (
|
||||||
|
<li key={`${game.appid}-${index}`}>
|
||||||
|
<GameRow game={game} value={value(game)} linked={request.source === 'own'}/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{status === 'loading' && <Skeleton className="h-24 rounded-xl"/>}
|
||||||
|
{status === 'ok' && state.games.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('STATS.DRILL.EMPTY')}</p>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && state.untracked > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('STATS.FINISHED.BEFORE', {count: state.untracked})}</p>
|
||||||
|
)}
|
||||||
|
{failed && (
|
||||||
|
<div className="flex flex-col items-start gap-2 text-sm text-muted-foreground">
|
||||||
|
<p>{t(ERRORS[status] ?? 'STATS.DRILL.ERROR')}</p>
|
||||||
|
{status === 401
|
||||||
|
? <Button variant="outline" size="sm" className="min-h-11" onClick={() => globalThis.location.reload()}>
|
||||||
|
{t('STATS.DRILL.RELOAD')}</Button>
|
||||||
|
: status !== 403 && (
|
||||||
|
<Button variant="outline" size="sm" className="min-h-11"
|
||||||
|
onClick={() => load(state.page + (state.games.length > 0 ? 1 : 0), state.games)}>
|
||||||
|
{t('DASHBOARD.RETRY')}</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && state.page + 1 < state.totalPages && (
|
||||||
|
<Button variant="outline" className="min-h-11" onClick={() => load(state.page + 1, state.games)}>
|
||||||
|
{t('STATS.DRILL.LOAD_MORE')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import StatGamesDialog from './StatGamesDialog.jsx';
|
||||||
|
import {StatCount, StatDrillProvider} from './StatDrill.jsx';
|
||||||
|
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const GAMES = '/api/library/stats/games';
|
||||||
|
const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => (
|
||||||
|
{appid: from + i, name: `Game ${from + i}`, status: 'BACKLOG', playtimeMinutes: 60, ...extra}));
|
||||||
|
const page = (content, index, totalPages, totalElements) =>
|
||||||
|
json(200, {content, page: index, size: 40, totalElements, totalPages});
|
||||||
|
|
||||||
|
const renderDialog = (request, onClose = () => {}) => render(
|
||||||
|
<MemoryRouter><StatGamesDialog request={{title: 'Shooter', source: 'own', params: {key: 'GENRE', arg: 'Shooter'}, ...request}}
|
||||||
|
onClose={onClose}/></MemoryRouter>);
|
||||||
|
|
||||||
|
describe('StatGamesDialog', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('loadsPageZeroAndAppendsTheNextOne', async () => {
|
||||||
|
fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1')
|
||||||
|
? page(rows(41, 5), 1, 2, 45)
|
||||||
|
: page(rows(1, 40), 0, 2, 45)});
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 40')).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(40);
|
||||||
|
expect(fetch.mock.calls[0][0]).toContain('key=GENRE&arg=Shooter&page=0');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'STATS.DRILL.LOAD_MORE'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 45')).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(45);
|
||||||
|
expect(screen.queryByRole('button', {name: 'STATS.DRILL.LOAD_MORE'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closesOnEscape', async () => {
|
||||||
|
fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)});
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderDialog({}, onClose);
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offersARetryAfterAnError', async () => {
|
||||||
|
let fail = true;
|
||||||
|
fetchRoutes({[`GET ${GAMES}`]: () => (fail ? json(500, {}) : page(rows(1, 1), 0, 1, 1))});
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText('STATS.DRILL.ERROR')).toBeTruthy();
|
||||||
|
fail = false;
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.RETRY'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saysSoWhenTheOwnerDoesNotShareTheNumber', async () => {
|
||||||
|
fetchRoutes({'GET /api/public/profiles/library/stats/games': json(403, {code: 'NOT_SHARED'})});
|
||||||
|
renderDialog({source: 'profile', ticket: 't'});
|
||||||
|
|
||||||
|
expect(await screen.findByText('STATS.DRILL.NOT_SHARED')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('button', {name: 'DASHBOARD.RETRY'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asksForAReloadWhenTheTicketExpired', async () => {
|
||||||
|
const mock = fetchRoutes({'GET /api/public/profiles/library/stats/games': json(401, {code: 'INVALID_TICKET'})});
|
||||||
|
renderDialog({source: 'profile', ticket: 'old'});
|
||||||
|
|
||||||
|
expect(await screen.findByText('STATS.DRILL.EXPIRED')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', {name: 'STATS.DRILL.RELOAD'})).toBeTruthy();
|
||||||
|
expect(mock.mock.calls[0][1].headers['X-Profile-Ticket']).toBe('old');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheStatusMoveOfATrendRow', async () => {
|
||||||
|
fetchRoutes({[`GET ${GAMES}`]: page(
|
||||||
|
[{appid: 5, name: 'Moved', fromStatus: 'BACKLOG', toStatus: 'PLAYING', at: '2026-09-20T10:00:00Z'}], 0, 1, 1)});
|
||||||
|
renderDialog({params: {key: 'TREND_MOVES'}});
|
||||||
|
|
||||||
|
await screen.findByText('Moved');
|
||||||
|
|
||||||
|
expect(screen.getByText(/BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheLibraryCountOfACommunityRow', async () => {
|
||||||
|
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
|
||||||
|
renderDialog({source: 'community', params: {key: 'OWNED'}});
|
||||||
|
|
||||||
|
await screen.findByText('Shared');
|
||||||
|
|
||||||
|
expect(screen.getByText('STATS.DRILL.IN_LIBRARIES:3 · 2.0h')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focusReturnsToTheClickedNumber', async () => {
|
||||||
|
fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)});
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own">
|
||||||
|
<StatCount params={{key: 'PLAYED'}} title="Played" label="Played: 1">1</StatCount>
|
||||||
|
</StatDrillProvider></MemoryRouter>);
|
||||||
|
const number = screen.getByRole('button', {name: 'Played: 1'});
|
||||||
|
number.focus();
|
||||||
|
fireEvent.click(number);
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(number);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aLateAnswerOfAClosedListDoesNotFillTheOpenOne', async () => {
|
||||||
|
let answerOld;
|
||||||
|
fetchRoutes({[`GET ${GAMES}`]: (url) => (url.includes('arg=Old')
|
||||||
|
? new Promise((resolve) => { answerOld = resolve; })
|
||||||
|
: page([{appid: 2, name: 'New game', playtimeMinutes: 1}], 0, 1, 1))});
|
||||||
|
const request = (arg) => ({title: arg, source: 'own', params: {key: 'GENRE', arg}});
|
||||||
|
const view = render(<MemoryRouter><StatGamesDialog request={request('Old')} onClose={() => {}}/></MemoryRouter>);
|
||||||
|
view.rerender(<MemoryRouter><StatGamesDialog request={request('New')} onClose={() => {}}/></MemoryRouter>);
|
||||||
|
await screen.findByText('New game');
|
||||||
|
|
||||||
|
await act(async () => answerOld(page([{appid: 1, name: 'Old game', playtimeMinutes: 1}], 0, 1, 1)));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Old game')).toBeNull();
|
||||||
|
expect(screen.getByText('New game')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
export default function StatTile({label, value, hint}) {
|
import {Link} from 'react-router-dom';
|
||||||
return (
|
|
||||||
<div className="rounded-lg border p-4">
|
import {cn} from '@/lib/utils';
|
||||||
|
import {useDrillTarget} from './StatDrill.jsx';
|
||||||
|
|
||||||
|
const CLICKABLE = 'block w-full text-left transition-colors hover:border-primary focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param drill `{to, params, title}` of StatCount: the tile is a link or button when it has a target here
|
||||||
|
*/
|
||||||
|
export default function StatTile({label, value, hint, drill}) {
|
||||||
|
const target = useDrillTarget({title: label, ...drill});
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
|
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
|
||||||
<div className="text-sm text-muted-foreground">{label}</div>
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
const className = 'rounded-lg border p-4';
|
||||||
|
if (target?.to) return <Link to={target.to} className={cn(className, CLICKABLE)}>{body}</Link>;
|
||||||
|
if (target) return <button type="button" onClick={target.onClick} className={cn(className, CLICKABLE)}>{body}</button>;
|
||||||
|
return <div className={className}>{body}</div>;
|
||||||
}
|
}
|
||||||
@@ -2,26 +2,32 @@ 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 {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';
|
||||||
|
|
||||||
export function TagBars({tags, label = (name) => name}) {
|
/**
|
||||||
|
* @param onSelect `(tag, shownLabel)`: the rows are buttons
|
||||||
|
*/
|
||||||
|
export function TagBars({tags, label = (name) => name, onSelect}) {
|
||||||
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">
|
||||||
<div className="flex justify-between gap-2">
|
<DrillRow onSelect={onSelect && (() => onSelect(tag, label(tag.name)))}>
|
||||||
<span className="truncate">{label(tag.name)}</span>
|
<div className="flex justify-between gap-2">
|
||||||
<span className="text-muted-foreground tabular-nums">
|
<span className="truncate">{label(tag.name)}</span>
|
||||||
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
|
<span className="text-muted-foreground tabular-nums">
|
||||||
</span>
|
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
|
||||||
</div>
|
</span>
|
||||||
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
</div>
|
||||||
<div className="h-full rounded-full bg-primary"
|
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||||
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
<div className="h-full rounded-full bg-primary"
|
||||||
</div>
|
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
||||||
|
</div>
|
||||||
|
</DrillRow>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -30,6 +36,7 @@ export function TagBars({tags, label = (name) => name}) {
|
|||||||
|
|
||||||
export default function TasteStats({taste}) {
|
export default function TasteStats({taste}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const select = useTagDrill();
|
||||||
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}>
|
||||||
@@ -42,12 +49,12 @@ export default function TasteStats({taste}) {
|
|||||||
<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}
|
<TagBars tags={taste.genres} onSelect={select('GENRE')}
|
||||||
label={(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} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
|
<TagBars tags={taste.moods} onSelect={select('MOOD')} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Trophy} from 'lucide-react';
|
import {Trophy} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
import GameRow from './GameRow.jsx';
|
import GameRow from './GameRow.jsx';
|
||||||
|
import {useStatDrill} from './StatDrill.jsx';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
|
|
||||||
export default function TopGamesStats({topGames, linked = true}) {
|
export default function TopGamesStats({topGames, linked = true}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const drill = useStatDrill();
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
||||||
{topGames.mostPlayed.length === 0 ? (
|
{topGames.mostPlayed.length === 0 ? (
|
||||||
@@ -18,6 +21,12 @@ export default function TopGamesStats({topGames, linked = true}) {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
{drill && topGames.mostPlayed.length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" className="self-start max-sm:min-h-11"
|
||||||
|
onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}>
|
||||||
|
{t('STATS.DRILL.SHOW_ALL')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{topGames.mostLoyal && (
|
{topGames.mostLoyal && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>
|
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>
|
||||||
|
|||||||
+14
-11
@@ -23,6 +23,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
|
|||||||
import useUserStats from '../hooks/useUserStats.js';
|
import useUserStats from '../hooks/useUserStats.js';
|
||||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
import TileGrid from '../components/TileGrid.jsx';
|
import TileGrid from '../components/TileGrid.jsx';
|
||||||
|
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
|
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
|
||||||
@@ -122,18 +123,20 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<StatDrillProvider source="own">
|
||||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
equipped={equipped}
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
equipped={equipped}
|
||||||
|
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||||
|
|
||||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||||
<WhatsNewDialog/>
|
<WhatsNewDialog/>
|
||||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||||
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
|
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
|
||||||
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
</StatDrillProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+105
-70
@@ -14,12 +14,16 @@ import Logo from '../components/Logo.jsx';
|
|||||||
import StatCard from '../components/StatCard.jsx';
|
import StatCard from '../components/StatCard.jsx';
|
||||||
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
||||||
import {TagBars} from '../components/stats/TasteStats.jsx';
|
import {TagBars} from '../components/stats/TasteStats.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';
|
||||||
|
|
||||||
function RankedGames({title, games, formatValue}) {
|
function RankedGames({title, games, formatValue, drillKey}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const drill = useStatDrill();
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0 py-0">
|
<Card className="gap-0 py-0">
|
||||||
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
|
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
|
||||||
@@ -31,15 +35,58 @@ function RankedGames({title, games, formatValue}) {
|
|||||||
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
|
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
|
||||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
|
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
|
||||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
|
||||||
<span className="@container min-w-0 flex-1 font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></span>
|
<GameName game={g}/>
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
|
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
{drill && (
|
||||||
|
<Button variant="ghost" className="m-2 self-start max-sm:min-h-11"
|
||||||
|
onClick={() => drill.open(title, {key: drillKey})}>
|
||||||
|
{t('STATS.DRILL.SHOW_ALL')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GameName({game}) {
|
||||||
|
const href = gameHref(game.appid, false);
|
||||||
|
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommunityBars({core}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const drill = useStatDrill();
|
||||||
|
const select = useTagDrill();
|
||||||
|
const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name});
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{core?.gamesPerPlatform?.length > 0 && (
|
||||||
|
<BarsCard title={t('LANDING.PLATFORMS')}>
|
||||||
|
<TagBars tags={core.gamesPerPlatform} onSelect={select('PLATFORM')}/>
|
||||||
|
</BarsCard>
|
||||||
|
)}
|
||||||
|
{core?.topGenres?.length > 0 && (
|
||||||
|
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
||||||
|
<TagBars tags={core.topGenres} label={genreLabel} onSelect={select('GENRE')}/>
|
||||||
|
</BarsCard>
|
||||||
|
)}
|
||||||
|
{core?.finishedPerYear?.length > 0 && (
|
||||||
|
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
||||||
|
<YearBars perYear={core.finishedPerYear}
|
||||||
|
onSelect={drill && ((year) => drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||||
|
</BarsCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BarsCard({title, children}) {
|
function BarsCard({title, children}) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0 py-0">
|
<Card className="gap-0 py-0">
|
||||||
@@ -60,81 +107,69 @@ export default function LandingPage() {
|
|||||||
const stats = [
|
const stats = [
|
||||||
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
|
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
|
||||||
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
|
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
|
||||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2],
|
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
||||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles],
|
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
||||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks],
|
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
|
||||||
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
|
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
|
||||||
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
|
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
|
||||||
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
|
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grow">
|
<StatDrillProvider source="community">
|
||||||
<section className="relative overflow-hidden border-b">
|
<div className="grow">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
|
<section className="relative overflow-hidden border-b">
|
||||||
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
|
||||||
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
|
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
|
||||||
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
|
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
|
||||||
</Badge>
|
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
|
||||||
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
</Badge>
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
||||||
<Button asChild size="lg" className="mt-10">
|
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
||||||
{active ? (
|
<Button asChild size="lg" className="mt-10">
|
||||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
{active ? (
|
||||||
) : (
|
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
) : (
|
||||||
|
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
|
||||||
|
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{isLoading
|
||||||
|
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||||
|
: stats.map(([label, value, icon, suffix, key]) => (
|
||||||
|
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
|
||||||
|
drill={key && {params: {key}}}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{core?.topPlayedGames?.length > 0 && (
|
||||||
|
<RankedGames
|
||||||
|
title={t('LANDING.TOP_PLAYED')}
|
||||||
|
games={core.topPlayedGames}
|
||||||
|
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
||||||
|
drillKey="MOST_PLAYED"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
{core?.topOwnedGames?.length > 0 && (
|
||||||
</div>
|
<RankedGames
|
||||||
</section>
|
title={t('LANDING.TOP_OWNED')}
|
||||||
|
games={core.topOwnedGames}
|
||||||
|
formatValue={(count) => count.toLocaleString(i18n.language)}
|
||||||
|
drillKey="OWNED"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
|
<CommunityBars core={core}/>
|
||||||
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
</section>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
</StatDrillProvider>
|
||||||
{isLoading
|
|
||||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
|
||||||
: stats.map(([label, value, icon, suffix]) => (
|
|
||||||
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
{core?.topPlayedGames?.length > 0 && (
|
|
||||||
<RankedGames
|
|
||||||
title={t('LANDING.TOP_PLAYED')}
|
|
||||||
games={core.topPlayedGames}
|
|
||||||
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{core?.topOwnedGames?.length > 0 && (
|
|
||||||
<RankedGames
|
|
||||||
title={t('LANDING.TOP_OWNED')}
|
|
||||||
games={core.topOwnedGames}
|
|
||||||
formatValue={(count) => count.toLocaleString(i18n.language)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
||||||
{core?.gamesPerPlatform?.length > 0 && (
|
|
||||||
<BarsCard title={t('LANDING.PLATFORMS')}>
|
|
||||||
<TagBars tags={core.gamesPerPlatform}/>
|
|
||||||
</BarsCard>
|
|
||||||
)}
|
|
||||||
{core?.topGenres?.length > 0 && (
|
|
||||||
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
|
||||||
<TagBars tags={core.topGenres}
|
|
||||||
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
|
|
||||||
</BarsCard>
|
|
||||||
)}
|
|
||||||
{core?.finishedPerYear?.length > 0 && (
|
|
||||||
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
|
||||||
<YearBars perYear={core.finishedPerYear}/>
|
|
||||||
</BarsCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, 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 LandingPage from './LandingPage.jsx';
|
import LandingPage from './LandingPage.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
@@ -42,4 +42,20 @@ describe('LandingPage', () => {
|
|||||||
expect(screen.queryByText(`LANDING.${title}`)).toBeNull();
|
expect(screen.queryByText(`LANDING.${title}`)).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
|
||||||
|
'GET /api/stats/public/games': json(200, {content: [{appid: 3, name: 'Common', libraries: 2, minutes: 60}],
|
||||||
|
page: 0, size: 40, totalElements: 1, totalPages: 1}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /LANDING\.TOTAL_GAMES/}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Common')).toBeTruthy();
|
||||||
|
expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/public/games?key=ENTRIES&page=0');
|
||||||
|
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+95
-79
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
|
|||||||
import EffectBackground from '../components/EffectBackground.jsx';
|
import EffectBackground from '../components/EffectBackground.jsx';
|
||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import useInView from '../hooks/useInView.js';
|
import useInView from '../hooks/useInView.js';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
@@ -60,95 +61,103 @@ export default function PointsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
|
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
|
||||||
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
|
if (['MONTH', 'STREAK'].includes(entry.kind)) {
|
||||||
|
return (
|
||||||
|
<StatCount params={{kind: entry.kind, ref: entry.ref}} title={`${t(`POINTS.KINDS.${entry.kind}`)} ${entry.ref}`}>
|
||||||
|
{entry.ref}
|
||||||
|
</StatCount>
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<StatDrillProvider source="points">
|
||||||
<div>
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
<div>
|
||||||
<p className="mt-2 flex items-center gap-2 text-lg">
|
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
||||||
<Coins className="size-5 text-primary"/>
|
<p className="mt-2 flex items-center gap-2 text-lg">
|
||||||
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
<Coins className="size-5 text-primary"/>
|
||||||
</p>
|
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="gap-2 p-5">
|
<Card className="gap-2 p-5">
|
||||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||||
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
||||||
{SLOTS.map((slot) => (
|
{SLOTS.map((slot) => (
|
||||||
<Card key={slot} className="gap-3 p-5">
|
<Card key={slot} className="gap-3 p-5">
|
||||||
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
||||||
const owned = wallet.owned.includes(item.code);
|
const owned = wallet.owned.includes(item.code);
|
||||||
const worn = wallet.equipped[slot] === item.code;
|
const worn = wallet.equipped[slot] === item.code;
|
||||||
const locked = !owned && item.track;
|
const locked = !owned && item.track;
|
||||||
return (
|
return (
|
||||||
<li key={item.code} className="flex items-center gap-3 py-3">
|
<li key={item.code} className="flex items-center gap-3 py-3">
|
||||||
<Preview code={item.code}/>
|
<Preview code={item.code}/>
|
||||||
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
||||||
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
||||||
{owned ? (
|
{owned ? (
|
||||||
<Button size="sm" variant="outline"
|
<Button size="sm" variant="outline"
|
||||||
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
||||||
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
||||||
</Button>
|
|
||||||
) : locked ? (
|
|
||||||
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
|
||||||
<Button size="sm" disabled={wallet.balance < item.price}
|
|
||||||
onClick={() => act(() => buyItem(item.code))}>
|
|
||||||
{t('POINTS.BUY')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
) : locked ? (
|
||||||
|
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
||||||
|
<Button size="sm" disabled={wallet.balance < item.price}
|
||||||
|
onClick={() => act(() => buyItem(item.code))}>
|
||||||
|
{t('POINTS.BUY')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="gap-3 p-5">
|
||||||
|
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
||||||
|
{wallet.history.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{wallet.history.map((entry) => {
|
||||||
|
const label = entryLabel(entry);
|
||||||
|
return (
|
||||||
|
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
||||||
|
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
||||||
|
{formatDate(entry.eventAt, i18n.language)}
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 gap-1">
|
||||||
|
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
||||||
|
{label && <span className="min-w-0 truncate">{label}</span>}
|
||||||
|
</span>
|
||||||
|
{entry.points !== 0 && (
|
||||||
|
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||||
|
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
)}
|
||||||
))}
|
</Card>
|
||||||
</section>
|
</PageContainer>
|
||||||
|
</StatDrillProvider>
|
||||||
<Card className="gap-3 p-5">
|
|
||||||
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
|
||||||
{wallet.history.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y">
|
|
||||||
{wallet.history.map((entry) => {
|
|
||||||
const label = entryLabel(entry);
|
|
||||||
return (
|
|
||||||
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
|
||||||
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
|
||||||
{formatDate(entry.eventAt, i18n.language)}
|
|
||||||
</span>
|
|
||||||
<span className="flex min-w-0 flex-1 gap-1">
|
|
||||||
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
|
||||||
{label && <span className="min-w-0 truncate">{label}</span>}
|
|
||||||
</span>
|
|
||||||
{entry.points !== 0 && (
|
|
||||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
|
||||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +201,15 @@ function LockedProgress({item, progress}) {
|
|||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const count = Math.min(progress, item.target);
|
const count = Math.min(progress, item.target);
|
||||||
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.
|
||||||
|
const target = useDrillTarget({
|
||||||
|
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`),
|
||||||
|
});
|
||||||
|
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
||||||
|
const Wrapper = target ? 'button' : 'div';
|
||||||
return (
|
return (
|
||||||
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
|
<Wrapper {...(target && {type: 'button', onClick: target.onClick})}
|
||||||
|
className={cn(className, target && 'min-h-11 text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm')}>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<LockKeyhole className="size-3 shrink-0"/>
|
<LockKeyhole className="size-3 shrink-0"/>
|
||||||
<span title={text}>{text}</span>
|
<span title={text}>{text}</span>
|
||||||
@@ -202,6 +218,6 @@ function LockedProgress({item, progress}) {
|
|||||||
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
|
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
|
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,34 @@ describe('PointsPage', () => {
|
|||||||
const bar = within(item).getByRole('progressbar');
|
const bar = within(item).getByRole('progressbar');
|
||||||
expect(bar.getAttribute('aria-valuenow')).toBe('7');
|
expect(bar.getAttribute('aria-valuenow')).toBe('7');
|
||||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||||
|
expect(within(item).queryByRole('button', {name: 'POINTS.BUY'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the games behind a locked bar, and the history of a month', async () => {
|
||||||
|
const page = (appid) => json(200, {content: [{appid, name: `G${appid}`, status: 'FINISHED', at: '2026-09-01T10:00:00Z'}],
|
||||||
|
page: 0, size: 40, totalElements: 1, totalPages: 1, untracked: 3});
|
||||||
|
await renderPage({
|
||||||
|
'GET /api/rewards/progress/GAMES_FINISHED/games': page(1),
|
||||||
|
'GET /api/rewards/history/MONTH/2026-09/games': page(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bar = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||||
|
fireEvent.click(within(bar).getByRole('button'));
|
||||||
|
expect(await screen.findByText('G1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('STATS.FINISHED.BEFORE:3')).toBeTruthy();
|
||||||
|
fireEvent.keyDown(document.activeElement, {key: 'Escape'});
|
||||||
|
await waitFor(() => expect(screen.queryByText('G1')).toBeNull());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: '2026-09'}));
|
||||||
|
expect(await screen.findByText('G2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no button on a playtime-month bar', async () => {
|
||||||
|
await renderPage({'GET /api/rewards/wallet': json(200, wallet({
|
||||||
|
catalog: [{code: 'BADGE_MONTHS', slot: 'BADGE', price: 0, track: 'PLAY_MORE_MONTHS', target: 3}],
|
||||||
|
}))});
|
||||||
|
|
||||||
|
const item = screen.getByText('POINTS.ITEMS.BADGE_MONTHS').closest('li');
|
||||||
expect(within(item).queryByRole('button')).toBeNull();
|
expect(within(item).queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import HeroBanner from '../components/HeroBanner.jsx';
|
|||||||
import PageBackground from '../components/PageBackground.jsx';
|
import PageBackground from '../components/PageBackground.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import TileGrid from '../components/TileGrid.jsx';
|
import TileGrid from '../components/TileGrid.jsx';
|
||||||
|
import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||||
|
|
||||||
// Games per tile size; Core sends at most 8 playing and 12 finished games.
|
// Games per tile size; Core sends at most 8 playing and 12 finished games.
|
||||||
@@ -177,8 +178,11 @@ export default function PublicProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={owner && editing}
|
<StatDrillProvider source="profile" ticket={profile.ticket}>
|
||||||
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
||||||
|
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
||||||
|
onCancel={() => setEditing(false)}/>
|
||||||
|
</StatDrillProvider>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -187,22 +191,25 @@ export default function PublicProfilePage() {
|
|||||||
function Summary({library, compact}) {
|
function Summary({library, compact}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const stats = [
|
const stats = [
|
||||||
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
|
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES'), 'TOTAL', library.totalGames],
|
||||||
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
|
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
|
||||||
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')],
|
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames],
|
||||||
];
|
];
|
||||||
if (library.averageCompletion != null) {
|
if (library.averageCompletion != null) {
|
||||||
stats.push(
|
stats.push(
|
||||||
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
|
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames],
|
||||||
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
|
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
|
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
|
||||||
{stats.map(([Icon, value, label]) => (
|
{stats.map(([Icon, value, label, key, count]) => (
|
||||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||||
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
||||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
<div className="text-xl md:text-2xl font-bold tabular-nums">
|
||||||
|
<StatCount params={count > 0 ? {key} : null} title={label}
|
||||||
|
label={`${label}: ${value}`}>{value}</StatCount>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -225,7 +232,10 @@ function StatusBar({byStatus}) {
|
|||||||
<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}`)}
|
||||||
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
|
<StatCount params={byStatus[s] > 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)}
|
||||||
|
label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}>
|
||||||
|
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
|
||||||
|
</StatCount>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ const library = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// `me` only for a test with a session: without one the page asks nothing about the viewer.
|
// `me` only for a test with a session: without one the page asks nothing about the viewer.
|
||||||
function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) {
|
function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200, extra = {}} = {}) {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
[`GET /api/public/profiles/${encodeURIComponent(name)}`]:
|
[`GET /api/public/profiles/${encodeURIComponent(name)}`]:
|
||||||
json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
|
json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
|
||||||
'GET /api/public/profiles/library': json(libraryStatus, answer),
|
'GET /api/public/profiles/library': json(libraryStatus, answer),
|
||||||
'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}),
|
'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}),
|
||||||
|
...extra,
|
||||||
...(me && {
|
...(me && {
|
||||||
'GET /api/users/me': json(200, me),
|
'GET /api/users/me': json(200, me),
|
||||||
'PUT /api/users/settings/profile-tiles': json(204, {}),
|
'PUT /api/users/settings/profile-tiles': json(204, {}),
|
||||||
@@ -260,4 +261,33 @@ describe('PublicProfilePage', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
|
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opensTheGamesBehindAStatusCountWithTheTicket', async () => {
|
||||||
|
mockBackends({extra: {'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 {container} = renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Backlogged')).toBeTruthy();
|
||||||
|
const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games'));
|
||||||
|
expect(call[0]).toContain('key=STATUS&arg=BACKLOG');
|
||||||
|
expect(call[1].headers['X-Profile-Ticket']).toBe('the-ticket');
|
||||||
|
expect(container.querySelector('a[href^="/library"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rendersNoButtonForAGroupTheOwnerDoesNotShare', async () => {
|
||||||
|
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
|
||||||
|
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
|
||||||
|
try {
|
||||||
|
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('STATS.HABITS.TITLE');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: /STATS\.PILE/})).toBeNull();
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+33
-30
@@ -12,6 +12,7 @@ import TasteStats from '../components/stats/TasteStats.jsx';
|
|||||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
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 useUserStats from '../hooks/useUserStats.js';
|
import useUserStats from '../hooks/useUserStats.js';
|
||||||
|
|
||||||
const ALL_GROUPS = [];
|
const ALL_GROUPS = [];
|
||||||
@@ -21,38 +22,40 @@ export default function StatsPage() {
|
|||||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<StatDrillProvider source="own">
|
||||||
<div>
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
<div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
||||||
</div>
|
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
||||||
|
|
||||||
{failed && (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
|
||||||
{t('STATS.ERROR_LOAD')}
|
|
||||||
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!stats && !failed && (
|
{failed && (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
||||||
<Skeleton className="h-48 rounded-xl"/>
|
{t('STATS.ERROR_LOAD')}
|
||||||
<Skeleton className="h-48 rounded-xl"/>
|
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stats && (
|
{!stats && !failed && (
|
||||||
<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>
|
<Skeleton className="h-48 rounded-xl"/>
|
||||||
<PileStats pile={stats.pile}/>
|
<Skeleton className="h-48 rounded-xl"/>
|
||||||
<TrendStats trend={stats.trend}/>
|
</div>
|
||||||
<TopGamesStats topGames={stats.topGames}/>
|
)}
|
||||||
<TasteStats taste={stats.taste}/>
|
|
||||||
<PlatformStats platforms={stats.platforms}/>
|
{stats && (
|
||||||
<HabitsStats habits={stats.habits}/>
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<FinishedStats finished={stats.finished}/>
|
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||||
</div>
|
<PileStats pile={stats.pile}/>
|
||||||
)}
|
<TrendStats trend={stats.trend}/>
|
||||||
</PageContainer>
|
<TopGamesStats topGames={stats.topGames}/>
|
||||||
|
<TasteStats taste={stats.taste}/>
|
||||||
|
<PlatformStats platforms={stats.platforms}/>
|
||||||
|
<HabitsStats habits={stats.habits}/>
|
||||||
|
<FinishedStats finished={stats.finished}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
</StatDrillProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,4 +89,36 @@ describe('StatsPage', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opensTheGamesBehindARowWithTheRawName', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/library/stats': json(200, stats()),
|
||||||
|
'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}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /STATS\.GENRE/}));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=GENRE&arg=Shooter&page=0$/));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linksAStatusCountToTheFilteredLibrary', async () => {
|
||||||
|
answerStats(json(200, stats()));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 5'});
|
||||||
|
|
||||||
|
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leavesTheMoonTripsAlone', async () => {
|
||||||
|
answerStats(json(200, stats()));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('STATS.HABITS.MOON');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {request} from './api.js';
|
||||||
|
|
||||||
|
const CORE = () => import.meta.env.VITE_CORE_API_URL;
|
||||||
|
|
||||||
|
const query = (params) => params
|
||||||
|
.filter(([, value]) => value != null && value !== '')
|
||||||
|
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of one page of the games behind a number.
|
||||||
|
*
|
||||||
|
* @param source own, profile, community or points
|
||||||
|
* @param track points: the unlock track of a locked bar
|
||||||
|
* @param kind points: the history kind (MONTH or STREAK) with its `ref`
|
||||||
|
*/
|
||||||
|
export function statGamesUrl({source, key, arg, page = 0, track, kind, ref}) {
|
||||||
|
const base = `${CORE()}/api`;
|
||||||
|
if (source === 'points') {
|
||||||
|
const path = track
|
||||||
|
? `progress/${encodeURIComponent(track)}`
|
||||||
|
: `history/${encodeURIComponent(kind)}/${encodeURIComponent(ref)}`;
|
||||||
|
return `${base}/rewards/${path}/games?${query([['page', page]])}`;
|
||||||
|
}
|
||||||
|
const path = {
|
||||||
|
own: 'library/stats/games',
|
||||||
|
profile: 'public/profiles/library/stats/games',
|
||||||
|
community: 'stats/public/games',
|
||||||
|
}[source];
|
||||||
|
return `${base}/${path}?${query([['key', key], ['arg', arg], ['page', page]])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{ok, status, data}` like `request`. The profile and the community list are public: no login, no refresh.
|
||||||
|
*/
|
||||||
|
export async function fetchStatGames(source, params, ticket) {
|
||||||
|
const url = statGamesUrl({source, ...params});
|
||||||
|
if (source !== 'profile' && source !== 'community') return request(url);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, source === 'profile' ? {headers: {'X-Profile-Ticket': ticket}} : undefined);
|
||||||
|
return {ok: response.ok, status: response.status, data: response.ok ? await response.json() : null};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Request to ${url} failed:`, err);
|
||||||
|
return {ok: false, status: 0, data: null};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, own) => {
|
||||||
|
if (own) return `/library/${appid}`;
|
||||||
|
return appid > 0 ? `https://store.steampowered.com/app/${appid}` : null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import {gameHref, libraryLink, statGamesUrl} from './statGames.js';
|
||||||
|
|
||||||
|
describe('statGamesUrl', () => {
|
||||||
|
it('own_EncodesTheArgAndPage', () => {
|
||||||
|
expect(statGamesUrl({source: 'own', key: 'GENRE', arg: 'Role-Playing & Co', page: 2}))
|
||||||
|
.toMatch(/\/api\/library\/stats\/games\?key=GENRE&arg=Role-Playing%20%26%20Co&page=2$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('profile_And_Community_UseTheirPaths', () => {
|
||||||
|
expect(statGamesUrl({source: 'profile', key: 'PILE'})).toMatch(/\/api\/public\/profiles\/library\/stats\/games\?key=PILE&page=0$/);
|
||||||
|
expect(statGamesUrl({source: 'community', key: 'OWNED'})).toMatch(/\/api\/stats\/public\/games\?key=OWNED&page=0$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points_TrackAndHistory', () => {
|
||||||
|
expect(statGamesUrl({source: 'points', track: 'GAMES_FINISHED'})).toMatch(/\/api\/rewards\/progress\/GAMES_FINISHED\/games\?page=0$/);
|
||||||
|
expect(statGamesUrl({source: 'points', kind: 'MONTH', ref: '2026-09', page: 1}))
|
||||||
|
.toMatch(/\/api\/rewards\/history\/MONTH\/2026-09\/games\?page=1$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('libraryLink', () => {
|
||||||
|
it('addsTheStatusOnlyWhenGiven', () => {
|
||||||
|
expect(libraryLink()).toBe('/library?hideDuplicates=true');
|
||||||
|
expect(libraryLink('BACKLOG')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gameHref', () => {
|
||||||
|
it('own_LinksTheLibraryPage', () => expect(gameHref(5, true)).toBe('/library/5'));
|
||||||
|
it('visitor_LinksTheStoreOnlyForSteamApps', () => {
|
||||||
|
expect(gameHref(5, false)).toBe('https://store.steampowered.com/app/5');
|
||||||
|
expect(gameHref(-3, false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user