Public Access
fix(stats): open game detail pages from stat lists, perfect games clickable
Add perfect games click-through on the dashboard. Remove Steam store links from game lists. Stat numbers with count 0 are not clickable. Tests cover the perfect-games click, zero guards, and owner versus visitor behavior.
This commit is contained in:
1 parent
777ba87fd6
commit
8b94a25844
27 files changed
+270
-51
No files matched your search
@@ -5,6 +5,7 @@ import {Trophy} from 'lucide-react';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import {StatCount} from './stats/StatDrill.jsx';
|
||||
|
||||
/**
|
||||
* @param summary the answer of GET /api/library/achievements/summary
|
||||
@@ -15,12 +16,14 @@ export default function AchievementsCard({summary}) {
|
||||
const started = summary.startedGames ?? 0;
|
||||
const pending = summary.pendingGames ?? 0;
|
||||
const hidden = summary.hiddenGames ?? 0;
|
||||
const perfect = summary.perfectGames ?? 0;
|
||||
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
|
||||
const steamHidesAll = hidden > 0 && started === 0;
|
||||
|
||||
const stats = [
|
||||
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
||||
[(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')],
|
||||
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
|
||||
{params: {key: 'PERFECT'}, count: perfect}],
|
||||
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
||||
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
||||
];
|
||||
@@ -33,9 +36,11 @@ export default function AchievementsCard({summary}) {
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map(([value, label]) => (
|
||||
{stats.map(([value, label, drill]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">
|
||||
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
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 AchievementsCard from './AchievementsCard.jsx';
|
||||
import {StatDrillProvider} from './stats/StatDrill.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -49,4 +51,22 @@ describe('AchievementsCard', () => {
|
||||
|
||||
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opensThePerfectGamesInTheOwnLibrary', async () => {
|
||||
fetchRoutes({'GET /api/library/stats/games': json(200, {
|
||||
content: [{appid: -7, name: 'Manual Game'}], page: 0, size: 40, totalElements: 1, totalPages: 1})});
|
||||
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary()}/></StatDrillProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.ACHIEVEMENTS.PERFECT: 2'}));
|
||||
|
||||
const link = await screen.findByRole('link', {name: /Manual Game/});
|
||||
expect(fetch.mock.calls[0][0]).toContain('key=PERFECT&page=0');
|
||||
expect(link.getAttribute('href')).toBe('/library/-7');
|
||||
});
|
||||
|
||||
it('perfectGamesAreNotClickableAtZero', () => {
|
||||
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary({perfectGames: 0})}/></StatDrillProvider></MemoryRouter>);
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
||||
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}}
|
||||
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}} count={backlog}
|
||||
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
|
||||
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
||||
</StatCount>
|
||||
@@ -77,7 +77,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
<StatCount to={libraryLink(s)} label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
|
||||
<StatCount to={libraryLink(s)} count={summary.byStatus[s]}
|
||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
|
||||
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
||||
</StatCount>
|
||||
</li>
|
||||
|
||||
@@ -65,6 +65,16 @@ describe('PileOfShameCard', () => {
|
||||
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('zeroCountsAreNotClickable', () => {
|
||||
render(<MemoryRouter><StatDrillProvider source="own">
|
||||
<PileOfShameCard onShuffle={() => {}} summary={summary(0)}/>
|
||||
</StatDrillProvider></MemoryRouter>);
|
||||
|
||||
expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 0'})).toBeNull();
|
||||
expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.FINISHED: 0'})).toBeNull();
|
||||
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the backlog hours when IGDB knows some', () => {
|
||||
renderCard({summary: summary(2, {backlogMinutes: 7440})});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function useCountUp(target, durationMs = 1600) {
|
||||
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
|
||||
const {i18n} = useTranslation();
|
||||
const animated = useCountUp(value);
|
||||
const target = useDrillTarget({title: label, ...drill});
|
||||
const target = useDrillTarget({title: label, count: value, ...drill});
|
||||
const card = (
|
||||
<Card className="gap-3 p-5">
|
||||
{Icon && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
|
||||
import {Card} from '@/components/ui/card';
|
||||
|
||||
const DURATION_MS = 1000;
|
||||
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
||||
|
||||
const SteamGameCount = ({targetCount}) => {
|
||||
const {t} = useTranslation();
|
||||
@@ -33,7 +34,9 @@ const SteamGameCount = ({targetCount}) => {
|
||||
<Gamepad2 className="size-5"/>
|
||||
</div>
|
||||
<div>
|
||||
<Link to="/library" className="block text-5xl font-bold tracking-tight tabular-nums hover:text-primary">{count}</Link>
|
||||
{targetCount > 0
|
||||
? <Link to="/library" className={`${COUNT_CLASS} hover:text-primary`}>{count}</Link>
|
||||
: <span className={COUNT_CLASS}>{count}</span>}
|
||||
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
|
||||
</div>
|
||||
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import SteamGameCount from './SteamGameCount.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
describe('SteamGameCount', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
||||
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
||||
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(1);
|
||||
|
||||
rerender(<MemoryRouter><SteamGameCount targetCount={5}/></MemoryRouter>);
|
||||
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
||||
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import GameCover from '../GameCover.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 in the user's own library, to the Steam
|
||||
* store for a visitor.
|
||||
* A game with its cover and a value on the right; links to the game page when the games are the viewer's own, plain
|
||||
* otherwise.
|
||||
*/
|
||||
export default function GameRow({game, value, linked = true}) {
|
||||
const {t} = useTranslation();
|
||||
const content = (
|
||||
<>
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
||||
@@ -17,14 +15,11 @@ export default function GameRow({game, value, linked = true}) {
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span>
|
||||
</>
|
||||
);
|
||||
const className = 'flex items-center gap-3 hover:text-primary transition-colors';
|
||||
if (linked) return <Link to={gameHref(game.appid, true)} className={className}>{content}</Link>;
|
||||
const href = gameHref(game.appid, false);
|
||||
if (href) {
|
||||
if (linked) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" title={t('STATS.DRILL.OPEN')} className={className}>
|
||||
<Link to={gameHref(game.appid)} className="flex items-center gap-3 hover:text-primary transition-colors">
|
||||
{content}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className="flex items-center gap-3">{content}</div>;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function HabitsStats({habits}) {
|
||||
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
||||
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
||||
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
|
||||
drill={{params: {key: 'UNDER_ONE_HOUR'}}}/>
|
||||
drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/>
|
||||
)}
|
||||
</div>
|
||||
</StatsSection>
|
||||
|
||||
@@ -15,18 +15,19 @@ export default function OverviewStats({overview}) {
|
||||
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
||||
drill={{to: libraryLink(), params: {key: 'TOTAL'}}}/>
|
||||
drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
|
||||
drill={{params: {key: 'PLAYED'}}}/>
|
||||
drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
|
||||
drill={{params: {key: 'NEVER_STARTED'}}}/>
|
||||
drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<li key={s}>
|
||||
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
||||
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
|
||||
count={overview.byStatus[s] ?? 0}
|
||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
|
||||
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||
</StatCount>
|
||||
|
||||
@@ -18,10 +18,10 @@ export default function PileStats({pile, linked = true}) {
|
||||
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}
|
||||
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}}}/>
|
||||
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}, count: pile.backlogGames}}/>
|
||||
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
||||
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}
|
||||
drill={{params: {key: 'PILE_TIME_TO_BEAT'}}}/>
|
||||
drill={{params: {key: 'PILE_TIME_TO_BEAT'}, count: pile.gamesWithTimeToBeat}}/>
|
||||
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
||||
</div>
|
||||
{pile.normalMinutes > 0 && (
|
||||
|
||||
@@ -15,8 +15,9 @@ const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max
|
||||
*
|
||||
* @param source own, profile, community or points
|
||||
* @param ticket profile: the ticket for the visitor's requests
|
||||
* @param linked the games are the viewer's own: rows link to the detail page
|
||||
*/
|
||||
export function StatDrillProvider({source, ticket, children}) {
|
||||
export function StatDrillProvider({source, ticket, linked = false, children}) {
|
||||
const [request, setRequest] = useState(null);
|
||||
const opener = useRef(null);
|
||||
useEffect(() => {
|
||||
@@ -26,9 +27,9 @@ export function StatDrillProvider({source, ticket, children}) {
|
||||
source,
|
||||
open: (title, params) => {
|
||||
opener.current = document.activeElement;
|
||||
setRequest({title, source, ticket, params});
|
||||
setRequest({title, source, ticket, linked, params});
|
||||
},
|
||||
}), [source, ticket]);
|
||||
}), [source, ticket, linked]);
|
||||
return (
|
||||
<StatDrillContext.Provider value={value}>
|
||||
{children}
|
||||
@@ -43,9 +44,11 @@ export const useStatDrill = () => useContext(StatDrillContext);
|
||||
|
||||
/**
|
||||
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
|
||||
* Nothing at `count` 0; `count` undefined means no count is known.
|
||||
*/
|
||||
export function useDrillTarget({to, params, title}) {
|
||||
export function useDrillTarget({to, params, title, count}) {
|
||||
const drill = useStatDrill();
|
||||
if (count === 0) return null;
|
||||
if (to && drill?.source === 'own') return {to};
|
||||
if (drill && params) return {onClick: () => drill.open(title, params)};
|
||||
return null;
|
||||
@@ -80,9 +83,10 @@ export function DrillButton({onClick, label, className, children}) {
|
||||
*
|
||||
* @param to library URL, used instead of the dialog in the user's own views
|
||||
* @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request
|
||||
* @param count the number shown; 0 is never clickable
|
||||
*/
|
||||
export function StatCount({to, params, title, label, className, children}) {
|
||||
const target = useDrillTarget({to, params, title});
|
||||
export function StatCount({to, params, title, label, count, className, children}) {
|
||||
const target = useDrillTarget({to, params, title, count});
|
||||
if (!target) return children;
|
||||
return target.to
|
||||
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {StatCount, StatDrillProvider} from './StatDrill.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||
|
||||
const renderCount = (count) => render(
|
||||
<MemoryRouter><StatDrillProvider source="own">
|
||||
<StatCount params={{key: 'PLAYED'}} count={count}>{count}</StatCount>
|
||||
</StatDrillProvider></MemoryRouter>);
|
||||
|
||||
describe('StatCount', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('aZeroCountIsNotClickable', () => {
|
||||
renderCount(0);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
|
||||
cleanup();
|
||||
renderCount(3);
|
||||
expect(screen.getByRole('button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
|
||||
/**
|
||||
* The games behind one number, 40 per page.
|
||||
*
|
||||
* @param request `{title, source, ticket, params}` or null (closed)
|
||||
* @param request `{title, source, ticket, linked, params}` or null (closed)
|
||||
*/
|
||||
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
@@ -75,7 +75,7 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
||||
<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'}/>
|
||||
<GameRow game={game} value={value(game)} linked={request.source === 'own' || request.linked === true}/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -128,4 +128,31 @@ describe('StatGamesDialog', () => {
|
||||
expect(screen.queryByText('Old game')).toBeNull();
|
||||
expect(screen.getByText('New game')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('aProfileRowIsPlainForAVisitor', async () => {
|
||||
fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)});
|
||||
renderDialog({source: 'profile', ticket: 't'});
|
||||
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('aProfileRowOpensTheDetailPageForTheOwner', async () => {
|
||||
fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)});
|
||||
renderDialog({source: 'profile', ticket: 't', linked: true});
|
||||
|
||||
const link = await screen.findByRole('link', {name: /Game 1/});
|
||||
|
||||
expect(link.getAttribute('href')).toBe('/library/1');
|
||||
});
|
||||
|
||||
it('aCommunityRowIsPlain', async () => {
|
||||
fetchRoutes({'GET /api/stats/public/games': page(rows(1, 1, {libraries: 2, minutes: 60}), 0, 1, 1)});
|
||||
renderDialog({source: 'community'});
|
||||
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import PlatformStats from './PlatformStats.jsx';
|
||||
* null tile, and an element is never null.
|
||||
*
|
||||
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
||||
* @param linked the game rows link to the game pages; false on the public profile
|
||||
* @param linked true for the user's own games: the dashboard and the owner on the profile
|
||||
*/
|
||||
export default function statGroupTile(id, stats, linked = true) {
|
||||
switch (id) {
|
||||
|
||||
Reference in new issue
Block a user