Public Access
feat(library): ask whether a game is finished
This commit is contained in:
1 parent
011711fe4f
commit
83bbc2730d
8 files changed
+208
-2
No files matched your search
@@ -33,6 +33,9 @@ To Be Played is split into three repositories:
|
||||
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
|
||||
every achievement is unlocked.
|
||||
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
|
||||
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
|
||||
most players need, finished?", with "Mark as finished" and "Not yet".
|
||||
- **Hide games**: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists,
|
||||
the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
|
||||
- **Wishlist** with store price and discount.
|
||||
|
||||
+5
-1
@@ -220,7 +220,11 @@
|
||||
"REMOVE_BACKGROUND": "Hintergrund entfernen",
|
||||
"SET_BANNER": "Als Banner",
|
||||
"ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.",
|
||||
"SET_COMPLETED": "Auf 100 % setzen"
|
||||
"SET_COMPLETED": "Auf 100 % setzen",
|
||||
"FINISHED_ENDING": "Du hast \"{{achievement}}\" freigeschaltet, das Ende der Geschichte. Hast du das Spiel durchgespielt?",
|
||||
"FINISHED_PLAYTIME": "Du hast {{played}} gespielt; die meisten spielen dieses Spiel in {{normal}} durch. Durchgespielt?",
|
||||
"SET_FINISHED": "Als durchgespielt markieren",
|
||||
"NOT_YET": "Noch nicht"
|
||||
},
|
||||
"LANDING": {
|
||||
"STATS_TITLE": "Community-Statistiken",
|
||||
|
||||
+5
-1
@@ -220,7 +220,11 @@
|
||||
"REMOVE_BACKGROUND": "Remove background",
|
||||
"SET_BANNER": "Use as banner",
|
||||
"ALL_UNLOCKED": "You unlocked every achievement of this game.",
|
||||
"SET_COMPLETED": "Set to 100 %"
|
||||
"SET_COMPLETED": "Set to 100 %",
|
||||
"FINISHED_ENDING": "You unlocked \"{{achievement}}\", the end of the story. Did you finish the game?",
|
||||
"FINISHED_PLAYTIME": "You played {{played}}; most players finish this game in {{normal}}. Did you finish it?",
|
||||
"SET_FINISHED": "Mark as finished",
|
||||
"NOT_YET": "Not yet"
|
||||
},
|
||||
"LANDING": {
|
||||
"STATS_TITLE": "Community stats",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {Flag, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
/**
|
||||
* Core's guess that a game is finished: the ending achievement is unlocked, or the playtime passed the normal time
|
||||
* to beat. Only a question; the user decides.
|
||||
*
|
||||
* @param onChanged gets the saved backlog entry after "Mark as finished"
|
||||
*/
|
||||
export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const hint = game.finishedHint;
|
||||
// A status saved elsewhere on the page answers the question too.
|
||||
if (!hint || (game.status !== 'BACKLOG' && game.status !== 'PLAYING')) return null;
|
||||
|
||||
const run = async (request, onOk) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
}
|
||||
await onOk(response);
|
||||
} catch (err) {
|
||||
console.error('finished hint error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setFinished = () => run(() => saveGameStatus(game.appid, 'FINISHED'), async (response) => {
|
||||
onChanged(await response.json());
|
||||
toast.success(t('BACKLOG.SAVED'));
|
||||
});
|
||||
|
||||
const notYet = () => run(() => dismissFinishedHint(game.appid, hint.type), () => onDismissed());
|
||||
|
||||
const text = hint.type === 'ENDING'
|
||||
? t('GAME_DETAIL.FINISHED_ENDING', {achievement: hint.achievement})
|
||||
: t('GAME_DETAIL.FINISHED_PLAYTIME', {
|
||||
played: formatPlaytime(game.playtimeMinutes),
|
||||
normal: formatPlaytime(game.timeToBeat?.normallyMinutes ?? 0),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="gap-3 border-emerald-500/40 bg-emerald-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex flex-1 items-start gap-3 sm:items-center">
|
||||
<Flag className="size-5 shrink-0 text-emerald-500"/>
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 self-end sm:self-auto">
|
||||
<Button size="sm" variant="outline" onClick={notYet} disabled={busy}>{t('GAME_DETAIL.NOT_YET')}</Button>
|
||||
<Button size="sm" onClick={setFinished} disabled={busy}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t('GAME_DETAIL.SET_FINISHED')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import FinishedHint from './FinishedHint.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBeat: {normallyMinutes: 500},
|
||||
finishedHint: {type: 'ENDING', achievement: 'Survivor'}};
|
||||
const renderHint = (props) => render(
|
||||
<MemoryRouter initialEntries={['/library/322110']}>
|
||||
<Routes>
|
||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||
<Route path="/login" element={<p>login page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>);
|
||||
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
|
||||
|
||||
describe('FinishedHint', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => (String(url).endsWith('/finished-hint')
|
||||
? {ok: true, status: 204}
|
||||
: {ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: null, notes: null, priority: null})}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows nothing without a hint', () => {
|
||||
renderHint({game: {...ending, finishedHint: null}});
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['FINISHED', 'COMPLETED', 'DROPPED'])('shows nothing once the status is %s', (status) => {
|
||||
renderHint({game: {...ending, status}});
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('names the ending achievement', () => {
|
||||
renderHint();
|
||||
|
||||
expect(screen.getByText('GAME_DETAIL.FINISHED_ENDING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('explains the playtime hint', () => {
|
||||
renderHint({game: {...ending, finishedHint: {type: 'PLAYTIME', achievement: null}}});
|
||||
|
||||
expect(screen.getByText('GAME_DETAIL.FINISHED_PLAYTIME')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marks the game as finished', async () => {
|
||||
const onChanged = vi.fn();
|
||||
renderHint({onChanged});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'}));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(expect.objectContaining({status: 'FINISHED'})));
|
||||
const [url, init] = puts()[0];
|
||||
expect(url).toContain('/api/library/games/322110/status');
|
||||
expect(JSON.parse(init.body)).toEqual({status: 'FINISHED'});
|
||||
});
|
||||
|
||||
it('sends "Not yet" with the type of the hint', async () => {
|
||||
const onDismissed = vi.fn();
|
||||
renderHint({game: {...ending, finishedHint: {type: 'PLAYTIME', achievement: null}}, onDismissed});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
|
||||
|
||||
await waitFor(() => expect(onDismissed).toHaveBeenCalled());
|
||||
const [url, init] = puts()[0];
|
||||
expect(url).toContain('/api/library/games/322110/finished-hint');
|
||||
expect(JSON.parse(init.body)).toEqual({dismissed: 'PLAYTIME'});
|
||||
});
|
||||
|
||||
it('ends the session on a 401', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401}));
|
||||
renderHint();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
|
||||
|
||||
expect(await screen.findByText('login page')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||
import CompletedHint from '../components/CompletedHint.jsx';
|
||||
import FinishedHint from '../components/FinishedHint.jsx';
|
||||
import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
@@ -248,7 +249,13 @@ export default function GameDetail() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* The finished question also answers "are you playing it?". */}
|
||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||
)}
|
||||
<FinishedHint game={data}
|
||||
onChanged={(entry) => setData((d) => ({...d, ...entry, finishedHint: null}))}
|
||||
onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/>
|
||||
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||
|
||||
<BacklogCard key={data.appid} game={data}
|
||||
|
||||
@@ -38,6 +38,15 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('GameDetail', () => {
|
||||
it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, playtime2Weeks: 90,
|
||||
source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}})}));
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull();
|
||||
});
|
||||
|
||||
it('hides a Steam game and shows it again', async () => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
|
||||
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
|
||||
|
||||
@@ -37,6 +37,18 @@ export const saveGameStatus = (appid, status) =>
|
||||
body: JSON.stringify({status}),
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/library/games/{appid}/finished-hint: "Not yet" on the finished hint. Answers 204.
|
||||
*
|
||||
* @param dismissed the hint type the user answered, ENDING or PLAYTIME
|
||||
*/
|
||||
export const dismissFinishedHint = (appid, dismissed) =>
|
||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/finished-hint`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({dismissed}),
|
||||
});
|
||||
|
||||
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden}) => {
|
||||
const params = new URLSearchParams({page: String(page), size: String(size), sort});
|
||||
if (hidden) params.set('hidden', 'true');
|
||||
|
||||
Reference in new issue
Block a user