diff --git a/README.md b/README.md index 862bf79..547c4b8 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,17 @@ To Be Played is split into three repositories: ## Features - **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion, - Steam avatar and a banner from the most played game of the last two weeks. + 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 %. - **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any), high priority first, reroll without repeats, and "Start playing" in one click. - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - in library, rating or priority, paging. + in library, rating or priority, paging. Each row shows the unlocked achievements. - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - and a hint when Steam shows recent playtime for a game still in the backlog. + a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when + every achievement is unlocked. - **Wishlist** with store price and discount. -- **Public profile** at `/u/`, opt-in in the settings. +- **Public profile** at `/u/`, opt-in in the settings, with perfect games and average completion. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. Saved in Auth, so it follows you to every device. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout. @@ -83,7 +85,6 @@ npm run build ## Roadmap - Mood filter for "What to play next". -- Achievement progress on the library cards and a library-wide achievement page. - Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in `nginx.conf`. - Installable PWA. diff --git a/public/i18n/de.json b/public/i18n/de.json index c440a40..91b4b38 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -134,7 +134,18 @@ "ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.", "RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)", "BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist", - "OPEN_PICKER": "Such mir was aus" + "OPEN_PICKER": "Such mir was aus", + "ACHIEVEMENTS": { + "TITLE": "Erfolge", + "UNLOCKED": "Freigeschaltet", + "PERFECT": "Perfekte Spiele", + "AVERAGE": "Ø Abschluss", + "CLOSEST": "Fast bei 100 %", + "NONE": "Noch keine Erfolge freigeschaltet.", + "PENDING_one": "Für {{count}} Spiel fehlen noch Erfolgsdaten. Sie werden nach jedem Steam-Sync geladen.", + "PENDING_other": "Für {{count}} Spiele fehlen noch Erfolgsdaten. Sie werden nach jedem Steam-Sync geladen.", + "HIDDEN": "Steam zeigt deine Erfolge nicht. Stell in den Steam-Privatsphäre-Einstellungen „Spieldetails“ auf öffentlich." + } }, "LIBRARY": { "TITLE": "Spielebibliothek", @@ -163,7 +174,8 @@ "NO_MATCH": "Keine Spiele für diesen Filter.", "SORT_RATING": "Beste Bewertung", "SORT_PRIORITY": "Höchste Priorität", - "FILTER_SHORT": "Kurz (bis 10 h)" + "FILTER_SHORT": "Kurz (bis 10 h)", + "ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen" }, "GAME_DETAIL": { "BACK": "Zurück", @@ -187,7 +199,9 @@ "TTB_COMPLETELY": "100 %", "SET_BACKGROUND": "Als Hintergrund", "REMOVE_BACKGROUND": "Hintergrund entfernen", - "SET_BANNER": "Als Banner" + "SET_BANNER": "Als Banner", + "ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.", + "SET_COMPLETED": "Auf 100 % setzen" }, "LANDING": { "STATS_TITLE": "Community-Statistiken", @@ -268,7 +282,9 @@ "FINISHED_GAMES": "Durchgespielte Spiele", "NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.", "ERROR_LOAD": "Profil konnte nicht geladen werden.", - "TO_START": "Zur Startseite" + "TO_START": "Zur Startseite", + "PERFECT_GAMES": "Perfekte Spiele", + "AVERAGE_COMPLETION": "Ø Abschluss" }, "PLAY_NEXT": { "TITLE": "Was spiele ich als Nächstes?", diff --git a/public/i18n/en.json b/public/i18n/en.json index 4029c90..53eabd5 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -134,7 +134,18 @@ "ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.", "RECENTLY_PLAYED": "Recently played (2 weeks)", "BACKLOG_HOURS": "≈ {{time}} of play to clear it", - "OPEN_PICKER": "Pick for me" + "OPEN_PICKER": "Pick for me", + "ACHIEVEMENTS": { + "TITLE": "Achievements", + "UNLOCKED": "Unlocked", + "PERFECT": "Perfect games", + "AVERAGE": "Avg. completion", + "CLOSEST": "Closest to 100 %", + "NONE": "No achievements unlocked yet.", + "PENDING_one": "No achievement data yet for {{count}} game. It is fetched after each Steam sync.", + "PENDING_other": "No achievement data yet for {{count}} games. It is fetched after each Steam sync.", + "HIDDEN": "Steam hides your achievements. Set \"Game details\" to public in your Steam privacy settings." + } }, "LIBRARY": { "TITLE": "Game Library", @@ -163,7 +174,8 @@ "NO_MATCH": "No games match this filter.", "SORT_RATING": "Best rated", "SORT_PRIORITY": "Highest priority", - "FILTER_SHORT": "Short (up to 10 h)" + "FILTER_SHORT": "Short (up to 10 h)", + "ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements" }, "GAME_DETAIL": { "BACK": "Back", @@ -187,7 +199,9 @@ "TTB_COMPLETELY": "100%", "SET_BACKGROUND": "Use as background", "REMOVE_BACKGROUND": "Remove background", - "SET_BANNER": "Use as banner" + "SET_BANNER": "Use as banner", + "ALL_UNLOCKED": "You unlocked every achievement of this game.", + "SET_COMPLETED": "Set to 100 %" }, "LANDING": { "STATS_TITLE": "Community stats", @@ -268,7 +282,9 @@ "FINISHED_GAMES": "Finished games", "NOT_FOUND": "This profile does not exist or is private.", "ERROR_LOAD": "Could not load the profile.", - "TO_START": "To the start page" + "TO_START": "To the start page", + "PERFECT_GAMES": "Perfect games", + "AVERAGE_COMPLETION": "Avg. completion" }, "PLAY_NEXT": { "TITLE": "What to play next", diff --git a/src/components/AchievementsCard.jsx b/src/components/AchievementsCard.jsx new file mode 100644 index 0000000..b05782d --- /dev/null +++ b/src/components/AchievementsCard.jsx @@ -0,0 +1,87 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Trophy} from 'lucide-react'; + +import {Card} from '@/components/ui/card'; +import GameCover from './GameCover.jsx'; + +/** + * @param summary the answer of GET /api/library/achievements/summary + */ +export default function AchievementsCard({summary}) { + const {t, i18n} = useTranslation(); + const closest = summary.closest ?? []; + const started = summary.startedGames ?? 0; + const pending = summary.pendingGames ?? 0; + const hidden = summary.hiddenGames ?? 0; + // A private profile keeps games pending; the privacy hint explains that better than the pending count. + const steamHidesAll = hidden > 0 && started === 0; + + const stats = [ + [(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')], + [(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')], + [summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–', + t('DASHBOARD.ACHIEVEMENTS.AVERAGE')], + ]; + + return ( + +

