feat(achievements): dashboard card, library badge, 100 % hint and profile stats

This commit is contained in:
lobo committed 2026-09-25 10:13:31 +02:00
1 parent 2efdf64675
commit 085fcf15a4
14 files changed
+369 -19

No files matched your search

+22 -3
View File
@@ -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 && <ActivityCard activity={activity}/>}
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
</PageContainer>
);
};
+9
View File
@@ -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();
+2
View File
@@ -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() {
</Card>
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
+8 -1
View File
@@ -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}`}
</span>
<GameStatusBadge status={g.status} compact/>
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className="size-3"/>
{g.achievementsUnlocked}/{g.achievementsTotal}
</Badge>
)}
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)}
+11
View File
@@ -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');
+8 -2
View File
@@ -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() {
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div>
<div className="relative grid grid-cols-3 gap-3">
<div className={`relative grid gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
{stats.map(([Icon, value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/>
+17
View File
@@ -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 () => {