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:
lobo committed 2026-10-09 13:55:57 +02:00
1 parent 16651a1f56
commit f1a6bc0443
9 files changed
+178 -2

No files matched your search

+11
View File
@@ -65,6 +65,10 @@ To Be Played is split into three repositories:
once (no "Hide duplicates" filter any more), and the numbers count it once. The editions of a set share one status, rating and notes (the page once (no "Hide duplicates" filter any more), and the numbers count it once. The editions of a set share one status, rating and notes (the page
reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays
split. "Same game as..." on the details page asks the auditors for a link. split. "Same game as..." on the details page asks the auditors for a link.
- **Detail per platform**: the details page of a game with several platforms (or a PlayStation title on a Steam game)
shows `PlatformsCard` above the editions: one row per platform with playtime, last played and the achievement or
trophy progress of that platform, a total row, and a link to the edition whose page lists the achievements. The hero
playtime and the library card show the set total (`totalPlaytimeMinutes`); sorting and the stats stay per edition.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
hits to add them at once through the import review. hits to add them at once through the import review.
@@ -268,6 +272,13 @@ flowchart LR
Row -.->|"no platforms (hidden game)"| Badge["single platform badge"] Row -.->|"no platforms (hidden game)"| Badge["single platform badge"]
``` ```
```mermaid
flowchart LR
DetailData["Core GET /api/library/games/{appid}: platforms[] + totalPlaytimeMinutes"] --> Platforms["PlatformsCard: row per platform, total"]
DetailData --> Hero["GameHeroCard: total playtime"]
Platforms -->|"row of another edition"| Other["/library/{appid} of that edition"]
```
```mermaid ```mermaid
flowchart LR flowchart LR
Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"] Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"]
+6
View File
@@ -796,6 +796,12 @@
"OTHER_OWNERS_other": "{{count}} andere Besitzer", "OTHER_OWNERS_other": "{{count}} andere Besitzer",
"SHOW_OWNERS": "Zeigen, wer es besitzt" "SHOW_OWNERS": "Zeigen, wer es besitzt"
}, },
"PLATFORMS": {
"TITLE": "Spielzeit pro Plattform",
"TOTAL": "Gesamt",
"LAST_PLAYED": "Zuletzt gespielt",
"NEVER": "nie"
},
"LINKS": { "LINKS": {
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:", "ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...", "SAME_AS": "Dasselbe Spiel wie...",
+6
View File
@@ -796,6 +796,12 @@
"OTHER_OWNERS_other": "{{count}} other owners", "OTHER_OWNERS_other": "{{count}} other owners",
"SHOW_OWNERS": "Show who owns it" "SHOW_OWNERS": "Show who owns it"
}, },
"PLATFORMS": {
"TITLE": "Playtime per platform",
"TOTAL": "Total",
"LAST_PLAYED": "Last played",
"NEVER": "never"
},
"LINKS": { "LINKS": {
"ALSO_IN_LIBRARY": "Also in your library:", "ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...", "SAME_AS": "Same game as...",
+1 -1
View File
@@ -76,7 +76,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="tabular-nums"> <Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/> <Clock className="size-3"/>
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})} {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
</Badge> </Badge>
<ComingBadge game={game}/> <ComingBadge game={game}/>
<GameStatusBadge status={game.status}/> <GameStatusBadge status={game.status}/>
+61
View File
@@ -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>
);
}
+79
View File
@@ -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('');
});
});
+3
View File
@@ -18,6 +18,7 @@ import EditionsCard from '../components/EditionsCard.jsx';
import OwnersCard from '../components/people/OwnersCard.jsx'; import OwnersCard from '../components/people/OwnersCard.jsx';
import GameMergeDialog from '../components/GameMergeDialog.jsx'; import GameMergeDialog from '../components/GameMergeDialog.jsx';
import LinkGameDialog from '../components/LinkGameDialog.jsx'; import LinkGameDialog from '../components/LinkGameDialog.jsx';
import PlatformsCard from '../components/PlatformsCard.jsx';
import PlayingHint from '../components/PlayingHint.jsx'; import PlayingHint from '../components/PlayingHint.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useGame from '../hooks/useGame.js'; import useGame from '../hooks/useGame.js';
@@ -110,6 +111,8 @@ export default function GameDetail() {
if (data.editions?.length) reload(); if (data.editions?.length) reload();
}}/> }}/>
<PlatformsCard game={data}/>
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/> <EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
<OwnersCard key={`owners-${data.appid}`} appid={data.appid}/> <OwnersCard key={`owners-${data.appid}`} appid={data.appid}/>
+1 -1
View File
@@ -363,7 +363,7 @@ export default function GameLibrary() {
)} )}
<Badge variant="secondary" className="tabular-nums"> <Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/> <Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)} {formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
</Badge> </Badge>
<ChevronRight className="size-4 text-muted-foreground"/> <ChevronRight className="size-4 text-muted-foreground"/>
</Link> </Link>
+10
View File
@@ -235,6 +235,16 @@ describe('GameLibrary short games', () => {
expect(screen.queryByText('Steam')).toBeNull(); 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 () => { it('leaves the hidden games with a status click', async () => {
const lib = renderLibrary('/library?hidden=true'); const lib = renderLibrary('/library?hidden=true');
await waitFor(() => expect(fetch).toHaveBeenCalled()); await waitFor(() => expect(fetch).toHaveBeenCalled());