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