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

+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');
+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';
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 = {
+3
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';
@@ -95,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}))}/>
+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 = {
+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();
+2 -1
View File
@@ -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();
+9
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();
+6
View File
@@ -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();
+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',
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(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
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')]}),
+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. */
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})});
+37 -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,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'});
});
});