Compare commits

...
8 Commits
Author SHA1 Message Date
lobo c126592887 feat(points): show early finish and expected points on questions
Build, Test & Push Frontend / docker (push) Successful in 24s
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Core pays 10 early points for an unproven Steam finish. The rest
comes with proof. Show this in the toast and the game line.
Questions cards show the full value from GET /api/rewards/expected.
2026-10-09 16:00:06 +02:00
lobo 814decc781 feat(points): show points result on every status save
Core now settles on GET /api/rewards/games/{appid}. The Frontend
shows a toast, refreshes the header balance, and lists the paid
rows and reasons on the game page. Deploy Core first.
2026-10-09 15:25:04 +02:00
lobo 9adc606c57 feat(library): show set total playtime on dashboard cards
Most played and currently playing cards show the total playtime of
the game set. Use the own minutes when the API sends no total.
2026-10-09 14:35:07 +02:00
lobo f1a6bc0443 feat(library): show playtime per platform on game detail
Hero and library card show the set total. Fall back to the
row's own minutes when the API sends no total.
2026-10-09 13:55:57 +02:00
lobo 16651a1f56 feat(library): add platform badges, remove hide duplicates
Gitea #34 slice 2 of 3. Core lists each set once now.
The hide duplicates toggle and its param are not needed.
PlatformIcons shows a badge per platform (platforms[]).
2026-10-09 11:31:11 +02:00
lobo 25cc5ff2d8 feat(library): add not the same game button to editions
Part of Gitea #34 slice 1 of 3: one entry per game across platforms.
Status, rating and notes are shared, so the detail page reloads after a save.
2026-10-09 10:57:23 +02:00
lobo e136a7abc1 feat(library): add source chips to game library
Chips show only when the user has 2 or more sources.
A selected source the user lacks stays visible, so it can be cleared.
2026-10-09 10:03:05 +02:00
lobo d3ad19e2fa feat(questions): add questions page with open count badge
Add /questions page and a nav badge with the open count.
Core decides which question is open. The page only shows and answers.
Move RatingStars out of BacklogCard so both pages share it.
2026-10-09 09:31:48 +02:00
51 changed files with 1787 additions and 150 deletions

No files matched your search

