diff --git a/public/i18n/de.json b/public/i18n/de.json index 3ff4c64..cd14754 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -107,7 +107,8 @@ "ACTIVITY": "Spielzeit", "ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen", "ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.", - "RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)" + "RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)", + "BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist" }, "LIBRARY": { "TITLE": "Spielebibliothek", @@ -135,7 +136,8 @@ "SEARCH_PLACEHOLDER": "Spiel suchen …", "NO_MATCH": "Keine Spiele für diesen Filter.", "SORT_RATING": "Beste Bewertung", - "SORT_PRIORITY": "Höchste Priorität" + "SORT_PRIORITY": "Höchste Priorität", + "FILTER_SHORT": "Kurz (bis 10 h)" }, "GAME_DETAIL": { "BACK": "Zurück", @@ -152,7 +154,11 @@ "NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.", "STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.", "PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.", - "SET_PLAYING": "Auf „Spiele ich“ setzen" + "SET_PLAYING": "Auf „Spiele ich“ setzen", + "TTB": "Spieldauer laut IGDB", + "TTB_HASTILY": "Story", + "TTB_NORMALLY": "+ Extras", + "TTB_COMPLETELY": "100 %" }, "LANDING": { "STATS_TITLE": "Community-Statistiken", diff --git a/public/i18n/en.json b/public/i18n/en.json index ec768d1..6b21f8f 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -107,7 +107,8 @@ "ACTIVITY": "Playtime", "ACTIVITY_TOTAL": "{{time}} in {{days}} days", "ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.", - "RECENTLY_PLAYED": "Recently played (2 weeks)" + "RECENTLY_PLAYED": "Recently played (2 weeks)", + "BACKLOG_HOURS": "≈ {{time}} of play to clear it" }, "LIBRARY": { "TITLE": "Game Library", @@ -135,7 +136,8 @@ "SEARCH_PLACEHOLDER": "Search games …", "NO_MATCH": "No games match this filter.", "SORT_RATING": "Best rated", - "SORT_PRIORITY": "Highest priority" + "SORT_PRIORITY": "Highest priority", + "FILTER_SHORT": "Short (up to 10 h)" }, "GAME_DETAIL": { "BACK": "Back", @@ -152,7 +154,11 @@ "NO_ACHIEVEMENTS": "This game has no achievements on Steam.", "STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.", "PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.", - "SET_PLAYING": "Set to \"Playing\"" + "SET_PLAYING": "Set to \"Playing\"", + "TTB": "Time to beat (IGDB)", + "TTB_HASTILY": "Story", + "TTB_NORMALLY": "+ Extras", + "TTB_COMPLETELY": "100%" }, "LANDING": { "STATS_TITLE": "Community stats", diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index f6a6a37..da13d18 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -7,6 +7,7 @@ import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; import {STATUS_META} from './GameStatusBadge.jsx'; import {backlogShare, GAME_STATUSES} from '../utils/backlog.js'; +import {formatPlaytime} from '../utils/games.js'; export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) { const {t, i18n} = useTranslation(); @@ -36,6 +37,11 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}

{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}

+ {summary.backlogMinutes > 0 && ( +

+ {t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})} +

