diff --git a/README.md b/README.md index bf79571..ca14e24 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/i18n/de.json b/public/i18n/de.json index 4c127c2..4279c50 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index 778d43b..9c25ffe 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/components/FinishedHint.jsx b/src/components/FinishedHint.jsx new file mode 100644 index 0000000..60e75ea --- /dev/null +++ b/src/components/FinishedHint.jsx @@ -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 ( + +
+ +

{text}

+
+
+ + +
+
+ ); +} diff --git a/src/components/FinishedHint.test.jsx b/src/components/FinishedHint.test.jsx new file mode 100644 index 0000000..23e9807 --- /dev/null +++ b/src/components/FinishedHint.test.jsx @@ -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( + + + {}} onDismissed={() => {}} {...props}/>}/> + login page

}/> +
+
); +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(); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 0547d2b..51a745e 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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() { - setData((d) => ({...d, ...entry}))}/> + {/* The finished question also answers "are you playing it?". */} + {!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && ( + setData((d) => ({...d, ...entry}))}/> + )} + setData((d) => ({...d, ...entry, finishedHint: null}))} + onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/> setData((d) => ({...d, ...entry}))}/> { }); 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)} diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 23ee2f6..a0775ec 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -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');