feat(library): time to beat, short-games filter and backlog hours

This commit is contained in:
lobo committed 2026-09-24 14:40:41 +02:00
1 parent fd343da4be
commit 91d5672421
11 files changed
+131 -11

No files matched your search

+6
View File
@@ -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}
<p className="mt-1 text-sm text-muted-foreground">
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
</p>
{summary.backlogMinutes > 0 && (
<p className="text-sm text-muted-foreground">
{t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})}
</p>
)}
</div>
{summary.total > 0 && (
+12
View File
@@ -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();
});
});
+29
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<Timer className="size-4 text-primary" aria-label={t('GAME_DETAIL.TTB')}/>
{known.map(([field, labelKey]) => (
<span key={field}>
{t(labelKey)} <span className="font-medium text-foreground tabular-nums">{formatPlaytime(time[field])}</span>
</span>
))}
</div>
);
}
+26
View File
@@ -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(<TimeToBeat time={{hastilyMinutes: 510, normallyMinutes: 750, completelyMinutes: null}}/>);
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(<TimeToBeat time={{hastilyMinutes: null, normallyMinutes: null, completelyMinutes: null}}/>);
expect(container.textContent).toBe('');
cleanup();
expect(render(<TimeToBeat time={null}/>).container.textContent).toBe('');
});
});