+ + {t('DASHBOARD.ACHIEVEMENTS.TITLE')} +

+ +
+ {stats.map(([value, label]) => ( +
+
{value}
+
{label}
+
+ ))} +
+ + {started === 0 && pending === 0 && hidden === 0 && ( +

{t('DASHBOARD.ACHIEVEMENTS.NONE')}

+ )} + + {closest.length > 0 && ( +
+

{t('DASHBOARD.ACHIEVEMENTS.CLOSEST')}

+
    + {closest.map((game) => ( +
  • + + +
    +
    + + {game.name ?? `App ${game.appid}`} + + + {game.unlocked} / {game.total} + +
    +
    +
    +
    +
    + +
  • + ))} +
+
+ )} + + {pending > 0 && !steamHidesAll && ( +

{t('DASHBOARD.ACHIEVEMENTS.PENDING', {count: pending})}

+ )} + {steamHidesAll && ( +

{t('DASHBOARD.ACHIEVEMENTS.HIDDEN')}

+ )} +
+ ); +} diff --git a/src/components/AchievementsCard.test.jsx b/src/components/AchievementsCard.test.jsx new file mode 100644 index 0000000..419e18d --- /dev/null +++ b/src/components/AchievementsCard.test.jsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import AchievementsCard from './AchievementsCard.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +const summary = (extra = {}) => ({ + unlocked: 24, startedGames: 4, perfectGames: 2, averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, + closest: [{appid: 20, name: 'Portal 2', unlocked: 3, total: 4}], ...extra, +}); + +const renderCard = (data) => render(); + +describe('AchievementsCard', () => { + afterEach(() => { + cleanup(); + }); + + it('shows the totals and links the games closest to 100 %', () => { + renderCard(summary()); + + expect(screen.getByText('24')).toBeTruthy(); + expect(screen.getByText('2')).toBeTruthy(); + expect(screen.getByText('71,3 %')).toBeTruthy(); + expect(screen.getByRole('link', {name: /Portal 2/}).getAttribute('href')).toBe('/library/20'); + expect(screen.getByText('3 / 4')).toBeTruthy(); + expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull(); + }); + + it('says how many games are still syncing', () => { + renderCard(summary({pendingGames: 7})); + + expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.PENDING:7')).toBeTruthy(); + }); + + it('explains hidden achievements when nothing is visible', () => { + renderCard(summary({unlocked: 0, startedGames: 0, perfectGames: 0, averageCompletion: null, hiddenGames: 5, + pendingGames: 200, closest: []})); + + expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.HIDDEN')).toBeTruthy(); + expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull(); + expect(screen.getByText('–')).toBeTruthy(); + }); + + it('says so when nothing is unlocked yet', () => { + renderCard(summary({unlocked: 0, startedGames: 0, perfectGames: 0, averageCompletion: null, closest: []})); + + expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy(); + }); +}); diff --git a/src/components/CompletedHint.jsx b/src/components/CompletedHint.jsx new file mode 100644 index 0000000..391f58d --- /dev/null +++ b/src/components/CompletedHint.jsx @@ -0,0 +1,59 @@ +import {useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import {Loader2, Trophy} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {saveGameStatus} from '../utils/backlog.js'; + +/** + * Shown for a game with every achievement unlocked that is not marked as 100 % yet. + * + * @param onChanged gets the saved backlog entry + */ +export default function CompletedHint({game, onChanged}) { + const {t} = useTranslation(); + const expireSession = useSessionExpired(); + const [busy, setBusy] = useState(false); + + const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0 + && game.achievementsUnlocked >= game.achievementsTotal; + if (!allUnlocked || game.status === 'COMPLETED') return null; + + const setCompleted = async () => { + setBusy(true); + try { + const response = await saveGameStatus(game.appid, 'COMPLETED'); + if (response.status === 401) { + expireSession(); + return; + } + if (!response.ok) { + toast.error(t('BACKLOG.SAVE_FAILED')); + return; + } + onChanged(await response.json()); + toast.success(t('BACKLOG.SAVED')); + } catch (err) { + console.error('set completed error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return ( + +
+ +

{t('GAME_DETAIL.ALL_UNLOCKED')}

+
+ +
+ ); +} diff --git a/src/components/CompletedHint.test.jsx b/src/components/CompletedHint.test.jsx new file mode 100644 index 0000000..3acc9e1 --- /dev/null +++ b/src/components/CompletedHint.test.jsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import CompletedHint from './CompletedHint.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const game = {appid: 220, status: 'FINISHED', hasAchievements: true, statsVisible: true, achievementsTotal: 10, achievementsUnlocked: 10}; +const renderHint = (props) => render( {}} {...props}/>); + +describe('CompletedHint', () => { + beforeEach(() => { + localStorage.setItem('tbp_jwt', 'token'); + globalThis.fetch = vi.fn(async (url, init) => ({ + ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}), + })); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it.each([ + ['a game already at 100 %', {...game, status: 'COMPLETED'}], + ['a game with achievements left', {...game, achievementsUnlocked: 9}], + ['a game without achievements', {...game, hasAchievements: false, achievementsTotal: 0, achievementsUnlocked: 0}], + ['a game whose achievements Steam hides', {...game, statsVisible: false}], + ])('is hidden for %s', (_, other) => { + renderHint({game: other}); + + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('sets only the status to COMPLETED', async () => { + const onChanged = vi.fn(); + renderHint({onChanged}); + + fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})); + + await waitFor(() => expect(onChanged).toHaveBeenCalledWith({status: 'COMPLETED', rating: 5, notes: 'keep', priority: null})); + const [url, init] = fetch.mock.calls[0]; + expect(url).toMatch(/\/api\/library\/games\/220\/status$/); + expect(JSON.parse(init.body)).toEqual({status: 'COMPLETED'}); + }); +}); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 8a03006..e9c72d3 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -8,6 +8,7 @@ import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; +import AchievementsCard from '../components/AchievementsCard.jsx'; import ActivityCard from '../components/ActivityCard.jsx'; import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx'; import GameCover from '../components/GameCover.jsx'; @@ -46,6 +47,7 @@ const Dashboard = () => { const [playing, setPlaying] = useState([]); const [summary, setSummary] = useState(null); const [activity, setActivity] = useState(null); + const [achievements, setAchievements] = useState(null); const [summaryFailed, setSummaryFailed] = useState(false); const [shuffling, setShuffling] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -137,6 +139,21 @@ const Dashboard = () => { } }, [expireSession]); + const fetchAchievements = useCallback(async () => { + try { + const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: authHeaders()}); + if (r.status === 401) { + expireSession(); + return; + } + if (r.ok) { + setAchievements(await r.json()); + } + } catch (err) { + console.error('Error fetching achievements:', err); + } + }, [expireSession]); + const shuffleSuggestion = async () => { setShuffling(true); if (!await fetchSummary(summary?.suggestion?.appid)) { @@ -151,7 +168,8 @@ const Dashboard = () => { fetchPlaying(); fetchSummary(); fetchActivity(); - }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]); + fetchAchievements(); + }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); useEffect(() => { const fetchUserData = async () => { @@ -171,7 +189,7 @@ const Dashboard = () => { setUserData(data); if (data.steamId) { - await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity()]); + await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]); } } else if (profileResponse.status === 401) { expireSession(); @@ -187,7 +205,7 @@ const Dashboard = () => { }; fetchUserData(); - }, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]); + }, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); const hasSteamId = !!userData?.steamId; const {appearance} = usePreferences(); @@ -340,6 +358,7 @@ const Dashboard = () => { )} {hasSteamId && activity && } + {hasSteamId && achievements && } ); }; diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index b715914..c8ab0e6 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -56,6 +56,8 @@ describe('Dashboard', () => { if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [ {appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}); if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true}); + if (url.includes('/api/library/achievements/summary')) return ok({unlocked: 24, startedGames: 4, perfectGames: 2, + averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}); return ok({content: [], total: 0, byStatus: {}}); }); return render( @@ -72,6 +74,13 @@ describe('Dashboard', () => { expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull(); }); + it('shows the achievement summary', async () => { + mockDashboard(); + + expect(await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE')).toBeTruthy(); + expect(screen.getByText('71,3 %')).toBeTruthy(); + }); + it('shows the picked banner game instead', async () => { localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620})); const container = mockDashboard(); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index cb6c11b..e064cad 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -11,6 +11,7 @@ import {cn} from '@/lib/utils'; import BacklogCard from '../components/BacklogCard.jsx'; import GameCover from '../components/GameCover.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx'; +import CompletedHint from '../components/CompletedHint.jsx'; import PlayingHint from '../components/PlayingHint.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx'; import {formatPlaytime} from '../utils/games.js'; @@ -177,6 +178,7 @@ export default function GameDetail() { setData((d) => ({...d, ...entry}))}/> + setData((d) => ({...d, ...entry}))}/> setData((d) => ({...d, ...entry}))}/> diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 27c4192..5e92037 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; import {formatPlaytime} from '../utils/games.js'; import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js'; -import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer} from 'lucide-react'; +import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -266,6 +266,13 @@ export default function GameLibrary() { {g.name ?? `App ${g.appid}`} + {g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( + + + {g.achievementsUnlocked}/{g.achievementsTotal} + + )} {formatPlaytime(g.playtimeMinutes)} diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index d4de539..45088c5 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -51,6 +51,17 @@ afterEach(() => { }); describe('GameLibrary search', () => { + it('shows the achievement progress of a game when it is known', async () => { + globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [ + {...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4}, + {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5}, + ]})})); + renderLibrary(); + + expect(await screen.findByText('3/4')).toBeTruthy(); + expect(screen.queryByText(/\/5$/)).toBeNull(); + }); + it('writes the trimmed search to the URL after the delay and resets the page', async () => { const lib = renderLibrary('/library?page=3'); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index b64280c..14b36e6 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Link, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {CircleCheck, Clock, Gamepad2, Library, Star, UserRoundX} from 'lucide-react'; +import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; @@ -90,6 +90,12 @@ export default function PublicProfilePage() { [Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')], [CircleCheck, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')], ]; + if (library.averageCompletion != null) { + stats.push( + [Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')], + [Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')], + ); + } const memberSince = profile.memberSince ? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'}) : null; @@ -105,7 +111,7 @@ export default function PublicProfilePage() {

{t('PROFILE.MEMBER_SINCE', {date: memberSince})}

)} -
+
3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}> {stats.map(([Icon, value, label]) => (
diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index f4fdc23..b1ad687 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -43,6 +43,23 @@ describe('PublicProfilePage', () => { expect(screen.getByText('Lobo')).toBeTruthy(); expect(screen.getByText('Half-Life 2')).toBeTruthy(); expect(screen.getByText('233')).toBeTruthy(); + expect(screen.queryByText('PROFILE.PERFECT_GAMES')).toBeNull(); + }); + + it('shows perfect games and the average completion when there are achievements', async () => { + library.perfectGames = 12; + library.averageCompletion = 64.5; + try { + mockBackends(); + renderPage(); + + expect(await screen.findByText('PROFILE.PERFECT_GAMES')).toBeTruthy(); + expect(screen.getByText('12')).toBeTruthy(); + expect(screen.getByText('64,5 %')).toBeTruthy(); + } finally { + delete library.perfectGames; + delete library.averageCompletion; + } }); it('says the profile is unknown or private on a 404', async () => {