From f1a6bc04433dfcea10310d18d548b8ba91861222 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 9 Oct 2026 13:55:57 +0200 Subject: [PATCH] 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. --- README.md | 11 ++++ public/i18n/de.json | 6 ++ public/i18n/en.json | 6 ++ src/components/GameHeroCard.jsx | 2 +- src/components/PlatformsCard.jsx | 61 +++++++++++++++++++++ src/components/PlatformsCard.test.jsx | 79 +++++++++++++++++++++++++++ src/pages/GameDetail.jsx | 3 + src/pages/GameLibrary.jsx | 2 +- src/pages/GameLibrary.test.jsx | 10 ++++ 9 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/components/PlatformsCard.jsx create mode 100644 src/components/PlatformsCard.test.jsx diff --git a/README.md b/README.md index 3f9ccd0..2c22059 100644 --- a/README.md +++ b/README.md @@ -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 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. +- **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, 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. @@ -268,6 +272,13 @@ flowchart LR 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 flowchart LR Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"] diff --git a/public/i18n/de.json b/public/i18n/de.json index a54e575..4f32846 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -796,6 +796,12 @@ "OTHER_OWNERS_other": "{{count}} andere Besitzer", "SHOW_OWNERS": "Zeigen, wer es besitzt" }, + "PLATFORMS": { + "TITLE": "Spielzeit pro Plattform", + "TOTAL": "Gesamt", + "LAST_PLAYED": "Zuletzt gespielt", + "NEVER": "nie" + }, "LINKS": { "ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:", "SAME_AS": "Dasselbe Spiel wie...", diff --git a/public/i18n/en.json b/public/i18n/en.json index 177de84..0366ae4 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -796,6 +796,12 @@ "OTHER_OWNERS_other": "{{count}} other owners", "SHOW_OWNERS": "Show who owns it" }, + "PLATFORMS": { + "TITLE": "Playtime per platform", + "TOTAL": "Total", + "LAST_PLAYED": "Last played", + "NEVER": "never" + }, "LINKS": { "ALSO_IN_LIBRARY": "Also in your library:", "SAME_AS": "Same game as...", diff --git a/src/components/GameHeroCard.jsx b/src/components/GameHeroCard.jsx index fc6c1d4..e5a4838 100644 --- a/src/components/GameHeroCard.jsx +++ b/src/components/GameHeroCard.jsx @@ -76,7 +76,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
- {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})} + {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})} diff --git a/src/components/PlatformsCard.jsx b/src/components/PlatformsCard.jsx new file mode 100644 index 0000000..587afe8 --- /dev/null +++ b/src/components/PlatformsCard.jsx @@ -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 ( + +

{t('PLATFORMS.TITLE')}

+
    + {lines.map((line, index) => { + const label = platformLabel(line) ?? t(`LIBRARY.SOURCE.${line.source}`); + return ( +
  • + + + {line.appid === game.appid + ? {label} + : {label}} + + {formatPlaytime(line.playtimeMinutes)} + + {t('PLATFORMS.LAST_PLAYED')}{' '} + {line.lastPlayedAt + ? formatDate(line.lastPlayedAt, i18n.language, {dateStyle: 'medium'}) + : t('PLATFORMS.NEVER')} + + {line.achievementsTotal != null && ( + + + {line.achievementsUnlocked ?? '?'} / {line.achievementsTotal} + + )} +
  • + ); + })} +
+ {lines.length > 1 && ( +

+ {t('PLATFORMS.TOTAL')} + {formatPlaytime(game.totalPlaytimeMinutes)} +

+ )} +
+ ); +} diff --git a/src/components/PlatformsCard.test.jsx b/src/components/PlatformsCard.test.jsx new file mode 100644 index 0000000..42f32ca --- /dev/null +++ b/src/components/PlatformsCard.test.jsx @@ -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(); +// 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(''); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index e038b52..74dfbdb 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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(); }}/> + + setLinking(true)} onChanged={reload}/> diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 59cfb45..222a99c 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -363,7 +363,7 @@ export default function GameLibrary() { )} - {formatPlaytime(g.playtimeMinutes)} + {formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)} diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index be78f86..6a02c1e 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -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());