feat(psn): replace PSN import button with sync review

Remove the PSN import tab; linking now starts a sync.
Show the PSN review link, last played date and PSN expiry notes.
PSN rows hide the hours field; unchecked titles you saw are dismissed,
unseen ones stay open.
This commit is contained in:
lobo committed 2026-10-08 08:15:37 +02:00
1 parent 599b083591
commit 27c402fa34
30 files changed
+338 -286

No files matched your search

+9 -7
View File
@@ -61,15 +61,17 @@ const Dashboard = () => {
};
const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0;
// A PlayStation link syncs too, also without a Steam ID.
const canSync = hasSteamId || !!steamData.canSync;
const showLibrary = canSync || steamData.count > 0;
const {appearance, equipped} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
: null;
const sync = useLibrarySync({
enabled: hasSteamId,
expectInitialSync: hasSteamId && steamData.count === 0,
enabled: canSync,
expectInitialSync: canSync && steamData.count === 0,
onComplete: () => {
reloadLibrary();
stats.reload();
@@ -126,7 +128,7 @@ const Dashboard = () => {
</Button>
</Card>
);
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, charts, change);
}
@@ -137,14 +139,14 @@ const Dashboard = () => {
<PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
{hasSteamId && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
<DashboardHint user={userData} canSync={canSync} summary={summaryFailed ? null : summary ?? undefined}
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
</PageContainer>
</StatDrillProvider>
+37
View File
@@ -234,6 +234,43 @@ describe('Dashboard', () => {
await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1));
});
describe('of a user with a PlayStation link and no Steam ID', () => {
const psnOnlyRoutes = (canSync) => ({
...dashboardRoutes(),
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', settings: {}}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: false, canSync}),
});
const asked = (path) => fetch.mock.calls.some(([url]) => url.includes(path));
it('loads the activity and the sync result, but not the achievements', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
await waitFor(() => expect(asked('/api/library/activity')).toBe(true));
await waitFor(() => expect(asked('/api/library/sync-changes')).toBe(true));
expect(asked('/api/library/achievements/summary')).toBe(false);
expect(asked('/api/library/steam-profile')).toBe(false);
});
it('shows the sync button of the welcome card', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
expect(await screen.findByText('LIBRARY.RESYNC')).toBeTruthy();
});
it('asks for no activity and no sync result without a PlayStation link', async () => {
fetchRoutes(psnOnlyRoutes(false));
renderDashboard();
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
expect(asked('/api/library/activity')).toBe(false);
expect(asked('/api/library/sync-changes')).toBe(false);
expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull();
});
});
it('does not watch a sync when the server started none', async () => {
fetchRoutes(dashboardRoutes());
renderDashboard();
+8 -1
View File
@@ -24,9 +24,10 @@ import useGame from '../hooks/useGame.js';
import PageContainer from '../components/PageContainer.jsx';
import {canCorrect} from '../utils/corrections.js';
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {formatDate} from '../utils/format.js';
export default function GameDetail() {
const {t} = useTranslation();
const {t, i18n} = useTranslation();
const {appid} = useParams();
const navigate = useNavigate();
@@ -87,6 +88,12 @@ export default function GameDetail() {
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
onProfileStyleSaved={setSavedProfileStyle}/>
{data.lastPlayedAt && (
<p className="text-sm text-muted-foreground">
{t('GAME_DETAIL.LAST_PLAYED', {date: formatDate(data.lastPlayedAt, i18n.language, {dateStyle: 'medium'})})}
</p>
)}
{/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
+23
View File
@@ -249,6 +249,29 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
it('psnRowOffersNoHoursField', async () => {
answer({...manualGame, source: 'PSN', platform: 'PS5'});
renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.queryByLabelText('MANUAL_GAME.HOURS')).toBeNull();
});
it('shows when a PlayStation game was last played, and nothing for a game without that date', async () => {
answer({...manualGame, source: 'PSN', platform: 'PS5', lastPlayedAt: '2026-10-05T18:30:00Z'},
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
renderDetail(-1025);
expect(await screen.findByText('GAME_DETAIL.LAST_PLAYED')).toBeTruthy();
cleanup();
answer({...manualGame, source: 'PSN', platform: 'PS5', lastPlayedAt: null});
renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.LAST_PLAYED')).toBeNull();
});
it('saves platform and hours', async () => {
renderDetail(-1025);
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
+1 -1
View File
@@ -22,7 +22,7 @@ beforeEach(() => {
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
'GET /api/library/games': json(200, {content: games, page: 0, size: 20, totalElements: 2, totalPages: 1}),
'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true}),
'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true, canSync: true}),
});
});
+6 -5
View File
@@ -65,8 +65,9 @@ export default function GameLibrary() {
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates);
const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
// Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back.
const [hasSteamId, setHasSteamId] = useState(true);
// A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
// and come back.
const [canSync, setCanSync] = useState(true);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true);
@@ -98,7 +99,7 @@ export default function GameLibrary() {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
.then((response) => (response.ok ? response.json() : null))
.then((json) => {
if (!cancelled && json) setHasSteamId(json.hasSteamId);
if (!cancelled && json) setCanSync(json.canSync);
})
.catch(() => {});
return () => {
@@ -108,7 +109,7 @@ export default function GameLibrary() {
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({
enabled: hasSteamId,
enabled: canSync,
expectInitialSync,
onComplete: load,
});
@@ -164,7 +165,7 @@ export default function GameLibrary() {
<LibraryTabs/>
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
<div className="flex flex-wrap items-center gap-3">
{hasSteamId && (
{canSync && (
<>
<SyncStatusBadge
status={sync.status}
+12 -4
View File
@@ -17,10 +17,10 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
const DEBOUNCE_WAIT_MS = 700;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const answer = (games = page.content, hasSteamId = true) => fetchRoutes({
const answer = (games = page.content, canSync = true, hasSteamId = canSync) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync}),
});
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -92,7 +92,7 @@ describe('GameLibrary search', () => {
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true}),
'GET /api/library/games': (url, init) => {
const q = new URL(url, 'http://x').searchParams.get('q');
if (!q) return games('Half-Life 2');
@@ -263,7 +263,15 @@ describe('GameLibrary with games added by hand', () => {
expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import');
});
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
it('shows the sync for a user with a PlayStation link and no Steam ID', async () => {
answer([zelda], true, false);
renderLibrary();
await screen.findByText('Zelda');
expect(await screen.findByText('LIBRARY.RESYNC')).toBeTruthy();
});
it('hides the sync for a user who has neither a Steam ID nor a PlayStation link but still offers adding a game', async () => {
answer([zelda], false);
renderLibrary();
+2 -3
View File
@@ -14,7 +14,7 @@ import {decodeStoreList} from '../utils/storeList.js';
/**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; ?source=PSN opens the PlayStation input; the history below can
* the matches and saves them. ?id= opens one import (also the PlayStation review the sync made); the history below can
* undo a saved import. #data= is the list the bookmarklet read on a store page.
*/
export default function ImportPage() {
@@ -84,8 +84,7 @@ export default function ImportPage() {
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
) : (
// A store list comes after the first answer: a new key opens its tab.
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows}
initialSource={searchParams.get('source') === 'PSN' ? 'PSN' : null} onCreated={(created) => {
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
setStoreRows(null);
show(created);
loadImports();
-102
View File
@@ -1,102 +0,0 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const linked = {enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt: new Date(Date.now() + 5 * 86_400_000).toISOString()};
function renderPage(routes, entry = '/import?source=PSN') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: null}}),
'GET /api/library/imports': json(200, {imports: []}),
'GET /api/library/psn-account': json(200, linked),
...routes,
});
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/settings" element={<p>settings page</p>}/>
</Routes>
</MemoryRouter>,
);
}
const startButton = () => screen.findByRole('button', {name: 'IMPORT.START'});
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ImportPage PlayStation source', () => {
it('opens on the PlayStation tab for ?source=PSN', async () => {
renderPage({});
expect(await screen.findByText('IMPORT.PSN_HINT')).toBeTruthy();
expect(screen.getByRole('button', {name: 'IMPORT.SOURCE_PSN'}).getAttribute('aria-pressed')).toBe('true');
});
it('opens on the list without the parameter', async () => {
renderPage({}, '/import');
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('points to the settings when no account is linked', async () => {
renderPage({'GET /api/library/psn-account': json(200, {enabled: true, linked: false})});
expect(await screen.findByText(/IMPORT.PSN_NOT_LINKED/)).toBeTruthy();
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
fireEvent.click(screen.getByRole('link', {name: 'IMPORT.PSN_SETTINGS'}));
expect(await screen.findByText('settings page')).toBeTruthy();
});
it('points to the settings when the link has ended', async () => {
renderPage({'GET /api/library/psn-account': json(200, {...linked, expiresAt: '2020-01-01T00:00:00Z'})});
expect(await screen.findByText(/IMPORT.PSN_RELINK/)).toBeTruthy();
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
});
it('starts the import at the PlayStation endpoint without sending rows, and shows its progress', async () => {
renderPage({
'POST /api/library/psn-account/imports': json(202, {id: 9}),
'GET /api/library/imports/9': json(200, {id: 9, source: 'PSN', status: 'MATCHING', total: 4, matched: 1}),
});
fireEvent.click(await startButton());
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
const [, init] = fetch.mock.calls.find(([url, call]) => call?.method === 'POST' && url.endsWith('/psn-account/imports'));
expect(init.body).toBeUndefined();
});
it.each([
[204, {}, 'info', 'IMPORT.PSN_NO_TITLES'],
[404, {}, 'error', 'IMPORT.PSN_NOT_LINKED'],
[409, {code: 'PSN_RELINK'}, 'error', 'IMPORT.PSN_RELINK'],
[409, {code: 'IMPORT_RUNNING'}, 'error', 'IMPORT.RUNNING'],
[429, {code: 'PSN_BUSY'}, 'error', 'IMPORT.PSN_BUSY'],
[502, {}, 'error', 'IMPORT.PSN_UNAVAILABLE'],
[503, {}, 'error', 'MANUAL_GAME.UNAVAILABLE'],
])('answers status %i %j with a toast', async (status, body, kind, key) => {
renderPage({'POST /api/library/psn-account/imports': json(status, body)});
fireEvent.click(await startButton());
await waitFor(() => expect(toast[kind]).toHaveBeenCalledWith(key));
expect(screen.getByText('IMPORT.PSN_HINT')).toBeTruthy();
});
});
+1 -1
View File
@@ -259,7 +259,7 @@ describe('ImportPage', () => {
expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [
{lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0},
{lineNo: 3, igdbId: 1, platform: 'Switch', status: 'PLAYING', playtimeMinutes: 90},
]});
], shownLineNos: [1, 2, 3]});
});
it('checks every line with a new match at once, and none', async () => {
+11 -2
View File
@@ -1,5 +1,6 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {Loader2} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
@@ -8,8 +9,10 @@ import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js';
import {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js';
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
/**
* The newest notes about the people the user follows. Opening the page marks them read.
* The newest notes about the people the user follows and about the PlayStation link. Opening the page marks them read.
*/
export default function InboxPage() {
const {t, i18n} = useTranslation();
@@ -32,6 +35,7 @@ export default function InboxPage() {
}, []);
const text = (note) => {
if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`);
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
@@ -55,7 +59,12 @@ export default function InboxPage() {
<p className="text-xs text-muted-foreground">
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>{text(note)}</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{text(note)}
{PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)}
</p>
</li>
))}
</ul>
+18
View File
@@ -34,6 +34,24 @@ describe('InboxPage', () => {
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
});
it.each(['PSN_EXPIRING', 'PSN_EXPIRED'])('shows the %s note with a link to the settings', async (kind) => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {kind, displayName: null})], unread: 1}),
'PUT /api/inbox/read': json(204)});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText(`INBOX.${kind}`)).toBeTruthy();
expect(screen.getByRole('link', {name: 'INBOX.OPEN_SETTINGS'}).getAttribute('href')).toBe('/settings');
expect(screen.queryByText('INBOX.DIGEST')).toBeNull();
});
it('gives a digest or a visibility note no settings link', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 2}), note(2, {kind: 'VISIBILITY'})], unread: 0})});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
expect(screen.queryByRole('link', {name: 'INBOX.OPEN_SETTINGS'})).toBeNull();
});
it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})});
renderWithProviders(<InboxPage/>);