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

+3
View File
@@ -25,6 +25,9 @@ To Be Played is split into three repositories:
days ("this month: +N added, -N off the pile") and a random suggestion,
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
total, perfect games, average completion and the 5 games closest to 100 %.
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
`pileGames`, so coming games are not on it; Core never picks them.
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
played a lot, waiting longest, or random. Reroll without repeats, and "Start playing" in one click.
+2
View File
@@ -234,6 +234,8 @@
"LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
"ERROR_LOAD": "Spiele konnten nicht geladen werden.",
"COMING": "Erscheint am {{date}}",
"COMING_SOON": "Erscheint bald",
"PER_PAGE": "Pro Seite:",
"SORT_BY": "Sortieren:",
"SORT_PLAYTIME_DESC": "Meistgespielt",
+2
View File
@@ -234,6 +234,8 @@
"LOADING": "Loading games …",
"EMPTY": "No games yet. Sync your Steam library or add a game.",
"ERROR_LOAD": "Could not load your games.",
"COMING": "Coming {{date}}",
"COMING_SOON": "Coming soon",
"PER_PAGE": "Per page:",
"SORT_BY": "Sort by:",
"SORT_PLAYTIME_DESC": "Most played",
+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)});
+17
View File
@@ -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();
+2
View File
@@ -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"
+14
View File
@@ -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},
+3
View File
@@ -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>
))}
+13
View File
@@ -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();
+4 -1
View File
@@ -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;
+4
View File
@@ -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);