Public Access
Compare commits
3
Commits
2794696daa
...
bbc707de87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbc707de87 | ||
|
|
c966877b23 | ||
|
|
e044959629 |
No files matched your search
@@ -117,6 +117,7 @@ flowchart LR
|
|||||||
(`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag;
|
(`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
|
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.
|
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 %),
|
- **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
|
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
|
game still in the backlog, and one to set the status to 100 % when
|
||||||
|
|||||||
+5
-2
@@ -1182,7 +1182,7 @@
|
|||||||
"RULES": {
|
"RULES": {
|
||||||
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
"NO_PROOF": "kein Nachweis, dass du es kürzlich gespielt hast; spiel es nach dem Durchspielen noch einmal",
|
||||||
"WEEKLY_LIMIT": "Wochenlimit erreicht",
|
"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",
|
"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"
|
"NOT_ALL_ACHIEVEMENTS": "nicht alle Erfolge freigeschaltet"
|
||||||
},
|
},
|
||||||
"SLOTS": {
|
"SLOTS": {
|
||||||
@@ -1334,7 +1335,9 @@
|
|||||||
"CLOSE": "Schließen",
|
"CLOSE": "Schließen",
|
||||||
"EMPTY": "Keine Änderungen zum Anzeigen.",
|
"EMPTY": "Keine Änderungen zum Anzeigen.",
|
||||||
"ERROR": "Die Änderungen konnten nicht geladen werden.",
|
"ERROR": "Die Änderungen konnten nicht geladen werden.",
|
||||||
|
"COMPLETED_DONE": "Auf 100 % gesetzt",
|
||||||
"HEADING": {
|
"HEADING": {
|
||||||
|
"ALL_UNLOCKED": "Alle Erfolge freigeschaltet",
|
||||||
"ADDED": "Neue Spiele",
|
"ADDED": "Neue Spiele",
|
||||||
"REMOVED": "Entfernte Spiele",
|
"REMOVED": "Entfernte Spiele",
|
||||||
"PLAYTIME": "Spielzeit",
|
"PLAYTIME": "Spielzeit",
|
||||||
|
|||||||
+5
-2
@@ -1182,7 +1182,7 @@
|
|||||||
"RULES": {
|
"RULES": {
|
||||||
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
"NO_PROOF": "no proof of recent play; play it once more after finishing",
|
||||||
"WEEKLY_LIMIT": "weekly limit reached",
|
"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",
|
"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"
|
"NOT_ALL_ACHIEVEMENTS": "not all achievements unlocked"
|
||||||
},
|
},
|
||||||
"SLOTS": {
|
"SLOTS": {
|
||||||
@@ -1334,7 +1335,9 @@
|
|||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"EMPTY": "No changes to show.",
|
"EMPTY": "No changes to show.",
|
||||||
"ERROR": "The changes could not be loaded.",
|
"ERROR": "The changes could not be loaded.",
|
||||||
|
"COMPLETED_DONE": "Set to 100%",
|
||||||
"HEADING": {
|
"HEADING": {
|
||||||
|
"ALL_UNLOCKED": "All achievements unlocked",
|
||||||
"ADDED": "New games",
|
"ADDED": "New games",
|
||||||
"REMOVED": "Removed games",
|
"REMOVED": "Removed games",
|
||||||
"PLAYTIME": "Playtime",
|
"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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
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';
|
import {STATUS_NOT_REACHED} from '../utils/rewards.js';
|
||||||
|
|
||||||
const WAITING = {NO_PROOF: 'POINTS.GAME.WAITING', NO_PLAY_AFTER_START: 'POINTS.GAME.WAITING_START'};
|
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]);
|
}, [check, game.appid, game.status]);
|
||||||
|
|
||||||
if (!points) return null;
|
if (!points) return null;
|
||||||
|
const earlySinceText = points.earlySince && formatDay(points.earlySince, i18n.language, {dateStyle: 'medium'});
|
||||||
const kindText = (kind) => t(`POINTS.KINDS.${kind}`);
|
const kindText = (kind) => t(`POINTS.KINDS.${kind}`);
|
||||||
const lines = [
|
const lines = [
|
||||||
...points.paid.map((row) => t('POINTS.GAME.PAID', {
|
...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')}`)),
|
}) + (row.appid === game.appid ? '' : ` ${t('POINTS.GAME.OTHER_EDITION')}`)),
|
||||||
...Object.entries(points.missing).filter(([, reason]) => !STATUS_NOT_REACHED.includes(reason)).map(([kind, reason]) => (
|
...Object.entries(points.missing).filter(([, reason]) => !STATUS_NOT_REACHED.includes(reason)).map(([kind, reason]) => (
|
||||||
waitingKey(kind, reason) ? t(waitingKey(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>);
|
return lines.map((line, i) => <p key={i} className="text-sm text-muted-foreground">{line}</p>);
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,9 @@ const keepShown = (prev, body, markedAt) =>
|
|||||||
* device. A manual sync without changes says so once.
|
* 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 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 {t} = useTranslation();
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [dismissed, setDismissed] = 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}>
|
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
|
||||||
<X/>
|
<X/>
|
||||||
</Button>
|
</Button>
|
||||||
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
|
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)} onChanged={onChanged}/>}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import {useTranslation} from 'react-i18next';
|
|||||||
|
|
||||||
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
|
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import SyncCompletedButton from './SyncCompletedButton.jsx';
|
||||||
import GameRow from './stats/GameRow.jsx';
|
import GameRow from './stats/GameRow.jsx';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.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
|
* 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.
|
* 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 {t} = useTranslation();
|
||||||
const [changes, setChanges] = useState(null);
|
const [changes, setChanges] = useState(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -52,7 +55,12 @@ export default function SyncChangesDialog({onClose}) {
|
|||||||
<section key={kind} className="space-y-2">
|
<section key={kind} className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
|
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
|
||||||
<ul className="space-y-2">
|
<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}`}>
|
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
|
||||||
<GameRow game={change} value={value(change)} linked/>
|
<GameRow game={change} value={value(change)} linked/>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import {useCallback, useEffect, useRef} from 'react';
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
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';
|
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',
|
toast(t(notice.rest ? 'POINTS.GAME.TOAST_PAID_REST' : 'POINTS.GAME.TOAST_PAID',
|
||||||
{date: formatDate(notice.date, i18n.language, {dateStyle: 'medium'})}));
|
{date: formatDate(notice.date, i18n.language, {dateStyle: 'medium'})}));
|
||||||
} else if (notice.type === 'none') {
|
} 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 {
|
} else {
|
||||||
toast(t('POINTS.GAME.TOAST_STATUS'));
|
toast(t('POINTS.GAME.TOAST_STATUS'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ describe('translation keys', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('points texts', () => {
|
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', () => {
|
it('pointsTexts_SayEarnNotPay', () => {
|
||||||
const texts = (file) => flatTexts(readTexts(file).POINTS).map(([, text]) => text);
|
const texts = (file) => flatTexts(readTexts(file).POINTS).map(([, text]) => text);
|
||||||
for (const text of texts('de.json')) expect(text).not.toMatch(/\bzahl|Beweis|bezahlt/);
|
for (const text of texts('de.json')) expect(text).not.toMatch(/\bzahl|Beweis|bezahlt/);
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ const Dashboard = () => {
|
|||||||
stats.reload();
|
stats.reload();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const reloadAfterCompleted = () => {
|
||||||
|
reloadLibrary();
|
||||||
|
stats.reload();
|
||||||
|
};
|
||||||
const {watch} = sync;
|
const {watch} = sync;
|
||||||
const forceSync = useForceSync(watch);
|
const forceSync = useForceSync(watch);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,7 +144,7 @@ const Dashboard = () => {
|
|||||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||||
equipped={equipped}
|
equipped={equipped}
|
||||||
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
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}
|
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import PageContainer from '../components/PageContainer.jsx';
|
|||||||
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
|
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import useInView from '../hooks/useInView.js';
|
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';
|
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
|
||||||
|
|
||||||
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED'];
|
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED'];
|
||||||
@@ -72,6 +72,8 @@ export default function PointsPage() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const earlySince = wallet.earlySince && formatDay(wallet.earlySince, i18n.language, {dateStyle: 'medium'});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatDrillProvider source="points">
|
<StatDrillProvider source="points">
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
@@ -86,7 +88,7 @@ export default function PointsPage() {
|
|||||||
<Card className="gap-2 p-5">
|
<Card className="gap-2 p-5">
|
||||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
<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>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import {apiFetch} from './api.js';
|
|||||||
const CORE = () => import.meta.env.VITE_CORE_API_URL;
|
const CORE = () => import.meta.env.VITE_CORE_API_URL;
|
||||||
|
|
||||||
// The order of the groups in the details.
|
// 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}]}`,
|
* `{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:
|
* 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() {
|
export async function fetchSyncChanges() {
|
||||||
const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
|
const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ describe('bannerOf', () => {
|
|||||||
expect(bannerOf(result({changes}))).toEqual(
|
expect(bannerOf(result({changes}))).toEqual(
|
||||||
{mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1});
|
{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', () => {
|
describe('groupByKind', () => {
|
||||||
@@ -50,4 +56,10 @@ describe('groupByKind', () => {
|
|||||||
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
|
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
|
||||||
expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]);
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user