feat(library): add ordered backlog page and up-next dashboard tile
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 24s

This commit is contained in:
lobo committed 2026-10-06 09:06:51 +02:00
1 parent c158909083
commit b213b067dc
21 files changed
+677 -24

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChevronDown, ChevronUp, EllipsisVertical, GripVertical, ListOrdered, Timer} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import ComingBadge from '../components/ComingBadge.jsx';
import FitText from '../components/FitText.jsx';
import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
import useDragSort from '../hooks/useDragSort.js';
import {loadBacklog, moveItem, saveBacklogOrder} from '../utils/backlogOrder.js';
import {formatPlaytime} from '../utils/games.js';
export default function BacklogPage() {
const {t} = useTranslation();
const nextSignal = useAbortPrevious();
const [games, setGames] = useState(null);
const [error, setError] = useState(null);
// While a PUT runs, only the newest list waits; it is sent when the PUT answers, so the server gets them in order.
const saving = useRef(false);
const queued = useRef(null);
const load = useCallback(async () => {
const signal = nextSignal();
const {ok, status, data} = await loadBacklog({signal});
if (signal.aborted || status === 401) return;
if (ok) {
setError(null);
setGames(data.games ?? []);
} else {
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'BACKLOG_ORDER.LOAD_FAILED');
}
}, [nextSignal]);
useEffect(() => {
load();
}, [load]);
const save = async (appids) => {
if (saving.current) {
queued.current = appids;
return;
}
saving.current = true;
try {
let next = appids;
while (next) {
queued.current = null;
const response = await saveBacklogOrder(next);
if (response.status === 401) return;
if (!response.ok) throw new Error(`Saving the backlog order failed with ${response.status}`);
next = queued.current;
}
} catch (err) {
console.error('Backlog order save error:', err);
queued.current = null;
toast.error(t('BACKLOG_ORDER.SAVE_FAILED'));
load();
} finally {
saving.current = false;
}
};
const move = (from, to) => {
const next = moveItem(games, from, to);
if (next === games) return;
setGames(next);
save(next.map((game) => game.appid));
};
const {dragFrom, dragOver, rowRef, handleProps} = useDragSort(move);
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<div className="space-y-3">
<LibraryTabs/>
<h1 className="text-3xl font-bold tracking-tight">{t('BACKLOG_ORDER.TITLE')}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{t('BACKLOG_ORDER.SUBTITLE')}</p>
</div>
{games === null && !error && (
<Card className="gap-0 py-0 divide-y">
{Array.from({length: 5}, (_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-3">
<Skeleton className="h-9 w-16 rounded-md"/>
<Skeleton className="h-4 flex-1"/>
</div>
))}
</Card>
)}
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{games?.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground">
<ListOrdered className="size-10"/>
<p className="max-w-sm text-sm">{t('BACKLOG_ORDER.EMPTY')}</p>
<Button asChild variant="outline" size="sm"><Link to="/library">{t('LIBRARY.TITLE')}</Link></Button>
</Card>
)}
{games?.length > 0 && (
<Card className="gap-0 py-0">
<ol className="divide-y">
{games.map((game, index) => (
<li
key={game.appid}
ref={rowRef(index)}
className={cn(
'flex items-center gap-1.5 px-2 py-2 sm:gap-3 sm:px-4',
dragFrom === index && 'bg-muted opacity-70',
dragFrom !== null && dragOver === index && dragFrom !== index && 'bg-primary/10',
)}
>
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground sm:w-7 sm:text-sm">
{index + 1}
</span>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-12 shrink-0 sm:w-24"/>
<div className="min-w-0 flex-1 space-y-1">
<Link to={`/library/${game.appid}`} className="@container block min-w-0 text-sm font-medium transition-colors hover:text-primary sm:text-base">
<FitText text={game.name ?? `App ${game.appid}`}/>
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<ComingBadge game={game} compact/>
{game.timeToBeatMinutes > 0 && (
<span className="flex items-center gap-1 tabular-nums">
<Timer className="size-3.5"/>{formatPlaytime(game.timeToBeatMinutes)}
</span>
)}
{game.priority && (
<Badge variant="secondary">{t(`BACKLOG.PRIORITY.${game.priority}`)}</Badge>
)}
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
aria-label={t('BACKLOG_ORDER.MOVE_DRAG')}
className={cn('touch-none select-none cursor-grab', dragFrom === index && 'cursor-grabbing')}
{...handleProps(index)}
>
<GripVertical/>
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={t('BACKLOG_ORDER.MOVE_UP')}
disabled={index === 0}
onClick={() => move(index, index - 1)}
>
<ChevronUp/>
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={t('BACKLOG_ORDER.MOVE_DOWN')}
disabled={index === games.length - 1}
onClick={() => move(index, index + 1)}
>
<ChevronDown/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t('BACKLOG_ORDER.MORE')}>
<EllipsisVertical/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={index === 0} onSelect={() => move(index, 0)}>
{t('BACKLOG_ORDER.MOVE_TOP')}
</DropdownMenuItem>
<DropdownMenuItem disabled={index === games.length - 1}
onSelect={() => move(index, games.length - 1)}>
{t('BACKLOG_ORDER.MOVE_BOTTOM')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
))}
</ol>
</Card>
)}
</PageContainer>
);
}
+195
View File
@@ -0,0 +1,195 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import BacklogPage from './BacklogPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = (appid, extra = {}) => ({appid, name: `Game ${appid}`, priority: null, timeToBeatMinutes: null, ...extra});
const backlog = {games: [game(1), game(2), game(3), game(4, {coming: true, comingOn: '2099-01-01'})]};
// The providers load the user and the preferences; this page does not use them.
const withAuth = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
const renderPage = () => renderWithProviders(<BacklogPage/>,
{path: '/backlog', entries: ['/backlog'], guarded: true, routes: {'/library': <p>library</p>}});
const names = () => [...document.querySelectorAll('ol a[href^="/library/"]')].map((a) => a.textContent);
const puts = () => globalThis.fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
.map(([, init]) => JSON.parse(init.body).appids);
const rowButton = (rowName, label) => {
const row = screen.getByText(rowName).closest('li');
return [...row.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === label);
};
describe('BacklogPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('shows the games in the order of the server, a coming game too', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
expect(screen.getByText('LIBRARY.COMING', {exact: false})).toBeTruthy();
});
it('moves a game up and down with the arrows and sends the whole list', async () => {
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': json(204, {}),
});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4]]));
expect(names()).toEqual(['Game 1', 'Game 3', 'Game 2', 'Game 4']);
fireEvent.click(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DOWN'));
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4], [3, 1, 2, 4]]));
});
it('does not offer to move the first game up or the last game down', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_UP').disabled).toBe(true);
expect(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_DOWN').disabled).toBe(true);
});
it.each([
['BACKLOG_ORDER.MOVE_TOP', 'Game 3', [3, 1, 2, 4]],
['BACKLOG_ORDER.MOVE_BOTTOM', 'Game 2', [1, 3, 4, 2]],
])('%s from the menu sends the new order', async (item, row, expected) => {
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': json(204, {}),
});
renderPage();
await screen.findByText('Game 1');
fireEvent.keyDown(rowButton(row, 'BACKLOG_ORDER.MORE'), {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitem', {name: item}));
await waitFor(() => expect(puts()).toEqual([expected]));
});
it('moves a game with the drag handle to the row under the pointer', async () => {
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': json(204, {}),
});
renderPage();
await screen.findByText('Game 1');
document.querySelectorAll('ol > li').forEach((row, i) => {
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
});
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
fireEvent.pointerMove(grip, {clientY: 240, pointerId: 1});
fireEvent.pointerUp(grip, {pointerId: 1});
await waitFor(() => expect(puts()).toEqual([[2, 3, 1, 4]]));
expect(names()).toEqual(['Game 2', 'Game 3', 'Game 1', 'Game 4']);
});
it('sends nothing when a drag ends on the row it started on, or is cancelled', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
document.querySelectorAll('ol > li').forEach((row, i) => {
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
});
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
fireEvent.pointerUp(grip, {pointerId: 1});
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
fireEvent.pointerMove(grip, {clientY: 340, pointerId: 1});
fireEvent.pointerCancel(grip, {pointerId: 1});
expect(puts()).toEqual([]);
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
});
it('shows a toast and loads the list again when saving fails', async () => {
let loads = 0;
withAuth({
'GET /api/library/backlog': () => json(200, ++loads === 1 ? backlog : {games: [game(2), game(1)]}),
'PUT /api/library/backlog': json(500, {}),
});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG_ORDER.SAVE_FAILED'));
await waitFor(() => expect(names()).toEqual(['Game 2', 'Game 1']));
});
it('sends only the newest list after a running save, so the server gets them in order', async () => {
let release;
const first = new Promise((resolve) => { release = resolve; });
let puts_ = 0;
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': async () => {
if (++puts_ === 1) await first;
return json(204, {});
},
});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
fireEvent.click(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_UP'));
expect(puts()).toEqual([[2, 1, 3, 4]]);
release();
await waitFor(() => expect(puts()).toEqual([[2, 1, 3, 4], [2, 3, 4, 1]]));
});
it('explains an empty backlog and links to the library', async () => {
withAuth({'GET /api/library/backlog': json(200, {games: []})});
renderPage();
expect(await screen.findByText('BACKLOG_ORDER.EMPTY')).toBeTruthy();
// The tab and the link of the empty card.
expect(screen.getAllByRole('link', {name: 'LIBRARY.TITLE'})).toHaveLength(2);
});
it('shows an error when the list cannot be loaded', async () => {
withAuth({'GET /api/library/backlog': json(500, {})});
renderPage();
expect(await screen.findByText('BACKLOG_ORDER.LOAD_FAILED')).toBeTruthy();
});
it('lets only the drag handle stop the page scroll', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG').className).toContain('touch-none');
const row = screen.getByText('Game 1').closest('li');
expect(row.className).not.toContain('touch-none');
expect(screen.getByText('Game 1').closest('ol').className).not.toContain('touch-none');
});
});
+4 -2
View File
@@ -14,6 +14,7 @@ import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
import MostPlayedCard from '../components/MostPlayedCard.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount';
import UpNextCard from '../components/UpNextCard.jsx';
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
import useDashboardData from '../hooks/useDashboardData.js';
@@ -29,7 +30,7 @@ import TileGrid from '../components/TileGrid.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx';
import {
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
} from '../utils/tiles.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
@@ -38,7 +39,7 @@ const Dashboard = () => {
const {t} = useTranslation();
const {user: userData, updateUser} = useCurrentUserContext();
const {
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
fetchSummary, reloadLibrary,
} = useDashboardData();
const [shuffling, setShuffling] = useState(false);
@@ -110,6 +111,7 @@ const Dashboard = () => {
return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
);
case 'UP_NEXT': return showLibrary && <UpNextCard games={upNext.slice(0, UP_NEXT_GAMES[size])}/>;
case 'PILE_OF_SHAME':
if (!showLibrary) return null;
if (summary) {
+3 -1
View File
@@ -33,6 +33,7 @@ const dashboardRoutes = (settings) => ({
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/backlog': json(200, {games: [{appid: 7, name: 'Hades'}]}),
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
@@ -85,7 +86,7 @@ describe('Dashboard', () => {
const container = mockDashboard();
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'UP_NEXT', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
expect(screen.getByText('Game 4')).toBeTruthy();
expect(screen.queryByText('Game 5')).toBeNull();
});
@@ -213,6 +214,7 @@ describe('Dashboard', () => {
'GET /api/library/stats': json(200, empty),
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/backlog': json(200, {games: []}),
'GET /api/library/games': (url) => (mostPlayed(url)
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
: json(200, empty)),
+1
View File
@@ -36,6 +36,7 @@ describe('Dashboard sync on a visit', () => {
'GET /api/library/stats': json(200, {trend: null}),
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
'GET /api/library/backlog': json(200, {games: []}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
'GET /api/library/sync-status': () => json(200, syncStatus),
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0,
+4 -3
View File
@@ -27,7 +27,7 @@ const BUDGETS = [
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
// games without that data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
const PICK_COUNT = 3;
const PICK_COUNT = 4;
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100;
@@ -139,13 +139,13 @@ export default function PlayNextPage() {
{error && <p className="text-sm text-destructive">{t(error)}</p>}
{games === null && !error && (
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({length: PICK_COUNT}, (_, i) => <Skeleton key={i} className="h-56 rounded-xl"/>)}
</div>
)}
{games?.length > 0 && (
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
</div>
)}
@@ -186,6 +186,7 @@ const reasonText = (reason, t, language) => {
if (reason.source === 'LONGEST') {
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
}
if (reason.source === 'NEXT_UP') return t('PLAY_NEXT.REASON.NEXT_UP');
return t('PLAY_NEXT.REASON.RANDOM');
};
+10 -2
View File
@@ -43,7 +43,7 @@ describe('PlayNextPage', () => {
localStorage.clear();
});
it('shows three picks for any length by default', async () => {
it('shows four picks for any length by default', async () => {
mockBackend([[game(1), game(2), game(3)]]);
renderPage();
@@ -51,11 +51,19 @@ describe('PlayNextPage', () => {
expect(await screen.findByText('Game 1')).toBeTruthy();
expect(screen.getByText('Game 3')).toBeTruthy();
const params = pickerUrls()[0];
expect(params.get('count')).toBe('3');
expect(params.get('count')).toBe('4');
expect(params.has('maxMinutes')).toBe(false);
expect(params.has('exclude')).toBe(false);
});
it('says why a game from the top of the backlog order was picked', async () => {
mockBackend([[game(1, {reason: {source: 'NEXT_UP'}})]]);
renderPage();
expect(await screen.findByText('PLAY_NEXT.REASON.NEXT_UP')).toBeTruthy();
});
it('asks for games up to the chosen length', async () => {
mockBackend([[game(1)], [game(2)]]);
renderPage();