Public Access
feat(play-next): pick games by mood
This commit is contained in:
1 parent
7d9cf907fc
commit
fa1bd59509
4 files changed
+111
-13
No files matched your search
+13
-2
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"PLAY_NEXT": {
|
||||
"TITLE": "Was spiele ich als Nächstes?",
|
||||
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst.",
|
||||
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.",
|
||||
"BUDGET_LABEL": "Spieldauer",
|
||||
"BUDGET": {
|
||||
"SHORT": "Bis 5 h",
|
||||
@@ -304,7 +304,18 @@
|
||||
"START_OVER": "Von vorn",
|
||||
"EMPTY": "Dein Backlog ist leer. Stark!",
|
||||
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
|
||||
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden."
|
||||
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.",
|
||||
"MOOD_LABEL": "Stimmung",
|
||||
"MOOD": {
|
||||
"ANY": "Jede Stimmung",
|
||||
"STORY": "Story",
|
||||
"COOP": "Koop",
|
||||
"CHILL": "Entspannt",
|
||||
"ACTION": "Action",
|
||||
"SCARY": "Gruselig"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"MANUAL_GAME": {
|
||||
"ADD_BUTTON": "Spiel hinzufügen",
|
||||
|
||||
+13
-2
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"PLAY_NEXT": {
|
||||
"TITLE": "What to play next",
|
||||
"SUBTITLE": "Three games from your backlog, high priority first.",
|
||||
"SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.",
|
||||
"BUDGET_LABEL": "Time to beat",
|
||||
"BUDGET": {
|
||||
"SHORT": "Up to 5 h",
|
||||
@@ -304,7 +304,18 @@
|
||||
"START_OVER": "Start over",
|
||||
"EMPTY": "Your backlog is empty. Nice!",
|
||||
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
|
||||
"ERROR_LOAD": "Could not load the picks."
|
||||
"ERROR_LOAD": "Could not load the picks.",
|
||||
"MOOD_LABEL": "Mood",
|
||||
"MOOD": {
|
||||
"ANY": "Any mood",
|
||||
"STORY": "Story",
|
||||
"COOP": "Co-op",
|
||||
"CHILL": "Chill",
|
||||
"ACTION": "Action",
|
||||
"SCARY": "Scary"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"MANUAL_GAME": {
|
||||
"ADD_BUTTON": "Add game",
|
||||
|
||||
@@ -23,13 +23,16 @@ 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'];
|
||||
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;
|
||||
|
||||
const pickerQuery = (maxMinutes, exclude) => {
|
||||
const pickerQuery = (maxMinutes, mood, exclude) => {
|
||||
const params = new URLSearchParams({count: String(PICK_COUNT)});
|
||||
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
|
||||
if (mood !== 'ANY') params.set('mood', mood);
|
||||
exclude.forEach((appid) => params.append('exclude', String(appid)));
|
||||
return params.toString();
|
||||
};
|
||||
@@ -39,14 +42,15 @@ export default function PlayNextPage() {
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||
const [mood, setMood] = useState('ANY');
|
||||
const [games, setGames] = useState(null);
|
||||
const [seen, setSeen] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// A slow answer for an older budget must not replace the newer one.
|
||||
// A slow answer for an older budget or mood must not replace the newer one.
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
const load = useCallback(async (maxMinutes, exclude) => {
|
||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
@@ -56,7 +60,7 @@ export default function PlayNextPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`);
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
|
||||
if (request !== latestRequest.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
@@ -80,8 +84,8 @@ export default function PlayNextPage() {
|
||||
}, [navigate, expireSession]);
|
||||
|
||||
useEffect(() => {
|
||||
load(budget.maxMinutes, []);
|
||||
}, [budget, load]);
|
||||
load(budget.maxMinutes, mood, []);
|
||||
}, [budget, mood, load]);
|
||||
|
||||
const chooseBudget = (next) => {
|
||||
if (next.key === budget.key) return;
|
||||
@@ -90,6 +94,13 @@ export default function PlayNextPage() {
|
||||
setBudget(next);
|
||||
};
|
||||
|
||||
const chooseMood = (next) => {
|
||||
if (next === mood) return;
|
||||
setGames(null);
|
||||
setSeen([]);
|
||||
setMood(next);
|
||||
};
|
||||
|
||||
const allShown = games?.length === 0 && seen.length > 0;
|
||||
|
||||
return (
|
||||
@@ -118,6 +129,25 @@ export default function PlayNextPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.MOOD_LABEL')}>
|
||||
{MOODS.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={mood === key ? 'default' : 'outline'}
|
||||
aria-pressed={mood === key}
|
||||
onClick={() => chooseMood(key)}
|
||||
>
|
||||
{t(`PLAY_NEXT.MOOD.${key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{mood !== 'ANY' && (
|
||||
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.MOOD_HINT')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{t(error)}</p>}
|
||||
|
||||
{games === null && !error && (
|
||||
@@ -133,7 +163,7 @@ export default function PlayNextPage() {
|
||||
)}
|
||||
|
||||
{(games?.length > 0 || error) && (
|
||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, seen)} disabled={loading}>
|
||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, seen)} disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin"/> : <Shuffle/>}
|
||||
{t('PLAY_NEXT.REROLL')}
|
||||
</Button>
|
||||
@@ -142,7 +172,7 @@ export default function PlayNextPage() {
|
||||
{allShown && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">{t('PLAY_NEXT.ALL_SHOWN')}</p>
|
||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, [])} disabled={loading}>
|
||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, [])} disabled={loading}>
|
||||
<Shuffle/>{t('PLAY_NEXT.START_OVER')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,13 +180,18 @@ export default function PlayNextPage() {
|
||||
|
||||
{games?.length === 0 && seen.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')}
|
||||
{t(emptyKey(budget, mood))}
|
||||
</p>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyKey = (budget, mood) => {
|
||||
if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD';
|
||||
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
|
||||
};
|
||||
|
||||
function PickCard({game, expireSession}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -69,6 +69,47 @@ describe('PlayNextPage', () => {
|
||||
expect(pickerUrls()[1].has('exclude')).toBe(false);
|
||||
});
|
||||
|
||||
it('asks for games of the chosen mood and keeps the length', async () => {
|
||||
mockBackend([[game(1)], [game(2)], [game(3)]]);
|
||||
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.COOP'}));
|
||||
|
||||
expect(await screen.findByText('Game 3')).toBeTruthy();
|
||||
expect(pickerUrls()[2].get('mood')).toBe('COOP');
|
||||
expect(pickerUrls()[2].get('maxMinutes')).toBe('300');
|
||||
expect(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}).getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('sends no mood for any mood and rerolls within the mood', async () => {
|
||||
mockBackend([[game(1)], [game(2)], [game(3)]]);
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
expect(pickerUrls()[0].has('mood')).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
|
||||
await screen.findByText('Game 2');
|
||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
||||
|
||||
await screen.findByText('Game 3');
|
||||
expect(pickerUrls()[2].get('mood')).toBe('STORY');
|
||||
expect(pickerUrls()[2].getAll('exclude')).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('suggests another mood when none of the backlog fits', async () => {
|
||||
mockBackend([[game(1)], []]);
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SCARY'}));
|
||||
|
||||
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
|
||||
expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drops the old picks when a new budget fails to load', async () => {
|
||||
mockBackend([[game(1)]]);
|
||||
const pickerFetch = globalThis.fetch;
|
||||
|
||||
Reference in new issue
Block a user