feat(dashboard): currently playing and pile of shame tiles
Build, Test & Push Frontend / quality-check (push) Successful in 10s
Build, Test & Push Frontend / docker (push) Successful in 22s

Pile of Shame shows the backlog count and share, a status bar and a random
backlog pick. Both tiles show the first-sync text while the initial sync runs;
a failed summary load shows a retry button.
This commit is contained in:
lobo committed 2026-09-24 11:25:12 +02:00
1 parent f94e0aff15
commit c24c22d5ed
8 files changed
+262 -11

No files matched your search

+12 -1
View File
@@ -86,7 +86,18 @@
"MOST_PLAYED": "Meistgespielt", "MOST_PLAYED": "Meistgespielt",
"SHOW_ALL": "Alle anzeigen", "SHOW_ALL": "Alle anzeigen",
"TO_LIBRARY": "Zur Bibliothek", "TO_LIBRARY": "Zur Bibliothek",
"WELCOME_BACK": "Willkommen zurück" "WELCOME_BACK": "Willkommen zurück",
"CURRENTLY_PLAYING": "Spiele ich gerade",
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
"PILE_OF_SHAME": "Pile of Shame",
"PILE_SHARE": "{{percent}} % deiner Bibliothek",
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
"NEVER_PLAYED_other": "{{count}} Spiele nie gestartet",
"SUGGESTION": "Wie wäre es mit",
"OTHER_SUGGESTION": "Anderer Vorschlag",
"BACKLOG_EMPTY": "Dein Backlog ist leer. Stark!",
"SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.",
"RETRY": "Erneut versuchen"
}, },
"LIBRARY": { "LIBRARY": {
"TITLE": "Spielebibliothek", "TITLE": "Spielebibliothek",
+12 -1
View File
@@ -86,7 +86,18 @@
"MOST_PLAYED": "Most played", "MOST_PLAYED": "Most played",
"SHOW_ALL": "Show all", "SHOW_ALL": "Show all",
"TO_LIBRARY": "Go to library", "TO_LIBRARY": "Go to library",
"WELCOME_BACK": "Welcome back" "WELCOME_BACK": "Welcome back",
"CURRENTLY_PLAYING": "Currently playing",
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
"PILE_OF_SHAME": "Pile of Shame",
"PILE_SHARE": "{{percent}}% of your library",
"NEVER_PLAYED_one": "{{count}} game never started",
"NEVER_PLAYED_other": "{{count}} games never started",
"SUGGESTION": "How about",
"OTHER_SUGGESTION": "Other pick",
"BACKLOG_EMPTY": "Your backlog is empty. Nice!",
"SUMMARY_FAILED": "Could not load the overview.",
"RETRY": "Try again"
}, },
"LIBRARY": { "LIBRARY": {
"TITLE": "Game Library", "TITLE": "Game Library",
+47
View File
@@ -0,0 +1,47 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowRight, Gamepad2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js';
export default function CurrentlyPlayingCard({games, syncing}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>
{t('DASHBOARD.CURRENTLY_PLAYING')}
</h2>
<Button asChild variant="ghost" size="sm">
<Link to="/library?status=PLAYING">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
{games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
</p>
) : (
<ul className="grid grid-cols-2 gap-3 p-4">
{games.map((g) => (
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
<GameCover appid={g.appid} className="w-full transition-opacity group-hover:opacity-80"/>
<div className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</div>
</Link>
</li>
))}
</ul>
)}
</Card>
);
}
+5 -5
View File
@@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
export const STATUS_META = { export const STATUS_META = {
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'}, BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'}, PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'}, FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'}, COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'}, DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
}; };
/** /**
+99
View File
@@ -0,0 +1,99 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Inbox, Shuffle} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) {
const {t, i18n} = useTranslation();
const backlog = summary.byStatus?.BACKLOG ?? 0;
const suggestion = summary.suggestion;
if (syncing && summary.total === 0) {
return (
<Card className="gap-5 p-5">
<Title/>
<p className="py-6 text-center text-sm text-muted-foreground">{t('LIBRARY.SYNCING_FIRST_TIME')}</p>
</Card>
);
}
return (
<Card className="gap-5 p-5">
<Title/>
<div>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
<span className="text-sm text-muted-foreground">
{t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
</p>
</div>
{summary.total > 0 && (
<div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{GAME_STATUSES.map((s) => (
<div
key={s}
className={STATUS_META[s].bar}
style={{width: `${(summary.byStatus[s] / summary.total) * 100}%`}}
/>
))}
</div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{GAME_STATUSES.map((s) => (
<li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)}
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
</li>
))}
</ul>
</div>
)}
{suggestion ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('DASHBOARD.SUGGESTION')}</span>
{backlog > 1 && (
<Button variant="ghost" size="sm" onClick={onShuffle} disabled={shuffling}>
<Shuffle/>{t('DASHBOARD.OTHER_SUGGESTION')}
</Button>
)}
</div>
<Link
to={`/library/${suggestion.appid}`}
className="group flex items-center gap-3 rounded-lg border p-2 transition-colors hover:bg-muted/50"
>
<GameCover appid={suggestion.appid} className="w-24 shrink-0"/>
<span className="min-w-0 truncate font-medium group-hover:text-primary transition-colors">
{suggestion.name ?? `App ${suggestion.appid}`}
</span>
</Link>
</div>
) : (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.BACKLOG_EMPTY')}</p>
)}
</Card>
);
}
function Title() {
const {t} = useTranslation();
return (
<h2 className="flex items-center gap-2 font-semibold">
<Inbox className="size-4 text-rose-500"/>
{t('DASHBOARD.PILE_OF_SHAME')}
</h2>
);
}
+72 -3
View File
@@ -1,13 +1,16 @@
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom'; import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react'; import {ArrowRight, Hash, Link2, RefreshCw, 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';
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 CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount'; import SteamGameCount from '../components/SteamGameCount';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useLibrarySync from '../hooks/useLibrarySync.js'; import useLibrarySync from '../hooks/useLibrarySync.js';
@@ -17,6 +20,8 @@ import {formatPlaytime} from '../utils/games.js';
const TOP_GAMES = 5; const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80. // The games endpoint accepts only the page sizes 20, 40 and 80.
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc'; const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
const PLAYING_GAMES = 4;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const authHeaders = () => ({ const authHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
@@ -29,6 +34,10 @@ const Dashboard = () => {
const [userData, setUserData] = useState(null); const [userData, setUserData] = useState(null);
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
const [topGames, setTopGames] = useState([]); const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]);
const [summary, setSummary] = useState(null);
const [summaryFailed, setSummaryFailed] = useState(false);
const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -55,10 +64,52 @@ const Dashboard = () => {
} }
}, []); }, []);
const fetchPlaying = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: authHeaders()});
if (r.ok) {
const page = await r.json();
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES));
}
} catch (err) {
console.error('Error fetching playing games:', err);
}
}, []);
/**
* @param excludeAppid the current suggestion, so the new pick differs from it
* @returns false when the request failed
*/
const fetchSummary = useCallback(async (excludeAppid) => {
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: authHeaders()});
if (r.ok) {
setSummary(await r.json());
setSummaryFailed(false);
return true;
}
} catch (err) {
console.error('Error fetching library summary:', err);
}
setSummaryFailed(true);
return false;
}, []);
const shuffleSuggestion = async () => {
setShuffling(true);
if (!await fetchSummary(summary?.suggestion?.appid)) {
toast.error(t('DASHBOARD.SUMMARY_FAILED'));
}
setShuffling(false);
};
const reloadLibrary = useCallback(() => { const reloadLibrary = useCallback(() => {
fetchSteamCount(); fetchSteamCount();
fetchTopGames(); fetchTopGames();
}, [fetchSteamCount, fetchTopGames]); fetchPlaying();
fetchSummary();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
@@ -78,7 +129,7 @@ const Dashboard = () => {
setUserData(data); setUserData(data);
if (data.steamId) { if (data.steamId) {
await Promise.all([fetchSteamCount(), fetchTopGames()]); await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
} }
} else if (profileResponse.status === 401 || profileResponse.status === 403) { } else if (profileResponse.status === 401 || profileResponse.status === 403) {
localStorage.removeItem('tbp_jwt'); localStorage.removeItem('tbp_jwt');
@@ -95,7 +146,7 @@ const Dashboard = () => {
}; };
fetchUserData(); fetchUserData();
}, [navigate, t, fetchSteamCount, fetchTopGames]); }, [navigate, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const sync = useLibrarySync({ const sync = useLibrarySync({
@@ -209,6 +260,24 @@ const Dashboard = () => {
</Card> </Card>
</div> </div>
)} )}
{hasSteamId && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
{summary && (
<PileOfShameCard summary={summary} syncing={sync.isRunning}
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
)}
{!summary && summaryFailed && (
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
{t('DASHBOARD.SUMMARY_FAILED')}
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
<RefreshCw/>{t('DASHBOARD.RETRY')}
</Button>
</Card>
)}
</div>
)}
</div> </div>
); );
}; };
+3
View File
@@ -19,3 +19,6 @@ export const libraryQuery = ({page, size, sort, status, q}) => {
if (q?.trim()) params.set('q', q.trim()); if (q?.trim()) params.set('q', q.trim());
return params.toString(); return params.toString();
}; };
export const backlogShare = (summary) =>
summary?.total ? Math.round(((summary.byStatus?.BACKLOG ?? 0) / summary.total) * 100) : 0;
+12 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js'; import {backlogShare, libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js';
describe('toBacklogRequest', () => { describe('toBacklogRequest', () => {
it('sends every field', () => { it('sends every field', () => {
@@ -32,3 +32,14 @@ describe('libraryQuery', () => {
.toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more'); .toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more');
}); });
}); });
describe('backlogShare', () => {
it('is the rounded backlog percent of the library', () => {
expect(backlogShare({total: 3, byStatus: {BACKLOG: 2}})).toBe(67);
});
it('is 0 for an empty library', () => {
expect(backlogShare({total: 0, byStatus: {BACKLOG: 0}})).toBe(0);
expect(backlogShare(null)).toBe(0);
});
});