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

+2
View File
@@ -23,6 +23,7 @@ import LinkQueuePage from './pages/LinkQueuePage.jsx';
import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
import BacklogPage from './pages/BacklogPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx';
@@ -83,6 +84,7 @@ function App() {
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/backlog" element={<BacklogPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
+2 -2
View File
@@ -29,8 +29,8 @@ import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist is a tab of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
// The wishlist and the backlog order are tabs of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
];
+10 -3
View File
@@ -1,11 +1,12 @@
import {NavLink} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Gift, Library} from 'lucide-react';
import {Gift, Library, ListOrdered} from 'lucide-react';
import {cn} from '@/lib/utils';
const TABS = [
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
{to: '/backlog', labelKey: 'BACKLOG_ORDER.TITLE', icon: ListOrdered},
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
];
@@ -18,13 +19,19 @@ export default function LibraryTabs() {
key={to}
to={to}
end
title={t(labelKey)}
className={({isActive}) => cn(
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-4 shrink-0"/>
<span className="truncate">{t(labelKey)}</span>
{({isActive}) => (
<>
<Icon className="size-4 shrink-0"/>
{/* Below sm only the active tab shows its label; the others keep it as their name. */}
<span className={cn('truncate', !isActive && 'sr-only sm:not-sr-only')}>{t(labelKey)}</span>
</>
)}
</NavLink>
))}
</nav>
+46
View File
@@ -0,0 +1,46 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowRight, ListOrdered} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
/**
* The first games of the user's own backlog order.
*/
export default function UpNextCard({games}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<ListOrdered className="size-4 text-primary"/>
{t('DASHBOARD.UP_NEXT')}
</h2>
<Button asChild variant="ghost" size="sm">
<Link to="/backlog">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
{games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground">{t('DASHBOARD.UP_NEXT_EMPTY')}</p>
) : (
<ol className="divide-y">
{games.map((g, index) => (
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/50">
<span className="w-4 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{index + 1}</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<div className="@container min-w-0 flex-1 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
</Link>
</li>
))}
</ol>
)}
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
// @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 UpNextCard from './UpNextCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const renderCard = (games) => render(<MemoryRouter><UpNextCard games={games}/></MemoryRouter>);
describe('UpNextCard', () => {
afterEach(cleanup);
it('shows the games with their rank and links to the backlog page', () => {
renderCard([{appid: 7, name: 'Hades'}, {appid: 9, name: 'Celeste'}]);
const rows = screen.getAllByRole('listitem');
expect(rows.map((row) => row.textContent)).toEqual(['1Hades', '2Celeste']);
expect(screen.getByRole('link', {name: /DASHBOARD.SHOW_ALL/}).getAttribute('href')).toBe('/backlog');
});
it('says so when the backlog is empty', () => {
renderCard([]);
expect(screen.getByText('DASHBOARD.UP_NEXT_EMPTY')).toBeTruthy();
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
});
});
+8 -4
View File
@@ -1,10 +1,11 @@
import {useCallback, useEffect, useState} from 'react';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js';
import {PLAYING_GAMES, TOP_GAMES} from '../utils/tiles.js';
import {PLAYING_GAMES, TOP_GAMES, UP_NEXT_GAMES} from '../utils/tiles.js';
// The games endpoint accepts only the page sizes 20, 40 and 80.
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
const UP_NEXT_URL = `/api/library/backlog?limit=${UP_NEXT_GAMES.L}&released=true`;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
// A card whose request fails or has no JSON body stays empty.
@@ -18,6 +19,7 @@ export default function useDashboardData() {
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]);
const [upNext, setUpNext] = useState([]);
const [summary, setSummary] = useState(null);
const [activity, setActivity] = useState(null);
const [achievements, setAchievements] = useState(null);
@@ -28,6 +30,7 @@ export default function useDashboardData() {
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
const fetchUpNext = useCallback(() => loadInto(UP_NEXT_URL, setUpNext, (data) => data.games ?? []), []);
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
@@ -47,6 +50,7 @@ export default function useDashboardData() {
fetchSteamCount();
fetchTopGames();
fetchPlaying();
fetchUpNext();
fetchSummary();
fetchActivity();
fetchAchievements();
@@ -57,7 +61,7 @@ export default function useDashboardData() {
const {ok, status, data} = await reloadUser();
if (ok) {
// Games added by hand need no Steam ID; history and achievements come only from Steam.
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(),
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(), fetchSummary(),
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
} else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE');
@@ -68,10 +72,10 @@ export default function useDashboardData() {
};
fetchUserData();
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
return {
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
fetchSummary, reloadLibrary,
};
}
+61
View File
@@ -0,0 +1,61 @@
import {useRef, useState} from 'react';
/**
* Drag to reorder a list with pointer events, so it works with a mouse and with touch. Put `handleProps(index)` on
* a drag handle and `rowRef(index)` on each row. The handle needs `touch-none`: only it stops the page scroll.
* The drop target is the row whose middle is closest to the pointer. There is no auto-scroll.
*
* @param onMove called with (from, to) when a drag ends on another row
* @returns dragFrom and dragOver: the indexes of the dragged row and of the row it is over, null when idle
*/
export default function useDragSort(onMove) {
const [drag, setDrag] = useState({from: null, over: null});
const current = useRef(drag);
const rows = useRef([]);
const update = (next) => {
current.current = next;
setDrag(next);
};
const reset = () => update({from: null, over: null});
const closestRow = (clientY) => {
let best = null;
let bestDistance = Infinity;
rows.current.forEach((row, index) => {
if (!row) return;
const rect = row.getBoundingClientRect();
const distance = Math.abs(rect.top + rect.height / 2 - clientY);
if (distance < bestDistance) {
best = index;
bestDistance = distance;
}
});
return best;
};
const handleProps = (index) => ({
onPointerDown: (e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture?.(e.pointerId);
update({from: index, over: index});
},
onPointerMove: (e) => {
if (current.current.from === null) return;
const over = closestRow(e.clientY);
if (over !== null && over !== current.current.over) update({...current.current, over});
},
onPointerUp: () => {
const {from, over} = current.current;
reset();
if (from !== null && over !== null && from !== over) onMove(from, over);
},
onPointerCancel: reset,
});
const rowRef = (index) => (node) => {
rows.current[index] = node;
};
return {dragFrom: drag.from, dragOver: drag.over, rowRef, handleProps};
}
+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();
+25
View File
@@ -0,0 +1,25 @@
import {apiFetch, request} from './api.js';
/**
* The listed backlog in the user's own order, coming games included.
*/
export const loadBacklog = (init) => request(`${import.meta.env.VITE_CORE_API_URL}/api/library/backlog`, init);
export const saveBacklogOrder = (appids) =>
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/backlog`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({appids}),
});
/**
* A new list with the item at `from` moved to index `to`, which is clamped to the list. The same list when nothing moves.
*/
export const moveItem = (list, from, to) => {
const target = Math.min(Math.max(to, 0), list.length - 1);
if (from < 0 || from >= list.length || from === target) return list;
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(target, 0, item);
return next;
};
+27
View File
@@ -0,0 +1,27 @@
import {describe, expect, it} from 'vitest';
import {moveItem} from './backlogOrder.js';
const list = ['a', 'b', 'c', 'd'];
describe('moveItem', () => {
it('moves an item up and down by one', () => {
expect(moveItem(list, 2, 1)).toEqual(['a', 'c', 'b', 'd']);
expect(moveItem(list, 1, 2)).toEqual(['a', 'c', 'b', 'd']);
});
it('moves an item to the top and to the bottom', () => {
expect(moveItem(list, 2, 0)).toEqual(['c', 'a', 'b', 'd']);
expect(moveItem(list, 1, 3)).toEqual(['a', 'c', 'd', 'b']);
});
it('clamps a target outside the list', () => {
expect(moveItem(list, 2, -5)).toEqual(['c', 'a', 'b', 'd']);
expect(moveItem(list, 1, 99)).toEqual(['a', 'c', 'd', 'b']);
});
it('leaves the list as it is for a source outside the list, and does not change the input', () => {
expect(moveItem(list, 9, 0)).toBe(list);
moveItem(list, 0, 3);
expect(list).toEqual(['a', 'b', 'c', 'd']);
});
});
+4 -1
View File
@@ -23,6 +23,7 @@ export const DASHBOARD_TILES = {
GAME_COUNT: {S: 2, M: 4, L: 8},
MOST_PLAYED: {S: 4, M: 8, L: 12},
PLAYING: {S: 3, M: 6, L: 12},
UP_NEXT: {S: 3, M: 6, L: 12},
PILE_OF_SHAME: {M: 6, L: 12},
ACTIVITY: {S: 6, M: 12},
ACHIEVEMENTS: {S: 6, M: 12},
@@ -43,6 +44,7 @@ const STAT_TITLES = Object.fromEntries(Object.keys(STAT_TILES).map((id) => [id,
export const DASHBOARD_TITLES = {
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
UP_NEXT: 'DASHBOARD.UP_NEXT',
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
};
@@ -54,13 +56,14 @@ export const PROFILE_TITLES = {
const all = (ids) => ids.map((id) => ({id, size: 'M'}));
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'UP_NEXT', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
export const SIZES = ['S', 'M', 'L'];
// Games per dashboard tile size; the dashboard loads the most for the large tile.
export const TOP_GAMES = {S: 3, M: 5, L: 10};
export const PLAYING_GAMES = {S: 2, M: 4, L: 8};
export const UP_NEXT_GAMES = {S: 3, M: 5, L: 5};
// Written out, so Tailwind finds every class.
const SPANS = {
+8
View File
@@ -19,6 +19,14 @@ describe('normalizeLayout', () => {
});
});
describe('DEFAULT_DASHBOARD', () => {
it('has the up next tile, and a saved layout keeps it', () => {
expect(DEFAULT_DASHBOARD.map((tile) => tile.id)).toContain('UP_NEXT');
expect(normalizeLayout([{id: 'UP_NEXT', size: 'L'}], DASHBOARD_TILES, DEFAULT_DASHBOARD))
.toEqual([{id: 'UP_NEXT', size: 'L'}]);
});
});
describe('spanClass', () => {
it('halves and doubles the normal width', () => {
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))