diff --git a/public/i18n/de.json b/public/i18n/de.json
index 4de28fc..33ed291 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -86,7 +86,18 @@
"MOST_PLAYED": "Meistgespielt",
"SHOW_ALL": "Alle anzeigen",
"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": {
"TITLE": "Spielebibliothek",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 0e5f4ee..2d297ba 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -86,7 +86,18 @@
"MOST_PLAYED": "Most played",
"SHOW_ALL": "Show all",
"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": {
"TITLE": "Game Library",
diff --git a/src/components/CurrentlyPlayingCard.jsx b/src/components/CurrentlyPlayingCard.jsx
new file mode 100644
index 0000000..9add25f
--- /dev/null
+++ b/src/components/CurrentlyPlayingCard.jsx
@@ -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 (
+
+
+
+
+ {t('DASHBOARD.CURRENTLY_PLAYING')}
+
+
+
+
+ {games.length === 0 ? (
+
+ {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/GameStatusBadge.jsx b/src/components/GameStatusBadge.jsx
index 7e53a1b..cadc198 100644
--- a/src/components/GameStatusBadge.jsx
+++ b/src/components/GameStatusBadge.jsx
@@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
export const STATUS_META = {
- BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'},
- PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'},
- FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'},
- COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'},
- DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'},
+ 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', 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', 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', 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', bar: 'bg-rose-500'},
};
/**
diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx
new file mode 100644
index 0000000..f6a6a37
--- /dev/null
+++ b/src/components/PileOfShameCard.jsx
@@ -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 (
+
+
+ {t('LIBRARY.SYNCING_FIRST_TIME')}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {backlog.toLocaleString(i18n.language)}
+
+ {t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
+
+
+
+ {t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
+
+
+
+ {summary.total > 0 && (
+
+
+ {GAME_STATUSES.map((s) => (
+
+ ))}
+
+
+ {GAME_STATUSES.map((s) => (
+ -
+
+ {t(`BACKLOG.STATUS.${s}`)}
+ {summary.byStatus[s]}
+
+ ))}
+
+
+ )}
+
+ {suggestion ? (
+
+
+ {t('DASHBOARD.SUGGESTION')}
+ {backlog > 1 && (
+
+ )}
+
+
+
+
+ {suggestion.name ?? `App ${suggestion.appid}`}
+
+
+
+ ) : (
+ {t('DASHBOARD.BACKLOG_EMPTY')}
+ )}
+
+ );
+}
+
+function Title() {
+ const {t} = useTranslation();
+ return (
+
+
+ {t('DASHBOARD.PILE_OF_SHAME')}
+
+ );
+}
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index a5bd0a5..58ecfc7 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -1,13 +1,16 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
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 CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import GameCover from '../components/GameCover.jsx';
+import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useLibrarySync from '../hooks/useLibrarySync.js';
@@ -17,6 +20,8 @@ import {formatPlaytime} from '../utils/games.js';
const TOP_GAMES = 5;
// 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 PLAYING_GAMES = 4;
+const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const authHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
@@ -29,6 +34,10 @@ const Dashboard = () => {
const [userData, setUserData] = useState(null);
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
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 [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(() => {
fetchSteamCount();
fetchTopGames();
- }, [fetchSteamCount, fetchTopGames]);
+ fetchPlaying();
+ fetchSummary();
+ }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
useEffect(() => {
const fetchUserData = async () => {
@@ -78,7 +129,7 @@ const Dashboard = () => {
setUserData(data);
if (data.steamId) {
- await Promise.all([fetchSteamCount(), fetchTopGames()]);
+ await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
}
} else if (profileResponse.status === 401 || profileResponse.status === 403) {
localStorage.removeItem('tbp_jwt');
@@ -95,7 +146,7 @@ const Dashboard = () => {
};
fetchUserData();
- }, [navigate, t, fetchSteamCount, fetchTopGames]);
+ }, [navigate, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
const hasSteamId = !!userData?.steamId;
const sync = useLibrarySync({
@@ -209,6 +260,24 @@ const Dashboard = () => {
)}
+
+ {hasSteamId && (
+
+
+ {summary && (
+
+ )}
+ {!summary && summaryFailed && (
+
+ {t('DASHBOARD.SUMMARY_FAILED')}
+
+
+ )}
+
+ )}
);
};
diff --git a/src/utils/backlog.js b/src/utils/backlog.js
index 910aee2..33f149f 100644
--- a/src/utils/backlog.js
+++ b/src/utils/backlog.js
@@ -19,3 +19,6 @@ export const libraryQuery = ({page, size, sort, status, q}) => {
if (q?.trim()) params.set('q', q.trim());
return params.toString();
};
+
+export const backlogShare = (summary) =>
+ summary?.total ? Math.round(((summary.byStatus?.BACKLOG ?? 0) / summary.total) * 100) : 0;
diff --git a/src/utils/backlog.test.js b/src/utils/backlog.test.js
index c733a7b..c000e11 100644
--- a/src/utils/backlog.test.js
+++ b/src/utils/backlog.test.js
@@ -1,5 +1,5 @@
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', () => {
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');
});
});
+
+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);
+ });
+});