From 2efdf6467596b3d9bca55ade85723419bfead84e Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 09:29:15 +0200 Subject: [PATCH] feat(play-next): page that picks the next backlog game --- README.md | 4 +- public/i18n/de.json | 26 ++- public/i18n/en.json | 26 ++- src/App.jsx | 2 + src/components/AppHeader.jsx | 3 +- src/components/PileOfShameCard.jsx | 5 +- src/pages/PlayNextPage.jsx | 223 +++++++++++++++++++++++ src/pages/PlayNextPage.language.test.jsx | 42 +++++ src/pages/PlayNextPage.test.jsx | 168 +++++++++++++++++ src/utils/backlog.js | 10 + 10 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 src/pages/PlayNextPage.jsx create mode 100644 src/pages/PlayNextPage.language.test.jsx create mode 100644 src/pages/PlayNextPage.test.jsx diff --git a/README.md b/README.md index e3264c2..862bf79 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ 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. +- **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. - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest in library, rating or priority, paging. - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), @@ -80,7 +82,7 @@ npm run build ## Roadmap -- "What to play next" page: time budget and mood on top of the current random suggestion. +- Mood filter for "What to play next". - Achievement progress on the library cards and a library-wide achievement page. - Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in `nginx.conf`. diff --git a/public/i18n/de.json b/public/i18n/de.json index e441a05..c440a40 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -133,7 +133,8 @@ "ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen", "ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.", "RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)", - "BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist" + "BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist", + "OPEN_PICKER": "Such mir was aus" }, "LIBRARY": { "TITLE": "Spielebibliothek", @@ -218,7 +219,8 @@ "MENU": "Menü", "LANGUAGE": "Sprache", "DARK_MODE": "Dunkles Design", - "LIGHT_MODE": "Helles Design" + "LIGHT_MODE": "Helles Design", + "PLAY_NEXT": "Nächstes Spiel" }, "BACKLOG": { "TITLE": "Mein Backlog", @@ -267,5 +269,25 @@ "NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.", "ERROR_LOAD": "Profil konnte nicht geladen werden.", "TO_START": "Zur Startseite" + }, + "PLAY_NEXT": { + "TITLE": "Was spiele ich als Nächstes?", + "SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst.", + "BUDGET_LABEL": "Spieldauer", + "BUDGET": { + "SHORT": "Bis 5 h", + "MEDIUM": "Bis 15 h", + "LONG": "Bis 40 h", + "ANY": "Beliebig lang" + }, + "BUDGET_HINT": "Nur Spiele mit bekannter Spieldauer (von IGDB).", + "REROLL": "Andere Vorschläge", + "START_PLAYING": "Jetzt spielen", + "STARTED": "Wird gespielt", + "ALL_SHOWN": "Du hast alle passenden Spiele gesehen.", + "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." } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 20d8cf7..4029c90 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -133,7 +133,8 @@ "ACTIVITY_TOTAL": "{{time}} in {{days}} days", "ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.", "RECENTLY_PLAYED": "Recently played (2 weeks)", - "BACKLOG_HOURS": "≈ {{time}} of play to clear it" + "BACKLOG_HOURS": "≈ {{time}} of play to clear it", + "OPEN_PICKER": "Pick for me" }, "LIBRARY": { "TITLE": "Game Library", @@ -218,7 +219,8 @@ "MENU": "Menu", "LANGUAGE": "Language", "DARK_MODE": "Dark mode", - "LIGHT_MODE": "Light mode" + "LIGHT_MODE": "Light mode", + "PLAY_NEXT": "Play next" }, "BACKLOG": { "TITLE": "My backlog", @@ -267,5 +269,25 @@ "NOT_FOUND": "This profile does not exist or is private.", "ERROR_LOAD": "Could not load the profile.", "TO_START": "To the start page" + }, + "PLAY_NEXT": { + "TITLE": "What to play next", + "SUBTITLE": "Three games from your backlog, high priority first.", + "BUDGET_LABEL": "Time to beat", + "BUDGET": { + "SHORT": "Up to 5 h", + "MEDIUM": "Up to 15 h", + "LONG": "Up to 40 h", + "ANY": "Any length" + }, + "BUDGET_HINT": "Only games with a known time to beat (from IGDB).", + "REROLL": "Other picks", + "START_PLAYING": "Start playing", + "STARTED": "Now playing", + "ALL_SHOWN": "You have seen every game that fits.", + "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." } } diff --git a/src/App.jsx b/src/App.jsx index 6a1d5c3..d624ae5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import SettingsPage from './pages/SettingsPage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; import WishlistPage from './pages/WishlistPage.jsx'; +import PlayNextPage from './pages/PlayNextPage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; import {Toaster} from '@/components/ui/sonner'; @@ -61,6 +62,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 8bb3a56..ec73ed3 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,6 +1,6 @@ import {Link, NavLink, useLocation} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react'; +import {Dices, Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react'; import {Button} from '@/components/ui/button'; import { @@ -20,6 +20,7 @@ const NAV_ITEMS = [ {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, // The wishlist is a tab of the library. {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']}, + {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, ]; const navLinkClass = (isActive) => cn( diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index da13d18..4140f5f 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -1,6 +1,6 @@ import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {Inbox, Shuffle} from 'lucide-react'; +import {Dices, Inbox, Shuffle} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; @@ -86,6 +86,9 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling} {suggestion.name ?? `App ${suggestion.appid}`} + ) : (

