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

+8 -2
View File
@@ -98,7 +98,11 @@
"OTHER_SUGGESTION": "Anderer Vorschlag",
"BACKLOG_EMPTY": "Dein Backlog ist leer. Stark!",
"SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.",
"RETRY": "Erneut versuchen"
"RETRY": "Erneut versuchen",
"ACTIVITY": "Spielzeit",
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
"RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)"
},
"LIBRARY": {
"TITLE": "Spielebibliothek",
@@ -141,7 +145,9 @@
"REMAINING": "Offen",
"COMPLETION": "Fortschritt",
"NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.",
"STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen."
"STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.",
"PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.",
"SET_PLAYING": "Auf „Spiele ich“ setzen"
},
"LANDING": {
"STATS_TITLE": "Community-Statistiken",
+8 -2
View File
@@ -98,7 +98,11 @@
"OTHER_SUGGESTION": "Other pick",
"BACKLOG_EMPTY": "Your backlog is empty. Nice!",
"SUMMARY_FAILED": "Could not load the overview.",
"RETRY": "Try again"
"RETRY": "Try again",
"ACTIVITY": "Playtime",
"ACTIVITY_TOTAL": "{{time}} in {{days}} days",
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
"RECENTLY_PLAYED": "Recently played (2 weeks)"
},
"LIBRARY": {
"TITLE": "Game Library",
@@ -141,7 +145,9 @@
"REMAINING": "Remaining",
"COMPLETION": "Completion",
"NO_ACHIEVEMENTS": "This game has no achievements on Steam.",
"STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements."
"STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.",
"PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.",
"SET_PLAYING": "Set to \"Playing\""
},
"LANDING": {
"STATS_TITLE": "Community stats",
+81
View File
@@ -0,0 +1,81 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js';
const CHART_HEIGHT = 80;
const BAR_WIDTH = 10;
const BAR_GAP = 2;
const formatDay = (day, locale) =>
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
/**
* @param activity the answer of GET /api/library/activity
*/
export default function ActivityCard({activity}) {
const {t, i18n} = useTranslation();
const {days, totalMinutes, recentGames} = activity;
const max = Math.max(1, ...days.map((d) => d.minutes));
const width = days.length * (BAR_WIDTH + BAR_GAP);
return (
<Card className="gap-5 p-5">
<div className="flex items-baseline justify-between gap-4">
<h2 className="flex items-center gap-2 font-semibold">
<Activity className="size-4 text-primary"/>
{t('DASHBOARD.ACTIVITY')}
</h2>
<span className="text-sm text-muted-foreground tabular-nums">
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
</span>
</div>
{totalMinutes === 0 ? (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p>
) : (
<div>
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
{days.map((d, i) => {
const height = (d.minutes / max) * CHART_HEIGHT;
return (
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
className="fill-primary">
<title>{`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`}</title>
</rect>
);
})}
</svg>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{formatDay(days[0].day, i18n.language)}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}</span>
</div>
</div>
)}
{recentGames.length > 0 && (
<div className="space-y-2">
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
<ul className="space-y-2">
{recentGames.map((g) => (
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3">
<GameCover appid={g.appid} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatPlaytime(g.playtime2Weeks)}</span>
</Link>
</li>
))}
</ul>
</div>
)}
</Card>
);
}
+38
View File
@@ -0,0 +1,38 @@
// @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 ActivityCard from './ActivityCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m}));
const renderCard = (activity) => render(<MemoryRouter><ActivityCard activity={activity}/></MemoryRouter>);
describe('ActivityCard', () => {
afterEach(cleanup);
it('draws one bar per day, the tallest for the day with most playtime', () => {
renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []});
const bars = screen.getAllByTestId('activity-bar');
expect(bars).toHaveLength(3);
const heights = bars.map((b) => Number(b.getAttribute('height')));
expect(heights[2]).toBeGreaterThan(heights[1]);
expect(heights[0]).toBe(0);
});
it('lists the recently played games', () => {
renderCard({days: days([0]), totalMinutes: 0,
recentGames: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, playtime2Weeks: 120, status: 'PLAYING'}]});
expect(screen.getByText('Portal 2')).toBeTruthy();
expect(screen.getByText('2.0h')).toBeTruthy();
});
it('explains that the history starts now when nothing was recorded yet', () => {
renderCard({days: days([0, 0]), totalMinutes: 0, recentGames: []});
expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy();
});
});
+9 -7
View File
@@ -1,4 +1,4 @@
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
@@ -11,7 +11,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, toBacklogRequest} from '../utils/backlog.js';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
const NOTES_MAX = 2000;
@@ -29,6 +29,12 @@ export default function BacklogCard({game, onSaved}) {
const expireSession = useSessionExpired();
const [saved, setSaved] = useState(() => toForm(game));
const [form, setForm] = useState(saved);
// The PlayingHint can change the status from outside; keep the other unsaved edits.
useEffect(() => {
setSaved((current) => (current.status === game.status ? current : {...current, status: game.status}));
setForm((current) => (current.status === game.status ? current : {...current, status: game.status}));
}, [game.status]);
const [busy, setBusy] = useState(false);
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
@@ -42,11 +48,7 @@ export default function BacklogCard({game, onSaved}) {
}
setBusy(true);
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${game.appid}/backlog`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify(toBacklogRequest(form)),
});
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
if (response.status === 401) {
expireSession();
return;
+62
View File
@@ -0,0 +1,62 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Gamepad2, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveBacklogEntry} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
/**
* Shown for a backlog game that Steam reports as played in the last two weeks.
*
* @param onChanged gets the saved backlog entry
*/
export default function PlayingHint({game, onChanged}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false);
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
const setPlaying = async () => {
setBusy(true);
try {
const response = await saveBacklogEntry(game.appid, {
status: 'PLAYING', rating: game.rating ?? null, notes: game.notes ?? null, priority: game.priority ?? null,
});
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 playing error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="gap-3 border-primary/40 bg-primary/5 p-4 sm:flex-row sm:items-center sm:gap-4">
<div className="flex flex-1 items-start gap-3 sm:items-center">
<Gamepad2 className="size-5 shrink-0 text-primary"/>
<p className="text-sm">
{t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}
</p>
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={setPlaying} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_PLAYING')}
</Button>
</Card>
);
}
+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} from 'react-router-dom';
import PlayingHint from './PlayingHint.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: 'BACKLOG', rating: 3, notes: 'keep', priority: 'HIGH', playtime2Weeks: 90};
const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
describe('PlayingHint', () => {
beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it.each([
['a game that is not in the backlog', {...game, status: 'PLAYING'}],
['a backlog game without recent playtime', {...game, playtime2Weeks: 0}],
])('is hidden for %s', (_, other) => {
renderHint({game: other});
expect(screen.queryByRole('button')).toBeNull();
});
it('sets the game to PLAYING and keeps the other fields', async () => {
const onChanged = vi.fn();
renderHint({onChanged});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
await waitFor(() => expect(onChanged).toHaveBeenCalledWith({status: 'PLAYING', rating: 3, notes: 'keep', priority: 'HIGH'}));
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: 3, notes: 'keep', priority: 'HIGH'});
});
});
+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');
});
});
+12
View File
@@ -13,6 +13,18 @@ export const toBacklogRequest = ({status, rating, notes, priority}) => ({
priority: priority === NO_PRIORITY ? null : priority,
});
/**
* PUT /api/library/games/{appid}/backlog. The caller handles the response status.
*
* @param entry a backlog entry in request form, see toBacklogRequest
*/
export const saveBacklogEntry = (appid, entry) =>
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
body: JSON.stringify(entry),
});
export const libraryQuery = ({page, size, sort, status, q}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (status) params.set('status', status);