From 83bbc2730db642a0194eb69a9d45a59e7e67cf5b Mon Sep 17 00:00:00 2001
From: lobo
Date: Sat, 26 Sep 2026 19:00:57 +0200
Subject: [PATCH] feat(library): ask whether a game is finished
---
README.md | 3 +
public/i18n/de.json | 6 +-
public/i18n/en.json | 6 +-
src/components/FinishedHint.jsx | 77 ++++++++++++++++++++++++
src/components/FinishedHint.test.jsx | 90 ++++++++++++++++++++++++++++
src/pages/GameDetail.jsx | 9 ++-
src/pages/GameDetail.test.jsx | 9 +++
src/utils/backlog.js | 12 ++++
8 files changed, 209 insertions(+), 3 deletions(-)
create mode 100644 src/components/FinishedHint.jsx
create mode 100644 src/components/FinishedHint.test.jsx
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 (
+
+