Compare commits

..
3 Commits
Author SHA1 Message Date
lobo bbc707de87 feat(sync): add Set to 100% button for all-unlocked games
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 40s
The sync details show a Set to 100% button for ALL_UNLOCKED games.
The button uses the existing status PUT, checks points, and reloads
the Dashboard. Refs #21
2026-10-11 16:22:49 +02:00
lobo c966877b23 feat(points): show early-since date in point texts
Refs #44. Core sends earlySince in the games and wallet responses.
COMPLETION_TOO_OLD and the COMPLETED rule show it as {{date}}.
2026-10-11 13:51:34 +02:00
lobo e044959629 fix(rewards): explain manual edition finish opens COMPLETED
COMPLETED rule and missing-reason texts (de, en) now name the manual
edition finish as a way to open it. Refs #35
2026-10-11 13:07:26 +02:00
16 changed files with 274 additions and 17 deletions

No files matched your search

+1
View File
@@ -117,6 +117,7 @@ flowchart LR
(`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag;
a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the
next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game.
The sync details list games whose last achievement this sync unlocked, with a button to set them to 100 %.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when
+5 -2
View File
@@ -1182,7 +1182,7 @@
"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 (je nach Spieldauer), wenn du ein Steam-Spiel durchspielst und es einen Nachweis gibt, dass du es gespielt hast. Ohne Nachweis bekommst du sofort 10 Punkte und den Rest, sobald du das Spiel nach dem Durchspielen auf Steam spielst. Ist keine Spielzeit erfasst, gibt es auch erst 10 Punkte, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.",
"COMPLETED": "+40 Punkte, wenn ein Spiel auf 100 % steht und du alle Erfolge freigeschaltet hast. Dafür braucht es ein nachgewiesenes Durchspielen auf Steam (die frühen 10 Punkte reichen erst, wenn der Nachweis da ist); ein manuelles Spiel bekommt diesen Bonus nie.",
"COMPLETED": "+40 Punkte, wenn ein Spiel auf 100 % steht und du alle Erfolge freigeschaltet hast. Dafür braucht es ein nachgewiesenes Durchspielen auf Steam (die frühen 10 Punkte reichen erst, wenn der Nachweis da ist) oder Punkte fürs Durchspielen einer manuellen Edition des Spiels, wenn du die 100 % am {{date}} oder später erreicht hast; ein manuelles Spiel selbst bekommt diesen Bonus nie.",
"FINISHED_MANUAL": "15 Punkte, wenn du ein manuelles Spiel durchspielst. Höchstens 3 durchgespielte Spiele pro Woche (Montag bis Sonntag) bringen Punkte; manuelle Spiele und Steam-Spiele ohne Nachweis teilen sich dieses Limit. Ein 4. manuelles Spiel in dieser Woche bringt keine Punkte; ein 4. Steam-Spiel ohne Nachweis bekommt die vollen Punkte, sobald der Nachweis da ist.",
"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, in der du ein Spiel durchspielst, wenn du auch in der Woche davor eins durchgespielt hast.",
@@ -1219,7 +1219,8 @@
"NO_PROOF": "kein Nachweis, dass du es kürzlich gespielt hast; spiel es nach dem Durchspielen noch einmal",
"WEEKLY_LIMIT": "Wochenlimit erreicht",
"WEEKLY_LIMIT_NO_PROOF": "Wochenlimit erreicht, darum keine frühen Punkte; mit Nachweis gibt es weiter die vollen Punkte fürs Durchspielen",
"NEEDS_PROVEN_FINISH": "braucht zuerst ein nachgewiesenes Durchspielen auf Steam",
"NEEDS_PROVEN_FINISH": "braucht zuerst ein nachgewiesenes Durchspielen auf Steam oder Punkte fürs Durchspielen einer manuellen Edition",
"COMPLETION_TOO_OLD": "100 % vor dem {{date}} erreicht – zu früh für diese Punkte",
"NOT_ALL_ACHIEVEMENTS": "nicht alle Erfolge freigeschaltet"
},
"SLOTS": {
@@ -1334,7 +1335,9 @@
"CLOSE": "Schließen",
"EMPTY": "Keine Änderungen zum Anzeigen.",
"ERROR": "Die Änderungen konnten nicht geladen werden.",
"COMPLETED_DONE": "Auf 100 % gesetzt",
"HEADING": {
"ALL_UNLOCKED": "Alle Erfolge freigeschaltet",
"ADDED": "Neue Spiele",
"REMOVED": "Entfernte Spiele",
"PLAYTIME": "Spielzeit",
+5 -2
View File
@@ -1182,7 +1182,7 @@
"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 (by time to beat) when you finish a Steam game and there is proof that you played it. Without proof you get 10 points now and the rest once you play the game on Steam after finishing it. With no playtime on record you also get 10 points first, and the rest only with Steam playtime. Once per game.",
"COMPLETED": "+40 points when a game is at 100% and you unlocked all achievements. It needs a proven Steam finish (the early 10 points count only once proof arrives); a manual game never gets this bonus.",
"COMPLETED": "+40 points when a game is at 100% and you unlocked all achievements. It needs a proven Steam finish (the early 10 points count only once proof arrives) or finish points for a manual edition of the game, when you reached 100% on or after {{date}}; a manual game itself never gets this bonus.",
"FINISHED_MANUAL": "15 points when you finish a manual game. At most 3 finishes per week (Monday to Sunday) earn points; manual games and Steam games without proof share this limit. A 4th manual finish in that week earns nothing; a 4th Steam finish without proof still earns the full points 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 in which you finish a game, if you also finished one the week before.",
@@ -1219,7 +1219,8 @@
"NO_PROOF": "no proof of recent play; play it once more after finishing",
"WEEKLY_LIMIT": "weekly limit reached",
"WEEKLY_LIMIT_NO_PROOF": "weekly limit reached, so no early points; with proof you still get the full finish points",
"NEEDS_PROVEN_FINISH": "needs a proven Steam finish first",
"NEEDS_PROVEN_FINISH": "needs a proven Steam finish or finish points for a manual edition first",
"COMPLETION_TOO_OLD": "100% reached before {{date}}, too early for these points",
"NOT_ALL_ACHIEVEMENTS": "not all achievements unlocked"
},
"SLOTS": {
@@ -1334,7 +1335,9 @@
"CLOSE": "Close",
"EMPTY": "No changes to show.",
"ERROR": "The changes could not be loaded.",
"COMPLETED_DONE": "Set to 100%",
"HEADING": {
"ALL_UNLOCKED": "All achievements unlocked",
"ADDED": "New games",
"REMOVED": "Removed games",
"PLAYTIME": "Playtime",
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import GamePointsLine from './GamePointsLine.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
// t() shows its options, so the test sees what Core's earlySince becomes.
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options ? `${key}|${Object.values(options).join('|')}` : key),
i18n: {language: 'en-GB'},
}),
}));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GamePointsLine date', () => {
it('puts the early-since day of Core into the reason text', async () => {
fetchRoutes({
'GET /api/rewards/games/220': json(200, {
balance: 15, paid: [], earlySince: '2026-09-10', missing: {COMPLETED: 'COMPLETION_TOO_OLD'},
}),
'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}),
});
render(<PreferencesProvider><GamePointsLine game={{appid: 220, status: 'COMPLETED'}}/></PreferencesProvider>);
expect(await screen.findByText(/POINTS\.REASONS\.COMPLETION_TOO_OLD\|10 Sept(ember)? 2026/)).toBeTruthy();
});
});
+3 -2
View File
@@ -1,7 +1,7 @@
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {formatDate} from '../utils/format.js';
import {formatDate, formatDay} 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'};
@@ -33,6 +33,7 @@ export default function GamePointsLine({game}) {
}, [check, game.appid, game.status]);
if (!points) return null;
const earlySinceText = points.earlySince && formatDay(points.earlySince, i18n.language, {dateStyle: 'medium'});
const kindText = (kind) => t(`POINTS.KINDS.${kind}`);
const lines = [
...points.paid.map((row) => t('POINTS.GAME.PAID', {
@@ -41,7 +42,7 @@ export default function GamePointsLine({game}) {
}) + (row.appid === game.appid ? '' : ` ${t('POINTS.GAME.OTHER_EDITION')}`)),
...Object.entries(points.missing).filter(([, reason]) => !STATUS_NOT_REACHED.includes(reason)).map(([kind, reason]) => (
waitingKey(kind, reason) ? t(waitingKey(kind, reason))
: t('POINTS.GAME.NONE', {kind: kindText(kind), reason: t(`POINTS.REASONS.${reason}`)}))),
: t('POINTS.GAME.NONE', {kind: kindText(kind), reason: t(`POINTS.REASONS.${reason}`, {date: earlySinceText})}))),
];
return lines.map((line, i) => <p key={i} className="text-sm text-muted-foreground">{line}</p>);
}
+3 -2
View File
@@ -16,8 +16,9 @@ const keepShown = (prev, body, markedAt) =>
* device. A manual sync without changes says so once.
*
* @param lastSyncedAt the end of the last sync: a new value loads the result again
* @param onChanged called after a game was set to COMPLETED from the details
*/
export default function SyncChangesBanner({lastSyncedAt}) {
export default function SyncChangesBanner({lastSyncedAt, onChanged}) {
const {t} = useTranslation();
const [result, setResult] = useState(null);
const [dismissed, setDismissed] = useState(null);
@@ -71,7 +72,7 @@ export default function SyncChangesBanner({lastSyncedAt}) {
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
<X/>
</Button>
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)} onChanged={onChanged}/>}
</Card>
);
}
+10 -2
View File
@@ -3,6 +3,7 @@ import {useTranslation} from 'react-i18next';
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import {Skeleton} from '@/components/ui/skeleton';
import SyncCompletedButton from './SyncCompletedButton.jsx';
import GameRow from './stats/GameRow.jsx';
import {formatPlaytime} from '../utils/games.js';
import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
@@ -10,8 +11,10 @@ import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
/**
* The changes of the last sync, grouped by kind. Loads the result again when it opens: the achievements of a sync
* arrive after its games.
*
* @param onChanged called after a game was set to COMPLETED from here
*/
export default function SyncChangesDialog({onClose}) {
export default function SyncChangesDialog({onClose, onChanged}) {
const {t} = useTranslation();
const [changes, setChanges] = useState(null);
const [failed, setFailed] = useState(false);
@@ -52,7 +55,12 @@ export default function SyncChangesDialog({onClose}) {
<section key={kind} className="space-y-2">
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
<ul className="space-y-2">
{rows.map((change, index) => (
{rows.map((change, index) => kind === 'ALL_UNLOCKED' ? (
<li key={change.appid} className="flex items-center gap-3">
<div className="min-w-0 flex-1"><GameRow game={change} linked/></div>
<SyncCompletedButton appid={change.appid} onChanged={onChanged}/>
</li>
) : (
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
<GameRow game={change} value={value(change)} linked/>
</li>
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import SyncChangesDialog from './SyncChangesDialog.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
const open = (changes) => {
fetchRoutes({'GET /api/library/sync-changes': () => json(200, {syncedAt: 'x', seen: false, changes})});
render(<MemoryRouter><SyncChangesDialog onClose={() => {}}/></MemoryRouter>);
};
describe('SyncChangesDialog', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('showsTheGameAndTheButtonForAllUnlocked', async () => {
open([{kind: 'ALL_UNLOCKED', appid: 220, name: 'Half-Life 2'}]);
expect(await screen.findByText('SYNC_CHANGES.HEADING.ALL_UNLOCKED')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})).toBeTruthy();
});
it('showsNoButtonForOtherKinds', async () => {
open([{kind: 'PLAYTIME', appid: 220, name: 'Half-Life 2', amount: 30}]);
await screen.findByText('Half-Life 2');
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {saveGameStatus} from '../utils/backlog.js';
/**
* Sets a game to COMPLETED from the sync details, the same call as {@link CompletedHint}.
*
* @param onChanged called after the status was saved
*/
export default function SyncCompletedButton({appid, onChanged}) {
const {t} = useTranslation();
const check = usePointsCheck();
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const setCompleted = async () => {
setBusy(true);
try {
const response = await saveGameStatus(appid, 'COMPLETED');
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
setDone(true);
toast.success(t('BACKLOG.SAVED'));
check(appid, {status: 'COMPLETED', tell: true});
onChanged?.();
} catch (err) {
console.error('set completed error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
if (done) return <span className="text-sm text-muted-foreground">{t('SYNC_CHANGES.COMPLETED_DONE')}</span>;
return (
<Button size="sm" className="min-h-11 shrink-0" onClick={setCompleted} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_COMPLETED')}
</Button>
);
}
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import SyncCompletedButton from './SyncCompletedButton.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const pointsCheck = vi.fn(() => Promise.resolve(null));
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => pointsCheck}));
const click = () => fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}));
describe('SyncCompletedButton', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('setsTheStatusToCompletedAndShowsTheDoneText', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
render(<SyncCompletedButton appid={220}/>);
click();
expect(await screen.findByText('SYNC_CHANGES.COMPLETED_DONE')).toBeTruthy();
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({status: 'COMPLETED'});
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
expect(screen.queryByRole('button')).toBeNull();
});
it('keepsTheEnabledButtonWhenSavingFails', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(500, {})});
render(<SyncCompletedButton appid={220}/>);
click();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}).disabled).toBe(false);
});
it('sendsOnlyOneRequestOnADoubleClick', async () => {
let release;
fetchRoutes({'PUT /api/library/games/220/status': () => new Promise((resolve) => {
release = () => resolve(json(200, {status: 'COMPLETED'}));
})});
render(<SyncCompletedButton appid={220}/>);
click();
click();
expect(screen.getByRole('button').disabled).toBe(true);
release();
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('settlesAndTellsThePointsAfterSaving', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
render(<SyncCompletedButton appid={220}/>);
click();
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'COMPLETED', tell: true});
});
it('callsOnChangedAfterSaving', async () => {
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
const onChanged = vi.fn();
render(<SyncCompletedButton appid={220} onChanged={onChanged}/>);
click();
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
expect(onChanged).toHaveBeenCalledTimes(1);
});
});
+3 -2
View File
@@ -2,7 +2,7 @@ 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 {formatDate, formatDay} from '../utils/format.js';
import {loadGamePoints, pointsNotice} from '../utils/rewards.js';
/**
@@ -33,7 +33,8 @@ export default function usePointsCheck() {
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}`)}));
toast(t('POINTS.GAME.TOAST_NONE', {reason: t(`POINTS.REASONS.${notice.reason}`,
{date: data.earlySince && formatDay(data.earlySince, i18n.language, {dateStyle: 'medium'})})}));
} else {
toast(t('POINTS.GAME.TOAST_STATUS'));
}
+8
View File
@@ -72,6 +72,14 @@ describe('translation keys', () => {
});
describe('points texts', () => {
it('pointsTexts_NameTheEarlySinceDateInBothLanguages', () => {
for (const file of ['de.json', 'en.json']) {
const points = readTexts(file).POINTS;
expect(points.REASONS.COMPLETION_TOO_OLD).toContain('{{date}}');
expect(points.RULES.COMPLETED).toContain('{{date}}');
}
});
it('pointsTexts_SayEarnNotPay', () => {
const texts = (file) => flatTexts(readTexts(file).POINTS).map(([, text]) => text);
for (const text of texts('de.json')) expect(text).not.toMatch(/\bzahl|Beweis|bezahlt/);
+5 -1
View File
@@ -77,6 +77,10 @@ const Dashboard = () => {
stats.reload();
},
});
const reloadAfterCompleted = () => {
reloadLibrary();
stats.reload();
};
const {watch} = sync;
const forceSync = useForceSync(watch);
useEffect(() => {
@@ -140,7 +144,7 @@ const Dashboard = () => {
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped}
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt} onChanged={reloadAfterCompleted}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
+4 -2
View File
@@ -15,7 +15,7 @@ import PageContainer from '../components/PageContainer.jsx';
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.js';
import {formatDate, formatDay} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED'];
@@ -72,6 +72,8 @@ export default function PointsPage() {
return null;
};
const earlySince = wallet.earlySince && formatDay(wallet.earlySince, i18n.language, {dateStyle: 'medium'});
return (
<StatDrillProvider source="points">
<PageContainer className="p-4 md:p-8 space-y-6">
@@ -86,7 +88,7 @@ export default function PointsPage() {
<Card className="gap-2 p-5">
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`, {date: earlySince})}</li>)}
</ul>
</Card>
+3 -2
View File
@@ -3,12 +3,13 @@ import {apiFetch} from './api.js';
const CORE = () => import.meta.env.VITE_CORE_API_URL;
// The order of the groups in the details.
const KINDS = ['ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
const KINDS = ['ALL_UNLOCKED', 'ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
/**
* `{syncedAt, startedBy, seen, importedGames, changes: [{kind, appid, name, imageUrl, amount, fromStatus, toStatus}]}`,
* or null when the user has no result (204) or the answer is not 200. PLAYTIME: amount in minutes, ACHIEVEMENTS:
* amount is the number of new unlocks.
* amount is the number of new unlocks. ALL_UNLOCKED: the sync unlocked the last
* achievement; gone once the game is COMPLETED.
*/
export async function fetchSyncChanges() {
const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
+12
View File
@@ -39,6 +39,12 @@ describe('bannerOf', () => {
expect(bannerOf(result({changes}))).toEqual(
{mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1});
});
it('countsAllUnlockedNowhere', () => {
const banner = bannerOf(result({changes: [{kind: 'ALL_UNLOCKED', appid: 1}]}));
expect(banner).toEqual({mode: 'changes', imported: 0, added: 0, removed: 0, minutes: 0, achievements: 0, moves: 0});
});
});
describe('groupByKind', () => {
@@ -50,4 +56,10 @@ describe('groupByKind', () => {
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]);
});
it('putsAllUnlockedFirst', () => {
const groups = groupByKind([{kind: 'ADDED', appid: 1}, {kind: 'ALL_UNLOCKED', appid: 2}]);
expect(groups.map((group) => group.kind)).toEqual(['ALL_UNLOCKED', 'ADDED']);
});
});