feat(games): add coming badge for unreleased games
Build, Test & Push Frontend / quality-check (push) Successful in 32s
Build, Test & Push Frontend / docker (push) Successful in 22s

This commit is contained in:
lobo committed 2026-10-05 20:33:25 +02:00
1 parent edeea5a43d
commit c158909083
17 files changed
+169 -3

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import {useTranslation} from 'react-i18next';
import {CalendarClock} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {formatDay} from '../utils/format.js';
/**
* The "coming <date>" badge of a game that is not released yet; nothing for a released game. The date comes from the
* backend as `comingOn`, null for "coming soon".
*
* @param compact shows only the icon below the sm breakpoint
*/
export default function ComingBadge({game, compact = false}) {
const {t, i18n} = useTranslation();
if (!game.coming) return null;
const label = game.comingOn
? t('LIBRARY.COMING', {date: formatDay(game.comingOn, i18n.language, {dateStyle: 'medium'})})
: t('LIBRARY.COMING_SOON');
return (
<Badge variant="outline" title={label}>
<CalendarClock/>
<span className={compact ? 'hidden sm:inline' : undefined}>{label}</span>
</Badge>
);
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import ComingBadge from './ComingBadge.jsx';
const i18n = vi.hoisted(() => ({language: 'en'}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options?.date ? `${key} ${options.date}` : key),
i18n,
}),
}));
describe('ComingBadge', () => {
afterEach(cleanup);
it('shows the release date in English', () => {
i18n.language = 'en';
render(<ComingBadge game={{coming: true, comingOn: '2026-12-04'}}/>);
expect(screen.getByText('LIBRARY.COMING Dec 4, 2026')).toBeTruthy();
});
it('shows the release date in German', () => {
i18n.language = 'de';
render(<ComingBadge game={{coming: true, comingOn: '2026-12-04'}}/>);
expect(screen.getByText('LIBRARY.COMING 04.12.2026')).toBeTruthy();
});
it('says "coming soon" when the date is unknown', () => {
render(<ComingBadge game={{coming: true, comingOn: null}}/>);
expect(screen.getByText('LIBRARY.COMING_SOON')).toBeTruthy();
});
it('shows nothing for a released game', () => {
const {container} = render(<ComingBadge game={{coming: false, comingOn: null}}/>);
expect(container.firstChild).toBeNull();
});
it('shows nothing when an older Core sends no field', () => {
const {container} = render(<ComingBadge game={{appid: 220}}/>);
expect(container.firstChild).toBeNull();
});
});
+2
View File
@@ -9,6 +9,7 @@ import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import ComingBadge from './ComingBadge.jsx';
import GameStatusBadge from './GameStatusBadge.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx';
@@ -77,6 +78,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
<Clock className="size-3"/>
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})}
</Badge>
<ComingBadge game={game}/>
<GameStatusBadge status={game.status}/>
{platformLabel(game) && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
+2 -2
View File
@@ -8,7 +8,7 @@ import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx';
import {StatCount} from './stats/StatDrill.jsx';
import {backlogShare} from '../utils/backlog.js';
import {backlogShare, pileGames} from '../utils/backlog.js';
import {sliceColors, statusSlices} from '../utils/statCharts.js';
import {formatPlaytime} from '../utils/games.js';
import {libraryLink} from '../utils/statGames.js';
@@ -18,7 +18,7 @@ import {libraryLink} from '../utils/statGames.js';
*/
export default function PileOfShameCard({summary, trend, syncing, onShuffle, shuffling}) {
const {t, i18n} = useTranslation();
const backlog = summary.byStatus?.BACKLOG ?? 0;
const backlog = pileGames(summary);
const suggestion = summary.suggestion;
const slices = statusSlices(summary.byStatus);
const colors = sliceColors(slices);
+13
View File
@@ -28,6 +28,19 @@ describe('PileOfShameCard', () => {
expect(screen.queryByText('DASHBOARD.BACKLOG_EMPTY')).toBeNull();
});
it('counts the pile without the games that are not released yet', () => {
renderCard({summary: summary(5, {pileGames: 3})});
expect(screen.getByText('3', {selector: '.text-4xl'})).toBeTruthy();
expect(screen.queryByText('5', {selector: '.text-4xl'})).toBeNull();
});
it('falls back to the BACKLOG status count when an older Core sends no pileGames', () => {
renderCard({summary: summary(5)});
expect(screen.getByText('5', {selector: '.text-4xl'})).toBeTruthy();
});
it('hides the shuffle button when there is nothing else to pick', () => {
renderCard({summary: summary(1)});
+2
View File
@@ -5,6 +5,7 @@ import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import {Skeleton} from '@/components/ui/skeleton';
import GameRow from './GameRow.jsx';
import ComingBadge from '../ComingBadge.jsx';
import GameStatusBadge from '../GameStatusBadge.jsx';
import {formatDate} from '../../utils/format.js';
import {formatPlaytime} from '../../utils/games.js';
@@ -52,6 +53,7 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
const moved = game.fromStatus || game.toStatus;
return (
<>
<ComingBadge game={game} compact/>
{!moved && game.status && <GameStatusBadge status={game.status} compact/>}
{moved && (
<span>{game.fromStatus ? t(`BACKLOG.STATUS.${game.fromStatus}`) : t('STATS.DRILL.NEW_IN_LIBRARY')}
@@ -78,6 +78,18 @@ describe('StatGamesDialog', () => {
expect(mock.mock.calls[0][1].headers['X-Profile-Ticket']).toBe('old');
});
it('showsTheComingBadgeOnlyForAGameNotReleasedYet', async () => {
fetchRoutes({[`GET ${GAMES}`]: page([
{appid: 5, name: 'Dawn of War 4', status: 'BACKLOG', playtimeMinutes: 0, coming: true, comingOn: '2026-12-04'},
{appid: 6, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 0, coming: false, comingOn: null}], 0, 1, 2)});
renderDialog();
await screen.findByText('Portal 2');
expect(screen.getAllByText('LIBRARY.COMING')).toHaveLength(1);
expect(screen.getByText('Dawn of War 4').closest('li').textContent).toContain('LIBRARY.COMING');
});
it('showsTheStatusMoveOfATrendRow', async () => {
fetchRoutes({[`GET ${GAMES}`]: page(
[{appid: 5, name: 'Moved', fromStatus: 'BACKLOG', toStatus: 'PLAYING', at: '2026-09-20T10:00:00Z'}], 0, 1, 1)});