From c158909083809aaf7154b3691a04a36c2831fe0f Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 5 Oct 2026 20:33:25 +0200 Subject: [PATCH] feat(games): add coming badge for unreleased games --- README.md | 3 ++ public/i18n/de.json | 2 + public/i18n/en.json | 2 + src/components/ComingBadge.jsx | 25 ++++++++++ src/components/ComingBadge.test.jsx | 49 +++++++++++++++++++ src/components/GameHeroCard.jsx | 2 + src/components/PileOfShameCard.jsx | 4 +- src/components/PileOfShameCard.test.jsx | 13 +++++ src/components/stats/StatGamesDialog.jsx | 2 + src/components/stats/StatGamesDialog.test.jsx | 12 +++++ src/pages/GameDetail.test.jsx | 17 +++++++ src/pages/GameLibrary.jsx | 2 + src/pages/GameLibrary.test.jsx | 14 ++++++ src/pages/PublicProfilePage.jsx | 3 ++ src/pages/PublicProfilePage.test.jsx | 13 +++++ src/utils/backlog.js | 5 +- src/utils/backlog.test.js | 4 ++ 17 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 src/components/ComingBadge.jsx create mode 100644 src/components/ComingBadge.test.jsx diff --git a/README.md b/README.md index b29c3ab..34007d3 100644 --- a/README.md +++ b/README.md @@ -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 " 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. diff --git a/public/i18n/de.json b/public/i18n/de.json index 7bafe7a..b89028c 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index 909fc51..49fc154 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/components/ComingBadge.jsx b/src/components/ComingBadge.jsx new file mode 100644 index 0000000..7ac4183 --- /dev/null +++ b/src/components/ComingBadge.jsx @@ -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 " 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 ( + + + {label} + + ); +} diff --git a/src/components/ComingBadge.test.jsx b/src/components/ComingBadge.test.jsx new file mode 100644 index 0000000..03f2433 --- /dev/null +++ b/src/components/ComingBadge.test.jsx @@ -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(); + + expect(screen.getByText('LIBRARY.COMING Dec 4, 2026')).toBeTruthy(); + }); + + it('shows the release date in German', () => { + i18n.language = 'de'; + render(); + + expect(screen.getByText('LIBRARY.COMING 04.12.2026')).toBeTruthy(); + }); + + it('says "coming soon" when the date is unknown', () => { + render(); + + expect(screen.getByText('LIBRARY.COMING_SOON')).toBeTruthy(); + }); + + it('shows nothing for a released game', () => { + const {container} = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('shows nothing when an older Core sends no field', () => { + const {container} = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/GameHeroCard.jsx b/src/components/GameHeroCard.jsx index 6ea2fcc..fc6c1d4 100644 --- a/src/components/GameHeroCard.jsx +++ b/src/components/GameHeroCard.jsx @@ -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 {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})} + {platformLabel(game) && ( {platformLabel(game)} diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index 2d4277c..faa6a42 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -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); diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index ef8ec9d..9b37952 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -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)}); diff --git a/src/components/stats/StatGamesDialog.jsx b/src/components/stats/StatGamesDialog.jsx index dbd3971..5196832 100644 --- a/src/components/stats/StatGamesDialog.jsx +++ b/src/components/stats/StatGamesDialog.jsx @@ -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 ( <> + {!moved && game.status && } {moved && ( {game.fromStatus ? t(`BACKLOG.STATUS.${game.fromStatus}`) : t('STATS.DRILL.NEW_IN_LIBRARY')} diff --git a/src/components/stats/StatGamesDialog.test.jsx b/src/components/stats/StatGamesDialog.test.jsx index 638181d..0b59bcd 100644 --- a/src/components/stats/StatGamesDialog.test.jsx +++ b/src/components/stats/StatGamesDialog.test.jsx @@ -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)}); diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index cbc50ca..1c65640 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -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(); diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 4444863..2d1fc32 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -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() { x{g.editions} )} + {g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( { }); 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}, diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 532c685..4178d95 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -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}) {
{formatPlaytime(g.playtimeMinutes)}
+ ))} @@ -269,6 +271,7 @@ function Finished({games, equipped}) { ))}
)} + ))} diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index a1cc354..77a88ef 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -34,6 +34,19 @@ const renderPage = (name = 'Lobo') => renderWithProviders(, 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(); diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 08cd5ca..0c7aa41 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -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; diff --git a/src/utils/backlog.test.js b/src/utils/backlog.test.js index 1a1fae7..3990e0d 100644 --- a/src/utils/backlog.test.js +++ b/src/utils/backlog.test.js @@ -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);