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
@@ -575,7 +575,6 @@
|
|||||||
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
||||||
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
||||||
"NEW_IN_LIBRARY": "Neu",
|
"NEW_IN_LIBRARY": "Neu",
|
||||||
"OPEN": "Im Steam-Shop öffnen",
|
|
||||||
"GAMES_one": "{{count}} Spiel",
|
"GAMES_one": "{{count}} Spiel",
|
||||||
"GAMES_other": "{{count}} Spiele"
|
"GAMES_other": "{{count}} Spiele"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,7 +575,6 @@
|
|||||||
"TREND_ADDED": "Added to the pile this month",
|
"TREND_ADDED": "Added to the pile this month",
|
||||||
"TREND_DONE": "Off the pile this month",
|
"TREND_DONE": "Off the pile this month",
|
||||||
"NEW_IN_LIBRARY": "New",
|
"NEW_IN_LIBRARY": "New",
|
||||||
"OPEN": "Open in the Steam store",
|
|
||||||
"GAMES_one": "{{count}} game",
|
"GAMES_one": "{{count}} game",
|
||||||
"GAMES_other": "{{count}} games"
|
"GAMES_other": "{{count}} games"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {Trophy} from 'lucide-react';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
|
import {StatCount} from './stats/StatDrill.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param summary the answer of GET /api/library/achievements/summary
|
* @param summary the answer of GET /api/library/achievements/summary
|
||||||
@@ -15,12 +16,14 @@ export default function AchievementsCard({summary}) {
|
|||||||
const started = summary.startedGames ?? 0;
|
const started = summary.startedGames ?? 0;
|
||||||
const pending = summary.pendingGames ?? 0;
|
const pending = summary.pendingGames ?? 0;
|
||||||
const hidden = summary.hiddenGames ?? 0;
|
const hidden = summary.hiddenGames ?? 0;
|
||||||
|
const perfect = summary.perfectGames ?? 0;
|
||||||
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
|
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
|
||||||
const steamHidesAll = hidden > 0 && started === 0;
|
const steamHidesAll = hidden > 0 && started === 0;
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
||||||
[(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')],
|
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
|
||||||
|
{params: {key: 'PERFECT'}, count: perfect}],
|
||||||
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
||||||
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
||||||
];
|
];
|
||||||
@@ -33,9 +36,11 @@ export default function AchievementsCard({summary}) {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{stats.map(([value, label]) => (
|
{stats.map(([value, label, drill]) => (
|
||||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
<div className="text-xl md:text-2xl font-bold tabular-nums">
|
||||||
|
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, render, screen} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import AchievementsCard from './AchievementsCard.jsx';
|
import AchievementsCard from './AchievementsCard.jsx';
|
||||||
|
import {StatDrillProvider} from './stats/StatDrill.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
@@ -49,4 +51,22 @@ describe('AchievementsCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opensThePerfectGamesInTheOwnLibrary', async () => {
|
||||||
|
fetchRoutes({'GET /api/library/stats/games': json(200, {
|
||||||
|
content: [{appid: -7, name: 'Manual Game'}], page: 0, size: 40, totalElements: 1, totalPages: 1})});
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary()}/></StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.ACHIEVEMENTS.PERFECT: 2'}));
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: /Manual Game/});
|
||||||
|
expect(fetch.mock.calls[0][0]).toContain('key=PERFECT&page=0');
|
||||||
|
expect(link.getAttribute('href')).toBe('/library/-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('perfectGamesAreNotClickableAtZero', () => {
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary({perfectGames: 0})}/></StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -36,7 +36,7 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}}
|
<StatCount to={libraryLink('BACKLOG')} title={t('DASHBOARD.PILE_OF_SHAME')} params={{key: 'PILE'}} count={backlog}
|
||||||
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
|
label={`${t('BACKLOG.STATUS.BACKLOG')}: ${backlog}`}>
|
||||||
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
@@ -77,7 +77,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
|||||||
<li key={s} className="flex items-center gap-1.5">
|
<li key={s} className="flex items-center gap-1.5">
|
||||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||||
{t(`BACKLOG.STATUS.${s}`)}
|
{t(`BACKLOG.STATUS.${s}`)}
|
||||||
<StatCount to={libraryLink(s)} label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
|
<StatCount to={libraryLink(s)} count={summary.byStatus[s]}
|
||||||
|
label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
|
||||||
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ describe('PileOfShameCard', () => {
|
|||||||
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeroCountsAreNotClickable', () => {
|
||||||
|
render(<MemoryRouter><StatDrillProvider source="own">
|
||||||
|
<PileOfShameCard onShuffle={() => {}} summary={summary(0)}/>
|
||||||
|
</StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 0'})).toBeNull();
|
||||||
|
expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.FINISHED: 0'})).toBeNull();
|
||||||
|
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the backlog hours when IGDB knows some', () => {
|
it('shows the backlog hours when IGDB knows some', () => {
|
||||||
renderCard({summary: summary(2, {backlogMinutes: 7440})});
|
renderCard({summary: summary(2, {backlogMinutes: 7440})});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
|
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
|
||||||
const {i18n} = useTranslation();
|
const {i18n} = useTranslation();
|
||||||
const animated = useCountUp(value);
|
const animated = useCountUp(value);
|
||||||
const target = useDrillTarget({title: label, ...drill});
|
const target = useDrillTarget({title: label, count: value, ...drill});
|
||||||
const card = (
|
const card = (
|
||||||
<Card className="gap-3 p-5">
|
<Card className="gap-3 p-5">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
|
||||||
const DURATION_MS = 1000;
|
const DURATION_MS = 1000;
|
||||||
|
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
||||||
|
|
||||||
const SteamGameCount = ({targetCount}) => {
|
const SteamGameCount = ({targetCount}) => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -33,7 +34,9 @@ const SteamGameCount = ({targetCount}) => {
|
|||||||
<Gamepad2 className="size-5"/>
|
<Gamepad2 className="size-5"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Link to="/library" className="block text-5xl font-bold tracking-tight tabular-nums hover:text-primary">{count}</Link>
|
{targetCount > 0
|
||||||
|
? <Link to="/library" className={`${COUNT_CLASS} hover:text-primary`}>{count}</Link>
|
||||||
|
: <span className={COUNT_CLASS}>{count}</span>}
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
|
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import SteamGameCount from './SteamGameCount.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
describe('SteamGameCount', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
||||||
|
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
||||||
|
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(1);
|
||||||
|
|
||||||
|
rerender(<MemoryRouter><SteamGameCount targetCount={5}/></MemoryRouter>);
|
||||||
|
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
||||||
|
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import GameCover from '../GameCover.jsx';
|
import GameCover from '../GameCover.jsx';
|
||||||
import FitText from '../FitText.jsx';
|
import FitText from '../FitText.jsx';
|
||||||
import {gameHref} from '../../utils/statGames.js';
|
import {gameHref} from '../../utils/statGames.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A game with its cover and a value on the right; links to the game page in the user's own library, to the Steam
|
* A game with its cover and a value on the right; links to the game page when the games are the viewer's own, plain
|
||||||
* store for a visitor.
|
* otherwise.
|
||||||
*/
|
*/
|
||||||
export default function GameRow({game, value, linked = true}) {
|
export default function GameRow({game, value, linked = true}) {
|
||||||
const {t} = useTranslation();
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
||||||
@@ -17,14 +15,11 @@ export default function GameRow({game, value, linked = true}) {
|
|||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span>
|
<span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const className = 'flex items-center gap-3 hover:text-primary transition-colors';
|
if (linked) {
|
||||||
if (linked) return <Link to={gameHref(game.appid, true)} className={className}>{content}</Link>;
|
|
||||||
const href = gameHref(game.appid, false);
|
|
||||||
if (href) {
|
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" title={t('STATS.DRILL.OPEN')} className={className}>
|
<Link to={gameHref(game.appid)} className="flex items-center gap-3 hover:text-primary transition-colors">
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div className="flex items-center gap-3">{content}</div>;
|
return <div className="flex items-center gap-3">{content}</div>;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function HabitsStats({habits}) {
|
|||||||
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
||||||
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
||||||
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
|
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
|
||||||
drill={{params: {key: 'UNDER_ONE_HOUR'}}}/>
|
drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</StatsSection>
|
</StatsSection>
|
||||||
|
|||||||
@@ -15,18 +15,19 @@ export default function OverviewStats({overview}) {
|
|||||||
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
||||||
drill={{to: libraryLink(), params: {key: 'TOTAL'}}}/>
|
drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/>
|
||||||
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
|
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
|
||||||
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
|
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
|
||||||
drill={{params: {key: 'PLAYED'}}}/>
|
drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/>
|
||||||
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
|
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
|
||||||
drill={{params: {key: 'NEVER_STARTED'}}}/>
|
drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
{GAME_STATUSES.map((s) => (
|
{GAME_STATUSES.map((s) => (
|
||||||
<li key={s}>
|
<li key={s}>
|
||||||
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
||||||
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
|
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
|
||||||
|
count={overview.byStatus[s] ?? 0}
|
||||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
|
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
|
||||||
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||||
</StatCount>
|
</StatCount>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export default function PileStats({pile, linked = true}) {
|
|||||||
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}
|
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}
|
||||||
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}}}/>
|
drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}, count: pile.backlogGames}}/>
|
||||||
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
||||||
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}
|
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}
|
||||||
drill={{params: {key: 'PILE_TIME_TO_BEAT'}}}/>
|
drill={{params: {key: 'PILE_TIME_TO_BEAT'}, count: pile.gamesWithTimeToBeat}}/>
|
||||||
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
||||||
</div>
|
</div>
|
||||||
{pile.normalMinutes > 0 && (
|
{pile.normalMinutes > 0 && (
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max
|
|||||||
*
|
*
|
||||||
* @param source own, profile, community or points
|
* @param source own, profile, community or points
|
||||||
* @param ticket profile: the ticket for the visitor's requests
|
* @param ticket profile: the ticket for the visitor's requests
|
||||||
|
* @param linked the games are the viewer's own: rows link to the detail page
|
||||||
*/
|
*/
|
||||||
export function StatDrillProvider({source, ticket, children}) {
|
export function StatDrillProvider({source, ticket, linked = false, children}) {
|
||||||
const [request, setRequest] = useState(null);
|
const [request, setRequest] = useState(null);
|
||||||
const opener = useRef(null);
|
const opener = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,9 +27,9 @@ export function StatDrillProvider({source, ticket, children}) {
|
|||||||
source,
|
source,
|
||||||
open: (title, params) => {
|
open: (title, params) => {
|
||||||
opener.current = document.activeElement;
|
opener.current = document.activeElement;
|
||||||
setRequest({title, source, ticket, params});
|
setRequest({title, source, ticket, linked, params});
|
||||||
},
|
},
|
||||||
}), [source, ticket]);
|
}), [source, ticket, linked]);
|
||||||
return (
|
return (
|
||||||
<StatDrillContext.Provider value={value}>
|
<StatDrillContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
@@ -43,9 +44,11 @@ export const useStatDrill = () => useContext(StatDrillContext);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
|
* What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null).
|
||||||
|
* Nothing at `count` 0; `count` undefined means no count is known.
|
||||||
*/
|
*/
|
||||||
export function useDrillTarget({to, params, title}) {
|
export function useDrillTarget({to, params, title, count}) {
|
||||||
const drill = useStatDrill();
|
const drill = useStatDrill();
|
||||||
|
if (count === 0) return null;
|
||||||
if (to && drill?.source === 'own') return {to};
|
if (to && drill?.source === 'own') return {to};
|
||||||
if (drill && params) return {onClick: () => drill.open(title, params)};
|
if (drill && params) return {onClick: () => drill.open(title, params)};
|
||||||
return null;
|
return null;
|
||||||
@@ -80,9 +83,10 @@ export function DrillButton({onClick, label, className, children}) {
|
|||||||
*
|
*
|
||||||
* @param to library URL, used instead of the dialog in the user's own views
|
* @param to library URL, used instead of the dialog in the user's own views
|
||||||
* @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request
|
* @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request
|
||||||
|
* @param count the number shown; 0 is never clickable
|
||||||
*/
|
*/
|
||||||
export function StatCount({to, params, title, label, className, children}) {
|
export function StatCount({to, params, title, label, count, className, children}) {
|
||||||
const target = useDrillTarget({to, params, title});
|
const target = useDrillTarget({to, params, title, count});
|
||||||
if (!target) return children;
|
if (!target) return children;
|
||||||
return target.to
|
return target.to
|
||||||
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
|
? <Link to={target.to} aria-label={label} className={cn(DRILL_CLASS, className)}>{children}</Link>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {StatCount, StatDrillProvider} from './StatDrill.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const renderCount = (count) => render(
|
||||||
|
<MemoryRouter><StatDrillProvider source="own">
|
||||||
|
<StatCount params={{key: 'PLAYED'}} count={count}>{count}</StatCount>
|
||||||
|
</StatDrillProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
describe('StatCount', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('aZeroCountIsNotClickable', () => {
|
||||||
|
renderCount(0);
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
renderCount(3);
|
||||||
|
expect(screen.getByRole('button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
|
|||||||
/**
|
/**
|
||||||
* The games behind one number, 40 per page.
|
* The games behind one number, 40 per page.
|
||||||
*
|
*
|
||||||
* @param request `{title, source, ticket, params}` or null (closed)
|
* @param request `{title, source, ticket, linked, params}` or null (closed)
|
||||||
*/
|
*/
|
||||||
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
@@ -75,7 +75,7 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{state.games.map((game, index) => (
|
{state.games.map((game, index) => (
|
||||||
<li key={`${game.appid}-${index}`}>
|
<li key={`${game.appid}-${index}`}>
|
||||||
<GameRow game={game} value={value(game)} linked={request.source === 'own'}/>
|
<GameRow game={game} value={value(game)} linked={request.source === 'own' || request.linked === true}/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -128,4 +128,31 @@ describe('StatGamesDialog', () => {
|
|||||||
expect(screen.queryByText('Old game')).toBeNull();
|
expect(screen.queryByText('Old game')).toBeNull();
|
||||||
expect(screen.getByText('New game')).toBeTruthy();
|
expect(screen.getByText('New game')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aProfileRowIsPlainForAVisitor', async () => {
|
||||||
|
fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)});
|
||||||
|
renderDialog({source: 'profile', ticket: 't'});
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aProfileRowOpensTheDetailPageForTheOwner', async () => {
|
||||||
|
fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)});
|
||||||
|
renderDialog({source: 'profile', ticket: 't', linked: true});
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: /Game 1/});
|
||||||
|
|
||||||
|
expect(link.getAttribute('href')).toBe('/library/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aCommunityRowIsPlain', async () => {
|
||||||
|
fetchRoutes({'GET /api/stats/public/games': page(rows(1, 1, {libraries: 2, minutes: 60}), 0, 1, 1)});
|
||||||
|
renderDialog({source: 'community'});
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,7 @@ import PlatformStats from './PlatformStats.jsx';
|
|||||||
* null tile, and an element is never null.
|
* null tile, and an element is never null.
|
||||||
*
|
*
|
||||||
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
||||||
* @param linked the game rows link to the game pages; false on the public profile
|
* @param linked true for the user's own games: the dashboard and the owner on the profile
|
||||||
*/
|
*/
|
||||||
export default function statGroupTile(id, stats, linked = true) {
|
export default function statGroupTile(id, stats, linked = true) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {TagBars} from '../components/stats/TasteStats.jsx';
|
|||||||
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
||||||
import useSession from '../hooks/useSession.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
import {gameHref} from '../utils/statGames.js';
|
|
||||||
|
|
||||||
import usePublicStats from '../hooks/usePublicStats.js';
|
import usePublicStats from '../hooks/usePublicStats.js';
|
||||||
|
|
||||||
@@ -51,11 +50,9 @@ function RankedGames({title, games, formatValue, drillKey}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GameName({game}) {
|
function GameName({game}) {
|
||||||
const href = gameHref(game.appid, false);
|
|
||||||
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
|
|
||||||
return (
|
return (
|
||||||
<span className="@container min-w-0 flex-1 font-medium">
|
<span className="@container min-w-0 flex-1 font-medium">
|
||||||
{href ? <a href={href} target="_blank" rel="noopener noreferrer" className="hover:text-primary">{name}</a> : name}
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ describe('LandingPage', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('topGamesHaveNoStoreLink', async () => {
|
||||||
|
answer({topPlayedGames: [{appid: 220, name: 'Half-Life 2', value: 600}]});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
|
||||||
|
expect(container.querySelector('a[href*="steampowered"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
|
it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
|
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}),
|
||||||
@@ -58,4 +67,13 @@ describe('LandingPage', () => {
|
|||||||
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
|
expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull();
|
||||||
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aZeroTotalIsNotClickable', async () => {
|
||||||
|
answer({totalLibraryEntries: 0});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('LANDING.TOTAL_GAMES');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -203,7 +203,7 @@ function LockedProgress({item, progress}) {
|
|||||||
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
||||||
// Playtime months compare two numbers: no list of games shows them.
|
// Playtime months compare two numbers: no list of games shows them.
|
||||||
const target = useDrillTarget({
|
const target = useDrillTarget({
|
||||||
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`),
|
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`), count,
|
||||||
});
|
});
|
||||||
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
||||||
const Wrapper = target ? 'button' : 'div';
|
const Wrapper = target ? 'button' : 'div';
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ describe('PointsPage', () => {
|
|||||||
expect(within(item).queryByRole('button')).toBeNull();
|
expect(within(item).queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows no button on a locked bar without progress', async () => {
|
||||||
|
await renderPage({'GET /api/rewards/wallet': json(200, wallet({progress: {GAMES_FINISHED: 0}}))});
|
||||||
|
|
||||||
|
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||||
|
expect(within(item).queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('caps the progress at the target', async () => {
|
it('caps the progress at the target', async () => {
|
||||||
await renderPage();
|
await renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default function PublicProfilePage() {
|
|||||||
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
||||||
case 'FINISHED':
|
case 'FINISHED':
|
||||||
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
|
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
|
||||||
default: return statGroupTile(id, library.stats, false);
|
default: return statGroupTile(id, library.stats, owner);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
||||||
@@ -178,7 +178,7 @@ export default function PublicProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<StatDrillProvider source="profile" ticket={profile.ticket}>
|
<StatDrillProvider source="profile" ticket={profile.ticket} linked={owner}>
|
||||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
||||||
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
||||||
onCancel={() => setEditing(false)}/>
|
onCancel={() => setEditing(false)}/>
|
||||||
|
|||||||
@@ -290,4 +290,69 @@ describe('PublicProfilePage', () => {
|
|||||||
delete library.stats;
|
delete library.stats;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gamesRoute = {'GET /api/public/profiles/library/stats/games': json(200, {
|
||||||
|
content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})};
|
||||||
|
const topGamesStats = {pile: null, trend: null, taste: null, finished: null, habits: null,
|
||||||
|
topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}], mostLoyal: null}};
|
||||||
|
|
||||||
|
it('theOwnerOpensTheDetailPageFromTheList', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
try {
|
||||||
|
mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'}));
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1');
|
||||||
|
} finally {
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theOwnerTileRowsOpenTheDetailPage', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
library.stats = topGamesStats;
|
||||||
|
try {
|
||||||
|
mockBackends({me: {displayName: 'lobo', role: 'USER'}, style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aVisitorSeesTileRowsWithoutLinks', async () => {
|
||||||
|
library.stats = topGamesStats;
|
||||||
|
try {
|
||||||
|
mockBackends({style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}});
|
||||||
|
const {container} = renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Portal 2');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
|
||||||
|
expect(container.querySelector('a[href*="steampowered"]')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
delete library.stats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opensTheGamesBehindPerfectGames', async () => {
|
||||||
|
library.perfectGames = 12;
|
||||||
|
library.averageCompletion = 64.5;
|
||||||
|
try {
|
||||||
|
mockBackends({extra: gamesRoute});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /PROFILE\.PERFECT_GAMES: 12/}));
|
||||||
|
|
||||||
|
await screen.findByText('Backlogged');
|
||||||
|
expect(fetch.mock.calls.find(([url]) => url.includes('/stats/games'))[0]).toContain('key=PERFECT');
|
||||||
|
} finally {
|
||||||
|
delete library.perfectGames;
|
||||||
|
delete library.averageCompletion;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -113,6 +113,34 @@ describe('StatsPage', () => {
|
|||||||
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aZeroTileIsNotClickable', async () => {
|
||||||
|
const base = stats();
|
||||||
|
answerStats(json(200, stats({overview: {...base.overview, neverStarted: 0}})));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('STATS.OVERVIEW.NEVER_STARTED');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: /STATS\.OVERVIEW\.NEVER_STARTED/})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeroCountsAreNotClickableInPileHabitsAndStatuses', async () => {
|
||||||
|
const base = stats();
|
||||||
|
answerStats(json(200, stats({
|
||||||
|
overview: {...base.overview, byStatus: {...base.overview.byStatus, DROPPED: 0}},
|
||||||
|
pile: {...base.pile, backlogGames: 0, gamesWithTimeToBeat: 0},
|
||||||
|
habits: {...base.habits, underOneHourGames: 0},
|
||||||
|
})));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('STATS.PILE.GAMES');
|
||||||
|
|
||||||
|
for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED: 0/]) {
|
||||||
|
expect(screen.queryByRole('button', {name})).toBeNull();
|
||||||
|
expect(screen.queryByRole('link', {name})).toBeNull();
|
||||||
|
}
|
||||||
|
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('leavesTheMoonTripsAlone', async () => {
|
it('leavesTheMoonTripsAlone', async () => {
|
||||||
answerStats(json(200, stats()));
|
answerStats(json(200, stats()));
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -47,8 +47,4 @@ export async function fetchStatGames(source, params, ticket) {
|
|||||||
|
|
||||||
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`;
|
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`;
|
||||||
|
|
||||||
// A visitor has no game page of the owner; the Steam store is the only public one. Manual games have no appid there.
|
export const gameHref = (appid) => `/library/${appid}`;
|
||||||
export const gameHref = (appid, own) => {
|
|
||||||
if (own) return `/library/${appid}`;
|
|
||||||
return appid > 0 ? `https://store.steampowered.com/app/${appid}` : null;
|
|
||||||
};
|
|
||||||
@@ -27,9 +27,8 @@ describe('libraryLink', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('gameHref', () => {
|
describe('gameHref', () => {
|
||||||
it('own_LinksTheLibraryPage', () => expect(gameHref(5, true)).toBe('/library/5'));
|
it('linksTheLibraryPage', () => {
|
||||||
it('visitor_LinksTheStoreOnlyForSteamApps', () => {
|
expect(gameHref(5)).toBe('/library/5');
|
||||||
expect(gameHref(5, false)).toBe('https://store.steampowered.com/app/5');
|
expect(gameHref(-3)).toBe('/library/-3');
|
||||||
expect(gameHref(-3, false)).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user