diff --git a/README.md b/README.md index 34007d3..138d622 100644 --- a/README.md +++ b/README.md @@ -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 " 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 `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 - 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 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. @@ -195,6 +199,7 @@ flowchart LR 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 (`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). diff --git a/public/i18n/de.json b/public/i18n/de.json index b89028c..7d8847f 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -202,6 +202,8 @@ "WELCOME_BACK": "Willkommen zurück", "CURRENTLY_PLAYING": "Spiele ich gerade", "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_SHARE": "{{percent}} % deiner Bibliothek", "NEVER_PLAYED_one": "{{count}} Spiel nie gestartet", @@ -386,6 +388,19 @@ "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": { "TITLE": "Wunschliste", "ONLY_ON_SALE": "Nur im Angebot", @@ -411,11 +426,12 @@ }, "PLAY_NEXT": { "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": { "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", "LONGEST": "Auf deiner Liste seit {{date}}", - "RANDOM": "Zufallstreffer" + "RANDOM": "Zufallstreffer", + "NEXT_UP": "Als Nächstes in deinem Backlog" }, "BUDGET_LABEL": "Spieldauer", "BUDGET": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 49fc154..449e3b4 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -202,6 +202,8 @@ "WELCOME_BACK": "Welcome back", "CURRENTLY_PLAYING": "Currently 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_SHARE": "{{percent}}% of your library", "NEVER_PLAYED_one": "{{count}} game never started", @@ -386,6 +388,19 @@ "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": { "TITLE": "Wishlist", "ONLY_ON_SALE": "On sale only", @@ -411,11 +426,12 @@ }, "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": { "SIMILAR": "Because you played {{name}} ({{playtime}})", "LONGEST": "On your list since {{date}}", - "RANDOM": "Random pick" + "RANDOM": "Random pick", + "NEXT_UP": "Next in your backlog" }, "BUDGET_LABEL": "Time to beat", "BUDGET": { diff --git a/src/App.jsx b/src/App.jsx index 132b013..a987cb9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index dba7030..21b755b 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -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}, ]; diff --git a/src/components/LibraryTabs.jsx b/src/components/LibraryTabs.jsx index 2c1b675..45d3732 100644 --- a/src/components/LibraryTabs.jsx +++ b/src/components/LibraryTabs.jsx @@ -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', )} > - - {t(labelKey)} + {({isActive}) => ( + <> + + {/* Below sm only the active tab shows its label; the others keep it as their name. */} + {t(labelKey)} + + )} ))} diff --git a/src/components/UpNextCard.jsx b/src/components/UpNextCard.jsx new file mode 100644 index 0000000..d98d475 --- /dev/null +++ b/src/components/UpNextCard.jsx @@ -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 ( + +
+

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

+ +
+ + {games.length === 0 ? ( +

{t('DASHBOARD.UP_NEXT_EMPTY')}

+ ) : ( +
    + {games.map((g, index) => ( +
  1. + + {index + 1} + +
    + +
    + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/components/UpNextCard.test.jsx b/src/components/UpNextCard.test.jsx new file mode 100644 index 0000000..3c4cf32 --- /dev/null +++ b/src/components/UpNextCard.test.jsx @@ -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(); + +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); + }); +}); diff --git a/src/hooks/useDashboardData.js b/src/hooks/useDashboardData.js index b8287bc..b91f495 100644 --- a/src/hooks/useDashboardData.js +++ b/src/hooks/useDashboardData.js @@ -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, }; } diff --git a/src/hooks/useDragSort.js b/src/hooks/useDragSort.js new file mode 100644 index 0000000..565abd5 --- /dev/null +++ b/src/hooks/useDragSort.js @@ -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}; +} diff --git a/src/pages/BacklogPage.jsx b/src/pages/BacklogPage.jsx new file mode 100644 index 0000000..95ed41f --- /dev/null +++ b/src/pages/BacklogPage.jsx @@ -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 ( + +
+ +

{t('BACKLOG_ORDER.TITLE')}

+

{t('BACKLOG_ORDER.SUBTITLE')}

+
+ + {games === null && !error && ( + + {Array.from({length: 5}, (_, i) => ( +
+ + +
+ ))} +
+ )} + + {error &&
{t(error)}
} + + {games?.length === 0 && ( + + +

{t('BACKLOG_ORDER.EMPTY')}

+ +
+ )} + + {games?.length > 0 && ( + +
    + {games.map((game, index) => ( +
  1. + + {index + 1} + + +
    + + + +
    + + {game.timeToBeatMinutes > 0 && ( + + {formatPlaytime(game.timeToBeatMinutes)} + + )} + {game.priority && ( + {t(`BACKLOG.PRIORITY.${game.priority}`)} + )} +
    +
    +
    + + + + + + + + + move(index, 0)}> + {t('BACKLOG_ORDER.MOVE_TOP')} + + move(index, games.length - 1)}> + {t('BACKLOG_ORDER.MOVE_BOTTOM')} + + + +
    +
  2. + ))} +
+
+ )} +
+ ); +} diff --git a/src/pages/BacklogPage.test.jsx b/src/pages/BacklogPage.test.jsx new file mode 100644 index 0000000..f411f74 --- /dev/null +++ b/src/pages/BacklogPage.test.jsx @@ -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(, + {path: '/backlog', entries: ['/backlog'], guarded: true, routes: {'/library':

library

}}); + +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'); + }); +}); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 810a3ef..78cfa75 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 && ( ); + case 'UP_NEXT': return showLibrary && ; case 'PILE_OF_SHAME': if (!showLibrary) return null; if (summary) { diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index 709caca..9cffb92 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -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)), diff --git a/src/pages/Dashboard.visitSync.test.jsx b/src/pages/Dashboard.visitSync.test.jsx index eb8bba6..5f0add2 100644 --- a/src/pages/Dashboard.visitSync.test.jsx +++ b/src/pages/Dashboard.visitSync.test.jsx @@ -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, diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 7a216f9..c8afc81 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -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 &&

{t(error)}

} {games === null && !error && ( -
+
{Array.from({length: PICK_COUNT}, (_, i) => )}
)} {games?.length > 0 && ( -
+
{games.map((game) => )}
)} @@ -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'); }; diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index ff2894f..faa1823 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -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(); diff --git a/src/utils/backlogOrder.js b/src/utils/backlogOrder.js new file mode 100644 index 0000000..531045b --- /dev/null +++ b/src/utils/backlogOrder.js @@ -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; +}; diff --git a/src/utils/backlogOrder.test.js b/src/utils/backlogOrder.test.js new file mode 100644 index 0000000..345a06d --- /dev/null +++ b/src/utils/backlogOrder.test.js @@ -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']); + }); +}); diff --git a/src/utils/tiles.js b/src/utils/tiles.js index a9fbf97..85b61d3 100644 --- a/src/utils/tiles.js +++ b/src/utils/tiles.js @@ -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 = { diff --git a/src/utils/tiles.test.js b/src/utils/tiles.test.js index d9e3cc3..c4e5944 100644 --- a/src/utils/tiles.test.js +++ b/src/utils/tiles.test.js @@ -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})))