From e7b08cfb470039d4f69e366cbb6a23f97cc3df16 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 8 Oct 2026 09:18:15 +0200 Subject: [PATCH] feat(psn): show trophy sets on game detail and dashboard 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. --- README.md | 13 +- nginx.conf | 2 +- public/i18n/de.json | 25 ++- public/i18n/en.json | 25 ++- src/components/GameAchievements.jsx | 210 +++++++++++++++-------- src/components/GameAchievements.test.jsx | 113 ++++++++++++ src/csp.test.js | 5 +- src/hooks/useDashboardData.js | 6 +- src/pages/Dashboard.jsx | 2 +- src/pages/Dashboard.test.jsx | 12 +- src/pages/GameDetail.jsx | 11 +- src/pages/GameDetail.test.jsx | 19 +- src/pages/GameLibrary.jsx | 3 +- src/pages/GameLibrary.test.jsx | 8 + 14 files changed, 349 insertions(+), 105 deletions(-) create mode 100644 src/components/GameAchievements.test.jsx diff --git a/README.md b/README.md index c156c50..2b143e5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ To Be Played is split into three repositories: in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 days ("this month: +N added, -N off the pile") and a random suggestion, Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked - total, perfect games, average completion and the 5 games closest to 100 %. + total, perfect games, average completion and the 5 games closest to 100 %. The card counts PlayStation trophies too + (platinum = perfect) and also shows for a user with only a PlayStation link. - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming " badge in the library, on the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in `pileGames`, so coming games are not on it; Core never picks them. @@ -87,6 +88,10 @@ To Be Played is split into three repositories: - **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on several waits for the user's pick. +- **PlayStation trophies** (slice 3): the game detail shows one card per set, Steam first, then PS5 and PS4 + (`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag; + a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the + next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game. - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when @@ -202,7 +207,8 @@ flowchart LR Core --> Igdb["IGDB API"] Core -->|"OAuth token"| Twitch["Twitch"] Core --> Speedrun["speedrun.com API"] - Browser -.->|"game art"| Images["Steam and IGDB image hosts"] + Core -->|"NPSSO sign-in, played titles, trophies"| Psn["PlayStation API (unofficial)"] + Browser -.->|"game art, trophy icons"| Images["Steam, IGDB and PlayStation (psnobj) image hosts"] Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"] Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"] Auth -->|"check_authentication"| SteamLogin @@ -210,7 +216,8 @@ flowchart LR - `useDragSort` (`src/hooks/useDragSort.js`) sorts a list with pointer events. Only the drag handle has `touch-action: none`, so the page still scrolls from a row. - nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly - (`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts. + (`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts (Steam, IGDB and, for trophy icons, + `psnobj.prod.dl.playstation.net`). - Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS). - Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the `X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again. diff --git a/nginx.conf b/nginx.conf index 814b11b..be87ce7 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,7 +3,7 @@ server { root /usr/share/nginx/html; # An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile). - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com https://psnobj.prod.dl.playstation.net; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/public/i18n/de.json b/public/i18n/de.json index 568efaa..8db92ba 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -312,8 +312,19 @@ "UNLOCKED": "Erreicht", "REMAINING": "Offen", "COMPLETION": "Fortschritt", - "NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.", + "NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge oder Trophäen.", "STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.", + "TROPHIES": "{{platform}}-Trophäen", + "TROPHIES_PENDING": "Die Trophäenliste wird mit dem nächsten Sync geladen.", + "TROPHY_TYPE": { + "BRONZE": "Bronze", + "SILVER": "Silber", + "GOLD": "Gold", + "PLATINUM": "Platin" + }, + "HIDDEN_TROPHY": "Versteckte Trophäe", + "RARITY": "{{percent}} % der Spieler", + "DLC": "DLC", "PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.", "SET_PLAYING": "Auf „Spiele ich“ setzen", "TTB": "Spieldauer laut IGDB", @@ -528,7 +539,7 @@ "REMOVE_YES": "Ja, entfernen", "REMOVE_NO": "Abbrechen", "REMOVED": "Spiel entfernt.", - "NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele.", + "NO_ACHIEVEMENTS": "Erfolge und Trophäen gibt es nur für Spiele von Steam oder PlayStation.", "SELECT": "{{name}} auswählen", "SELECTED": "{{count}} ausgewählt", "ADD_SELECTED": "Prüfen und hinzufügen" @@ -938,8 +949,8 @@ "OPEN_SETTINGS": "Einstellungen öffnen", "PART_NEW_GAMES_one": "{{count}} neues Spiel", "PART_NEW_GAMES_other": "{{count}} neue Spiele", - "PART_ACHIEVEMENTS_one": "{{count}} Erfolg", - "PART_ACHIEVEMENTS_other": "{{count}} Erfolge", + "PART_ACHIEVEMENTS_one": "{{count}} Erfolg oder Trophäe", + "PART_ACHIEVEMENTS_other": "{{count}} Erfolge oder Trophäen", "PART_STATUS_CHANGES_one": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_other": "{{count}} Statuswechsel", "PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag", @@ -1264,8 +1275,8 @@ "REMOVED_one": "{{count}} Spiel entfernt", "REMOVED_other": "{{count}} Spiele entfernt", "PLAYTIME": "{{time}} gespielt", - "ACHIEVEMENTS_one": "{{count}} neuer Erfolg", - "ACHIEVEMENTS_other": "{{count}} neue Erfolge", + "ACHIEVEMENTS_one": "{{count}} neuer Erfolg oder Trophäe", + "ACHIEVEMENTS_other": "{{count}} neue Erfolge oder Trophäen", "MOVES_one": "{{count}} Statuswechsel", "MOVES_other": "{{count}} Statuswechsel", "DETAILS": "Details", @@ -1276,7 +1287,7 @@ "ADDED": "Neue Spiele", "REMOVED": "Entfernte Spiele", "PLAYTIME": "Spielzeit", - "ACHIEVEMENTS": "Neue Erfolge", + "ACHIEVEMENTS": "Neue Erfolge oder Trophäen", "STATUS": "Statuswechsel" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index e2e139a..ddfd930 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -312,8 +312,19 @@ "UNLOCKED": "Unlocked", "REMAINING": "Remaining", "COMPLETION": "Completion", - "NO_ACHIEVEMENTS": "This game has no achievements on Steam.", + "NO_ACHIEVEMENTS": "This game has no achievements or trophies.", "STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.", + "TROPHIES": "{{platform}} trophies", + "TROPHIES_PENDING": "The trophy list loads with the next sync.", + "TROPHY_TYPE": { + "BRONZE": "Bronze", + "SILVER": "Silver", + "GOLD": "Gold", + "PLATINUM": "Platinum" + }, + "HIDDEN_TROPHY": "Hidden trophy", + "RARITY": "{{percent}} % of players", + "DLC": "DLC", "PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.", "SET_PLAYING": "Set to \"Playing\"", "TTB": "Time to beat (IGDB)", @@ -528,7 +539,7 @@ "REMOVE_YES": "Yes, remove", "REMOVE_NO": "Cancel", "REMOVED": "Game removed.", - "NO_ACHIEVEMENTS": "Achievements exist only for Steam games.", + "NO_ACHIEVEMENTS": "Achievements and trophies exist only for games from Steam or PlayStation.", "SELECT": "Select {{name}}", "SELECTED": "{{count}} selected", "ADD_SELECTED": "Check and add" @@ -938,8 +949,8 @@ "OPEN_SETTINGS": "Open settings", "PART_NEW_GAMES_one": "{{count}} new game", "PART_NEW_GAMES_other": "{{count}} new games", - "PART_ACHIEVEMENTS_one": "{{count}} achievement", - "PART_ACHIEVEMENTS_other": "{{count}} achievements", + "PART_ACHIEVEMENTS_one": "{{count}} achievement or trophy", + "PART_ACHIEVEMENTS_other": "{{count}} achievements or trophies", "PART_STATUS_CHANGES_one": "{{count}} status change", "PART_STATUS_CHANGES_other": "{{count}} status changes", "PART_WISHLIST_ADDS_one": "{{count}} wishlist add", @@ -1264,8 +1275,8 @@ "REMOVED_one": "{{count}} game removed", "REMOVED_other": "{{count}} games removed", "PLAYTIME": "{{time}} played", - "ACHIEVEMENTS_one": "{{count}} new achievement", - "ACHIEVEMENTS_other": "{{count}} new achievements", + "ACHIEVEMENTS_one": "{{count}} new achievement or trophy", + "ACHIEVEMENTS_other": "{{count}} new achievements or trophies", "MOVES_one": "{{count}} status change", "MOVES_other": "{{count}} status changes", "DETAILS": "Details", @@ -1276,7 +1287,7 @@ "ADDED": "New games", "REMOVED": "Removed games", "PLAYTIME": "Playtime", - "ACHIEVEMENTS": "New achievements", + "ACHIEVEMENTS": "New achievements or trophies", "STATUS": "Status changes" } } diff --git a/src/components/GameAchievements.jsx b/src/components/GameAchievements.jsx index 3d28ff5..db528bd 100644 --- a/src/components/GameAchievements.jsx +++ b/src/components/GameAchievements.jsx @@ -1,12 +1,21 @@ import {useMemo, useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {Trophy} from 'lucide-react'; +import {ShieldOff, Trophy} from 'lucide-react'; import {Card} from '@/components/ui/card'; import {cn} from '@/lib/utils'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {formatDate} from '../utils/format.js'; +const STEAM_SET = 'steam'; + +const TROPHY_COLORS = { + BRONZE: 'text-amber-700', + SILVER: 'text-slate-400', + GOLD: 'text-yellow-500', + PLATINUM: 'text-cyan-400', +}; + const formatUnlockedAt = (instant, locale) => { if (!instant) return ''; try { @@ -16,93 +25,160 @@ const formatUnlockedAt = (instant, locale) => { } }; -function AchievementIcon({appid, src, unlocked}) { +// Only Steam icons can be repaired: the broken-image report goes to the Steam icon store. +function AchievementIcon({appid, src, unlocked, steam}) { const [failed, setFailed] = useState(false); if (!src || failed) return
; return ( { - reportBrokenImage(appid, ACHIEVEMENT_ICONS); + if (steam) reportBrokenImage(appid, ACHIEVEMENT_ICONS); setFailed(true); }}/> ); } -export default function GameAchievements({game}) { - const {t, i18n} = useTranslation(); +// The base game (no group) first, then the DLC groups in their order; inside a group the earned ones first. +function sortAchievements(achievements) { + return [...achievements].sort((a, b) => { + if ((a.group ?? '') !== (b.group ?? '')) return (a.group ?? '').localeCompare(b.group ?? ''); + if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1; + if (a.unlocked && b.unlocked) { + return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? ''); + } + return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName); + }); +} - const sortedAchievements = useMemo(() => { - if (!game.achievements) return []; - return [...game.achievements].sort((a, b) => { - if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1; - if (a.unlocked && b.unlocked) { - return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? ''); - } - return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName); - }); - }, [game.achievements]); +function SetHeader({set}) { + const {t} = useTranslation(); + const steam = set.id === STEAM_SET; + return ( +
+

+ + + {steam ? t('GAME_DETAIL.ACHIEVEMENTS') : t('GAME_DETAIL.TROPHIES', {platform: set.platform})} + +

+ {set.unlocked} / {set.total} +
+ ); +} +function SetSummary({set}) { + const {t} = useTranslation(); + const percent = set.perfect ? 100 : set.total === 0 ? 0 : Math.round((set.unlocked * 1000) / set.total) / 10; const stats = [ - [t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked], - [t('GAME_DETAIL.REMAINING'), game.achievementsRemaining], - [t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`], + [t('GAME_DETAIL.UNLOCKED'), set.unlocked], + [t('GAME_DETAIL.REMAINING'), Math.max(0, set.total - set.unlocked)], + [t('GAME_DETAIL.COMPLETION'), `${percent}%`], ]; + return ( + <> +
+
+
+
+ {stats.map(([label, value]) => ( +
+
{value}
+
{label}
+
+ ))} +
+ + ); +} +function AchievementItem({game, achievement}) { + const {t, i18n} = useTranslation(); + const steam = achievement.kind !== 'PSN'; + const icon = achievement.unlocked ? achievement.iconUrl : achievement.iconGrayUrl; + const name = achievement.displayName + ?? (achievement.hidden && !steam ? t('GAME_DETAIL.HIDDEN_TROPHY') : achievement.apiName); + const trophyLabel = achievement.trophyType && t(`GAME_DETAIL.TROPHY_TYPE.${achievement.trophyType}`); + return ( +
  • + +
    +
    + {achievement.trophyType && ( + + {trophyLabel} + + )} +
    {name}
    + {achievement.group && ( + + {t('GAME_DETAIL.DLC')} + + )} +
    + {achievement.description && ( +
    {achievement.description}
    + )} + {achievement.rarity != null && ( +
    + {t('GAME_DETAIL.RARITY', {percent: Math.round(achievement.rarity * 10) / 10})} +
    + )} + {achievement.unlocked && achievement.unlockedAt && ( +
    + {formatUnlockedAt(achievement.unlockedAt, i18n.language)} +
    + )} +
    +
  • + ); +} + +function AchievementSet({game, set, achievements}) { + const {t} = useTranslation(); + const sorted = useMemo(() => sortAchievements(achievements), [achievements]); + const steam = set.id === STEAM_SET; + + if (steam && !set.visible) { + return ( + + + {t('GAME_DETAIL.STATS_HIDDEN')} + + ); + } return ( <> -
    -

    - - {t('GAME_DETAIL.ACHIEVEMENTS')} -

    - - {game.achievementsUnlocked} / {game.achievementsTotal} - -
    -
    -
    -
    -
    - {stats.map(([label, value]) => ( -
    -
    {value}
    -
    {label}
    -
    - ))} -
    + + {!steam && set.name &&

    {set.name}

    } + + {!set.listed &&

    {t('GAME_DETAIL.TROPHIES_PENDING')}

    } -
      - {sortedAchievements.map((a) => { - const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl; - return ( -
    • - -
      -
      {a.displayName ?? a.apiName}
      - {a.description && ( -
      {a.description}
      - )} - {a.unlocked && a.unlockedAt && ( -
      - {formatUnlockedAt(a.unlockedAt, i18n.language)} -
      - )} -
      -
    • - ); - })} + {sorted.map((achievement) => ( + + ))}
    ); } + +export default function GameAchievements({game}) { + const bySet = useMemo(() => { + const groups = new Map(); + for (const a of game.achievements ?? []) { + const id = a.setId ?? STEAM_SET; + if (!groups.has(id)) groups.set(id, []); + groups.get(id).push(a); + } + return groups; + }, [game.achievements]); + + return (game.achievementSets ?? []).map((set) => ( + + )); +} diff --git a/src/components/GameAchievements.test.jsx b/src/components/GameAchievements.test.jsx new file mode 100644 index 0000000..7520a0b --- /dev/null +++ b/src/components/GameAchievements.test.jsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import GameAchievements from './GameAchievements.jsx'; +import {reportBrokenImage} from '../utils/brokenImages.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('../utils/brokenImages.js', () => ({ACHIEVEMENT_ICONS: 'ACHIEVEMENT_ICONS', reportBrokenImage: vi.fn()})); + +const set = (id, extra = {}) => ({id, kind: id === 'steam' ? 'STEAM' : 'PSN', platform: null, name: null, total: 2, + unlocked: 1, perfect: false, visible: true, listed: true, ...extra}); +const steam = (apiName, extra = {}) => ({apiName, displayName: `Steam ${apiName}`, unlocked: false, kind: 'STEAM', + setId: null, iconUrl: 'https://cdn.steamstatic.com/a.jpg', iconGrayUrl: 'https://cdn.steamstatic.com/b.jpg', ...extra}); +const trophy = (setId, apiName, extra = {}) => ({apiName, displayName: `Trophy ${setId} ${apiName}`, unlocked: false, + kind: 'PSN', setId, trophyType: 'BRONZE', iconUrl: 'https://psnobj.prod.dl.playstation.net/t.png', + iconGrayUrl: 'https://psnobj.prod.dl.playstation.net/t.png', ...extra}); + +const renderGame = (game) => render(); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('GameAchievements', () => { + it('shows one card per set in the order of achievementSets', () => { + renderGame({ + achievementSets: [set('steam'), set('NPWR1_00', {platform: 'PS5'}), set('NPWR2_00', {platform: 'PS4'})], + achievements: [trophy('NPWR2_00', '1'), steam('A'), trophy('NPWR1_00', '1')], + }); + + const headings = screen.getAllByRole('heading', {level: 2}).map((heading) => heading.textContent); + expect(headings).toEqual(['GAME_DETAIL.ACHIEVEMENTS', 'GAME_DETAIL.TROPHIES', 'GAME_DETAIL.TROPHIES']); + const names = screen.getAllByRole('listitem').map((item) => item.textContent); + expect(names[0]).toContain('Steam A'); + expect(names[1]).toContain('Trophy NPWR1_00 1'); + expect(names[2]).toContain('Trophy NPWR2_00 1'); + }); + + it('lists the base game first and the DLC groups after it, whatever the earned state', () => { + renderGame({ + achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 3, unlocked: 1})], + achievements: [trophy('NPWR1_00', '1', {group: '002', unlocked: true, unlockedAt: '2026-01-02T00:00:00Z'}), + trophy('NPWR1_00', '2', {group: '001'}), trophy('NPWR1_00', '3', {group: null})], + }); + + expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + expect.stringContaining('Trophy NPWR1_00 3'), expect.stringContaining('Trophy NPWR1_00 2'), + expect.stringContaining('Trophy NPWR1_00 1')]); + }); + + it('titles the trophy type and marks DLC trophies and the rarity', () => { + renderGame({ + achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})], + achievements: [trophy('NPWR1_00', '1', {trophyType: 'GOLD', group: '001', rarity: 12.34})], + }); + + expect(screen.getByTitle('GAME_DETAIL.TROPHY_TYPE.GOLD')).toBeTruthy(); + expect(screen.getByText('GAME_DETAIL.DLC')).toBeTruthy(); + expect(screen.getByText('GAME_DETAIL.RARITY')).toBeTruthy(); + }); + + it('shows a hidden trophy that is not earned without a name', () => { + renderGame({ + achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})], + achievements: [trophy('NPWR1_00', '1', {displayName: null, description: null, hidden: true})], + }); + + expect(screen.getByText('GAME_DETAIL.HIDDEN_TROPHY')).toBeTruthy(); + }); + + it('shows the hidden-stats text for a Steam set Steam hides, and still lists the PlayStation set', () => { + renderGame({ + achievementSets: [set('steam', {visible: false, unlocked: 0}), set('NPWR1_00', {platform: 'PS5'})], + achievements: [steam('A'), trophy('NPWR1_00', '1')], + }); + + expect(screen.getByText('GAME_DETAIL.STATS_HIDDEN')).toBeTruthy(); + expect(screen.queryByText('Steam A')).toBeNull(); + expect(screen.getByText('Trophy NPWR1_00 1')).toBeTruthy(); + }); + + it('shows the pending text for a trophy set whose list is not stored yet', () => { + renderGame({achievementSets: [set('NPWR1_00', {platform: 'PS4', listed: false})], achievements: []}); + + expect(screen.getByText('GAME_DETAIL.TROPHIES_PENDING')).toBeTruthy(); + }); + + it('counts a perfect set as 100 percent, also with trophies missing', () => { + renderGame({ + achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 50, unlocked: 30, perfect: true})], + achievements: [], + }); + + expect(screen.getByText('100%')).toBeTruthy(); + expect(screen.getByText('30 / 50')).toBeTruthy(); + }); + + it('reports a broken Steam icon but not a broken PlayStation icon', () => { + const {container} = renderGame({ + achievementSets: [set('steam', {total: 1, unlocked: 0}), set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})], + achievements: [steam('A'), trophy('NPWR1_00', '1')], + }); + + const steamItem = screen.getByText('Steam A').closest('li'); + const psnItem = screen.getByText('Trophy NPWR1_00 1').closest('li'); + fireEvent.error(psnItem.querySelector('img')); + expect(reportBrokenImage).not.toHaveBeenCalled(); + fireEvent.error(steamItem.querySelector('img')); + expect(reportBrokenImage).toHaveBeenCalledTimes(1); + expect(container.querySelectorAll('img')).toHaveLength(0); + }); +}); diff --git a/src/csp.test.js b/src/csp.test.js index 78edb4e..0e3c92a 100644 --- a/src/csp.test.js +++ b/src/csp.test.js @@ -21,8 +21,9 @@ describe('Content Security Policy in nginx.conf', () => { const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1])); // Only links: a navigation needs no img-src. LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host)); - // Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures. - ['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com'] + // Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures, PlayStation trophy icons. + ['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com', + 'psnobj.prod.dl.playstation.net'] .forEach((host) => hosts.add(host)); for (const host of hosts) { diff --git a/src/hooks/useDashboardData.js b/src/hooks/useDashboardData.js index 5eeafc8..aaf4da5 100644 --- a/src/hooks/useDashboardData.js +++ b/src/hooks/useDashboardData.js @@ -62,11 +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; achievements come only from Steam. The history also comes - // from a PlayStation link, which games-count reports as canSync. + // Games added by hand need no Steam ID. The history and the achievements (Steam achievements, + // PlayStation trophies) also come 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(); + if (!data.steamId && counts?.canSync) await Promise.all([fetchActivity(), fetchAchievements()]); } else if (status === 0) { setError('INFO.ERROR.NETWORK_FAILURE'); } else if (status !== 401) { diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index ac98c4a..9fcc6fa 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -129,7 +129,7 @@ const Dashboard = () => { ); case 'ACTIVITY': return canSync && activity && ; - case 'ACHIEVEMENTS': return hasSteamId && achievements && ; + case 'ACHIEVEMENTS': return canSync && achievements && ; default: return statGroupTile(id, stats.stats, true, charts, change); } }; diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index cb6c9a4..4ab9aa4 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -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(); }); }); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 1a2b4cb..281e7ab 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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() { )} - {data.hasAchievements && !data.statsVisible && ( - - - {t('GAME_DETAIL.STATS_HIDDEN')} - - )} - - {data.hasAchievements && data.statsVisible && } + {data.hasAchievements && } )} {celebration && setCelebration(null)}/>} diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 38a3c51..d91e348 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -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(); diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index bae14b7..12440e4 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -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 && ( - + {g.achievementsUnlocked}/{g.achievementsTotal} )} diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 76dfb6a..c38682a 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -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});