Compare commits

...
2 Commits
Author SHA1 Message Date
lobo edeea5a43d feat(play-next): show why each game was picked
Build, Test & Push Frontend / docker (push) Successful in 2m33s
Build, Test & Push Frontend / quality-check (push) Successful in 1m15s
Each card shows a reason line under the game name. Issue #28.
2026-10-05 16:13:20 +02:00
lobo 2b20b389b7 refactor(hooks): move import polling and settings load into hooks
useImportPolling and useSettingsLoad take the effects out of ImportView
and SettingsPage, line for line. useSettingsLoad no longer sets state
after unmount. Tests for both hooks. Issue #6.
2026-10-05 15:37:16 +02:00
11 changed files with 373 additions and 98 deletions

No files matched your search

+2 -2
View File
@@ -26,8 +26,8 @@ To Be Played is split into three repositories:
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) 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.
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
played a lot, waiting longest, or random. Reroll without repeats, and "Start playing" in one click.
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
+6 -1
View File
@@ -409,7 +409,12 @@
},
"PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?",
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.",
"SUBTITLE": "Drei Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende und eins zufällig. Wähle eine Länge und eine Stimmung.",
"REASON": {
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
"LONGEST": "Auf deiner Liste seit {{date}}",
"RANDOM": "Zufallstreffer"
},
"BUDGET_LABEL": "Spieldauer",
"BUDGET": {
"SHORT": "Bis 5 h",
+6 -1
View File
@@ -409,7 +409,12 @@
},
"PLAY_NEXT": {
"TITLE": "What to play next",
"SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.",
"SUBTITLE": "Three games from your backlog: one like a game you played a lot, the one waiting longest, and one at random. Pick a length and a mood.",
"REASON": {
"SIMILAR": "Because you played {{name}} ({{playtime}})",
"LONGEST": "On your list since {{date}}",
"RANDOM": "Random pick"
},
"BUDGET_LABEL": "Time to beat",
"BUDGET": {
"SHORT": "Up to 5 h",
+4 -70
View File
@@ -1,4 +1,4 @@
import {useEffect, useRef, useState} from 'react';
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {CheckCircle2, Circle, Loader2, RotateCcw, XCircle} from 'lucide-react';
@@ -8,82 +8,16 @@ import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import ImportReview from './ImportReview.jsx';
import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx';
import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
import useImportPolling from '../../hooks/useImportPolling.js';
import {retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
const POLL_MS = 2000;
const MAX_BACKOFF_MS = 30_000;
const MAX_FAILURES = 10;
// Polls while a worker has the import.
export default function ImportView({id, onDone, onChanged}) {
const {t} = useTranslation();
const [data, setData] = useState(null);
const [failed, setFailed] = useState(false);
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
const [version, setVersion] = useState(0);
const reload = () => setVersion((current) => current + 1);
const lastStatus = useRef(null);
const {data, failed, reload} = useImportPolling(id, onChanged);
// undefined while loading: the review preselects with it, so it waits.
const {user} = useCurrentUserContext();
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
useEffect(() => {
let timer;
let cancelled = false;
let failures = 0;
let paused = false;
// A hidden tab waits for the visibilitychange instead of polling.
const poll = () => {
if (document.hidden) paused = true;
else load();
};
const onVisible = () => {
if (!document.hidden && paused) {
paused = false;
load();
}
};
// A 5xx while Core restarts or a network error is asked again, each time twice as late.
const retry = () => {
if (++failures >= MAX_FAILURES) setFailed(true);
else timer = setTimeout(poll, Math.min(POLL_MS * 2 ** failures, MAX_BACKOFF_MS));
};
const load = async () => {
try {
const response = await getImport(id);
if (cancelled) return;
if (response.status === 401) return;
// A 4xx ends the polling.
if (response.status >= 400 && response.status < 500) {
setFailed(true);
return;
}
if (!response.ok) {
retry();
return;
}
const next = await response.json();
if (cancelled) return;
failures = 0;
// A new status also shows in the history.
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
lastStatus.current = next.status;
setData(next);
if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS);
} catch (err) {
console.error('Import load error:', err);
if (!cancelled) retry();
}
};
document.addEventListener('visibilitychange', onVisible);
load();
return () => {
cancelled = true;
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [id, version, onChanged]);
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
return (
+77
View File
@@ -0,0 +1,77 @@
import {useEffect, useRef, useState} from 'react';
import {getImport, RUNNING} from '../utils/imports.js';
const POLL_MS = 2000;
const MAX_BACKOFF_MS = 30_000;
const MAX_FAILURES = 10;
/**
* Polls one import while a worker has it. `onChanged` runs when the status changes.
*/
export default function useImportPolling(id, onChanged) {
const [data, setData] = useState(null);
const [failed, setFailed] = useState(false);
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
const [version, setVersion] = useState(0);
const reload = () => setVersion((current) => current + 1);
const lastStatus = useRef(null);
useEffect(() => {
let timer;
let cancelled = false;
let failures = 0;
let paused = false;
// A hidden tab waits for the visibilitychange instead of polling.
const poll = () => {
if (document.hidden) paused = true;
else load();
};
const onVisible = () => {
if (!document.hidden && paused) {
paused = false;
load();
}
};
// A 5xx while Core restarts or a network error is asked again, each time twice as late.
const retry = () => {
if (++failures >= MAX_FAILURES) setFailed(true);
else timer = setTimeout(poll, Math.min(POLL_MS * 2 ** failures, MAX_BACKOFF_MS));
};
const load = async () => {
try {
const response = await getImport(id);
if (cancelled) return;
if (response.status === 401) return;
// A 4xx ends the polling.
if (response.status >= 400 && response.status < 500) {
setFailed(true);
return;
}
if (!response.ok) {
retry();
return;
}
const next = await response.json();
if (cancelled) return;
failures = 0;
// A new status also shows in the history.
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
lastStatus.current = next.status;
setData(next);
if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS);
} catch (err) {
console.error('Import load error:', err);
if (!cancelled) retry();
}
};
document.addEventListener('visibilitychange', onVisible);
load();
return () => {
cancelled = true;
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [id, version, onChanged]);
return {data, failed, reload};
}
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, renderHook} from '@testing-library/react';
import useImportPolling from './useImportPolling.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
const ROUTE = 'GET /api/library/imports/7';
// A stable callback, as ImportPage passes: a new one per render would restart the polling.
const noop = () => {};
const tick = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(console, 'error').mockImplementation(() => {});
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
localStorage.clear();
});
describe('useImportPolling', () => {
it('polls every 2000 ms while the status is running', async () => {
const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING', matched: 1})});
const {result} = renderHook(() => useImportPolling(7, noop));
await tick(0);
expect(fetch).toHaveBeenCalledTimes(1);
expect(result.current.data.matched).toBe(1);
await tick(2000);
expect(fetch).toHaveBeenCalledTimes(2);
await tick(2000);
expect(fetch).toHaveBeenCalledTimes(3);
});
it('stops after DONE and reports the change once', async () => {
let answer = json(200, {status: 'MATCHING'});
const fetch = fetchRoutes({[ROUTE]: () => answer});
const onChanged = vi.fn();
const {result} = renderHook(() => useImportPolling(7, onChanged));
await tick(0);
answer = json(200, {status: 'DONE'});
await tick(2000);
const calls = fetch.mock.calls.length;
await tick(30_000);
expect(fetch.mock.calls.length).toBe(calls);
expect(result.current.data.status).toBe('DONE');
expect(onChanged).toHaveBeenCalledTimes(1);
});
it('backs off on 503 and gives up after 10 failures', async () => {
const fetch = fetchRoutes({[ROUTE]: json(503, {})});
const {result} = renderHook(() => useImportPolling(7, noop));
await tick(0);
expect(fetch).toHaveBeenCalledTimes(1);
await tick(3999);
expect(fetch).toHaveBeenCalledTimes(1);
await tick(1);
expect(fetch).toHaveBeenCalledTimes(2);
await tick(7999);
expect(fetch).toHaveBeenCalledTimes(2);
await tick(1);
expect(fetch).toHaveBeenCalledTimes(3);
await tick(300_000);
expect(fetch).toHaveBeenCalledTimes(10);
expect(result.current.failed).toBe(true);
});
it('does not call again after unmount with a timer pending', async () => {
const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING'})});
const {unmount} = renderHook(() => useImportPolling(7, noop));
await tick(0);
unmount();
await tick(10_000);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('does not call again after unmount with a request pending', async () => {
let release;
const fetch = fetchRoutes({[ROUTE]: () => new Promise((resolve) => { release = resolve; })});
const {unmount} = renderHook(() => useImportPolling(7, noop));
await tick(0);
unmount();
release(json(200, {status: 'MATCHING'}));
await tick(10_000);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('reload() restarts the loop: one poll per 2000 ms, not two', async () => {
const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING'})});
const {result} = renderHook(() => useImportPolling(7, noop));
await tick(0);
act(() => result.current.reload());
await tick(0);
const calls = fetch.mock.calls.length;
await tick(2000);
expect(fetch.mock.calls.length).toBe(calls + 1);
});
});
+39
View File
@@ -0,0 +1,39 @@
import {useEffect, useState} from 'react';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {refreshSession} from '../utils/api.js';
import {markSession} from '../utils/auth.js';
/**
* Loads the profile for the settings page. After a Steam link (`steamOutcome === 'linked'`) the session is refreshed
* first. `loadError` is a translation key.
*/
export default function useSettingsLoad(steamOutcome) {
const {reload: reloadUser} = useCurrentUserContext();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
// From this load: the context user can still be empty when the form mounts.
const [displayName, setDisplayName] = useState('');
useEffect(() => {
let cancelled = false;
const load = async () => {
if (steamOutcome === 'linked') {
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
await refreshSession().catch(() => false);
markSession();
}
const {ok, status, data} = await reloadUser();
if (cancelled) return;
if (ok) setDisplayName(data.displayName ?? '');
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
setIsLoading(false);
};
load();
return () => {
cancelled = true;
};
}, [reloadUser, steamOutcome]);
return {isLoading, loadError, displayName};
}
+83
View File
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, renderHook, waitFor} from '@testing-library/react';
import useSettingsLoad from './useSettingsLoad.js';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
const renderLoad = (steamOutcome = null) =>
renderHook(() => useSettingsLoad(steamOutcome), {wrapper: CurrentUserProvider});
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
localStorage.clear();
});
describe('useSettingsLoad', () => {
it('returns the display name when /me answers', async () => {
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}})});
const {result} = renderLoad();
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.displayName).toBe('Lobo');
expect(result.current.loadError).toBeNull();
});
it('reports a server error', async () => {
fetchRoutes({'GET /api/users/me': json(500, {})});
const {result} = renderLoad();
await waitFor(() => expect(result.current.loadError).toBe('INFO.ERROR.LOAD_PROFILE'));
expect(result.current.isLoading).toBe(false);
});
it('reports a network failure', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('network'));
const {result} = renderLoad();
await waitFor(() => expect(result.current.loadError).toBe('INFO.ERROR.NETWORK_FAILURE'));
});
it('shows no error on 401', async () => {
fetchRoutes({'GET /api/users/me': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
const {result} = renderLoad();
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.loadError).toBeNull();
});
it('refreshes the session before /me after a Steam link', async () => {
const fetch = fetchRoutes({
'POST /api/auth/refresh': json(204, null),
'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
});
const {result} = renderLoad('linked');
await waitFor(() => expect(result.current.isLoading).toBe(false));
const paths = fetch.mock.calls.map(([url]) => new URL(url, 'http://x').pathname);
expect(paths.findIndex((p) => p.endsWith('/auth/refresh'))).toBeLessThan(
paths.findIndex((p) => p.endsWith('/users/me')));
});
it('marksSessionAfterSteamRefreshEvenWhenUnmounted', async () => {
let answerRefresh;
fetchRoutes({
'POST /api/auth/refresh': () => new Promise((resolve) => {
answerRefresh = resolve;
}),
'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
});
const {unmount} = renderLoad('linked');
await waitFor(() => expect(answerRefresh).toBeDefined());
unmount();
answerRefresh(json(204, null));
await waitFor(() => expect(localStorage.getItem('tbp_session')).not.toBe('1'));
});
});
+18 -1
View File
@@ -12,6 +12,7 @@ import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {saveGameStatus} from '../utils/backlog.js';
import {formatDate} from '../utils/format.js';
import {formatPlaytime} from '../utils/games.js';
import {request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
@@ -174,6 +175,20 @@ export default function PlayNextPage() {
);
}
const reasonText = (reason, t, language) => {
if (!reason) return null;
if (reason.source === 'SIMILAR') {
return t('PLAY_NEXT.REASON.SIMILAR', {
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
});
}
if (reason.source === 'LONGEST') {
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
}
return t('PLAY_NEXT.REASON.RANDOM');
};
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';
@@ -182,9 +197,10 @@ const emptyKey = (budget, mood) => {
};
function PickCard({game}) {
const {t} = useTranslation();
const {t, i18n} = useTranslation();
const [busy, setBusy] = useState(false);
const [started, setStarted] = useState(false);
const reason = reasonText(game.reason, t, i18n.language);
const startPlaying = async () => {
setBusy(true);
@@ -214,6 +230,7 @@ function PickCard({game}) {
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
<FitText text={game.name ?? `App ${game.appid}`}/>
</Link>
{reason && <p className="text-sm text-muted-foreground">{reason}</p>}
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{game.timeToBeatMinutes > 0 && (
<span className="flex items-center gap-1">
+23
View File
@@ -224,4 +224,27 @@ describe('PlayNextPage', () => {
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
});
it('shows why each game was picked', async () => {
mockBackend([[
game(1, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}}),
game(2, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}}),
game(3, {reason: {source: 'RANDOM'}}),
]]);
renderPage();
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.RANDOM')).toBeTruthy();
});
it('shows no reason line for a game without a reason', async () => {
mockBackend([[game(1)]]);
renderPage();
await screen.findByText('Game 1');
expect(screen.queryByText(/PLAY_NEXT\.REASON/)).toBeNull();
});
});
+3 -23
View File
@@ -13,8 +13,7 @@ import PreferencesCard from '../components/PreferencesCard.jsx';
import ProfileCard from '../components/ProfileCard.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {refreshSession} from '../utils/api.js';
import {markSession} from '../utils/auth.js';
import useSettingsLoad from '../hooks/useSettingsLoad.js';
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
const STEAM_LINK_TOASTS = {
@@ -26,14 +25,11 @@ const STEAM_LINK_TOASTS = {
export default function SettingsPage() {
const {t} = useTranslation();
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
// From this page's own load: the context user can still be empty when the form mounts.
const [displayName, setDisplayName] = useState('');
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam'));
const {isLoading, loadError, displayName} = useSettingsLoad(steamOutcome);
// A language change gives a new `t` and runs the effect again; the toast shows once.
const steamToastShown = useRef(false);
@@ -48,22 +44,6 @@ export default function SettingsPage() {
}, {replace: true});
}, [steamOutcome, setSearchParams, t]);
useEffect(() => {
const load = async () => {
if (steamOutcome === 'linked') {
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
await refreshSession().catch(() => false);
markSession();
}
const {ok, status, data} = await reloadUser();
if (ok) setDisplayName(data.displayName ?? '');
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
setIsLoading(false);
};
load();
}, [reloadUser, steamOutcome]);
if (isLoading) {
return (
<div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">