feat(sync): add Set to 100% button for all-unlocked games
Build, Test & Push Frontend / quality-check (push) Successful in 40s
Build, Test & Push Frontend / docker (push) Successful in 23s

The sync details show a Set to 100% button for ALL_UNLOCKED games.
The button uses the existing status PUT, checks points, and reloads
the Dashboard. Refs #21
This commit is contained in:
lobo committed 2026-10-11 16:22:49 +02:00
1 parent c966877b23
commit bbc707de87
11 files changed
+210 -7

No files matched your search

+3 -2
View File
@@ -16,8 +16,9 @@ const keepShown = (prev, body, markedAt) =>
* device. A manual sync without changes says so once.
*
* @param lastSyncedAt the end of the last sync: a new value loads the result again
* @param onChanged called after a game was set to COMPLETED from the details
*/
export default function SyncChangesBanner({lastSyncedAt}) {
export default function SyncChangesBanner({lastSyncedAt, onChanged}) {
const {t} = useTranslation();
const [result, setResult] = useState(null);
const [dismissed, setDismissed] = useState(null);
@@ -71,7 +72,7 @@ export default function SyncChangesBanner({lastSyncedAt}) {
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
<X/>
</Button>
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)} onChanged={onChanged}/>}
</Card>
);
}
+10 -2
View File
@@ -3,6 +3,7 @@ import {useTranslation} from 'react-i18next';
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import {Skeleton} from '@/components/ui/skeleton';
import SyncCompletedButton from './SyncCompletedButton.jsx';
import GameRow from './stats/GameRow.jsx';
import {formatPlaytime} from '../utils/games.js';
import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
@@ -10,8 +11,10 @@ import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
/**
* The changes of the last sync, grouped by kind. Loads the result again when it opens: the achievements of a sync
* arrive after its games.
*
* @param onChanged called after a game was set to COMPLETED from here
*/
export default function SyncChangesDialog({onClose}) {
export default function SyncChangesDialog({onClose, onChanged}) {
const {t} = useTranslation();
const [changes, setChanges] = useState(null);
const [failed, setFailed] = useState(false);
@@ -52,7 +55,12 @@ export default function SyncChangesDialog({onClose}) {
<section key={kind} className="space-y-2">
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
<ul className="space-y-2">
{rows.map((change, index) => (
{rows.map((change, index) => kind === 'ALL_UNLOCKED' ? (
<li key={change.appid} className="flex items-center gap-3">
<div className="min-w-0 flex-1"><GameRow game={change} linked/></div>
<SyncCompletedButton appid={change.appid} onChanged={onChanged}/>
</li>
) : (
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
<GameRow game={change} value={value(change)} linked/>
</li>
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import SyncChangesDialog from './SyncChangesDialog.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 open = (changes) => {
fetchRoutes({'GET /api/library/sync-changes': () => json(200, {syncedAt: 'x', seen: false, changes})});
render(<MemoryRouter><SyncChangesDialog onClose={() => {}}/></MemoryRouter>);
};
describe('SyncChangesDialog', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('showsTheGameAndTheButtonForAllUnlocked', async () => {
open([{kind: 'ALL_UNLOCKED', appid: 220, name: 'Half-Life 2'}]);
expect(await screen.findByText('SYNC_CHANGES.HEADING.ALL_UNLOCKED')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})).toBeTruthy();
});
it('showsNoButtonForOtherKinds', async () => {
open([{kind: 'PLAYTIME', appid: 220, name: 'Half-Life 2', amount: 30}]);
await screen.findByText('Half-Life 2');
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
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';
/**
* Sets a game to COMPLETED from the sync details, the same call as {@link CompletedHint}.
*
* @param onChanged called after the status was saved
*/
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;
}
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);
}
};
if (done) return <span className="text-sm text-muted-foreground">{t('SYNC_CHANGES.COMPLETED_DONE')}</span>;
return (
<Button size="sm" className="min-h-11 shrink-0" onClick={setCompleted} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_COMPLETED')}
</Button>
);
}
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import SyncCompletedButton from './SyncCompletedButton.jsx';
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('../hooks/usePointsCheck.js', () => ({default: () => pointsCheck}));
const click = () => fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}));
describe('SyncCompletedButton', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('setsTheStatusToCompletedAndShowsTheDoneText', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
render(<SyncCompletedButton appid={220}/>);
click();
expect(await screen.findByText('SYNC_CHANGES.COMPLETED_DONE')).toBeTruthy();
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({status: 'COMPLETED'});
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
expect(screen.queryByRole('button')).toBeNull();
});
it('keepsTheEnabledButtonWhenSavingFails', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(500, {})});
render(<SyncCompletedButton appid={220}/>);
click();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}).disabled).toBe(false);
});
it('sendsOnlyOneRequestOnADoubleClick', async () => {
let release;
fetchRoutes({'PUT /api/library/games/220/status': () => new Promise((resolve) => {
release = () => resolve(json(200, {status: 'COMPLETED'}));
})});
render(<SyncCompletedButton appid={220}/>);
click();
click();
expect(screen.getByRole('button').disabled).toBe(true);
release();
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('settlesAndTellsThePointsAfterSaving', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
render(<SyncCompletedButton appid={220}/>);
click();
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'COMPLETED', tell: true});
});
it('callsOnChangedAfterSaving', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
const onChanged = vi.fn();
render(<SyncCompletedButton appid={220} onChanged={onChanged}/>);
click();
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
expect(onChanged).toHaveBeenCalledTimes(1);
});
});
+5 -1
View File
@@ -77,6 +77,10 @@ const Dashboard = () => {
stats.reload();
},
});
const reloadAfterCompleted = () => {
reloadLibrary();
stats.reload();
};
const {watch} = sync;
const forceSync = useForceSync(watch);
useEffect(() => {
@@ -140,7 +144,7 @@ const Dashboard = () => {
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped}
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt} onChanged={reloadAfterCompleted}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
+3 -2
View File
@@ -3,12 +3,13 @@ import {apiFetch} from './api.js';
const CORE = () => import.meta.env.VITE_CORE_API_URL;
// The order of the groups in the details.
const KINDS = ['ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
const KINDS = ['ALL_UNLOCKED', 'ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
/**
* `{syncedAt, startedBy, seen, importedGames, changes: [{kind, appid, name, imageUrl, amount, fromStatus, toStatus}]}`,
* or null when the user has no result (204) or the answer is not 200. PLAYTIME: amount in minutes, ACHIEVEMENTS:
* amount is the number of new unlocks.
* amount is the number of new unlocks. ALL_UNLOCKED: the sync unlocked the last
* achievement; gone once the game is COMPLETED.
*/
export async function fetchSyncChanges() {
const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
+12
View File
@@ -39,6 +39,12 @@ describe('bannerOf', () => {
expect(bannerOf(result({changes}))).toEqual(
{mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1});
});
it('countsAllUnlockedNowhere', () => {
const banner = bannerOf(result({changes: [{kind: 'ALL_UNLOCKED', appid: 1}]}));
expect(banner).toEqual({mode: 'changes', imported: 0, added: 0, removed: 0, minutes: 0, achievements: 0, moves: 0});
});
});
describe('groupByKind', () => {
@@ -50,4 +56,10 @@ describe('groupByKind', () => {
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]);
});
it('putsAllUnlockedFirst', () => {
const groups = groupByKind([{kind: 'ADDED', appid: 1}, {kind: 'ALL_UNLOCKED', appid: 2}]);
expect(groups.map((group) => group.kind)).toEqual(['ALL_UNLOCKED', 'ADDED']);
});
});