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

+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')]}),