Public Access
feat(games): add coming badge for unreleased games
This commit is contained in:
1 parent
edeea5a43d
commit
c158909083
17 files changed
+169
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)});
|
||||
|
||||
|
||||
@@ -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)});
|
||||
|
||||
Reference in new issue
Block a user