From 73ad4fd3b87156e13e57d1b5d3b5af0ce7677041 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 11 Oct 2026 17:55:30 +0200 Subject: [PATCH] refactor(hooks): add useSetStatus for game status saves Replaces 5 copies of the save code in hints, PlayNextPage and sync. An empty 2xx body now counts as saved, not a network error. Refs #46 --- src/components/CompletedHint.jsx | 27 +---- src/components/CompletedHint.test.jsx | 1 + src/components/FinishedHint.jsx | 26 ++-- src/components/FinishedHint.test.jsx | 1 + src/components/PlayingHint.jsx | 27 +---- src/components/PlayingHint.test.jsx | 1 + src/components/SyncCompletedButton.jsx | 34 ++---- src/hooks/useSetStatus.js | 48 ++++++++ src/hooks/useSetStatus.test.jsx | 160 +++++++++++++++++++++++++ src/pages/PlayNextPage.jsx | 28 +---- 10 files changed, 239 insertions(+), 114 deletions(-) create mode 100644 src/hooks/useSetStatus.js create mode 100644 src/hooks/useSetStatus.test.jsx diff --git a/src/components/CompletedHint.jsx b/src/components/CompletedHint.jsx index 7b29260..da60a23 100644 --- a/src/components/CompletedHint.jsx +++ b/src/components/CompletedHint.jsx @@ -1,11 +1,9 @@ -import {useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {toast} from 'sonner'; import {Loader2, Trophy} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; -import {saveGameStatus} from '../utils/backlog.js'; +import useSetStatus from '../hooks/useSetStatus.js'; import {allAchievementsUnlocked} from '../utils/confetti.js'; /** @@ -15,36 +13,17 @@ import {allAchievementsUnlocked} from '../utils/confetti.js'; */ export default function CompletedHint({game, onChanged}) { const {t} = useTranslation(); - const [busy, setBusy] = useState(false); + const {setStatus, busy} = useSetStatus(game.appid, {onChanged}); if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null; - const setCompleted = async () => { - setBusy(true); - try { - const response = await saveGameStatus(game.appid, 'COMPLETED'); - if (response.status === 401) return; - if (!response.ok) { - toast.error(t('BACKLOG.SAVE_FAILED')); - return; - } - onChanged(await response.json()); - toast.success(t('BACKLOG.SAVED')); - } catch (err) { - console.error('set completed error:', err); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setBusy(false); - } - }; - return (

{t('GAME_DETAIL.ALL_UNLOCKED')}

