The library becomes browsable: detail pages, sorting, real filters

136 games with nothing to do but look at them. Now:

Every card links to /library/game/<key> — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 23:38:35 -04:00
co-authored by Claude Fable 5
parent 2e1693c0be
commit f5e949bd62
6 changed files with 293 additions and 11 deletions
+28
View File
@@ -460,6 +460,34 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.game .info { padding: .55rem .7rem .7rem; } .game .info { padding: .55rem .7rem .7rem; }
.game .gname { font-weight: 700; font-family: var(--font-display); } .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; } .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 { .empty {
background: var(--board); border: 2px dashed var(--board-edge); background: var(--board); border: 2px dashed var(--board-edge);
border-radius: var(--radius-lg); padding: 2rem; text-align: center; border-radius: var(--radius-lg); padding: 2rem; text-align: center;
+1 -1
View File
@@ -30,7 +30,7 @@
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p> <p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — 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 (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p> <p><b><a href="/review">Review</a></b> — 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 (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — 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 <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p> <p><b><a href="/queue">Queue</a></b> — 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 <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
<p><b><a href="/library">Library</a></b> — 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.</p> <p><b><a href="/library">Library</a></b> — 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, <b>your</b> 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.</p>
</div> </div>
<h2 id="curation">Fixing the titles: edit, split, remove</h2> <h2 id="curation">Fixing the titles: edit, split, remove</h2>
+55 -8
View File
@@ -1,10 +1,25 @@
<h1>Library</h1> <h1>Library</h1>
<div class="pagebar"><span id="libcount"></span></div> <div class="pagebar"><span id="libcount"></span></div>
<div class="filterbar" role="group" aria-label="filter by kind"> <div class="filterbar" role="group" aria-label="filter by kind">
<input type="search" id="libsearch" placeholder="search your games…" aria-label="search library"> <input type="search" id="libsearch" placeholder="search titles, designers, mechanics…"
aria-label="search library">
<button type="button" data-kind="" aria-pressed="true">All</button> <button type="button" data-kind="" aria-pressed="true">All</button>
<button type="button" data-kind="boardgame" aria-pressed="false">Board games</button> <button type="button" data-kind="boardgame" aria-pressed="false">Board games</button>
<button type="button" data-kind="rpgitem" aria-pressed="false">RPGs</button> <button type="button" data-kind="rpgitem" aria-pressed="false">RPGs</button>
<button type="button" data-kind="localgame" aria-pressed="false">Off BGG</button>
</div>
<div class="filterbar">
<label>plays with
<input type="number" id="libplayers" min="1" max="20" placeholder="any"
style="width:5em" aria-label="filter by player count"></label>
<label>sort
<select id="libsort" aria-label="sort library">
<option value="name">name</option>
<option value="year">year (newest)</option>
<option value="rank">BGG rank</option>
<option value="weight">weight (lightest)</option>
<option value="playtime">playing time</option>
</select></label>
</div> </div>
<div id="libbody"></div> <div id="libbody"></div>
<script> <script>
@@ -21,7 +36,7 @@ function gameCard(g) {
const weight = g.weight ? `weight ${g.weight.toFixed(1)}` : ""; const weight = g.weight ? `weight ${g.weight.toFixed(1)}` : "";
const rank = g.rank ? `BGG rank ${g.rank}` : ""; const rank = g.rank ? `BGG rank ${g.rank}` : "";
return ` return `
<article class="game"> <a class="game" href="/library/game/${encodeURIComponent(g.key)}">
${art ${art
? `<img src="${esc(art)}" alt="" loading="lazy">` ? `<img src="${esc(art)}" alt="" loading="lazy">`
: `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`} : `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`}
@@ -32,22 +47,52 @@ function gameCard(g) {
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div> <div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""} ${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
</div> </div>
</article>`; </a>`;
} }
function matchesKind(g) { function matchesKind(g) {
if (!KIND) return true; if (!KIND) return true;
if (KIND === "boardgame") return g.type !== "rpgitem"; if (KIND === "boardgame") return !["rpgitem", "localgame"].includes(g.type);
return g.type === "rpgitem"; return g.type === KIND;
} }
// null sorts last whichever way the column runs: an unranked game is not
// "rank 0", and a game with no weight is not the lightest
function by(field, dir = 1) {
return (a, b) => {
const x = a[field], y = b[field];
if (x == null && y == null) return 0;
if (x == null) return 1;
if (y == null) return -1;
return (x - y) * dir;
};
}
const SORTS = {
name: (a, b) => (a.name || "").localeCompare(b.name || "", undefined, {sensitivity: "base"}),
year: by("year", -1),
rank: by("rank"),
weight: by("weight"),
playtime: by("playtime"),
};
function render() { function render() {
const q = document.getElementById("libsearch").value.trim().toLowerCase(); const q = document.getElementById("libsearch").value.trim().toLowerCase();
const seats = Number(document.getElementById("libplayers").value) || 0;
let rows = GAMES.filter(matchesKind); let rows = GAMES.filter(matchesKind);
if (q) rows = rows.filter(g => if (q) rows = rows.filter(g => [
(g.name + " " + (g.designers || []).join(" ")).toLowerCase().includes(q)); g.name,
(g.designers || []).join(" "),
(g.mechanics || []).join(" "),
(g.categories || []).join(" "),
g.version && g.version.name,
].filter(Boolean).join(" ").toLowerCase().includes(q));
if (seats) rows = rows.filter(g =>
(g.min_players || 0) <= seats && seats <= (g.max_players || 0));
rows = [...rows].sort(SORTS[document.getElementById("libsort").value] || SORTS.name);
document.getElementById("libcount").innerHTML = document.getElementById("libcount").innerHTML =
`<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`; `<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`
+ (seats ? ` playable with <b>${seats}</b>` : "");
document.getElementById("libbody").innerHTML = rows.length document.getElementById("libbody").innerHTML = rows.length
? `<div class="shelfgrid">${rows.map(gameCard).join("")}</div>` ? `<div class="shelfgrid">${rows.map(gameCard).join("")}</div>`
: `<p class="empty">${GAMES.length : `<p class="empty">${GAMES.length
@@ -64,6 +109,8 @@ async function refresh() {
} }
document.getElementById("libsearch").addEventListener("input", render); document.getElementById("libsearch").addEventListener("input", render);
document.getElementById("libplayers").addEventListener("input", render);
document.getElementById("libsort").addEventListener("change", render);
document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => { document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => {
KIND = b.dataset.kind; KIND = b.dataset.kind;
document.querySelectorAll("[data-kind]").forEach(o => document.querySelectorAll("[data-kind]").forEach(o =>
@@ -0,0 +1,116 @@
<h1 id="gname">Loading…</h1>
<div class="pagebar">
<a href="/library">← library</a>
<span id="gcrumb"></span>
</div>
<div id="gbody"><p class="empty">Loading…</p></div>
<script>
"use strict";
const KEY = decodeURIComponent(location.pathname.replace(/^\/library\/game\//, ""));
function chips(label, values) {
if (!values || !values.length) return "";
return `<div class="factrow"><b>${esc(label)}</b>
<span class="chiplist">${values.map(v =>
`<span class="cue">${esc(v)}</span>`).join("")}</span></div>`;
}
function fact(label, value) {
return value === null || value === undefined || value === ""
? ""
: `<div class="factrow"><b>${esc(label)}</b> ${esc(value)}</div>`;
}
function players(g) {
const lo = g.min_players, hi = g.max_players;
if (!lo && !hi) return "";
const range = lo === hi ? `${lo}` : `${lo ?? "?"}${hi ?? "?"}`;
const best = (g.best_player_counts || []).length
? ` (best at ${esc(g.best_player_counts.join(", "))})`
: "";
return `${range} players${best}`;
}
function playtime(g) {
if (!g.playtime && !g.min_playtime) return "";
if (g.min_playtime && g.max_playtime && g.min_playtime !== g.max_playtime)
return `${g.min_playtime}${g.max_playtime} min`;
return `${g.playtime || g.min_playtime} min`;
}
function render(g) {
document.getElementById("gname").textContent = g.name || "(unnamed)";
document.title = `${g.name} · bggpipe`;
const local = g.type === "localgame";
const rpg = g.type === "rpgitem";
document.getElementById("gcrumb").innerHTML = [
g.year ? `<b>${esc(g.year)}</b>` : "",
local ? `<span class="chip open">not on BGG · local</span>` : "",
rpg ? `<span class="chip open">RPG · local only</span>` : "",
g.bgg_id
? `<a href="https://boardgamegeek.com/boardgame/${encodeURIComponent(g.bgg_id)}"
target="_blank" rel="noopener">view on BGG ↗</a>`
: "",
].filter(Boolean).join(" · ");
const art = g.image
? `<img class="gameart" src="${esc(g.image)}" alt="box art for ${esc(g.name)}">`
: `<div class="gameart noart">${esc((g.name || "?")[0])}</div>`;
const facts = [
fact("players", players(g)),
fact("playing time", playtime(g)),
fact("ages", g.min_age ? `${g.min_age}+` : ""),
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
fact("BGG rank", g.rank || ""),
fact("BGG rating", g.rating ? g.rating.toFixed(2) : ""),
fact("owned by", g.users_owned ? `${g.users_owned.toLocaleString()} people` : ""),
chips("designers", g.designers),
chips("artists", (g.artists || []).slice(0, 8)),
chips("publishers", (g.publishers || []).slice(0, 6)),
chips("categories", g.categories),
chips("mechanics", g.mechanics),
].filter(Boolean).join("");
const version = g.version
? `<h2>Your edition</h2>
<div class="card prose">
<div class="factrow"><b>${esc(g.version.name || "—")}</b>
${g.version.year ? ` · ${esc(g.version.year)}` : ""}</div>
${chips("publishers", g.version.publishers)}
${chips("languages", g.version.languages)}
</div>`
: `<h2>Your edition</h2>
<div class="card prose"><p class="meta">No specific edition recorded —
set one from the <a href="/titles">Titles</a> page if you know it.</p></div>`;
const photos = (g.photos || []).length
? `<h2>Seen on your shelves</h2>
<div class="card prose"><p>${g.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(" · ")}</p></div>`
: "";
document.getElementById("gbody").innerHTML = `
<div class="gamedetail">
<div class="gameartcol">${art}</div>
<div class="gamefacts">${facts}</div>
</div>
${version}
${photos}
${g.description ? `<h2>About</h2>
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
}
async function refresh() {
try {
render(await fetchJSON(`/api/library/${encodeURIComponent(KEY)}`));
} catch (err) {
document.getElementById("gbody").innerHTML =
`<p class="empty">This game isn't in the library
(${esc(err.message || err)}) — it may need <b>enrich</b> to run.
Back to the <a href="/library">library</a>.</p>`;
}
}
refresh();
</script>
+48 -2
View File
@@ -723,6 +723,11 @@ def create_app(
def library_page() -> str: def library_page() -> str:
return render_page("library") 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) @app.get("/help", response_class=HTMLResponse)
def help_page() -> str: def help_page() -> str:
return render_page("help") return render_page("help")
@@ -781,10 +786,51 @@ def create_app(
"log": log, "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") @app.get("/api/library")
def api_library() -> list[dict]: def api_library() -> list[dict]:
games = read_games() # the list view never needs the description: it is by far the
return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold()) # 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: def _pending(path: Path, action: str, log_rows: list[dict]) -> int:
from bggpipe.upload import annotate_queue from bggpipe.upload import annotate_queue
+45
View File
@@ -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[("a.jpg",)]["version_status"] == "version_unknown"
assert lines[("b.jpg",)]["version_status"] == "version_ambiguous" 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