Public Access
feat(sync): add Set to 100% button for all-unlocked games
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:
1 parent
c966877b23
commit
bbc707de87
11 files changed
+210
-7
No files matched your search
@@ -117,6 +117,7 @@ flowchart LR
|
||||
(`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
|
||||
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 %),
|
||||
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
|
||||
|
||||
@@ -1335,7 +1335,9 @@
|
||||
"CLOSE": "Schließen",
|
||||
"EMPTY": "Keine Änderungen zum Anzeigen.",
|
||||
"ERROR": "Die Änderungen konnten nicht geladen werden.",
|
||||
"COMPLETED_DONE": "Auf 100 % gesetzt",
|
||||
"HEADING": {
|
||||
"ALL_UNLOCKED": "Alle Erfolge freigeschaltet",
|
||||
"ADDED": "Neue Spiele",
|
||||
"REMOVED": "Entfernte Spiele",
|
||||
"PLAYTIME": "Spielzeit",
|
||||
|
||||
@@ -1335,7 +1335,9 @@
|
||||
"CLOSE": "Close",
|
||||
"EMPTY": "No changes to show.",
|
||||
"ERROR": "The changes could not be loaded.",
|
||||
"COMPLETED_DONE": "Set to 100%",
|
||||
"HEADING": {
|
||||
"ALL_UNLOCKED": "All achievements unlocked",
|
||||
"ADDED": "New games",
|
||||
"REMOVED": "Removed games",
|
||||
"PLAYTIME": "Playtime",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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`);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user