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