feat(picker): add the speedrun mood and the speedrun.com link
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 23s

"What to play next" gets a Speedrun mood. The game detail links the
game's speedrun.com page when Core found one with verified runs; only
https://www.speedrun.com/ links are shown.
This commit is contained in:
lobo committed 2026-09-26 10:39:10 +02:00
1 parent b0c78d8dca
commit 23ab5a91e7
7 files changed
+82 -12

No files matched your search

+7 -4
View File
@@ -22,14 +22,16 @@ To Be Played is split into three repositories:
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion, - **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion,
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked 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 %. 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), high - **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
priority first, reroll without repeats, and "Start playing" in one click. (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without
repeats, and "Start playing" in one click.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. a platform and optional hours. Works without a Steam ID.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. every achievement is unlocked.
- **Wishlist** with store price and discount. - **Wishlist** with store price and discount.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion. - **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
@@ -64,6 +66,7 @@ flowchart LR
Core --> Steam["Steam Web API"] Core --> Steam["Steam Web API"]
Core --> Igdb["IGDB API"] Core --> Igdb["IGDB API"]
Core -->|"OAuth token"| Twitch["Twitch"] Core -->|"OAuth token"| Twitch["Twitch"]
Core --> Speedrun["speedrun.com API"]
Browser -.->|"game art"| Images["Steam and IGDB image hosts"] Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
``` ```
@@ -117,7 +120,7 @@ npm run build
## Roadmap ## Roadmap
- Mood filter for "What to play next". See the roadmap in the ToBePlayed-Core readme.
## License ## License
+6 -2
View File
@@ -185,6 +185,7 @@
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.", "NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
"PLAYTIME": "Spielzeit: {{time}}", "PLAYTIME": "Spielzeit: {{time}}",
"OPEN_IN_STEAM": "Auf Steam ansehen", "OPEN_IN_STEAM": "Auf Steam ansehen",
"OPEN_IN_SPEEDRUN": "Speedruns auf speedrun.com",
"ACHIEVEMENTS": "Erfolge", "ACHIEVEMENTS": "Erfolge",
"UNLOCKED": "Erreicht", "UNLOCKED": "Erreicht",
"REMAINING": "Offen", "REMAINING": "Offen",
@@ -312,10 +313,13 @@
"COOP": "Koop", "COOP": "Koop",
"CHILL": "Entspannt", "CHILL": "Entspannt",
"ACTION": "Action", "ACTION": "Action",
"SCARY": "Gruselig" "SCARY": "Gruselig",
"SPEEDRUN": "Speedrun"
}, },
"MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.", "MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.",
"EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl." "MOOD_HINT_SPEEDRUN": "Nur Spiele mit bestätigten Runs auf speedrun.com. Der Backlog wird im Hintergrund geprüft, etwa ein Spiel alle paar Sekunden.",
"EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl.",
"EMPTY_SPEEDRUN": "Kein Backlog-Spiel mit Speedruns gefunden. Neue Spiele werden eventuell noch im Hintergrund geprüft; versuch es in ein paar Minuten noch einmal."
}, },
"MANUAL_GAME": { "MANUAL_GAME": {
"ADD_BUTTON": "Spiel hinzufügen", "ADD_BUTTON": "Spiel hinzufügen",
+6 -2
View File
@@ -185,6 +185,7 @@
"NOT_FOUND": "Game not in your library.", "NOT_FOUND": "Game not in your library.",
"PLAYTIME": "Playtime: {{time}}", "PLAYTIME": "Playtime: {{time}}",
"OPEN_IN_STEAM": "View on Steam", "OPEN_IN_STEAM": "View on Steam",
"OPEN_IN_SPEEDRUN": "Speedruns on speedrun.com",
"ACHIEVEMENTS": "Achievements", "ACHIEVEMENTS": "Achievements",
"UNLOCKED": "Unlocked", "UNLOCKED": "Unlocked",
"REMAINING": "Remaining", "REMAINING": "Remaining",
@@ -312,10 +313,13 @@
"COOP": "Co-op", "COOP": "Co-op",
"CHILL": "Chill", "CHILL": "Chill",
"ACTION": "Action", "ACTION": "Action",
"SCARY": "Scary" "SCARY": "Scary",
"SPEEDRUN": "Speedrun"
}, },
"MOOD_HINT": "Only games whose genre IGDB knows.", "MOOD_HINT": "Only games whose genre IGDB knows.",
"EMPTY_MOOD": "No backlog game fits. Try another mood or length. New games get their mood a moment after the first pick." "MOOD_HINT_SPEEDRUN": "Only games with verified runs on speedrun.com. The backlog is checked in the background, about one game every few seconds.",
"EMPTY_MOOD": "No backlog game fits. Try another mood or length. New games get their mood a moment after the first pick.",
"EMPTY_SPEEDRUN": "No backlog game with speedruns found. New games may still be checked in the background; try again in a few minutes."
}, },
"MANUAL_GAME": { "MANUAL_GAME": {
"ADD_BUTTON": "Add game", "ADD_BUTTON": "Add game",
+13 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom'; import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ArrowLeft, Clock, ExternalLink, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Trophy} from 'lucide-react'; import {ArrowLeft, Clock, ExternalLink, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -24,6 +24,8 @@ import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js'; import {isSteamKey} from '../utils/manualGames.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
const formatUnlockedAt = (instant, locale) => { const formatUnlockedAt = (instant, locale) => {
if (!instant) return ''; if (!instant) return '';
try { try {
@@ -103,6 +105,9 @@ export default function GameDetail() {
const manual = data?.source === 'MANUAL'; const manual = data?.source === 'MANUAL';
const onSteam = !!data && isSteamKey(data.appid); const onSteam = !!data && isSteamKey(data.appid);
// Core checks the link too; a link from the API must never run script.
const speedrunUrl = typeof data?.speedrunUrl === 'string' && data.speedrunUrl.startsWith(SPEEDRUN_PREFIX)
? data.speedrunUrl : null;
const isBackground = !!data && appearance.backgroundAppid === data.appid; const isBackground = !!data && appearance.backgroundAppid === data.appid;
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid; const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
@@ -154,6 +159,13 @@ export default function GameDetail() {
<div className="mt-3"> <div className="mt-3">
<TimeToBeat time={data.timeToBeat}/> <TimeToBeat time={data.timeToBeat}/>
</div> </div>
{speedrunUrl && (
<Button asChild variant="outline" size="sm" className="mt-3">
<a href={speedrunUrl} target="_blank" rel="noopener noreferrer">
<Timer/>{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}<ExternalLink/>
</a>
</Button>
)}
</div> </div>
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */} {/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
{onSteam && <div className="flex flex-wrap gap-2"> {onSteam && <div className="flex flex-wrap gap-2">
+18
View File
@@ -35,6 +35,24 @@ afterEach(() => {
}); });
describe('GameDetail', () => { describe('GameDetail', () => {
it('links the speedrun.com page when Core found one', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})}));
renderDetail();
const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/});
expect(link.getAttribute('href')).toBe('https://www.speedrun.com/hl2');
});
it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])(
'shows no speedrun link for %s', async (speedrunUrl) => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, speedrunUrl})}));
renderDetail();
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_SPEEDRUN')).toBeNull();
});
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => { it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
renderDetail(); renderDetail();
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL'); const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
+8 -3
View File
@@ -23,8 +23,9 @@ const BUDGETS = [
{key: 'LONG', maxMinutes: 2400}, {key: 'LONG', maxMinutes: 2400},
{key: 'ANY', maxMinutes: null}, {key: 'ANY', maxMinutes: null},
]; ];
// The moods Core derives from IGDB's genres, themes and game modes; a mood leaves out games without IGDB data. // Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY']; // games without that data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
const PICK_COUNT = 3; const PICK_COUNT = 3;
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100; const MAX_EXCLUDES = 100;
@@ -144,7 +145,9 @@ export default function PlayNextPage() {
))} ))}
</div> </div>
{mood !== 'ANY' && ( {mood !== 'ANY' && (
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.MOOD_HINT')}</p> <p className="text-xs text-muted-foreground">
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
</p>
)} )}
</div> </div>
@@ -188,6 +191,8 @@ export default function PlayNextPage() {
} }
const emptyKey = (budget, mood) => { 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';
if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD'; if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD';
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY'; return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
}; };
+24
View File
@@ -110,6 +110,30 @@ describe('PlayNextPage', () => {
expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy();
}); });
it('asks for speedrun games and explains the background check', async () => {
mockBackend([[game(1)], []]);
renderPage();
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'}));
expect(await screen.findByText('PLAY_NEXT.EMPTY_SPEEDRUN')).toBeTruthy();
expect(pickerUrls()[1].get('mood')).toBe('SPEEDRUN');
expect(screen.getByText('PLAY_NEXT.MOOD_HINT_SPEEDRUN')).toBeTruthy();
});
it('with a length limit, an empty speedrun pick says to change the length', async () => {
mockBackend([[game(1)], [game(2)], []]);
renderPage();
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
await screen.findByText('Game 2');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'}));
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
});
it('drops the old picks when a new budget fails to load', async () => { it('drops the old picks when a new budget fails to load', async () => {
mockBackend([[game(1)]]); mockBackend([[game(1)]]);
const pickerFetch = globalThis.fetch; const pickerFetch = globalThis.fetch;