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

+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'});
});
});