diff --git a/public/i18n/de.json b/public/i18n/de.json index e69a44e..a4da644 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -1134,5 +1134,31 @@ }, "UNSAVED_CHANGES": { "CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?" + }, + "SYNC_CHANGES": { + "TITLE": "Änderungen im letzten Sync", + "NOTHING": "Im letzten Sync hat sich nichts geändert.", + "IMPORTED_one": "{{count}} Spiel importiert", + "IMPORTED_other": "{{count}} Spiele importiert", + "ADDED_one": "{{count}} neues Spiel", + "ADDED_other": "{{count}} neue Spiele", + "REMOVED_one": "{{count}} Spiel entfernt", + "REMOVED_other": "{{count}} Spiele entfernt", + "PLAYTIME": "{{time}} gespielt", + "ACHIEVEMENTS_one": "{{count}} neuer Erfolg", + "ACHIEVEMENTS_other": "{{count}} neue Erfolge", + "MOVES_one": "{{count}} Statuswechsel", + "MOVES_other": "{{count}} Statuswechsel", + "DETAILS": "Details", + "CLOSE": "Schließen", + "EMPTY": "Keine Änderungen zum Anzeigen.", + "ERROR": "Die Änderungen konnten nicht geladen werden.", + "HEADING": { + "ADDED": "Neue Spiele", + "REMOVED": "Entfernte Spiele", + "PLAYTIME": "Spielzeit", + "ACHIEVEMENTS": "Neue Erfolge", + "STATUS": "Statuswechsel" + } } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 0b4ce0a..9ec5bae 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1134,5 +1134,31 @@ }, "UNSAVED_CHANGES": { "CONFIRM": "You have changes that are not saved. Leave this page and lose them?" + }, + "SYNC_CHANGES": { + "TITLE": "Changes in the last sync", + "NOTHING": "Nothing changed in the last sync.", + "IMPORTED_one": "{{count}} game imported", + "IMPORTED_other": "{{count}} games imported", + "ADDED_one": "{{count}} new game", + "ADDED_other": "{{count}} new games", + "REMOVED_one": "{{count}} game removed", + "REMOVED_other": "{{count}} games removed", + "PLAYTIME": "{{time}} played", + "ACHIEVEMENTS_one": "{{count}} new achievement", + "ACHIEVEMENTS_other": "{{count}} new achievements", + "MOVES_one": "{{count}} status change", + "MOVES_other": "{{count}} status changes", + "DETAILS": "Details", + "CLOSE": "Close", + "EMPTY": "No changes to show.", + "ERROR": "The changes could not be loaded.", + "HEADING": { + "ADDED": "New games", + "REMOVED": "Removed games", + "PLAYTIME": "Playtime", + "ACHIEVEMENTS": "New achievements", + "STATUS": "Status changes" + } } } diff --git a/src/components/SyncChangesBanner.jsx b/src/components/SyncChangesBanner.jsx new file mode 100644 index 0000000..9d0f6f9 --- /dev/null +++ b/src/components/SyncChangesBanner.jsx @@ -0,0 +1,77 @@ +import {useEffect, useRef, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {X} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import SyncChangesDialog from './SyncChangesDialog.jsx'; +import {formatPlaytime} from '../utils/games.js'; +import {bannerOf, fetchSyncChanges, markSyncChangesSeen} from '../utils/syncChanges.js'; + +const keepShown = (prev, body, markedAt) => + prev && body && !prev.seen && body.seen && body.syncedAt === prev.syncedAt && body.syncedAt === markedAt; + +/** + * What the last sync changed, in one line with the details in a dialog. The X marks the result as seen on every + * device. A manual sync without changes says so once. + * + * @param lastSyncedAt the end of the last sync: a new value loads the result again + */ +export default function SyncChangesBanner({lastSyncedAt}) { + const {t} = useTranslation(); + const [result, setResult] = useState(null); + const [dismissed, setDismissed] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const marked = useRef(null); + const banner = result && dismissed !== result.syncedAt ? bannerOf(result) : null; + const nothing = banner?.mode === 'nothing'; + + useEffect(() => { + let current = true; + // An error, a 204 and a 404 (server without the endpoint) show nothing. + fetchSyncChanges() + // The "nothing changed" line was marked seen at once: its own result, now seen, must not hide it. + .then((body) => current && setResult((prev) => keepShown(prev, body, marked.current) ? prev : body)) + .catch(() => undefined); + return () => { + current = false; + }; + }, [lastSyncedAt]); + + useEffect(() => { + if (!nothing || marked.current === result.syncedAt) return; + marked.current = result.syncedAt; + markSyncChangesSeen(result.syncedAt).catch(() => undefined); + }, [nothing, result]); + + if (!banner) return null; + + const close = () => { + setDismissed(result.syncedAt); + markSyncChangesSeen(result.syncedAt).catch(() => undefined); + }; + + const parts = nothing ? [t('SYNC_CHANGES.NOTHING')] : [ + banner.imported > 0 && t('SYNC_CHANGES.IMPORTED', {count: banner.imported}), + banner.added > 0 && t('SYNC_CHANGES.ADDED', {count: banner.added}), + banner.removed > 0 && t('SYNC_CHANGES.REMOVED', {count: banner.removed}), + banner.minutes > 0 && t('SYNC_CHANGES.PLAYTIME', {time: formatPlaytime(banner.minutes)}), + banner.achievements > 0 && t('SYNC_CHANGES.ACHIEVEMENTS', {count: banner.achievements}), + banner.moves > 0 && t('SYNC_CHANGES.MOVES', {count: banner.moves}), + ].filter(Boolean); + + return ( + + {parts.join(' · ')} + {!nothing && ( + + )} + + {detailsOpen && setDetailsOpen(false)}/>} + + ); +} diff --git a/src/components/SyncChangesBanner.test.jsx b/src/components/SyncChangesBanner.test.jsx new file mode 100644 index 0000000..91a2216 --- /dev/null +++ b/src/components/SyncChangesBanner.test.jsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import SyncChangesBanner from './SyncChangesBanner.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +const SYNCED_AT = '2026-10-04T10:00:00.123456Z'; +const CHANGES = '/api/library/sync-changes'; +const SEEN = '/api/library/sync-changes/seen'; +const result = (extra = {}) => ({syncedAt: SYNCED_AT, startedBy: 'AUTO', seen: false, importedGames: 0, changes: [], ...extra}); +const changed = result({ + changes: [ + {kind: 'ADDED', appid: 11, name: 'New Game'}, + {kind: 'PLAYTIME', appid: 22, name: 'Played Game', amount: 90}, + {kind: 'STATUS', appid: 22, name: 'Played Game', fromStatus: 'BACKLOG', toStatus: 'PLAYING'}, + ], +}); + +const renderBanner = () => render(); +const puts = (mock) => mock.mock.calls.filter(([, init]) => init?.method === 'PUT'); + +describe('SyncChangesBanner', () => { + afterEach(cleanup); + + it('showsTheSummary', async () => { + fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)}); + renderBanner(); + + expect(await screen.findByText('SYNC_CHANGES.ADDED:1 · SYNC_CHANGES.PLAYTIME · SYNC_CHANGES.MOVES:1')).toBeTruthy(); + }); + + it('closesWithXAndMarksTheResultSeen', async () => { + const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, changed), [`PUT ${SEEN}`]: json(204, {})}); + renderBanner(); + await screen.findByText(/SYNC_CHANGES.ADDED/); + + fireEvent.click(screen.getByRole('button', {name: 'SYNC_CHANGES.CLOSE'})); + + expect(screen.queryByText(/SYNC_CHANGES.ADDED/)).toBeNull(); + expect(puts(mock)).toHaveLength(1); + expect(JSON.parse(puts(mock)[0][1].body)).toEqual({syncedAt: SYNCED_AT}); + }); + + it('saysNothingChangedOnceForAManualSyncAndMarksItSeenAtOnce', async () => { + const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, result({startedBy: 'MANUAL'})), [`PUT ${SEEN}`]: json(204, {})}); + renderBanner(); + + expect(await screen.findByText('SYNC_CHANGES.NOTHING')).toBeTruthy(); + await waitFor(() => expect(puts(mock)).toHaveLength(1)); + expect(screen.queryByRole('button', {name: 'SYNC_CHANGES.DETAILS'})).toBeNull(); + }); + + it('nothingChangedStaysWhenLastSyncedAtArrivesLater', async () => { + let seen = false; + const mock = fetchRoutes({ + [`GET ${CHANGES}`]: () => json(200, result({startedBy: 'MANUAL', seen})), + [`PUT ${SEEN}`]: () => { + seen = true; + return json(204, {}); + }, + }); + // The dashboard renders before its first sync-status answer: lastSyncedAt is null, then the real value. + const {rerender} = render(); + await screen.findByText('SYNC_CHANGES.NOTHING'); + await waitFor(() => expect(puts(mock)).toHaveLength(1)); + + rerender(); + await waitFor(() => expect(mock.mock.calls.filter(([url]) => url.endsWith(CHANGES))).toHaveLength(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(screen.queryByText('SYNC_CHANGES.NOTHING')).toBeTruthy(); + }); + + it('showsNothingForAnEmptyAutomaticSync', async () => { + const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, result())}); + const {container} = renderBanner(); + + await waitFor(() => expect(mock).toHaveBeenCalled()); + expect(container.textContent).toBe(''); + }); + + it('showsNothingWithoutAResult', async () => { + const mock = fetchRoutes({[`GET ${CHANGES}`]: json(204, {})}); + const {container} = renderBanner(); + + await waitFor(() => expect(mock).toHaveBeenCalled()); + expect(container.textContent).toBe(''); + }); + + it('showsNothingWhenCoreHasNoEndpointYet', async () => { + const mock = fetchRoutes({[`GET ${CHANGES}`]: json(404, {})}); + const {container} = renderBanner(); + + await waitFor(() => expect(mock).toHaveBeenCalled()); + expect(container.textContent).toBe(''); + }); + + it('listsTheChangesGroupedByKindWithLinksToTheGames', async () => { + fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)}); + renderBanner(); + fireEvent.click(await screen.findByRole('button', {name: 'SYNC_CHANGES.DETAILS'})); + + expect(await screen.findByText('SYNC_CHANGES.HEADING.ADDED')).toBeTruthy(); + expect(screen.getByText('SYNC_CHANGES.HEADING.PLAYTIME')).toBeTruthy(); + expect(screen.getByText('SYNC_CHANGES.HEADING.STATUS')).toBeTruthy(); + expect(screen.queryByText('SYNC_CHANGES.HEADING.REMOVED')).toBeNull(); + const link = screen.getAllByRole('link').find((a) => a.getAttribute('href') === '/library/11'); + expect(link).toBeTruthy(); + expect(screen.getByText('+1.5h')).toBeTruthy(); + expect(screen.getByText('BACKLOG.STATUS.BACKLOG → BACKLOG.STATUS.PLAYING')).toBeTruthy(); + }); + + it('loadsTheResultAgainWhenTheDetailsOpen', async () => { + const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)}); + renderBanner(); + fireEvent.click(await screen.findByRole('button', {name: 'SYNC_CHANGES.DETAILS'})); + + await screen.findByText('SYNC_CHANGES.HEADING.ADDED'); + + expect(mock.mock.calls.filter(([url]) => url.endsWith(CHANGES))).toHaveLength(2); + }); +}); diff --git a/src/components/SyncChangesDialog.jsx b/src/components/SyncChangesDialog.jsx new file mode 100644 index 0000000..c2a8210 --- /dev/null +++ b/src/components/SyncChangesDialog.jsx @@ -0,0 +1,66 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; + +import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog'; +import {Skeleton} from '@/components/ui/skeleton'; +import GameRow from './stats/GameRow.jsx'; +import {formatPlaytime} from '../utils/games.js'; +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. + */ +export default function SyncChangesDialog({onClose}) { + const {t} = useTranslation(); + const [changes, setChanges] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let current = true; + fetchSyncChanges() + .then((body) => { + if (!current) return; + if (body) setChanges(body.changes ?? []); + else setFailed(true); + }) + .catch(() => current && setFailed(true)); + return () => { + current = false; + }; + }, []); + + const value = (change) => { + switch (change.kind) { + case 'PLAYTIME': return `+${formatPlaytime(change.amount)}`; + case 'ACHIEVEMENTS': return `+${change.amount}`; + case 'STATUS': return `${t(`BACKLOG.STATUS.${change.fromStatus}`)} → ${t(`BACKLOG.STATUS.${change.toStatus}`)}`; + default: return null; + } + }; + + return ( + !open && onClose()}> + + {t('SYNC_CHANGES.TITLE')} + {t('SYNC_CHANGES.TITLE')} + {changes === null && !failed && } + {failed &&

{t('SYNC_CHANGES.ERROR')}

} + {changes && changes.length === 0 &&

{t('SYNC_CHANGES.EMPTY')}

} + {changes && groupByKind(changes).map(({kind, changes: rows}) => ( +
+

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

+
    + {rows.map((change, index) => ( +
  • + +
  • + ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index ba4163d..4109e49 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -14,6 +14,7 @@ import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx'; import MostPlayedCard from '../components/MostPlayedCard.jsx'; import PileOfShameCard from '../components/PileOfShameCard.jsx'; import SteamGameCount from '../components/SteamGameCount'; +import SyncChangesBanner from '../components/SyncChangesBanner.jsx'; import WhatsNewDialog from '../components/WhatsNewDialog.jsx'; import useDashboardData from '../hooks/useDashboardData.js'; import useLibrarySync from '../hooks/useLibrarySync.js'; @@ -128,6 +129,7 @@ const Dashboard = () => { setEditing(true)}/> + {hasSteamId && } setEditing(false)}/> diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index 3310e99..4808349 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -17,6 +17,7 @@ const quietRoutes = { 'GET /api/library/summary': json(200, empty), 'GET /api/hints': json(200, {results: []}), 'GET /api/news': json(200, {results: []}), + 'GET /api/library/sync-changes': json(204, {}), }; // Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401. diff --git a/src/utils/syncChanges.js b/src/utils/syncChanges.js new file mode 100644 index 0000000..21b4f91 --- /dev/null +++ b/src/utils/syncChanges.js @@ -0,0 +1,53 @@ +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']; + +/** + * `{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. + */ +export async function fetchSyncChanges() { + const response = await apiFetch(`${CORE()}/api/library/sync-changes`); + return response.status === 200 ? response.json() : null; +} + +/** + * The result with this `syncedAt` stays seen on every device. A newer result is not touched. + */ +export const markSyncChangesSeen = (syncedAt) => + apiFetch(`${CORE()}/api/library/sync-changes/seen`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({syncedAt}), + }); + +/** + * What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or + * `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty + * result of an automatic sync, show nothing. + */ +export function bannerOf(result) { + if (!result || result.seen) return null; + const changes = result.changes ?? []; + const imported = result.importedGames ?? 0; + if (changes.length === 0 && imported === 0) { + return result.startedBy === 'MANUAL' ? {mode: 'nothing'} : null; + } + const of = (kind) => changes.filter((change) => change.kind === kind); + const sum = (kind) => of(kind).reduce((total, change) => total + (change.amount ?? 0), 0); + return { + mode: 'changes', imported, added: of('ADDED').length, removed: of('REMOVED').length, + minutes: sum('PLAYTIME'), achievements: sum('ACHIEVEMENTS'), moves: of('STATUS').length, + }; +} + +/** + * `[{kind, changes}]` for the kinds that have changes, in the order of KINDS. + */ +export const groupByKind = (changes) => KINDS + .map((kind) => ({kind, changes: changes.filter((change) => change.kind === kind)})) + .filter((group) => group.changes.length > 0); diff --git a/src/utils/syncChanges.test.js b/src/utils/syncChanges.test.js new file mode 100644 index 0000000..6331ec3 --- /dev/null +++ b/src/utils/syncChanges.test.js @@ -0,0 +1,49 @@ +import {describe, expect, it} from 'vitest'; +import {bannerOf, groupByKind} from './syncChanges.js'; + +const result = (extra = {}) => ({syncedAt: '2026-10-04T10:00:00Z', startedBy: 'AUTO', seen: false, importedGames: 0, changes: [], ...extra}); + +describe('bannerOf', () => { + it('showsNothingWithoutAResult', () => { + expect(bannerOf(null)).toBeNull(); + }); + + it('showsNothingForASeenResult', () => { + expect(bannerOf(result({seen: true, changes: [{kind: 'ADDED', appid: 1}]}))).toBeNull(); + }); + + it('showsNothingForAnEmptyAutomaticSync', () => { + expect(bannerOf(result())).toBeNull(); + }); + + it('saysNothingChangedForAnEmptyManualSync', () => { + expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'}); + }); + + it('countsTheGamesOfAnImport', () => { + expect(bannerOf(result({importedGames: 12}))).toMatchObject({mode: 'changes', imported: 12, added: 0}); + }); + + it('sumsMinutesAndUnlocksAndCountsTheRest', () => { + const changes = [ + {kind: 'ADDED', appid: 1}, {kind: 'ADDED', appid: 2}, {kind: 'REMOVED', appid: 3}, + {kind: 'PLAYTIME', appid: 4, amount: 30}, {kind: 'PLAYTIME', appid: 5, amount: 45}, + {kind: 'ACHIEVEMENTS', appid: 4, amount: 2}, {kind: 'ACHIEVEMENTS', appid: 5, amount: 3}, + {kind: 'STATUS', appid: 5, fromStatus: 'BACKLOG', toStatus: 'PLAYING'}, + ]; + + expect(bannerOf(result({changes}))).toEqual( + {mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1}); + }); +}); + +describe('groupByKind', () => { + it('putsTheGroupsInTheFixedOrderAndLeavesOutEmptyOnes', () => { + const changes = [{kind: 'STATUS', appid: 1}, {kind: 'ADDED', appid: 2}, {kind: 'PLAYTIME', appid: 3}, {kind: 'ADDED', appid: 4}]; + + const groups = groupByKind(changes); + + expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']); + expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]); + }); +});