{t('DASHBOARD.BACKLOG_EMPTY')}

diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx new file mode 100644 index 0000000..9c3f6b9 --- /dev/null +++ b/src/pages/PlayNextPage.jsx @@ -0,0 +1,223 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {Link, useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react'; + +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import {Skeleton} from '@/components/ui/skeleton'; +import GameCover from '../components/GameCover.jsx'; +import PageContainer from '../components/PageContainer.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {saveGameStatus} from '../utils/backlog.js'; +import {formatPlaytime} from '../utils/games.js'; + +// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data. +const BUDGETS = [ + {key: 'SHORT', maxMinutes: 300}, + {key: 'MEDIUM', maxMinutes: 900}, + {key: 'LONG', maxMinutes: 2400}, + {key: 'ANY', maxMinutes: null}, +]; +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 params = new URLSearchParams({count: String(PICK_COUNT)}); + if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); + exclude.forEach((appid) => params.append('exclude', String(appid))); + return params.toString(); +}; + +export default function PlayNextPage() { + const {t} = useTranslation(); + const navigate = useNavigate(); + const expireSession = useSessionExpired(); + const [budget, setBudget] = useState(BUDGETS[3]); + 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. + const latestRequest = useRef(0); + + const load = useCallback(async (maxMinutes, exclude) => { + const token = localStorage.getItem('tbp_jwt'); + if (!token) { + navigate('/login'); + return; + } + const request = ++latestRequest.current; + setLoading(true); + setError(null); + try { + const response = await fetch( + `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`, + {headers: {'Authorization': `Bearer ${token}`}}); + if (request !== latestRequest.current) return; + if (response.status === 401) { + expireSession(); + return; + } + if (!response.ok) { + setError('PLAY_NEXT.ERROR_LOAD'); + return; + } + const picked = (await response.json()).games ?? []; + if (request !== latestRequest.current) return; + setGames(picked); + setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES)); + } catch (err) { + console.error('Picker load error:', err); + if (request === latestRequest.current) setError('INFO.ERROR.NETWORK_FAILURE'); + } finally { + if (request === latestRequest.current) setLoading(false); + } + // No `t` here: a language change gives a new `t`, and a reload would replace the random picks. + }, [navigate, expireSession]); + + useEffect(() => { + load(budget.maxMinutes, []); + }, [budget, load]); + + const chooseBudget = (next) => { + if (next.key === budget.key) return; + setGames(null); + setSeen([]); + setBudget(next); + }; + + const allShown = games?.length === 0 && seen.length > 0; + + return ( + +
+

{t('PLAY_NEXT.TITLE')}

+

{t('PLAY_NEXT.SUBTITLE')}

+
+ +
+
+ {BUDGETS.map((b) => ( + + ))} +
+ {budget.maxMinutes && ( +

{t('PLAY_NEXT.BUDGET_HINT')}

+ )} +
+ + {error &&

{t(error)}

} + + {games === null && !error && ( +
+ {Array.from({length: PICK_COUNT}, (_, i) => )} +
+ )} + + {games?.length > 0 && ( +
+ {games.map((game) => )} +
+ )} + + {(games?.length > 0 || error) && ( + + )} + + {allShown && ( +
+

{t('PLAY_NEXT.ALL_SHOWN')}

+ +
+ )} + + {games?.length === 0 && seen.length === 0 && ( +

+ {t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')} +

+ )} +
+ ); +} + +function PickCard({game, expireSession}) { + const {t} = useTranslation(); + const [busy, setBusy] = useState(false); + const [started, setStarted] = useState(false); + + const startPlaying = async () => { + setBusy(true); + try { + const response = await saveGameStatus(game.appid, 'PLAYING'); + if (response.status === 401) { + expireSession(); + return; + } + if (!response.ok) { + toast.error(t('BACKLOG.SAVE_FAILED')); + return; + } + setStarted(true); + toast.success(t('BACKLOG.SAVED')); + } catch (err) { + console.error('Start playing error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return ( + + + + +
+ + {game.name ?? `App ${game.appid}`} + +
+ {game.timeToBeatMinutes > 0 && ( + + {formatPlaytime(game.timeToBeatMinutes)} + + )} + {game.priority && ( + + {t('BACKLOG.PRIORITY_LABEL')}: {t(`BACKLOG.PRIORITY.${game.priority}`)} + + )} +
+
+ {started ? ( +

+ {t('PLAY_NEXT.STARTED')} +

+ ) : ( + + )} +
+
+
+ ); +} diff --git a/src/pages/PlayNextPage.language.test.jsx b/src/pages/PlayNextPage.language.test.jsx new file mode 100644 index 0000000..0c4e25a --- /dev/null +++ b/src/pages/PlayNextPage.language.test.jsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import PlayNextPage from './PlayNextPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; + +// react-i18next hands out a new `t` on every language change; this mock does the same on demand. +const i18n = {language: 'de', t: (key) => key}; +vi.mock('react-i18next', () => ({useTranslation: () => ({t: i18n.t, i18n})})); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const game = (appid) => ({appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, + priority: null, timeToBeatMinutes: 600}); + +const pickerCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/library/picker')).length; + +describe('PlayNextPage on a language change', () => { + beforeEach(() => { + localStorage.setItem('tbp_jwt', 'token'); + i18n.t = (key) => key; + globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})})); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('keeps the picks when the language changes', async () => { + const {rerender} = render(); + await screen.findByText('Game 1'); + expect(pickerCalls()).toBe(1); + + i18n.language = 'en'; + i18n.t = (key) => key; + rerender(); + + await waitFor(() => expect(screen.getByText('Game 1')).toBeTruthy()); + expect(pickerCalls()).toBe(1); + }); +}); diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx new file mode 100644 index 0000000..82839ad --- /dev/null +++ b/src/pages/PlayNextPage.test.jsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import PlayNextPage from './PlayNextPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const game = (appid, extra = {}) => ({ + appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null, + timeToBeatMinutes: 600, ...extra, +}); + +const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body}); + +// Answers each picker call with the next list and records every request. +function mockBackend(pickerAnswers, detail = null) { + const answers = [...pickerAnswers]; + globalThis.fetch = vi.fn(async (url, options = {}) => { + if (url.includes('/api/library/picker')) return json({games: answers.shift() ?? []}); + if (options.method === 'PUT') return json(JSON.parse(options.body)); + return json(detail); + }); +} + +const pickerUrls = () => globalThis.fetch.mock.calls + .map(([url]) => url) + .filter((url) => url.includes('/api/library/picker')) + .map((url) => new URL(url, 'http://x').searchParams); + +function renderPage() { + render(); +} + +describe('PlayNextPage', () => { + beforeEach(() => { + localStorage.setItem('tbp_jwt', 'token'); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('shows three picks for any length by default', async () => { + mockBackend([[game(1), game(2), game(3)]]); + + renderPage(); + + expect(await screen.findByText('Game 1')).toBeTruthy(); + expect(screen.getByText('Game 3')).toBeTruthy(); + const params = pickerUrls()[0]; + expect(params.get('count')).toBe('3'); + expect(params.has('maxMinutes')).toBe(false); + expect(params.has('exclude')).toBe(false); + }); + + it('asks for games up to the chosen length', async () => { + mockBackend([[game(1)], [game(2)]]); + renderPage(); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'})); + + expect(await screen.findByText('Game 2')).toBeTruthy(); + expect(pickerUrls()[1].get('maxMinutes')).toBe('300'); + expect(pickerUrls()[1].has('exclude')).toBe(false); + }); + + it('drops the old picks when a new budget fails to load', async () => { + mockBackend([[game(1)]]); + const pickerFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (url, options) => + (url.includes('maxMinutes=300') ? json({}, 500) : pickerFetch(url, options))); + renderPage(); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'})); + + expect(await screen.findByText('PLAY_NEXT.ERROR_LOAD')).toBeTruthy(); + expect(screen.queryByText('Game 1')).toBeNull(); + }); + + it('rerolls without the games already shown', async () => { + mockBackend([[game(1), game(2)], [game(3)]]); + renderPage(); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); + + expect(await screen.findByText('Game 3')).toBeTruthy(); + expect(pickerUrls()[1].getAll('exclude')).toEqual(['1', '2']); + }); + + it('offers to start over when every game was shown', async () => { + mockBackend([[game(1)], [], [game(1)]]); + renderPage(); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); + fireEvent.click(await screen.findByRole('button', {name: 'PLAY_NEXT.START_OVER'})); + + expect(await screen.findByText('Game 1')).toBeTruthy(); + expect(pickerUrls()[2].has('exclude')).toBe(false); + }); + + it('says so when the backlog is empty', async () => { + mockBackend([[]]); + + renderPage(); + + expect(await screen.findByText('PLAY_NEXT.EMPTY')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).toBeNull(); + }); + + it('starts playing with a status-only update', async () => { + mockBackend([[game(7, {priority: 'HIGH'})]]); + renderPage(); + await screen.findByText('Game 7'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})); + + expect(await screen.findByText('PLAY_NEXT.STARTED')).toBeTruthy(); + const puts = globalThis.fetch.mock.calls.filter(([, options]) => options?.method === 'PUT'); + expect(puts).toHaveLength(1); + expect(puts[0][0]).toContain('/api/library/games/7/status'); + expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'}); + }); + + it('lets the user try again when saving fails', async () => { + mockBackend([[game(7)]]); + const pickerFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (url, options) => + (options?.method === 'PUT' ? json({}, 500) : pickerFetch(url, options))); + renderPage(); + await screen.findByText('Game 7'); + + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})); + + await waitFor(() => expect(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}).disabled).toBe(false)); + expect(screen.queryByText('PLAY_NEXT.STARTED')).toBeNull(); + }); + + it('never sends more excludes than the backend reads', async () => { + const answers = Array.from({length: 40}, (_, round) => [game(round * 3 + 1), game(round * 3 + 2), game(round * 3 + 3)]); + mockBackend(answers); + renderPage(); + await screen.findByText('Game 1'); + + for (let round = 1; round < 40; round++) { + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); + await screen.findByText(`Game ${round * 3 + 1}`); + } + + const largest = Math.max(...pickerUrls().map((params) => params.getAll('exclude').length)); + expect(largest).toBeLessThanOrEqual(100); + }); + + it('offers a retry when the first load fails', async () => { + globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + renderPage(); + await screen.findByText('INFO.ERROR.NETWORK_FAILURE'); + + expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull(); + }); +}); diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 882c776..5adb251 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -25,6 +25,16 @@ export const saveBacklogEntry = (appid, entry) => body: JSON.stringify(entry), }); +/** + * PUT /api/library/games/{appid}/status. The caller handles the response status. + */ +export const saveGameStatus = (appid, status) => + fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, { + method: 'PUT', + headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'}, + body: JSON.stringify({status}), + }); + export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => { const params = new URLSearchParams({page: String(page), size: String(size), sort}); if (status) params.set('status', status);