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

+10 -3
View File
@@ -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 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, 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 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 <date>" badge in the library, on - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" 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 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. `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 - **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 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. 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 %), - **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 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 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 --> Igdb["IGDB API"]
Core -->|"OAuth token"| Twitch["Twitch"] Core -->|"OAuth token"| Twitch["Twitch"]
Core --> Speedrun["speedrun.com API"] 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 -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"] Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"]
Auth -->|"check_authentication"| SteamLogin 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. - `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 - 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). - 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 - 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. `X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again.
+1 -1
View File
@@ -3,7 +3,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
# An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile). # 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 X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+18 -7
View File
@@ -312,8 +312,19 @@
"UNLOCKED": "Erreicht", "UNLOCKED": "Erreicht",
"REMAINING": "Offen", "REMAINING": "Offen",
"COMPLETION": "Fortschritt", "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.", "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.", "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", "SET_PLAYING": "Auf „Spiele ich“ setzen",
"TTB": "Spieldauer laut IGDB", "TTB": "Spieldauer laut IGDB",
@@ -528,7 +539,7 @@
"REMOVE_YES": "Ja, entfernen", "REMOVE_YES": "Ja, entfernen",
"REMOVE_NO": "Abbrechen", "REMOVE_NO": "Abbrechen",
"REMOVED": "Spiel entfernt.", "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", "SELECT": "{{name}} auswählen",
"SELECTED": "{{count}} ausgewählt", "SELECTED": "{{count}} ausgewählt",
"ADD_SELECTED": "Prüfen und hinzufügen" "ADD_SELECTED": "Prüfen und hinzufügen"
@@ -938,8 +949,8 @@
"OPEN_SETTINGS": "Einstellungen öffnen", "OPEN_SETTINGS": "Einstellungen öffnen",
"PART_NEW_GAMES_one": "{{count}} neues Spiel", "PART_NEW_GAMES_one": "{{count}} neues Spiel",
"PART_NEW_GAMES_other": "{{count}} neue Spiele", "PART_NEW_GAMES_other": "{{count}} neue Spiele",
"PART_ACHIEVEMENTS_one": "{{count}} Erfolg", "PART_ACHIEVEMENTS_one": "{{count}} Erfolg oder Trophäe",
"PART_ACHIEVEMENTS_other": "{{count}} Erfolge", "PART_ACHIEVEMENTS_other": "{{count}} Erfolge oder Trophäen",
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag", "PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
@@ -1264,8 +1275,8 @@
"REMOVED_one": "{{count}} Spiel entfernt", "REMOVED_one": "{{count}} Spiel entfernt",
"REMOVED_other": "{{count}} Spiele entfernt", "REMOVED_other": "{{count}} Spiele entfernt",
"PLAYTIME": "{{time}} gespielt", "PLAYTIME": "{{time}} gespielt",
"ACHIEVEMENTS_one": "{{count}} neuer Erfolg", "ACHIEVEMENTS_one": "{{count}} neuer Erfolg oder Trophäe",
"ACHIEVEMENTS_other": "{{count}} neue Erfolge", "ACHIEVEMENTS_other": "{{count}} neue Erfolge oder Trophäen",
"MOVES_one": "{{count}} Statuswechsel", "MOVES_one": "{{count}} Statuswechsel",
"MOVES_other": "{{count}} Statuswechsel", "MOVES_other": "{{count}} Statuswechsel",
"DETAILS": "Details", "DETAILS": "Details",
@@ -1276,7 +1287,7 @@
"ADDED": "Neue Spiele", "ADDED": "Neue Spiele",
"REMOVED": "Entfernte Spiele", "REMOVED": "Entfernte Spiele",
"PLAYTIME": "Spielzeit", "PLAYTIME": "Spielzeit",
"ACHIEVEMENTS": "Neue Erfolge", "ACHIEVEMENTS": "Neue Erfolge oder Trophäen",
"STATUS": "Statuswechsel" "STATUS": "Statuswechsel"
} }
} }
+18 -7
View File
@@ -312,8 +312,19 @@
"UNLOCKED": "Unlocked", "UNLOCKED": "Unlocked",
"REMAINING": "Remaining", "REMAINING": "Remaining",
"COMPLETION": "Completion", "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.", "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.", "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\"", "SET_PLAYING": "Set to \"Playing\"",
"TTB": "Time to beat (IGDB)", "TTB": "Time to beat (IGDB)",
@@ -528,7 +539,7 @@
"REMOVE_YES": "Yes, remove", "REMOVE_YES": "Yes, remove",
"REMOVE_NO": "Cancel", "REMOVE_NO": "Cancel",
"REMOVED": "Game removed.", "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}}", "SELECT": "Select {{name}}",
"SELECTED": "{{count}} selected", "SELECTED": "{{count}} selected",
"ADD_SELECTED": "Check and add" "ADD_SELECTED": "Check and add"
@@ -938,8 +949,8 @@
"OPEN_SETTINGS": "Open settings", "OPEN_SETTINGS": "Open settings",
"PART_NEW_GAMES_one": "{{count}} new game", "PART_NEW_GAMES_one": "{{count}} new game",
"PART_NEW_GAMES_other": "{{count}} new games", "PART_NEW_GAMES_other": "{{count}} new games",
"PART_ACHIEVEMENTS_one": "{{count}} achievement", "PART_ACHIEVEMENTS_one": "{{count}} achievement or trophy",
"PART_ACHIEVEMENTS_other": "{{count}} achievements", "PART_ACHIEVEMENTS_other": "{{count}} achievements or trophies",
"PART_STATUS_CHANGES_one": "{{count}} status change", "PART_STATUS_CHANGES_one": "{{count}} status change",
"PART_STATUS_CHANGES_other": "{{count}} status changes", "PART_STATUS_CHANGES_other": "{{count}} status changes",
"PART_WISHLIST_ADDS_one": "{{count}} wishlist add", "PART_WISHLIST_ADDS_one": "{{count}} wishlist add",
@@ -1264,8 +1275,8 @@
"REMOVED_one": "{{count}} game removed", "REMOVED_one": "{{count}} game removed",
"REMOVED_other": "{{count}} games removed", "REMOVED_other": "{{count}} games removed",
"PLAYTIME": "{{time}} played", "PLAYTIME": "{{time}} played",
"ACHIEVEMENTS_one": "{{count}} new achievement", "ACHIEVEMENTS_one": "{{count}} new achievement or trophy",
"ACHIEVEMENTS_other": "{{count}} new achievements", "ACHIEVEMENTS_other": "{{count}} new achievements or trophies",
"MOVES_one": "{{count}} status change", "MOVES_one": "{{count}} status change",
"MOVES_other": "{{count}} status changes", "MOVES_other": "{{count}} status changes",
"DETAILS": "Details", "DETAILS": "Details",
@@ -1276,7 +1287,7 @@
"ADDED": "New games", "ADDED": "New games",
"REMOVED": "Removed games", "REMOVED": "Removed games",
"PLAYTIME": "Playtime", "PLAYTIME": "Playtime",
"ACHIEVEMENTS": "New achievements", "ACHIEVEMENTS": "New achievements or trophies",
"STATUS": "Status changes" "STATUS": "Status changes"
} }
} }
+143 -67
View File
@@ -1,12 +1,21 @@
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react'; import {ShieldOff, Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {formatDate} from '../utils/format.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) => { const formatUnlockedAt = (instant, locale) => {
if (!instant) return ''; if (!instant) return '';
try { 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); const [failed, setFailed] = useState(false);
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>; if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
return ( return (
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')} <img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
onError={() => { onError={() => {
reportBrokenImage(appid, ACHIEVEMENT_ICONS); if (steam) reportBrokenImage(appid, ACHIEVEMENT_ICONS);
setFailed(true); setFailed(true);
}}/> }}/>
); );
} }
export default function GameAchievements({game}) { // The base game (no group) first, then the DLC groups in their order; inside a group the earned ones first.
const {t, i18n} = useTranslation(); 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(() => { function SetHeader({set}) {
if (!game.achievements) return []; const {t} = useTranslation();
return [...game.achievements].sort((a, b) => { const steam = set.id === STEAM_SET;
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1; return (
if (a.unlocked && b.unlocked) { <div className="flex items-baseline justify-between gap-3">
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? ''); <h2 className="flex min-w-0 items-center gap-2 text-lg font-semibold">
} <Trophy className="size-4 shrink-0 text-amber-500"/>
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName); <span className="truncate">
}); {steam ? t('GAME_DETAIL.ACHIEVEMENTS') : t('GAME_DETAIL.TROPHIES', {platform: set.platform})}
}, [game.achievements]); </span>
</h2>
<span className="text-sm text-muted-foreground tabular-nums">{set.unlocked} / {set.total}</span>
</div>
);
}
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 = [ const stats = [
[t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked], [t('GAME_DETAIL.UNLOCKED'), set.unlocked],
[t('GAME_DETAIL.REMAINING'), game.achievementsRemaining], [t('GAME_DETAIL.REMAINING'), Math.max(0, set.total - set.unlocked)],
[t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`], [t('GAME_DETAIL.COMPLETION'), `${percent}%`],
]; ];
return (
<>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all"
style={{width: `${Math.min(100, percent)}%`}}/>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
{stats.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</>
);
}
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 (
<li className={cn('flex items-start gap-3 rounded-xl border bg-card p-3',
achievement.unlocked ? 'border-primary/40' : 'opacity-60')}>
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={achievement.unlocked} steam={steam}/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{achievement.trophyType && (
<Trophy className={cn('size-3.5 shrink-0', TROPHY_COLORS[achievement.trophyType])}
aria-label={trophyLabel}>
<title>{trophyLabel}</title>
</Trophy>
)}
<div className="truncate font-medium">{name}</div>
{achievement.group && (
<span className="shrink-0 rounded bg-muted px-1.5 text-[10px] font-semibold">
{t('GAME_DETAIL.DLC')}
</span>
)}
</div>
{achievement.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{achievement.description}</div>
)}
{achievement.rarity != null && (
<div className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('GAME_DETAIL.RARITY', {percent: Math.round(achievement.rarity * 10) / 10})}
</div>
)}
{achievement.unlocked && achievement.unlockedAt && (
<div className="mt-1 text-xs text-primary tabular-nums">
{formatUnlockedAt(achievement.unlockedAt, i18n.language)}
</div>
)}
</div>
</li>
);
}
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 (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
);
}
return ( return (
<> <>
<Card className="p-6 gap-5"> <Card className="p-6 gap-5">
<div className="flex items-baseline justify-between"> <SetHeader set={set}/>
<h2 className="flex items-center gap-2 text-lg font-semibold"> {!steam && set.name && <p className="-mt-3 truncate text-sm text-muted-foreground">{set.name}</p>}
<Trophy className="size-4 text-amber-500"/> <SetSummary set={set}/>
{t('GAME_DETAIL.ACHIEVEMENTS')} {!set.listed && <p className="text-sm text-muted-foreground">{t('GAME_DETAIL.TROPHIES_PENDING')}</p>}
</h2>
<span className="text-sm text-muted-foreground tabular-nums">
{game.achievementsUnlocked} / {game.achievementsTotal}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{width: `${Math.min(100, game.completionPercent)}%`}}
/>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
{stats.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</Card> </Card>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{sortedAchievements.map((a) => { {sorted.map((achievement) => (
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl; <AchievementItem key={`${achievement.setId ?? STEAM_SET}:${achievement.apiName}`} game={game}
return ( achievement={achievement}/>
<li ))}
key={a.apiName}
className={cn(
'flex items-start gap-3 rounded-xl border bg-card p-3',
a.unlocked ? 'border-primary/40' : 'opacity-60',
)}
>
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={a.unlocked}/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
{a.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
)}
{a.unlocked && a.unlockedAt && (
<div className="mt-1 text-xs text-primary tabular-nums">
{formatUnlockedAt(a.unlockedAt, i18n.language)}
</div>
)}
</div>
</li>
);
})}
</ul> </ul>
</> </>
); );
} }
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) => (
<AchievementSet key={set.id} game={game} set={set} achievements={bySet.get(set.id) ?? []}/>
));
}
+113
View File
@@ -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(<GameAchievements game={{appid: 220, ...game}}/>);
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);
});
});
+3 -2
View File
@@ -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])); const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1]));
// Only links: a navigation needs no img-src. // Only links: a navigation needs no img-src.
LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host)); LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host));
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures. // 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'] ['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com',
'psnobj.prod.dl.playstation.net']
.forEach((host) => hosts.add(host)); .forEach((host) => hosts.add(host));
for (const host of hosts) { for (const host of hosts) {
+3 -3
View File
@@ -62,11 +62,11 @@ export default function useDashboardData() {
const fetchUserData = async () => { const fetchUserData = async () => {
const {ok, status, data} = await reloadUser(); const {ok, status, data} = await reloadUser();
if (ok) { if (ok) {
// Games added by hand need no Steam ID; achievements come only from Steam. The history also comes // Games added by hand need no Steam ID. The history and the achievements (Steam achievements,
// from a PlayStation link, which games-count reports as canSync. // PlayStation trophies) also come from a PlayStation link, which games-count reports as canSync.
const [counts] = await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(), const [counts] = await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(),
fetchSummary(), ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]); 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) { } else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE'); setError('INFO.ERROR.NETWORK_FAILURE');
} else if (status !== 401) { } else if (status !== 401) {
+1 -1
View File
@@ -129,7 +129,7 @@ const Dashboard = () => {
</Card> </Card>
); );
case 'ACTIVITY': return canSync && 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}/>; case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, charts, change); 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)); 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)); fetchRoutes(psnOnlyRoutes(true));
renderDashboard(); renderDashboard();
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy(); expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
await waitFor(() => expect(asked('/api/library/activity')).toBe(true)); await waitFor(() => expect(asked('/api/library/activity')).toBe(true));
await waitFor(() => expect(asked('/api/library/sync-changes')).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); 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 () => { it('shows the sync button of the welcome card', async () => {
fetchRoutes(psnOnlyRoutes(true)); fetchRoutes(psnOnlyRoutes(true));
renderDashboard(); renderDashboard();
@@ -267,6 +274,7 @@ describe('Dashboard', () => {
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy(); expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
expect(asked('/api/library/activity')).toBe(false); expect(asked('/api/library/activity')).toBe(false);
expect(asked('/api/library/sync-changes')).toBe(false); expect(asked('/api/library/sync-changes')).toBe(false);
expect(asked('/api/library/achievements/summary')).toBe(false);
expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull(); expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull();
}); });
}); });
+2 -9
View File
@@ -1,7 +1,7 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom'; import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
@@ -122,14 +122,7 @@ export default function GameDetail() {
</Card> </Card>
)} )}
{data.hasAchievements && !data.statsVisible && ( {data.hasAchievements && <GameAchievements game={data}/>}
<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}/>}
</> </>
)} )}
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>} {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 () => { 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`, 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, 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(); const {container} = renderDetail();
await screen.findByText('A'); await screen.findByText('A');
@@ -144,6 +146,19 @@ describe('GameDetail', () => {
expect(container.querySelector('img[src^="https://x/"]')).toBeNull(); 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 () => { it('links the speedrun.com page when Core found one', async () => {
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'}); answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
renderDetail(); renderDetail();
+2 -1
View File
@@ -14,6 +14,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {cn} from '@/lib/utils';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from '../components/AddGamePanel.jsx'; import AddGamePanel from '../components/AddGamePanel.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
@@ -323,7 +324,7 @@ export default function GameLibrary() {
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( {g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums" <Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}> 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} {g.achievementsUnlocked}/{g.achievementsTotal}
</Badge> </Badge>
)} )}
+8
View File
@@ -87,6 +87,14 @@ describe('GameLibrary search', () => {
expect(screen.queryByText(/\/5$/)).toBeNull(); 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 () => { it('shows the newest search when an older answer comes last', async () => {
const later = []; const later = [];
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1}); const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});