From 17d6cecab99a99bfa78f961f5f2b70acdfc59a76 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 11 Oct 2026 20:32:55 +0000 Subject: [PATCH] feat: remove a saved game & redirect on missing/deleted game (#20) - DELETE /api/games/:id route + Db.delete() (404 when absent) - PresetPicker: per-saved-game trash button + confirm dialog (Cancel/Delete) - App: runDeleteGame; useSse onError -> goHome on 'deleted'/'not found' (a deleted/missing game now returns the live tab and any deep link home) - DeleteGameResponse in shared/protocol.ts; api.deleteGame() - unit test (Db.delete) + 2 e2e tests (home-screen delete; in-flight delete returns the active tab home and a fresh deep link lands on home) - version 0.7.0 -> 0.8.0 + changelog Fixes #20 --- CHANGELOG.md | 7 +++ e2e/sim.spec.ts | 83 +++++++++++++++++++++++++++++++++ package.json | 2 +- server/db.test.ts | 15 ++++++ server/db.ts | 5 ++ server/index.ts | 11 +++++ shared/protocol.ts | 5 ++ src/App.tsx | 30 ++++++++++++ src/components/PresetPicker.tsx | 78 ++++++++++++++++++++++++++++--- src/lib/api.ts | 4 ++ src/lib/sse.ts | 17 +++++++ 11 files changed, 250 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1692c8e..62fb283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to **History** are listed here, newest first. +## v0.8.0 — 2026-10-11 20:29 + +- **Remove a saved game (#20)** — every saved game now has a delete control: a trash button on its card in the home screen's "Saved games" section opens a confirmation dialog ("Delete this game?" showing the scenario/turn/date) with Cancel and a destructive Delete. The new `DELETE /api/games/:id` route removes the game's row from SQLite and returns `{ deleted: true }` (404 if the id is unknown). +- **Redirect on a missing/deleted game (#20)** — loading a non-existent game id no longer leaves you stranded. A deep link to a missing game already fell back to home, and now a game that disappears *while its tab is open* (deleted from another tab or the same session) is pushed home too: the server broadcasts an SSE `error` event on delete, and the client's `useSse` gains an `onError` handler that returns the tab to the home screen (matching the existing "game not found" connect behavior). +- `PresetPicker` gained an `onDelete` prop, a per-card `data-game-id` attribute, the delete button, and the confirm dialog; `api.deleteGame(id)`, `Db.delete(id)`, and the shared `DeleteGameResponse` type were added to support it. +- **Unit + e2e tests** — new `Db.delete` test (removes by id; reports absence for unknown / already-gone ids) and two e2e specs: (a) deleting a saved game from the home screen (open → cancel leaves it; confirm removes the card, the response is `{deleted:true}`, and a follow-up `GET` is 404), and (b) deleting an open game (the live tab is pushed home via the SSE error, and a fresh deep-link to the deleted id lands on home). + ## v0.7.0 — 2026-10-11 19:17 - Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target. diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index 5e4bdfc..95f1a61 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -560,3 +560,86 @@ test.describe('History — Advisor thinking indicator (#15)', () => { await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/); }); }); + +/** + * Remove a saved game (#20): a saved game can be deleted from the home screen + * (with a confirmation dialog), and loading a non-existent / deleted game id + * (directly or via a stale open tab) returns the user to the home screen. + */ +test.describe('History — remove a saved game & redirect (#20)', () => { + test('deleting a saved game from the home screen removes it (with confirmation)', async ({ + page, + }) => { + test.setTimeout(90000); + const id = await createUkGame(page); + await expect(page.getByTestId('world-map')).toBeVisible(); + + // Back to the home screen, where the just-created game appears in "Saved games". + await page.getByTestId('back-to-scenarios').click(); + await expect(page.getByTestId('saved-games')).toBeVisible({ timeout: 15000 }); + const card = page.locator(`[data-game-id="${id}"]`); + await expect(card).toBeVisible(); + + // Opening the confirm dialog… + await card.getByTestId('delete-game').click(); + const dialog = page.getByTestId('delete-game-confirm-dialog'); + await expect(dialog).toBeVisible(); + + // …and cancelling leaves the game in place. + await page.getByTestId('delete-game-cancel').click(); + await expect(dialog).toHaveCount(0); + await expect(card).toBeVisible(); + + // Confirming the delete removes it: the DELETE succeeds, the card disappears, + // and a subsequent fetch reports the game gone. + await card.getByTestId('delete-game').click(); + await expect(page.getByTestId('delete-game-confirm-dialog')).toBeVisible(); + const [resp] = await Promise.all([ + page.waitForResponse( + (r) => + r.request().method() === 'DELETE' && + new URL(r.url()).pathname === `/api/games/${id}`, + ), + page.getByTestId('delete-game-confirm').click(), + ]); + expect((await resp.json()) as { deleted: boolean }).toEqual({ deleted: true }); + await expect(page.getByTestId('delete-game-confirm-dialog')).toHaveCount(0); + await expect(card).toHaveCount(0); + + // The game is gone from the server too. + const status = await page.evaluate(async (gid) => { + const r = await fetch(`/api/games/${gid}`); + return r.status; + }, id); + expect(status).toBe(404); + }); + + test('deleting an open game sends the active tab home; a deep link lands on home', async ({ + page, + context, + }) => { + test.setTimeout(90000); + const id = await createUkGame(page); + await expect(page.getByTestId('world-map')).toBeVisible(); + await expect(page.getByTestId('turn-count')).toHaveText('1'); + const origin = await page.evaluate(() => location.origin); + + // Delete the game straight through the API while its tab is open. The server + // broadcasts an SSE `error` and the client's useSse onError handler returns + // this live tab to the home screen. + const delStatus = await page.evaluate( + async (gid) => (await fetch(`/api/games/${gid}`, { method: 'DELETE' })).status, + id, + ); + expect(delStatus).toBe(200); + await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 }); + await waitHash(page, '#/'); + + // A brand-new page deep-linking to the now-deleted id also lands on home. + const page2 = await context.newPage(); + await page2.goto(`${origin}/#/game/${id}`); + await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 }); + await waitHash(page2, '#/'); + await page2.close(); + }); +}); diff --git a/package.json b/package.json index be7bbb0..3fe59c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/db.test.ts b/server/db.test.ts index 2ac1f05..0062051 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -72,4 +72,19 @@ describe('openDb', () => { expect(db.load('missing')).toBeNull(); db.close(); }); + + it('delete removes a game by id and reports absence otherwise', () => { + const db = openDb(':memory:'); + db.save(makeState('a')); + db.save(makeState('b')); + + expect(db.delete('nope')).toBe(false); // unknown id + expect(db.delete('a')).toBe(true); // removes a + expect(db.load('a')).toBeNull(); + expect(db.load('b')?.id).toBe('b'); + expect([...db.listIds()].sort()).toEqual(['b']); + + expect(db.delete('a')).toBe(false); // already gone + db.close(); + }); }); diff --git a/server/db.ts b/server/db.ts index d682769..bc1574e 100644 --- a/server/db.ts +++ b/server/db.ts @@ -8,6 +8,7 @@ export interface Db { save(state: GameState): void; load(id: string): GameState | null; listIds(): string[]; + delete(id: string): boolean; close(): void; } @@ -29,6 +30,7 @@ export function openDb(filePath: string): Db { ); const byId = db.prepare('SELECT data FROM games WHERE id = ?'); const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC'); + const del = db.prepare('DELETE FROM games WHERE id = ?'); return { save(state) { upsert.run(state.id, JSON.stringify(state), new Date().toISOString()); @@ -45,6 +47,9 @@ export function openDb(filePath: string): Db { listIds() { return (all.all() as { id: string }[]).map((r) => r.id); }, + delete(id) { + return del.run(id).changes > 0; + }, close() { db.close(); }, diff --git a/server/index.ts b/server/index.ts index 0a8aed0..6ea59e3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -156,6 +156,17 @@ app.get('/api/games/:id/map', (req, res) => { res.json({ regions: buildRegionMap(game) }); }); +// Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on +// success broadcast an SSE `error` to any other open tab watching it so it +// returns home (see the `event: error` handling in src/lib/sse.ts). +app.delete('/api/games/:id', (req, res) => { + const id = req.params.id; + const removed = db.delete(id); + if (!removed) return res.status(404).json({ error: 'game not found' }); + broadcast(id, 'error', { message: 'game deleted' }); + res.json({ deleted: true }); +}); + // Queue a free-text player action onto the pending round. No LLM call and no // turn advance — the round is only committed (in one turn) by "Jump forward". app.post('/api/games/:id/action', (req, res) => { diff --git a/shared/protocol.ts b/shared/protocol.ts index 5bbe3e9..813ab44 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -141,6 +141,11 @@ export interface GameSummary { date: string; } +/** Response for deleting a saved game (see `DELETE /api/games/:id`). */ +export interface DeleteGameResponse { + deleted: boolean; +} + /** * Deterministic, stable per-nation color for the choropleth. Hashes the nation id * to a hue; sat/light tuned for the dark command-center theme. Pure + shared so diff --git a/src/App.tsx b/src/App.tsx index 306c1d8..705172f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -309,6 +309,12 @@ export default function App() { useSse(game?.id ?? null, { onState: applyState, onTurn: applyState, + // The game disappeared while this tab had it open — either deleted from + // another tab (the server broadcasts an SSE `error`) or a stale deep link to + // a missing game (the SSE connect emits `error`). Return this tab home. + onError: (msg) => { + if (/deleted|not found/i.test(msg)) latestRef.current?.goHome(); + }, }); const play = useCallback(async (presetId: string) => { @@ -465,6 +471,29 @@ export default function App() { setAdvisors([]); }, []); + // Delete a saved game. Removes it from the "Saved games" list, clears it if it + // was the remembered active game, and heads home if it's the one currently + // shown. The server also broadcasts an SSE `error` to any *other* open tab of + // this game, which the `useSse` `onError` handler above turns into a go-home. + const runDeleteGame = useCallback( + async (id: string) => { + try { + await api.deleteGame(id); + } catch (e) { + toastError(e); + return; + } + setSavedGames((prev) => prev.filter((g) => g.id !== id)); + if (activeGameId === id) { + clearActiveGameId(); + setActiveGameId(null); + } + if (game?.id === id) goHome(); + toast.success('Game deleted'); + }, + [activeGameId, game, goHome], + ); + // Expose the latest game + handlers to the mount-once hashchange listener. latestRef.current = { game, goHome, loadGame }; @@ -509,6 +538,7 @@ export default function App() { savedGames={savedGames} activeGameId={activeGameId} onResume={resumeGame} + onDelete={runDeleteGame} /> ); diff --git a/src/components/PresetPicker.tsx b/src/components/PresetPicker.tsx index c69fdb5..24f101d 100644 --- a/src/components/PresetPicker.tsx +++ b/src/components/PresetPicker.tsx @@ -1,7 +1,16 @@ -import { Play } from 'lucide-react'; +import { useState } from 'react'; +import { Play, Trash2 } from 'lucide-react'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; import type { PresetMeta } from '@shared/types'; import type { GameSummary } from '@shared/protocol'; @@ -15,12 +24,14 @@ interface Props { activeGameId?: string | null; /** Resume a saved game by id (shared by the resume button and saved-games rows). */ onResume?: (id: string) => void; + /** Delete a saved game by id (confirmed by a dialog in this component). */ + onDelete?: (id: string) => void; } /** * Home screen: a grid of scenario cards (each with a Play button), a prominent * "Resume last game" affordance, and a "Saved games" section listing prior - * games so the user can get back into one. + * games so the user can get back into one — or delete it. */ export default function PresetPicker({ presets, @@ -29,8 +40,14 @@ export default function PresetPicker({ savedGames = [], activeGameId, onResume, + onDelete, }: Props) { const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined; + // The game awaiting a delete confirmation (null = dialog closed). + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const confirmDeleteTarget = confirmDeleteId + ? savedGames.find((g) => g.id === confirmDeleteId) + : undefined; return (
@@ -90,7 +107,7 @@ export default function PresetPicker({ ))}
- {/* Saved games: get back into any prior game. */} + {/* Saved games: get back into any prior game (or delete it). */} {savedGames.length > 0 && (

Saved games

@@ -100,13 +117,28 @@ export default function PresetPicker({ key={g.id} className="flex flex-col" data-testid="saved-game" + data-game-id={g.id} >
{g.presetTitle} - - {g.date} - +
+ + {g.date} + + +
Turn {g.turn}
@@ -125,6 +157,40 @@ export default function PresetPicker({
)} + + {/* Confirm before permanently deleting a saved game. */} + !o && setConfirmDeleteId(null)}> + + + Delete this game? + + {confirmDeleteTarget + ? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.` + : 'This game will be permanently removed.'} + + + + + + + + ); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3f06cc6..be6a0c1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,6 +7,7 @@ import type { AddDiplomacyResponse, AdvisorResponse, CreateGameResponse, + DeleteGameResponse, GameSummary, RemoveQueueItemResponse, TurnResponse, @@ -33,6 +34,9 @@ export const api = { body: JSON.stringify({ presetId, playerNationId }), }), getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`), + // Delete a saved game entirely. Fast, non-LLM. Returns { deleted: true }. + deleteGame: (id: string) => + req(`/api/games/${id}`, { method: 'DELETE' }), // Every saved game as a lightweight summary, newest-updated first (for the // home screen's "Saved games" section / resume affordances). listGames: () => req<{ games: GameSummary[] }>('/api/games'), diff --git a/src/lib/sse.ts b/src/lib/sse.ts index 3b7af95..a068038 100644 --- a/src/lib/sse.ts +++ b/src/lib/sse.ts @@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol'; interface SseHandlers { onState?: (state: GameState) => void; onTurn?: (state: GameState, result: TurnResult) => void; + /** + * Fired for a server-sent NAMED `event: error` (a MessageEvent with a JSON + * body `{ message }`) — e.g. the game was deleted or no longer exists. Pure + * connection errors (no data) are ignored and EventSource auto-reconnects. + */ + onError?: (message: string) => void; } /** @@ -37,8 +43,19 @@ export function useSse(id: string | null, handlers: SseHandlers): void { /* ignore malformed */ } }; + const onError = (e: Event) => { + if (e instanceof MessageEvent && e.data) { + try { + const d = JSON.parse(e.data) as { message?: string }; + if (d.message) handlersRef.current.onError?.(d.message); + } catch { + /* ignore malformed */ + } + } + }; es.addEventListener('state', onState); es.addEventListener('turn', onTurn); + es.addEventListener('error', onError); return () => es.close(); }, [id]); }