+68 -7
View File
@@ -52,15 +52,40 @@ To Be Played is split into three repositories:
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.
system setting "reduce motion" or the "Reduce animations" switch in the settings. After every status save (game page,
Play Next "Start", Questions, add game) `usePointsCheck` asks Core `GET /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`); `pointsNotice` in `utils/rewards.js` picks the
toast, the reason codes come from Core and `POINTS.REASONS` only 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" from `GET /api/rewards/expected`; when that call fails the card shows no line.
```mermaid
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, "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.
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library
and the filter "Hide duplicates"; the numbers count the set once. "Same game as..." on the details page asks the
auditors for a link.
- **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, from `platforms[]`; 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 `PlatformsCard` above 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.
@@ -252,6 +277,42 @@ flowchart LR
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.js` only loads the list and sends the answer. A yes to Finished? gets
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
```mermaid
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"]
```
```mermaid
flowchart LR
DetailData["Core GET /api/library/games/{appid}: platforms[] + totalPlaytimeMinutes"] --> Platforms["PlatformsCard: row per platform, total"]
DetailData --> Hero["GameHeroCard: total playtime"]
Platforms -->|"row of another edition"| Other["/library/{appid} of that edition"]
```
```mermaid
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
```
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
```mermaid
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}"]
```
- 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 through `saveSettings`.
+81 -5
View File
@@ -256,6 +256,8 @@
}
},
"LIBRARY": {
"SOURCE_LABEL": "Quelle",
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manuell" },
"TITLE": "Spielebibliothek",
"LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
@@ -289,7 +291,6 @@
"FILTER_HIDDEN": "Ausgeblendet",
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen",
"FILTER_HIDE_DUPLICATES": "Duplikate ausblenden",
"EDITIONS": "{{count}} Ausgaben in deiner Bibliothek"
},
"GAME_DETAIL": {
@@ -392,6 +393,9 @@
"INBOX": "Postfach",
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
"QUESTIONS": "Fragen",
"QUESTIONS_OPEN_one": "Fragen, {{count}} offen",
"QUESTIONS_OPEN_other": "Fragen, {{count}} offen",
"WHATS_NEW": "Neuigkeiten",
"ADMIN_NEWS": "Neuigkeiten verwalten",
"ADMIN_BROADCASTS": "Rundmail",
@@ -792,6 +796,12 @@
"OTHER_OWNERS_other": "{{count}} andere Besitzer",
"SHOW_OWNERS": "Zeigen, wer es besitzt"
},
"PLATFORMS": {
"TITLE": "Spielzeit pro Plattform",
"TOTAL": "Gesamt",
"LAST_PLAYED": "Zuletzt gespielt",
"NEVER": "nie"
},
"LINKS": {
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...",
@@ -804,6 +814,9 @@
"REQUEST": "Anfrage senden",
"LINK": "Verknüpfen",
"UNLINK": "Verknüpfung lösen",
"SPLIT": "Nicht dasselbe Spiel",
"SPLIT_DONE": "Spiel getrennt. Es steht jetzt allein in deiner Bibliothek.",
"SHARED_STATUS": "Status, Bewertung und Notizen gelten für alle diese Ausgaben.",
"DISMISS": "Nicht dasselbe",
"REQUESTED": "Anfrage gesendet",
"LINKED": "Spiele verknüpft",
@@ -1167,12 +1180,46 @@
"HOW_TO": "So bekommst du Punkte",
"RULES": {
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).",
"FINISHED": "30, 50 oder 80 Punkte, wenn du ein Spiel durchspielst (nach seiner Spielzeit), sofern du es wirklich gespielt hast. Einmal pro Spiel.",
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast.",
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.",
"FINISHED": "30, 50 oder 80 Punkte, wenn du ein Spiel durchspielst (nach seiner Spielzeit) und es danach gespielt hast. Ohne Beweis für Spielen bekommst du jetzt 10 Punkte und den Rest, sobald du es nach dem Durchspielen auf Steam spielst. Bei 0 Spielzeit bekommst du 10, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.",
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast. Es braucht ein belegtes Steam-Durchspielen (die frühen 10 Punkte zählen erst mit Beweis); ein manuelles öffnet es nie.",
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst. Höchstens 3 Spiele pro Woche (Montag bis Sonntag) zahlen Punkte; manuelle Spiele und Steam-Spiele ohne Beweis teilen sich dieses Limit. Ein 4. manuelles Durchspielen in dieser Woche zahlt nichts; ein 4. Steam-Durchspielen ohne Beweis zahlt mit Beweis weiter voll.",
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel.",
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst."
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst.",
"PROOF": "Ein Steam-Spiel braucht als Beweis frisches Spielen: den Abschluss-Erfolg (höchstens 30 Tage vor dem Durchspielen freigeschaltet), Spielzeit ab 30 Tage vor dem Durchspielen oder die halbe Normalzeit danach gespielt.",
"ONCE": "Jede Art zahlt einmal pro Spiel. Editionen eines Spiels zählen als eines.",
"PLAYSTATION": "PlayStation-Spiele zahlen nichts und PlayStation-Spielzeit ist kein Beweis. Ein manuelles oder Steam-Spiel, das mit einem PlayStation-Titel verknüpft ist, zahlt nach seinen eigenen Regeln.",
"HIDDEN": "Versteckte Spiele zahlen nichts.",
"CHECKED": "Punkte werden geprüft, wenn du einen Status änderst, ein Spiel oder die App öffnest, und jede Nacht.",
"STARTER": "Durchgespielte Spiele aus der Zeit vor dem Status-Verlauf bekamen einmal Startpunkte (20 pro Spiel, höchstens 250)."
},
"GAME": {
"PAID": "{{kind}}: {{points}} Punkte, bezahlt am {{date}}",
"OTHER_EDITION": "(andere Edition des Spiels)",
"WAITING": "Durchgespielt: wartet auf Beweis. Spiele das Spiel nach dem Durchspielen einmal.",
"WAITING_START": "Gestartet: spiele das Spiel einmal, um die 5 Punkte zu bekommen.",
"WAITING_TOPUP": "Der Rest der Durchspiel-Punkte: spiele nach dem Durchspielen einmal auf Steam.",
"NONE": "{{kind}}: keine Punkte - {{reason}}",
"TOAST_BOOKED": "+{{count}} Punkte",
"TOAST_BOOKED_REST": "+{{count}} Punkte; den Rest gibt es mit Beweis",
"TOAST_PAID": "Schon bezahlt am {{date}}",
"TOAST_PAID_REST": "Schon bezahlt am {{date}}; den Rest gibt es mit Beweis",
"TOAST_NONE": "Noch keine Punkte: {{reason}}",
"TOAST_STATUS": "Dieser Status bringt keine Punkte"
},
"REASONS": {
"HIDDEN": "das Spiel ist ausgeblendet",
"PLAYSTATION": "PlayStation-Spiele zahlen nichts",
"BEFORE_HISTORY": "durchgespielt, bevor der Status-Verlauf begann (siehe Startpunkte)",
"MANUAL_GAME": "ein manuelles Spiel hat keine Spielzeit als Beweis",
"NO_PLAY_AFTER_START": "seit dem Start nicht gespielt",
"NO_PLAYTIME": "keine Spielzeit erfasst",
"PLAY_NOT_COUNTED": "PlayStation-Minuten sind in dieses Spiel eingerechnet, darum ist Spielen kein Beweis",
"NO_PROOF": "kein Beweis für frisches Spielen, spiele einmal nach dem Durchspielen",
"WEEKLY_LIMIT": "Wochenlimit erreicht",
"WEEKLY_LIMIT_NO_PROOF": "Wochenlimit erreicht, also keine frühen Punkte; ein Beweis zahlt weiter das volle Durchspielen",
"NEEDS_PROVEN_FINISH": "braucht zuerst einen Beweis für Spielen auf Steam",
"NOT_ALL_ACHIEVEMENTS": "nicht alle Erfolge freigeschaltet"
},
"SLOTS": {
"AVATAR_BORDER": "Avatarrahmen",
@@ -1247,6 +1294,8 @@
"STARTED": "Gestartet",
"FINISHED": "Durchgespielt",
"FINISHED_MANUAL": "Durchgespielt (manuelles Spiel)",
"FINISHED_EARLY": "Durchgespielt (frühe Punkte)",
"FINISHED_TOPUP": "Durchspielen bewiesen, Rest",
"COMPLETED": "100-%-Bonus",
"MONTH": "Kleinerer Stapel im",
"STREAK": "Serien-Woche",
@@ -1291,5 +1340,32 @@
"ACHIEVEMENTS": "Neue Erfolge oder Trophäen",
"STATUS": "Statuswechsel"
}
},
"QUESTIONS": {
"TITLE": "Fragen",
"TITLE_COUNT": "Fragen ({{count}})",
"EMPTY": "Gerade gibt es nichts zu entscheiden.",
"GONE": "Diese Frage ist schon beantwortet. Die Liste wird neu geladen.",
"YES": "Ja",
"NO": "Nein",
"SKIP": "Überspringen",
"START": {
"ASK": "Angefangen?",
"REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog."
},
"FINISHED": {
"ASK": "Durchgespielt?",
"REASON": "Du hast {{playtime}} gespielt.",
"REASON_TTB": "Du hast {{playtime}} gespielt. Die meisten brauchen {{ttb}}.",
"POINTS": "Bis zu {{count}} Punkte"
},
"DROPPED": {
"ASK": "Abgebrochen?",
"REASON": "Keine Spielzeit seit {{date}}."
},
"RATE": {
"ASK": "Bewerten?",
"REASON": "Du hast es durchgespielt. Wie war es?"
}
}
}
+81 -5
View File
@@ -256,6 +256,8 @@
}
},
"LIBRARY": {
"SOURCE_LABEL": "Source",
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manual" },
"TITLE": "Game Library",
"LOADING": "Loading games …",
"EMPTY": "No games yet. Sync your Steam library or add a game.",
@@ -289,7 +291,6 @@
"FILTER_HIDDEN": "Hidden",
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements",
"FILTER_HIDE_DUPLICATES": "Hide duplicates",
"EDITIONS": "{{count}} editions in your library"
},
"GAME_DETAIL": {
@@ -392,6 +393,9 @@
"INBOX": "Inbox",
"INBOX_UNREAD_one": "Inbox, {{count}} new note",
"INBOX_UNREAD_other": "Inbox, {{count}} new notes",
"QUESTIONS": "Questions",
"QUESTIONS_OPEN_one": "Questions, {{count}} open",
"QUESTIONS_OPEN_other": "Questions, {{count}} open",
"WHATS_NEW": "What's new",
"ADMIN_NEWS": "News",
"ADMIN_BROADCASTS": "Mail",
@@ -792,6 +796,12 @@
"OTHER_OWNERS_other": "{{count}} other owners",
"SHOW_OWNERS": "Show who owns it"
},
"PLATFORMS": {
"TITLE": "Playtime per platform",
"TOTAL": "Total",
"LAST_PLAYED": "Last played",
"NEVER": "never"
},
"LINKS": {
"ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...",
@@ -804,6 +814,9 @@
"REQUEST": "Send request",
"LINK": "Link",
"UNLINK": "Unlink",
"SPLIT": "Not the same game",
"SPLIT_DONE": "Game split off. It stands alone in your library now.",
"SHARED_STATUS": "Status, rating and notes are shared by these editions.",
"DISMISS": "Not the same",
"REQUESTED": "Request sent",
"LINKED": "Games linked",
@@ -1167,12 +1180,46 @@
"HOW_TO": "How to earn points",
"RULES": {
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).",
"FINISHED": "30, 50 or 80 points when you finish a game (by its time to beat), if you really played it. Once per game.",
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game.",
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.",
"FINISHED": "30, 50 or 80 points when you finish a game (by its time to beat) and played it since. Without proof of play you get 10 points now, and the rest once you play it on Steam after finishing. With 0 play time on record you get 10, and the rest only with Steam play. Once per game.",
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game. It needs a proven Steam finish (the early 10 points do not count until proof arrives); a manual finish never opens it.",
"FINISHED_MANUAL": "15 points for a manual game you finish. At most 3 finishes per week (Monday to Sunday) pay points; manual finishes and Steam finishes without proof share this limit. A 4th manual finish in that week pays nothing; a 4th Steam finish without proof still pays in full once proven.",
"MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.",
"STREAK": "20 points for every week with a finished game after a week with a finished game.",
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal."
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal.",
"PROOF": "A Steam finish needs proof of fresh play: the ending achievement (not older than 30 days before the finish), any play from 30 days before the finish on, or half of the normal time played after it.",
"ONCE": "Each kind pays once per game. Editions of one game count as one.",
"PLAYSTATION": "PlayStation games pay nothing and PlayStation play is no proof. A manual or Steam game linked to a PlayStation title pays by its own rules.",
"HIDDEN": "Hidden games pay nothing.",
"CHECKED": "Points are checked when you change a status, open a game or the app, and every night.",
"STARTER": "Finishes from before the status history got starter points once (20 per game, 250 at most)."
},
"GAME": {
"PAID": "{{kind}}: {{points}} points, paid on {{date}}",
"OTHER_EDITION": "(other edition of the game)",
"WAITING": "Finished: waiting for proof. Play the game once after finishing.",
"WAITING_START": "Started: play the game once to get the 5 points.",
"WAITING_TOPUP": "The rest of the finish points: play once on Steam after finishing.",
"NONE": "{{kind}}: no points - {{reason}}",
"TOAST_BOOKED": "+{{count}} points",
"TOAST_BOOKED_REST": "+{{count}} points; the rest comes with proof",
"TOAST_PAID": "Already paid on {{date}}",
"TOAST_PAID_REST": "Already paid on {{date}}; the rest comes with proof",
"TOAST_NONE": "No points yet: {{reason}}",
"TOAST_STATUS": "This status earns no points"
},
"REASONS": {
"HIDDEN": "the game is hidden",
"PLAYSTATION": "PlayStation games pay nothing",
"BEFORE_HISTORY": "finished before the status history began (see starter points)",
"MANUAL_GAME": "a manual game has no play time to prove it",
"NO_PLAY_AFTER_START": "no play since the start",
"NO_PLAYTIME": "no play time on record",
"PLAY_NOT_COUNTED": "PlayStation minutes are mixed into this game, so play on it is no proof",
"NO_PROOF": "no proof of fresh play, play once after finishing",
"WEEKLY_LIMIT": "weekly limit reached",
"WEEKLY_LIMIT_NO_PROOF": "weekly limit reached, so no early points; proof still pays the full finish",
"NEEDS_PROVEN_FINISH": "needs proof of play on Steam first",
"NOT_ALL_ACHIEVEMENTS": "not all achievements unlocked"
},
"SLOTS": {
"AVATAR_BORDER": "Avatar border",
@@ -1247,6 +1294,8 @@
"STARTED": "Started",
"FINISHED": "Finished",
"FINISHED_MANUAL": "Finished (manual game)",
"FINISHED_EARLY": "Finished (early points)",
"FINISHED_TOPUP": "Finish proven, rest",
"COMPLETED": "100 % bonus",
"MONTH": "Smaller pile in",
"STREAK": "Streak week",
@@ -1291,5 +1340,32 @@
"ACHIEVEMENTS": "New achievements or trophies",
"STATUS": "Status changes"
}
},
"QUESTIONS": {
"TITLE": "Questions",
"TITLE_COUNT": "Questions ({{count}})",
"EMPTY": "Nothing to decide right now.",
"GONE": "That question was already answered. The list is loaded again.",
"YES": "Yes",
"NO": "No",
"SKIP": "Skip",
"START": {
"ASK": "Start it?",
"REASON": "You played {{playtime}}, but it is still in your backlog."
},
"FINISHED": {
"ASK": "Finished?",
"REASON": "You played {{playtime}}.",
"REASON_TTB": "You played {{playtime}}. Most players need {{ttb}}.",
"POINTS": "Up to {{count}} points"
},
"DROPPED": {
"ASK": "Dropped?",
"REASON": "No playtime since {{date}}."
},
"RATE": {
"ASK": "Rate it?",
"REASON": "You finished it. How was it?"
}
}
}
+2
View File
@@ -14,6 +14,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx';
import InboxPage from './pages/InboxPage.jsx';
import QuestionsPage from './pages/QuestionsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx';
@@ -93,6 +94,7 @@ function App() {
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/inbox" element={<InboxPage/>}/>
<Route path="/questions" element={<QuestionsPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/games" element={<GameCatalogPage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/>
+1
View File
@@ -35,6 +35,7 @@ describe('App', () => {
'GET /api/library/steam-profile': json(200, null),
'POST /api/auth/refresh': json(401, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/library/questions/count': json(200, {count: 0}),
});
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
+3
View File
@@ -12,6 +12,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import GameSearch from './GameSearch.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
@@ -91,6 +92,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
const [status, setStatus] = useState('BACKLOG');
const [hours, setHours] = useState('');
const [busy, setBusy] = useState(false);
const check = usePointsCheck();
const submit = async (e) => {
e.preventDefault();
@@ -110,6 +112,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
}
const {appid} = await response.json();
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
check(appid, {status, tell: true});
navigate(`/library/${appid}`);
} catch (err) {
console.error('add game error:', err);
+22 -1
View File
@@ -7,7 +7,7 @@ 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()}}));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const zelda = {
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg',
@@ -23,6 +23,9 @@ function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Ninte
'GET /api/library/game-search': found,
'POST /api/library/games': post,
'POST /api/library/imports': post,
'GET /api/rewards/games/-1025': json(200, {balance: 15, missing: {}, paid: [
{kind: 'FINISHED_MANUAL', appid: -1025, points: 15, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]}),
'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
@@ -79,6 +82,24 @@ describe('AddGamePanel', () => {
});
});
it('checks the points after adding a game as finished and shows the toast', async () => {
// jsdom has no scrollIntoView, and the Radix select calls it when it opens.
Element.prototype.scrollIntoView = vi.fn();
renderPanel();
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
fireEvent.keyDown(screen.getByRole('combobox', {name: 'BACKLOG.STATUS_LABEL'}), {key: 'ArrowDown'});
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.STATUS.FINISHED'}));
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
await screen.findByText('detail page');
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:15'));
const points = fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/-1025'));
expect(points).toHaveLength(1);
expect(JSON.parse(fetch.mock.calls.find(([, call]) => call?.method === 'POST')[1].body).status).toBe('FINISHED');
});
it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
renderPanel(undefined, null);
search('zelda');
+44 -15
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Bell, ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -26,6 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.js';
import {fetchInbox} from '../utils/inbox.js';
import {fetchQuestionCount} from '../utils/questions.js';
import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
@@ -33,10 +34,18 @@ const NAV_ITEMS = [
// 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: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
];
const CountBadge = ({count}) => (
<span aria-hidden="true"
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{count > 9 ? '9+' : count}
</span>
);
const navLinkClass = (isActive) => cn(
'flex items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium transition-colors sm:px-3',
isActive
@@ -86,6 +95,22 @@ export default function AppHeader({isAuthenticated, onLogout}) {
}, [isAuthenticated, pathname]);
const shownUnread = pathname === '/inbox' ? 0 : unread;
// Loaded again on every page change. The questions page shows the live count in its title, so it has no badge.
const [openQuestions, setOpenQuestions] = useState(0);
useEffect(() => {
if (!isAuthenticated) return undefined;
let current = true;
fetchQuestionCount().then(({ok, data}) => {
if (current && ok) setOpenQuestions(data.count);
});
return () => {
current = false;
};
}, [isAuthenticated, pathname]);
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
// Below sm the Questions icon shows only with open questions or on its own page; the points chip then gives up its number.
const questionsIconHidden = openQuestions === 0 && pathname !== '/questions';
const switchRole = async (role) => {
try {
const response = await viewAs(role === currentUser.role ? null : role);
@@ -129,13 +154,19 @@ export default function AppHeader({isAuthenticated, onLogout}) {
{isAuthenticated && (
<nav className="flex items-center sm:gap-1">
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => (
<NavLink key={to} to={to} title={t(labelKey)}
className={({isActive}) => navLinkClass(isActive || alsoActiveOn.includes(pathname))}>
<Icon className="size-4"/>
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
</NavLink>
))}
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => {
const count = to === '/questions' ? shownQuestions : 0;
return (
<NavLink key={to} to={to} title={t(labelKey)}
aria-label={count > 0 ? t(`${labelKey}_OPEN`, {count}) : undefined}
className={({isActive}) => cn(navLinkClass(isActive || alsoActiveOn.includes(pathname)), 'relative',
to === '/questions' && questionsIconHidden && 'max-sm:hidden')}>
<Icon className="size-4"/>
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
{count > 0 && <CountBadge count={count}/>}
</NavLink>
);
})}
</nav>
)}
@@ -146,19 +177,17 @@ export default function AppHeader({isAuthenticated, onLogout}) {
)}
{isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink lg:shrink-0">
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
<Coins className="text-primary"/>
<span className={cn('min-w-0 truncate tabular-nums', !questionsIconHidden && 'max-sm:hidden')}>{wallet.balance}</span>
</Link>
</Button>
)}
{isAuthenticated && (
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}>
<Bell/>
{shownUnread > 0 && (
<span aria-hidden="true"
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{shownUnread > 9 ? '9+' : shownUnread}
</span>
)}
{shownUnread > 0 && <CountBadge count={shownUnread}/>}
</Link>
</Button>
)}
+31
View File
@@ -18,13 +18,44 @@ function Load() {
return <button onClick={reloadWallet}>load</button>;
}
async function loadChip(count) {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/library/questions/count': json(200, {count}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
fireEvent.click(screen.getByText('load'));
return screen.findByTitle('POINTS.TITLE: 135');
}
describe('AppHeader points chip', () => {
it('hides the number below sm while the questions icon shows, and keeps the balance as label', async () => {
const chip = await loadChip(2);
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135');
expect(screen.getByText('135').className).toContain('max-sm:hidden');
});
it('shows the number at every width while the questions icon is hidden', async () => {
const chip = await loadChip(0);
await screen.findByTitle('NAV.QUESTIONS');
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135');
expect(screen.getByText('135').className).not.toContain('max-sm:hidden');
});
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/library/questions/count': json(200, {count: 0}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
+40 -1
View File
@@ -19,7 +19,8 @@ afterEach(() => {
const loggedInAs = (me, routes = {}, options = {}) => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}), ...routes});
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/library/questions/count': json(200, {count: 0}), ...routes});
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
};
@@ -86,6 +87,44 @@ describe('AppHeader', () => {
expect(link.textContent).toBe('');
});
it('shows how many questions are open', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 12})});
const link = await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:12'});
expect(link.getAttribute('href')).toBe('/questions');
expect(link.textContent).toBe('NAV.QUESTIONS9+');
});
it('hides the questions icon below sm while no question is open', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
expect((await screen.findByTitle('NAV.QUESTIONS')).className).toContain('max-sm:hidden');
});
it('keeps the questions icon at every width while questions are open', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2})});
expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden');
});
it('keeps the questions icon below sm on the questions page, without a badge', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})},
{entries: ['/questions']});
expect((await screen.findByTitle('NAV.QUESTIONS')).className).not.toContain('max-sm:hidden');
});
it('shows no question count on the questions page, whose title has it', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 3})},
{entries: ['/questions']});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
expect(screen.getByTitle('NAV.QUESTIONS').textContent).toBe('NAV.QUESTIONS');
expect(screen.queryByRole('link', {name: 'NAV.QUESTIONS_OPEN:3'})).toBeNull();
});
it('offers the admin page and "view as" to an admin', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
+3 -17
View File
@@ -1,14 +1,14 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ListTodo, Loader2, Star} from 'lucide-react';
import {ListTodo, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import RatingStars from './RatingStars.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
@@ -104,21 +104,7 @@ export default function BacklogCard({game, onSaved}) {
<div className="space-y-2">
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={form.rating === n}
onClick={() => set('rating')(form.rating === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
</button>
))}
</div>
<RatingStars value={form.rating} onChange={set('rating')} labelledBy="backlog-rating"/>
</div>
</div>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function CurrentlyPlayingCard({games, syncing, wide}) {
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
{formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
</div>
</Link>
</li>
@@ -0,0 +1,18 @@
// @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 CurrentlyPlayingCard from './CurrentlyPlayingCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
describe('CurrentlyPlayingCard', () => {
afterEach(cleanup);
it('shows the set total of a game, not the minutes of its own row', () => {
render(<MemoryRouter><CurrentlyPlayingCard games={[{appid: 620, name: 'Portal 2', playtimeMinutes: 100,
totalPlaytimeMinutes: 600, status: 'PLAYING'}]}/></MemoryRouter>);
expect(screen.getByText('10h')).toBeTruthy();
});
});
+18 -9
View File
@@ -1,37 +1,38 @@
import {useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Layers, Link2Off} from 'lucide-react';
import {Layers, Link2Off, Unlink} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import FitText from './FitText.jsx';
import {unlinkGame} from '../utils/links.js';
import {splitGame, unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
/**
* The other editions of this game in the own library, and the buttons to ask for a link (everyone) or to unlink
* (auditor). Shown on every game: a request must be possible from any game.
* The other editions of this game in the own library, and the buttons to ask for a link (everyone), to split the game
* off its set (everyone, for the own library) or to unlink (auditor). Shown on every game: a request must be possible
* from any game.
*/
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const unlink = async () => {
const run = async (call, doneKey) => {
setBusy(true);
try {
const response = await unlinkGame(game.appid);
const response = await call(game.appid);
if (response.status === 401) return;
if (response.ok) {
toast.success(t('LINKS.UNLINKED'));
toast.success(t(doneKey));
onChanged();
} else {
toast.error(t('LINKS.ERROR'));
}
} catch (err) {
console.error('Unlink error:', err);
console.error('Edition change error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
@@ -56,14 +57,22 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
</li>
))}
</ul>
<p className="text-muted-foreground">{t('LINKS.SHARED_STATUS')}</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button>
{game.editions?.length > 0 && (
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(splitGame, 'LINKS.SPLIT_DONE')}>
<Unlink/>{t('LINKS.SPLIT')}
</Button>
)}
{auditor && game.linked && (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={unlink}>
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(unlinkGame, 'LINKS.UNLINKED')}>
<Link2Off/>{t('LINKS.UNLINK')}
</Button>
)}
+56
View File
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import EditionsCard from './EditionsCard.jsx';
import {splitGame} from '../utils/links.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../utils/links.js', () => ({splitGame: vi.fn(), unlinkGame: vi.fn()}));
const edition = {appid: 221, name: 'Game (PS5)', platform: 'PS5', source: 'PSN', status: 'PLAYING'};
const renderCard = (game, onChanged = vi.fn()) => {
render(<MemoryRouter><EditionsCard game={game} auditor={false} onRequest={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
return onChanged;
};
describe('EditionsCard', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
it('showsTheSplitButtonAndTheSharedHintOnlyWithEditions', () => {
renderCard({appid: 220, editions: []});
expect(screen.queryByText('LINKS.SPLIT')).toBeNull();
expect(screen.queryByText('LINKS.SHARED_STATUS')).toBeNull();
cleanup();
renderCard({appid: 220, editions: [edition]});
expect(screen.getByText('LINKS.SPLIT')).toBeTruthy();
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
});
it('splitsTheGameAndReloads', async () => {
splitGame.mockResolvedValue({ok: true, status: 204});
const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT'));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(splitGame).toHaveBeenCalledWith(220);
expect(toast.success).toHaveBeenCalledWith('LINKS.SPLIT_DONE');
});
it('showsAnErrorAndDoesNotReloadWhenTheCallFails', async () => {
splitGame.mockResolvedValue({ok: false, status: 404});
const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
expect(onChanged).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -76,7 +76,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})}
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
</Badge>
<ComingBadge game={game}/>
<GameStatusBadge status={game.status}/>
+47
View File
@@ -0,0 +1,47 @@
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {formatDate} from '../utils/format.js';
import {STATUS_NOT_REACHED} from '../utils/rewards.js';
const WAITING = {NO_PROOF: 'POINTS.GAME.WAITING', NO_PLAY_AFTER_START: 'POINTS.GAME.WAITING_START'};
// The rest of an early finish waits for Steam play, with or without playtime on record.
const WAITING_TOPUP = ['NO_PROOF', 'NO_PLAYTIME'];
const waitingKey = (kind, reason) => (kind === 'FINISHED_TOPUP' ? WAITING_TOPUP.includes(reason) && 'POINTS.GAME.WAITING_TOPUP' : WAITING[reason]);
/**
* What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it
* settles the points; a status change after the first load also shows the points toast.
*/
export default function GamePointsLine({game}) {
const {t, i18n} = useTranslation();
const check = usePointsCheck();
const [points, setPoints] = useState(null);
const lastStatus = useRef(null);
useEffect(() => {
const changed = lastStatus.current !== null && lastStatus.current !== game.status;
lastStatus.current = game.status;
let stale = false;
check(game.appid, {status: game.status, tell: changed}).then((data) => {
if (!stale && data) setPoints(data);
});
return () => {
stale = true;
};
}, [check, game.appid, game.status]);
if (!points) return null;
const kindText = (kind) => t(`POINTS.KINDS.${kind}`);
const lines = [
...points.paid.map((row) => t('POINTS.GAME.PAID', {
kind: kindText(row.kind), points: row.points,
date: formatDate(row.paidAt, i18n.language, {dateStyle: 'medium'}),
}) + (row.appid === game.appid ? '' : ` ${t('POINTS.GAME.OTHER_EDITION')}`)),
...Object.entries(points.missing).filter(([, reason]) => !STATUS_NOT_REACHED.includes(reason)).map(([kind, reason]) => (
waitingKey(kind, reason) ? t(waitingKey(kind, reason))
: t('POINTS.GAME.NONE', {kind: kindText(kind), reason: t(`POINTS.REASONS.${reason}`)}))),
];
return lines.map((line, i) => <p key={i} className="text-sm text-muted-foreground">{line}</p>);
}
+118
View File
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import GamePointsLine from './GamePointsLine.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const row = (kind, extra = {}) => ({kind, appid: 220, points: 30, paidAt: '2026-10-01T10:00:00Z', bookedNow: false, ...extra});
const wallet = (balance) => json(200, {balance, catalog: [], owned: [], equipped: {}, history: []});
const renderLine = (status, points) => {
const answers = Array.isArray(points) ? [...points] : [points];
fetchRoutes({
'GET /api/rewards/games/220': () => json(200, answers.length > 1 ? answers.shift() : answers[0]),
'GET /api/rewards/wallet': wallet(30),
});
const tree = (s) => <PreferencesProvider><GamePointsLine game={{appid: 220, status: s}}/></PreferencesProvider>;
const view = render(tree(status));
return {view, tree};
};
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GamePointsLine', () => {
it('shows one line per paid row with its date and notes another edition', async () => {
renderLine('FINISHED', {balance: 35, missing: {}, paid: [row('STARTED', {points: 5}), row('FINISHED', {appid: 221})]});
expect(await screen.findAllByText(/^POINTS.GAME.PAID/)).toHaveLength(2);
expect(screen.getAllByText(/POINTS.GAME.OTHER_EDITION/)).toHaveLength(1);
});
it('shows the waiting line for missing proof and a reason line for another missing kind', async () => {
renderLine('COMPLETED', {balance: 30, paid: [], missing: {FINISHED: 'NO_PROOF', COMPLETED: 'NEEDS_PROVEN_FINISH'}});
expect(await screen.findByText('POINTS.GAME.WAITING')).toBeTruthy();
expect(screen.getAllByText(/^POINTS.GAME.NONE/)).toHaveLength(1);
});
it('shows the early row and the waiting line for the rest of the finish', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PROOF'}});
expect(await screen.findByText('POINTS.GAME.WAITING_TOPUP')).toBeTruthy();
expect(screen.getAllByText(/^POINTS.GAME.PAID/)).toHaveLength(1);
expect(screen.queryByText(/^POINTS.GAME.NONE/)).toBeNull();
});
it('shows the waiting line for the rest when there is no play time on record', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PLAYTIME'}});
expect(await screen.findByText('POINTS.GAME.WAITING_TOPUP')).toBeTruthy();
});
it('shows a reason line for a hidden game that waits for the rest', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'HIDDEN'}});
expect(await screen.findByText(/^POINTS.GAME.NONE/)).toBeTruthy();
});
it('shows the top-up row with its date', async () => {
renderLine('FINISHED', {balance: 30, paid: [row('FINISHED_EARLY', {points: 10}), row('FINISHED_TOPUP', {points: 20})], missing: {}});
expect(await screen.findAllByText(/^POINTS.GAME.PAID/)).toHaveLength(2);
});
it('shows no line for a status the game never reached', async () => {
renderLine('BACKLOG', {balance: 30, paid: [], missing: {STARTED: 'NOT_STARTED', FINISHED: 'NOT_FINISHED', COMPLETED: 'NOT_COMPLETED'}});
await waitFor(() => expect(pointsCalls()).toHaveLength(1));
expect(document.body.textContent).toBe('');
});
it('asks again with a toast after a status change, not on the first load', async () => {
const {view, tree} = renderLine('PLAYING', [
{balance: 0, paid: [], missing: {STARTED: 'NO_PLAY_AFTER_START'}},
{balance: 30, paid: [row('FINISHED', {bookedNow: true})], missing: {}},
]);
await screen.findByText('POINTS.GAME.WAITING_START');
expect(toast).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
view.rerender(tree('FINISHED'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:30'));
expect(pointsCalls()).toHaveLength(2);
expect(await screen.findByText(/^POINTS.GAME.PAID/)).toBeTruthy();
});
it('reloads the wallet when the balance changed, so the header updates', async () => {
renderLine('FINISHED', {balance: 30, paid: [row('FINISHED')], missing: {}});
await screen.findByText(/^POINTS.GAME.PAID/);
await waitFor(() => expect(globalThis.fetch.mock.calls.some(([url]) => url.includes('/api/rewards/wallet'))).toBe(true));
});
it('shows nothing and no toast when the request fails', async () => {
fetchRoutes({'GET /api/rewards/games/220': json(404, {code: 'GAME_NOT_FOUND'})});
render(<PreferencesProvider><GamePointsLine game={{appid: 220, status: 'FINISHED'}}/></PreferencesProvider>);
await waitFor(() => expect(pointsCalls()).toHaveLength(1));
expect(document.body.textContent).toBe('');
expect(toast).not.toHaveBeenCalled();
});
});
+6 -4
View File
@@ -12,15 +12,17 @@ import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js';
import {playChart} from '../utils/statCharts.js';
const total = (g) => g.totalPlaytimeMinutes ?? g.playtimeMinutes;
/**
* @param chart LIST, BARS, PIE or DONUT
* @param onChart `(form)`: shows the picker
*/
export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) {
const {t} = useTranslation();
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtimeMinutes}));
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: total(g)}));
const form = playChart(chart, mapped);
const maxMinutes = games[0]?.playtimeMinutes || 1;
const maxMinutes = mapped[0]?.minutes || 1;
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">
@@ -62,12 +64,12 @@ export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST'
<div className="mt-2 h-1.5 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
style={{width: `${Math.max(2, (total(g) / maxMinutes) * 100)}%`}}
/>
</div>
</div>
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
{formatPlaytime(total(g))}
</span>
</Link>
</li>
+18
View File
@@ -0,0 +1,18 @@
// @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 MostPlayedCard from './MostPlayedCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
describe('MostPlayedCard', () => {
afterEach(cleanup);
it('shows the set total of a game, not the minutes of its own row', () => {
render(<MemoryRouter><MostPlayedCard games={[{appid: 620, name: 'Portal 2', playtimeMinutes: 100,
totalPlaytimeMinutes: 600, status: 'PLAYING'}]}/></MemoryRouter>);
expect(screen.getByText('10h')).toBeTruthy();
});
});
+1 -1
View File
@@ -116,7 +116,7 @@ describe('PileOfShameCard', () => {
render(<MemoryRouter><StatDrillProvider source="own"><PileOfShameCard summary={summary(2)} onShuffle={() => {}}/></StatDrillProvider></MemoryRouter>);
expect(screen.getAllByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 2'})[0].getAttribute('href'))
.toBe('/library?hideDuplicates=true&status=BACKLOG');
.toBe('/library?status=BACKLOG');
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.NEVER_PLAYED:1'}));
expect(await screen.findByText('Untouched')).toBeTruthy();
+30
View File
@@ -0,0 +1,30 @@
import {useTranslation} from 'react-i18next';
import {platformLabel} from '../utils/platforms.js';
// lucide has no brand icons: a short text badge per platform.
const shortName = ({source, platform}) => {
if (source === 'STEAM') return 'ST';
return platform ?? (source === 'PSN' ? 'PS' : 'M');
};
/**
* @param platforms the {source, platform} of every edition of a library entry, see Core's `platforms`
*/
export default function PlatformIcons({platforms}) {
const {t} = useTranslation();
if (!platforms?.length) return null;
return (
<ul className="flex flex-wrap gap-1">
{platforms.map((p) => {
const label = platformLabel(p) ?? t(`LIBRARY.SOURCE.${p.source}`);
return (
<li key={`${p.source}:${p.platform ?? ''}`} title={label} aria-label={label}
className="rounded border px-1 text-[10px] leading-4 font-semibold text-muted-foreground">
{shortName(p)}
</li>
);
})}
</ul>
);
}
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import PlatformIcons from './PlatformIcons.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(cleanup);
describe('PlatformIcons', () => {
it('showsOneBadgeWithALabelPerPlatform', () => {
render(<PlatformIcons platforms={[{source: 'STEAM', platform: null}, {source: 'PSN', platform: 'PS5'}]}/>);
expect(screen.getAllByRole('listitem')).toHaveLength(2);
expect(screen.getByLabelText('Steam').textContent).toBe('ST');
expect(screen.getByLabelText('PS5').textContent).toBe('PS5');
});
it('namesAPlayStationTitleWithoutPlatformAfterItsSource', () => {
render(<PlatformIcons platforms={[{source: 'PSN', platform: null}]}/>);
expect(screen.getByLabelText('LIBRARY.SOURCE.PSN').textContent).toBe('PS');
});
it('showsAManualPlatformByName', () => {
render(<PlatformIcons platforms={[{source: 'MANUAL', platform: 'Switch'}]}/>);
expect(screen.getByLabelText('Switch').textContent).toBe('Switch');
});
it('aManualGameWithoutPlatformIsNotShownAsPlayStation', () => {
render(<PlatformIcons platforms={[{source: 'MANUAL', platform: null}]}/>);
expect(screen.getByLabelText('LIBRARY.SOURCE.MANUAL').textContent).not.toBe('PS');
});
it('rendersNothingWithoutPlatforms', () => {
const {container} = render(<PlatformIcons platforms={[]}/>);
expect(container.firstChild).toBeNull();
cleanup();
const missing = render(<PlatformIcons/>);
expect(missing.container.firstChild).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import PlatformIcons from './PlatformIcons.jsx';
import {formatPlaytime} from '../utils/games.js';
import {formatDate} from '../utils/format.js';
import {platformLabel} from '../utils/platforms.js';
/**
* One row per platform of the game: playtime, last played and the progress of that platform. A row of another edition
* links to that edition, whose page lists its achievements.
*/
export default function PlatformsCard({game}) {
const {t, i18n} = useTranslation();
const lines = game.platforms;
if (!lines?.length) return null;
return (
<Card className="gap-3 p-4">
<h2 className="text-sm font-medium">{t('PLATFORMS.TITLE')}</h2>
<ul className="divide-y text-sm">
{lines.map((line, index) => {
const label = platformLabel(line) ?? t(`LIBRARY.SOURCE.${line.source}`);
return (
<li key={`${line.appid}:${line.source}:${index}`}
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 first:pt-0 last:pb-0">
<span className="flex min-w-24 items-center gap-2 font-medium">
<PlatformIcons platforms={[line]}/>
{line.appid === game.appid
? <span>{label}</span>
: <Link to={`/library/${line.appid}`} className="hover:text-primary hover:underline">{label}</Link>}
</span>
<span className="tabular-nums">{formatPlaytime(line.playtimeMinutes)}</span>
<span className="text-muted-foreground">
{t('PLATFORMS.LAST_PLAYED')}{' '}
{line.lastPlayedAt
? formatDate(line.lastPlayedAt, i18n.language, {dateStyle: 'medium'})
: t('PLATFORMS.NEVER')}
</span>
{line.achievementsTotal != null && (
<span className="inline-flex items-center gap-1 tabular-nums">
<Trophy className={cn('size-3', line.perfect && 'text-amber-500')}/>
{line.achievementsUnlocked ?? '?'} / {line.achievementsTotal}
</span>
)}
</li>
);
})}
</ul>
{lines.length > 1 && (
<p className="flex items-center justify-between border-t pt-2 text-sm font-medium">
<span>{t('PLATFORMS.TOTAL')}</span>
<span className="tabular-nums">{formatPlaytime(game.totalPlaytimeMinutes)}</span>
</p>
)}
</Card>
);
}
+79
View File
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import PlatformsCard from './PlatformsCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const steam = {source: 'STEAM', platform: null, appid: 220, playtimeMinutes: 3000, lastPlayedAt: '2026-10-01T10:00:00Z',
achievementsUnlocked: 2, achievementsTotal: 3, perfect: false};
const ps5 = {source: 'PSN', platform: 'PS5', appid: 221, playtimeMinutes: 600, lastPlayedAt: null,
achievementsUnlocked: 10, achievementsTotal: 10, perfect: true};
const renderCard = (game) => render(<MemoryRouter><PlatformsCard game={game}/></MemoryRouter>);
// The icon of a row is a list too.
const lineRows = () => [...document.querySelectorAll('ul.divide-y > li')];
describe('PlatformsCard', () => {
afterEach(cleanup);
it('showsOneRowPerPlatformWithPlaytimeAndProgress', () => {
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
const rows = lineRows();
expect(rows).toHaveLength(2);
expect(within(rows[0]).getByText('Steam')).toBeTruthy();
expect(within(rows[0]).getByText('50h')).toBeTruthy();
expect(within(rows[0]).getByText('2 / 3')).toBeTruthy();
expect(within(rows[1]).getByText('PS5', {selector: 'a, span'})).toBeTruthy();
expect(within(rows[1]).getByText('10h')).toBeTruthy();
});
it('marksAPerfectLineOnly', () => {
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
const rows = lineRows();
expect(rows[0].querySelector('.text-amber-500')).toBeNull();
expect(rows[1].querySelector('.text-amber-500')).not.toBeNull();
});
it('showsTheTotalRowOnlyWithTwoLines', () => {
renderCard({appid: 220, platforms: [steam], totalPlaytimeMinutes: 3000});
expect(screen.queryByText('PLATFORMS.TOTAL')).toBeNull();
cleanup();
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
expect(screen.getByText('PLATFORMS.TOTAL')).toBeTruthy();
expect(screen.getByText('60h')).toBeTruthy();
});
it('showsNeverForAMissingLastPlayed', () => {
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
expect(screen.getAllByText(/PLATFORMS\.NEVER/)).toHaveLength(1);
});
it('linksOnlyTheLinesOfAnotherEdition', () => {
renderCard({appid: 220, platforms: [steam, ps5], totalPlaytimeMinutes: 3600});
const links = screen.getAllByRole('link');
expect(links).toHaveLength(1);
expect(links[0].getAttribute('href')).toBe('/library/221');
});
it('showsNoAchievementsForALineWithoutAny', () => {
renderCard({appid: 220, platforms: [{...steam, achievementsUnlocked: null, achievementsTotal: null}],
totalPlaytimeMinutes: 3000});
expect(screen.queryByText(/ \/ /)).toBeNull();
});
it('isHiddenWithoutPlatforms', () => {
const {container} = renderCard({appid: 220, platforms: [], totalPlaytimeMinutes: 0});
expect(container.textContent).toBe('');
});
});
+68
View File
@@ -0,0 +1,68 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import FitText from './FitText.jsx';
import GameCover from './GameCover.jsx';
import RatingStars from './RatingStars.jsx';
import {formatDate} from '../utils/format.js';
import {formatPlaytime} from '../utils/games.js';
/**
* One open question of one game. The page decides what an answer does.
*
* @param item a result of `GET /api/library/questions`
* @param busy true while an answer is on its way
* @param expectedPoints the points a finish would pay (`GET /api/rewards/expected`), absent or 0 shows nothing
* @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars
*/
export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
const {t, i18n} = useTranslation();
const {appid, name, imageUrl, question, playtimeMinutes, normallyMinutes, lastActivity} = item;
const reasonKey = question === 'FINISHED' && normallyMinutes > 0 ? 'FINISHED.REASON_TTB' : `${question}.REASON`;
const reason = t(`QUESTIONS.${reasonKey}`, {
playtime: formatPlaytime(playtimeMinutes),
ttb: formatPlaytime(normallyMinutes),
date: lastActivity ? formatDate(lastActivity, i18n.language) : '',
});
const askId = `question-${appid}`;
return (
<Card className="flex-row items-start gap-3 p-4">
<Link to={`/library/${appid}`} className="w-16 shrink-0 sm:w-24">
<GameCover appid={appid} imageUrl={imageUrl}/>
</Link>
<div className="min-w-0 flex-1 space-y-2">
<Link to={`/library/${appid}`} className="@container block min-w-0 font-medium hover:text-primary">
<FitText text={name ?? `App ${appid}`}/>
</Link>
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
<p className="text-sm text-muted-foreground">{reason}</p>
{question === 'FINISHED' && expectedPoints > 0 && (
<p className="text-sm font-medium text-primary">{t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints})}</p>
)}
{question === 'RATE' ? (
<div className="flex flex-wrap items-center gap-2">
<RatingStars value={0} labelledBy={askId} disabled={busy}
onChange={(rating) => onAnswer({yes: true, rating})}/>
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => onAnswer({yes: false})}>
{t('QUESTIONS.SKIP')}
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={busy} onClick={() => onAnswer({yes: true})}>
{t('QUESTIONS.YES')}
</Button>
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => onAnswer({yes: false})}>
{t('QUESTIONS.NO')}
</Button>
</div>
)}
</div>
</Card>
);
}
+51
View File
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import QuestionCard from './QuestionCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(cleanup);
const item = (overrides) => ({appid: 10, name: 'Hades', imageUrl: null, question: 'RATE', status: 'FINISHED',
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null, ...overrides});
const renderCard = (props, onAnswer = vi.fn()) => {
render(<MemoryRouter><QuestionCard item={item(props)} busy={false} onAnswer={onAnswer}/></MemoryRouter>);
return onAnswer;
};
describe('QuestionCard', () => {
it('sendsTheStarsAsARating', () => {
const onAnswer = renderCard();
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
expect(onAnswer).toHaveBeenCalledWith({yes: true, rating: 4});
});
it('sendsNoForSkip', () => {
const onAnswer = renderCard();
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.SKIP'}));
expect(onAnswer).toHaveBeenCalledWith({yes: false});
});
it('answersYesAndNoWithoutARating', () => {
const onAnswer = renderCard({question: 'START', status: 'BACKLOG'});
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'}));
expect(onAnswer.mock.calls).toEqual([[{yes: true}], [{yes: false}]]);
expect(screen.queryByRole('button', {name: 'QUESTIONS.SKIP'})).toBeNull();
});
it('linksTheNameToTheGamePage', () => {
renderCard();
expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10');
});
});
+32
View File
@@ -0,0 +1,32 @@
import {useTranslation} from 'react-i18next';
import {Star} from 'lucide-react';
import {cn} from '@/lib/utils';
/**
* Five star buttons. Clicking the chosen star again sends 0, so the parent decides what that means.
*
* @param value 0 for no rating
* @param labelledBy id of the element that names the group
*/
export default function RatingStars({value, onChange, labelledBy, disabled}) {
const {t} = useTranslation();
return (
<div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
disabled={disabled}
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={value === n}
onClick={() => onChange(value === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
</button>
))}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import {useCallback, useEffect, useRef} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {formatDate} from '../utils/format.js';
import {loadGamePoints, pointsNotice} from '../utils/rewards.js';
/**
* Returns `check(appid, {status, tell})`: asks Core what the game earned (Core settles first), reloads the wallet when
* the balance moved, so the header updates, and with `tell` shows a toast. Resolves to the answer, or null when the
* request failed (nothing is shown then).
*/
export default function usePointsCheck() {
const {t, i18n} = useTranslation();
const {wallet, reloadWallet} = usePreferences();
const balance = useRef(wallet?.balance);
useEffect(() => {
balance.current = wallet?.balance;
}, [wallet]);
return useCallback(async (appid, {status, tell}) => {
const {ok, data} = await loadGamePoints(appid);
if (!ok) return null;
if (data.balance !== balance.current) {
balance.current = data.balance;
reloadWallet();
}
if (tell) {
const notice = pointsNotice(data, status);
if (notice.type === 'booked') {
toast.success(t(notice.rest ? 'POINTS.GAME.TOAST_BOOKED_REST' : 'POINTS.GAME.TOAST_BOOKED', {count: notice.points}));
} else if (notice.type === 'paid') {
toast(t(notice.rest ? 'POINTS.GAME.TOAST_PAID_REST' : 'POINTS.GAME.TOAST_PAID',
{date: formatDate(notice.date, i18n.language, {dateStyle: 'medium'})}));
} else if (notice.type === 'none') {
toast(t('POINTS.GAME.TOAST_NONE', {reason: t(`POINTS.REASONS.${notice.reason}`)}));
} else {
toast(t('POINTS.GAME.TOAST_STATUS'));
}
}
return data;
}, [t, i18n.language, reloadWallet]);
}
+1
View File
@@ -7,6 +7,7 @@ import {renderWithProviders} from '../test/renderWithProviders.jsx';
import WalletLoader from '../test/WalletLoader.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../components/GamePointsLine.jsx', () => ({default: () => null}));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
+11 -1
View File
@@ -10,6 +10,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
import Confetti from '../components/Confetti.jsx';
import GameAchievements from '../components/GameAchievements.jsx';
import GameHeroCard from '../components/GameHeroCard.jsx';
import GamePointsLine from '../components/GamePointsLine.jsx';
import ManualGameCard from '../components/ManualGameCard.jsx';
import CompletedHint from '../components/CompletedHint.jsx';
import FinishedHint from '../components/FinishedHint.jsx';
@@ -18,6 +19,7 @@ import EditionsCard from '../components/EditionsCard.jsx';
import OwnersCard from '../components/people/OwnersCard.jsx';
import GameMergeDialog from '../components/GameMergeDialog.jsx';
import LinkGameDialog from '../components/LinkGameDialog.jsx';
import PlatformsCard from '../components/PlatformsCard.jsx';
import PlayingHint from '../components/PlayingHint.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useGame from '../hooks/useGame.js';
@@ -94,6 +96,8 @@ export default function GameDetail() {
</p>
)}
<GamePointsLine key={`points-${data.appid}`} game={data}/>
{/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
@@ -104,7 +108,13 @@ export default function GameDetail() {
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
onSaved={(entry) => {
setData((d) => ({...d, ...entry}));
// The other editions share status, rating and notes.
if (data.editions?.length) reload();
}}/>
<PlatformsCard game={data}/>
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
+1
View File
@@ -6,6 +6,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../components/GamePointsLine.jsx', () => ({default: () => null}));
// Stands in for a saved correction, which loads the game again.
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
+1
View File
@@ -8,6 +8,7 @@ import {resetBrokenImageReports} from '../utils/brokenImages.js';
import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../components/GamePointsLine.jsx', () => ({default: () => null}));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
+87 -54
View File
@@ -1,12 +1,12 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {Link, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import useLibrarySync from '../hooks/useLibrarySync.js';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseStatus} from '../utils/backlog.js';
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js';
import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
@@ -23,6 +23,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {coverEffect} from '../utils/rewards.js';
import {apiFetch, request} from '../utils/api.js';
@@ -58,17 +59,19 @@ export default function GameLibrary() {
const q = searchParams.get('q') ?? '';
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
const hiddenOnly = searchParams.get('hidden') === 'true';
const hideDuplicates = searchParams.get('hideDuplicates') === 'true';
// The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored.
const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly;
const listHideDuplicates = !hiddenOnly && hideDuplicates;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates);
const sources = parseSources(searchParams.get('source'));
const listSources = hiddenOnly ? [] : sources;
const sourceKey = listSources.join(',');
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || sourceKey);
const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
// A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
// and come back.
const [canSync, setCanSync] = useState(true);
const [ownSources, setOwnSources] = useState([]);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true);
@@ -79,7 +82,7 @@ export default function GameLibrary() {
const signal = nextSignal();
setIsLoading(true);
setError(null);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, hideDuplicates: listHideDuplicates})}`;
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey})}`;
const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return;
setIsLoading(false);
@@ -89,30 +92,41 @@ export default function GameLibrary() {
return;
}
setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]);
useEffect(() => {
load();
}, [load]);
const mounted = useRef(true);
useEffect(() => {
let cancelled = false;
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const loadCounts = useCallback(() => {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
.then((response) => (response.ok ? response.json() : null))
.then((json) => {
if (!cancelled && json) setCanSync(json.canSync);
if (!mounted.current || !json) return;
setCanSync(json.canSync);
setOwnSources(json.sources ?? []);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
loadCounts();
}, [loadCounts]);
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({
enabled: canSync,
expectInitialSync,
onComplete: load,
onComplete: () => {
load();
loadCounts();
},
});
const updateParams = useCallback((next) => {
@@ -137,8 +151,13 @@ export default function GameLibrary() {
updateParams({status: value, hidden: null, page: 0});
};
const toggleSource = (source) => {
const next = GAME_SOURCES.filter((s) => (s === source ? !sources.includes(s) : sources.includes(s)));
updateParams({source: next.join(','), hidden: null, page: 0});
};
const onHiddenChange = () => {
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, page: 0});
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, source: null, page: 0});
};
const onSizeChange = (value) => {
@@ -158,6 +177,8 @@ export default function GameLibrary() {
const syncing = syncBusy || sync.isRunning;
const ready = !isLoading && !error;
// A selected source the user lacks stays visible so a stale URL value can be cleared.
const sourceChips = GAME_SOURCES.filter((s) => ownSources.includes(s) || sources.includes(s));
return (
<PageContainer className="p-4 md:p-8 space-y-6">
@@ -216,46 +237,55 @@ export default function GameLibrary() {
{adding && <AddGamePanel onClose={() => setAdding(false)}/>}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
{[null, ...GAME_STATUSES].map((s) => (
<div className="flex flex-col gap-1.5">
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
{[null, ...GAME_STATUSES].map((s) => (
<Button
key={s ?? 'ALL'}
size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={() => onStatusChange(s)}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
</Button>
))}
<Button
key={s ?? 'ALL'}
size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly}
variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={() => onStatusChange(s)}
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button>
))}
<Button
size="sm"
variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
>
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button>
<Button
size="sm"
variant={listHideDuplicates ? 'default' : 'outline'}
aria-pressed={listHideDuplicates}
className="h-7 rounded-full px-3 text-xs"
onClick={() => updateParams({hideDuplicates: listHideDuplicates ? null : true, hidden: null, page: 0})}
>
<Layers className="size-3"/>{t('LIBRARY.FILTER_HIDE_DUPLICATES')}
</Button>
<Button
size="sm"
variant={hiddenOnly ? 'default' : 'outline'}
aria-pressed={hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={onHiddenChange}
>
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
</Button>
<Button
size="sm"
variant={hiddenOnly ? 'default' : 'outline'}
aria-pressed={hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={onHiddenChange}
>
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
</Button>
</div>
{sourceChips.length >= 2 && (
<div role="group" aria-label={t('LIBRARY.SOURCE_LABEL')} className="flex flex-wrap gap-1.5">
{sourceChips.map((s) => (
<Button
key={s}
size="sm"
variant={listSources.includes(s) ? 'default' : 'outline'}
aria-pressed={listSources.includes(s)}
className="h-7 rounded-full px-3 text-xs"
onClick={() => toggleSource(s)}
>
{t(`LIBRARY.SOURCE.${s}`)}
</Button>
))}
</div>
)}
</div>
<div className="relative sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
@@ -306,10 +336,13 @@ export default function GameLibrary() {
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="@container min-w-0 flex-1 font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
<span className="@container min-w-0 flex-1">
<span className="block font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
<PlatformIcons platforms={g.platforms}/>
</span>
{platformLabel(g) && (
{!g.platforms?.length && platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge>
@@ -330,7 +363,7 @@ export default function GameLibrary() {
)}
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)}
{formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
</Badge>
<ChevronRight className="size-4 text-muted-foreground"/>
</Link>
+131 -9
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import {StrictMode} from 'react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
@@ -11,16 +12,20 @@ import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
const syncOptions = vi.hoisted(() => ({current: null}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
syncOptions.current = opts;
return {status: 'OK', lastSyncedAt: null, isRunning: false};
}}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const DEBOUNCE_WAIT_MS = 700;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const answer = (games = page.content, canSync = true, hasSteamId = canSync) => fetchRoutes({
const answer = (games = page.content, canSync = true, hasSteamId = canSync, sources = []) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync, sources}),
});
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -212,15 +217,32 @@ describe('GameLibrary short games', () => {
expect(screen.getAllByText(/^x\d/)).toHaveLength(1);
});
it('hideDuplicatesFilter_AddsHideDuplicatesToTheUrlAndTheRequest', async () => {
const lib = renderLibrary();
it('hasNoHideDuplicatesToggle_AndTheRequestNeverCarriesTheParam', async () => {
renderLibrary('/library?hideDuplicates=true');
await waitFor(() => expect(fetch).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}));
expect(screen.queryByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'})).toBeNull();
expect(fetch.mock.calls.every(([url]) => !String(url).includes('hideDuplicates'))).toBe(true);
});
await waitFor(() => expect(lib.params().get('hideDuplicates')).toBe('true'));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hideDuplicates=true(&|$)/));
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}).getAttribute('aria-pressed')).toBe('true');
it('showsThePlatformsOfAnEntryInsteadOfTheSinglePlatformBadge', async () => {
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM', platform: null,
platforms: [{source: 'STEAM', platform: null}, {source: 'PSN', platform: 'PS5'}]}], true);
renderLibrary();
expect(await screen.findByLabelText('PS5')).toBeTruthy();
expect(screen.getByLabelText('Steam')).toBeTruthy();
expect(screen.queryByText('Steam')).toBeNull();
});
it('showsTheSetTotalPlaytimeOnTheCard_AndTheOwnMinutesWithoutATotal', async () => {
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 600, totalPlaytimeMinutes: 3600, status: 'BACKLOG'},
{appid: 620, name: 'Portal 2', playtimeMinutes: 1200, status: 'BACKLOG'}]);
renderLibrary();
expect(await screen.findByText('60h')).toBeTruthy();
expect(screen.getByText('20h')).toBeTruthy();
expect(screen.queryByText('10h')).toBeNull();
});
it('leaves the hidden games with a status click', async () => {
@@ -250,6 +272,106 @@ describe('GameLibrary short games', () => {
});
});
describe('GameLibrary source filter', () => {
const lastGamesUrl = () => String(fetch.mock.calls.map(([url]) => url).filter((url) => String(url).includes('/api/library/games?')).at(-1));
const chip = (source) => screen.getByRole('button', {name: `LIBRARY.SOURCE.${source}`});
it('sourceFilter_AddsSourceToTheUrlAndTheRequest', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const lib = renderLibrary('/library?page=2');
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
fireEvent.click(chip('STEAM'));
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM'));
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM,PSN'));
expect(lib.params().get('page')).toBe('0');
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]source=STEAM%2CPSN(&|$)/));
expect(chip('STEAM').getAttribute('aria-pressed')).toBe('true');
fireEvent.click(chip('STEAM'));
await waitFor(() => expect(lib.params().get('source')).toBe('PSN'));
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBeNull());
});
it('sourceFilter_HiddenWithOneOwnSource', async () => {
answer(page.content, true, true, ['STEAM']);
renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
await wait(50);
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
});
it('sourceFilter_ShowsASelectedSourceTheUserLacks', async () => {
answer(page.content, true, true, ['STEAM']);
const lib = renderLibrary('/library?source=PSN');
expect((await screen.findByRole('button', {name: 'LIBRARY.SOURCE.PSN'})).getAttribute('aria-pressed')).toBe('true');
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBeNull());
});
it('sourceFilter_IgnoresUnknownValuesInTheUrl', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
renderLibrary('/library?source=XBOX');
await waitFor(() => expect(lastGamesUrl()).toContain('/api/library/games?'));
expect(lastGamesUrl()).not.toMatch(/source=/);
});
it('hiddenFilter_ClearsTheSource', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const lib = renderLibrary('/library?source=PSN');
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDDEN'}));
await waitFor(() => expect(lib.params().get('hidden')).toBe('true'));
expect(lib.params().get('source')).toBeNull();
await waitFor(() => expect(lastGamesUrl()).not.toMatch(/source=/));
});
it('hiddenLink_IgnoresTheSource', async () => {
renderLibrary('/library?hidden=true&source=PSN');
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]hidden=true(&|$)/));
expect(lastGamesUrl()).not.toMatch(/source=/);
});
it('sourceFilter_ChipsFollowAFinishedSync', async () => {
answer(page.content, true, true, ['STEAM']);
renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
await wait(50);
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
answer(page.content, true, true, ['STEAM', 'PSN']);
await act(async () => syncOptions.current.onComplete());
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
});
it('sourceFilter_ChipsShowUnderStrictMode', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const router = createMemoryRouter([{
path: '/library',
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/></CurrentUserProvider></PreferencesProvider>,
}], {initialEntries: ['/library']});
render(<StrictMode><RouterProvider router={router}/></StrictMode>);
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
});
it('sourceFilter_EmptyResultShowsNoMatch', async () => {
answer([], true, true, ['STEAM', 'PSN']);
renderLibrary('/library?source=PSN');
expect(await screen.findByText('LIBRARY.NO_MATCH')).toBeTruthy();
});
});
describe('GameLibrary with games added by hand', () => {
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
+3
View File
@@ -11,6 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {saveGameStatus} from '../utils/backlog.js';
import {formatDate} from '../utils/format.js';
import {formatPlaytime} from '../utils/games.js';
@@ -281,6 +282,7 @@ function PickCard({game}) {
const {t, i18n} = useTranslation();
const [busy, setBusy] = useState(false);
const [started, setStarted] = useState(false);
const check = usePointsCheck();
const reason = reasonText(game.reason, t, i18n.language);
const startPlaying = async () => {
@@ -294,6 +296,7 @@ function PickCard({game}) {
}
setStarted(true);
toast.success(t('BACKLOG.SAVED'));
check(game.appid, {status: 'PLAYING', tell: true});
} catch (err) {
console.error('Start playing error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+15 -1
View File
@@ -8,7 +8,7 @@ import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const game = (appid, extra = {}) => ({
appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null,
@@ -33,6 +33,9 @@ function mockBackend(answer, routes = {}) {
fetchRoutes({
'GET /api/library/picker': (url) => json(200, {categories: answer(new URL(url, 'http://x').searchParams)}),
'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)),
'GET /api/rewards/games/7': json(200, {balance: 5, missing: {}, paid: [
{kind: 'STARTED', appid: 7, points: 5, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]}),
'GET /api/rewards/wallet': json(200, {balance: 5, catalog: [], owned: [], equipped: {}, history: []}),
...routes,
});
}
@@ -323,6 +326,17 @@ describe('PlayNextPage', () => {
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'});
});
it('shows the points toast after Start', async () => {
mockRows([row('NEXT_UP', game(7))]);
renderPage();
await screen.findByText('Game 7');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:5'));
expect(globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/7'))).toHaveLength(1);
});
it('lets the user try again when saving fails', async () => {
mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})});
renderPage();
+3 -2
View File
@@ -18,8 +18,9 @@ import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH',
'STREAK', 'STARTER', 'UNLOCK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
+20
View File
@@ -61,6 +61,15 @@ afterEach(() => {
});
describe('PointsPage', () => {
it('lists all thirteen rules', async () => {
await renderPage();
const rules = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED',
'MONTH', 'STREAK', 'STARTER', 'UNLOCK'];
const shown = screen.getAllByRole('listitem').map((item) => item.textContent);
expect(shown.filter((text) => text.startsWith('POINTS.RULES.'))).toEqual(rules.map((rule) => `POINTS.RULES.${rule}`));
});
it('shows the effect slots with a preview of each look', async () => {
await renderPage();
@@ -205,6 +214,17 @@ describe('PointsPage', () => {
expect(screen.getByText('+50')).toBeTruthy();
});
it('links early and top-up rows to the game', async () => {
await renderPage({'GET /api/rewards/wallet': json(200, wallet({history: [
{kind: 'FINISHED_EARLY', ref: '10', gameName: 'Early Game', points: 10, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'FINISHED_TOPUP', ref: '10', gameName: 'Early Game', points: 20, eventAt: '2026-09-30T10:00:00Z'},
]}))});
const links = screen.getAllByRole('link', {name: 'Early Game'});
expect(links).toHaveLength(2);
expect(links[0].getAttribute('href')).toBe('/library/10');
});
it('buys, then loads the wallet again', async () => {
let balance = 160;
const mock = await renderPageWith(() => json(200, wallet({balance})), {
+89
View File
@@ -0,0 +1,89 @@
import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {toast} from 'sonner';
import PageContainer from '../components/PageContainer.jsx';
import QuestionCard from '../components/QuestionCard.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
import {loadExpected} from '../utils/rewards.js';
// Core answers at most this many appids at once.
const MAX_EXPECTED = 200;
const STATUS_OF_QUESTION = {START: 'PLAYING', FINISHED: 'FINISHED', DROPPED: 'DROPPED'};
/**
* The open questions about the user's games: one card per game, answered one click at a time. Core decides which
* question is open; a yes changes the game like the game page does.
*/
export default function QuestionsPage() {
const {t} = useTranslation();
const check = usePointsCheck();
const [items, setItems] = useState([]);
const [status, setStatus] = useState('loading');
const [busyAppid, setBusyAppid] = useState(null);
const [expected, setExpected] = useState({});
const load = useCallback(async () => {
const {ok, status: code, data} = await fetchQuestions();
if (code === 401) return;
if (!ok) {
setStatus('error');
return;
}
setItems(data.results);
setStatus('ready');
// Points are a hint: a failed call shows no line and no error.
const finished = data.results.filter((item) => item.question === 'FINISHED').map((item) => item.appid);
for (let from = 0; from < finished.length; from += MAX_EXPECTED) {
const points = await loadExpected(finished.slice(from, from + MAX_EXPECTED));
if (points.ok) setExpected((current) => ({...current, ...points.data}));
}
}, []);
useEffect(() => {
load();
}, [load]);
const answer = async (item, choice) => {
setBusyAppid(item.appid);
try {
const {ok, status: code, data} = await answerQuestion(item.appid, {question: item.question, ...choice});
if (code === 401) return;
if (ok) {
// The next question of the same game (Rate it? after Finished?) takes the place of the card.
setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry])));
toast.success(t('BACKLOG.SAVED'));
const newStatus = choice.yes && STATUS_OF_QUESTION[item.question];
if (newStatus) check(item.appid, {status: newStatus, tell: true});
} else if (code === 404 || code === 409) {
toast(t('QUESTIONS.GONE'));
await load();
} else if (code === 0) {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} else {
toast.error(t('BACKLOG.SAVE_FAILED'));
}
} finally {
setBusyAppid(null);
}
};
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<h1 className="text-2xl font-bold tracking-tight">
{status === 'ready' && items.length > 0 ? t('QUESTIONS.TITLE_COUNT', {count: items.length}) : t('QUESTIONS.TITLE')}
</h1>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('INFO.ERROR.NETWORK_FAILURE')}</p>}
{status === 'ready' && items.length === 0 && (
<p className="text-sm text-muted-foreground">{t('QUESTIONS.EMPTY')}</p>
)}
{status === 'ready' && items.map((item) => (
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null} expectedPoints={expected[item.appid]}
onAnswer={(choice) => answer(item, choice)}/>
))}
</PageContainer>
);
}
+212
View File
@@ -0,0 +1,212 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import QuestionsPage from './QuestionsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING',
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null});
const booked = (appid) => ({balance: 30, missing: {}, paid: [{kind: 'FINISHED', appid, points: 30, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]});
const WALLET = {
'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/rewards/expected': json(200, {}),
};
const expectedCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/expected'));
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
const renderPage = (routes) => {
const mock = fetchRoutes({...WALLET, ...routes});
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
return mock;
};
const bodyOf = (mock) => JSON.parse(mock.mock.calls.find(([, init]) => init?.method === 'POST')[1].body);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('QuestionsPage', () => {
it('showsOneCardPerGameWithTheCountInTheTitle', async () => {
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})});
expect(await screen.findByText('Game 1')).toBeTruthy();
expect(screen.getByText('Game 2')).toBeTruthy();
expect(screen.getByText('QUESTIONS.START.ASK')).toBeTruthy();
expect(screen.getByText('QUESTIONS.TITLE_COUNT:2')).toBeTruthy();
});
it('removesTheCardAfterAYes', async () => {
const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
'POST /api/library/questions/1': json(200, {next: null}),
'GET /api/rewards/games/1': json(200, booked(1)),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.YES'})[0]);
await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull());
expect(screen.getByText('Game 2')).toBeTruthy();
expect(bodyOf(mock)).toEqual({question: 'START', yes: true});
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
});
it('removesTheCardAfterANo', async () => {
const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'DROPPED')]}),
'POST /api/library/questions/1': json(200, {next: null}),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'}));
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
expect(bodyOf(mock)).toEqual({question: 'DROPPED', yes: false});
});
it('replacesTheCardWithTheRateQuestionAfterFinished', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}),
'GET /api/rewards/games/1': json(200, booked(1)),
});
await screen.findByText('QUESTIONS.FINISHED.ASK');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
expect(await screen.findByText('QUESTIONS.RATE.ASK')).toBeTruthy();
expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull();
});
it('showsThePointsToastAfterAYesToFinished', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'POST /api/library/questions/1': json(200, {next: null}),
'GET /api/rewards/games/1': json(200, booked(1)),
});
await screen.findByText('QUESTIONS.FINISHED.ASK');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:30'));
expect(pointsCalls()).toHaveLength(1);
});
it('asksForNoPointsAfterANoOrTheRatingAnswer', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED'), card(2, 'RATE')]}),
'POST /api/library/questions/1': json(200, {next: null}),
'POST /api/library/questions/2': json(200, {next: null}),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.NO'})[0]);
await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull());
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
await screen.findByText('QUESTIONS.EMPTY');
expect(pointsCalls()).toHaveLength(0);
});
it('sendsTheRatingOfTheStars', async () => {
const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
'POST /api/library/questions/1': json(200, {next: null}),
});
await screen.findByText('QUESTIONS.RATE.ASK');
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
await screen.findByText('QUESTIONS.EMPTY');
expect(bodyOf(mock)).toEqual({question: 'RATE', yes: true, rating: 5});
});
it('loadsTheListAgainWhenTheQuestionIsGone', async () => {
let results = [card(1, 'START')];
renderPage({
'GET /api/library/questions': () => json(200, {results}),
'POST /api/library/questions/1': () => {
results = [];
return json(409, {code: 'QUESTION_GONE'});
},
});
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
expect(toast).toHaveBeenCalledWith('QUESTIONS.GONE');
});
it('keepsTheCardAndSaysSoWhenSavingFails', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'START')]}),
'POST /api/library/questions/1': json(500, {}),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(screen.getByText('Game 1')).toBeTruthy();
});
it('showsTheExpectedPointsOnAFinishedCardOnly', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED'), card(2, 'FINISHED'), card(3, 'START')]}),
'GET /api/rewards/expected': json(200, {1: 50, 2: 0}),
});
expect(await screen.findByText('QUESTIONS.FINISHED.POINTS:50')).toBeTruthy();
expect(screen.getAllByText(/^QUESTIONS.FINISHED.POINTS/)).toHaveLength(1);
expect(expectedCalls()[0][0]).toContain('appids=1,2');
});
it('asksForTheExpectedPointsOfEveryFinishedCardInChunks', async () => {
const cards = Array.from({length: 201}, (_, i) => card(i + 1, 'FINISHED'));
renderPage({'GET /api/library/questions': json(200, {results: cards})});
await waitFor(() => expect(expectedCalls().length).toBeGreaterThan(0));
const asked = expectedCalls().flatMap(([url]) => new URL(url, 'http://x').searchParams.get('appids').split(','));
expect(asked).toContain('201');
});
it('asksForNoExpectedPointsWithoutAFinishedCard', async () => {
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START')]})});
await screen.findByText('Game 1');
expect(expectedCalls()).toHaveLength(0);
});
it('showsNoPointsLineAndNoErrorWhenTheExpectedCallFails', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'GET /api/rewards/expected': json(500, {}),
});
expect(await screen.findByText('Game 1')).toBeTruthy();
await waitFor(() => expect(expectedCalls()).toHaveLength(1));
expect(screen.queryByText(/^QUESTIONS.FINISHED.POINTS/)).toBeNull();
expect(screen.queryByText('INFO.ERROR.NETWORK_FAILURE')).toBeNull();
});
it('showsTheEmptyState', async () => {
renderPage({'GET /api/library/questions': json(200, {results: []})});
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
});
});
+4 -4
View File
@@ -116,7 +116,7 @@ describe('StatsPage', () => {
const link = await screen.findByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG');
expect(link.getAttribute('href')).toBe('/library?status=BACKLOG');
});
it('aZeroTileIsNotClickable', async () => {
@@ -178,7 +178,7 @@ describe('StatsPage', () => {
const link = await within(await screen.findByText('STATS.OVERVIEW.TITLE').then(overview))
.findByRole('link', {name: 'STATS.OVERVIEW.GAMES: 12'});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true');
expect(link.getAttribute('href')).toBe('/library');
});
it('linksALegendRowToTheStatusLibrary', async () => {
@@ -188,7 +188,7 @@ describe('StatsPage', () => {
await screen.findByText('STATS.OVERVIEW.TITLE');
expect(within(overview()).getByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}).getAttribute('href'))
.toBe('/library?hideDuplicates=true&status=BACKLOG');
.toBe('/library?status=BACKLOG');
});
it('leavesAStatusWithoutGamesWithoutSliceAndTarget', async () => {
@@ -211,7 +211,7 @@ describe('StatsPage', () => {
fireEvent.click(paths()[1]);
await waitFor(() => expect(router.state.location.pathname + router.state.location.search)
.toBe('/library?hideDuplicates=true&status=PLAYING'));
.toBe('/library?status=PLAYING'));
});
it('showsTheGamesTileInsteadOfTheDonutForAnEmptyLibrary', async () => {
+5 -3
View File
@@ -1,11 +1,13 @@
import {apiFetch} from './api.js';
import {GAME_STATUSES} from './limits.js';
import {GAME_SOURCES, GAME_STATUSES} from './limits.js';
// Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE';
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const parseSources = (value) => GAME_SOURCES.filter((s) => (value ?? '').split(',').includes(s));
export const toBacklogRequest = ({status, rating, notes, priority}) => ({
status,
rating: rating || null,
@@ -56,13 +58,13 @@ export const dismissFinishedHint = (appid, dismissed) =>
body: JSON.stringify({dismissed}),
});
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates}) => {
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, source}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true');
if (hideDuplicates) params.set('hideDuplicates', 'true');
if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
if (source) params.set('source', source);
return params.toString();
};
+12 -5
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {backlogShare, libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js';
import {backlogShare, libraryQuery, NO_PRIORITY, parseSources, parseStatus, toBacklogRequest} from './backlog.js';
describe('toBacklogRequest', () => {
it('sends every field', () => {
@@ -53,10 +53,17 @@ describe('backlogShare', () => {
});
});
describe('libraryQuery hideDuplicates', () => {
it('libraryQuery_SetsHideDuplicatesOnlyWhenAsked', () => {
describe('sources', () => {
it('parseSources_KeepsKnownValuesInOrder', () => {
expect(parseSources('PSN,FOO,STEAM,PSN')).toEqual(['STEAM', 'PSN']);
expect(parseSources(null)).toEqual([]);
expect(parseSources('')).toEqual([]);
});
it('libraryQuery_SetsSourceOnlyWhenSelected', () => {
const base = {page: 0, size: 20, sort: 'appid,asc', status: null, q: ''};
expect(libraryQuery({...base, hideDuplicates: true})).toBe('page=0&size=20&sort=appid%2Casc&hideDuplicates=true');
expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates');
expect(libraryQuery({...base, source: 'STEAM,PSN'})).toBe('page=0&size=20&sort=appid%2Casc&source=STEAM%2CPSN');
expect(libraryQuery({...base, source: ''})).not.toContain('source');
expect(libraryQuery(base)).not.toContain('source');
});
});
+2
View File
@@ -2,6 +2,8 @@
// Core GameStatus
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
// Core GameSource, in the order the UI shows it
export const GAME_SOURCES = ['STEAM', 'PSN', 'MANUAL'];
// Core GamePriority, in the order the UI shows it
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
+5
View File
@@ -21,6 +21,11 @@ export const linkGames = (appid, otherAppid) =>
*/
export const unlinkGame = (appid) => apiFetch(`${base()}/games/${appid}/link`, {method: 'DELETE'});
/**
* DELETE: the game stands alone in the own library from now on. 204, 404 NOT_LINKED.
*/
export const splitGame = (appid) => apiFetch(`${base()}/games/${appid}/set`, {method: 'DELETE'});
/**
* DELETE: 204, 404 PAIR_NOT_OPEN.
*/
+24
View File
@@ -0,0 +1,24 @@
import {request} from './api.js';
const QUESTIONS = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/questions`;
/**
* GET /api/library/questions: `{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes,
* lastActivity}]}`, one open question per game, most important first. `question` is `START`, `FINISHED`, `DROPPED` or
* `RATE`; `normallyMinutes` is IGDB's time to beat or null; `lastActivity` (an ISO time) only comes with `DROPPED`.
*/
export const fetchQuestions = () => request(QUESTIONS());
/** GET /api/library/questions/count: `{count}`, the number of cards the list has. */
export const fetchQuestionCount = () => request(`${QUESTIONS()}/count`);
/**
* POST /api/library/questions/{appid}: `{question, yes, rating?}`. A yes to `RATE` needs `rating` (1 to 5). 200 is
* `{next}`: the next question of the same game (e.g. `RATE` after `FINISHED`) or null. 404 `GAME_NOT_FOUND`, 409
* `QUESTION_GONE` (the question is not open any more).
*/
export const answerQuestion = (appid, {question, yes, rating}) => request(`${QUESTIONS()}/${appid}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({question, yes, rating}),
});
+37
View File
@@ -120,6 +120,43 @@ export const cacheEquipped = (equipped) => storage.set(EQUIPPED_STORAGE_KEY, JSO
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
/** Settles in Core, then answers the paid rows of the game and why a kind is missing: `{balance, paid, missing}`. */
export const loadGamePoints = (appid) => request(`${REWARDS_URL}/games/${appid}`);
/** The full finish value per game, read-only: `{ok, status, data: {appid: points}}`. */
export const loadExpected = (appids) => request(`${REWARDS_URL}/expected?appids=${appids.join(',')}`);
const FINISH_KINDS = ['FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY'];
// The kind of points a status can earn; the other statuses (BACKLOG, DROPPED) earn none.
const KIND_OF_STATUS = {PLAYING: 'STARTED', FINISHED: 'FINISHED', COMPLETED: 'COMPLETED'};
const BLOCKED = ['HIDDEN', 'PLAYSTATION'];
/** The codes that only say the status never got there: no text, no line. */
export const STATUS_NOT_REACHED = ['NOT_STARTED', 'NOT_FINISHED', 'NOT_COMPLETED'];
/**
* What to tell the user after a status save, from the answer of `loadGamePoints`:
* `{type: 'booked', points}` (rows written by that call), `{type: 'paid', date}`, `{type: 'none', reason}` (a code
* of Core) or `{type: 'status'}` (this status earns no points). `rest: true` on booked and paid: a finish is paid
* early and the rest waits for proof.
*/
export const pointsNotice = (points, status) => {
const {missing} = points;
const rest = (status === 'FINISHED' || status === 'COMPLETED') && Boolean(missing.FINISHED_TOPUP);
const booked = points.paid.filter((row) => row.bookedNow).reduce((sum, row) => sum + row.points, 0);
if (booked > 0) return {type: 'booked', points: booked, ...(rest && {rest})};
const kind = KIND_OF_STATUS[status];
if (!kind) return {type: 'status'};
const kinds = kind === 'FINISHED' ? FINISH_KINDS : [kind];
const row = points.paid.find((paid) => kinds.includes(paid.kind));
if (row) return {type: 'paid', date: row.paidAt, ...(rest && {rest})};
// A completion rests on a finish: its own reason comes after the reason of the finish.
const reasons = kind === 'COMPLETED' ? [missing.FINISHED, missing.COMPLETED] : [missing[kind]];
const reason = [...reasons, Object.values(missing).find((code) => BLOCKED.includes(code))]
.find((code) => code && !STATUS_NOT_REACHED.includes(code));
return reason ? {type: 'none', reason} : {type: 'status'};
};
/** POST /api/rewards/purchases; the caller handles the status (409: ALREADY_OWNED or NOT_ENOUGH_POINTS). */
export const buyItem = (code) =>
apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
+53 -1
View File
@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest';
import {EFFECTS} from '../components/EffectBackground.jsx';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS, pointsNotice} from './rewards.js';
describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
@@ -47,3 +47,55 @@ describe('LOOKS backgrounds', () => {
for (const look of Object.values(LOOKS)) if (look.effect) expect(EFFECTS).toContain(look.effect);
});
});
describe('pointsNotice', () => {
const row = (kind, extra = {}) => ({kind, appid: 1, points: 30, paidAt: '2026-10-01T10:00:00Z', bookedNow: false, ...extra});
it('sums the rows booked by this call', () => {
const points = {paid: [row('STARTED', {points: 5, bookedNow: true}), row('FINISHED', {bookedNow: true})], missing: {}};
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'booked', points: 35});
});
it('gives the date of the row of the kind of the status', () => {
const points = {paid: [row('STARTED', {paidAt: '2026-09-01T10:00:00Z'}), row('FINISHED_MANUAL')], missing: {}};
expect(pointsNotice(points, 'PLAYING')).toEqual({type: 'paid', date: '2026-09-01T10:00:00Z'});
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'paid', date: '2026-10-01T10:00:00Z'});
});
it('counts an early finish as a paid finish and says the rest waits for proof', () => {
const points = {paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PROOF'}};
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'paid', date: '2026-10-01T10:00:00Z', rest: true});
expect(pointsNotice({...points, missing: {...points.missing, COMPLETED: 'NEEDS_PROVEN_FINISH'}}, 'COMPLETED'))
.toEqual({type: 'none', reason: 'NEEDS_PROVEN_FINISH'});
});
it('marks booked early points with rest', () => {
const points = {paid: [row('FINISHED_EARLY', {points: 10, bookedNow: true})], missing: {FINISHED_TOPUP: 'NO_PROOF'}};
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'booked', points: 10, rest: true});
expect(pointsNotice({paid: [row('FINISHED_TOPUP', {points: 20, bookedNow: true})], missing: {}}, 'FINISHED'))
.toEqual({type: 'booked', points: 20});
});
it('gives the reason of the missing kind', () => {
expect(pointsNotice({paid: [], missing: {FINISHED: 'NO_PROOF'}}, 'FINISHED')).toEqual({type: 'none', reason: 'NO_PROOF'});
expect(pointsNotice({paid: [], missing: {FINISHED: 'HIDDEN'}}, 'PLAYING')).toEqual({type: 'none', reason: 'HIDDEN'});
});
it('explains a COMPLETED game with an unpaid finish by the finish', () => {
const points = {paid: [], missing: {FINISHED: 'NO_PROOF', COMPLETED: 'NEEDS_PROVEN_FINISH'}};
expect(pointsNotice(points, 'COMPLETED')).toEqual({type: 'none', reason: 'NO_PROOF'});
expect(pointsNotice({paid: [row('FINISHED')], missing: {COMPLETED: 'NOT_ALL_ACHIEVEMENTS'}}, 'COMPLETED'))
.toEqual({type: 'none', reason: 'NOT_ALL_ACHIEVEMENTS'});
});
it('says the status earns no points for BACKLOG, DROPPED and a NOT_ reason', () => {
expect(pointsNotice({paid: [], missing: {}}, 'BACKLOG')).toEqual({type: 'status'});
expect(pointsNotice({paid: [row('FINISHED')], missing: {}}, 'DROPPED')).toEqual({type: 'status'});
expect(pointsNotice({paid: [], missing: {STARTED: 'NOT_STARTED'}}, 'PLAYING')).toEqual({type: 'status'});
});
});
+1 -1
View File
@@ -45,6 +45,6 @@ export async function fetchStatGames(source, params, ticket) {
}
}
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`;
export const libraryLink = (status) => `/library${status ? `?status=${status}` : ''}`;
export const gameHref = (appid) => `/library/${appid}`;
+2 -2
View File
@@ -21,8 +21,8 @@ describe('statGamesUrl', () => {
describe('libraryLink', () => {
it('addsTheStatusOnlyWhenGiven', () => {
expect(libraryLink()).toBe('/library?hideDuplicates=true');
expect(libraryLink('BACKLOG')).toBe('/library?hideDuplicates=true&status=BACKLOG');
expect(libraryLink()).toBe('/library');
expect(libraryLink('BACKLOG')).toBe('/library?status=BACKLOG');
});
});