feat(sync): show sync changes in banner and details dialog

This commit is contained in:
lobo committed 2026-10-04 11:36:09 +02:00
1 parent e054e13a59
commit dabb0c941c
9 files changed
+425

No files matched your search

+77
View File
@@ -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 (
<Card className="flex-row flex-wrap items-center gap-x-3 gap-y-2 p-3 text-sm">
<span className="min-w-0 flex-1">{parts.join(' · ')}</span>
{!nothing && (
<Button variant="outline" size="sm" className="min-h-11" onClick={() => setDetailsOpen(true)}>
{t('SYNC_CHANGES.DETAILS')}
</Button>
)}
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
<X/>
</Button>
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
</Card>
);
}
+125
View File
@@ -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(<MemoryRouter><SyncChangesBanner lastSyncedAt="a"/></MemoryRouter>);
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(<MemoryRouter><SyncChangesBanner lastSyncedAt={null}/></MemoryRouter>);
await screen.findByText('SYNC_CHANGES.NOTHING');
await waitFor(() => expect(puts(mock)).toHaveLength(1));
rerender(<MemoryRouter><SyncChangesBanner lastSyncedAt="a"/></MemoryRouter>);
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);
});
});
+66
View File
@@ -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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
<DialogTitle className="pr-6">{t('SYNC_CHANGES.TITLE')}</DialogTitle>
<DialogDescription className="sr-only">{t('SYNC_CHANGES.TITLE')}</DialogDescription>
{changes === null && !failed && <Skeleton className="h-24 rounded-xl"/>}
{failed && <p className="text-sm text-muted-foreground">{t('SYNC_CHANGES.ERROR')}</p>}
{changes && changes.length === 0 && <p className="text-sm text-muted-foreground">{t('SYNC_CHANGES.EMPTY')}</p>}
{changes && groupByKind(changes).map(({kind, changes: rows}) => (
<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) => (
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
<GameRow game={change} value={value(change)} linked/>
</li>
))}
</ul>
</section>
))}
</DialogContent>
</Dialog>
);
}