Shows six feature cards and two next steps (Steam, import). The dashboard redirects to it while Auth settings.welcomeSeenAt is null; "Let's go" and "Skip" set the stamp. Settings links to it to reopen later.
To Be Played – Frontend
Web app of To Be Played, a Steam backlog tracker: import your Steam library, decide what to play, and track what you finished. The UI is in German and English, with light and dark mode.
| Game detail | Public profile | Mobile |
|---|---|---|
![]() |
![]() |
![]() |
To Be Played is split into three repositories:
| Repository | Role |
|---|---|
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend (this one) | React single-page app |
Features
- Dashboard with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 days ("this month: +N added, -N off the pile") and a random suggestion, Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked total, perfect games, average completion and the 5 games closest to 100 %.
- What to play next (
/play-next): three 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), high priority first, reroll without repeats, and "Start playing" in one click. - 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. - Library: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest in library, rating or priority, paging. Each row shows the unlocked achievements.
- Add a game by name for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several hits to add them at once through the import review.
- Import (
/import): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the progress, and "Try again" for an import that stopped. The review shows the best match, other candidates and "search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be undone. - Export files in the same file field, told apart by their header (
src/utils/importFormats.js): GOG Galaxy 2.0 (the community script GOG-Galaxy-Export-Script, minutes), Grouvee (Settings, "Export your collection", shelves as status, the wish list is left out), HowLongToBeat (its CSV export, the lists as status, "Ignored" left out) and Playnite (the add-on "Library Exporter Advanced", completion status, seconds). Store and platform names become IGDB abbreviations (platformFromNames); an unknown one stays empty and the review preselects. Unverified: each format is built from the exporter's public source, not from a real file; the test files insrc/test/fixtures/importare made up. - Store pages (tab "Store page",
src/utils/storeList.js): a bookmarklet for PlayStation (library.playstation.com), Xbox (www.xbox.com), Nintendo eShop (ec.nintendo.com), Epic Games (www.epicgames.com) and GOG (www.gog.com). On the store page it reads the user's games with the page's own login and opens/import#data=...with at most 1000 titles and platforms. Firefox blocks bookmarklets on pages with a strict Content Security Policy, so the same code can be copied and pasted in the browser console. Xbox has no JSON without its own tokens: it reads the game links shown on the page and is the most likely to break. Nintendo lists purchases only, no free games. Unverified: each reader follows the site's JSON as public scripts use it, not a real account. - Platform everywhere: each game shows its platform ("Steam" for Steam games). The favorite platform from the settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on several waits for the user's pick.
- Game detail: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when every achievement is unlocked.
- Confetti: opening a finished game plays a small confetti burst, a finished game with every achievement a big one; setting the status to finished plays it at once. Logo and accent colors, a canvas of our own (no library), nothing with "reduce motion".
- Finished?: Core marks a game as finished by itself when the ending achievement is unlocked and the status was never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than most players need, finished?", with "Mark as finished" and "Not yet".
- Hide games: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists, the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
- Wishlist with store price and discount.
- Steam account linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to verify it; unlinking is possible.
- Public profile at
/u/<display name>, opt-in in the settings, with perfect games and average completion. The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of "Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art ("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs. - Appearance: accent color, corner style, wide layout, page background and banner from your own game art. Saved in Auth, so it follows you to every device.
- Language: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
mode stays per device. The registration sends the browser language (
defor a German browser, elseen): Auth writes its mails in it and saves it as the user's language. - Registration (
/register): no invite code; the page says that an admin checks each new account and stays on "registration sent" after the send. Auth answers409 PENDING_LIMIT_REACHEDwhile 20 wait. - Broken pictures: a Steam cover or achievement icon that does not load shows a placeholder and is reported
to Core once per page load (
POST /api/library/broken-images, only with a session). Core repairs it at night; a repaired cover comes back asimageUrl. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- Admin (PLATFORM_ADMIN only,
/admin/users): search the users, block, unblock and delete them (the email is typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change and menu opening. The login tells a waiting user that the admin has not decided yet. - Dashboard hints: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
"Accept" hint stays until "OK". Admins write, preview and end hints on
/admin/hints(Auth stores them). - What's new: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit.
/whats-new(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on/admin/news. - Welcome page:
/welcomeopens once after the first login (Authsettings.welcomeSeenAtis null; only the dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from/meand the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page links to it again. - Correct game data (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own. "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for every user.
- Delete my account in the settings: a dialog asks for the own email and the password. Auth blocks the account at once and removes it after 30 days; the page ends the session and goes to the landing page.
- Build version in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
commit. CI passes them as Docker build args; a local build shows
dev. - Installable as an app on phone and desktop (web app manifest, no service worker, so no offline mode).
- Session: the tokens are HttpOnly cookies that no script can read.
apiFetchrenews an expired access token once and sends the request again; parallel requests and other tabs share one refresh.
Tech stack
- React 19, React Router 7, Vite 8
- Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons
- react-hook-form + zod for forms, i18next for translations (
public/i18n) - Vitest + React Testing Library + jsdom
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy and security headers
- ESLint with the React hooks rules (
npm run lint) - CI: Forgejo Actions (
npm ci,npm run lint,npm test,npm run build, Docker image)
Architecture
flowchart LR
Browser["Browser"] --> Proxy["Traefik (TLS)"]
Proxy --> Frontend["ToBePlayed-Frontend<br/>nginx, static files"]
Proxy --> Auth["ToBePlayed-Auth"]
Proxy --> Core["ToBePlayed-Core"]
Core -->|"JWKS"| Auth
Auth --> Db[("PostgreSQL")]
Core --> Db
Core --> Steam["Steam Web API"]
Core --> Igdb["IGDB API"]
Core -->|"OAuth token"| Twitch["Twitch"]
Core --> Speedrun["speedrun.com API"]
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"]
Auth -->|"check_authentication"| SteamLogin
- 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).
- Calls for the logged-in user go through
src/utils/api.js.clientFetchsends the HttpOnly cookies and theX-TBP-Clientheader.apiFetchanswers a401with onePOST /api/auth/refreshand sends the request again. Parallel requests and other tabs share this refresh (navigator.locks). When the second answer is still a401,apiFetchends the session (expireSession); the pages only stop.request()wrapsapiFetchas{ok, status, data}and never throws. Public pages (landing page, public profile) and the publicGET /api/versionin the settings use plainfetch. src/utils/auth.jsis the session store:markSession,clearSession(logout) andexpireSession(a401) tell everyuseSession(), also on a login or logout in another tab. The user (CurrentUserProvider) and the look (PreferencesProvider) follow it.- The pages that need a login sit under the
RequireSessionlayout route inApp.jsx. Without a session it opens the login page: after a401with the "session expired" message and back to the same page, after a logout without a way back. A public page stays open when the session ends there. CurrentUserProviderholdsGET /api/users/mefor the whole app (useCurrentUserContext,useCurrentUser). Dashboard and settings load it again when they open.- Tests render through
src/test/renderWithProviders.jsx(a data router likemain.jsx) and answer requests withsrc/test/fetchRoutes.js, which fails a test on any request without a route. src/utils/brokenImages.jsreports pictures that fail to load (GameCover, achievement icons on the game detail page) to Core.useLibrarySyncpolls Core's/api/library/sync-statuswhile a Steam sync runs, then loads the data again.- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
The import page reads it once and removes it from the URL.
RequireSessionkeeps the fragment on the way through the login page. - The import page polls a running import every 2 s. After a
5xxor a network error it waits twice as long (up to 30 s) and gives up after 10 failures in a row; a hidden tab does not poll. - All
localStorageaccess goes throughsrc/utils/storage.js: with site data blocked a read isnulland a write is lost, but the app still starts. PUT /api/users/settingsreplaces dark mode, language, display name and public profile at once, so every save goes throughsaveSettingsinsrc/utils/settings.js: one after the other, each on a freshGET /api/users/me.- Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing again.
Project structure
src/
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
│ # of one page that share page-only code
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
├── context/ # PreferencesContext: theme, language, appearance
├── utils/ # Validation schemas and helpers
└── test/ # Test setup (i18n mock)
Tests sit next to the code they test (Component.test.jsx).
Run locally
Local development without keys (profile local)
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
Java 25, Node 24 and Docker. Clone ToBePlayed-Auth, ToBePlayed-Core and ToBePlayed-Frontend next to each
other, then start them in this order (Core also starts without Auth and finds it later):
- Auth: in
ToBePlayed-Auth,./mvnw spring-boot:run -Dspring-boot.run.profiles=local(IDE: active profilelocal). Port 8080. - Core: in
ToBePlayed-Core,./mvnw spring-boot:run -Dspring-boot.run.profiles=local. Port 8090. - Frontend: in
ToBePlayed-Frontend,npm install, thennpm run dev, and openhttp://localhost:5173(localhost, not127.0.0.1, so the browser sends the login cookies to every local port). The development values must beVITE_API_URL=http://localhost:8080andVITE_CORE_API_URL=http://localhost:8090. - Log in with
dev@localhost/password123. On the dashboard, start a Steam sync: the library fills with 8 games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
The dev user is PLATFORM_ADMIN, so user, auditor and admin pages can all be tried ("View as" in the menu
switches down). What the profile does in Auth and Core: their readmes, "Run locally".
Against other services
Needs Node 24 and running Auth and Core services.
npm install
npm run dev
| Variable | Purpose |
|---|---|
VITE_API_URL |
Base URL of the Auth service |
VITE_CORE_API_URL |
Base URL of the Core service |
Values for development and production live in .env.development and .env.production. They end up in the
browser bundle, so they must never hold a secret.
Tests and build
npm run lint
npm test
npm run build
Roadmap
See ROADMAP.md in the ToBePlayed-Deployment repo.
License
All rights reserved. The source is public for reading only.



