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
+330 -278

No files matched your search

+3 -1
View File
@@ -146,7 +146,9 @@ To Be Played is split into three repositories:
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
and marks them read; a user who goes private ends the follow with a note. Notes older than 90 days are deleted.
and marks them read; a user who goes private ends the follow with a note. Two more notes come from the user's own
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
settings. Notes older than 90 days are deleted.
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
+9 -10
View File
@@ -152,11 +152,12 @@
"WHAT_IT_GIVES": "Der NPSSO-Code ist deine PlayStation-Anmeldesitzung. Wer ihn hat, kann PlayStation-Seiten als du nutzen. Wir tauschen ihn sofort gegen ein begrenztes Token (Umfang der PlayStation-App) ein, speichern nur dieses Token verschlüsselt und verwerfen den Code. Du kannst das jederzeit beenden: hier trennen oder in den PlayStation-Kontoeinstellungen auf allen Geräten abmelden. Das nutzt dieselbe inoffizielle Schnittstelle wie Trophäen-Seiten von Drittanbietern; Sony unterstützt sie nicht und kann sie sperren.",
"NPSSO_LABEL": "NPSSO-Code",
"LINK": "PlayStation-Konto verknüpfen",
"LINKED": "PlayStation-Konto verknüpft.",
"LINKED": "PlayStation-Konto verknüpft, die Synchronisierung startet jetzt.",
"LINKED_SINCE": "Verknüpft seit {{date}}",
"EXPIRES_ON": "Verknüpfung endet am {{date}}",
"EXPIRED": "Die Verknüpfung ist beendet. Füge einen neuen NPSSO-Code ein, um sie zu erneuern.",
"IMPORT": "Gespielte Spiele importieren",
"SYNC_HINT": "Neue gespielte Titel kommen mit jeder Synchronisierung. Sichere Treffer werden hinzugefügt, unsichere warten im Import-Review.",
"REVIEW": "Wartende Titel prüfen",
"UNLINK": "Trennen",
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
@@ -298,6 +299,7 @@
"ERROR_LOAD": "Spiel konnte nicht geladen werden.",
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
"PLAYTIME": "Spielzeit: {{time}}",
"LAST_PLAYED": "Zuletzt gespielt {{date}}",
"OPEN_IN_STEAM": "Auf Steam ansehen",
"HIDE": "Spiel ausblenden",
"UNHIDE": "Spiel wieder anzeigen",
@@ -931,6 +933,9 @@
"LOAD_ERROR": "Das Postfach konnte nicht geladen werden.",
"DIGEST": "{{name}}: {{parts}}",
"VISIBILITY": "{{name}} hat die Sichtbarkeit geändert, du folgst nicht mehr.",
"PSN_EXPIRING": "Deine PlayStation-Verknüpfung endet in etwa 2 Tagen. Füge in den Einstellungen einen neuen NPSSO-Code ein, damit die Synchronisierung weiterläuft.",
"PSN_EXPIRED": "Deine PlayStation-Verknüpfung ist beendet. Die Synchronisierung überspringt PlayStation, bis du einen neuen NPSSO-Code einfügst.",
"OPEN_SETTINGS": "Einstellungen öffnen",
"PART_NEW_GAMES_one": "{{count}} neues Spiel",
"PART_NEW_GAMES_other": "{{count}} neue Spiele",
"PART_ACHIEVEMENTS_one": "{{count}} Erfolg",
@@ -1049,6 +1054,7 @@
"SOURCE": "Eingabe",
"SOURCE_TEXT": "Liste",
"SOURCE_CSV": "CSV-Datei",
"SOURCE_PSN": "PlayStation",
"SOURCE_SEARCH": "Suche",
"TEXT_LABEL": "Spieltitel",
"TEXT_PLACEHOLDER": "Ein Spiel pro Zeile",
@@ -1060,13 +1066,7 @@
"START": "Spiele suchen",
"START_FAILED": "Der Import konnte nicht starten.",
"RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.",
"PSN_HINT": "Importiert deine gespielten PS4- und PS5-Titel mit Spielzeit. Titel, die du gekauft, aber nie gestartet hast, stehen nicht in der Liste.",
"PSN_NOT_LINKED": "Verknüpfe zuerst dein PlayStation-Konto in den Einstellungen.",
"PSN_RELINK": "Die PlayStation-Verknüpfung ist beendet. Füge in den Einstellungen einen neuen NPSSO-Code ein.",
"PSN_NO_TITLES": "PlayStation listet für dieses Konto keine gespielten Titel.",
"PSN_UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
"PSN_BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
"PSN_SETTINGS": "Einstellungen öffnen",
"PSN_UNCHECKED_DISMISSED": "Nicht angehakte PlayStation-Titel werden dir nicht noch einmal angeboten.",
"LOAD_FAILED": "Der Import konnte nicht geladen werden.",
"MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}",
"MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.",
@@ -1130,7 +1130,6 @@
"SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store-Seite",
"SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Gelesen als: {{format}}",
"STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.",
"STORE_LINK": "To Be Played Import",
+9 -10
View File
@@ -152,11 +152,12 @@
"WHAT_IT_GIVES": "The NPSSO code is your PlayStation sign-in session. Whoever has it can use PlayStation sites as you. We exchange it at once for a limited token (PlayStation app scope), store only that token encrypted, and drop the code. You can end it any time: unlink here, or sign out of all devices in your PlayStation account settings. This uses the same unofficial interface as third-party trophy sites; Sony does not support it and may block it.",
"NPSSO_LABEL": "NPSSO code",
"LINK": "Link PlayStation account",
"LINKED": "PlayStation account linked.",
"LINKED": "PlayStation account linked, the sync starts now.",
"LINKED_SINCE": "Linked since {{date}}",
"EXPIRES_ON": "Link ends on {{date}}",
"EXPIRED": "The link has ended. Paste a new NPSSO code to link again.",
"IMPORT": "Import played games",
"SYNC_HINT": "New played titles come with every sync. Sure matches are added; unsure ones wait in the import review.",
"REVIEW": "Review waiting titles",
"UNLINK": "Unlink",
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
@@ -298,6 +299,7 @@
"ERROR_LOAD": "Could not load this game.",
"NOT_FOUND": "Game not in your library.",
"PLAYTIME": "Playtime: {{time}}",
"LAST_PLAYED": "Last played {{date}}",
"OPEN_IN_STEAM": "View on Steam",
"HIDE": "Hide game",
"UNHIDE": "Show game again",
@@ -931,6 +933,9 @@
"LOAD_ERROR": "The inbox could not be loaded.",
"DIGEST": "{{name}}: {{parts}}",
"VISIBILITY": "{{name}} changed visibility, you no longer follow them.",
"PSN_EXPIRING": "Your PlayStation link ends in about 2 days. Paste a new NPSSO in the settings to keep the sync.",
"PSN_EXPIRED": "Your PlayStation link has ended. The sync skips PlayStation until you paste a new NPSSO.",
"OPEN_SETTINGS": "Open settings",
"PART_NEW_GAMES_one": "{{count}} new game",
"PART_NEW_GAMES_other": "{{count}} new games",
"PART_ACHIEVEMENTS_one": "{{count}} achievement",
@@ -1049,6 +1054,7 @@
"SOURCE": "Input",
"SOURCE_TEXT": "List",
"SOURCE_CSV": "CSV file",
"SOURCE_PSN": "PlayStation",
"SOURCE_SEARCH": "Search",
"TEXT_LABEL": "Game titles",
"TEXT_PLACEHOLDER": "One game per line",
@@ -1060,13 +1066,7 @@
"START": "Search games",
"START_FAILED": "Could not start the import.",
"RUNNING": "Another import is still being matched. Wait until it is done.",
"PSN_HINT": "Imports your played PS4 and PS5 titles with playtime. Titles you bought but never started are not listed.",
"PSN_NOT_LINKED": "Link your PlayStation account in the settings first.",
"PSN_RELINK": "The PlayStation link has ended. Paste a new NPSSO code in the settings.",
"PSN_NO_TITLES": "PlayStation lists no played titles for this account.",
"PSN_UNAVAILABLE": "PlayStation did not answer. Please try again later.",
"PSN_BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.",
"PSN_SETTINGS": "Open settings",
"PSN_UNCHECKED_DISMISSED": "Unchecked PlayStation titles are not offered again.",
"LOAD_FAILED": "Could not load the import.",
"MATCHING": "Searching games: {{matched}} / {{total}}",
"MATCHING_HINT": "You can leave this page and come back later.",
@@ -1130,7 +1130,6 @@
"SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store page",
"SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Read as: {{format}}",
"STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.",
"STORE_LINK": "To Be Played import",
+5 -4
View File
@@ -33,10 +33,11 @@ const rememberClosed = (hintId) => {
* hint hides it for 7 days, "Do not show again" for good.
*
* @param user the current user
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
* @param summary Core's library summary; undefined while loading, null when it failed
* @param trend the TREND group of the stats; undefined while loading, null when it failed
*/
export default function DashboardHint({user, summary, trend}) {
export default function DashboardHint({user, canSync, summary, trend}) {
const {t} = useTranslation();
const [data, setData] = useState({hints: [], automatic: []});
const [closed, setClosed] = useState(closedIds);
@@ -67,12 +68,12 @@ export default function DashboardHint({user, summary, trend}) {
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) {
|| (automatic.includes('NO_STEAM') && (!user || canSync === undefined)) || links === undefined) {
return undefined;
}
const libraryEmpty = summary?.total === 0;
const growth = pileGrowth(trend);
const condition = {NO_STEAM: !user?.steamId, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
const condition = {NO_STEAM: !canSync, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
const count = {LINK_QUEUE: links, PILE_GREW: growth};
return [
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
@@ -80,7 +81,7 @@ export default function DashboardHint({user, summary, trend}) {
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
})),
].find((h) => !closed.includes(h.id));
}, [data, closed, summary, trend, user, linkCount, wantsLinkQueue, t]);
}, [data, closed, summary, trend, user, canSync, linkCount, wantsLinkQueue, t]);
// A failed save only shows an error: the hint comes back in the next browser session.
const close = useCallback(async (forever, byUser) => {
+21 -9
View File
@@ -148,7 +148,7 @@ describe('DashboardHint, automatic hints', () => {
});
it('shows the admin hints before the automatic ones', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}, [DISMISS]);
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false}, [DISMISS]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
@@ -156,14 +156,26 @@ describe('DashboardHint, automatic hints', () => {
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('shows NO_STEAM only without a Steam ID', async () => {
await showAuto(['NO_STEAM']);
it('shows NO_STEAM only when nothing can sync', async () => {
await showAuto(['NO_STEAM'], {canSync: true});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows no NO_STEAM hint to a user with a PlayStation link and no Steam ID', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: true});
expect(screen.queryByRole('status')).toBeNull();
});
it('waits with NO_STEAM until it is known whether the user can sync', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: undefined});
expect(screen.queryByRole('status')).toBeNull();
});
it('hides an automatic hint for 7 days with X', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
@@ -172,7 +184,7 @@ describe('DashboardHint, automatic hints', () => {
});
it('hides an automatic hint for good with the tick', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
@@ -181,7 +193,7 @@ describe('DashboardHint, automatic hints', () => {
});
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
@@ -189,12 +201,12 @@ describe('DashboardHint, automatic hints', () => {
expect(doneCalls()).toEqual([]);
cleanup();
sessionStorage.clear();
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('hides the auto closed hint for good when the tick is set', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
@@ -265,7 +277,7 @@ describe('DashboardHint, automatic hints', () => {
it('shows only the admin hints when Auth sends no automatic list', async () => {
hintRoutes(json(200, {results: [DISMISS]}));
mount({user: {...USER, steamId: null}});
mount({user: {...USER, steamId: null}, canSync: false});
await act(async () => undefined);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
+4 -2
View File
@@ -14,10 +14,12 @@ import SyncStatusBadge from './SyncStatusBadge.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {bannerEffect, lookClass} from '../utils/rewards.js';
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) {
// canSync: a Steam ID or a PlayStation link; the sync controls follow it, the Steam ID badge only the Steam ID.
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped}) {
const {t} = useTranslation();
const {user: userData} = useCurrentUserContext();
const hasSteamId = !!userData?.steamId;
const showSync = canSync ?? hasSteamId;
const syncBusy = forceSync.busy || sync.isRunning;
return (
@@ -53,7 +55,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
</div>
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
{hasSteamId && (
{showSync && (
<>
<SyncStatusBadge
status={sync.status}
+6 -2
View File
@@ -25,7 +25,9 @@ export default function ManualGameCard({game, onSaved}) {
const [busy, setBusy] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const playtimeMinutes = hoursToMinutes(hours);
// PlayStation owns the playtime of its rows: Core ignores a value sent for one.
const psn = game.source === 'PSN';
const playtimeMinutes = psn ? game.playtimeMinutes : hoursToMinutes(hours);
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
useUnsavedChanges(dirty);
@@ -65,7 +67,7 @@ export default function ManualGameCard({game, onSaved}) {
<Hand className="size-4 text-primary"/>
{t('MANUAL_GAME.TITLE')}
</h2>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>
{!psn && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>}
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
@@ -73,11 +75,13 @@ export default function ManualGameCard({game, onSaved}) {
<Input id="manual-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH}
onChange={(e) => setPlatform(e.target.value)}/>
</div>
{!psn && (
<div className="space-y-2">
<Label htmlFor="manual-hours">{t('MANUAL_GAME.HOURS')}</Label>
<Input id="manual-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
onChange={(e) => setHours(e.target.value)}/>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
+6 -5
View File
@@ -1,7 +1,7 @@
import {useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Gamepad, Link2, Loader2, Unlink, Upload} from 'lucide-react';
import {Gamepad, Link2, Loader2, Unlink} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
@@ -13,7 +13,7 @@ import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
/**
* The linked PlayStation account. The user pastes an NPSSO once; Core keeps only an encrypted token for about 10 days,
* so the card shows when the link ends.
* so the card shows when the link ends. Linking starts a sync; titles the sync could not match wait in an import review.
*/
export default function PsnAccountCard() {
const {t, i18n} = useTranslation();
@@ -94,11 +94,12 @@ export default function PsnAccountCard() {
{expired && <> · {t('SETTINGS.PSN.EXPIRED')}</>}
</p>
</div>
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.SYNC_HINT')}</p>
{expired && form}
<div className="flex flex-wrap gap-2">
{!expired && (
<Button asChild>
<Link to="/import?source=PSN"><Upload/>{t('SETTINGS.PSN.IMPORT')}</Link>
{account.reviewImportId && (
<Button asChild variant="outline">
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
</Button>
)}
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
+21 -3
View File
@@ -11,7 +11,8 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn(
const NPSSO = 'npsso-secret-value';
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
const linkedAccount = (expiresAt) => ({enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt});
const linkedAccount = (expiresAt, reviewImportId = null) =>
({enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt, reviewImportId});
const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString();
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
@@ -66,7 +67,8 @@ describe('PsnAccountCard', () => {
expect(await screen.findByText('SETTINGS.PSN.LINKED_SINCE')).toBeTruthy();
expect(screen.getByText('SETTINGS.PSN.EXPIRES_ON')).toBeTruthy();
expect(screen.getByRole('link', {name: /SETTINGS.PSN.IMPORT/}).getAttribute('href')).toBe('/import?source=PSN');
expect(screen.getByText('SETTINGS.PSN.SYNC_HINT')).toBeTruthy();
expect(screen.queryByRole('link', {name: /SETTINGS.PSN.IMPORT/})).toBeNull();
expect(JSON.parse(calls('POST')[0][1].body)).toEqual({npsso: NPSSO});
expect(document.body.innerHTML).not.toContain(NPSSO);
expect(input.isConnected && input.value).toBeFalsy();
@@ -97,7 +99,23 @@ describe('PsnAccountCard', () => {
expect(await screen.findByText(/SETTINGS.PSN.EXPIRED/)).toBeTruthy();
expect(screen.getByText(/SETTINGS.PSN.EXPIRES_ON/).className).toContain('text-destructive');
expect(screen.getByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
expect(screen.queryByRole('link', {name: /SETTINGS.PSN.IMPORT/})).toBeNull();
expect(screen.queryByRole('link', {name: /SETTINGS.PSN.REVIEW/})).toBeNull();
});
it('links to the review only when titles wait in one', async () => {
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5), 42))});
expect(await screen.findByRole('link', {name: 'SETTINGS.PSN.REVIEW'})).toBeTruthy();
expect(screen.getByRole('link', {name: 'SETTINGS.PSN.REVIEW'}).getAttribute('href')).toBe('/import?id=42');
});
it('has no review link and no import button without a waiting review', async () => {
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5)))});
await screen.findByText('SETTINGS.PSN.SYNC_HINT');
expect(screen.queryByRole('link')).toBeNull();
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
});
it('unlinks and shows the link form again', async () => {
+6 -50
View File
@@ -1,6 +1,5 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {Copy, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner';
@@ -8,13 +7,10 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea';
import usePsnAccount from '../../hooks/usePsnAccount.js';
import useStartImport from '../../hooks/useStartImport.js';
import useStartPsnImport from '../../hooks/useStartPsnImport.js';
import {readImportFile} from '../../utils/importFormats.js';
import {parseTextList} from '../../utils/imports.js';
import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
import {isExpired} from '../../utils/psn.js';
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG';
@@ -23,11 +19,10 @@ const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(loca
/**
* @param storeRows the games the bookmarklet read on a store page, or null
* @param initialSource the tab to open, e.g. PSN; null for the default
*/
export default function ImportInput({storeRows, initialSource = null, onCreated}) {
export default function ImportInput({storeRows, onCreated}) {
const {t} = useTranslation();
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : initialSource ?? 'TEXT');
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT');
const [text, setText] = useState('');
const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false);
@@ -49,14 +44,12 @@ export default function ImportInput({storeRows, initialSource = null, onCreated}
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2">
{['TEXT', 'CSV', 'BOOKMARKLET', 'PSN'].map((value) => (
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))}
</div>
{source === 'PSN' ? (
<PsnInput onCreated={onCreated}/>
) : source === 'BOOKMARKLET' ? (
{source === 'BOOKMARKLET' ? (
<StoreInput rows={storeRows}/>
) : source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
@@ -73,14 +66,14 @@ export default function ImportInput({storeRows, initialSource = null, onCreated}
<RowPreview rows={csv?.rows}/>
</div>
)}
{source !== 'PSN' && <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
</span>
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button>
</div>}
</div>
</Card>
);
}
@@ -124,40 +117,3 @@ function StoreInput({rows}) {
</div>
);
}
/**
* The played PlayStation titles: Core reads them from the linked account, so there is nothing to type.
*/
function PsnInput({onCreated}) {
const {t} = useTranslation();
const {account, failed} = usePsnAccount();
const startImport = useStartPsnImport();
const [busy, setBusy] = useState(false);
const start = async () => {
setBusy(true);
const id = await startImport();
setBusy(false);
if (id) onCreated(id);
};
if (failed) return <p className="text-sm text-destructive">{t('SETTINGS.PSN.ERROR')}</p>;
if (!account) return <Loader2 className="animate-spin text-muted-foreground"/>;
const linked = account.enabled && account.linked;
if (!linked || isExpired(account)) {
return (
<p className="text-sm">
{t(linked ? 'IMPORT.PSN_RELINK' : 'IMPORT.PSN_NOT_LINKED')}{' '}
<Link to="/settings" className="underline">{t('IMPORT.PSN_SETTINGS')}</Link>
</p>
);
}
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">{t('IMPORT.PSN_HINT')}</p>
<Button onClick={start} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button>
</div>
);
}
+13 -5
View File
@@ -49,7 +49,7 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
playtimeMinutes: hoursToMinutes(choice.hours),
})));
})), data.rows.map((row) => row.lineNo));
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('IMPORT.SAVE_FAILED'));
@@ -64,15 +64,21 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
}
};
// A PlayStation review can be saved with nothing checked: the unchecked titles are not offered again.
const dismissesUnchecked = data.source === 'PSN';
return (
<Card className="gap-0 overflow-hidden p-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p>
{dismissesUnchecked && <p className="text-sm text-muted-foreground">{t('IMPORT.PSN_UNCHECKED_DISMISSED')}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => checkAll(true)} disabled={busy}>{t('IMPORT.SELECT_ALL')}</Button>
<Button variant="outline" onClick={() => checkAll(false)} disabled={busy}>{t('IMPORT.SELECT_NONE')}</Button>
<Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button>
<Button onClick={save} disabled={busy || chosen.length === 0}>
<Button onClick={save} disabled={busy || (chosen.length === 0 && !dismissesUnchecked)}>
{busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})}
</Button>
</div>
@@ -80,14 +86,14 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
<ul className="divide-y">
{choices.map((choice, index) => (
<ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice}
onChange={change} onPick={pick}/>
psn={dismissesUnchecked} onChange={change} onPick={pick}/>
))}
</ul>
</Card>
);
}
const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
const ReviewRow = memo(function ReviewRow({index, row, choice, psn, onChange: changeLine, onPick: pickLine}) {
const {t} = useTranslation();
const onChange = (changes) => changeLine(index, changes);
const onPick = (candidate) => pickLine(index, candidate);
@@ -117,7 +123,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
{choice.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p>
) : (
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_6rem]">
<div className={`grid gap-2 ${psn ? 'sm:grid-cols-[2fr_1fr_1fr]' : 'sm:grid-cols-[2fr_1fr_1fr_6rem]'}`}>
<select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''}
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
{choice.candidates.map((known) => (
@@ -143,8 +149,10 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
onChange={(e) => onChange({status: e.target.value})}>
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
</select>
{!psn && (
<Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours}
aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/>
)}
</div>
)}
</li>
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import ImportReview from './ImportReview.jsx';
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
import {renderWithProviders} from '../../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const wilds = {igdbId: 7, name: 'Monster Hunter Wilds', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
const review = (source) => ({
id: 5, source, status: 'READY', total: 1, matched: 1,
rows: [{lineNo: 1, title: 'Monster Hunter Wilds', platform: 'PS5', playtimeMinutes: 30, status: null,
searchFailed: false, exact: false, candidates: [wilds]}],
});
const onSaved = vi.fn();
function renderReview(source) {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'POST /api/library/imports/5/save': json(202, {}),
});
return renderWithProviders(<ImportReview data={review(source)} favorite={null} onSaved={onSaved} onDone={vi.fn()}/>);
}
const savePosts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'POST' && url.endsWith('/5/save'));
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ImportReview of a PlayStation import', () => {
it('tells that unchecked titles are not offered again', async () => {
renderReview('PSN');
expect(await screen.findByText('IMPORT.PSN_UNCHECKED_DISMISSED')).toBeTruthy();
});
it('offers no hours field, as PlayStation owns the playtime', async () => {
renderReview('PSN');
expect(await screen.findByLabelText(/IMPORT.PLATFORM/)).toBeTruthy();
expect(screen.queryByLabelText(/IMPORT.HOURS/)).toBeNull();
});
it('saves with nothing checked and posts no rows', async () => {
renderReview('PSN');
fireEvent.click(await screen.findByRole('button', {name: /IMPORT.SAVE/}));
await waitFor(() => expect(savePosts()).toHaveLength(1));
expect(JSON.parse(savePosts()[0][1].body)).toEqual({rows: [], shownLineNos: [1]});
await waitFor(() => expect(onSaved).toHaveBeenCalled());
});
});
describe('ImportReview of another import', () => {
it('has no dismiss hint, and saving needs a checked line', async () => {
renderReview('TEXT');
const save = await screen.findByRole('button', {name: /IMPORT.SAVE/});
expect(screen.queryByText('IMPORT.PSN_UNCHECKED_DISMISSED')).toBeNull();
expect(save.disabled).toBe(true);
});
it('offers the hours field', async () => {
renderReview('TEXT');
expect(await screen.findByLabelText(/IMPORT.HOURS/)).toBeTruthy();
});
});
+9 -5
View File
@@ -11,12 +11,14 @@ const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cde
// A card whose request fails or has no JSON body stays empty.
async function loadInto(path, set, pick = (data) => data) {
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`);
if (ok && data != null) set(pick(data));
if (!ok || data == null) return null;
set(pick(data));
return data;
}
export default function useDashboardData() {
const {reload: reloadUser} = useCurrentUserContext();
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, canSync: false, syncStarted: false});
const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]);
const [upNext, setUpNext] = useState([]);
@@ -60,9 +62,11 @@ export default function useDashboardData() {
const fetchUserData = async () => {
const {ok, status, data} = await reloadUser();
if (ok) {
// Games added by hand need no Steam ID; history and achievements come only from Steam.
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(), fetchSummary(),
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
// Games added by hand need no Steam ID; achievements come only from Steam. The history also comes
// from a PlayStation link, which games-count reports as canSync.
const [counts] = await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(),
fetchSummary(), ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
if (!data.steamId && counts?.canSync) await fetchActivity();
} else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE');
} else if (status !== 401) {
-41
View File
@@ -1,41 +0,0 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {startPsnImport} from '../utils/psn.js';
/**
* Returns `start()`: it asks Core to read the played PlayStation titles and create an import. Resolves to the import's
* id, or shows why not and resolves to null.
*/
export default function useStartPsnImport() {
const {t} = useTranslation();
return useCallback(async () => {
try {
const response = await startPsnImport();
if (response.status === 401) return null;
if (response.status === 204) {
toast.info(t('IMPORT.PSN_NO_TITLES'));
} else if (response.status === 404) {
toast.error(t('IMPORT.PSN_NOT_LINKED'));
} else if (response.status === 409) {
const body = await response.json().catch(() => null);
toast.error(t(body?.code === 'PSN_RELINK' ? 'IMPORT.PSN_RELINK' : 'IMPORT.RUNNING'));
} else if (response.status === 429) {
toast.error(t('IMPORT.PSN_BUSY'));
} else if (response.status === 502) {
toast.error(t('IMPORT.PSN_UNAVAILABLE'));
} else if (response.status === 503) {
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
} else if (!response.ok) {
toast.error(t('IMPORT.START_FAILED'));
} else {
return (await response.json()).id;
}
} catch (err) {
console.error('PSN import start error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
return null;
}, [t]);
}
+8
View File
@@ -33,6 +33,14 @@ describe('admin broadcast texts', () => {
});
describe('translation keys', () => {
it('importHistoryHasALabelForThePsnSource', () => {
const dir = path.resolve(import.meta.dirname, '../public/i18n');
for (const file of ['en.json', 'de.json']) {
const texts = JSON.parse(readFileSync(path.join(dir, file), 'utf8')).IMPORT;
expect(texts.SOURCE_PSN, file).toBeTruthy();
}
});
it('de_And_En_HaveTheSameKeys', () => {
const dir = path.resolve(import.meta.dirname, '../public/i18n');
const keys = (value, prefix = '') => Object.entries(value).flatMap(([key, child]) =>
+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/>);
+3 -2
View File
@@ -15,8 +15,9 @@ export const listImports = () => apiFetch(IMPORTS_URL());
export const getImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}`);
export const saveImport = (id, rows) =>
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows})});
// shownLineNos: the lines the user saw; a PlayStation review keeps the others open (the sync may add lines meanwhile).
export const saveImport = (id, rows, shownLineNos) =>
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows, shownLineNos})});
export const retryImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/retry`, {method: 'POST'});
+2 -1
View File
@@ -4,7 +4,8 @@ const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
/**
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST` or `VISIBILITY`; `unread` counts all unread notes.
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED` (the PSN
* kinds have no `displayName`); `unread` counts all unread notes.
*/
export const fetchInbox = () => request(INBOX());
-2
View File
@@ -11,8 +11,6 @@ export const linkPsn = (npsso) =>
export const unlinkPsn = () => apiFetch(PSN_URL(), {method: 'DELETE'});
export const startPsnImport = () => apiFetch(`${PSN_URL()}/imports`, {method: 'POST'});
// Where a signed-in user reads the NPSSO value.
export const NPSSO_PAGE_URL = 'https://ca.account.sony.com/api/v1/ssocookie';