feat(psn): show trophy sets on game detail and dashboard
Build, Test & Push Frontend / quality-check (push) Successful in 35s
Build, Test & Push Frontend / docker (push) Successful in 33s

Detail page groups achievements by set: Steam, then PSN trophies.
Tiles show the real count; a perfect game gets an amber trophy.
Dashboard card shows for PSN-only users; CSP allows PSN icons.
This commit is contained in:
lobo committed 2026-10-08 09:18:15 +02:00
1 parent 27c402fa34
commit e7b08cfb47
14 files changed
+349 -105

No files matched your search

+1 -1
View File
@@ -129,7 +129,7 @@ const Dashboard = () => {
</Card>
);
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, charts, change);
}
};
+10 -2
View File
@@ -242,17 +242,24 @@ describe('Dashboard', () => {
});
const asked = (path) => fetch.mock.calls.some(([url]) => url.includes(path));
it('loads the activity and the sync result, but not the achievements', async () => {
it('loads the activity, the achievements and the sync result, but no Steam profile', 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);
await waitFor(() => expect(asked('/api/library/achievements/summary')).toBe(true));
expect(asked('/api/library/steam-profile')).toBe(false);
});
it('shows the achievements card, which counts trophies too', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
expect(await screen.findByText('DASHBOARD.ACHIEVEMENTS.UNLOCKED')).toBeTruthy();
});
it('shows the sync button of the welcome card', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
@@ -267,6 +274,7 @@ describe('Dashboard', () => {
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
expect(asked('/api/library/activity')).toBe(false);
expect(asked('/api/library/sync-changes')).toBe(false);
expect(asked('/api/library/achievements/summary')).toBe(false);
expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull();
});
});
+2 -9
View File
@@ -1,7 +1,7 @@
import {useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, EyeOff, ShieldOff, Trophy} from 'lucide-react';
import {ArrowLeft, EyeOff, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
@@ -122,14 +122,7 @@ export default function GameDetail() {
</Card>
)}
{data.hasAchievements && !data.statsVisible && (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
)}
{data.hasAchievements && data.statsVisible && <GameAchievements game={data}/>}
{data.hasAchievements && <GameAchievements game={data}/>}
</>
)}
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
+17 -2
View File
@@ -129,9 +129,11 @@ describe('GameDetail', () => {
it('reports a broken achievement icon once and shows the empty tile', async () => {
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
iconGrayUrl: `https://x/${apiName}_gray.jpg`, kind: 'STEAM', setId: null});
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)});
completionPercent: 0, achievements: [achievement('A'), achievement('B')],
achievementSets: [{id: 'steam', kind: 'STEAM', total: 2, unlocked: 0, perfect: false, visible: true, listed: true}]},
{'POST /api/library/broken-images': json(204)});
const {container} = renderDetail();
await screen.findByText('A');
@@ -144,6 +146,19 @@ describe('GameDetail', () => {
expect(container.querySelector('img[src^="https://x/"]')).toBeNull();
});
it('shows the trophies of a PlayStation game and not the no-achievements card', async () => {
answer({...game, source: 'PSN', hasAchievements: true, statsVisible: true,
achievements: [{apiName: '0', displayName: 'Legend', unlocked: true, kind: 'PSN', setId: 'NPWR1_00',
trophyType: 'PLATINUM', iconUrl: 'https://psnobj.prod.dl.playstation.net/0.png'}],
achievementSets: [{id: 'NPWR1_00', kind: 'PSN', platform: 'PS5', name: 'Ghost', total: 1, unlocked: 1,
perfect: true, visible: true, listed: true}]});
renderDetail();
expect(await screen.findByText('Legend')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.TROPHIES')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.NO_ACHIEVEMENTS')).toBeNull();
});
it('links the speedrun.com page when Core found one', async () => {
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
renderDetail();
+2 -1
View File
@@ -14,6 +14,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {cn} from '@/lib/utils';
import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from '../components/AddGamePanel.jsx';
import GameCover from '../components/GameCover.jsx';
@@ -323,7 +324,7 @@ export default function GameLibrary() {
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className="size-3"/>
<Trophy className={cn('size-3', g.achievementsPerfect && 'text-amber-500')}/>
{g.achievementsUnlocked}/{g.achievementsTotal}
</Badge>
)}
+8
View File
@@ -87,6 +87,14 @@ describe('GameLibrary search', () => {
expect(screen.queryByText(/\/5$/)).toBeNull();
});
it('shows the real count of a game with a platinum trophy and marks it perfect', async () => {
answer([{...page.content[0], achievementsUnlocked: 30, achievementsTotal: 50, achievementsPerfect: true}]);
renderLibrary();
const badge = (await screen.findByText('30/50')).closest('[data-slot="badge"]');
expect(badge.querySelector('svg.text-amber-500')).not.toBeNull();
});
it('shows the newest search when an older answer comes last', async () => {
const later = [];
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});