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)});
|
||||
|
||||
@@ -38,6 +38,23 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('GameDetail', () => {
|
||||
it('shows the coming badge of a game that is not released yet', async () => {
|
||||
answer({...game, source: 'STEAM', coming: true, comingOn: '2026-12-04'});
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText('LIBRARY.COMING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no coming badge for a released game', async () => {
|
||||
answer({...game, source: 'STEAM', coming: false, comingOn: null});
|
||||
renderDetail();
|
||||
|
||||
await screen.findByText('Half-Life 2');
|
||||
|
||||
expect(screen.queryByText('LIBRARY.COMING')).toBeNull();
|
||||
expect(screen.queryByText('LIBRARY.COMING_SOON')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => {
|
||||
answer({...game, playtime2Weeks: 90, source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}});
|
||||
renderDetail();
|
||||
|
||||
@@ -19,6 +19,7 @@ import AddGamePanel from '../components/AddGamePanel.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import ComingBadge from '../components/ComingBadge.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
@@ -316,6 +317,7 @@ export default function GameLibrary() {
|
||||
<Layers className="size-3"/>x{g.editions}
|
||||
</Badge>
|
||||
)}
|
||||
<ComingBadge game={g} compact/>
|
||||
<GameStatusBadge status={g.status} compact/>
|
||||
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
|
||||
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
|
||||
|
||||
@@ -62,6 +62,20 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('GameLibrary search', () => {
|
||||
it('shows the coming badge only for a game that is not released yet', async () => {
|
||||
answer([
|
||||
{...page.content[0], coming: true, comingOn: '2026-12-04'},
|
||||
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', coming: false, comingOn: null},
|
||||
]);
|
||||
renderLibrary();
|
||||
|
||||
await screen.findByText('Portal 2');
|
||||
|
||||
expect(screen.getAllByText('LIBRARY.COMING')).toHaveLength(1);
|
||||
expect(screen.getByText('Half-Life 2').closest('li').textContent).toContain('LIBRARY.COMING');
|
||||
expect(screen.getByText('Portal 2').closest('li').textContent).not.toContain('LIBRARY.COMING');
|
||||
});
|
||||
|
||||
it('shows the achievement progress of a game when it is known', async () => {
|
||||
answer([
|
||||
{...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import ComingBadge from '../components/ComingBadge.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
|
||||
@@ -242,6 +243,7 @@ function Playing({games, compact}) {
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
|
||||
<div className="@container text-sm font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
|
||||
<ComingBadge game={g}/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -269,6 +271,7 @@ function Finished({games, equipped}) {
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<ComingBadge game={g} compact/>
|
||||
<GameStatusBadge status={g.status} compact/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -34,6 +34,19 @@ const renderPage = (name = 'Lobo') => renderWithProviders(<PublicProfilePage/>,
|
||||
describe('PublicProfilePage', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows the coming badge on a game that is not released yet, in both lists', async () => {
|
||||
mockBackends({answer: {...library,
|
||||
playing: [{...library.playing[0], status: 'PLAYING', coming: true, comingOn: '2026-12-04'}],
|
||||
finished: [{...library.finished[0], coming: false, comingOn: null}]}});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Portal 2');
|
||||
|
||||
expect(screen.getAllByText('LIBRARY.COMING')).toHaveLength(1);
|
||||
expect(screen.getByText('Half-Life 2').closest('li').textContent).toContain('LIBRARY.COMING');
|
||||
expect(screen.getByText('Portal 2').closest('li').textContent).not.toContain('LIBRARY.COMING');
|
||||
});
|
||||
|
||||
it('wears the premium accent and the badge of the owner', async () => {
|
||||
mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_GOLD', BADGE: 'BADGE_PILE_SLAYER', BANNER_FRAME: 'FRAME_GOLD'}});
|
||||
renderPage();
|
||||
|
||||
@@ -66,5 +66,8 @@ export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, h
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
// pileGames is missing from an older Core
|
||||
export const pileGames = (summary) => summary?.pileGames ?? summary?.byStatus?.BACKLOG ?? 0;
|
||||
|
||||
export const backlogShare = (summary) =>
|
||||
summary?.total ? Math.round(((summary.byStatus?.BACKLOG ?? 0) / summary.total) * 100) : 0;
|
||||
summary?.total ? Math.round((pileGames(summary) / summary.total) * 100) : 0;
|
||||
@@ -43,6 +43,10 @@ describe('backlogShare', () => {
|
||||
expect(backlogShare({total: 3, byStatus: {BACKLOG: 2}})).toBe(67);
|
||||
});
|
||||
|
||||
it('uses the pile without coming games when Core sends it', () => {
|
||||
expect(backlogShare({total: 4, byStatus: {BACKLOG: 3}, pileGames: 1})).toBe(25);
|
||||
});
|
||||
|
||||
it('is 0 for an empty library', () => {
|
||||
expect(backlogShare({total: 0, byStatus: {BACKLOG: 0}})).toBe(0);
|
||||
expect(backlogShare(null)).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user