+ )} {summary.total > 0 && ( diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index 71f1aa5..0c38979 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -47,4 +47,16 @@ describe('PileOfShameCard', () => { expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy(); }); + + it('shows the backlog hours when IGDB knows some', () => { + renderCard({summary: summary(2, {backlogMinutes: 7440})}); + + expect(screen.getByText('DASHBOARD.BACKLOG_HOURS')).toBeTruthy(); + }); + + it('hides the backlog hours without data', () => { + renderCard({summary: summary(2, {backlogMinutes: 0})}); + + expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull(); + }); }); diff --git a/src/components/TimeToBeat.jsx b/src/components/TimeToBeat.jsx new file mode 100644 index 0000000..09b6020 --- /dev/null +++ b/src/components/TimeToBeat.jsx @@ -0,0 +1,29 @@ +import {useTranslation} from 'react-i18next'; +import {Timer} from 'lucide-react'; + +import {formatPlaytime} from '../utils/games.js'; + +const ROWS = [ + ['hastilyMinutes', 'GAME_DETAIL.TTB_HASTILY'], + ['normallyMinutes', 'GAME_DETAIL.TTB_NORMALLY'], + ['completelyMinutes', 'GAME_DETAIL.TTB_COMPLETELY'], +]; + +/** + * IGDB's average time to beat; renders nothing without data. + */ +export default function TimeToBeat({time}) { + const {t} = useTranslation(); + const known = ROWS.filter(([field]) => time?.[field]); + if (known.length === 0) return null; + return ( +
+ + {known.map(([field, labelKey]) => ( + + {t(labelKey)} {formatPlaytime(time[field])} + + ))} +
+ ); +} diff --git a/src/components/TimeToBeat.test.jsx b/src/components/TimeToBeat.test.jsx new file mode 100644 index 0000000..cac084c --- /dev/null +++ b/src/components/TimeToBeat.test.jsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import TimeToBeat from './TimeToBeat.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +describe('TimeToBeat', () => { + afterEach(cleanup); + + it('shows the known times', () => { + render(); + + expect(screen.getByText('8.5h')).toBeTruthy(); + expect(screen.getByText('13h')).toBeTruthy(); + expect(screen.queryByText('GAME_DETAIL.TTB_COMPLETELY')).toBeNull(); + }); + + it('renders nothing without data', () => { + const {container} = render(); + + expect(container.textContent).toBe(''); + cleanup(); + expect(render().container.textContent).toBe(''); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 7e255c8..e9b9284 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -12,6 +12,7 @@ import BacklogCard from '../components/BacklogCard.jsx'; import GameCover from '../components/GameCover.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx'; import PlayingHint from '../components/PlayingHint.jsx'; +import TimeToBeat from '../components/TimeToBeat.jsx'; import {formatPlaytime} from '../utils/games.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; @@ -134,6 +135,9 @@ export default function GameDetail() { +
+ +
))} +
diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 8f72848..6bb681c 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -102,3 +102,16 @@ describe('GameLibrary sort', () => { expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort); }); }); + +describe('GameLibrary short games', () => { + it('filters by time to beat and keeps the chip state in the URL', async () => { + const lib = renderLibrary(); + await waitFor(() => expect(fetch).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'})); + + await waitFor(() => expect(lib.params().get('maxMinutes')).toBe('600')); + await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]maxMinutes=600(&|$)/)); + expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true'); + }); +}); diff --git a/src/utils/backlog.js b/src/utils/backlog.js index b4780f4..882c776 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -25,10 +25,11 @@ export const saveBacklogEntry = (appid, entry) => body: JSON.stringify(entry), }); -export const libraryQuery = ({page, size, sort, status, q}) => { +export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => { const params = new URLSearchParams({page: String(page), size: String(size), sort}); if (status) params.set('status', status); if (q?.trim()) params.set('q', q.trim()); + if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); return params.toString(); }; diff --git a/src/utils/backlog.test.js b/src/utils/backlog.test.js index c000e11..1ba86aa 100644 --- a/src/utils/backlog.test.js +++ b/src/utils/backlog.test.js @@ -27,6 +27,11 @@ describe('libraryQuery', () => { .toBe('page=0&size=20&sort=playtimeMinutes%2Cdesc'); }); + it('adds the time-to-beat limit', () => { + expect(libraryQuery({page: 0, size: 20, sort: 'appid,asc', status: null, q: '', maxMinutes: 600})) + .toBe('page=0&size=20&sort=appid%2Casc&maxMinutes=600'); + }); + it('adds status and the trimmed, encoded search', () => { expect(libraryQuery({page: 1, size: 40, sort: 'appid,asc', status: 'DROPPED', q: ' 100% & more '})) .toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more');