Public Access
feat(achievements): dashboard card, library badge, 100 % hint and profile stats
This commit is contained in:
1 parent
2efdf64675
commit
085fcf15a4
14 files changed
+369
-19
No files matched your search
+22
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}))}/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user