From 89c5ba525300e57cf71bc9cd26bb28f20a44afad Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 13:41:37 +0200 Subject: [PATCH] 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. --- public/i18n/de.json | 10 +++- public/i18n/en.json | 10 +++- src/components/ActivityCard.jsx | 81 ++++++++++++++++++++++++++++ src/components/ActivityCard.test.jsx | 38 +++++++++++++ src/components/BacklogCard.jsx | 16 +++--- src/components/PlayingHint.jsx | 62 +++++++++++++++++++++ src/components/PlayingHint.test.jsx | 44 +++++++++++++++ src/pages/Dashboard.jsx | 26 +++++++-- src/pages/GameDetail.jsx | 6 ++- src/pages/GameDetail.test.jsx | 44 +++++++++++++++ src/utils/backlog.js | 12 +++++ 11 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 src/components/ActivityCard.jsx create mode 100644 src/components/ActivityCard.test.jsx create mode 100644 src/components/PlayingHint.jsx create mode 100644 src/components/PlayingHint.test.jsx create mode 100644 src/pages/GameDetail.test.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index ab75cbb..f5860c9 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index dffe91c..3e5349d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/components/ActivityCard.jsx b/src/components/ActivityCard.jsx new file mode 100644 index 0000000..e4a4603 --- /dev/null +++ b/src/components/ActivityCard.jsx @@ -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 ( + +
+

+ + {t('DASHBOARD.ACTIVITY')} +

+ + {t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})} + +
+ + {totalMinutes === 0 ? ( +

{t('DASHBOARD.ACTIVITY_EMPTY')}

+ ) : ( +
+ + {days.map((d, i) => { + const height = (d.minutes / max) * CHART_HEIGHT; + return ( + + {`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`} + + ); + })} + +
+ {formatDay(days[0].day, i18n.language)} + {formatDay(days[days.length - 1].day, i18n.language)} +
+
+ )} + + {recentGames.length > 0 && ( +
+ {t('DASHBOARD.RECENTLY_PLAYED')} +
    + {recentGames.map((g) => ( +
  • + + + + {g.name ?? `App ${g.appid}`} + + {formatPlaytime(g.playtime2Weeks)} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/ActivityCard.test.jsx b/src/components/ActivityCard.test.jsx new file mode 100644 index 0000000..afbd2c9 --- /dev/null +++ b/src/components/ActivityCard.test.jsx @@ -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(); + +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(); + }); +}); diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx index 81cf210..b0bbead 100644 --- a/src/components/BacklogCard.jsx +++ b/src/components/BacklogCard.jsx @@ -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; diff --git a/src/components/PlayingHint.jsx b/src/components/PlayingHint.jsx new file mode 100644 index 0000000..c062d31 --- /dev/null +++ b/src/components/PlayingHint.jsx @@ -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 ( + +
+ +

+ {t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})} +

+
+ +
+ ); +} diff --git a/src/components/PlayingHint.test.jsx b/src/components/PlayingHint.test.jsx new file mode 100644 index 0000000..d0ce2d5 --- /dev/null +++ b/src/components/PlayingHint.test.jsx @@ -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( {}} {...props}/>); + +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'}); + }); +}); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index be5a1ff..fce0cf9 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 = () => { )} )} + + {hasSteamId && activity && } ); }; diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 9463282..7e255c8 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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() { - setData((d) => ({...d, ...entry}))}/> + setData((d) => ({...d, ...entry}))}/> + + setData((d) => ({...d, ...entry}))}/> {!data.hasAchievements && ( diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx new file mode 100644 index 0000000..aa015c2 --- /dev/null +++ b/src/pages/GameDetail.test.jsx @@ -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( + + }/> + , +); + +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'); + }); +}); diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 33f149f..b4780f4 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -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);