Audit round 9 lands: the location layer keeps its promises

Five blind reviewers over the day-old shelves layer, ~28 verified
findings — plus Eric's screenshot catching the biggest one live: the
generic .card is flex (built for review's photo-beside-ballot layout),
so unit headers shared a row with their grids and unshelved rows
flowed horizontally off the page. Shelf cards are now .card.stack.

The model fixes. Containment chains resolve recursively with a cycle
guard — minis inside an insert inside a big box live where the big box
does, instead of vanishing from every list; the stored-in endpoint
walks the whole chain when refusing cycles, and clears the newly
contained game's own shelf spot (one box must never consume capacity
in two openings). Contained games are listed residents but occupy no
shelf space: only physical boxes are stacked and fit-checked — the web
report now agrees with the dims report about the same opening. A
location pointing at a vanished opening (hand-edited or reverted
store) SURFACES as unshelved with a "shelf gone" chip in the app and
counts as homeless in the CLI, instead of hiding the game from every
list while the page declares everything has a home.

Honest edges. Opening dimensions are all-or-none everywhere (a
half-sized opening silently became limitless; the CLI report crashed
formatting it); a second grid on a unit continues the row letters so
labels stay unique and label-addressed CSV imports keep working, and
row letters survive past Z; CSV re-imports preserve hand-entered
notes; the ambiguity reject names the fix that actually works;
corrupt furniture/locations stores speak a 500 instead of a raw
traceback; the dims help text stops saying Kallax; DIM_AXES gets one
home in models.py instead of three drifting copies; the new stores
join CLAUDE.md's commit registry.

