From 9ddc50f81992c43e69064fc55f9f6123157d2c45 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 07:51:09 +0200 Subject: [PATCH] fix: abort old loads, stale sync state, warn before unsaved edits are lost --- public/i18n/de.json | 4 + public/i18n/en.json | 4 + src/App.jsx | 2 + src/components/BacklogCard.jsx | 2 + src/components/ManualGameCard.jsx | 4 + src/components/ManualGameCard.test.jsx | 93 +++++++++++++++++++ src/components/SyncStatusBadge.jsx | 13 ++- src/hooks/useAbortPrevious.js | 17 ++++ src/hooks/useCurrentUser.test.jsx | 54 +++++++++++ src/hooks/useForceSync.js | 8 +- src/hooks/useForceSync.test.jsx | 66 ++++++++++++++ src/hooks/useLibrarySync.js | 62 ++++++++----- src/hooks/useLibrarySync.test.jsx | 91 +++++++++++++++++++ src/hooks/useUnsavedChanges.js | 58 ++++++++++++ src/hooks/useUnsavedChanges.test.jsx | 118 +++++++++++++++++++++++++ src/pages/AdminUsersPage.jsx | 16 ++-- src/pages/Dashboard.jsx | 3 +- src/pages/GameDetail.jsx | 46 +++------- src/pages/GameDetail.test.jsx | 18 ++++ src/pages/GameLibrary.jsx | 41 ++++----- src/pages/GameLibrary.test.jsx | 51 +++++++++++ src/pages/ImportPage.jsx | 5 +- src/pages/ImportPage.test.jsx | 17 ++++ src/pages/PlayNextPage.jsx | 40 ++++----- src/pages/SettingsPage.test.jsx | 26 ++++++ src/utils/admin.js | 4 +- src/utils/api.js | 1 + src/utils/api.test.js | 10 +++ 28 files changed, 753 insertions(+), 121 deletions(-) create mode 100644 src/components/ManualGameCard.test.jsx create mode 100644 src/hooks/useAbortPrevious.js create mode 100644 src/hooks/useCurrentUser.test.jsx create mode 100644 src/hooks/useForceSync.test.jsx create mode 100644 src/hooks/useLibrarySync.test.jsx create mode 100644 src/hooks/useUnsavedChanges.js create mode 100644 src/hooks/useUnsavedChanges.test.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index ff1bb86..ed693c6 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -205,6 +205,7 @@ "SYNCING": "Synchronisiere mit Steam …", "SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.", "SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.", + "SYNC_STALE": "Die Synchronisation dauert länger als sonst. Bitte lade die Seite später neu.", "LAST_SYNCED": "Zuletzt synchronisiert {{when}}", "FILTER_ALL": "Alle", "SEARCH_PLACEHOLDER": "Spiel suchen …", @@ -705,5 +706,8 @@ "RETRY": "Nochmal versuchen", "RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.", "ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter." + }, + "UNSAVED_CHANGES": { + "CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 527adff..a2145c0 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -205,6 +205,7 @@ "SYNCING": "Syncing with Steam …", "SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.", "SYNC_FAILED": "Sync failed. Please try again later.", + "SYNC_STALE": "The sync takes longer than usual. Reload the page later.", "LAST_SYNCED": "Last synced {{when}}", "FILTER_ALL": "All", "SEARCH_PLACEHOLDER": "Search games …", @@ -705,5 +706,8 @@ "RETRY": "Try again", "RETRY_FAILED": "Could not start the import again.", "ATTEMPT": "Try {{attempt}}: the last try made no progress." + }, + "UNSAVED_CHANGES": { + "CONFIRM": "You have changes that are not saved. Leave this page and lose them?" } } diff --git a/src/App.jsx b/src/App.jsx index bb13544..f99c11c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -25,6 +25,7 @@ import PageBackground from './components/PageBackground.jsx'; import {usePreferences} from './context/PreferencesContext.jsx'; import {clearSession} from './utils/auth.js'; import useSession from './hooks/useSession.js'; +import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js'; import {logout} from './utils/api.js'; function App() { @@ -84,6 +85,7 @@ function App() { + ); diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx index 08abfc7..8e5f532 100644 --- a/src/components/BacklogCard.jsx +++ b/src/components/BacklogCard.jsx @@ -12,6 +12,7 @@ import {cn} from '@/lib/utils'; import {STATUS_META} from './GameStatusBadge.jsx'; import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js'; import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js'; +import useUnsavedChanges from '../hooks/useUnsavedChanges.js'; const toForm = (entry) => ({ status: entry.status ?? 'BACKLOG', @@ -33,6 +34,7 @@ export default function BacklogCard({game, onSaved}) { const [busy, setBusy] = useState(false); const dirty = JSON.stringify(form) !== JSON.stringify(saved); + useUnsavedChanges(dirty); const set = (field) => (value) => setForm((f) => ({...f, [field]: value})); const save = async () => { diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx index f3e59b6..d037d80 100644 --- a/src/components/ManualGameCard.jsx +++ b/src/components/ManualGameCard.jsx @@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js'; import {MAX_PLATFORM_LENGTH} from '../utils/limits.js'; +import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js'; /** * Platform and hours of a game added by hand, and removing it. Steam games get both from the sync. @@ -26,6 +27,7 @@ export default function ManualGameCard({game, onSaved}) { const playtimeMinutes = hoursToMinutes(hours); const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes; + useUnsavedChanges(dirty); const run = async (request, onOk) => { setBusy(true); @@ -52,6 +54,8 @@ export default function ManualGameCard({game, onSaved}) { const remove = () => run(() => removeGame(game.appid), () => { toast.success(t('MANUAL_GAME.REMOVED')); + // The game is gone, so are its unsaved edits on this page. + discardChanges(); navigate('/library', {replace: true}); }); diff --git a/src/components/ManualGameCard.test.jsx b/src/components/ManualGameCard.test.jsx new file mode 100644 index 0000000..1d0fcca --- /dev/null +++ b/src/components/ManualGameCard.test.jsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; +import {toast} from 'sonner'; +import ManualGameCard from './ManualGameCard.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const game = {appid: -1025, platform: 'PS5', playtimeMinutes: 90}; + +const answer = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes}); + +const renderCard = (onSaved = vi.fn()) => renderWithProviders(, + {path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library':

library page

}}); + +const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'}); + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('ManualGameCard', () => { + it('saves the trimmed platform and the minutes, then reports them', async () => { + answer({'PUT /api/library/games/-1025/manual': json(200, {})}); + const onSaved = vi.fn(); + renderCard(onSaved); + + fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' Switch '}}); + fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2'}}); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSaved).toHaveBeenCalledWith({platform: 'Switch', playtimeMinutes: 120})); + const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT'); + expect(url).toContain('/api/library/games/-1025/manual'); + expect(JSON.parse(init.body)).toEqual({platform: 'Switch', playtimeMinutes: 120}); + }); + + it('keeps the save button disabled while nothing changed', () => { + answer({}); + renderCard(); + + expect(saveButton().disabled).toBe(true); + fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'PS5 '}}); + expect(saveButton().disabled).toBe(true); + }); + + it('removes the game only after a confirm and opens the library', async () => { + answer({'DELETE /api/library/games/-1025': json(204, null)}); + const {router} = renderCard(); + + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'})); + expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false); + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'})); + + await waitFor(() => expect(router.state.location.pathname).toBe('/library')); + }); + + it('shows an error when the save fails', async () => { + answer({'PUT /api/library/games/-1025/manual': json(500, {})}); + const onSaved = vi.fn(); + renderCard(onSaved); + + fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}}); + fireEvent.click(saveButton()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED')); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it('shows no toast on a 401', async () => { + answer({'PUT /api/library/games/-1025/manual': json(401, {}), 'POST /api/auth/refresh': json(401, {})}); + const onSaved = vi.fn(); + renderCard(onSaved); + + fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}}); + fireEvent.click(saveButton()); + + await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true)); + await waitFor(() => expect(saveButton().disabled).toBe(false)); + expect(toast.error).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/SyncStatusBadge.jsx b/src/components/SyncStatusBadge.jsx index 793cca5..f1ae09b 100644 --- a/src/components/SyncStatusBadge.jsx +++ b/src/components/SyncStatusBadge.jsx @@ -1,5 +1,5 @@ import {useTranslation} from 'react-i18next'; -import {CircleAlert, CircleCheck, RefreshCw} from 'lucide-react'; +import {CircleAlert, CircleCheck, Clock, RefreshCw} from 'lucide-react'; const formatRelative = (iso, locale) => { if (!iso) return null; @@ -12,7 +12,7 @@ const formatRelative = (iso, locale) => { return rtf.format(-Math.round(diffSec / 86400), 'day'); }; -export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) { +export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) { const {t, i18n} = useTranslation(); if (isRunning) { @@ -24,6 +24,15 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) { ); } + if (stale) { + return ( +
+ + {t('LIBRARY.SYNC_STALE')} +
+ ); + } + if (status === 'FAILED') { return (
diff --git a/src/hooks/useAbortPrevious.js b/src/hooks/useAbortPrevious.js new file mode 100644 index 0000000..9020eb6 --- /dev/null +++ b/src/hooks/useAbortPrevious.js @@ -0,0 +1,17 @@ +import {useCallback, useEffect, useRef} from 'react'; + +/** + * Returns `nextSignal()` for a load that can run again before the last one answered: it aborts the last request and + * gives the signal for the next one. Unmounting aborts too. After the await, a load stops when its signal is aborted. + */ +export default function useAbortPrevious() { + const controller = useRef(null); + + useEffect(() => () => controller.current?.abort(), []); + + return useCallback(() => { + controller.current?.abort(); + controller.current = new AbortController(); + return controller.current.signal; + }, []); +} diff --git a/src/hooks/useCurrentUser.test.jsx b/src/hooks/useCurrentUser.test.jsx new file mode 100644 index 0000000..c640294 --- /dev/null +++ b/src/hooks/useCurrentUser.test.jsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it} from 'vitest'; +import {cleanup, renderHook, waitFor} from '@testing-library/react'; +import useCurrentUser from './useCurrentUser.js'; +import {CurrentUserProvider, useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +const renderUser = () => renderHook(() => useCurrentUser(true), {wrapper: CurrentUserProvider}); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe('useCurrentUser', () => { + it('maps the user from /me and normalizes the profile style', async () => { + localStorage.setItem('tbp_session', '1'); + fetchRoutes({'GET /api/users/me': json(200, {role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo', + settings: {profileStyle: {accent: 'gold', corners: 'round', bannerAppid: 220, backgroundAppid: -5}}})}); + const {result} = renderUser(); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current).toEqual({role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo', + profileStyle: {accent: 'violet', corners: 'round', bannerAppid: 220, backgroundAppid: null}}); + }); + + it('uses the role as active role when there is no "view as"', async () => { + localStorage.setItem('tbp_session', '1'); + fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', displayName: 'Lobo', settings: {}})}); + const {result} = renderUser(); + + await waitFor(() => expect(result.current?.activeRole).toBe('USER')); + expect(result.current.profileStyle).toEqual({accent: 'violet', corners: 'default', bannerAppid: null, + backgroundAppid: null}); + }); + + it('returns null when not enabled', async () => { + localStorage.setItem('tbp_session', '1'); + fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', settings: {}})}); + const {result} = renderHook(() => ({mapped: useCurrentUser(false), user: useCurrentUserContext().user}), + {wrapper: CurrentUserProvider}); + + await waitFor(() => expect(result.current.user?.role).toBe('USER')); + expect(result.current.mapped).toBeNull(); + }); + + it('returns null without a session and asks for no user', () => { + const fetch = fetchRoutes({}); + const {result} = renderUser(); + + expect(result.current).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useForceSync.js b/src/hooks/useForceSync.js index 28986d6..09645d3 100644 --- a/src/hooks/useForceSync.js +++ b/src/hooks/useForceSync.js @@ -3,11 +3,9 @@ import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {apiFetch} from '../utils/api.js'; -const RELOAD_DELAY_MS = 4000; - /** - * Starts a library sync (POST /api/library/sync), reports the result as a toast and calls - * onStarted after a short delay, so the caller can reload once Steam data has arrived. + * Starts a library sync (POST /api/library/sync), reports the result as a toast and calls onStarted, e.g. the + * `watch` of useLibrarySync, which reloads the data when the run is over. */ export default function useForceSync(onStarted) { const {t} = useTranslation(); @@ -30,7 +28,7 @@ export default function useForceSync(onStarted) { return; } toast.success(t('LIBRARY.SYNC_STARTED')); - setTimeout(onStarted, RELOAD_DELAY_MS); + onStarted(); } catch (err) { console.error('sync trigger error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); diff --git a/src/hooks/useForceSync.test.jsx b/src/hooks/useForceSync.test.jsx new file mode 100644 index 0000000..6c6d7e1 --- /dev/null +++ b/src/hooks/useForceSync.test.jsx @@ -0,0 +1,66 @@ +// @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 useForceSync from './useForceSync.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), warning: vi.fn(), error: vi.fn()}})); + +const SYNC = 'POST /api/library/sync'; + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); + localStorage.clear(); +}); + +const trigger = async (onStarted) => { + const {result} = renderHook(() => useForceSync(onStarted)); + await act(() => result.current.trigger()); + return result; +}; + +describe('useForceSync', () => { + it('calls onStarted at once after the start, and not again later', async () => { + fetchRoutes({[SYNC]: json(202, {})}); + const onStarted = vi.fn(); + + await trigger(onStarted); + expect(onStarted).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + + expect(onStarted).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith('LIBRARY.SYNC_STARTED'); + }); + + it('says how long to wait on a 429', async () => { + fetchRoutes({[SYNC]: json(429, {retryAfterSeconds: 42})}); + const onStarted = vi.fn(); + + await trigger(onStarted); + + expect(toast.warning).toHaveBeenCalledWith('LIBRARY.SYNC_COOLDOWN'); + expect(onStarted).not.toHaveBeenCalled(); + }); + + it('says so when the start fails', async () => { + fetchRoutes({[SYNC]: json(500, {})}); + await trigger(vi.fn()); + expect(toast.error).toHaveBeenCalledWith('LIBRARY.SYNC_FAILED'); + }); + + it('says so when the network is down', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + fetchRoutes({[SYNC]: () => Promise.reject(new TypeError('Failed to fetch'))}); + await trigger(vi.fn()); + expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE'); + }); +}); diff --git a/src/hooks/useLibrarySync.js b/src/hooks/useLibrarySync.js index db618bf..0570967 100644 --- a/src/hooks/useLibrarySync.js +++ b/src/hooks/useLibrarySync.js @@ -1,4 +1,4 @@ -import {useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {apiFetch} from '../utils/api.js'; import {hasSession} from '../utils/auth.js'; @@ -7,28 +7,42 @@ const MAX_POLL_DURATION_MS = 120_000; const INITIAL_SYNC_GRACE_MS = 15_000; /** - * Polls /api/library/sync-status while a sync is running (or expected), - * and fires onComplete once when the run transitions to OK / FAILED. + * Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends. + * A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the + * user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running. * * @param {Object} opts * @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId) * @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the * gap between async sync dispatch and first DB write) - * @param {() => void} opts.onComplete - called once per transition to OK/FAILED - * @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}} + * @param {() => void} opts.onComplete - called once per run that ends + * @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null, + * watch: () => void}} */ export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) { - const [state, setState] = useState({status: null, lastSyncedAt: null, error: null}); - const previousRunningRef = useRef(null); + const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false}); + // count starts polling again; since is the last known end, so a new one shows that the started run is over + const [watched, setWatched] = useState({count: 0, since: undefined}); + const lastSyncedAtRef = useRef(undefined); + // The count of the last watch whose run ended; a later restart of the polling must not report that run again. + const endedWatch = useRef(0); const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; + const watch = useCallback(() => { + setWatched((current) => ({count: current.count + 1, since: lastSyncedAtRef.current})); + }, []); + useEffect(() => { if (!enabled) return undefined; let cancelled = false; let timer = null; + let wasRunning = false; const startedAt = Date.now(); + const watching = watched.count > endedWatch.current; + // Before the first answer the last end is unknown; then the first answer is the one to compare with. + let since = watched.since; const fetchOnce = async () => { if (!hasSession()) return; @@ -51,22 +65,24 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo const status = json.lastSyncStatus; const lastSyncedAt = json.lastSyncedAt; - setState({status, lastSyncedAt, error: null}); - + lastSyncedAtRef.current = lastSyncedAt; const isRunning = status === 'RUNNING'; - const wasRunning = previousRunningRef.current; - - if (wasRunning === true && !isRunning) { - onCompleteRef.current?.(); - } - previousRunningRef.current = isRunning; - const elapsed = Date.now() - startedAt; - const waitingForInitial = - expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS; - const stillWaiting = isRunning || waitingForInitial; + const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS; + setState({status, lastSyncedAt, error: null, stale}); - if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) { + if (since === undefined) since = lastSyncedAt; + const ended = !isRunning && (wasRunning || (watching && lastSyncedAt !== since)); + if (ended) { + endedWatch.current = watched.count; + onCompleteRef.current?.(); + return; + } + wasRunning = isRunning; + + const waitingForInitial = expectInitialSync && status == null; + const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS; + if ((isRunning && !stale) || waitingForStart) { timer = setTimeout(fetchOnce, POLL_INTERVAL_MS); } } catch (err) { @@ -82,12 +98,14 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo cancelled = true; if (timer) clearTimeout(timer); }; - }, [enabled, expectInitialSync]); + }, [enabled, expectInitialSync, watched]); return { status: state.status, lastSyncedAt: state.lastSyncedAt, - isRunning: state.status === 'RUNNING', + isRunning: state.status === 'RUNNING' && !state.stale, + stale: state.stale, error: state.error, + watch, }; } diff --git a/src/hooks/useLibrarySync.test.jsx b/src/hooks/useLibrarySync.test.jsx new file mode 100644 index 0000000..4b8c2fe --- /dev/null +++ b/src/hooks/useLibrarySync.test.jsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, renderHook} from '@testing-library/react'; +import useLibrarySync from './useLibrarySync.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +const status = (lastSyncStatus, lastSyncedAt) => json(200, {lastSyncStatus, lastSyncedAt}); + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + localStorage.clear(); +}); + +describe('useLibrarySync', () => { + it('shows a sync that runs longer than the poll limit as stale, not as running forever', async () => { + const fetch = fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)}); + const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}})); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(result.current.isRunning).toBe(true); + + await act(() => vi.advanceTimersByTimeAsync(125_000)); + + expect(result.current.isRunning).toBe(false); + expect(result.current.stale).toBe(true); + const calls = fetch.mock.calls.length; + await act(() => vi.advanceTimersByTimeAsync(30_000)); + expect(fetch.mock.calls.length).toBe(calls); + }); + + it('after watch() reports the end of a sync once, also when it never saw it running', async () => { + let answer = status('OK', '2026-09-30T08:00:00Z'); + fetchRoutes({'GET /api/library/sync-status': () => answer}); + const onComplete = vi.fn(); + const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete})); + await act(() => vi.advanceTimersByTimeAsync(0)); + + act(() => result.current.watch()); + await act(() => vi.advanceTimersByTimeAsync(0)); + // The sync was so fast that the next poll already sees it done. + answer = status('OK', '2026-09-30T08:05:00Z'); + await act(() => vi.advanceTimersByTimeAsync(3_000)); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('reports the end of a running sync once', async () => { + let answer = status('RUNNING', '2026-09-30T08:00:00Z'); + fetchRoutes({'GET /api/library/sync-status': () => answer}); + const onComplete = vi.fn(); + const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete})); + await act(() => vi.advanceTimersByTimeAsync(0)); + + answer = status('FAILED', '2026-09-30T08:01:00Z'); + await act(() => vi.advanceTimersByTimeAsync(3_000)); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('FAILED'); + }); + + it('fires onComplete only once when expectInitialSync flips after the watched run ended', async () => { + let answer = status('OK', '2026-09-30T08:00:00Z'); + fetchRoutes({'GET /api/library/sync-status': () => answer}); + const onComplete = vi.fn(); + const {result, rerender} = renderHook( + ({expectInitialSync}) => useLibrarySync({enabled: true, expectInitialSync, onComplete}), + {initialProps: {expectInitialSync: false}}); + await act(() => vi.advanceTimersByTimeAsync(0)); + + act(() => result.current.watch()); + await act(() => vi.advanceTimersByTimeAsync(0)); + answer = status('OK', '2026-09-30T08:05:00Z'); + await act(() => vi.advanceTimersByTimeAsync(3_000)); + expect(onComplete).toHaveBeenCalledTimes(1); + + // onComplete loads the list: isLoading goes true and back to false, and with 0 games expectInitialSync follows. + rerender({expectInitialSync: true}); + await act(() => vi.advanceTimersByTimeAsync(0)); + rerender({expectInitialSync: false}); + await act(() => vi.advanceTimersByTimeAsync(0)); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useUnsavedChanges.js b/src/hooks/useUnsavedChanges.js new file mode 100644 index 0000000..f7282c0 --- /dev/null +++ b/src/hooks/useUnsavedChanges.js @@ -0,0 +1,58 @@ +import {useEffect} from 'react'; +import {useBlocker} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {hasSession} from '../utils/auth.js'; + +// One entry per component with unsaved edits. A router allows only one blocker, and GameDetail shows two such cards. +const owners = new Set(); +let discardNext = false; + +/** + * The next navigation drops the unsaved edits without asking, e.g. after the game itself was removed. Only that one: + * a flag in the location state would also skip the question on a later "back" to that entry. + */ +export const discardChanges = () => { + discardNext = true; +}; + +/** + * Marks the calling component as having unsaved edits while `dirty`. UnsavedChangesGuard then asks before a + * navigation drops them. + */ +export default function useUnsavedChanges(dirty) { + useEffect(() => { + if (!dirty) return undefined; + const owner = {}; + owners.add(owner); + return () => owners.delete(owner); + }, [dirty]); +} + +/** + * Rendered once, inside a data router. Asks with the browser's own confirm; a reload or a closed tab gets the + * browser's own warning. Without a session nothing is held back, so the login page always opens. + */ +export function UnsavedChangesGuard() { + const {t} = useTranslation(); + const blocker = useBlocker(() => { + const discard = discardNext; + discardNext = false; + return owners.size > 0 && hasSession() && !discard; + }); + + useEffect(() => { + if (blocker.state !== 'blocked') return; + if (window.confirm(t('UNSAVED_CHANGES.CONFIRM'))) blocker.proceed(); + else blocker.reset(); + }, [blocker, t]); + + useEffect(() => { + const warn = (event) => { + if (owners.size > 0) event.preventDefault(); + }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, []); + + return null; +} diff --git a/src/hooks/useUnsavedChanges.test.jsx b/src/hooks/useUnsavedChanges.test.jsx new file mode 100644 index 0000000..7fa8e27 --- /dev/null +++ b/src/hooks/useUnsavedChanges.test.jsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {createMemoryRouter, Link, Outlet, RouterProvider, useNavigate} from 'react-router-dom'; +import useUnsavedChanges, {discardChanges, UnsavedChangesGuard} from './useUnsavedChanges.js'; +import {expireSession, markSession} from '../utils/auth.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +function Editor({dirty = true}) { + useUnsavedChanges(dirty); + const navigate = useNavigate(); + return ( + <> +