- diff --git a/src/components/CompletedHint.test.jsx b/src/components/CompletedHint.test.jsx index 1188558..6e2b177 100644 --- a/src/components/CompletedHint.test.jsx +++ b/src/components/CompletedHint.test.jsx @@ -7,6 +7,7 @@ import CompletedHint from './CompletedHint.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); +vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()})); const game = {appid: 220, status: 'FINISHED', hasAchievements: true, statsVisible: true, achievementsTotal: 10, achievementsUnlocked: 10}; const renderHint = (props) => render( {}} {...props}/>); diff --git a/src/components/FinishedHint.jsx b/src/components/FinishedHint.jsx index c4793d9..08226cc 100644 --- a/src/components/FinishedHint.jsx +++ b/src/components/FinishedHint.jsx @@ -5,7 +5,8 @@ import {Flag, Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; -import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js'; +import useSetStatus from '../hooks/useSetStatus.js'; +import {dismissFinishedHint} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; /** @@ -16,37 +17,32 @@ import {formatPlaytime} from '../utils/games.js'; */ export default function FinishedHint({game, onChanged, onDismissed}) { const {t} = useTranslation(); - const [busy, setBusy] = useState(false); + const {setStatus, busy: saving} = useSetStatus(game.appid, {onChanged}); + const [dismissing, setDismissing] = useState(false); + const busy = saving || dismissing; const hint = game.finishedHint; // A status saved elsewhere on the page answers the question too. if (!hint || (game.status !== 'BACKLOG' && game.status !== 'PLAYING')) return null; - const run = async (request, onOk) => { - setBusy(true); + const notYet = async () => { + setDismissing(true); try { - const response = await request(); + const response = await dismissFinishedHint(game.appid, hint.type); if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; } - await onOk(response); + onDismissed(); } catch (err) { console.error('finished hint error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); } finally { - setBusy(false); + setDismissing(false); } }; - const setFinished = () => run(() => saveGameStatus(game.appid, 'FINISHED'), async (response) => { - onChanged(await response.json()); - toast.success(t('BACKLOG.SAVED')); - }); - - const notYet = () => run(() => dismissFinishedHint(game.appid, hint.type), () => onDismissed()); - const text = hint.type === 'ENDING' ? t('GAME_DETAIL.FINISHED_ENDING', {achievement: hint.achievement}) : t('GAME_DETAIL.FINISHED_PLAYTIME', { @@ -62,7 +58,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
- diff --git a/src/components/FinishedHint.test.jsx b/src/components/FinishedHint.test.jsx index e8d4c68..f6feb46 100644 --- a/src/components/FinishedHint.test.jsx +++ b/src/components/FinishedHint.test.jsx @@ -8,6 +8,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); +vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()})); const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBeat: {normallyMinutes: 500}, finishedHint: {type: 'ENDING', achievement: 'Survivor'}}; diff --git a/src/components/PlayingHint.jsx b/src/components/PlayingHint.jsx index dc0c280..c151f97 100644 --- a/src/components/PlayingHint.jsx +++ b/src/components/PlayingHint.jsx @@ -1,11 +1,9 @@ -import {useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {toast} from 'sonner'; import {Gamepad2, Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; -import {saveGameStatus} from '../utils/backlog.js'; +import useSetStatus from '../hooks/useSetStatus.js'; import {formatPlaytime} from '../utils/games.js'; /** @@ -15,29 +13,10 @@ import {formatPlaytime} from '../utils/games.js'; */ export default function PlayingHint({game, onChanged}) { const {t} = useTranslation(); - const [busy, setBusy] = useState(false); + const {setStatus, busy} = useSetStatus(game.appid, {onChanged}); if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null; - const setPlaying = async () => { - setBusy(true); - try { - const response = await saveGameStatus(game.appid, 'PLAYING'); - if (response.status === 401) return; - if (!response.ok) { - toast.error(t('BACKLOG.SAVE_FAILED')); - return; - } - onChanged(await response.json()); - toast.success(t('BACKLOG.SAVED')); - } catch (err) { - console.error('set playing error:', err); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setBusy(false); - } - }; - return (
@@ -46,7 +25,7 @@ export default function PlayingHint({game, onChanged}) { {t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}

- diff --git a/src/components/PlayingHint.test.jsx b/src/components/PlayingHint.test.jsx index bc9bdcb..e1534b6 100644 --- a/src/components/PlayingHint.test.jsx +++ b/src/components/PlayingHint.test.jsx @@ -7,6 +7,7 @@ import PlayingHint from './PlayingHint.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); +vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()})); const game = {appid: 220, status: 'BACKLOG', rating: 3, notes: 'keep', priority: 'HIGH', playtime2Weeks: 90}; const renderHint = (props) => render( {}} {...props}/>); diff --git a/src/components/SyncCompletedButton.jsx b/src/components/SyncCompletedButton.jsx index 4e10f9a..45ff39d 100644 --- a/src/components/SyncCompletedButton.jsx +++ b/src/components/SyncCompletedButton.jsx @@ -1,11 +1,9 @@ import {useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {toast} from 'sonner'; import {Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; -import usePointsCheck from '../hooks/usePointsCheck.js'; -import {saveGameStatus} from '../utils/backlog.js'; +import useSetStatus from '../hooks/useSetStatus.js'; /** * Sets a game to COMPLETED from the sync details, the same call as {@link CompletedHint}. @@ -14,34 +12,18 @@ import {saveGameStatus} from '../utils/backlog.js'; */ export default function SyncCompletedButton({appid, onChanged}) { const {t} = useTranslation(); - const check = usePointsCheck(); - const [busy, setBusy] = useState(false); const [done, setDone] = useState(false); - - const setCompleted = async () => { - setBusy(true); - try { - const response = await saveGameStatus(appid, 'COMPLETED'); - if (response.status === 401) return; - if (!response.ok) { - toast.error(t('BACKLOG.SAVE_FAILED')); - return; - } + const {setStatus, busy} = useSetStatus(appid, { + checkPoints: true, + onChanged: (entry) => { setDone(true); - toast.success(t('BACKLOG.SAVED')); - check(appid, {status: 'COMPLETED', tell: true}); - onChanged?.(); - } catch (err) { - console.error('set completed error:', err); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setBusy(false); - } - }; + onChanged?.(entry); + }, + }); if (done) return {t('SYNC_CHANGES.COMPLETED_DONE')}; return ( - diff --git a/src/hooks/useSetStatus.js b/src/hooks/useSetStatus.js new file mode 100644 index 0000000..16d0784 --- /dev/null +++ b/src/hooks/useSetStatus.js @@ -0,0 +1,48 @@ +import {useRef, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import {saveGameStatus} from '../utils/backlog.js'; +import usePointsCheck from './usePointsCheck.js'; + +/** + * Returns `{setStatus, busy}`. `setStatus(status)` saves the status (PUT .../status), shows the saved or error toast, + * with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the saved backlog + * entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs does nothing. + * Resolves to true when saved. + * + * GameDetail's GamePointsLine already tells the points; do not set `checkPoints` there. + */ +export default function useSetStatus(appid, {onChanged, checkPoints = false} = {}) { + const {t} = useTranslation(); + const check = usePointsCheck(); + const [busy, setBusy] = useState(false); + const running = useRef(false); + + const setStatus = async (status) => { + if (running.current) return false; + running.current = true; + setBusy(true); + try { + const response = await saveGameStatus(appid, status); + if (response.status === 401) return false; + if (!response.ok) { + toast.error(t('BACKLOG.SAVE_FAILED')); + return false; + } + const entry = await response.json().catch(() => null); + toast.success(t('BACKLOG.SAVED')); + if (checkPoints) check(appid, {status, tell: true}); + onChanged?.(entry); + return true; + } catch (err) { + console.error('set status error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + return false; + } finally { + running.current = false; + setBusy(false); + } + }; + + return {setStatus, busy}; +} diff --git a/src/hooks/useSetStatus.test.jsx b/src/hooks/useSetStatus.test.jsx new file mode 100644 index 0000000..599dca4 --- /dev/null +++ b/src/hooks/useSetStatus.test.jsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, renderHook} from '@testing-library/react'; +import {toast} from 'sonner'; +import useSetStatus from './useSetStatus.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); +const pointsCheck = vi.fn(() => Promise.resolve(null)); +vi.mock('./usePointsCheck.js', () => ({default: () => pointsCheck})); + +const PUT = 'PUT /api/library/games/220/status'; + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + localStorage.clear(); +}); + +const render = (options) => renderHook(() => useSetStatus(220, options)); + +describe('useSetStatus', () => { + it('savesTheStatusAndShowsTheSavedToast', async () => { + const fetchMock = fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING'})}); + const {result} = render(); + + let saved; + await act(async () => { + saved = await result.current.setStatus('PLAYING'); + }); + + expect(saved).toBe(true); + expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED'); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body)).toEqual({status: 'PLAYING'}); + }); + + it('passesTheSavedEntryToOnChanged', async () => { + fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING', appid: 220})}); + const onChanged = vi.fn(); + const {result} = render({onChanged}); + + await act(() => result.current.setStatus('PLAYING')); + + expect(onChanged).toHaveBeenCalledTimes(1); + expect(onChanged).toHaveBeenCalledWith({status: 'PLAYING', appid: 220}); + }); + + it('countsAnEmptyBodyAsSavedAndPassesNull', async () => { + fetchRoutes({[PUT]: () => new Response(null, {status: 200})}); + const onChanged = vi.fn(); + const {result} = render({onChanged}); + + let saved; + await act(async () => { + saved = await result.current.setStatus('PLAYING'); + }); + + expect(saved).toBe(true); + expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED'); + expect(toast.error).not.toHaveBeenCalled(); + expect(onChanged).toHaveBeenCalledWith(null); + }); + + it('showsSaveFailedOnAServerError', async () => { + fetchRoutes({[PUT]: () => json(500, {})}); + const onChanged = vi.fn(); + const {result} = render({onChanged}); + + await act(() => result.current.setStatus('PLAYING')); + + expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'); + expect(onChanged).not.toHaveBeenCalled(); + expect(result.current.busy).toBe(false); + }); + + it('showsTheNetworkErrorWhenFetchRejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + fetchRoutes({[PUT]: () => { + throw new Error('offline'); + }}); + const {result} = render(); + + await act(() => result.current.setStatus('PLAYING')); + + expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE'); + }); + + it('staysSilentOn401', async () => { + fetchRoutes({[PUT]: () => json(401, {}), 'POST /api/auth/refresh': () => json(401, {})}); + const onChanged = vi.fn(); + const {result} = render({onChanged}); + + let saved; + await act(async () => { + saved = await result.current.setStatus('PLAYING'); + }); + + expect(saved).toBe(false); + expect(toast.error).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('sendsOneRequestWhenCalledTwice', async () => { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const fetchMock = fetchRoutes({[PUT]: () => held}); + const {result} = render(); + + await act(async () => { + const first = result.current.setStatus('PLAYING'); + const second = await result.current.setStatus('PLAYING'); + expect(second).toBe(false); + release(json(200, {})); + await first; + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('checksPointsOnlyWhenAsked', async () => { + fetchRoutes({[PUT]: () => json(200, {})}); + const plain = render(); + await act(() => plain.result.current.setStatus('PLAYING')); + expect(pointsCheck).not.toHaveBeenCalled(); + + const asking = render({checkPoints: true}); + await act(() => asking.result.current.setStatus('PLAYING')); + expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'PLAYING', tell: true}); + }); + + it('isBusyWhileSaving', async () => { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + fetchRoutes({[PUT]: () => held}); + const {result} = render(); + + let pending; + await act(async () => { + pending = result.current.setStatus('PLAYING'); + }); + expect(result.current.busy).toBe(true); + await act(async () => { + release(json(200, {})); + await pending; + }); + + expect(result.current.busy).toBe(false); + }); +}); diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 823ac5a..cdc10c0 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -11,8 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; -import usePointsCheck from '../hooks/usePointsCheck.js'; -import {saveGameStatus} from '../utils/backlog.js'; +import useSetStatus from '../hooks/useSetStatus.js'; import {formatDate} from '../utils/format.js'; import {formatPlaytime} from '../utils/games.js'; import {request} from '../utils/api.js'; @@ -280,31 +279,10 @@ const emptyKey = (budget, mood) => { function PickCard({game}) { const {t, i18n} = useTranslation(); - const [busy, setBusy] = useState(false); const [started, setStarted] = useState(false); - const check = usePointsCheck(); + const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: () => setStarted(true)}); const reason = reasonText(game.reason, t, i18n.language); - const startPlaying = async () => { - setBusy(true); - try { - const response = await saveGameStatus(game.appid, 'PLAYING'); - if (response.status === 401) return; - if (!response.ok) { - toast.error(t('BACKLOG.SAVE_FAILED')); - return; - } - setStarted(true); - toast.success(t('BACKLOG.SAVED')); - check(game.appid, {status: 'PLAYING', tell: true}); - } catch (err) { - console.error('Start playing error:', err); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setBusy(false); - } - }; - return ( @@ -333,7 +311,7 @@ function PickCard({game}) { {t('PLAY_NEXT.STARTED')}

) : ( -