The page behaves. Custom-dims fields hide unless the custom preset is
chosen (typed values were silently discarded); the sheet is a real
dialog (role, aria-modal, Escape, focus return, one layer at a time);
backdrop close requires press AND release on the backdrop (a text-
selection drag out of the search box no longer dismisses); refresh
goes through changeGate and stops wiping the search mid-interaction;
the prompt() chain is an inline per-unit form with client-side
all-or-none validation; unit-create only toasts success after the
openings actually land, recovers from its own half-failures, and
guards against double-submit (a click retried against the re-rendering
DOM built a second grid — caught live in a Playwright run); warnings
speak (aria-labels on ⚠ and overfull in the fill bar's label);
unmeasured boxes are visible in cells and sheet rows; the library's
unshelved filter matches the Shelves page's definition; reorder
buttons have names; the detail locform wraps at phone width.

Eight new regression tests from the seats' sketches; 380 total.

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-09 14:02:00 -04:00
co-authored by Claude Fable 5
parent 4f446f6f2a
commit 6ab4a836b4
12 changed files with 549 additions and 88 deletions
+2 -2
View File
@@ -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). - 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. - 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. - 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). - **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. - 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 ## Git
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub**`gh` CLI does not work here. - 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`.
+5 -5
View File
@@ -144,11 +144,11 @@ BGG's `0` means "never entered", not zero inches). Then:
bggpipe dims bggpipe dims
``` ```
reports coverage, the ten biggest footprints, and a fit check against an IKEA reports coverage, the ten biggest footprints, and per-opening fit checks
Kallax cube (13.2" × 13.2" opening, 15.4" deep) — listing every box that fits against your own furniture (see *Where everything lives*) — capacity,
in **no** orientation, and every game whose dimensions can't be verified, overfull warnings, boxes that fit in **no** orientation, and every game whose
because can't-verify is not the same as fits. Trusted dimensions also show on dimensions can't be verified, because can't-verify is not the same as fits.
each game's Library detail page. Trusted dimensions also show on each game's Library detail page.
## Publishing your library on your own site ## Publishing your library on your own site
+1 -1
View File
@@ -212,7 +212,7 @@ def shelve(
@app.command() @app.command()
def dims(config: ConfigOpt = None) -> None: 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 from bggpipe.dims import run_dims_report
cfg = load_config(config) cfg = load_config(config)
+7 -3
View File
@@ -14,6 +14,7 @@ import json
import typer import typer
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.models import DIM_AXES
from bggpipe.shelves import ( from bggpipe.shelves import (
load_furniture, load_furniture,
load_locations, load_locations,
@@ -21,7 +22,7 @@ from bggpipe.shelves import (
opening_report, opening_report,
) )
_AXES = ("width_in", "length_in", "depth_in") _AXES = DIM_AXES
def _footprint(dims: dict) -> float: def _footprint(dims: dict) -> float:
@@ -106,10 +107,11 @@ def run_dims_report(cfg: Config) -> dict:
typer.echo("\nOpenings:") typer.echo("\nOpenings:")
for oid, opening in openings.items(): for oid, opening in openings.items():
report = opening_report(opening, by_opening.get(oid, [])) report = opening_report(opening, by_opening.get(oid, []))
sized = all(opening.get(k) for k in ("width_in", "height_in", "depth_in"))
dims_note = ( dims_note = (
f"{opening['width_in']:g}×{opening['height_in']:g}" f"{opening['width_in']:g}×{opening['height_in']:g}"
f"×{opening['depth_in']:g} in" f"×{opening['depth_in']:g} in"
if opening.get("width_in") if sized
else "no size limits" else "no size limits"
) )
line = ( line = (
@@ -130,7 +132,9 @@ def run_dims_report(cfg: Config) -> dict:
entry.get("name") or "?" entry.get("name") or "?"
for key, entry in games.items() for key, entry in games.items()
if not entry.get("stored_in") 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: if homeless:
typer.echo(f"\n{len(homeless)} game(s) unshelved.") typer.echo(f"\n{len(homeless)} game(s) unshelved.")
+7 -2
View File
@@ -27,13 +27,18 @@ from bggpipe.bgg_client import (
) )
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text 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.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches from bggpipe.resolve import load_titles, read_matches
BATCH_SIZE = 20 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 _AGREE_IN = 0.5 # per-axis tolerance for calling versionless dims unanimous
+5
View File
@@ -23,6 +23,11 @@ PENDING_MATCH_STATUSES = ("ambiguous", "unmatched")
UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged") 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: def is_recognized(row: dict) -> bool:
"""A row that reaches diff/enrich: matched and carrying a real id.""" """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"]) return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"])
+37 -17
View File
@@ -26,9 +26,10 @@ import typer
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text from bggpipe.fsio import atomic_write_text
from bggpipe.models import DIM_AXES
from bggpipe.normalize import normalize_title from bggpipe.normalize import normalize_title
_AXES = ("width_in", "length_in", "depth_in") _AXES = DIM_AXES
_SLUG = re.compile(r"[^a-z0-9]+") _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] key: str, entry: dict, games: dict[str, dict], locations: dict[str, dict]
) -> tuple[str | None, str, str | None]: ) -> tuple[str | None, str, str | None]:
"""(opening_id, note, inherited_from_key). A contained game inherits """(opening_id, note, inherited_from_key). A contained game inherits
its container's location; everything else reads locations.json.""" its container's location through the WHOLE chain — minis inside an
container_id = entry.get("stored_in") insert inside a big box live wherever the big box does. A cycle (which
if container_id: the endpoints refuse, but hand-edited stores can hold) terminates as
for other_key, other in games.items(): unlocated rather than looping."""
if str(other.get("bgg_id")) == str(container_id): visited = {key}
loc = locations.get(other_key, {}) via: str | None = None
return loc.get("opening_id"), loc.get("note", ""), other_key current_key, current = key, entry
return None, "", None while current.get("stored_in"):
loc = locations.get(key, {}) container = next(
return loc.get("opening_id"), loc.get("note", ""), None (
(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: def opening_report(opening: dict, games_here: list[tuple[str, dict]]) -> dict:
"""Capacity picture for one opening: fill (stacked thickness vs """Capacity picture for one opening: fill (stacked thickness vs
interior height), misfits, and how many boxes are unmeasured.""" interior height), misfits, and how many boxes are unmeasured. Games
thicknesses = [stack_thickness(e) for _, e in games_here] 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) stacked = sum(t for t in thicknesses if t)
unmeasured = sum(1 for t in thicknesses if t is None) unmeasured = sum(1 for t in thicknesses if t is None)
height = opening.get("height_in") height = opening.get("height_in")
misfits = [ misfits = [
entry.get("name") or "?" entry.get("name") or "?"
for _, entry in games_here for _, entry in physical
if fits_opening(entry, opening) is False if fits_opening(entry, opening) is False
] ]
return { return {
"games": len(games_here), "games": len(physical),
"stacked_in": round(stacked, 2), "stacked_in": round(stacked, 2),
"unmeasured": unmeasured, "unmeasured": unmeasured,
"fill": round(stacked / height, 2) if height and stacked else 0, "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: if len(set(oids)) > 1:
rejects.append( rejects.append(
f"{name}: opening label {opening_ref!r} is ambiguous — " 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 continue
oid = oids[0] oid = oids[0]
@@ -244,7 +262,9 @@ def import_assignments(cfg: Config, csv_path: Path) -> dict:
f"{openings[oid]['label']} in any orientation" f"{openings[oid]['label']} in any orientation"
) )
continue 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 assigned += 1
save_locations(cfg, locations) save_locations(cfg, locations)
typer.echo(f"assigned {assigned} game(s); {len(rejects)} reject(s)") typer.echo(f"assigned {assigned} game(s); {len(rejects)} reject(s)")
+12 -1
View File
@@ -543,6 +543,10 @@ select {
#storedform select { flex: 1 1 auto; } #storedform select { flex: 1 1 auto; }
/* -- shelves: units as grids of openings, sheet for one opening ------- */ /* -- 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; .unit .unithead { display: flex; align-items: center; gap: .7rem;
justify-content: space-between; flex-wrap: wrap; } justify-content: space-between; flex-wrap: wrap; }
.openings { display: grid; gap: .5rem; .openings { display: grid; gap: .5rem;
@@ -560,7 +564,7 @@ button.opening:hover, button.opening:focus-visible {
.ocount { font-size: .82rem; color: var(--ink-soft); } .ocount { font-size: .82rem; color: var(--ink-soft); }
.fillbar { height: .45rem; background: var(--board-edge); .fillbar { height: .45rem; background: var(--board-edge);
border-radius: 1rem; overflow: hidden; } border-radius: 1rem; overflow: hidden; }
.fillbar > div { height: 100%; background: var(--ok, #4a7c4e); .fillbar > div { height: 100%; background: var(--go, #2f8a45);
border-radius: 1rem; } border-radius: 1rem; }
.fillbar > div.over { background: #b3372f; } .fillbar > div.over { background: #b3372f; }
.unshelved-row { display: flex; gap: .6rem; align-items: center; .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; .sheetrow img { width: 2.2rem; height: 2.2rem; object-fit: cover;
border-radius: .35rem; } border-radius: .35rem; }
.sheetrow .unassign { margin-left: auto; } .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; #sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit;
font-size: 1rem; padding: .55rem .7rem; font-size: 1rem; padding: .55rem .7rem;
border: 2px solid var(--board-edge); border-radius: var(--radius); } border: 2px solid var(--board-edge); border-radius: var(--radius); }
+3 -1
View File
@@ -99,7 +99,9 @@ function render() {
].filter(Boolean).join(" ").toLowerCase().includes(q)); ].filter(Boolean).join(" ").toLowerCase().includes(q));
const where = document.getElementById("libwhere").value; const where = document.getElementById("libwhere").value;
if (where === "unshelved") 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) else if (where)
rows = rows.filter(g => g.location && g.location.unit === where); rows = rows.filter(g => g.location && g.location.unit === where);
if (seats) rows = rows.filter(g => if (seats) rows = rows.filter(g =>
+122 -42
View File
@@ -14,7 +14,7 @@
</select></label> </select></label>
<label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label> <label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
<label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label> <label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label>
<span id="customdims"> <span id="customdims" hidden>
<label>W <input name="width_in" size="5"></label> <label>W <input name="width_in" size="5"></label>
<label>H <input name="height_in" size="5"></label> <label>H <input name="height_in" size="5"></label>
<label>D <input name="depth_in" size="5"></label> <label>D <input name="depth_in" size="5"></label>
@@ -32,7 +32,7 @@
<div id="shelfbody"><p class="empty">Loading…</p></div> <div id="shelfbody"><p class="empty">Loading…</p></div>
<div id="opensheet" hidden> <div id="opensheet" hidden>
<div class="sheetcard"> <div class="sheetcard" role="dialog" aria-modal="true" aria-labelledby="sheettitle">
<div class="sheethead"> <div class="sheethead">
<b id="sheettitle"></b> <b id="sheettitle"></b>
<button id="sheetclose" aria-label="close"></button> <button id="sheetclose" aria-label="close"></button>
@@ -50,8 +50,8 @@
<label>D <input name="depth_in" size="5"></label> <label>D <input name="depth_in" size="5"></label>
<span class="editactions"> <span class="editactions">
<button type="submit" class="primary">save</button> <button type="submit" class="primary">save</button>
<button type="button" id="openingup"></button> <button type="button" id="openingup" aria-label="move opening earlier"></button>
<button type="button" id="openingdown"></button> <button type="button" id="openingdown" aria-label="move opening later"></button>
<button type="button" id="openingdelete" class="danger">delete opening</button> <button type="button" id="openingdelete" class="danger">delete opening</button>
</span> </span>
</form> </form>
@@ -75,32 +75,49 @@ function fillBar(o) {
if (!o.width_in) return `<span class="meta">no size limits</span>`; if (!o.width_in) return `<span class="meta">no size limits</span>`;
const pct = Math.min(100, Math.round((o.fill || 0) * 100)); const pct = Math.min(100, Math.round((o.fill || 0) * 100));
return `<div class="fillbar" role="img" return `<div class="fillbar" role="img"
aria-label="${pct}% full${o.unmeasured ? `, ${o.unmeasured} unmeasured` : ""}"> aria-label="${pct}% full${o.overfull ? ", overfull" : ""}${
o.unmeasured ? `, ${o.unmeasured} unmeasured boxes not counted` : ""}">
<div style="width:${pct}%" class="${o.overfull ? "over" : ""}"></div> <div style="width:${pct}%" class="${o.overfull ? "over" : ""}"></div>
</div>`; </div>`;
} }
function openingCell(o) { function openingCell(o) {
const warn = o.overfull || o.misfits.length const why = [
? `<span class="chip no" title="${esc(o.overfull ? "stacked past the interior height" : "") o.overfull ? "stacked past the interior height" : "",
}${esc(o.misfits.length ? " doesn't fit: " + o.misfits.join(", ") : "")}">⚠</span>` o.misfits.length ? "doesn't fit: " + o.misfits.join(", ") : "",
].filter(Boolean).join("; ");
const warn = why
? `<span class="chip no" role="img" aria-label="${esc(why)}" title="${esc(why)}">⚠</span>`
: ""; : "";
const count = o.games
? `${o.games}${o.unmeasured ? ` · ${o.unmeasured} unmeasured` : ""}`
: "—";
return `<button class="opening" data-id="${esc(o.id)}"> return `<button class="opening" data-id="${esc(o.id)}">
<span class="olabel">${esc(o.label)}${warn}</span> <span class="olabel">${esc(o.label)}${warn}</span>
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""} ${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
<span class="ocount">${o.games || "—"}</span> <span class="ocount">${count}</span>
${fillBar(o)} ${fillBar(o)}
</button>`; </button>`;
} }
function unitBlock(u) { function unitBlock(u) {
const cols = Math.max(1, Math.round(Math.sqrt(u.openings.length))); const cols = Math.max(1, Math.round(Math.sqrt(u.openings.length)));
return `<section class="card unit"> return `<section class="card stack unit">
<div class="unithead"><h2>${esc(u.name)}</h2> <div class="unithead"><h2>${esc(u.name)}</h2>
<span class="editactions"> <span class="editactions">
<button class="addopening" data-unit="${esc(u.name)}">add opening</button> <button class="addopening" data-unit="${esc(u.name)}"
aria-expanded="false">add opening</button>
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button> <button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div> </span></div>
<form class="editform addopeningform" data-unit="${esc(u.name)}" hidden>
<label>Label <input name="label" required placeholder="wide top"></label>
<label>Zone <input name="zone"></label>
<label>W <input name="width_in" size="5"></label>
<label>H <input name="height_in" size="5"></label>
<label>D <input name="depth_in" size="5"></label>
<span class="editactions"><button type="submit" class="primary">add</button></span>
<span class="edithint">all three sizes, or none for a no-limit spot</span>
</form>
<div class="openings">${u.openings.map(openingCell).join("")}</div> <div class="openings">${u.openings.map(openingCell).join("")}</div>
</section>`; </section>`;
} }
@@ -114,6 +131,8 @@ function unshelvedRow(g) {
}).join(""); }).join("");
return `<div class="unshelved-row"> return `<div class="unshelved-row">
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims} <a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
${g.lost_home ? `<span class="chip no"
title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""}
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""} ${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
<span class="gos">${gos}</span> <span class="gos">${gos}</span>
</div>`; </div>`;
@@ -134,7 +153,7 @@ function render() {
} }
body.innerHTML = SHELVES.units.map(unitBlock).join("") + body.innerHTML = SHELVES.units.map(unitBlock).join("") +
(SHELVES.unshelved.length (SHELVES.unshelved.length
? `<section class="card"><h2>Unshelved</h2>` + ? `<section class="card stack"><h2>Unshelved</h2>` +
SHELVES.unshelved.map(unshelvedRow).join("") + `</section>` SHELVES.unshelved.map(unshelvedRow).join("") + `</section>`
: `<p class="empty">Everything has a home. 🎲</p>`); : `<p class="empty">Everything has a home. 🎲</p>`);
wire(); wire();
@@ -151,6 +170,8 @@ function sheetRender() {
<div class="sheetrow"> <div class="sheetrow">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""} ${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a> <a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
${g.measured ? "" : `<span class="chip open"
title="no trusted size — not counted in the fill bar">unmeasured</span>`}
${g.inherited ${g.inherited
? `<span class="meta">inside another box here</span>` ? `<span class="meta">inside another box here</span>`
: `<button class="unassign" data-key="${esc(g.key)}">remove</button>`} : `<button class="unassign" data-key="${esc(g.key)}">remove</button>`}
@@ -162,22 +183,42 @@ function sheetRender() {
f.elements.width_in.value = o.width_in ?? ""; f.elements.width_in.value = o.width_in ?? "";
f.elements.height_in.value = o.height_in ?? ""; f.elements.height_in.value = o.height_in ?? "";
f.elements.depth_in.value = o.depth_in ?? ""; f.elements.depth_in.value = o.depth_in ?? "";
document.getElementById("sheetsearch").value = "";
document.getElementById("sheetmatches").innerHTML = "";
} }
let SHEET_OPENER = null; // the button to hand focus back to on close
function openSheet(id) { function openSheet(id) {
OPEN = id; OPEN = id;
SHEET_OPENER = document.activeElement;
document.getElementById("unitform").hidden = true; // one layer at a time
document.querySelectorAll(".addopeningform").forEach(f => f.hidden = true);
document.getElementById("sheetsearch").value = "";
document.getElementById("sheetmatches").innerHTML = "";
document.getElementById("opensheet").hidden = false; document.getElementById("opensheet").hidden = false;
sheetRender(); sheetRender();
document.getElementById("sheetsearch").focus(); document.getElementById("sheetsearch").focus();
} }
function closeSheet() {
document.getElementById("opensheet").hidden = true;
OPEN = null;
if (SHEET_OPENER && document.contains(SHEET_OPENER)) SHEET_OPENER.focus();
SHEET_OPENER = null;
}
const GATE = changeGate();
async function refresh() { async function refresh() {
SHELVES = await fetchJSON("/api/shelves"); // the library refreshes too: a game enriched mid-session must be
if (!LIBRARY.length) LIBRARY = await fetchJSON("/api/library"); // findable in the sheet's search without a page reload
render(); const [shelves, library] = await Promise.all([
if (OPEN) sheetRender(); fetchJSON("/api/shelves"), fetchJSON("/api/library"),
]);
GATE({shelves, library}, () => {
SHELVES = shelves;
LIBRARY = library;
render();
if (OPEN) sheetRender();
});
} }
async function post(url, body) { async function post(url, body) {
@@ -202,29 +243,45 @@ function wire() {
}); });
document.querySelectorAll(".addopening").forEach(b => document.querySelectorAll(".addopening").forEach(b =>
b.onclick = () => { b.onclick = () => {
const label = prompt("Label for the new opening (e.g. wide top left):"); const form = document.querySelector(
if (!label) return; `.addopeningform[data-unit="${CSS.escape(b.dataset.unit)}"]`);
const w = prompt("Interior width in inches (blank = no size limits):"); form.hidden = !form.hidden;
const h = w ? prompt("Interior height in inches:") : ""; b.setAttribute("aria-expanded", String(!form.hidden));
const d = w ? prompt("Interior depth in inches:") : ""; if (!form.hidden) form.elements.label.focus();
post("/api/furniture/add-openings", { });
unit: b.dataset.unit, label, document.querySelectorAll(".addopeningform").forEach(form =>
width_in: parseFloat(w) || null, height_in: parseFloat(h) || null, form.onsubmit = async e => {
depth_in: parseFloat(d) || null, e.preventDefault();
const f = form.elements;
const dims = [f.width_in.value, f.height_in.value, f.depth_in.value];
const given = dims.filter(v => v.trim()).length;
if (given && given < 3) {
alert("Give all three interior dimensions, or none for a no-limit spot.");
return;
}
await post("/api/furniture/add-openings", {
unit: form.dataset.unit, label: f.label.value, zone: f.zone.value,
width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null,
}); });
}); });
} }
// -- the sheet ---------------------------------------------------------- // -- the sheet ----------------------------------------------------------
document.getElementById("sheetclose").addEventListener("click", () => { document.getElementById("sheetclose").addEventListener("click", closeSheet);
document.getElementById("opensheet").hidden = true; document.addEventListener("keydown", e => {
OPEN = null; if (e.key === "Escape" && OPEN) closeSheet();
});
// close only when BOTH press and release land on the backdrop — a text-
// selection drag out of the search box must not dismiss the sheet
let downOnBackdrop = false;
document.getElementById("opensheet").addEventListener("pointerdown", e => {
downOnBackdrop = e.target.id === "opensheet";
}); });
document.getElementById("opensheet").addEventListener("click", e => { document.getElementById("opensheet").addEventListener("click", e => {
if (e.target.id === "opensheet") { if (downOnBackdrop && e.target.id === "opensheet") closeSheet();
document.getElementById("opensheet").hidden = true; downOnBackdrop = false;
OPEN = null;
}
}); });
document.getElementById("sheetgames").addEventListener("click", e => { document.getElementById("sheetgames").addEventListener("click", e => {
const b = e.target.closest(".unassign"); const b = e.target.closest(".unassign");
@@ -249,12 +306,17 @@ document.getElementById("sheetsearch").addEventListener("input", () => {
document.getElementById("openingform").addEventListener("submit", e => { document.getElementById("openingform").addEventListener("submit", e => {
e.preventDefault(); e.preventDefault();
const f = e.target.elements; const f = e.target.elements;
const given = [f.width_in.value, f.height_in.value, f.depth_in.value]
.filter(v => v.trim()).length;
if (given && given < 3) {
alert("Give all three interior dimensions, or none to remove size limits.");
return;
}
post("/api/furniture/edit-opening", { post("/api/furniture/edit-opening", {
id: OPEN, label: f.label.value, zone: f.zone.value, id: OPEN, label: f.label.value, zone: f.zone.value,
width_in: parseFloat(f.width_in.value) || null, width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null, height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null, depth_in: parseFloat(f.depth_in.value) || null,
clear_dims: !f.width_in.value,
}); });
}); });
document.getElementById("openingup").addEventListener("click", () => document.getElementById("openingup").addEventListener("click", () =>
@@ -264,8 +326,7 @@ document.getElementById("openingdown").addEventListener("click", () =>
document.getElementById("openingdelete").addEventListener("click", () => { document.getElementById("openingdelete").addEventListener("click", () => {
if (confirm("Delete this opening? Its games become unshelved.")) { if (confirm("Delete this opening? Its games become unshelved.")) {
const gone = OPEN; const gone = OPEN;
OPEN = null; closeSheet();
document.getElementById("opensheet").hidden = true;
post("/api/furniture/delete-opening", {id: gone}); post("/api/furniture/delete-opening", {id: gone});
} }
}); });
@@ -282,22 +343,31 @@ const addUnitBtn = document.getElementById("addunit");
addUnitBtn.addEventListener("click", () => { addUnitBtn.addEventListener("click", () => {
unitForm.hidden = !unitForm.hidden; unitForm.hidden = !unitForm.hidden;
addUnitBtn.setAttribute("aria-expanded", String(!unitForm.hidden)); addUnitBtn.setAttribute("aria-expanded", String(!unitForm.hidden));
syncDims();
if (!unitForm.hidden) unitForm.elements.name.focus(); if (!unitForm.hidden) unitForm.elements.name.focus();
}); });
document.getElementById("unitcancel").addEventListener("click", () => { document.getElementById("unitcancel").addEventListener("click", () => {
unitForm.hidden = true; unitForm.hidden = true;
unitForm.reset(); unitForm.reset();
syncDims();
}); });
unitForm.elements.preset.addEventListener("change", () => { function syncDims() {
const v = unitForm.elements.preset.value; const v = unitForm.elements.preset.value;
document.getElementById("customdims").hidden = v !== "custom"; document.getElementById("customdims").hidden = v !== "custom";
if (v === "virtual") { if (v === "virtual") {
unitForm.elements.rows.value = 1; unitForm.elements.rows.value = 1;
unitForm.elements.cols.value = 1; unitForm.elements.cols.value = 1;
} }
}); }
unitForm.elements.preset.addEventListener("change", syncDims);
unitForm.addEventListener("submit", async e => { unitForm.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
// a double-click (or a click retried against the re-rendering DOM)
// must not create a second grid: one submit in flight at a time
const createBtn = unitForm.querySelector('button[type="submit"]');
if (createBtn.disabled) return;
createBtn.disabled = true;
try {
const f = unitForm.elements; const f = unitForm.elements;
const preset = PRESETS[f.preset.value]; const preset = PRESETS[f.preset.value];
const dims = f.preset.value === "custom" const dims = f.preset.value === "custom"
@@ -305,17 +375,27 @@ unitForm.addEventListener("submit", async e => {
height_in: parseFloat(f.height_in.value) || null, height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null} depth_in: parseFloat(f.depth_in.value) || null}
: preset || {}; : preset || {};
const made = await post("/api/furniture/add-unit", {name: f.name.value}); // create-then-fill is two calls: an existing empty unit (a previous
if (!made) return; // half-failure) is fine to fill, so a 409 on step one does not stop us
await post("/api/furniture/add-openings", { const existing = SHELVES.units.find(u => u.name === f.name.value.trim());
if (!existing || existing.openings.length) {
const made = await post("/api/furniture/add-unit", {name: f.name.value});
if (!made && !existing) return;
}
const filled = await post("/api/furniture/add-openings", {
unit: f.name.value.trim(), zone: f.zone.value, unit: f.name.value.trim(), zone: f.zone.value,
rows: parseInt(f.rows.value) || 1, cols: parseInt(f.cols.value) || 1, rows: parseInt(f.rows.value) || 1, cols: parseInt(f.cols.value) || 1,
...dims, ...dims,
}); });
if (!filled) return; // the unit exists but empty: resubmit retries
showToast(`<b>${esc(f.name.value)}</b> is up — click any opening to fill it, showToast(`<b>${esc(f.name.value)}</b> is up — click any opening to fill it,
or add more openings for its other sections`); or add more openings for its other sections`);
unitForm.reset(); unitForm.reset();
syncDims();
unitForm.hidden = true; unitForm.hidden = true;
} finally {
createBtn.disabled = false;
}
}); });
refresh().catch(err => errorBanner(err.message || err)); refresh().catch(err => errorBanner(err.message || err));
+91 -14
View File
@@ -244,10 +244,10 @@ class OpeningEditBody(BaseModel):
id: str id: str
label: str | None = None label: str | None = None
zone: str | None = None zone: str | None = None
# all three or none: a partial size cannot exist (see the endpoint)
width_in: float | None = None width_in: float | None = None
height_in: float | None = None height_in: float | None = None
depth_in: float | None = None depth_in: float | None = None
clear_dims: bool = False # explicit: make it a virtual location
class OpeningMoveBody(BaseModel): class OpeningMoveBody(BaseModel):
@@ -1050,22 +1050,34 @@ def create_app(
# -- the physical-location layer ----------------------------------- # -- the physical-location layer -----------------------------------
def _shelf_stores():
"""Furniture + locations for endpoints: corrupt stores become a
spoken 500, not a raw traceback."""
try:
return load_furniture(cfg), load_locations(cfg)
except ValueError as err:
raise HTTPException(500, str(err)) from err
def _shelf_state() -> dict: def _shelf_state() -> dict:
"""Units with per-opening capacity + resident games, the unshelved """Units with per-opening capacity + resident games, the unshelved
list with fit-checked suggestions, and virtual locations.""" list with fit-checked suggestions, and virtual locations."""
games = read_games() games = read_games()
units = load_furniture(cfg) units, locations = _shelf_stores()
locations = load_locations(cfg)
openings = opening_index(units) openings = opening_index(units)
residents: dict[str, list[tuple[str, dict]]] = {} residents: dict[str, list[tuple[str, dict]]] = {}
located_keys: set[str] = set() located_keys: set[str] = set()
lost_home: set[str] = set()
for key, entry in games.items(): for key, entry in games.items():
oid, _, via = effective_location(key, entry, games, locations) oid, _, via = effective_location(key, entry, games, locations)
if oid and oid in openings: if oid and oid in openings:
residents.setdefault(oid, []).append((key, entry)) residents.setdefault(oid, []).append((key, entry))
located_keys.add(key) located_keys.add(key)
elif oid and via is None:
# assigned to an opening that no longer exists (a store
# edited or reverted by hand): SURFACE it, never hide it
lost_home.add(key)
elif via is not None: elif via is not None:
located_keys.add(key) # inherits; container is unshelved located_keys.add(key) # rides with its container
out_units = [] out_units = []
for unit in units: for unit in units:
out_openings = [] out_openings = []
@@ -1082,6 +1094,7 @@ def create_app(
"name": e.get("name"), "name": e.get("name"),
"thumbnail": e.get("thumbnail") or "", "thumbnail": e.get("thumbnail") or "",
"inherited": bool(e.get("stored_in")), "inherited": bool(e.get("stored_in")),
"measured": game_dims(e) is not None,
} }
for k, e in sorted( for k, e in sorted(
here, here,
@@ -1097,8 +1110,6 @@ def create_app(
): ):
if key in located_keys or entry.get("stored_in"): if key in located_keys or entry.get("stored_in"):
continue continue
if (locations.get(key) or {}).get("opening_id"):
continue # assigned to an opening that no longer exists? keep out
note = (locations.get(key) or {}).get("note", "") note = (locations.get(key) or {}).get("note", "")
suggestions = [ suggestions = [
oid oid
@@ -1114,6 +1125,7 @@ def create_app(
"thumbnail": entry.get("thumbnail") or "", "thumbnail": entry.get("thumbnail") or "",
"note": note, "note": note,
"measured": game_dims(entry) is not None, "measured": game_dims(entry) is not None,
"lost_home": key in lost_home,
"suggestions": suggestions, "suggestions": suggestions,
} }
) )
@@ -1126,9 +1138,13 @@ def create_app(
return _shelf_state() return _shelf_state()
def _mutate_furniture(fn) -> dict: def _mutate_furniture(fn) -> dict:
# no _refuse_if_rewriting here: furniture/locations aren't pipeline
# artifacts — an extract/resolve rewrite can't clobber them (the
# stored-in endpoint edits MATCH rows, hence its guard)
with lock: with lock:
revision["n"] += 1 revision["n"] += 1
units = load_furniture(cfg) freshen()
units, _ = _shelf_stores()
fn(units) fn(units)
save_furniture(cfg, units) save_furniture(cfg, units)
return _shelf_state() return _shelf_state()
@@ -1174,10 +1190,33 @@ def create_app(
"height_in": body.height_in or None, "height_in": body.height_in or None,
"depth_in": body.depth_in or None, "depth_in": body.depth_in or None,
} }
given = [v for v in dims.values() if v]
if given and len(given) < 3:
# a half-sized opening would silently become limitless:
# fit checks need all three axes, or none (virtual)
raise HTTPException(400, "give all three interior dimensions, or none")
if body.rows and body.cols: if body.rows and body.cols:
for r in range(body.rows): # a second grid on the same unit continues the row letters
# (A1 already exists -> next section starts at C, say):
# labels stay unique, so label-addressed CSV imports work
used = [
ord(o["label"][0]) - 65
for o in unit["openings"]
if len(o["label"]) >= 2
and o["label"][0].isalpha()
and o["label"][1:].isdigit()
]
start = max(used) + 1 if used else 0
for r in range(start, start + body.rows):
letters = ""
n = r
while True:
letters = chr(65 + n % 26) + letters
n = n // 26 - 1
if n < 0:
break
for c in range(body.cols): for c in range(body.cols):
label = f"{chr(65 + r)}{c + 1}" label = f"{letters}{c + 1}"
unit["openings"].append( unit["openings"].append(
{ {
"id": new_opening_id(units, unit["name"], label), "id": new_opening_id(units, unit["name"], label),
@@ -1212,13 +1251,21 @@ def create_app(
real["label"] = body.label.strip() or real["label"] real["label"] = body.label.strip() or real["label"]
if body.zone is not None: if body.zone is not None:
real["zone"] = body.zone.strip() real["zone"] = body.zone.strip()
if body.clear_dims: # dims are all-or-none: three values sets, three blanks makes
# it virtual, anything else is a 400 — a partial edit must
# never silently strip an opening of its size limits
dims = [body.width_in, body.height_in, body.depth_in]
given = [v for v in dims if v]
if len(given) == 3:
real.update(
width_in=body.width_in,
height_in=body.height_in,
depth_in=body.depth_in,
)
elif not given:
real.update(width_in=None, height_in=None, depth_in=None) real.update(width_in=None, height_in=None, depth_in=None)
else: else:
for field in ("width_in", "height_in", "depth_in"): raise HTTPException(400, "give all three interior dimensions, or none")
value = getattr(body, field)
if value is not None:
real[field] = value
return _mutate_furniture(apply) return _mutate_furniture(apply)
@@ -1321,6 +1368,29 @@ def create_app(
) )
if holder is None: if holder is None:
raise HTTPException(400, "the container must be a cataloged game") raise HTTPException(400, "the container must be a cataloged game")
# walk the WHOLE chain: A in B in C in A must refuse too
seen = {body.key}
probe = holder
while probe is not None:
probe_key = next((k for k, e in games.items() if e is probe), None)
if probe_key in seen:
raise HTTPException(
400, "that would make the boxes contain each other"
)
seen.add(probe_key)
inner = probe.get("stored_in")
probe = (
next(
(
e
for e in games.values()
if str(e.get("bgg_id")) == str(inner)
),
None,
)
if inner
else None
)
if str(holder.get("stored_in") or "") == str(entry.get("bgg_id")): if str(holder.get("stored_in") or "") == str(entry.get("bgg_id")):
raise HTTPException(400, "that box already lives inside THIS one") raise HTTPException(400, "that box already lives inside THIS one")
rows = read_matches(cfg.matches_path) rows = read_matches(cfg.matches_path)
@@ -1351,6 +1421,13 @@ def create_app(
cfg.games_path, cfg.games_path,
json.dumps(games, indent=2, ensure_ascii=False) + "\n", json.dumps(games, indent=2, ensure_ascii=False) + "\n",
) )
if container:
# it rides with its container now: a lingering shelf spot
# of its own would count its thickness in TWO openings
locations = load_locations(cfg)
if body.key in locations:
locations.pop(body.key)
save_locations(cfg, locations)
return {"stored_in": container or None} return {"stored_in": container or None}
@app.get("/api/library/{key:path}") @app.get("/api/library/{key:path}")
+257
View File
@@ -360,3 +360,260 @@ def test_shelves_page_serves_with_phone_sheet(tmp_path):
assert 'href="/shelves" aria-current="page"' in html assert 'href="/shelves" aria-current="page"' in html
css = web.get("/static/app.css").text css = web.get("/static/app.css").text
assert "max-width: 900px" in css and ".sheetcard" in css assert "max-width: 900px" in css and ".sheetcard" in css
def test_stored_in_chain_resolves_to_the_outermost_box(tmp_path):
"""Minis inside an insert inside a big box live wherever the big box
does — a two-level chain must not vanish from the shelves page."""
games = {
"1": {
"bgg_id": 1,
"name": "Trove",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 4,
"source": "version",
},
},
"2": {"bgg_id": 2, "name": "Insert", "stored_in": "1"},
"3": {"bgg_id": 3, "name": "Minis", "stored_in": "2"},
}
web, cfg = _web(tmp_path, games)
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"label": "A1",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
web.post("/api/locate", json={"key": "1", "opening_id": opening})
state = web.get("/api/shelves").json()
resident = state["units"][0]["openings"][0]["resident_games"]
assert {g["name"] for g in resident} == {"Trove", "Insert", "Minis"}
# only the physical box is stacked and counted
assert state["units"][0]["openings"][0]["games"] == 1
assert state["units"][0]["openings"][0]["stacked_in"] == 4
assert state["unshelved"] == []
# deep cycles refuse: Trove into Minis would close the loop
res = web.post("/api/stored-in", json={"key": "1", "container": "3"})
assert res.status_code == 400
def test_stored_in_clears_the_games_own_shelf_spot(tmp_path):
"""Shelve X, then declare X lives inside B: X's own location record
must go — one box must never consume capacity in two openings."""
games = {
"1": {
"bgg_id": 1,
"name": "Big Box",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 4,
"source": "version",
},
},
"2": {
"bgg_id": 2,
"name": "Little Box",
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
},
}
web, cfg = _web(tmp_path, games)
from bggpipe.resolve import MATCH_COLUMNS, write_matches
write_matches(
cfg.matches_path,
[
{
**dict.fromkeys(MATCH_COLUMNS, ""),
"title_raw": name,
"bgg_id": str(i),
"bgg_name": name,
"match_status": "approved",
}
for i, name in ((1, "Big Box"), (2, "Little Box"))
],
)
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"rows": 1,
"cols": 2,
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
openings = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
web.post("/api/locate", json={"key": "1", "opening_id": openings[0]})
web.post("/api/locate", json={"key": "2", "opening_id": openings[1]})
assert (
web.post("/api/stored-in", json={"key": "2", "container": "1"}).status_code
== 200
)
locations = json.loads(cfg.locations_path.read_text())
assert "2" not in locations # its own spot is gone; it rides with Big Box
state = web.get("/api/shelves").json()
assert state["units"][0]["openings"][1]["games"] == 0
def test_delete_unit_clears_only_its_own_locations(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "A",
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
},
"2": {
"bgg_id": 2,
"name": "B",
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
},
}
web, cfg = _web(tmp_path, games)
for name in ("Den", "Loft"):
web.post("/api/furniture/add-unit", json={"name": name})
web.post(
"/api/furniture/add-openings",
json={
"unit": name,
"label": "A1",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
state = web.get("/api/shelves").json()
den = state["units"][0]["openings"][0]["id"]
loft = state["units"][1]["openings"][0]["id"]
web.post("/api/locate", json={"key": "1", "opening_id": den})
web.post("/api/locate", json={"key": "2", "opening_id": loft})
web.post("/api/furniture/delete-unit", json={"name": "Den"})
locations = json.loads(cfg.locations_path.read_text())
assert "1" not in locations and locations["2"]["opening_id"] == loft
state = web.get("/api/shelves").json()
assert [g["name"] for g in state["unshelved"]] == ["A"]
def test_locate_clearing_both_fields_deletes_the_record(tmp_path):
games = {"1": {"bgg_id": 1, "name": "A"}}
web, cfg = _web(tmp_path, games)
web.post("/api/locate", json={"key": "1", "note": "lent out"})
assert json.loads(cfg.locations_path.read_text())["1"]["note"] == "lent out"
web.post("/api/locate", json={"key": "1", "opening_id": "", "note": ""})
assert "1" not in json.loads(cfg.locations_path.read_text())
def test_ghost_opening_assignment_surfaces_as_unshelved(tmp_path):
"""A location pointing at a vanished opening (hand-edited or reverted
store) must SHOW — silently disappearing from every list is how a
game gets lost for real."""
from bggpipe.shelves import save_locations
games = {"1": {"bgg_id": 1, "name": "Ghosted"}}
web, cfg = _web(tmp_path, games)
save_locations(cfg, {"1": {"opening_id": "gone-a9", "note": ""}})
state = web.get("/api/shelves").json()
(row,) = state["unshelved"]
assert row["name"] == "Ghosted" and row["lost_home"] is True
def test_partial_dims_refuse_everywhere(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Den"})
res = web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"label": "half",
"width_in": 13.0,
},
)
assert res.status_code == 400
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"label": "whole",
"width_in": 13.0,
"height_in": 13.0,
"depth_in": 15.0,
},
)
oid = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
assert (
web.post(
"/api/furniture/edit-opening",
json={
"id": oid,
"height_in": 14.0,
},
).status_code
== 400
) # partial edit refused
# all three blanks makes it virtual, deliberately
web.post("/api/furniture/edit-opening", json={"id": oid})
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]
assert opening["width_in"] is None
def test_second_grid_continues_row_letters(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Wall"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 2,
"cols": 2,
"width_in": 13.0,
"height_in": 13.0,
"depth_in": 15.0,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 1,
"cols": 2,
"width_in": 26.5,
"height_in": 13.0,
"depth_in": 15.0,
},
)
labels = [
o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"]
]
assert labels == ["A1", "A2", "B1", "B2", "C1", "C2"]
assert len(set(labels)) == 6 # unique: label-addressed CSVs stay usable
def test_move_opening_boundaries_no_op(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"rows": 1,
"cols": 2,
"width_in": 13.0,
"height_in": 13.0,
"depth_in": 15.0,
},
)
ids = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
web.post("/api/furniture/move-opening", json={"id": ids[0], "direction": -1})
after = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
assert after == ids # first can't move earlier: quiet no-op