Public Access
feat(sync): show sync changes in banner and details dialog
This commit is contained in:
1 parent
e054e13a59
commit
dabb0c941c
9 files changed
+425
No files matched your search
@@ -1134,5 +1134,31 @@
|
|||||||
},
|
},
|
||||||
"UNSAVED_CHANGES": {
|
"UNSAVED_CHANGES": {
|
||||||
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1134,5 +1134,31 @@
|
|||||||
},
|
},
|
||||||
"UNSAVED_CHANGES": {
|
"UNSAVED_CHANGES": {
|
||||||
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
|
|||||||
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
||||||
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||||
import SteamGameCount from '../components/SteamGameCount';
|
import SteamGameCount from '../components/SteamGameCount';
|
||||||
|
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
|
||||||
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
||||||
import useDashboardData from '../hooks/useDashboardData.js';
|
import useDashboardData from '../hooks/useDashboardData.js';
|
||||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||||
@@ -128,6 +129,7 @@ const Dashboard = () => {
|
|||||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||||
equipped={equipped}
|
equipped={equipped}
|
||||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||||
|
{hasSteamId && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
|
||||||
|
|
||||||
<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)}/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const quietRoutes = {
|
|||||||
'GET /api/library/summary': json(200, empty),
|
'GET /api/library/summary': json(200, empty),
|
||||||
'GET /api/hints': json(200, {results: []}),
|
'GET /api/hints': json(200, {results: []}),
|
||||||
'GET /api/news': 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.
|
// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user