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

+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;