feat(points): show early finish and expected points on questions
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 24s

Core pays 10 early points for an unproven Steam finish. The rest
comes with proof. Show this in the toast and the game line.
Questions cards show the full value from GET /api/rewards/expected.
This commit is contained in:
lobo committed 2026-10-09 16:00:06 +02:00
1 parent 814decc781
commit c126592887
13 files changed
+166 -28

No files matched your search

+5 -1
View File
@@ -56,7 +56,10 @@ To Be Played is split into three repositories:
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.
toast, the reason codes come from Core and `POINTS.REASONS` only maps them to texts. A finish without proof pays 10
early points (toast "+10 points; the rest comes with proof", game line "The rest of the finish points: play once on
Steam after finishing"), and the rest once Steam play proves it. A "Finished?" card on the Questions page shows
"Up to N points" from `GET /api/rewards/expected`; when that call fails the card shows no line.
```mermaid
flowchart LR
@@ -65,6 +68,7 @@ flowchart LR
Check --> Toast["Toast"]
Check -->|balance moved| Wallet["reloadWallet: header"]
Core --> Line["GamePointsLine on the game page"]
Questions["Questions page"] -->|"GET /api/rewards/expected"| CoreExpected["Core /api/rewards/expected"]
```
- **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.
+14 -7
View File
@@ -1180,15 +1180,15 @@
"HOW_TO": "So bekommst du Punkte",
"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. 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.",
"FINISHED": "30, 50 oder 80 Punkte, wenn du ein Spiel durchspielst (nach seiner Spielzeit) und es danach gespielt hast. Ohne Beweis für Spielen bekommst du jetzt 10 Punkte und den Rest, sobald du es nach dem Durchspielen auf Steam spielst. Bei 0 Spielzeit bekommst du 10, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.",
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast. Es braucht ein belegtes Steam-Durchspielen (die frühen 10 Punkte zählen erst mit Beweis); ein manuelles öffnet es nie.",
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst. Höchstens 3 Spiele pro Woche (Montag bis Sonntag) zahlen Punkte; manuelle Spiele und Steam-Spiele ohne Beweis teilen sich dieses Limit. Ein 4. manuelles Durchspielen in dieser Woche zahlt nichts; ein 4. Steam-Durchspielen ohne Beweis zahlt mit Beweis weiter voll.",
"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.",
"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.",
"PLAYSTATION": "PlayStation-Spiele zahlen nichts und PlayStation-Spielzeit ist kein Beweis. Ein manuelles oder Steam-Spiel, das mit einem PlayStation-Titel verknüpft ist, zahlt nach seinen eigenen Regeln.",
"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)."
@@ -1198,23 +1198,27 @@
"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.",
"WAITING_TOPUP": "Der Rest der Durchspiel-Punkte: spiele nach dem Durchspielen einmal auf Steam.",
"NONE": "{{kind}}: keine Punkte - {{reason}}",
"TOAST_BOOKED": "+{{count}} Punkte",
"TOAST_BOOKED_REST": "+{{count}} Punkte; den Rest gibt es mit Beweis",
"TOAST_PAID": "Schon bezahlt am {{date}}",
"TOAST_PAID_REST": "Schon bezahlt am {{date}}; den Rest gibt es mit Beweis",
"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",
"PLAY_NOT_COUNTED": "PlayStation-Minuten sind in dieses Spiel eingerechnet, darum ist Spielen kein Beweis",
"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",
"WEEKLY_LIMIT_NO_PROOF": "Wochenlimit erreicht, also keine frühen Punkte; ein Beweis zahlt weiter das volle Durchspielen",
"NEEDS_PROVEN_FINISH": "braucht zuerst einen Beweis für Spielen auf Steam",
"NOT_ALL_ACHIEVEMENTS": "nicht alle Erfolge freigeschaltet"
},
"SLOTS": {
@@ -1290,6 +1294,8 @@
"STARTED": "Gestartet",
"FINISHED": "Durchgespielt",
"FINISHED_MANUAL": "Durchgespielt (manuelles Spiel)",
"FINISHED_EARLY": "Durchgespielt (frühe Punkte)",
"FINISHED_TOPUP": "Durchspielen bewiesen, Rest",
"COMPLETED": "100-%-Bonus",
"MONTH": "Kleinerer Stapel im",
"STREAK": "Serien-Woche",
@@ -1350,7 +1356,8 @@
"FINISHED": {
"ASK": "Durchgespielt?",
"REASON": "Du hast {{playtime}} gespielt.",
"REASON_TTB": "Du hast {{playtime}} gespielt. Die meisten brauchen {{ttb}}."
"REASON_TTB": "Du hast {{playtime}} gespielt. Die meisten brauchen {{ttb}}.",
"POINTS": "Bis zu {{count}} Punkte"
},
"DROPPED": {
"ASK": "Abgebrochen?",
+14 -7
View File
@@ -1180,15 +1180,15 @@
"HOW_TO": "How to earn points",
"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. 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.",
"FINISHED": "30, 50 or 80 points when you finish a game (by its time to beat) and played it since. Without proof of play you get 10 points now, and the rest once you play it on Steam after finishing. With 0 play time on record you get 10, and the rest only with Steam play. Once per game.",
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game. It needs a proven Steam finish (the early 10 points do not count until proof arrives); a manual finish never opens it.",
"FINISHED_MANUAL": "15 points for a manual game you finish. At most 3 finishes per week (Monday to Sunday) pay points; manual finishes and Steam finishes without proof share this limit. A 4th manual finish in that week pays nothing; a 4th Steam finish without proof still pays in full once proven.",
"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.",
"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.",
"PLAYSTATION": "PlayStation games pay nothing and PlayStation play is no proof. A manual or Steam game linked to a PlayStation title pays by its own rules.",
"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)."
@@ -1198,23 +1198,27 @@
"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.",
"WAITING_TOPUP": "The rest of the finish points: play once on Steam after finishing.",
"NONE": "{{kind}}: no points - {{reason}}",
"TOAST_BOOKED": "+{{count}} points",
"TOAST_BOOKED_REST": "+{{count}} points; the rest comes with proof",
"TOAST_PAID": "Already paid on {{date}}",
"TOAST_PAID_REST": "Already paid on {{date}}; the rest comes with proof",
"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",
"PLAY_NOT_COUNTED": "PlayStation minutes are mixed into this game, so play on it is no proof",
"NO_PROOF": "no proof of fresh play, play once after finishing",
"WEEKLY_LIMIT": "weekly limit reached",
"NEEDS_PROVEN_FINISH": "needs a proven finish first",
"WEEKLY_LIMIT_NO_PROOF": "weekly limit reached, so no early points; proof still pays the full finish",
"NEEDS_PROVEN_FINISH": "needs proof of play on Steam first",
"NOT_ALL_ACHIEVEMENTS": "not all achievements unlocked"
},
"SLOTS": {
@@ -1290,6 +1294,8 @@
"STARTED": "Started",
"FINISHED": "Finished",
"FINISHED_MANUAL": "Finished (manual game)",
"FINISHED_EARLY": "Finished (early points)",
"FINISHED_TOPUP": "Finish proven, rest",
"COMPLETED": "100 % bonus",
"MONTH": "Smaller pile in",
"STREAK": "Streak week",
@@ -1350,7 +1356,8 @@
"FINISHED": {
"ASK": "Finished?",
"REASON": "You played {{playtime}}.",
"REASON_TTB": "You played {{playtime}}. Most players need {{ttb}}."
"REASON_TTB": "You played {{playtime}}. Most players need {{ttb}}.",
"POINTS": "Up to {{count}} points"
},
"DROPPED": {
"ASK": "Dropped?",
+5 -1
View File
@@ -5,6 +5,10 @@ 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'};
// The rest of an early finish waits for Steam play, with or without playtime on record.
const WAITING_TOPUP = ['NO_PROOF', 'NO_PLAYTIME'];
const waitingKey = (kind, reason) => (kind === 'FINISHED_TOPUP' ? WAITING_TOPUP.includes(reason) && 'POINTS.GAME.WAITING_TOPUP' : WAITING[reason]);
/**
* What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it
@@ -36,7 +40,7 @@ export default function GamePointsLine({game}) {
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])
waitingKey(kind, reason) ? t(waitingKey(kind, 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>);
+26
View File
@@ -50,6 +50,32 @@ describe('GamePointsLine', () => {
expect(screen.getAllByText(/^POINTS.GAME.NONE/)).toHaveLength(1);
});
it('shows the early row and the waiting line for the rest of the finish', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PROOF'}});
expect(await screen.findByText('POINTS.GAME.WAITING_TOPUP')).toBeTruthy();
expect(screen.getAllByText(/^POINTS.GAME.PAID/)).toHaveLength(1);
expect(screen.queryByText(/^POINTS.GAME.NONE/)).toBeNull();
});
it('shows the waiting line for the rest when there is no play time on record', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PLAYTIME'}});
expect(await screen.findByText('POINTS.GAME.WAITING_TOPUP')).toBeTruthy();
});
it('shows a reason line for a hidden game that waits for the rest', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'HIDDEN'}});
expect(await screen.findByText(/^POINTS.GAME.NONE/)).toBeTruthy();
});
it('shows the top-up row with its date', async () => {
renderLine('FINISHED', {balance: 30, paid: [row('FINISHED_EARLY', {points: 10}), row('FINISHED_TOPUP', {points: 20})], missing: {}});
expect(await screen.findAllByText(/^POINTS.GAME.PAID/)).toHaveLength(2);
});
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'}});
+5 -1
View File
@@ -14,9 +14,10 @@ import {formatPlaytime} from '../utils/games.js';
*
* @param item a result of `GET /api/library/questions`
* @param busy true while an answer is on its way
* @param expectedPoints the points a finish would pay (`GET /api/rewards/expected`), absent or 0 shows nothing
* @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars
*/
export default function QuestionCard({item, busy, onAnswer}) {
export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
const {t, i18n} = useTranslation();
const {appid, name, imageUrl, question, playtimeMinutes, normallyMinutes, lastActivity} = item;
const reasonKey = question === 'FINISHED' && normallyMinutes > 0 ? 'FINISHED.REASON_TTB' : `${question}.REASON`;
@@ -38,6 +39,9 @@ export default function QuestionCard({item, busy, onAnswer}) {
</Link>
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
<p className="text-sm text-muted-foreground">{reason}</p>
{question === 'FINISHED' && expectedPoints > 0 && (
<p className="text-sm font-medium text-primary">{t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints})}</p>
)}
{question === 'RATE' ? (
<div className="flex flex-wrap items-center gap-2">
<RatingStars value={0} labelledBy={askId} disabled={busy}
+3 -2
View File
@@ -28,9 +28,10 @@ export default function usePointsCheck() {
if (tell) {
const notice = pointsNotice(data, status);
if (notice.type === 'booked') {
toast.success(t('POINTS.GAME.TOAST_BOOKED', {count: notice.points}));
toast.success(t(notice.rest ? 'POINTS.GAME.TOAST_BOOKED_REST' : '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'})}));
toast(t(notice.rest ? 'POINTS.GAME.TOAST_PAID_REST' : '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 {
+1 -1
View File
@@ -18,7 +18,7 @@ import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.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', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH',
'STREAK', 'STARTER', 'UNLOCK'];
+11
View File
@@ -214,6 +214,17 @@ describe('PointsPage', () => {
expect(screen.getByText('+50')).toBeTruthy();
});
it('links early and top-up rows to the game', async () => {
await renderPage({'GET /api/rewards/wallet': json(200, wallet({history: [
{kind: 'FINISHED_EARLY', ref: '10', gameName: 'Early Game', points: 10, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'FINISHED_TOPUP', ref: '10', gameName: 'Early Game', points: 20, eventAt: '2026-09-30T10:00:00Z'},
]}))});
const links = screen.getAllByRole('link', {name: 'Early Game'});
expect(links).toHaveLength(2);
expect(links[0].getAttribute('href')).toBe('/library/10');
});
it('buys, then loads the wallet again', async () => {
let balance = 160;
const mock = await renderPageWith(() => json(200, wallet({balance})), {
+11 -1
View File
@@ -7,7 +7,10 @@ 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';
import {loadExpected} from '../utils/rewards.js';
// Core answers at most this many appids at once.
const MAX_EXPECTED = 200;
const STATUS_OF_QUESTION = {START: 'PLAYING', FINISHED: 'FINISHED', DROPPED: 'DROPPED'};
/**
@@ -20,6 +23,7 @@ export default function QuestionsPage() {
const [items, setItems] = useState([]);
const [status, setStatus] = useState('loading');
const [busyAppid, setBusyAppid] = useState(null);
const [expected, setExpected] = useState({});
const load = useCallback(async () => {
const {ok, status: code, data} = await fetchQuestions();
@@ -30,6 +34,12 @@ export default function QuestionsPage() {
}
setItems(data.results);
setStatus('ready');
// Points are a hint: a failed call shows no line and no error.
const finished = data.results.filter((item) => item.question === 'FINISHED').map((item) => item.appid);
for (let from = 0; from < finished.length; from += MAX_EXPECTED) {
const points = await loadExpected(finished.slice(from, from + MAX_EXPECTED));
if (points.ok) setExpected((current) => ({...current, ...points.data}));
}
}, []);
useEffect(() => {
@@ -71,7 +81,7 @@ export default function QuestionsPage() {
<p className="text-sm text-muted-foreground">{t('QUESTIONS.EMPTY')}</p>
)}
{status === 'ready' && items.map((item) => (
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null}
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null} expectedPoints={expected[item.appid]}
onAnswer={(choice) => answer(item, choice)}/>
))}
</PageContainer>
+44 -1
View File
@@ -14,7 +14,11 @@ const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUr
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 WALLET = {
'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/rewards/expected': json(200, {}),
};
const expectedCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/expected'));
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
const renderPage = (routes) => {
@@ -161,6 +165,45 @@ describe('QuestionsPage', () => {
expect(screen.getByText('Game 1')).toBeTruthy();
});
it('showsTheExpectedPointsOnAFinishedCardOnly', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED'), card(2, 'FINISHED'), card(3, 'START')]}),
'GET /api/rewards/expected': json(200, {1: 50, 2: 0}),
});
expect(await screen.findByText('QUESTIONS.FINISHED.POINTS:50')).toBeTruthy();
expect(screen.getAllByText(/^QUESTIONS.FINISHED.POINTS/)).toHaveLength(1);
expect(expectedCalls()[0][0]).toContain('appids=1,2');
});
it('asksForTheExpectedPointsOfEveryFinishedCardInChunks', async () => {
const cards = Array.from({length: 201}, (_, i) => card(i + 1, 'FINISHED'));
renderPage({'GET /api/library/questions': json(200, {results: cards})});
await waitFor(() => expect(expectedCalls().length).toBeGreaterThan(0));
const asked = expectedCalls().flatMap(([url]) => new URL(url, 'http://x').searchParams.get('appids').split(','));
expect(asked).toContain('201');
});
it('asksForNoExpectedPointsWithoutAFinishedCard', async () => {
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START')]})});
await screen.findByText('Game 1');
expect(expectedCalls()).toHaveLength(0);
});
it('showsNoPointsLineAndNoErrorWhenTheExpectedCallFails', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'GET /api/rewards/expected': json(500, {}),
});
expect(await screen.findByText('Game 1')).toBeTruthy();
await waitFor(() => expect(expectedCalls()).toHaveLength(1));
expect(screen.queryByText(/^QUESTIONS.FINISHED.POINTS/)).toBeNull();
expect(screen.queryByText('INFO.ERROR.NETWORK_FAILURE')).toBeNull();
});
it('showsTheEmptyState', async () => {
renderPage({'GET /api/library/questions': json(200, {results: []})});
+11 -6
View File
@@ -123,28 +123,33 @@ 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 full finish value per game, read-only: `{ok, status, data: {appid: points}}`. */
export const loadExpected = (appids) => request(`${REWARDS_URL}/expected?appids=${appids.join(',')}`);
const FINISH_KINDS = ['FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY'];
// 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'];
const BLOCKED = ['HIDDEN', 'PLAYSTATION'];
/** 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).
* of Core) or `{type: 'status'}` (this status earns no points). `rest: true` on booked and paid: a finish is paid
* early and the rest waits for proof.
*/
export const pointsNotice = (points, status) => {
const {missing} = points;
const rest = (status === 'FINISHED' || status === 'COMPLETED') && Boolean(missing.FINISHED_TOPUP);
const booked = points.paid.filter((row) => row.bookedNow).reduce((sum, row) => sum + row.points, 0);
if (booked > 0) return {type: 'booked', points: booked};
if (booked > 0) return {type: 'booked', points: booked, ...(rest && {rest})};
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;
if (row) return {type: 'paid', date: row.paidAt, ...(rest && {rest})};
// 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))]
+16
View File
@@ -64,6 +64,22 @@ describe('pointsNotice', () => {
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'paid', date: '2026-10-01T10:00:00Z'});
});
it('counts an early finish as a paid finish and says the rest waits for proof', () => {
const points = {paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PROOF'}};
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'paid', date: '2026-10-01T10:00:00Z', rest: true});
expect(pointsNotice({...points, missing: {...points.missing, COMPLETED: 'NEEDS_PROVEN_FINISH'}}, 'COMPLETED'))
.toEqual({type: 'none', reason: 'NEEDS_PROVEN_FINISH'});
});
it('marks booked early points with rest', () => {
const points = {paid: [row('FINISHED_EARLY', {points: 10, bookedNow: true})], missing: {FINISHED_TOPUP: 'NO_PROOF'}};
expect(pointsNotice(points, 'FINISHED')).toEqual({type: 'booked', points: 10, rest: true});
expect(pointsNotice({paid: [row('FINISHED_TOPUP', {points: 20, bookedNow: true})], missing: {}}, 'FINISHED'))
.toEqual({type: 'booked', points: 20});
});
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'});