Public Access
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:
1 parent
d4c0f8d8f3
commit
89c5ba5253
11 files changed
+334
-15
No files matched your search
+8
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user