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

+6 -5
View File
@@ -20,15 +20,17 @@ To Be Played is split into three repositories:
## Features ## Features
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion, - **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 - **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. 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 - **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 %), - **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. - **Wishlist** with store price and discount.
- **Public profile** at `/u/<display name>`, opt-in in the settings. - **Public profile** at `/u/<display name>`, 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. - **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. 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. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
@@ -83,7 +85,6 @@ npm run build
## Roadmap ## Roadmap
- Mood filter for "What to play next". - 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 - Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in
`nginx.conf`. `nginx.conf`.
- Installable PWA. - Installable PWA.
+20 -4
View File
@@ -134,7 +134,18 @@
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.", "ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
"RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)", "RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)",
"BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist", "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": { "LIBRARY": {
"TITLE": "Spielebibliothek", "TITLE": "Spielebibliothek",
@@ -163,7 +174,8 @@
"NO_MATCH": "Keine Spiele für diesen Filter.", "NO_MATCH": "Keine Spiele für diesen Filter.",
"SORT_RATING": "Beste Bewertung", "SORT_RATING": "Beste Bewertung",
"SORT_PRIORITY": "Höchste Priorität", "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": { "GAME_DETAIL": {
"BACK": "Zurück", "BACK": "Zurück",
@@ -187,7 +199,9 @@
"TTB_COMPLETELY": "100 %", "TTB_COMPLETELY": "100 %",
"SET_BACKGROUND": "Als Hintergrund", "SET_BACKGROUND": "Als Hintergrund",
"REMOVE_BACKGROUND": "Hintergrund entfernen", "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": { "LANDING": {
"STATS_TITLE": "Community-Statistiken", "STATS_TITLE": "Community-Statistiken",
@@ -268,7 +282,9 @@
"FINISHED_GAMES": "Durchgespielte Spiele", "FINISHED_GAMES": "Durchgespielte Spiele",
"NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.", "NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.",
"ERROR_LOAD": "Profil konnte nicht geladen werden.", "ERROR_LOAD": "Profil konnte nicht geladen werden.",
"TO_START": "Zur Startseite" "TO_START": "Zur Startseite",
"PERFECT_GAMES": "Perfekte Spiele",
"AVERAGE_COMPLETION": "Ø Abschluss"
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?", "TITLE": "Was spiele ich als Nächstes?",
+20 -4
View File
@@ -134,7 +134,18 @@
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.", "ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
"RECENTLY_PLAYED": "Recently played (2 weeks)", "RECENTLY_PLAYED": "Recently played (2 weeks)",
"BACKLOG_HOURS": "≈ {{time}} of play to clear it", "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": { "LIBRARY": {
"TITLE": "Game Library", "TITLE": "Game Library",
@@ -163,7 +174,8 @@
"NO_MATCH": "No games match this filter.", "NO_MATCH": "No games match this filter.",
"SORT_RATING": "Best rated", "SORT_RATING": "Best rated",
"SORT_PRIORITY": "Highest priority", "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": { "GAME_DETAIL": {
"BACK": "Back", "BACK": "Back",
@@ -187,7 +199,9 @@
"TTB_COMPLETELY": "100%", "TTB_COMPLETELY": "100%",
"SET_BACKGROUND": "Use as background", "SET_BACKGROUND": "Use as background",
"REMOVE_BACKGROUND": "Remove 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": { "LANDING": {
"STATS_TITLE": "Community stats", "STATS_TITLE": "Community stats",
@@ -268,7 +282,9 @@
"FINISHED_GAMES": "Finished games", "FINISHED_GAMES": "Finished games",
"NOT_FOUND": "This profile does not exist or is private.", "NOT_FOUND": "This profile does not exist or is private.",
"ERROR_LOAD": "Could not load the profile.", "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": { "PLAY_NEXT": {
"TITLE": "What to play next", "TITLE": "What to play next",
+87
View File
@@ -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 (
<Card className="gap-5 p-5">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.ACHIEVEMENTS.TITLE')}
</h2>
<div className="grid grid-cols-3 gap-3">
{stats.map(([value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
{started === 0 && pending === 0 && hidden === 0 && (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.NONE')}</p>
)}
{closest.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('DASHBOARD.ACHIEVEMENTS.CLOSEST')}</h3>
<ul className="space-y-2">
{closest.map((game) => (
<li key={game.appid}>
<Link
to={`/library/${game.appid}`}
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
>
<GameCover appid={game.appid} className="w-16 shrink-0"/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{game.name ?? `App ${game.appid}`}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{game.unlocked} / {game.total}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-amber-500"
style={{width: `${(game.unlocked / game.total) * 100}%`}}/>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{pending > 0 && !steamHidesAll && (
<p className="text-xs text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.PENDING', {count: pending})}</p>
)}
{steamHidesAll && (
<p className="text-xs text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.HIDDEN')}</p>
)}
</Card>
);
}
+52
View File
@@ -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(<MemoryRouter><AchievementsCard summary={data}/></MemoryRouter>);
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();
});
});
+59
View File
@@ -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 (
<Card className="gap-3 border-amber-500/40 bg-amber-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
<div className="flex flex-1 items-start gap-3 sm:items-center">
<Trophy className="size-5 shrink-0 text-amber-500"/>
<p className="text-sm">{t('GAME_DETAIL.ALL_UNLOCKED')}</p>
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={setCompleted} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_COMPLETED')}
</Button>
</Card>
);
}
+48
View File
@@ -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(<MemoryRouter><CompletedHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
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'});
});
});
+22 -3
View File
@@ -8,6 +8,7 @@ import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import AchievementsCard from '../components/AchievementsCard.jsx';
import ActivityCard from '../components/ActivityCard.jsx'; import ActivityCard from '../components/ActivityCard.jsx';
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx'; import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
@@ -46,6 +47,7 @@ const Dashboard = () => {
const [playing, setPlaying] = useState([]); const [playing, setPlaying] = useState([]);
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [activity, setActivity] = useState(null); const [activity, setActivity] = useState(null);
const [achievements, setAchievements] = useState(null);
const [summaryFailed, setSummaryFailed] = useState(false); const [summaryFailed, setSummaryFailed] = useState(false);
const [shuffling, setShuffling] = useState(false); const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -137,6 +139,21 @@ const Dashboard = () => {
} }
}, [expireSession]); }, [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 () => { const shuffleSuggestion = async () => {
setShuffling(true); setShuffling(true);
if (!await fetchSummary(summary?.suggestion?.appid)) { if (!await fetchSummary(summary?.suggestion?.appid)) {
@@ -151,7 +168,8 @@ const Dashboard = () => {
fetchPlaying(); fetchPlaying();
fetchSummary(); fetchSummary();
fetchActivity(); fetchActivity();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]); fetchAchievements();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
@@ -171,7 +189,7 @@ const Dashboard = () => {
setUserData(data); setUserData(data);
if (data.steamId) { 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) { } else if (profileResponse.status === 401) {
expireSession(); expireSession();
@@ -187,7 +205,7 @@ const Dashboard = () => {
}; };
fetchUserData(); fetchUserData();
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]); }, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const {appearance} = usePreferences(); const {appearance} = usePreferences();
@@ -340,6 +358,7 @@ const Dashboard = () => {
)} )}
{hasSteamId && activity && <ActivityCard activity={activity}/>} {hasSteamId && activity && <ActivityCard activity={activity}/>}
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
</PageContainer> </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: [ 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'}]}); {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/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 ok({content: [], total: 0, byStatus: {}});
}); });
return render( return render(
@@ -72,6 +74,13 @@ describe('Dashboard', () => {
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull(); 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 () => { it('shows the picked banner game instead', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620})); localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
const container = mockDashboard(); const container = mockDashboard();
+2
View File
@@ -11,6 +11,7 @@ import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx'; import BacklogCard from '../components/BacklogCard.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import CompletedHint from '../components/CompletedHint.jsx';
import PlayingHint from '../components/PlayingHint.jsx'; import PlayingHint from '../components/PlayingHint.jsx';
import TimeToBeat from '../components/TimeToBeat.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
@@ -177,6 +178,7 @@ export default function GameDetail() {
</Card> </Card>
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/> <PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data} <BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/> 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 useSessionExpired from '../hooks/useSessionExpired.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.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 {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -266,6 +266,13 @@ export default function GameLibrary() {
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
<GameStatusBadge status={g.status} compact/> <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"> <Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/> <Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)} {formatPlaytime(g.playtimeMinutes)}
+11
View File
@@ -51,6 +51,17 @@ afterEach(() => {
}); });
describe('GameLibrary search', () => { 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 () => { it('writes the trimmed search to the URL after the delay and resets the page', async () => {
const lib = renderLibrary('/library?page=3'); const lib = renderLibrary('/library?page=3');
+8 -2
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom'; import {Link, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
@@ -90,6 +90,12 @@ export default function PublicProfilePage() {
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')], [Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')], [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 const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'}) ? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
: null; : 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> <p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)} )}
</div> </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]) => ( {stats.map(([Icon, value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center"> <div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/> <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('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy(); expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('233')).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 () => { it('says the profile is unknown or private on a 404', async () => {