feat(dashboard): playtime chart, recently played and a playing hint

The dashboard shows the playtime of the last 30 days and Steam's recently
played games. The detail page offers to set a backlog game to Playing when
Steam reports recent playtime; unsaved edits in the backlog card stay.
This commit is contained in:
lobo committed 2026-09-24 13:41:37 +02:00
1 parent d4c0f8d8f3
commit 89c5ba5253
11 files changed
+334 -15

No files matched your search

+23 -3
View File
@@ -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 ActivityCard from '../components/ActivityCard.jsx';
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import GameCover from '../components/GameCover.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx';
@@ -38,6 +39,7 @@ const Dashboard = () => {
const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]);
const [summary, setSummary] = useState(null);
const [activity, setActivity] = useState(null);
const [summaryFailed, setSummaryFailed] = useState(false);
const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -114,6 +116,21 @@ const Dashboard = () => {
return false;
}, [expireSession]);
const fetchActivity = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: authHeaders()});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
setActivity(await r.json());
}
} catch (err) {
console.error('Error fetching activity:', err);
}
}, [expireSession]);
const shuffleSuggestion = async () => {
setShuffling(true);
if (!await fetchSummary(summary?.suggestion?.appid)) {
@@ -127,7 +144,8 @@ const Dashboard = () => {
fetchTopGames();
fetchPlaying();
fetchSummary();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
fetchActivity();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
useEffect(() => {
const fetchUserData = async () => {
@@ -147,7 +165,7 @@ const Dashboard = () => {
setUserData(data);
if (data.steamId) {
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity()]);
}
} else if (profileResponse.status === 401) {
expireSession();
@@ -163,7 +181,7 @@ const Dashboard = () => {
};
fetchUserData();
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
const hasSteamId = !!userData?.steamId;
const sync = useLibrarySync({
@@ -296,6 +314,8 @@ const Dashboard = () => {
)}
</div>
)}
{hasSteamId && activity && <ActivityCard activity={activity}/>}
</div>
);
};
+5 -1
View File
@@ -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 PlayingHint from '../components/PlayingHint.jsx';
import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
@@ -142,7 +143,10 @@ export default function GameDetail() {
</div>
</Card>
<BacklogCard key={data.appid} game={data} onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
{!data.hasAchievements && (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 90, status: 'BACKLOG',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const renderDetail = () => render(
<MemoryRouter initialEntries={['/library/220']}>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</MemoryRouter>,
);
beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token');
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
: {ok: true, status: 200, json: async () => game});
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('GameDetail', () => {
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
renderDetail();
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
fireEvent.change(notes, {target: {value: 'my draft'}});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft');
});
});