editor

+ leave + + + ); +} + +function renderEditor(dirty) { + const router = createMemoryRouter([{ + element: <>, + children: [ + {path: '/edit', element: }, + {path: '/other', element:

other page

}, + {path: '/login', element:

login

}, + ], + }], {initialEntries: ['/edit']}); + render(); + return router; +} + +beforeEach(() => { + markSession(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe('useUnsavedChanges', () => { + it('asks before a link leaves unsaved edits, and stays when the user says no', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + renderEditor(true); + + fireEvent.click(screen.getByText('leave')); + + expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM'); + expect(await screen.findByText('editor')).toBeTruthy(); + }); + + it('leaves when the user says yes', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderEditor(true); + + fireEvent.click(screen.getByText('leave')); + + expect(await screen.findByText('other page')).toBeTruthy(); + }); + + it('does not ask without unsaved edits, or when the page drops them on purpose', async () => { + const confirm = vi.spyOn(window, 'confirm'); + renderEditor(false); + fireEvent.click(screen.getByText('leave')); + expect(await screen.findByText('other page')).toBeTruthy(); + cleanup(); + + renderEditor(true); + fireEvent.click(screen.getByText('remove')); + expect(await screen.findByText('other page')).toBeTruthy(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('never holds back the login page when the session ended', async () => { + const confirm = vi.spyOn(window, 'confirm'); + const router = renderEditor(true); + + act(() => expireSession()); + await act(() => router.navigate('/login')); + + expect(screen.getByText('login')).toBeTruthy(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('lets the browser warn before a reload or close only while there are unsaved edits', () => { + renderEditor(true); + const warned = new Event('beforeunload', {cancelable: true}); + window.dispatchEvent(warned); + expect(warned.defaultPrevented).toBe(true); + cleanup(); + + renderEditor(false); + const free = new Event('beforeunload', {cancelable: true}); + window.dispatchEvent(free); + expect(free.defaultPrevented).toBe(false); + }); + + it('asks before going back to an entry that once dropped changes on purpose', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + const router = renderEditor(true); + + fireEvent.click(screen.getByText('remove')); + await screen.findByText('other page'); + await act(() => router.navigate('/edit')); + await screen.findByText('editor'); + await act(() => router.navigate(-1)); + + expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM'); + expect(router.state.location.pathname).toBe('/edit'); + }); +}); diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index 6f351cd..e24e5f2 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react'; import {toast} from 'sonner'; @@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import PageContainer from '../components/PageContainer.jsx'; import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js'; +import useAbortPrevious from '../hooks/useAbortPrevious.js'; const PAGE_SIZE = 20; const REFUSAL_KEY = { @@ -44,13 +45,13 @@ export default function AdminUsersPage() { const [typedEmail, setTypedEmail] = useState(''); const [busy, setBusy] = useState(false); // Only the newest load may show its answer: a slow answer to an older search must not replace the list. - const loadCount = useRef(0); + const nextSignal = useAbortPrevious(); const load = useCallback(async () => { - const current = ++loadCount.current; + const signal = nextSignal(); try { - const response = await fetchUsers(search, page, PAGE_SIZE); - if (current !== loadCount.current) return; + const response = await fetchUsers(search, page, PAGE_SIZE, {signal}); + if (signal.aborted) return; if (response.status === 401) return; if (response.status === 403) { setStatus('forbidden'); @@ -61,14 +62,15 @@ export default function AdminUsersPage() { return; } const data = await response.json(); - if (current !== loadCount.current) return; + if (signal.aborted) return; setResult(data); setStatus('ready'); } catch (err) { + if (signal.aborted) return; console.error('Admin users error:', err); setStatus('error'); } - }, [search, page]); + }, [nextSignal, search, page]); useEffect(() => { load(); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index af81756..008166d 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -137,7 +137,7 @@ const Dashboard = () => { expectInitialSync: hasSteamId && steamData.count === 0, onComplete: reloadLibrary, }); - const forceSync = useForceSync(reloadLibrary); + const forceSync = useForceSync(sync.watch); if (isLoading) { return ; @@ -227,6 +227,7 @@ const Dashboard = () => { status={sync.status} lastSyncedAt={sync.lastSyncedAt} isRunning={sync.isRunning} + stale={sync.stale} />