fix: abort old loads, stale sync state, warn before unsaved edits are lost

This commit is contained in:
lobo committed 2026-09-30 07:51:09 +02:00
1 parent cbafa3d540
commit 9ddc50f819
28 files changed
+753 -121

No files matched your search

+2
View File
@@ -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 () => {
+4
View File
@@ -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});
});
+93
View File
@@ -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(<ManualGameCard game={game} onSaved={onSaved}/>,
{path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library': <p>library page</p>}});
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();
});
});
+11 -2
View File
@@ -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 (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="size-4"/>
<span>{t('LIBRARY.SYNC_STALE')}</span>
</div>
);
}
if (status === 'FAILED') {
return (
<div className="flex items-center gap-2 text-sm text-destructive">