From f5e949bd62174f421d71dbdc7ce99697c62467b3 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 5 Aug 2026 23:38:35 -0400 Subject: [PATCH] The library becomes browsable: detail pages, sorting, real filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 136 games with nothing to do but look at them. Now: Every card links to /library/game/ — a detail page with the box art, players (with best-at counts), playing time, weight, rank, rating, ages, owner count, designers/artists/publishers, categories and mechanics as chips, the description, YOUR edition (name, year, publishers, languages), and the shelf photos the game was read from, linking back to those photo pages. That provenance is the join only this pipeline can make: games.json knows the game, matches.csv knows which of your photos it came from. The list gains sorting (name, year, BGG rank, weight, playing time — with nulls always last, since an unranked game is not rank zero), a "plays with N" filter that keeps games whose player range covers the table, an Off-BGG kind filter, and a search that now covers designers, mechanics, categories and edition names rather than titles alone. /api/library drops the description field (a megabyte of dead weight across 136 games); the detail endpoint serves the whole entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- src/bggpipe/static/app.css | 28 +++++ src/bggpipe/templates/pages/help.html | 2 +- src/bggpipe/templates/pages/library.html | 63 ++++++++-- src/bggpipe/templates/pages/librarygame.html | 116 +++++++++++++++++++ src/bggpipe/webreview.py | 50 +++++++- tests/test_webreview.py | 45 +++++++ 6 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 src/bggpipe/templates/pages/librarygame.html diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 4536c3a..f463f21 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -460,6 +460,34 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .game .info { padding: .55rem .7rem .7rem; } .game .gname { font-weight: 700; font-family: var(--font-display); } .game .gmeta { font-size: .78rem; color: var(--ink-soft); margin-top: .2rem; line-height: 1.5; } +/* -- library: card grid links + one game's detail ---------------------- */ +a.game { text-decoration: none; color: inherit; } +a.game:hover { border-color: var(--accent); box-shadow: var(--shadow-raised); } +a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.gamedetail { display: flex; gap: 1.4rem; align-items: flex-start; flex-wrap: wrap; } +.gameartcol { flex: 0 0 clamp(12rem, 28vw, 18rem); } +.gameart { + width: 100%; border: var(--line); border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); display: block; background: var(--board); +} +.gameart.noart { + aspect-ratio: 1; display: flex; align-items: center; justify-content: center; + background: var(--sky-deep); color: var(--navy); + font-family: var(--font-display); font-size: 3rem; font-weight: 700; +} +.gamefacts { + flex: 1 1 18rem; background: var(--board); border: var(--line); + border-radius: var(--radius-lg); box-shadow: var(--shadow-card); + padding: .9rem 1.1rem; +} +.factrow { margin: .45rem 0; line-height: 1.5; } +.factrow b { + font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; + color: var(--ink-soft); margin-right: .4rem; +} +.chiplist { display: inline-flex; flex-wrap: wrap; gap: .3rem; vertical-align: middle; } +.gdesc { white-space: pre-wrap; line-height: 1.6; } + .empty { background: var(--board); border: 2px dashed var(--board-edge); border-radius: var(--radius-lg); padding: 2rem; text-align: center; diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index cf4150c..248c85d 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -30,7 +30,7 @@

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.

Queue — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a retry N failed checkbox. Each queued row shows what upload did with it — pending, done, failed, or retired (a review decision since the last diff withdrew it). Finished rows stay listed until the next diff rebuilds the queue; the log below them is the permanent record.

-

Library — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.

+

Library — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, your edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded.

Fixing the titles: edit, split, remove

diff --git a/src/bggpipe/templates/pages/library.html b/src/bggpipe/templates/pages/library.html index d70bedd..5295077 100644 --- a/src/bggpipe/templates/pages/library.html +++ b/src/bggpipe/templates/pages/library.html @@ -1,10 +1,25 @@

Library

- + + +
+
+ +
diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 4d9c1f0..4f46455 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -723,6 +723,11 @@ def create_app( def library_page() -> str: return render_page("library") + @app.get("/library/game/{key:path}", response_class=HTMLResponse) + def library_game_page(key: str) -> str: + # the fragment reads the key from its own URL; nothing interpolated + return render_page("librarygame", active="library") + @app.get("/help", response_class=HTMLResponse) def help_page() -> str: return render_page("help") @@ -781,10 +786,51 @@ def create_app( "log": log, } + def library_entries() -> dict[str, dict]: + """games.json plus what only the pipeline knows: the entry's key and + the photos the game was read from (its provenance back to a shelf).""" + games = read_games() + available = photo_names() + photos_by_key: dict[str, list[str]] = {} + for row in session.rows: + if not row["bgg_id"]: + continue + key = ( + f"{row['bgg_id']}:{row['version_id']}" + if row["version_id"] + else row["bgg_id"] + ) + photos_by_key.setdefault(key, []).extend( + p for p in row["source_photos"].split(";") if p + ) + out = {} + for key, game in games.items(): + photos = game.get("source_photos") or photos_by_key.get(key, []) + out[key] = { + **game, + "key": key, + "photos": [p for p in dict.fromkeys(photos) if p in available], + } + return out + @app.get("/api/library") def api_library() -> list[dict]: - games = read_games() - return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold()) + # the list view never needs the description: it is by far the + # largest field, and 136 of them is a megabyte of dead weight + return sorted( + ( + {k: v for k, v in g.items() if k != "description"} + for g in library_entries().values() + ), + key=lambda g: (g.get("name") or "").casefold(), + ) + + @app.get("/api/library/{key:path}") + def api_library_game(key: str) -> dict: + game = library_entries().get(key) + if game is None: + raise HTTPException(404, "no such game in the library") + return game def _pending(path: Path, action: str, log_rows: list[dict]) -> int: from bggpipe.upload import annotate_queue diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 27aa0f5..881ff35 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1243,3 +1243,48 @@ def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path): } assert lines[("a.jpg",)]["version_status"] == "version_unknown" assert lines[("b.jpg",)]["version_status"] == "version_ambiguous" + + +def test_library_detail_serves_one_game_with_provenance(tmp_path): + cfg = make_cfg(tmp_path) + rows = read_matches(cfg.matches_path) + rows.append( + _row( + title_raw="Britannia", + match_status="auto", + bgg_id="240", + bgg_name="Britannia", + version_id="24621", + source_photos="shelf.jpg", + ) + ) + write_matches(cfg.matches_path, rows) + cfg.games_path.write_text( + json.dumps( + { + "240:24621": { + "bgg_id": 240, + "name": "Britannia", + "year": 1986, + "type": "boardgame", + "description": "A long description.", + "version": {"version_id": 24621, "name": "Avalon Hill second"}, + } + } + ) + ) + web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path))) + + (listed,) = web.get("/api/library").json() + assert listed["key"] == "240:24621" + assert "description" not in listed # the list view stays light + + detail = web.get("/api/library/240:24621").json() + assert detail["name"] == "Britannia" + assert detail["description"] == "A long description." + # provenance the pipeline knows and games.json doesn't: the shelf photo + assert detail["photos"] == ["shelf.jpg"] + + assert web.get("/api/library/nope").status_code == 404 + page = web.get("/library/game/240:24621") + assert page.status_code == 200 and 'href="/library"' in page.text