diff --git a/CLAUDE.md b/CLAUDE.md
index 97050a6..2288b26 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,11 +50,11 @@ Full design lives in `docs/spec.md` (read it before changing pipeline semantics)
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
-- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), and `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
+- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich), and `data/furniture.json` + `data/locations.json` (the owner's shelf layout and each game's physical spot — local-only, never BGG's business) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). RPGGeek items carry their OWN link types (`rpgdesigner`, `rpgpublisher`, `rpggenre`, `rpgcategory`, `rpgmechanic`) — a board-game-only parser silently returns nothing for them. Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
## Git
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub** — `gh` CLI does not work here.
-- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
+- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/furniture.json`, `data/locations.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
diff --git a/docs/guide.md b/docs/guide.md
index 185027d..fcb6469 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -144,11 +144,11 @@ BGG's `0` means "never entered", not zero inches). Then:
bggpipe dims
```
-reports coverage, the ten biggest footprints, and a fit check against an IKEA
-Kallax cube (13.2" × 13.2" opening, 15.4" deep) — listing every box that fits
-in **no** orientation, and every game whose dimensions can't be verified,
-because can't-verify is not the same as fits. Trusted dimensions also show on
-each game's Library detail page.
+reports coverage, the ten biggest footprints, and per-opening fit checks
+against your own furniture (see *Where everything lives*) — capacity,
+overfull warnings, boxes that fit in **no** orientation, and every game whose
+dimensions can't be verified, because can't-verify is not the same as fits.
+Trusted dimensions also show on each game's Library detail page.
## Publishing your library on your own site
diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py
index ef99312..6796b6e 100644
--- a/src/bggpipe/cli.py
+++ b/src/bggpipe/cli.py
@@ -212,7 +212,7 @@ def shelve(
@app.command()
def dims(config: ConfigOpt = None) -> None:
- """Shelf-space report: box sizes, biggest footprints, Kallax fit."""
+ """Shelf-space report: box sizes, biggest footprints, per-opening fit."""
from bggpipe.dims import run_dims_report
cfg = load_config(config)
diff --git a/src/bggpipe/dims.py b/src/bggpipe/dims.py
index 12cb61f..21435fc 100644
--- a/src/bggpipe/dims.py
+++ b/src/bggpipe/dims.py
@@ -14,6 +14,7 @@ import json
import typer
from bggpipe.config import Config
+from bggpipe.models import DIM_AXES
from bggpipe.shelves import (
load_furniture,
load_locations,
@@ -21,7 +22,7 @@ from bggpipe.shelves import (
opening_report,
)
-_AXES = ("width_in", "length_in", "depth_in")
+_AXES = DIM_AXES
def _footprint(dims: dict) -> float:
@@ -106,10 +107,11 @@ def run_dims_report(cfg: Config) -> dict:
typer.echo("\nOpenings:")
for oid, opening in openings.items():
report = opening_report(opening, by_opening.get(oid, []))
+ sized = all(opening.get(k) for k in ("width_in", "height_in", "depth_in"))
dims_note = (
f"{opening['width_in']:g}×{opening['height_in']:g}"
f"×{opening['depth_in']:g} in"
- if opening.get("width_in")
+ if sized
else "no size limits"
)
line = (
@@ -130,7 +132,9 @@ def run_dims_report(cfg: Config) -> dict:
entry.get("name") or "?"
for key, entry in games.items()
if not entry.get("stored_in")
- and not (locations.get(key) or {}).get("opening_id")
+ # an assignment to a vanished opening is homeless too — a
+ # truthy id must not hide a game from the count
+ and (locations.get(key) or {}).get("opening_id") not in openings
]
if homeless:
typer.echo(f"\n{len(homeless)} game(s) unshelved.")
diff --git a/src/bggpipe/enrich.py b/src/bggpipe/enrich.py
index 6f7acbe..c1ef073 100644
--- a/src/bggpipe/enrich.py
+++ b/src/bggpipe/enrich.py
@@ -27,13 +27,18 @@ from bggpipe.bgg_client import (
)
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
-from bggpipe.models import is_confident_version, is_recognized, parse_version_dims
+from bggpipe.models import (
+ DIM_AXES,
+ is_confident_version,
+ is_recognized,
+ parse_version_dims,
+)
from bggpipe.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches
BATCH_SIZE = 20
-_AXES = ("width_in", "length_in", "depth_in")
+_AXES = DIM_AXES
_AGREE_IN = 0.5 # per-axis tolerance for calling versionless dims unanimous
diff --git a/src/bggpipe/models.py b/src/bggpipe/models.py
index d41ad69..5a1777e 100644
--- a/src/bggpipe/models.py
+++ b/src/bggpipe/models.py
@@ -23,6 +23,11 @@ PENDING_MATCH_STATUSES = ("ambiguous", "unmatched")
UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged")
+# the write/read contract for entry["dims"]: enrich writes these keys,
+# shelves and dims consume them
+DIM_AXES = ("width_in", "length_in", "depth_in")
+
+
def is_recognized(row: dict) -> bool:
"""A row that reaches diff/enrich: matched and carrying a real id."""
return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"])
diff --git a/src/bggpipe/shelves.py b/src/bggpipe/shelves.py
index d872e51..25422c8 100644
--- a/src/bggpipe/shelves.py
+++ b/src/bggpipe/shelves.py
@@ -26,9 +26,10 @@ import typer
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
+from bggpipe.models import DIM_AXES
from bggpipe.normalize import normalize_title
-_AXES = ("width_in", "length_in", "depth_in")
+_AXES = DIM_AXES
_SLUG = re.compile(r"[^a-z0-9]+")
@@ -136,32 +137,49 @@ def effective_location(
key: str, entry: dict, games: dict[str, dict], locations: dict[str, dict]
) -> tuple[str | None, str, str | None]:
"""(opening_id, note, inherited_from_key). A contained game inherits
- its container's location; everything else reads locations.json."""
- container_id = entry.get("stored_in")
- if container_id:
- for other_key, other in games.items():
- if str(other.get("bgg_id")) == str(container_id):
- loc = locations.get(other_key, {})
- return loc.get("opening_id"), loc.get("note", ""), other_key
- return None, "", None
- loc = locations.get(key, {})
- return loc.get("opening_id"), loc.get("note", ""), None
+ its container's location through the WHOLE chain — minis inside an
+ insert inside a big box live wherever the big box does. A cycle (which
+ the endpoints refuse, but hand-edited stores can hold) terminates as
+ unlocated rather than looping."""
+ visited = {key}
+ via: str | None = None
+ current_key, current = key, entry
+ while current.get("stored_in"):
+ container = next(
+ (
+ (k, e)
+ for k, e in games.items()
+ if str(e.get("bgg_id")) == str(current["stored_in"])
+ ),
+ None,
+ )
+ if container is None or container[0] in visited:
+ return None, "", via
+ current_key, current = container
+ visited.add(current_key)
+ via = current_key
+ loc = locations.get(current_key, {})
+ return loc.get("opening_id"), loc.get("note", ""), via if via != key else None
def opening_report(opening: dict, games_here: list[tuple[str, dict]]) -> dict:
"""Capacity picture for one opening: fill (stacked thickness vs
- interior height), misfits, and how many boxes are unmeasured."""
- thicknesses = [stack_thickness(e) for _, e in games_here]
+ interior height), misfits, and how many boxes are unmeasured. Games
+ stored INSIDE another box are listed residents but occupy no shelf
+ space of their own — only their container is stacked and fit-checked,
+ the same stance the dims report takes."""
+ physical = [(k, e) for k, e in games_here if not e.get("stored_in")]
+ thicknesses = [stack_thickness(e) for _, e in physical]
stacked = sum(t for t in thicknesses if t)
unmeasured = sum(1 for t in thicknesses if t is None)
height = opening.get("height_in")
misfits = [
entry.get("name") or "?"
- for _, entry in games_here
+ for _, entry in physical
if fits_opening(entry, opening) is False
]
return {
- "games": len(games_here),
+ "games": len(physical),
"stacked_in": round(stacked, 2),
"unmeasured": unmeasured,
"fill": round(stacked / height, 2) if height and stacked else 0,
@@ -234,7 +252,7 @@ def import_assignments(cfg: Config, csv_path: Path) -> dict:
if len(set(oids)) > 1:
rejects.append(
f"{name}: opening label {opening_ref!r} is ambiguous — "
- "use the opening id or 'unit label'"
+ "use the opening id (see data/furniture.json)"
)
continue
oid = oids[0]
@@ -244,7 +262,9 @@ def import_assignments(cfg: Config, csv_path: Path) -> dict:
f"{openings[oid]['label']} in any orientation"
)
continue
- locations[key] = {"opening_id": oid, "note": ""}
+ # a re-imported plan must not erase a hand-entered note
+ note = (locations.get(key) or {}).get("note", "")
+ locations[key] = {"opening_id": oid, "note": note}
assigned += 1
save_locations(cfg, locations)
typer.echo(f"assigned {assigned} game(s); {len(rejects)} reject(s)")
diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css
index 696fb9b..52fa7f8 100644
--- a/src/bggpipe/static/app.css
+++ b/src/bggpipe/static/app.css
@@ -543,6 +543,10 @@ select {
#storedform select { flex: 1 1 auto; }
/* -- shelves: units as grids of openings, sheet for one opening ------- */
+/* .card is flex for the review pages' photo-beside-body layout; a shelf
+ card is a vertical STACK — without this, unit headers share a row with
+ their grid and unshelved rows flow horizontally off the page */
+.card.stack { display: block; }
.unit .unithead { display: flex; align-items: center; gap: .7rem;
justify-content: space-between; flex-wrap: wrap; }
.openings { display: grid; gap: .5rem;
@@ -560,7 +564,7 @@ button.opening:hover, button.opening:focus-visible {
.ocount { font-size: .82rem; color: var(--ink-soft); }
.fillbar { height: .45rem; background: var(--board-edge);
border-radius: 1rem; overflow: hidden; }
-.fillbar > div { height: 100%; background: var(--ok, #4a7c4e);
+.fillbar > div { height: 100%; background: var(--go, #2f8a45);
border-radius: 1rem; }
.fillbar > div.over { background: #b3372f; }
.unshelved-row { display: flex; gap: .6rem; align-items: center;
@@ -590,6 +594,13 @@ button.suggest:hover { border-color: var(--accent); }
.sheetrow img { width: 2.2rem; height: 2.2rem; object-fit: cover;
border-radius: .35rem; }
.sheetrow .unassign { margin-left: auto; }
+.addopeningform { margin-top: .6rem; }
+#locform:not([hidden]) { display: flex; gap: .5rem; align-items: center;
+ flex-wrap: wrap; }
+#locform select { max-width: 100%; flex: 1 1 14rem; }
+#locform input { flex: 1 1 10rem; font: inherit; font-size: .85rem;
+ border: 2px solid var(--board-edge); border-radius: var(--radius);
+ padding: .25rem .45rem; }
#sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit;
font-size: 1rem; padding: .55rem .7rem;
border: 2px solid var(--board-edge); border-radius: var(--radius); }
diff --git a/src/bggpipe/templates/pages/library.html b/src/bggpipe/templates/pages/library.html
index 2decf56..95e64cb 100644
--- a/src/bggpipe/templates/pages/library.html
+++ b/src/bggpipe/templates/pages/library.html
@@ -99,7 +99,9 @@ function render() {
].filter(Boolean).join(" ").toLowerCase().includes(q));
const where = document.getElementById("libwhere").value;
if (where === "unshelved")
- rows = rows.filter(g => !(g.location && (g.location.text || g.location.via)));
+ // same definition as the Shelves page: no opening and not riding a
+ // container — a note-only spot ("lent out") still counts as unshelved
+ rows = rows.filter(g => !(g.location && (g.location.unit || g.location.via)));
else if (where)
rows = rows.filter(g => g.location && g.location.unit === where);
if (seats) rows = rows.filter(g =>
diff --git a/src/bggpipe/templates/pages/shelves.html b/src/bggpipe/templates/pages/shelves.html
index b2026d5..4243eda 100644
--- a/src/bggpipe/templates/pages/shelves.html
+++ b/src/bggpipe/templates/pages/shelves.html
@@ -14,7 +14,7 @@
-
+
@@ -32,7 +32,7 @@