diff --git a/README.md b/README.md index 09ef4cb..ed9280f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/i18n/de.json b/public/i18n/de.json index 44de362..155187f 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index 4812e79..be15036 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/components/SyncChangesBanner.jsx b/src/components/SyncChangesBanner.jsx index 9d0f6f9..2b7e8d3 100644 --- a/src/components/SyncChangesBanner.jsx +++ b/src/components/SyncChangesBanner.jsx @@ -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}) { - {detailsOpen && setDetailsOpen(false)}/>} + {detailsOpen && setDetailsOpen(false)} onChanged={onChanged}/>} ); } diff --git a/src/components/SyncChangesDialog.jsx b/src/components/SyncChangesDialog.jsx index c2a8210..4d0cb61 100644 --- a/src/components/SyncChangesDialog.jsx +++ b/src/components/SyncChangesDialog.jsx @@ -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}) {

{t(`SYNC_CHANGES.HEADING.${kind}`)}

    - {rows.map((change, index) => ( + {rows.map((change, index) => kind === 'ALL_UNLOCKED' ? ( +
  • +
    + +
  • + ) : (
  • diff --git a/src/components/SyncChangesDialog.test.jsx b/src/components/SyncChangesDialog.test.jsx new file mode 100644 index 0000000..8337282 --- /dev/null +++ b/src/components/SyncChangesDialog.test.jsx @@ -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( {}}/>); +}; + +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(); + }); +}); diff --git a/src/components/SyncCompletedButton.jsx b/src/components/SyncCompletedButton.jsx new file mode 100644 index 0000000..4e10f9a --- /dev/null +++ b/src/components/SyncCompletedButton.jsx @@ -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 {t('SYNC_CHANGES.COMPLETED_DONE')}; + return ( + + ); +} diff --git a/src/components/SyncCompletedButton.test.jsx b/src/components/SyncCompletedButton.test.jsx new file mode 100644 index 0000000..dc7dc62 --- /dev/null +++ b/src/components/SyncCompletedButton.test.jsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + click(); + + await screen.findByText('SYNC_CHANGES.COMPLETED_DONE'); + expect(onChanged).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 9fcc6fa..7134f91 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 = () => { setEditing(true)}/> - {canSync && } + {canSync && } setEditing(false)}/> diff --git a/src/utils/syncChanges.js b/src/utils/syncChanges.js index d281a1a..0dfbb8a 100644 --- a/src/utils/syncChanges.js +++ b/src/utils/syncChanges.js @@ -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`); diff --git a/src/utils/syncChanges.test.js b/src/utils/syncChanges.test.js index f6e23c7..2645f42 100644 --- a/src/utils/syncChanges.test.js +++ b/src/utils/syncChanges.test.js @@ -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']); + }); });