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
@@ -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/<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.
|
||||
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.
|
||||
|
||||
+20
-4
@@ -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?",
|
||||
|
||||
+20
-4
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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