Review UI: surface the unresolved backlog and a full catalog ledger

The done screen claimed "diff-ready" while 104 extracted titles had
never been resolved (they're invisible to matches.csv until the BGG
token arrives). The state now counts titles.json entries with no
matches row: the header tally shows "awaiting resolve", the done screen
says "Resolved set fully reviewed" with the real extracted total and
what to run on token day, and a read-only Catalog section lists every
extracted title with its status chip (auto/approved/rejected/awaiting
BGG), matched game, version, and source photos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 16:45:57 -04:00
parent f574fb2b3c
commit 6da499d31a
6 changed files with 147 additions and 7 deletions
+1 -1
View File
@@ -25,4 +25,4 @@ FLAT TOP,2529,Flat Top,1977,boardgame,auto,,,version_unknown,"[{""bgg_id"": 2529
Diplomacy,483,Diplomacy,1959,boardgame,auto,,,version_unknown,"[{""bgg_id"": 483, ""name"": ""Diplomacy"", ""year"": 1959, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg
Age of Renaissance,26,Age of Renaissance,1996,boardgame,auto,2601,Avalon Hill English edition,version_auto,"[{""bgg_id"": 26, ""name"": ""Age of Renaissance"", ""year"": 1996, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 2601, ""name"": ""Avalon Hill English edition"", ""year"": 1996, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
History of the World,224,History of the World,1991,boardgame,auto,22401,Avalon Hill English edition,version_auto,"[{""bgg_id"": 224, ""name"": ""History of the World"", ""year"": 1991, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 22401, ""name"": ""Avalon Hill English edition"", ""year"": 1993, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
DUNGEON!,1339,Dungeon!,1975,boardgame,auto,,,version_ambiguous,"[{""bgg_id"": 1339, ""name"": ""Dungeon!"", ""year"": 1975, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 133901, ""name"": ""TSR first edition"", ""year"": 1975, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133902, ""name"": ""TSR second edition"", ""year"": 1980, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133903, ""name"": ""TSR New Dungeon edition"", ""year"": 1989, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
DUNGEON!,1339,Dungeon!,1975,boardgame,auto,133903,TSR New Dungeon edition,version_approved,"[{""bgg_id"": 1339, ""name"": ""Dungeon!"", ""year"": 1975, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 133901, ""name"": ""TSR first edition"", ""year"": 1975, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133902, ""name"": ""TSR second edition"", ""year"": 1980, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133903, ""name"": ""TSR New Dungeon edition"", ""year"": 1989, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
1 title_raw bgg_id bgg_name year type match_status version_id version_name version_status candidates_json version_candidates_json source_photos
25 Diplomacy 483 Diplomacy 1959 boardgame auto version_unknown [{"bgg_id": 483, "name": "Diplomacy", "year": 1959, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
26 Age of Renaissance 26 Age of Renaissance 1996 boardgame auto 2601 Avalon Hill English edition version_auto [{"bgg_id": 26, "name": "Age of Renaissance", "year": 1996, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 2601, "name": "Avalon Hill English edition", "year": 1996, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4502.jpeg
27 History of the World 224 History of the World 1991 boardgame auto 22401 Avalon Hill English edition version_auto [{"bgg_id": 224, "name": "History of the World", "year": 1991, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 22401, "name": "Avalon Hill English edition", "year": 1993, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4502.jpeg
28 DUNGEON! 1339 Dungeon! 1975 boardgame auto 133903 TSR New Dungeon edition version_ambiguous version_approved [{"bgg_id": 1339, "name": "Dungeon!", "year": 1975, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 133901, "name": "TSR first edition", "year": 1975, "publishers": ["TSR"], "languages": ["English"], "score": 3}, {"version_id": 133902, "name": "TSR second edition", "year": 1980, "publishers": ["TSR"], "languages": ["English"], "score": 3}, {"version_id": 133903, "name": "TSR New Dungeon edition", "year": 1989, "publishers": ["TSR"], "languages": ["English"], "score": 3}] IMG_4502.jpeg
+54
View File
@@ -0,0 +1,54 @@
[
"IMG_4505.jpeg|Bottom area, left side near Mantis Falls, small figure/box partially visible||Small gold/tan colored object, possibly a game piece or small box, mostly obscured",
"IMG_4505.jpeg|Top shelf, far left edge, partially cut off by frame, to the left of Cat Crimes||Dark brown/maroon box spine with gold decorative swirl design, portion visible at left edge of image",
"IMG_4506.jpeg|Middle area, below and left of Santorini, dark red/maroon box spine with small square icons||Dark reddish-brown spine with small colored square icons in a row, text not legible",
"IMG_4506.jpeg|Right side, background behind Santorini box, partially visible dark box with small game piece icon||Dark background box with a small blue/white icon, mostly obscured by Santorini box in foreground",
"IMG_4506.jpeg|Top shelf, above Santorini spine, partially visible box with patterned tiles/squares in yellow, blue, and cream tones||Patchwork/quilt-like pattern of colored squares (yellow, tan, blue) along the top edge of the box, style suggests a tile-based board game",
"IMG_4506.jpeg|Top shelf, right side near blue corner box, small red 'X' symbols visible on light blue background|X X|Light blue box edge with red X pattern, possibly a tally or scoring game box",
"IMG_4507.jpeg|Middle shelf area, partially hidden behind Labyrinth box and below Utter Nonsense, left side of the stack|Dragon...|Green/dark colored box spine, text cut off by adjacent boxes, only 'Dragon' prefix visible",
"IMG_4507.jpeg|Upper right area near Santorini and a Gentle Rain, appears to be a narrow spine box|PATCH WORK or similar|Colorful patchwork/quilt-like pattern visible on spine, tan and multicolor squares design",
"IMG_4508.jpeg|Top shelf, center, yellow tin box sitting next to the Super Mario Checkers box||Small yellow tin, no visible title text, could be a card game tin",
"IMG_4508.jpeg|Top shelf, far left, behind the stack of papers/notebooks, next to the framed picture||Small black box with unreadable logo, mostly obscured by papers",
"IMG_4508.jpeg|Top shelf, right of center, wooden box with metal clasp between the checkers box and the papers stack||Wooden box with brass hardware, no visible title, appears decorative or a game storage box",
"IMG_4508.jpeg|Top shelf, right side, partially visible box behind the wooden box, between the stack of papers and the edge of the frame|SCRABBLE (partial letters visible)|Red and teal colored box edge, appears to be a Scrabble-branded box but mostly obscured",
"IMG_4511.jpeg|Middle shelf, black box stacked below SUPERFIGHT, above ROOK; left side of shelf|...Go... Yourself|Black spine box, small white/colored text, similar size to Superfight box",
"IMG_4511.jpeg|Right edge of shelf, partially cut off by frame, next to Cthulhu box|HEX|Blue box with yellow/tan geometric pattern, only edge visible",
"IMG_4517.jpeg|Middle of shelf, between Pie Face! (left) and the blue box with bird artwork (right)|...Grenade...|Tall narrow box, dark red/brown color scheme, appears to have a hand grenade or bomb-like illustration",
"IMG_4517.jpeg|Middle of shelf, right of the grenade-themed box and left of Walk the Plank!|Bird ... Bird ...|Blue box with tall bird-like cartoon character illustration, small/narrow box shape",
"IMG_4518.jpeg|Between Mage Wars Academy Core Set and Saberu Merry Men||Thin black spine, largely obscured, possibly a second small game box",
"IMG_4519.jpeg|Far right of shelf, white spine box next to the two red 'G' logo boxes|Before I Kill You, Mister Bond... / Kill Dr. No Lucky|White worn spine with black text, possibly James Bond themed game, 'CHEAPEST GAMES' text also visible on spine which may be a store sticker rather than title",
"IMG_4519.jpeg|Right-center of shelf, two matching red boxes standing between the D&D group on the left and the white 'Cheapest Games' spine on the right|Orange 'G' logo visible on both spines|Two identical red boxes with worn/taped edges, orange circular 'G' emblem, appears to be same game duplicated",
"IMG_4520.jpeg|Middle shelf, between Carcassonne and Evolution: The Beginning||Plain wooden-colored vertical box, no visible text, appears to be a wood grain finish game box or insert",
"IMG_4521.jpeg|Top edge of frame, above the Sleeping Gods box, partially cut off||Dark spines/boxes barely visible at top, too obscured by shadow and cropping to identify",
"IMG_4527.jpeg|Left edge of shelf, partially visible spines to the left of the main game box, on the top shelf|01, T...|Appears to be book-like spines, red and dark covers, stacked vertically, likely not games but partially obscured",
"IMG_4528.jpeg|Top shelf, back corner above the Wiz-War box; only the publisher logo area is visible, partially obscured by other items|SLUGFEST GAMES|Colorful red/orange box art, only top publisher banner visible, rest of box obscured",
"IMG_4529.jpeg|Below the 1000-piece puzzle box, spine showing armored warrior artwork, above the face-out Risk Lord of the Rings box|The Middle-earth|Dark battle scene with armored figures, likely same LOTR-themed game box viewed from spine angle",
"IMG_4529.jpeg|Middle of stack between the puzzle box and Risk Lord of the Rings, tan/cream colored spine with Asian characters and blue castle icon|unreadable Asian characters, blue icon|Tan spine with small blue building/castle graphic and Asian script text",
"IMG_4529.jpeg|Top of stack, second partial box edge, above the puzzle box|0|White/light colored box edge with a circular icon, only corner visible",
"IMG_4529.jpeg|Very top of stack, box behind/above the numbered fragments, purple/dark spine||Dark purple or navy spine, mostly obscured, edge of frame",
"IMG_4529.jpeg|Very top of stack, partial box edge visible above the puzzle box|3|Dark colored spine/edge, only a sliver visible, number '3' printed in white",
"IMG_4532.jpeg|Top right corner of image, partially cut off, above and behind the Gloomhaven box|GLO... (possibly part of another title starting with GLO)|Dark box with fiery orange/red background and large white curved text, only edge visible",
"IMG_4542.jpeg|Right side shelf, below Catan box, next to unidentified black-spined game|T...C...|Teal/orange box with train-like graphic, partially obscured, could be a train-themed game",
"IMG_4542.jpeg|Right side shelf, lower row near 'Catan' box||Dark spine with red accent, text too small/blurry to read",
"IMG_4542.jpeg|Top right shelf, partially hidden behind Dragon Land box|From the Makers of, M|Red box with a large stylized 'M' logo and small icons, likely a Mattel-published card game (possibly UNO), edge cut off by frame",
"IMG_4543.jpeg|right edge of image, behind Scrabble Slam box|CAT|Yellow/orange box edge visible, partial text 'CAT' in bold letters, rest obscured by foreground box",
"IMG_4543.jpeg|top right corner of image, above and behind the main Scrabble Slam box|Dogs Kitchen|Small blue/purple box partially cut off at top edge, illustrated cartoon-style artwork",
"IMG_4544.jpeg|Background shelf behind the Cat Stax box, blurred and out of focus, appears to be a row of game/book boxes||Multiple colorful spines/boxes including orange, white, and dark tones, too blurry to make out any text",
"IMG_4545.jpeg|Top shelf, far right edge, partially cut off by frame border|Ravens...|Yellow/orange box with small logo resembling a bird, likely Ravensburger branding but game title not legible",
"IMG_4545.jpeg|Top shelf, upper right corner, above and behind the Pocket Farkel tin, to the right of 'Bugs in the Kitchen'|DRAGON, land|Colorful fantasy-style box art, orange/red hues, dragon imagery visible, title cut off at top edge of frame",
"IMG_4547.jpeg|Top area, right of the maze-like box, small orange/red box||Small red/orange box, text illegible, largely obscured",
"IMG_4547.jpeg|Top left corner of image, partially visible behind main box|AZE|Blue and green box, appears to be a puzzle or maze-themed game, mostly cut off by frame edge",
"IMG_4547.jpeg|Top right corner of image|Dark T...|Dark blue/black box with white text, appears to be 'Dark T' something, possibly 'Dark Tower' or similar, cut off at frame edge",
"IMG_4547.jpeg|Top right, near 'Dark T' box||Colorful box with red/orange tones, illustration unclear, partially obscured by other boxes",
"IMG_4552.jpeg|Top left corner of frame, behind and above the Dark Cults package, no clearly identified games adjacent due to cropping|'Giv...'|Dark colored box with white/red text fragment, likely a card or board game box mostly out of frame",
"IMG_4552.jpeg|Top right edge of frame, partially obscured behind the plastic-wrapped Dark Cults package; no identifiable neighboring games visible due to cropping|'...ard Game', '-6 Players', 'Yeah, Right.'|White box with black text, appears to be a separate board game box edge cut off by frame, cannot determine title",
"IMG_4553.jpeg|Top right corner of image, partially visible behind/beside the main game, edge of frame||White box with a black silhouette figure, appears to be a card or board game box edge, mostly cut off by frame",
"IMG_4555.jpeg|top left corner of image, above the Kill Doctor Lucky box||Colorful box top edge visible, mostly cropped out of frame",
"IMG_4555.jpeg|top right corner of image, partially visible behind and above the main game box|Z-WAR (or similar, partially cut off)|Dark spine, appears to be a book or game box, mostly obscured by hand holding main item",
"IMG_4556.jpeg|Bottom of frame, left of 'Utter nonsense' box, partially stacked papers/boxes||White/light colored edge visible, mostly obscured by foreground objects",
"IMG_4556.jpeg|Bottom right corner, right of the 'Cards Against' box||Purple/violet box, partially visible, text not legible",
"IMG_4556.jpeg|Far right edge of shelf, beside the 'SANTO' box||Colorful patterned box (patchwork-like quilt design), largely cut off at right frame edge",
"IMG_4556.jpeg|Left side, lower area, below the Ravensburger-labeled box, left of Utter Nonsense box||Green box with dinosaur/reptile illustration, mostly cut off at left frame edge",
"IMG_4556.jpeg|Left side, second row, to the left of the green dinosaur-themed box and below the dark unmarked box|Ravensbu...|Blue box with white text, appears to be a puzzle or game brand box, partially cut off at left edge",
"IMG_4556.jpeg|Top left of shelf, above the 'Ravensburger'-labeled box, left of the dark unmarked box||Dark navy/black box, no legible text visible, top left corner of frame"
]
+3 -2
View File
@@ -60,9 +60,10 @@ class ReviewSession:
self.rows = read_matches(cfg.matches_path)
self.decisions = 0
try:
self._titles = {e.title_raw: e for e in load_titles(cfg.titles_path)}
self.titles = load_titles(cfg.titles_path)
except FileNotFoundError:
self._titles = {}
self.titles = []
self._titles = {e.title_raw: e for e in self.titles}
# -- plumbing -------------------------------------------------------
+46 -3
View File
@@ -166,6 +166,23 @@
background: var(--felt); color: var(--paper);
padding: .3rem .8rem; border-radius: 6px;
}
.done .waiting { color: var(--brass-deep); font-weight: 600; }
/* catalog: read-only status ledger, quieter than decision cards */
.catalog { background: var(--paper); border-radius: 8px; padding: .4rem 1rem; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.catalog table { width: 100%; border-collapse: collapse; font-size: .85rem; }
.catalog td { padding: .3rem .5rem; border-top: 1px solid var(--paper-edge); vertical-align: top; }
.catalog tr:first-child td { border-top: none; }
.catalog .t { font-weight: 600; }
.catalog .meta { color: var(--ink-soft); }
.chip {
font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
border-radius: 999px; padding: .1rem .55rem; white-space: nowrap;
}
.chip.ok { background: #dfe9df; color: var(--approve); }
.chip.wait { background: #f3e7cd; color: var(--brass-deep); }
.chip.no { background: #f0ddd8; color: var(--reject); }
.chip.open { background: #e4e4ef; color: #4c4c78; }
@media (max-width: 700px) {
.card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; }
@@ -311,20 +328,29 @@ function render() {
`<span><b>${s.pending.length}</b> matches</span>
<span><b>${s.versions.length}</b> editions</span>
<span><b>${s.unidentified.length}</b> reshoot</span>
${s.summary.unresolved ? `<span><b>${s.summary.unresolved}</b> awaiting resolve</span>` : ""}
<span>${s.decisions} decided this sitting</span>`;
let html = "";
if (!s.pending.length && !s.versions.length) {
const waiting = s.summary.unresolved;
html += `
<div class="done">
<h2>All reviewed — this catalog is diff-ready</h2>
<h2>${waiting
? "Resolved set fully reviewed"
: "All reviewed — this catalog is diff-ready"}</h2>
<div class="nums">
<div>${s.summary.extracted}<span>extracted</span></div>
<div>${s.summary.recognized}<span>recognized</span></div>
<div>${s.summary.version_updates}<span>with versions</span></div>
<div>${s.summary.rejected}<span>rejected</span></div>
</div>
<p>Next: <code>uv run bggpipe diff</code></p>
${s.unidentified.length ? `<p class="status">${s.unidentified.length} reshoot ticket(s) below — they don't block the diff.</p>` : ""}
${waiting
? `<p class="waiting">${waiting} title(s) extracted but not yet matched to BGG —
they're waiting on the API token. When it arrives:
<code>uv run bggpipe resolve</code>, then review the new arrivals here.</p>`
: `<p>Next: <code>uv run bggpipe diff</code></p>`}
${s.unidentified.length ? `<p class="status">${s.unidentified.length} reshoot ticket(s) below — they don't block anything.</p>` : ""}
</div>`;
}
if (s.pending.length) {
@@ -339,6 +365,23 @@ function render() {
html += `<h2>Reshoot <span class="count">— boxes seen but not identified</span></h2>`;
html += s.unidentified.map(ticket).join("");
}
if (s.catalog.length) {
const chip = c => {
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${c.status}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
return `<span class="chip open">${esc(c.status)}</span>`;
};
html += `<h2>Catalog <span class="count">— every title extracted so far (${s.catalog.length})</span></h2>
<div class="catalog"><table>` + s.catalog.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}</td>
<td>${chip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
<td class="meta">${esc(c.photos.join(", "))}</td>
</tr>`).join("") + `</table></div>`;
}
m.innerHTML = html;
const cards = actionables();
+24
View File
@@ -152,6 +152,27 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
counts: dict[str, int] = {}
for row in session.rows:
counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1
resolved_titles = {r["title_raw"] for r in session.rows}
rows_by_title: dict[str, dict] = {}
for r in session.rows:
rows_by_title.setdefault(r["title_raw"], r)
catalog = []
for entry in session.titles:
row = rows_by_title.get(entry.title_raw)
catalog.append(
{
"title_raw": entry.title_raw,
"confidence": entry.confidence,
"photos": list(entry.source_photos),
"status": row["match_status"] if row else "awaiting_resolve",
"bgg_id": row["bgg_id"] if row else "",
"bgg_name": row["bgg_name"] if row else "",
"version_name": row["version_name"] if row else "",
}
)
unresolved_count = sum(
1 for e in session.titles if e.title_raw not in resolved_titles
)
version_updates = sum(
1
for r in session.rows
@@ -172,6 +193,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
"pending": [row_payload(r) for r in session.pending_rows()],
"versions": [version_payload(r) for r in session.version_rows()],
"unidentified": sightings,
"catalog": catalog,
"decisions": session.decisions,
"summary": {
"recognized": counts.get("auto", 0) + counts.get("approved", 0),
@@ -180,6 +202,8 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
"rejected": counts.get("rejected", 0),
"version_updates": version_updates,
"total": len(session.rows),
"extracted": len(session.titles),
"unresolved": unresolved_count,
},
}
+19 -1
View File
@@ -105,7 +105,13 @@ def make_cfg(tmp_path) -> Config:
"publisher_hint": "Fantasy Flight",
"edition_hint": "",
"source_photos": ["shelf.jpg"],
}
},
{
# extracted but never resolved (no BGG token yet)
"title_raw": "Fresh Off The Shelf",
"confidence": "high",
"source_photos": ["shelf.jpg"],
},
]
)
)
@@ -268,3 +274,15 @@ def test_thumbnails_come_from_cached_thing_xml(tmp_path):
)
thumbs = load_thumbnails(cache)
assert thumbs == {478: "https://cf.example/citadels.jpg"}
def test_catalog_and_unresolved_backlog_are_visible(tmp_path):
"""Extracted-but-unresolved titles must not vanish from the UI, and the
done state must not claim diff-readiness while they exist."""
web, _ = make_client(tmp_path)
state = web.get("/api/state").json()
assert state["summary"]["extracted"] == 2
assert state["summary"]["unresolved"] == 1
by_title = {c["title_raw"]: c for c in state["catalog"]}
assert by_title["Fresh Off The Shelf"]["status"] == "awaiting_resolve"
assert by_title["Citadels"]["status"] == "ambiguous"