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

+7 -2
View File
@@ -28,9 +28,13 @@ To Be Played is split into three repositories:
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
`pileGames`, so coming games are not on it; Core never picks them. `pileGames`, so coming games are not on it; Core never picks them.
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood - **What to play next** (`/play-next`): four backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
played a lot, waiting longest, or random. Reroll without repeats, and "Start playing" in one click. played a lot, waiting longest, random, or next in your own backlog order. Reroll without repeats, and "Start playing" in one click.
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker has a "next in your
backlog" reason.
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in - **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods; years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
@@ -195,6 +199,7 @@ flowchart LR
Auth -->|"check_authentication"| SteamLogin Auth -->|"check_authentication"| SteamLogin
``` ```
- `useDragSort` (`src/hooks/useDragSort.js`) sorts a list with pointer events. Only the drag handle has `touch-action: none`, so the page still scrolls from a row.
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly - nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
(`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts. (`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts.
- Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS). - Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS).
+18 -2
View File
@@ -202,6 +202,8 @@
"WELCOME_BACK": "Willkommen zurück", "WELCOME_BACK": "Willkommen zurück",
"CURRENTLY_PLAYING": "Spiele ich gerade", "CURRENTLY_PLAYING": "Spiele ich gerade",
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.", "NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
"UP_NEXT": "Als Nächstes",
"UP_NEXT_EMPTY": "Dein Backlog ist leer. Schön!",
"PILE_OF_SHAME": "Pile of Shame", "PILE_OF_SHAME": "Pile of Shame",
"PILE_SHARE": "{{percent}} % deiner Bibliothek", "PILE_SHARE": "{{percent}} % deiner Bibliothek",
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet", "NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
@@ -386,6 +388,19 @@
"LOW": "Niedrig" "LOW": "Niedrig"
} }
}, },
"BACKLOG_ORDER": {
"TITLE": "Backlog",
"SUBTITLE": "Dein Backlog in deiner eigenen Reihenfolge. Ziehe ein Spiel oder nutze die Pfeile und das Menü. Die ersten Spiele erscheinen im Spielvorschlag und auf dem Dashboard.",
"EMPTY": "Dein Backlog ist leer. Schön!",
"LOAD_FAILED": "Der Backlog konnte nicht geladen werden.",
"SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.",
"MOVE_UP": "Nach oben",
"MOVE_DOWN": "Nach unten",
"MOVE_TOP": "Ganz nach oben",
"MOVE_BOTTOM": "Ganz nach unten",
"MOVE_DRAG": "Zum Verschieben ziehen",
"MORE": "Mehr"
},
"WISHLIST": { "WISHLIST": {
"TITLE": "Wunschliste", "TITLE": "Wunschliste",
"ONLY_ON_SALE": "Nur im Angebot", "ONLY_ON_SALE": "Nur im Angebot",
@@ -411,11 +426,12 @@
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?", "TITLE": "Was spiele ich als Nächstes?",
"SUBTITLE": "Drei Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende und eins zufällig. Wähle eine Länge und eine Stimmung.", "SUBTITLE": "Vier Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende, eins zufällig und eins von ganz oben in deiner Reihenfolge. Wähle eine Länge und eine Stimmung.",
"REASON": { "REASON": {
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
"LONGEST": "Auf deiner Liste seit {{date}}", "LONGEST": "Auf deiner Liste seit {{date}}",
"RANDOM": "Zufallstreffer" "RANDOM": "Zufallstreffer",
"NEXT_UP": "Als Nächstes in deinem Backlog"
}, },
"BUDGET_LABEL": "Spieldauer", "BUDGET_LABEL": "Spieldauer",
"BUDGET": { "BUDGET": {
+18 -2
View File
@@ -202,6 +202,8 @@
"WELCOME_BACK": "Welcome back", "WELCOME_BACK": "Welcome back",
"CURRENTLY_PLAYING": "Currently playing", "CURRENTLY_PLAYING": "Currently playing",
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".", "NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
"UP_NEXT": "Up next",
"UP_NEXT_EMPTY": "Your backlog is empty. Nice!",
"PILE_OF_SHAME": "Pile of Shame", "PILE_OF_SHAME": "Pile of Shame",
"PILE_SHARE": "{{percent}}% of your library", "PILE_SHARE": "{{percent}}% of your library",
"NEVER_PLAYED_one": "{{count}} game never started", "NEVER_PLAYED_one": "{{count}} game never started",
@@ -386,6 +388,19 @@
"LOW": "Low" "LOW": "Low"
} }
}, },
"BACKLOG_ORDER": {
"TITLE": "Backlog",
"SUBTITLE": "Your backlog in your own order. Drag a game, or use the arrows and the menu. The first games come up in the picker and on the dashboard.",
"EMPTY": "Your backlog is empty. Nice!",
"LOAD_FAILED": "Could not load the backlog.",
"SAVE_FAILED": "Could not save the order. The list is loaded again.",
"MOVE_UP": "Move up",
"MOVE_DOWN": "Move down",
"MOVE_TOP": "Move to top",
"MOVE_BOTTOM": "Move to bottom",
"MOVE_DRAG": "Drag to move",
"MORE": "More"
},
"WISHLIST": { "WISHLIST": {
"TITLE": "Wishlist", "TITLE": "Wishlist",
"ONLY_ON_SALE": "On sale only", "ONLY_ON_SALE": "On sale only",
@@ -411,11 +426,12 @@
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "What to play next", "TITLE": "What to play next",
"SUBTITLE": "Three games from your backlog: one like a game you played a lot, the one waiting longest, and one at random. Pick a length and a mood.", "SUBTITLE": "Four games from your backlog: one like a game you played a lot, the one waiting longest, one at random, and one from the top of your own order. Pick a length and a mood.",
"REASON": { "REASON": {
"SIMILAR": "Because you played {{name}} ({{playtime}})", "SIMILAR": "Because you played {{name}} ({{playtime}})",
"LONGEST": "On your list since {{date}}", "LONGEST": "On your list since {{date}}",
"RANDOM": "Random pick" "RANDOM": "Random pick",
"NEXT_UP": "Next in your backlog"
}, },
"BUDGET_LABEL": "Time to beat", "BUDGET_LABEL": "Time to beat",
"BUDGET": { "BUDGET": {
+2
View File
@@ -23,6 +23,7 @@ import LinkQueuePage from './pages/LinkQueuePage.jsx';
import GameLibrary from './pages/GameLibrary.jsx'; import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx'; import WishlistPage from './pages/WishlistPage.jsx';
import BacklogPage from './pages/BacklogPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx'; import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx'; import ImportPage from './pages/ImportPage.jsx';
@@ -83,6 +84,7 @@ function App() {
<Route path="/library" element={<GameLibrary/>}/> <Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/> <Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/backlog" element={<BacklogPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/> <Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/> <Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/> <Route path="/import" element={<ImportPage/>}/>
+2 -2
View File
@@ -29,8 +29,8 @@ import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [ const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist is a tab of the library. // The wishlist and the backlog order are tabs of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']}, {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn}, {to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
]; ];
+10 -3
View File
@@ -1,11 +1,12 @@
import {NavLink} from 'react-router-dom'; import {NavLink} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Gift, Library} from 'lucide-react'; import {Gift, Library, ListOrdered} from 'lucide-react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
const TABS = [ const TABS = [
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library}, {to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
{to: '/backlog', labelKey: 'BACKLOG_ORDER.TITLE', icon: ListOrdered},
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift}, {to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
]; ];
@@ -18,13 +19,19 @@ export default function LibraryTabs() {
key={to} key={to}
to={to} to={to}
end end
title={t(labelKey)}
className={({isActive}) => cn( className={({isActive}) => cn(
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors', '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', isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)} )}
> >
<Icon className="size-4 shrink-0"/> {({isActive}) => (
<span className="truncate">{t(labelKey)}</span> <>
<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> </NavLink>
))} ))}
</nav> </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 {useCallback, useEffect, useState} from 'react';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js'; 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. // 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 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'; 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. // 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 [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
const [topGames, setTopGames] = useState([]); const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]); const [playing, setPlaying] = useState([]);
const [upNext, setUpNext] = useState([]);
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [activity, setActivity] = useState(null); const [activity, setActivity] = useState(null);
const [achievements, setAchievements] = 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 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 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 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 fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []); const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
@@ -47,6 +50,7 @@ export default function useDashboardData() {
fetchSteamCount(); fetchSteamCount();
fetchTopGames(); fetchTopGames();
fetchPlaying(); fetchPlaying();
fetchUpNext();
fetchSummary(); fetchSummary();
fetchActivity(); fetchActivity();
fetchAchievements(); fetchAchievements();
@@ -57,7 +61,7 @@ export default function useDashboardData() {
const {ok, status, data} = await reloadUser(); const {ok, status, data} = await reloadUser();
if (ok) { if (ok) {
// Games added by hand need no Steam ID; history and achievements come only from Steam. // 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()] : [])]); ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
} else if (status === 0) { } else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE'); setError('INFO.ERROR.NETWORK_FAILURE');
@@ -68,10 +72,10 @@ export default function useDashboardData() {
}; };
fetchUserData(); fetchUserData();
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); }, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
return { return {
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error, steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
fetchSummary, reloadLibrary, 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 MostPlayedCard from '../components/MostPlayedCard.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx'; import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount'; import SteamGameCount from '../components/SteamGameCount';
import UpNextCard from '../components/UpNextCard.jsx';
import SyncChangesBanner from '../components/SyncChangesBanner.jsx'; import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
import WhatsNewDialog from '../components/WhatsNewDialog.jsx'; import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
import useDashboardData from '../hooks/useDashboardData.js'; import useDashboardData from '../hooks/useDashboardData.js';
@@ -29,7 +30,7 @@ import TileGrid from '../components/TileGrid.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx';
import { 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'; } from '../utils/tiles.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
@@ -38,7 +39,7 @@ const Dashboard = () => {
const {t} = useTranslation(); const {t} = useTranslation();
const {user: userData, updateUser} = useCurrentUserContext(); const {user: userData, updateUser} = useCurrentUserContext();
const { const {
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error, steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
fetchSummary, reloadLibrary, fetchSummary, reloadLibrary,
} = useDashboardData(); } = useDashboardData();
const [shuffling, setShuffling] = useState(false); const [shuffling, setShuffling] = useState(false);
@@ -110,6 +111,7 @@ const Dashboard = () => {
return showLibrary && ( return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/> <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': case 'PILE_OF_SHAME':
if (!showLibrary) return null; if (!showLibrary) return null;
if (summary) { 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: [ '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'}]}), {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/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, 'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}), averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
'PUT /api/users/settings/dashboard-tiles': json(204, {}), 'PUT /api/users/settings/dashboard-tiles': json(204, {}),
@@ -85,7 +86,7 @@ describe('Dashboard', () => {
const container = mockDashboard(); const container = mockDashboard();
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE'); 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.getByText('Game 4')).toBeTruthy();
expect(screen.queryByText('Game 5')).toBeNull(); expect(screen.queryByText('Game 5')).toBeNull();
}); });
@@ -213,6 +214,7 @@ describe('Dashboard', () => {
'GET /api/library/stats': json(200, empty), 'GET /api/library/stats': json(200, empty),
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}), '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/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/backlog': json(200, {games: []}),
'GET /api/library/games': (url) => (mostPlayed(url) 'GET /api/library/games': (url) => (mostPlayed(url)
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }} ? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
: json(200, empty)), : 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/stats': json(200, {trend: null}),
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: 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/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/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
'GET /api/library/sync-status': () => json(200, syncStatus), 'GET /api/library/sync-status': () => json(200, syncStatus),
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0, '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 // 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. // games without that data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN']; 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. // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100; const MAX_EXCLUDES = 100;
@@ -139,13 +139,13 @@ export default function PlayNextPage() {
{error && <p className="text-sm text-destructive">{t(error)}</p>} {error && <p className="text-sm text-destructive">{t(error)}</p>}
{games === null && !error && ( {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"/>)} {Array.from({length: PICK_COUNT}, (_, i) => <Skeleton key={i} className="h-56 rounded-xl"/>)}
</div> </div>
)} )}
{games?.length > 0 && ( {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}/>)} {games.map((game) => <PickCard key={game.appid} game={game}/>)}
</div> </div>
)} )}
@@ -186,6 +186,7 @@ const reasonText = (reason, t, language) => {
if (reason.source === 'LONGEST') { if (reason.source === 'LONGEST') {
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})}); 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'); return t('PLAY_NEXT.REASON.RANDOM');
}; };
+10 -2
View File
@@ -43,7 +43,7 @@ describe('PlayNextPage', () => {
localStorage.clear(); 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)]]); mockBackend([[game(1), game(2), game(3)]]);
renderPage(); renderPage();
@@ -51,11 +51,19 @@ describe('PlayNextPage', () => {
expect(await screen.findByText('Game 1')).toBeTruthy(); expect(await screen.findByText('Game 1')).toBeTruthy();
expect(screen.getByText('Game 3')).toBeTruthy(); expect(screen.getByText('Game 3')).toBeTruthy();
const params = pickerUrls()[0]; 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('maxMinutes')).toBe(false);
expect(params.has('exclude')).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 () => { it('asks for games up to the chosen length', async () => {
mockBackend([[game(1)], [game(2)]]); mockBackend([[game(1)], [game(2)]]);
renderPage(); 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}, GAME_COUNT: {S: 2, M: 4, L: 8},
MOST_PLAYED: {S: 4, M: 8, L: 12}, MOST_PLAYED: {S: 4, M: 8, L: 12},
PLAYING: {S: 3, M: 6, L: 12}, PLAYING: {S: 3, M: 6, L: 12},
UP_NEXT: {S: 3, M: 6, L: 12},
PILE_OF_SHAME: {M: 6, L: 12}, PILE_OF_SHAME: {M: 6, L: 12},
ACTIVITY: {S: 6, M: 12}, ACTIVITY: {S: 6, M: 12},
ACHIEVEMENTS: {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 = { export const DASHBOARD_TITLES = {
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING', 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', PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES, STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
}; };
@@ -54,13 +56,14 @@ export const PROFILE_TITLES = {
const all = (ids) => ids.map((id) => ({id, size: 'M'})); 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 DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
export const SIZES = ['S', 'M', 'L']; export const SIZES = ['S', 'M', 'L'];
// Games per dashboard tile size; the dashboard loads the most for the large tile. // 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 TOP_GAMES = {S: 3, M: 5, L: 10};
export const PLAYING_GAMES = {S: 2, M: 4, L: 8}; 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. // Written out, so Tailwind finds every class.
const SPANS = { 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', () => { describe('spanClass', () => {
it('halves and doubles the normal width', () => { it('halves and doubles the normal width', () => {
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size}))) expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))