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,
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), high
priority first, reroll without repeats, and "Start playing" in one click.
- **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.
- **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.
- **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.
- **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.
- **Wishlist** with store price and discount.
- **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 --> Igdb["IGDB API"]
Core -->|"OAuth token"| Twitch["Twitch"]
Core --> Speedrun["speedrun.com API"]
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
```
@@ -117,7 +120,7 @@ npm run build
## Roadmap
- Mood filter for "What to play next".
See the roadmap in the ToBePlayed-Core readme.
## License
+6 -2
View File
@@ -185,6 +185,7 @@
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
"PLAYTIME": "Spielzeit: {{time}}",
"OPEN_IN_STEAM": "Auf Steam ansehen",
"OPEN_IN_SPEEDRUN": "Speedruns auf speedrun.com",
"ACHIEVEMENTS": "Erfolge",
"UNLOCKED": "Erreicht",
"REMAINING": "Offen",
@@ -312,10 +313,13 @@
"COOP": "Koop",
"CHILL": "Entspannt",
"ACTION": "Action",
"SCARY": "Gruselig"
"SCARY": "Gruselig",
"SPEEDRUN": "Speedrun"
},
"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": {
"ADD_BUTTON": "Spiel hinzufügen",
+6 -2
View File
@@ -185,6 +185,7 @@
"NOT_FOUND": "Game not in your library.",
"PLAYTIME": "Playtime: {{time}}",
"OPEN_IN_STEAM": "View on Steam",
"OPEN_IN_SPEEDRUN": "Speedruns on speedrun.com",
"ACHIEVEMENTS": "Achievements",
"UNLOCKED": "Unlocked",
"REMAINING": "Remaining",
@@ -312,10 +313,13 @@
"COOP": "Co-op",
"CHILL": "Chill",
"ACTION": "Action",
"SCARY": "Scary"
"SCARY": "Scary",
"SPEEDRUN": "Speedrun"
},
"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": {
"ADD_BUTTON": "Add game",
+13 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
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 {Button} from '@/components/ui/button';
@@ -24,6 +24,8 @@ import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
try {
@@ -103,6 +105,9 @@ export default function GameDetail() {
const manual = data?.source === 'MANUAL';
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 isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
@@ -154,6 +159,13 @@ export default function GameDetail() {
<div className="mt-3">
<TimeToBeat time={data.timeToBeat}/>
</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>
{/* 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">
+18
View File
@@ -35,6 +35,24 @@ afterEach(() => {
});
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 () => {
renderDetail();
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
+8 -3
View File
@@ -23,8 +23,9 @@ const BUDGETS = [
{key: 'LONG', maxMinutes: 2400},
{key: 'ANY', maxMinutes: null},
];
// The moods Core derives from IGDB's genres, themes and game modes; a mood leaves out games without IGDB data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY'];
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
// games without that data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
const PICK_COUNT = 3;
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100;
@@ -144,7 +145,9 @@ export default function PlayNextPage() {
))}
</div>
{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>
@@ -188,6 +191,8 @@ export default function PlayNextPage() {
}
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';
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();
});
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 () => {
mockBackend([[game(1)]]);
const pickerFetch = globalThis.fetch;