Registration shows the address used and can start over. Confirm page says an admin approves; no login link. Welcome puts next steps first; Steam link returns to the dashboard. Dashboard and library explain an empty or private library. First sync shows queued, polls slower up to 10 min, then a calm text. Login: simple checks, resend offer after 401, password toggles. Only a real expired link counts as invalid; typed passwords stay. Default language follows the browser; texts are platform-neutral.
To Be Played – Frontend
Web app of To Be Played, a backlog tracker: import your Steam and PlayStation libraries or add games by hand, decide what to play, and track what you finished. The UI is in German and English, with light and dark mode.
| Dashboard | Game detail | Public profile | Mobile |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
To Be Played is split into four 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 |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
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" and a "Following" tile. Stored
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games and games in progress (one call,
GET /api/library/dashboard-games), "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 %. The card counts PlayStation trophies too (platinum = perfect) and also shows for a user with only a PlayStation link. - 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): seven rows of up to 3 backlog games each: up next (your own backlog order), next in series (the next part of a series you played), because you played (a similar game for each of your 3 most played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click. On a phone each row scrolls sideways. - 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's "Up next" row shows the first games. - 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. The user picks a chart form per group (bars, pie, donut, line or list,src/utils/statCharts.js). Auth saves it (PUT /api/users/settings/stat-chart). - Click a number (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime), averages, rates and the moon trips. The list always uses the same rules as the number.
- Pile Points (
/points): points for playing games off your pile (start, finish by time to beat, 100 %, month and streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG ineffects.css, two canvas engines inEffectCanvas; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings), shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more play time). Skins (skins.css, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still (canvas: one static frame, no loop) with the system setting "reduce motion" or the "Reduce animations" switch in the settings. After every status save (game page, Play Next "Start", Questions, add game)usePointsCheckasks CoreGET /api/rewards/games/{appid}, reloads the wallet when the balance moved and shows a toast ("+N points", "Already paid on ...", "No points yet: reason"). The game page shows the paid rows and the reason per missing kind (GamePointsLine);pointsNoticeinutils/rewards.jspicks the toast, the reason codes come from Core andPOINTS.REASONSonly maps them to texts. A finish without proof pays 10 early points (toast "+10 points; the rest comes with proof", game line "The rest of the finish points: play once on Steam after finishing"), and the rest once Steam play proves it. A "Finished?" card on the Questions page shows "Up to N points" fromGET /api/rewards/expected; when that call fails the card shows no line.
flowchart LR
Save["Status save"] --> Check["usePointsCheck"]
Check -->|"GET /api/rewards/games/{appid}"| Core["Core: settle + paid rows + reasons"]
Check --> Toast["Toast"]
Check -->|balance moved| Wallet["reloadWallet: header"]
Core --> Line["GamePointsLine on the game page"]
Questions["Questions page"] -->|"GET /api/rewards/expected"| CoreExpected["Core /api/rewards/expected"]
- Landing page: next to the community numbers and the top games, three blocks: games per platform, top genres and finished games per year of all users.
- Library: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "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. A game you own on several
platforms is one row (Core's counted edition) with a small badge per platform next to the title (
PlatformIcons: ST, PS or the platform name, fromplatforms[]; lucide has no brand icons). - Editions: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's "same game" key; an auditor can also link or unlink them for every user. A user who owns two or more of a linked set sees "Also in your library" on the details page and an "x2" badge in the library; the library lists the set once (no "Hide duplicates" filter any more), and the numbers count it once. The editions of a set share one status, rating and notes (the page reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays split. "Same game as..." on the details page asks the auditors for a link.
- Detail per platform: the details page of a game with several platforms (or a PlayStation title on a Steam game)
shows
PlatformsCardabove the editions: one row per platform with playtime, last played and the achievement or trophy progress of that platform, a total row, and a link to the edition whose page lists the achievements. The hero playtime and the library card show the set total (totalPlaytimeMinutes); sorting, stats, dashboard cards and the public profile use the set total (Core V45); per row stay the playtime history, rewards and the played / never started counts. - 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.
The Search page (
/search, nav item "Search") finds games by name: results with cover, year, platforms and an "In library" badge; an own game opens its game page, any other hit opens a detail view with "Add". - 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.
- PlayStation trophies (slice 3): the game detail shows one card per set, Steam first, then PS5 and PS4
(
GameAchievements, grouped byachievementSets). A trophy has its type (bronze to platinum), rarity and a DLC tag; a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game. The sync details list games whose last achievement this sync unlocked, with a button to set them to 100 %. - Game detail: the page shows the stored game at once (
?refresh=false); when Core saysachievementsStalea second call refreshes the achievements from Steam and replaces the data. 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" (system or the settings switch).
- 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 game, Steam or added by hand (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. After a successful link the settings page opens the dashboard (success toast), where the first sync starts; the other outcomes (in use, cancelled, failed) stay on the settings page.
- 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, reduce animations, 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 form asks for email and display name only (no password); the page says that the user gets a mail with a link, then an admin checks the request, and stays on "registration sent" after the send. Auth answers409 PENDING_LIMIT_REACHEDwhile 20 wait. The mail link opens/confirm-email#<token>(the token stays in the fragment and goes to Auth in a POST body); the page asks for the password (and its repeat, both with a show/hide toggle and the rules as helper text) and confirms on a button click, not on load, so mail scanners do not burn the link. Only a410(or a400that names thetokenfield) shows "link invalid"; any other400is a form error and keeps the typed password. An expired or used link and the page after the send offer "send the link again" (same answer for any address). After the send the page shows the address used and "Wrong address? Start over", which brings the form back. After the confirmation the page says that a mail comes once an admin has approved the account (no waiting time, no login link). - Login (
/login): the form checks both fields before it sends (zod, like the other forms) and has a show/hide toggle on the password. Auth answers every failed login with401(a wrong password, an unknown address and an unconfirmed one look the same), so after a401the page offers "Not confirmed yet? Send the link again" with the typed address. - Language default: the cached language, else the browser language (
de*gives German, any other English), else German. - Forgot password (
/reset-password): the login page links to it. Without a fragment it asks for the email and shows the same text for any address. The mail link opens/reset-password#<token>(fragment, sent in a POST body on the button click only): the page asks for the new password twice. A used or expired link shows the email form again. Change password is a card in the settings (current password and the new one twice); the other logins end, this one stays. The admin users page has "Send password reset" for an active, unblocked user. - 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), and set the role of another user in the "Roles" dialog, which also lists the role changes. "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. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. 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). A hint can target groups (without Steam, role and higher, new users, empty library; all must match). Four automatic hints (no Steam, empty library, pile grew this month, open link queue for auditors) show after the admin hints while their condition is true; the Frontend checks it with Core's library summary, stats and link queue. X hides an automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session. Admins turn each on or off on/admin/hints. Hint and news text may use**bold**and[text](url)(an app path or https; anything else stays plain text);HintTextrenders React elements, never HTML. - Follow users (#27): a logged-in visitor follows the owner of a public profile with "Follow" on
/u/<name>; the settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist adds; what the profile hides is left out)./inbox(bell in the header, with the unread count) lists the newest 50 notes and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and wishlist items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user who goes private ends the follow with a note. Two more notes come from the user's own PlayStation link (PSN_EXPIRINGabout 2 days before it ends,PSN_EXPIREDwhen it has ended), with a link to the settings. Notes older than 90 days are deleted. The feed links on (#52): the person's name goes to their profile while it is public (profileName), a game goes to your own game page (/library/<appid>), and "on their profile" opens/u/<name>?game=<appid>, which shows that game above the tiles (ProfileGameCard: status, playtime, achievements, only what the profile shares; one message when it hides or does not have the game). A game you do not own opens a catalog view (CatalogGameCard,GET /api/library/games/<appid>/preview) with an "Add to my library" button that opens the add search with the name filled in./following(nav item "Following", #51) lists the people you follow, newest feed message first, with an initial letter instead of an avatar, and "Unfollow"; the "Following" dashboard tile (opt-in via "Customize") shows the first 3, 5 or 10 of them and loads its own data. - 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. - Broadcast mail: admins write a mail in both languages on
/admin/broadcasts, send it to all active users or to picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts. - 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. "Next steps" come first; the main button is the first open step ("Link Steam", then "Import games"), the one other button is "Go to dashboard". Every way out (buttons, step links, feature links) stamps it in Auth first, so the gate does not send the user back. The settings page links to it again. - Empty library (dashboard): a card above the tiles. Without a Steam ID or PlayStation link: "Link Steam / PlayStation" and "Import games". With Steam, a finished sync and no games: Steam's "Game details" must be public, with a link to the Steam privacy settings (also in the empty library list); no such text for a PlayStation-only user. The "no Steam" and "empty library" hints stay until the user closes them. Texts are platform neutral ("Welcome" before the first sync, "Games", "Syncing your library").
- Correct game data (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, speedrun link, moods, endless flag, genres and time to beat (hours and minutes) 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; an auditor can undo a merge for 30 days from the audit log ("Undo"). An old link of a merged game opens the kept game.
- Game catalog (
/audit/games, AUDITOR or higher): search every game any user has by name (cover, platforms, key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links its games here. - Link queue (
/audit/links, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N players"; Link or Dismiss; paged, 20 pairs a page. Audit log (/audit/log): the newest merges, merge undos, links, unlinks, dismissals, corrections and resets with who did it, filter by action, paged; "Undo" on a merge made in the last 30 days. - 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. nginx reads
AUTH_HOSTandAPI_HOSTfrom the container env at start (nginx.confis an nginx template) and puts them into the CSPconnect-src. - ESLint with the React hooks rules (
npm run lint) - CI: Gitea Actions (
.gitea/workflows/docker-publish.yml:npm ci,npm run lint,npm test,npm run build, then the Docker image taggedlatest,<branch>,<branch>-<sha>)
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"]
Core -->|"NPSSO sign-in, played titles, trophies"| Psn["PlayStation API (unofficial)"]
Browser -.->|"game art, trophy icons"| Images["Steam, IGDB and PlayStation (psnobj) 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
useDragSort(src/hooks/useDragSort.js) sorts a list with pointer events. Only the drag handle hastouch-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 (Steam, IGDB and, for trophy icons,psnobj.prod.dl.playstation.net). - 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. - Public routes:
/,/login,/register,/confirm-email,/reset-password,/u/:nameand/impressum(ImprintPage, linked from the footer). Every other route sits underRequireSession. 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.- The games behind a number: pages wrap their content in
StatDrillProvider(sourceown, profile, community or points), which renders oneStatGamesDialog. Numbers calluseStatDrill()/StatCount; outside a provider they are plain text.src/utils/statGames.jsbuilds the four endpoint URLs.
flowchart LR
Number["Number, bar or row"] --> Provider["StatDrillProvider"]
Provider --> Dialog["StatGamesDialog"]
Dialog -->|own| Own["Core /api/library/stats/games"]
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
Dialog -->|community| Community["Core /api/stats/public/games"]
Dialog -->|points| Points["Core /api/rewards/.../games"]
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
/questions(QuestionsPage, nav item with the open count; the badge is hidden on the page, whose title shows the live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides which question is open;src/utils/questions.jsonly loads the list and sends the answer. A yes to Finished? gets the Rate it? card back at once.409/404reload the list.RatingStarsis shared withBacklogCard. Below the cards, a closed "Removed games" section (RemovedGamesSection,GET /api/library/removed-games) has a Restore button per game (POST /api/library/games/{appid}/restore). The toast after "Remove from library" on a manual game has an Undo that restores it the same way.
flowchart LR
Games["Core GET /api/library/games: one row per set + platforms[]"] --> Row["GameLibrary row"]
Row --> Icons["PlatformIcons: badge per platform"]
Row -.->|"no platforms (hidden game)"| Badge["single platform badge"]
flowchart LR
DetailData["Core GET /api/library/games/{appid}?refresh=false, then without it when achievementsStale: platforms[] + totalPlaytimeMinutes"] --> Platforms["PlatformsCard: row per platform, total"]
DetailData --> Hero["GameHeroCard: total playtime"]
Platforms -->|"row of another edition"| Other["/library/{appid} of that edition"]
flowchart LR
Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"]
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
Split --> Reload["reload the detail"]
Backlog["BacklogCard save"] -->|"game has editions"| Reload
EditionsCardshows "Not the same game" on every game that has other editions in the own library. The call goes throughsrc/utils/links.js(splitGame);404 NOT_LINKEDshows the generic error toast.
flowchart LR
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
Page["QuestionsPage"] --> List["Core /api/library/questions"]
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
Page -->|"Removed games, Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"]
- Owner and user lists (
src/components/people/,src/utils/people.js) need a login and show listed users by name (a link to/u/<name>), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is saved on its own (PUT /api/users/settings/owner-lists), never throughsaveSettings. useLibrarySyncpolls Core's/api/library/sync-statuswhile a sync runs, then loads the data again. While a first sync is expected but Core has no status row (queued: "First sync queued ..." with "usually under a minute"), it polls every 3 s for 15 s, then every 15 s, for 10 minutes in all; a run still going after that shows a calm "check back later" text.- 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/
├── App.jsx, main.jsx, i18n.js # Routes, entry point, i18next setup
├── style.css, skins.css, effects.css # Base styles, the skins, the background effects
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
├── components/ # Cards and shared components; ui/ holds the base components, import/, stats/ and people/ the
│ # parts of one page that share page-only code
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
├── context/ # CurrentUserContext, PreferencesContext
├── lib/ # utils.js (class name helper)
├── utils/ # Validation schemas and helpers
└── test/ # renderWithProviders.jsx, fetchRoutes.js, i18n mock, WalletLoader, fixtures/import
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 30 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.
Production image (container env, read by nginx at start):
| Variable | Purpose |
|---|---|
AUTH_HOST |
Auth host name for the CSP connect-src |
API_HOST |
Core host name, same |
The image refuses to start when either is unset or empty (docker-entrypoint.d/10-require-hosts.sh);
docker-compose.yml of the Deployment repo passes both. FRONTEND_HOST is not
passed to the frontend image; compose uses it for the Traefik rule and as APP_FRONTEND_URL of Auth and Core (Deployment docker-compose.yml:68,152).
Tests and build
npm run lint
npm test
npm run build
Overflow check (phones)
scripts/check-overflow.js visits every page at 360, 390, 412 and 414 px and reports any page that scrolls sideways
(scrollWidth != clientWidth) with its widest elements. It is not part of the gate and needs no dependency:
- Start the local stack (see "Run locally") and log in as an admin in the Playwright MCP browser.
- Set
GAME_ID(an appid in the library) andPROFILE_NAME(the display name of that user, the profile must be public) at the top of the script. - Run the file with the Playwright MCP tool
browser_run_code_unsafe(filename= the script). An emptyhitslist is green. - Other runs: edit
FONT_PERCENT = 130(large system font),DARK,SKIN. For the logged-out pages setLOGGED_OUT = trueand run in a browser without a session (a logged-in browser redirects them).
Use long display names (up to 24 characters in Auth) and long game names in the local data to stress the layout.
Roadmap
See ROADMAP.md in the ToBePlayed-Deployment repo.
License
All rights reserved. The source is public for reading only.




