The acknowledged overhang: "doesn't fit, shelved anyway" goes legit

Eric's office towers are 9.5 inches deep; Etherfields boxes are 11.8
inches square. Spine-out they stand 2.3 inches proud of the shelf edge
— clearly livable, it's how they sit today — but the lane model will
honestly refuse forever: can't stand (11.8 > 9.5 depth), can't stack
flat (same), so the opening warns NO PACKING FITS. The first deliberate
misfit deserves first-class status.

An assignment can now carry acknowledged: true. An acknowledged box
takes the standing lane WITHOUT the height/depth check — the human has
seen the overhang — while the width budget stays honest (nine
acknowledged monoliths still overflow a 23.25-inch row). It drops out
of misfit/laneless warnings everywhere: the wall diagram, the sheet,
and bggpipe dims (which now says "overhangs, acknowledged" instead).

Ways in: an "accept overhang" button in the opening's sheet (appears
on any resident the lanes can't take; tap again to retract), or an
acknowledge column (yes) in shelve --import. Once acknowledged, later
imports needn't re-state it.

Re-import hardening, prompted by Eric's "will a new import make a
mess?": it's an upsert, not a reset — and now genuinely so. Previously
a re-import preserved notes but silently wiped lane overrides; now
notes, lanes, and acknowledgments all survive when the opening is
unchanged (a move resets lane and acknowledgment — new spot, new
verdict). Same fix in /api/locate: the lane-flip button used to wipe
the hand-entered note because LocateBody.note defaulted to "" —
it's now tri-state, None means leave it alone.

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 15:45:36 -04:00
co-authored by Claude Fable 5
parent 4ce7706521
commit 7d7d3bb8da
10 changed files with 291 additions and 37 deletions
+45 -13
View File
@@ -3,6 +3,14 @@
{ {
"name": "Library Kallax", "name": "Library Kallax",
"openings": [ "openings": [
{
"id": "library-kallax-top",
"label": "Top",
"zone": "",
"width_in": 53.0,
"height_in": 38.0,
"depth_in": 15.4
},
{ {
"id": "library-kallax-a1", "id": "library-kallax-a1",
"label": "A1", "label": "A1",
@@ -125,6 +133,14 @@
"name": "Library Right Shelving", "name": "Library Right Shelving",
"description": "To the right of the Kallax", "description": "To the right of the Kallax",
"openings": [ "openings": [
{
"id": "library-right-shelving-top",
"label": "Top",
"zone": "",
"width_in": 23.5,
"height_in": 25.0,
"depth_in": 9.5
},
{ {
"id": "library-right-shelving-a1", "id": "library-right-shelving-a1",
"label": "A1", "label": "A1",
@@ -138,39 +154,51 @@
"label": "B1", "label": "B1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 10.75,
"depth_in": 9.5 "depth_in": 9.5,
"description": ""
}, },
{ {
"id": "library-right-shelving-c1", "id": "library-right-shelving-c1",
"label": "C1", "label": "C1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 12.25,
"depth_in": 9.5 "depth_in": 9.5,
"description": ""
}, },
{ {
"id": "library-right-shelving-d1", "id": "library-right-shelving-d1",
"label": "D1", "label": "D1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 13.25,
"depth_in": 9.5 "depth_in": 9.5,
"description": ""
}, },
{ {
"id": "library-right-shelving-e1", "id": "library-right-shelving-e1",
"label": "E1", "label": "E1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 19.0,
"depth_in": 9.5 "depth_in": 9.5,
"description": ""
} }
] ]
}, },
{ {
"name": "Library Left Shelving", "name": "Library Left Shelving",
"description": "To the right of the Kallax", "description": "To the left of the Kallax",
"openings": [ "openings": [
{
"label": "Top",
"zone": "",
"width_in": 23.5,
"height_in": 25.0,
"depth_in": 9.5,
"id": "library-right-shelving-2-top"
},
{ {
"label": "A1", "label": "A1",
"zone": "", "zone": "",
@@ -183,32 +211,36 @@
"label": "B1", "label": "B1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 10.75,
"depth_in": 9.5, "depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-b1" "id": "library-right-shelving-2-b1"
}, },
{ {
"label": "C1", "label": "C1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 12.25,
"depth_in": 9.5, "depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-c1" "id": "library-right-shelving-2-c1"
}, },
{ {
"label": "D1", "label": "D1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 13.25,
"depth_in": 9.5, "depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-d1" "id": "library-right-shelving-2-d1"
}, },
{ {
"label": "E1", "label": "E1",
"zone": "", "zone": "",
"width_in": 23.25, "width_in": 23.25,
"height_in": 10.25, "height_in": 19.0,
"depth_in": 9.5, "depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-e1" "id": "library-right-shelving-2-e1"
} }
] ]
+11
View File
@@ -0,0 +1,11 @@
name,opening
Etherfields,library-right-shelving-e1
Etherfields: Kittenburg Expansion,library-right-shelving-e1
Etherfields: 5th Player Expansion,library-right-shelving-e1
Etherfields: Funeral Witch Campaign,library-right-shelving-e1
Etherfields: Sphinx Campaign,library-right-shelving-e1
Etherfields: Stretch Goals Harpy & She-Wolf Campaigns,library-right-shelving-e1
Alice is Missing,library-right-shelving-2-d1
Alice is Missing: Silent Falls,library-right-shelving-2-d1
Parsely,library-right-shelving-2-d1
Dark Cults,library-right-shelving-2-d1
1 name opening
2 Etherfields library-right-shelving-e1
3 Etherfields: Kittenburg Expansion library-right-shelving-e1
4 Etherfields: 5th Player Expansion library-right-shelving-e1
5 Etherfields: Funeral Witch Campaign library-right-shelving-e1
6 Etherfields: Sphinx Campaign library-right-shelving-e1
7 Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns library-right-shelving-e1
8 Alice is Missing library-right-shelving-2-d1
9 Alice is Missing: Silent Falls library-right-shelving-2-d1
10 Parsely library-right-shelving-2-d1
11 Dark Cults library-right-shelving-2-d1
+13 -2
View File
@@ -157,8 +157,19 @@ keeps the whole layer out of the export.
Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns
`name,opening`, accepting opening ids or labels) — rejects are reported `name,opening`, accepting opening ids or labels) — rejects are reported
(unknown name, ambiguous copies, unknown opening, doesn't fit, lives (unknown name, ambiguous copies, unknown opening, doesn't fit, lives
inside another box), never guessed, and re-imports preserve hand-entered inside another box), never guessed. Re-importing is an upsert, not a
notes. `bggpipe dims` reports per-opening capacity, overfull warnings, and reset: rows move their games, games absent from the plan keep their
spots, and hand-entered notes, lane choices, and acknowledgments survive
whenever the opening is unchanged.
Some placements are deliberate misfits — square boxes spine-out on a
shallow shelf, standing proud of the edge. The checker will honestly
refuse those forever, so accept the overhang on purpose: the **accept
overhang** button in the opening's view (it appears on any resident the
lanes can't take), or an `acknowledge` column (`yes`) in the import CSV.
An acknowledged box takes the standing lane without the depth check —
the width budget stays honest — and stops counting as a warning. Once
acknowledged, later imports needn't re-state it. `bggpipe dims` reports per-opening capacity, overfull warnings, and
misfits against your real furniture. Capacity uses a **two-lane packing misfits against your real furniture. Capacity uses a **two-lane packing
model**: each opening holds a flat stack (thinnest axes summing against model**: each opening holds a flat stack (thinnest axes summing against
interior height, claiming the widest flat box's width) and a standing lane interior height, claiming the widest flat box's width) and a standing lane
+12 -1
View File
@@ -98,6 +98,10 @@ def run_dims_report(cfg: Config) -> dict:
) )
else: else:
locations = load_locations(cfg) locations = load_locations(cfg)
lane_overrides = {
k: v.get("lane", "") for k, v in locations.items() if v.get("lane")
}
acked = {k for k, v in locations.items() if v.get("acknowledged")}
openings = opening_index(units) openings = opening_index(units)
by_opening: dict[str, list[tuple[str, dict]]] = {} by_opening: dict[str, list[tuple[str, dict]]] = {}
for key, entry in games.items(): for key, entry in games.items():
@@ -106,7 +110,9 @@ def run_dims_report(cfg: Config) -> dict:
by_opening.setdefault(oid, []).append((key, entry)) by_opening.setdefault(oid, []).append((key, entry))
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, []), lane_overrides, acked
)
sized = all(opening.get(k) for k in ("width_in", "height_in", "depth_in")) 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}"
@@ -134,6 +140,11 @@ def run_dims_report(cfg: Config) -> dict:
if name not in misfit_names: if name not in misfit_names:
misfit_names.append(name) misfit_names.append(name)
typer.echo(f" ⚠ doesn't fit this opening at all: {name}") typer.echo(f" ⚠ doesn't fit this opening at all: {name}")
for k, e in by_opening.get(oid, []):
if k in acked:
typer.echo(
f" overhangs, acknowledged: {e.get('name') or '?'}"
)
homeless = [ homeless = [
entry.get("name") or "?" entry.get("name") or "?"
for key, entry in games.items() for key, entry in games.items()
+59 -13
View File
@@ -207,7 +207,11 @@ def _stands(dims: tuple, opening: dict) -> bool:
) )
def lane_split(opening: dict, physical: list[tuple[str, dict, str]]) -> dict: def lane_split(
opening: dict,
physical: list[tuple[str, dict, str]],
acknowledged: set[str] | None = None,
) -> dict:
"""The two-lane packing model. Each opening holds a flat stack """The two-lane packing model. Each opening holds a flat stack
(thinnest axes summing against interior HEIGHT, claiming the widest (thinnest axes summing against interior HEIGHT, claiming the widest
flat box's width) and a standing lane beside it (boxes on edge, flat box's width) and a standing lane beside it (boxes on edge,
@@ -217,7 +221,13 @@ def lane_split(opening: dict, physical: list[tuple[str, dict, str]]) -> dict:
take is laneless; the opening warns only then. take is laneless; the opening warns only then.
physical: [(key, entry, override)] with override in "", "flat", physical: [(key, entry, override)] with override in "", "flat",
"standing". Unmeasured boxes take no lane but are counted.""" "standing". Unmeasured boxes take no lane but are counted.
An ACKNOWLEDGED key is a deliberate "doesn't fit, shelved anyway"
(a box standing proud of a shallow shelf): it takes the standing
lane without the height/depth check — the human has seen the
overhang — but the width budget stays honest."""
acknowledged = acknowledged or set()
height = opening.get("height_in") height = opening.get("height_in")
width = opening.get("width_in") width = opening.get("width_in")
lanes: dict[str, str] = {} lanes: dict[str, str] = {}
@@ -251,10 +261,12 @@ def lane_split(opening: dict, physical: list[tuple[str, dict, str]]) -> dict:
lanes[key] = "flat" lanes[key] = "flat"
return True return True
def try_standing(key, dims): def try_standing(key, dims, relaxed=False):
thickness = min(dims) thickness = min(dims)
used = sum(t for _, t in standing) used = sum(t for _, t in standing)
if not _stands(dims, opening) or used + thickness > width - flat_claim: if not (relaxed or _stands(dims, opening)):
return False
if used + thickness > width - flat_claim:
return False return False
standing.append((key, thickness)) standing.append((key, thickness))
lanes[key] = "standing" lanes[key] = "standing"
@@ -262,6 +274,11 @@ def lane_split(opening: dict, physical: list[tuple[str, dict, str]]) -> dict:
max_fp = footprint(measured[0][3]) if measured else 0 max_fp = footprint(measured[0][3]) if measured else 0
for key, entry, override, dims in measured: for key, entry, override, dims in measured:
if key in acknowledged:
# the overhang is accepted: standing lane, width-honest only
if not try_standing(key, dims, relaxed=True):
laneless.append(entry.get("name") or "?")
continue
if override == "flat": if override == "flat":
placed = try_flat(key, dims) placed = try_flat(key, dims)
elif override == "standing": elif override == "standing":
@@ -295,6 +312,7 @@ def opening_report(
opening: dict, opening: dict,
games_here: list[tuple[str, dict]], games_here: list[tuple[str, dict]],
overrides: dict[str, str] | None = None, overrides: dict[str, str] | None = None,
acknowledged: set[str] | None = None,
) -> dict: ) -> dict:
"""Capacity picture for one opening under the two-lane model. Games """Capacity picture for one opening under the two-lane model. Games
stored INSIDE another box are listed residents but occupy no shelf stored INSIDE another box are listed residents but occupy no shelf
@@ -320,11 +338,12 @@ def opening_report(
"misfits": [], "misfits": [],
"laneless": [], "laneless": [],
} }
split = lane_split(opening, physical) split = lane_split(opening, physical, acknowledged)
acknowledged = acknowledged or set()
misfits = [ misfits = [
entry.get("name") or "?" entry.get("name") or "?"
for _, entry, _o in physical for key, entry, _o in physical
if fits_opening(entry, opening) is False if fits_opening(entry, opening) is False and key not in acknowledged
] ]
standing_budget = max(width - split["flat_claim"], 0.01) standing_budget = max(width - split["flat_claim"], 0.01)
return { return {
@@ -352,7 +371,12 @@ def opening_report(
def import_assignments(cfg: Config, csv_path: Path) -> dict: def import_assignments(cfg: Config, csv_path: Path) -> dict:
"""Bulk-assign from a name,opening CSV (opening ids or labels). """Bulk-assign from a name,opening CSV (opening ids or labels).
Rejects are reported, never guessed: unknown name, ambiguous name, Rejects are reported, never guessed: unknown name, ambiguous name,
unknown/ambiguous opening, doesn't fit, or lives inside another box.""" unknown/ambiguous opening, doesn't fit, or lives inside another box.
An optional `acknowledge` column (yes/true/1) accepts a doesn't-fit
placement deliberately — a box standing proud of a shallow shelf.
Re-importing is an upsert: rows move their game, absent games keep
their spots, and notes / lane choices / acknowledgments survive
when the opening is unchanged."""
if not cfg.games_path.exists(): if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.") typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -414,15 +438,37 @@ def import_assignments(cfg: Config, csv_path: Path) -> dict:
) )
continue continue
oid = oids[0] oid = oids[0]
if fits_opening(games[key], openings[oid]) is False: acknowledge = (row.get("acknowledge") or "").strip().casefold() in (
"yes",
"true",
"1",
"x",
)
prev = locations.get(key) or {}
# an overhang acknowledged once (in the UI or a prior import)
# stays acknowledged — a plan file needn't re-state it
already = bool(prev.get("acknowledged")) and prev.get("opening_id") == oid
if fits_opening(games[key], openings[oid]) is False and not (
acknowledge or already
):
rejects.append( rejects.append(
f"{name}: doesn't fit {openings[oid]['unit']} " f"{name}: doesn't fit {openings[oid]['unit']} "
f"{openings[oid]['label']} in any orientation" f"{openings[oid]['label']} in any orientation — add an "
"acknowledge column with yes to shelve it anyway"
) )
continue continue
# a re-imported plan must not erase a hand-entered note # a re-imported plan must not erase hand-entered curation:
note = (locations.get(key) or {}).get("note", "") # the note always survives; the lane choice and a standing
locations[key] = {"opening_id": oid, "note": note} # acknowledgment survive as long as the opening is unchanged
record = {"opening_id": oid, "note": prev.get("note", "")}
if prev.get("opening_id") == oid:
if prev.get("lane"):
record["lane"] = prev["lane"]
if prev.get("acknowledged"):
record["acknowledged"] = True
if acknowledge:
record["acknowledged"] = True
locations[key] = record
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)")
+6
View File
@@ -590,6 +590,12 @@ button.opening:hover, button.opening:focus-visible {
border: 1px solid var(--board-edge); border-radius: 1rem; border: 1px solid var(--board-edge); border-radius: 1rem;
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); } background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
.lanebtn:hover { border-color: var(--accent); color: var(--accent); } .lanebtn:hover { border-color: var(--accent); color: var(--accent); }
.ackbtn { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px solid #b3372f; border-radius: 1rem;
background: #fff; padding: .05rem .5rem; color: #b3372f; }
.ackbtn.on { border-color: var(--board-edge); background: var(--felt-soft, #eef2ea);
color: var(--ink-soft); }
.ackbtn:hover { border-color: var(--accent); color: var(--accent); }
.unshelved-row { display: flex; gap: .6rem; align-items: center; .unshelved-row { display: flex; gap: .6rem; align-items: center;
flex-wrap: wrap; padding: .4rem 0; flex-wrap: wrap; padding: .4rem 0;
border-bottom: 1px solid var(--board-edge); } border-bottom: 1px solid var(--board-edge); }
+1 -1
View File
@@ -30,7 +30,7 @@
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p> <p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p> <p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p> <p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
<p><b><a href="/shelves">Shelves</a></b> — where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Capacity is a two-lane model — a flat stack against interior height plus a standing lane against the remaining width; the largest boxes lie flat, smaller ones stand, and each box's lane is flippable in the opening's view. ⚠ means <i>no packing fits</i>, not merely "tall stack"; games stored inside another box ride along with their container, through whole chains. Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening) and check capacity anytime with <code>bggpipe dims</code>.</p> <p><b><a href="/shelves">Shelves</a></b> — where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Capacity is a two-lane model — a flat stack against interior height plus a standing lane against the remaining width; the largest boxes lie flat, smaller ones stand, and each box's lane is flippable in the opening's view. ⚠ means <i>no packing fits</i>, not merely "tall stack" — and when the misfit is deliberate (boxes spine-out, proud of a shallow shelf) the <b>accept overhang</b> button in the opening's view accepts it: the box takes the standing lane, the width budget stays honest, and the warning goes quiet. Games stored inside another box ride along with their container, through whole chains. Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening, plus an optional acknowledge column for deliberate overhangs) — re-imports are upserts that keep notes, lanes, and acknowledgments — and check capacity anytime with <code>bggpipe dims</code>.</p>
<p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p> <p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
</div> </div>
+13 -1
View File
@@ -240,6 +240,14 @@ function sheetRender() {
title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}"> title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}">
${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.lane_override ? " ✱" : ""}</button>` ${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.lane_override ? " ✱" : ""}</button>`
: ""} : ""}
${(g.misfit || g.laneless || g.acknowledged) && !g.inherited
? (g.acknowledged
? `<button class="ackbtn on" data-key="${esc(g.key)}" data-ack="false"
title="you accepted this overhang — tap to retract">overhang ✓</button>`
: `<button class="ackbtn" data-key="${esc(g.key)}" data-ack="true"
title="doesn't fit by the numbers — accept it deliberately
(spine-out proud of the shelf edge) and it stops counting as a warning">accept overhang</button>`)
: ""}
${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>`}
@@ -391,8 +399,12 @@ document.getElementById("sheetgames").addEventListener("click", e => {
const b = e.target.closest(".unassign"); const b = e.target.closest(".unassign");
if (b) { post("/api/locate", {key: b.dataset.key, opening_id: ""}); return; } if (b) { post("/api/locate", {key: b.dataset.key, opening_id: ""}); return; }
const lane = e.target.closest(".lanebtn"); const lane = e.target.closest(".lanebtn");
if (lane) post("/api/locate", { if (lane) { post("/api/locate", {
key: lane.dataset.key, opening_id: OPEN, lane: lane.dataset.next, key: lane.dataset.key, opening_id: OPEN, lane: lane.dataset.next,
}); return; }
const ack = e.target.closest(".ackbtn");
if (ack) post("/api/acknowledge", {
key: ack.dataset.key, acknowledged: ack.dataset.ack === "true",
}); });
}); });
document.getElementById("sheetsearch").addEventListener("input", () => { document.getElementById("sheetsearch").addEventListener("input", () => {
+45 -6
View File
@@ -269,10 +269,15 @@ class OpeningDeleteBody(BaseModel):
class LocateBody(BaseModel): class LocateBody(BaseModel):
key: str key: str
opening_id: str = "" # "" = unshelve opening_id: str = "" # "" = unshelve
note: str = "" note: str | None = None # None = leave the note alone; "" = clear it
lane: str = "" # "", "flat", "standing": how THIS box sits in the opening lane: str = "" # "", "flat", "standing": how THIS box sits in the opening
class AcknowledgeBody(BaseModel):
key: str
acknowledged: bool # True = "doesn't fit, shelved anyway" is deliberate
class StoredInBody(BaseModel): class StoredInBody(BaseModel):
key: str # games.json key of the game being housed key: str # games.json key of the game being housed
container: str = "" # container's bgg_id; "" = its own box again container: str = "" # container's bgg_id; "" = its own box again
@@ -1103,13 +1108,14 @@ def create_app(
lane_overrides = { lane_overrides = {
k: v.get("lane", "") for k, v in locations.items() if v.get("lane") k: v.get("lane", "") for k, v in locations.items() if v.get("lane")
} }
acked = {k for k, v in locations.items() if v.get("acknowledged")}
out_units = [] out_units = []
for unit in units: for unit in units:
unit = {**unit, "description": unit.get("description", "")} unit = {**unit, "description": unit.get("description", "")}
out_openings = [] out_openings = []
for opening in unit.get("openings", []): for opening in unit.get("openings", []):
here = residents.get(opening["id"], []) here = residents.get(opening["id"], [])
report = opening_report(opening, here, lane_overrides) report = opening_report(opening, here, lane_overrides, acked)
out_openings.append( out_openings.append(
{ {
**opening, **opening,
@@ -1123,6 +1129,10 @@ def create_app(
"measured": game_dims(e) is not None, "measured": game_dims(e) is not None,
"lane": report["lanes"].get(k, ""), "lane": report["lanes"].get(k, ""),
"lane_override": lane_overrides.get(k, ""), "lane_override": lane_overrides.get(k, ""),
"misfit": fits_opening(e, opening) is False,
"laneless": (e.get("name") or "?")
in report["laneless"],
"acknowledged": k in acked,
} }
for k, e in sorted( for k, e in sorted(
here, here,
@@ -1163,10 +1173,12 @@ def create_app(
# "fits" means the REMAINING opening: an opening whose # "fits" means the REMAINING opening: an opening whose
# current packing already fails is never offered, and the # current packing already fails is never offered, and the
# game must find a lane in the split with itself included # game must find a lane in the split with itself included
current = opening_report(opening, here, lane_overrides) current = opening_report(opening, here, lane_overrides, acked)
if current["laneless"]: if current["laneless"]:
continue continue
probe = opening_report(opening, [*here, (key, entry)], lane_overrides) probe = opening_report(
opening, [*here, (key, entry)], lane_overrides, acked
)
if (entry.get("name") or "?") in probe["laneless"]: if (entry.get("name") or "?") in probe["laneless"]:
continue continue
reunites = any(related(entry, e) for _, e in here) reunites = any(related(entry, e) for _, e in here)
@@ -1470,19 +1482,46 @@ def create_app(
opening_id = body.opening_id.strip() opening_id = body.opening_id.strip()
if opening_id and opening_id not in opening_index(load_furniture(cfg)): if opening_id and opening_id not in opening_index(load_furniture(cfg)):
raise HTTPException(400, "no such opening") raise HTTPException(400, "no such opening")
if opening_id or body.note.strip(): prev = locations.get(body.key) or {}
# a lane flip or ack toggle omits the note — leave it alone
note = prev.get("note", "") if body.note is None else body.note.strip()
if opening_id or note:
record = { record = {
"opening_id": opening_id or None, "opening_id": opening_id or None,
"note": body.note.strip(), "note": note,
} }
if body.lane in ("flat", "standing"): if body.lane in ("flat", "standing"):
record["lane"] = body.lane record["lane"] = body.lane
# an acknowledged overhang belongs to a spot, not the
# game: it survives edits in place, resets on a move
if prev.get("acknowledged") and prev.get("opening_id") == (
opening_id or None
):
record["acknowledged"] = True
locations[body.key] = record locations[body.key] = record
else: else:
locations.pop(body.key, None) locations.pop(body.key, None)
save_locations(cfg, locations) save_locations(cfg, locations)
return _shelf_state() return _shelf_state()
@app.post("/api/acknowledge")
def api_acknowledge(body: AcknowledgeBody) -> dict:
"""Accept (or retract accepting) a doesn't-fit placement — the
deliberate overhang: boxes standing proud of a shallow shelf."""
with lock:
revision["n"] += 1
freshen()
locations = load_locations(cfg)
record = locations.get(body.key)
if not record or not record.get("opening_id"):
raise HTTPException(404, "that game isn't shelved anywhere")
if body.acknowledged:
record["acknowledged"] = True
else:
record.pop("acknowledged", None)
save_locations(cfg, locations)
return _shelf_state()
@app.post("/api/stored-in") @app.post("/api/stored-in")
def api_stored_in(body: StoredInBody) -> dict: def api_stored_in(body: StoredInBody) -> dict:
"""Declare (or clear) which box an already-cataloged game lives """Declare (or clear) which box an already-cataloged game lives
+86
View File
@@ -13,9 +13,11 @@ from bggpipe.shelves import (
fits_opening, fits_opening,
import_assignments, import_assignments,
load_furniture, load_furniture,
load_locations,
new_opening_id, new_opening_id,
opening_report, opening_report,
save_furniture, save_furniture,
save_locations,
) )
@@ -1009,3 +1011,87 @@ def test_add_openings_at_top(tmp_path):
o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"] o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"]
] ]
assert labels == ["on top", "A1", "A2", "B1", "B2"] assert labels == ["on top", "A1", "A2", "B1", "B2"]
def test_acknowledged_overhang(tmp_path):
"""Etherfields on the office towers: 11.8\" boxes on a 9.5\"-deep
shelf stand 2.3\" proud. The checker honestly refuses — until the
overhang is acknowledged, after which it takes the standing lane
(width-honest) and stops counting as a warning."""
tower = {
"id": "tower-e1",
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 19.0,
"depth_in": 9.5,
}
ether = _entry("Etherfields", 11.8, 11.8, 2.9)
# face-out it FITS the opening; it's the lane packing that refuses
# (can't stand spine-out, can't stack flat — 11.8" > 9.5" depth)
report = opening_report(tower, [("g1", ether)])
assert report["misfits"] == []
assert report["laneless"] == ["Etherfields"]
assert report["overfull"]
report = opening_report(tower, [("g1", ether)], acknowledged={"g1"})
assert report["misfits"] == []
assert report["laneless"] == []
assert report["lanes"] == {"g1": "standing"}
assert report["standing_in"] == pytest.approx(2.9)
# the width budget stays honest: nine acknowledged monoliths
# still overflow a 23.25" row
crowd = [(f"g{i}", ether) for i in range(9)]
report = opening_report(tower, crowd, acknowledged={k for k, _ in crowd})
assert report["overfull"]
def test_import_acknowledge_column(tmp_path):
"""The CSV import rejects a doesn't-fit row with a hint, accepts it
with acknowledge=yes, and a re-import keeps lane + acknowledgment
when the opening is unchanged (upsert, not reset)."""
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(json.dumps({"1": _entry("Etherfields", 11.8, 11.8, 2.9)}))
save_furniture(
cfg,
[
{
"name": "Tower",
"openings": [
{
"id": "tower-e1",
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 10.0,
"depth_in": 9.5,
}
],
}
],
)
csv_path = tmp_path / "plan.csv"
csv_path.write_text("name,opening\nEtherfields,tower-e1\n")
result = import_assignments(cfg, csv_path)
assert result["assigned"] == 0
assert "acknowledge column" in result["rejects"][0]
csv_path.write_text("name,opening,acknowledge\nEtherfields,tower-e1,yes\n")
result = import_assignments(cfg, csv_path)
assert result["assigned"] == 1
locations = load_locations(cfg)
assert locations["1"]["acknowledged"] is True
# hand-set a lane, re-import WITHOUT the column: same opening, so
# the note, lane, and acknowledgment all survive
locations["1"]["lane"] = "standing"
locations["1"]["note"] = "top row"
save_locations(cfg, locations)
csv_path.write_text("name,opening\nEtherfields,tower-e1\n")
result = import_assignments(cfg, csv_path)
assert result["assigned"] == 1
record = load_locations(cfg)["1"]
assert record == {
"opening_id": "tower-e1",
"note": "top row",
"lane": "standing",
"acknowledged": True,
}