Public Access
feat(library): show playtime per platform on game detail
Hero and library card show the set total. Fall back to the row's own minutes when the API sends no total.
This commit is contained in:
1 parent
16651a1f56
commit
f1a6bc0443
9 files changed
+178
-2
No files matched your search
@@ -76,7 +76,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
<Clock className="size-3"/>
|
||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})}
|
||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
|
||||
</Badge>
|
||||
<ComingBadge game={game}/>
|
||||
<GameStatusBadge status={game.status}/>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Trophy} from 'lucide-react';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import PlatformIcons from './PlatformIcons.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
/**
|
||||
* One row per platform of the game: playtime, last played and the progress of that platform. A row of another edition
|
||||
* links to that edition, whose page lists its achievements.
|
||||
*/
|
||||
export default function PlatformsCard({game}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const lines = game.platforms;
|
||||
if (!lines?.length) return null;
|
||||
|
||||
return (
|
||||
<Card className="gap-3 p-4">
|
||||
<h2 className="text-sm font-medium">{t('PLATFORMS.TITLE')}</h2>
|
||||
<ul className="divide-y text-sm">
|
||||
{lines.map((line, index) => {
|
||||
const label = platformLabel(line) ?? t(`LIBRARY.SOURCE.${line.source}`);
|
||||
return (
|
||||
<li key={`${line.appid}:${line.source}:${index}`}
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 first:pt-0 last:pb-0">
|
||||
<span className="flex min-w-24 items-center gap-2 font-medium">
|
||||
<PlatformIcons platforms={[line]}/>
|
||||
{line.appid === game.appid
|
||||
? <span>{label}</span>
|
||||
: <Link to={`/library/${line.appid}`} className="hover:text-primary hover:underline">{label}</Link>}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatPlaytime(line.playtimeMinutes)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('PLATFORMS.LAST_PLAYED')}{' '}
|
||||
{line.lastPlayedAt
|
||||
? formatDate(line.lastPlayedAt, i18n.language, {dateStyle: 'medium'})
|
||||
: t('PLATFORMS.NEVER')}
|
||||
</span>
|
||||
{line.achievementsTotal != null && (
|
||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||
<Trophy className={cn('size-3', line.perfect && 'text-amber-500')}/>
|
||||
{line.achievementsUnlocked ?? '?'} / {line.achievementsTotal}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{lines.length > 1 && (
|
||||
<p className="flex items-center justify-between border-t pt-2 text-sm font-medium">
|
||||
<span>{t('PLATFORMS.TOTAL')}</span>
|
||||
<span className="tabular-nums">{formatPlaytime(game.totalPlaytimeMinutes)}</span>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, within} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import PlatformsCard from './PlatformsCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const steam = {source: 'STEAM', platform: null, appid: 220, playtimeMinutes: 3000, lastPlayedAt: '2026-10-01T10:00:00Z',
|
||||
achievementsUnlocked: 2, achievementsTotal: 3, perfect: false};
|
||||
const ps5 = {source: 'PSN', platform: 'PS5', appid: 221, playtimeMinutes: 600, lastPlayedAt: null,
|
||||
achievementsUnlocked: 10, achievementsTotal: 10, perfect: true};
|
||||
|
||||
const renderCard = (game) => render(<MemoryRouter><PlatformsCard game={game}/></MemoryRouter>);
|
||||
// The icon of a row is a list too.
|
||||
const lineRows = () => [...document.querySelectorAll('ul.divide-y > li')];
|
||||
|
||||
describe('PlatformsCard', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('showsOneRowPerPlatformWithPlaytimeAndProgress', () => {
|
||||
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
|
||||
|
||||
const rows = lineRows();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(within(rows[0]).getByText('Steam')).toBeTruthy();
|
||||
expect(within(rows[0]).getByText('50h')).toBeTruthy();
|
||||
expect(within(rows[0]).getByText('2 / 3')).toBeTruthy();
|
||||
expect(within(rows[1]).getByText('PS5', {selector: 'a, span'})).toBeTruthy();
|
||||
expect(within(rows[1]).getByText('10h')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marksAPerfectLineOnly', () => {
|
||||
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
|
||||
|
||||
const rows = lineRows();
|
||||
expect(rows[0].querySelector('.text-amber-500')).toBeNull();
|
||||
expect(rows[1].querySelector('.text-amber-500')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('showsTheTotalRowOnlyWithTwoLines', () => {
|
||||
renderCard({appid: 220, platforms: [steam], totalPlaytimeMinutes: 3000});
|
||||
|
||||
expect(screen.queryByText('PLATFORMS.TOTAL')).toBeNull();
|
||||
|
||||
cleanup();
|
||||
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
|
||||
|
||||
expect(screen.getByText('PLATFORMS.TOTAL')).toBeTruthy();
|
||||
expect(screen.getByText('60h')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsNeverForAMissingLastPlayed', () => {
|
||||
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
|
||||
|
||||
expect(screen.getAllByText(/PLATFORMS\.NEVER/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('linksOnlyTheLinesOfAnotherEdition', () => {
|
||||
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].getAttribute('href')).toBe('/library/221');
|
||||
});
|
||||
|
||||
it('showsNoAchievementsForALineWithoutAny', () => {
|
||||
renderCard({appid: 220, platforms: [{...steam, achievementsUnlocked: null, achievementsTotal: null}],
|
||||
totalPlaytimeMinutes: 3000});
|
||||
|
||||
expect(screen.queryByText(/ \/ /)).toBeNull();
|
||||
});
|
||||
|
||||
it('isHiddenWithoutPlatforms', () => {
|
||||
const {container} = renderCard({appid: 220, platforms: [], totalPlaytimeMinutes: 0});
|
||||
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import EditionsCard from '../components/EditionsCard.jsx';
|
||||
import OwnersCard from '../components/people/OwnersCard.jsx';
|
||||
import GameMergeDialog from '../components/GameMergeDialog.jsx';
|
||||
import LinkGameDialog from '../components/LinkGameDialog.jsx';
|
||||
import PlatformsCard from '../components/PlatformsCard.jsx';
|
||||
import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useGame from '../hooks/useGame.js';
|
||||
@@ -110,6 +111,8 @@ export default function GameDetail() {
|
||||
if (data.editions?.length) reload();
|
||||
}}/>
|
||||
|
||||
<PlatformsCard game={data}/>
|
||||
|
||||
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
|
||||
|
||||
<OwnersCard key={`owners-${data.appid}`} appid={data.appid}/>
|
||||
|
||||
@@ -363,7 +363,7 @@ export default function GameLibrary() {
|
||||
)}
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
<Clock className="size-3"/>
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
{formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
|
||||
</Badge>
|
||||
<ChevronRight className="size-4 text-muted-foreground"/>
|
||||
</Link>
|
||||
|
||||
@@ -235,6 +235,16 @@ describe('GameLibrary short games', () => {
|
||||
expect(screen.queryByText('Steam')).toBeNull();
|
||||
});
|
||||
|
||||
it('showsTheSetTotalPlaytimeOnTheCard_AndTheOwnMinutesWithoutATotal', async () => {
|
||||
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 600, totalPlaytimeMinutes: 3600, status: 'BACKLOG'},
|
||||
{appid: 620, name: 'Portal 2', playtimeMinutes: 1200, status: 'BACKLOG'}]);
|
||||
renderLibrary();
|
||||
|
||||
expect(await screen.findByText('60h')).toBeTruthy();
|
||||
expect(screen.getByText('20h')).toBeTruthy();
|
||||
expect(screen.queryByText('10h')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the hidden games with a status click', async () => {
|
||||
const lib = renderLibrary('/library?hidden=true');
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
|
||||
Reference in new issue
Block a user