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.
This commit is contained in:
lobo committed 2026-10-09 15:25:04 +02:00
1 parent 9adc606c57
commit 814decc781
20 files changed
+429 -12

No files matched your search

+14 -1
View File
@@ -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 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 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 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 - **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. 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 - **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
+33 -3
View File
@@ -1181,11 +1181,41 @@
"RULES": { "RULES": {
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).", "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.", "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.", "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.", "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.", "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.", "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": { "SLOTS": {
"AVATAR_BORDER": "Avatarrahmen", "AVATAR_BORDER": "Avatarrahmen",
+33 -3
View File
@@ -1181,11 +1181,41 @@
"RULES": { "RULES": {
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).", "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.", "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.", "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.", "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.", "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.", "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": { "SLOTS": {
"AVATAR_BORDER": "Avatar border", "AVATAR_BORDER": "Avatar border",
+3
View File
@@ -12,6 +12,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import GameSearch from './GameSearch.jsx'; import GameSearch from './GameSearch.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import useStartImport from '../hooks/useStartImport.js'; import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js'; import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {addGame, hoursToMinutes} from '../utils/manualGames.js'; import {addGame, hoursToMinutes} from '../utils/manualGames.js';
@@ -91,6 +92,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
const [status, setStatus] = useState('BACKLOG'); const [status, setStatus] = useState('BACKLOG');
const [hours, setHours] = useState(''); const [hours, setHours] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const check = usePointsCheck();
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -110,6 +112,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
} }
const {appid} = await response.json(); const {appid} = await response.json();
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name})); toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
check(appid, {status, tell: true});
navigate(`/library/${appid}`); navigate(`/library/${appid}`);
} catch (err) { } catch (err) {
console.error('add game error:', 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'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.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 zelda = { const zelda = {
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg', 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, 'GET /api/library/game-search': found,
'POST /api/library/games': post, 'POST /api/library/games': post,
'POST /api/library/imports': 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'], renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}}); 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 () => { it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
renderPanel(undefined, null); renderPanel(undefined, null);
search('zelda'); search('zelda');
+43
View File
@@ -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) => <p key={i} className="text-sm text-muted-foreground">{line}</p>);
}
+92
View File
@@ -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) => <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 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();
});
});
+42
View File
@@ -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]);
}
+1
View File
@@ -7,6 +7,7 @@ import {renderWithProviders} from '../test/renderWithProviders.jsx';
import WalletLoader from '../test/WalletLoader.jsx'; import WalletLoader from '../test/WalletLoader.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); 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()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = { const game = {
+3
View File
@@ -10,6 +10,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
import Confetti from '../components/Confetti.jsx'; import Confetti from '../components/Confetti.jsx';
import GameAchievements from '../components/GameAchievements.jsx'; import GameAchievements from '../components/GameAchievements.jsx';
import GameHeroCard from '../components/GameHeroCard.jsx'; import GameHeroCard from '../components/GameHeroCard.jsx';
import GamePointsLine from '../components/GamePointsLine.jsx';
import ManualGameCard from '../components/ManualGameCard.jsx'; import ManualGameCard from '../components/ManualGameCard.jsx';
import CompletedHint from '../components/CompletedHint.jsx'; import CompletedHint from '../components/CompletedHint.jsx';
import FinishedHint from '../components/FinishedHint.jsx'; import FinishedHint from '../components/FinishedHint.jsx';
@@ -95,6 +96,8 @@ export default function GameDetail() {
</p> </p>
)} )}
<GamePointsLine key={`points-${data.appid}`} game={data}/>
{/* The finished question also answers "are you playing it?". */} {/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && ( {!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/> <PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
+1
View File
@@ -6,6 +6,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); 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. // Stands in for a saved correction, which loads the game again.
vi.mock('../components/GameCorrectionDialog.jsx', () => ({ vi.mock('../components/GameCorrectionDialog.jsx', () => ({
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>, 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'; import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); 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()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = { const game = {
+3
View File
@@ -11,6 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx'; import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {saveGameStatus} from '../utils/backlog.js'; import {saveGameStatus} from '../utils/backlog.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
@@ -281,6 +282,7 @@ function PickCard({game}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [started, setStarted] = useState(false); const [started, setStarted] = useState(false);
const check = usePointsCheck();
const reason = reasonText(game.reason, t, i18n.language); const reason = reasonText(game.reason, t, i18n.language);
const startPlaying = async () => { const startPlaying = async () => {
@@ -294,6 +296,7 @@ function PickCard({game}) {
} }
setStarted(true); setStarted(true);
toast.success(t('BACKLOG.SAVED')); toast.success(t('BACKLOG.SAVED'));
check(game.appid, {status: 'PLAYING', tell: true});
} catch (err) { } catch (err) {
console.error('Start playing error:', err); console.error('Start playing error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE')); 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'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.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 = {}) => ({ const game = (appid, extra = {}) => ({
appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null, appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null,
@@ -33,6 +33,9 @@ function mockBackend(answer, routes = {}) {
fetchRoutes({ fetchRoutes({
'GET /api/library/picker': (url) => json(200, {categories: answer(new URL(url, 'http://x').searchParams)}), '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)), '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, ...routes,
}); });
} }
@@ -323,6 +326,17 @@ describe('PlayNextPage', () => {
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'}); 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 () => { it('lets the user try again when saving fails', async () => {
mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})}); mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})});
renderPage(); renderPage();
+2 -1
View File
@@ -19,7 +19,8 @@ import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js'; import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED']; 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() { export default function PointsPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
+9
View File
@@ -61,6 +61,15 @@ afterEach(() => {
}); });
describe('PointsPage', () => { 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 () => { it('shows the effect slots with a preview of each look', async () => {
await renderPage(); await renderPage();
+6
View File
@@ -5,14 +5,18 @@ import {toast} from 'sonner';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import QuestionCard from '../components/QuestionCard.jsx'; import QuestionCard from '../components/QuestionCard.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {answerQuestion, fetchQuestions} from '../utils/questions.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 * 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. * question is open; a yes changes the game like the game page does.
*/ */
export default function QuestionsPage() { export default function QuestionsPage() {
const {t} = useTranslation(); const {t} = useTranslation();
const check = usePointsCheck();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [status, setStatus] = useState('loading'); const [status, setStatus] = useState('loading');
const [busyAppid, setBusyAppid] = useState(null); 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. // 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]))); setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry])));
toast.success(t('BACKLOG.SAVED')); 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) { } else if (code === 404 || code === 409) {
toast(t('QUESTIONS.GONE')); toast(t('QUESTIONS.GONE'));
await load(); await load();
+37 -1
View File
@@ -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', const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING',
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null}); 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 renderPage = (routes) => {
const mock = fetchRoutes(routes); const mock = fetchRoutes({...WALLET, ...routes});
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>); render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
return mock; return mock;
}; };
@@ -45,6 +49,7 @@ describe('QuestionsPage', () => {
const mock = renderPage({ const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}), 'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
'POST /api/library/questions/1': json(200, {next: null}), 'POST /api/library/questions/1': json(200, {next: null}),
'GET /api/rewards/games/1': json(200, booked(1)),
}); });
await screen.findByText('Game 1'); await screen.findByText('Game 1');
@@ -73,6 +78,7 @@ describe('QuestionsPage', () => {
renderPage({ renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}), 'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}), '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'); await screen.findByText('QUESTIONS.FINISHED.ASK');
@@ -82,6 +88,36 @@ describe('QuestionsPage', () => {
expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull(); 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 () => { it('sendsTheRatingOfTheStars', async () => {
const mock = renderPage({ const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}), 'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
+32
View File
@@ -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. */ /** 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`); 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). */ /** POST /api/rewards/purchases; the caller handles the status (409: ALREADY_OWNED or NOT_ENOUGH_POINTS). */
export const buyItem = (code) => export const buyItem = (code) =>
apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})}); apiFetch(`${REWARDS_URL}/purchases`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({item: code})});
+37 -1
View File
@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {EFFECTS} from '../components/EffectBackground.jsx'; 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', () => { describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'}; 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); 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'});
});
});