diff --git a/README.md b/README.md
index 97a7c24..edee5ea 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,20 @@ 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.
+
+```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"]
+```
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 4f32846..b4f5ca2 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -1181,11 +1181,41 @@
"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.",
+ "COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast. Es braucht ein belegtes Steam-Durchspielen; ein manuelles öffnet es nie.",
+ "FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche (Montag bis Sonntag). Ein 4. Spiel in dieser Woche zahlt nichts.",
"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-Spielzeit zahlt nichts und ist kein Beweis. Ein Spiel, das mit einem PlayStation-Titel verknüpft ist, zahlt vorerst nichts.",
+ "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.",
+ "NONE": "{{kind}}: keine Punkte - {{reason}}",
+ "TOAST_BOOKED": "+{{count}} Punkte",
+ "TOAST_PAID": "Schon bezahlt am {{date}}",
+ "TOAST_NONE": "Noch keine Punkte: {{reason}}",
+ "TOAST_STATUS": "Dieser Status bringt keine Punkte"
+ },
+ "REASONS": {
+ "HIDDEN": "das Spiel ist ausgeblendet",
+ "PLAYSTATION": "PlayStation-Spiele zahlen nichts",
+ "PLAYSTATION_LINKED": "das Spiel ist mit einem PlayStation-Titel verknüpft (zahlt vorerst 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",
+ "NO_PROOF": "kein Beweis für frisches Spielen, spiele einmal nach dem Durchspielen",
+ "WEEKLY_LIMIT": "Wochenlimit erreicht",
+ "NEEDS_PROVEN_FINISH": "braucht zuerst ein bewiesenes Durchspielen",
+ "NOT_ALL_ACHIEVEMENTS": "nicht alle Erfolge freigeschaltet"
},
"SLOTS": {
"AVATAR_BORDER": "Avatarrahmen",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 0366ae4..c822556 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -1181,11 +1181,41 @@
"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.",
+ "COMPLETED": "+40 points when you reach 100 % achievements in a completed game. It needs a proven Steam finish; a manual finish never opens it.",
+ "FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week (Monday to Sunday). A 4th finish in that week pays nothing.",
"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 play pays nothing and is no proof. A game linked to a PlayStation title pays nothing for now.",
+ "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.",
+ "NONE": "{{kind}}: no points - {{reason}}",
+ "TOAST_BOOKED": "+{{count}} points",
+ "TOAST_PAID": "Already paid on {{date}}",
+ "TOAST_NONE": "No points yet: {{reason}}",
+ "TOAST_STATUS": "This status earns no points"
+ },
+ "REASONS": {
+ "HIDDEN": "the game is hidden",
+ "PLAYSTATION": "PlayStation games pay nothing",
+ "PLAYSTATION_LINKED": "the game is linked to a PlayStation title (pays nothing for now)",
+ "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",
+ "NO_PROOF": "no proof of fresh play, play once after finishing",
+ "WEEKLY_LIMIT": "weekly limit reached",
+ "NEEDS_PROVEN_FINISH": "needs a proven finish first",
+ "NOT_ALL_ACHIEVEMENTS": "not all achievements unlocked"
},
"SLOTS": {
"AVATAR_BORDER": "Avatar border",
diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx
index 027d61b..3ab783e 100644
--- a/src/components/AddGamePanel.jsx
+++ b/src/components/AddGamePanel.jsx
@@ -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);
diff --git a/src/components/AddGamePanel.test.jsx b/src/components/AddGamePanel.test.jsx
index 27319e2..dd07461 100644
--- a/src/components/AddGamePanel.test.jsx
+++ b/src/components/AddGamePanel.test.jsx
@@ -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( {}}/>, {path: '/library', entries: ['/library'],
routes: {'/library/:appid':
detail page
, '/import':
import page
}});
@@ -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');
diff --git a/src/components/GamePointsLine.jsx b/src/components/GamePointsLine.jsx
new file mode 100644
index 0000000..de96881
--- /dev/null
+++ b/src/components/GamePointsLine.jsx
@@ -0,0 +1,43 @@
+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'};
+
+/**
+ * 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]) => (
+ WAITING[reason] ? t(WAITING[reason])
+ : t('POINTS.GAME.NONE', {kind: kindText(kind), reason: t(`POINTS.REASONS.${reason}`)}))),
+ ];
+ return lines.map((line, i) =>
{line}
);
+}
diff --git a/src/components/GamePointsLine.test.jsx b/src/components/GamePointsLine.test.jsx
new file mode 100644
index 0000000..c2c3b39
--- /dev/null
+++ b/src/components/GamePointsLine.test.jsx
@@ -0,0 +1,92 @@
+// @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) => ;
+ 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 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();
+
+ await waitFor(() => expect(pointsCalls()).toHaveLength(1));
+ expect(document.body.textContent).toBe('');
+ expect(toast).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/usePointsCheck.js b/src/hooks/usePointsCheck.js
new file mode 100644
index 0000000..58a6198
--- /dev/null
+++ b/src/hooks/usePointsCheck.js
@@ -0,0 +1,42 @@
+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('POINTS.GAME.TOAST_BOOKED', {count: notice.points}));
+ } else if (notice.type === 'paid') {
+ toast(t('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]);
+}
diff --git a/src/pages/GameDetail.effects.test.jsx b/src/pages/GameDetail.effects.test.jsx
index 73ae823..9890abf 100644
--- a/src/pages/GameDetail.effects.test.jsx
+++ b/src/pages/GameDetail.effects.test.jsx
@@ -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 = {
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index 74dfbdb..2d2c70f 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -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';
@@ -95,6 +96,8 @@ export default function GameDetail() {
)}
+
+
{/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
setData((d) => ({...d, ...entry}))}/>
diff --git a/src/pages/GameDetail.reload.test.jsx b/src/pages/GameDetail.reload.test.jsx
index d0c8898..84bb696 100644
--- a/src/pages/GameDetail.reload.test.jsx
+++ b/src/pages/GameDetail.reload.test.jsx
@@ -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}) => ,
diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx
index d91e348..fd436fb 100644
--- a/src/pages/GameDetail.test.jsx
+++ b/src/pages/GameDetail.test.jsx
@@ -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 = {
diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx
index b3be395..823ac5a 100644
--- a/src/pages/PlayNextPage.jsx
+++ b/src/pages/PlayNextPage.jsx
@@ -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'));
diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx
index 8013753..cc62668 100644
--- a/src/pages/PlayNextPage.test.jsx
+++ b/src/pages/PlayNextPage.test.jsx
@@ -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();
diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx
index 961e718..bb920e5 100644
--- a/src/pages/PointsPage.jsx
+++ b/src/pages/PointsPage.jsx
@@ -19,7 +19,8 @@ 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 HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH',
+ 'STREAK', 'STARTER', 'UNLOCK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx
index 0ae372e..2c88e75 100644
--- a/src/pages/PointsPage.test.jsx
+++ b/src/pages/PointsPage.test.jsx
@@ -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();
diff --git a/src/pages/QuestionsPage.jsx b/src/pages/QuestionsPage.jsx
index e8f0858..bd9225d 100644
--- a/src/pages/QuestionsPage.jsx
+++ b/src/pages/QuestionsPage.jsx
@@ -5,14 +5,18 @@ 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';
+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);
@@ -41,6 +45,8 @@ export default function QuestionsPage() {
// 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();
diff --git a/src/pages/QuestionsPage.test.jsx b/src/pages/QuestionsPage.test.jsx
index e1c00c8..a6db1af 100644
--- a/src/pages/QuestionsPage.test.jsx
+++ b/src/pages/QuestionsPage.test.jsx
@@ -13,8 +13,12 @@ vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error
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: []})};
+const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
+
const renderPage = (routes) => {
- const mock = fetchRoutes(routes);
+ const mock = fetchRoutes({...WALLET, ...routes});
render();
return mock;
};
@@ -45,6 +49,7 @@ describe('QuestionsPage', () => {
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');
@@ -73,6 +78,7 @@ describe('QuestionsPage', () => {
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');
@@ -82,6 +88,36 @@ describe('QuestionsPage', () => {
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')]}),
diff --git a/src/utils/rewards.js b/src/utils/rewards.js
index f5b32c3..a34a10f 100644
--- a/src/utils/rewards.js
+++ b/src/utils/rewards.js
@@ -120,6 +120,38 @@ 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}`);
+
+const FINISH_KINDS = ['FINISHED', 'FINISHED_MANUAL'];
+
+// 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', 'PLAYSTATION_LINKED'];
+/** 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).
+ */
+export const pointsNotice = (points, status) => {
+ const booked = points.paid.filter((row) => row.bookedNow).reduce((sum, row) => sum + row.points, 0);
+ if (booked > 0) return {type: 'booked', points: booked};
+ 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};
+ const {missing} = points;
+ // 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})});
diff --git a/src/utils/rewards.test.js b/src/utils/rewards.test.js
index a650cd9..72b4ea8 100644
--- a/src/utils/rewards.test.js
+++ b/src/utils/rewards.test.js
@@ -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,39 @@ 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('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'});
+ });
+});