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
@@ -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"]
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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}/>
|
||||||
|
|||||||
@@ -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 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}/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in new issue
Block a user