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

+1
View File
@@ -117,6 +117,7 @@ flowchart LR
(`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag; (`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag;
a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the
next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game. next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game.
The sync details list games whose last achievement this sync unlocked, with a button to set them to 100 %.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
+2
View File
@@ -1335,7 +1335,9 @@
"CLOSE": "Schließen", "CLOSE": "Schließen",
"EMPTY": "Keine Änderungen zum Anzeigen.", "EMPTY": "Keine Änderungen zum Anzeigen.",
"ERROR": "Die Änderungen konnten nicht geladen werden.", "ERROR": "Die Änderungen konnten nicht geladen werden.",
"COMPLETED_DONE": "Auf 100 % gesetzt",
"HEADING": { "HEADING": {
"ALL_UNLOCKED": "Alle Erfolge freigeschaltet",
"ADDED": "Neue Spiele", "ADDED": "Neue Spiele",
"REMOVED": "Entfernte Spiele", "REMOVED": "Entfernte Spiele",
"PLAYTIME": "Spielzeit", "PLAYTIME": "Spielzeit",
+2
View File
@@ -1335,7 +1335,9 @@
"CLOSE": "Close", "CLOSE": "Close",
"EMPTY": "No changes to show.", "EMPTY": "No changes to show.",
"ERROR": "The changes could not be loaded.", "ERROR": "The changes could not be loaded.",
"COMPLETED_DONE": "Set to 100%",
"HEADING": { "HEADING": {
"ALL_UNLOCKED": "All achievements unlocked",
"ADDED": "New games", "ADDED": "New games",
"REMOVED": "Removed games", "REMOVED": "Removed games",
"PLAYTIME": "Playtime", "PLAYTIME": "Playtime",
+3 -2
View File
@@ -16,8 +16,9 @@ const keepShown = (prev, body, markedAt) =>
* device. A manual sync without changes says so once. * 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 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 {t} = useTranslation();
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [dismissed, setDismissed] = 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}> <Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
<X/> <X/>
</Button> </Button>
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>} {detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)} onChanged={onChanged}/>}
</Card> </Card>
); );
} }
+10 -2
View File
@@ -3,6 +3,7 @@ import {useTranslation} from 'react-i18next';
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog'; import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import SyncCompletedButton from './SyncCompletedButton.jsx';
import GameRow from './stats/GameRow.jsx'; import GameRow from './stats/GameRow.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.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 * 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. * 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 {t} = useTranslation();
const [changes, setChanges] = useState(null); const [changes, setChanges] = useState(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -52,7 +55,12 @@ export default function SyncChangesDialog({onClose}) {
<section key={kind} className="space-y-2"> <section key={kind} className="space-y-2">
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3> <h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
<ul className="space-y-2"> <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}`}> <li key={`${change.appid}-${change.fromStatus}-${index}`}>
<GameRow game={change} value={value(change)} linked/> <GameRow game={change} value={value(change)} linked/>
</li> </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(); stats.reload();
}, },
}); });
const reloadAfterCompleted = () => {
reloadLibrary();
stats.reload();
};
const {watch} = sync; const {watch} = sync;
const forceSync = useForceSync(watch); const forceSync = useForceSync(watch);
useEffect(() => { useEffect(() => {
@@ -140,7 +144,7 @@ const Dashboard = () => {
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl} <DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped} equipped={equipped}
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/> 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} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/> 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; const CORE = () => import.meta.env.VITE_CORE_API_URL;
// The order of the groups in the details. // 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}]}`, * `{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: * 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() { export async function fetchSyncChanges() {
const response = await apiFetch(`${CORE()}/api/library/sync-changes`); const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
+12
View File
@@ -39,6 +39,12 @@ describe('bannerOf', () => {
expect(bannerOf(result({changes}))).toEqual( expect(bannerOf(result({changes}))).toEqual(
{mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1}); {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', () => { describe('groupByKind', () => {
@@ -50,4 +56,10 @@ describe('groupByKind', () => {
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']); expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]); 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']);
});
}); });