From edeea5a43d148ee197d431e13eb53c028e9a7ef2 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 5 Oct 2026 16:13:20 +0200 Subject: [PATCH] feat(play-next): show why each game was picked Each card shows a reason line under the game name. Issue #28. --- README.md | 4 ++-- public/i18n/de.json | 7 ++++++- public/i18n/en.json | 7 ++++++- src/pages/PlayNextPage.jsx | 19 ++++++++++++++++++- src/pages/PlayNextPage.test.jsx | 23 +++++++++++++++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cc613a9..b29c3ab 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ To Be Played is split into three repositories: 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 %. - **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), high priority first, reroll without - repeats, and "Start playing" in one click. + (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. - **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods; trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. diff --git a/public/i18n/de.json b/public/i18n/de.json index 2e3fbf4..7bafe7a 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -409,7 +409,12 @@ }, "PLAY_NEXT": { "TITLE": "Was spiele ich als Nächstes?", - "SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.", + "SUBTITLE": "Drei Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende und eins zufällig. Wähle eine Länge und eine Stimmung.", + "REASON": { + "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", + "LONGEST": "Auf deiner Liste seit {{date}}", + "RANDOM": "Zufallstreffer" + }, "BUDGET_LABEL": "Spieldauer", "BUDGET": { "SHORT": "Bis 5 h", diff --git a/public/i18n/en.json b/public/i18n/en.json index 1d1bd8d..909fc51 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -409,7 +409,12 @@ }, "PLAY_NEXT": { "TITLE": "What to play next", - "SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.", + "SUBTITLE": "Three games from your backlog: one like a game you played a lot, the one waiting longest, and one at random. Pick a length and a mood.", + "REASON": { + "SIMILAR": "Because you played {{name}} ({{playtime}})", + "LONGEST": "On your list since {{date}}", + "RANDOM": "Random pick" + }, "BUDGET_LABEL": "Time to beat", "BUDGET": { "SHORT": "Up to 5 h", diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 381c3a3..7a216f9 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -12,6 +12,7 @@ import GameCover from '../components/GameCover.jsx'; import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {saveGameStatus} from '../utils/backlog.js'; +import {formatDate} from '../utils/format.js'; import {formatPlaytime} from '../utils/games.js'; import {request} from '../utils/api.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js'; @@ -174,6 +175,20 @@ export default function PlayNextPage() { ); } +const reasonText = (reason, t, language) => { + if (!reason) return null; + if (reason.source === 'SIMILAR') { + return t('PLAY_NEXT.REASON.SIMILAR', { + name: reason.anchorName ?? `App ${reason.anchorAppid}`, + playtime: formatPlaytime(reason.anchorPlaytimeMinutes), + }); + } + if (reason.source === 'LONGEST') { + return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})}); + } + return t('PLAY_NEXT.REASON.RANDOM'); +}; + const emptyKey = (budget, mood) => { // With a length limit, waiting for the background check may never help. if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN'; @@ -182,9 +197,10 @@ const emptyKey = (budget, mood) => { }; function PickCard({game}) { - const {t} = useTranslation(); + const {t, i18n} = useTranslation(); const [busy, setBusy] = useState(false); const [started, setStarted] = useState(false); + const reason = reasonText(game.reason, t, i18n.language); const startPlaying = async () => { setBusy(true); @@ -214,6 +230,7 @@ function PickCard({game}) { + {reason &&

{reason}

}
{game.timeToBeatMinutes > 0 && ( diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index 154a4b4..ff2894f 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -224,4 +224,27 @@ describe('PlayNextPage', () => { expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull(); }); + + it('shows why each game was picked', async () => { + mockBackend([[ + game(1, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}}), + game(2, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}}), + game(3, {reason: {source: 'RANDOM'}}), + ]]); + + renderPage(); + + expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.REASON.RANDOM')).toBeTruthy(); + }); + + it('shows no reason line for a game without a reason', async () => { + mockBackend([[game(1)]]); + + renderPage(); + + await screen.findByText('Game 1'); + expect(screen.queryByText(/PLAY_NEXT\.REASON/)).toBeNull(); + }); });