From 7d7d3bb8da8df8d99643d5ab59bd807554b73bdb Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 9 Aug 2026 15:45:36 -0400 Subject: [PATCH] The acknowledged overhang: "doesn't fit, shelved anyway" goes legit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- data/furniture.json | 58 ++++++++++++---- data/zones_delta.csv | 11 +++ docs/guide.md | 15 ++++- src/bggpipe/dims.py | 13 +++- src/bggpipe/shelves.py | 72 ++++++++++++++++---- src/bggpipe/static/app.css | 6 ++ src/bggpipe/templates/pages/help.html | 2 +- src/bggpipe/templates/pages/shelves.html | 14 +++- src/bggpipe/webreview.py | 51 ++++++++++++-- tests/test_shelves.py | 86 ++++++++++++++++++++++++ 10 files changed, 291 insertions(+), 37 deletions(-) create mode 100644 data/zones_delta.csv diff --git a/data/furniture.json b/data/furniture.json index c65662a..d4ca0ef 100644 --- a/data/furniture.json +++ b/data/furniture.json @@ -3,6 +3,14 @@ { "name": "Library Kallax", "openings": [ + { + "id": "library-kallax-top", + "label": "Top", + "zone": "", + "width_in": 53.0, + "height_in": 38.0, + "depth_in": 15.4 + }, { "id": "library-kallax-a1", "label": "A1", @@ -125,6 +133,14 @@ "name": "Library Right Shelving", "description": "To the right of the Kallax", "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", "label": "A1", @@ -138,39 +154,51 @@ "label": "B1", "zone": "", "width_in": 23.25, - "height_in": 10.25, - "depth_in": 9.5 + "height_in": 10.75, + "depth_in": 9.5, + "description": "" }, { "id": "library-right-shelving-c1", "label": "C1", "zone": "", "width_in": 23.25, - "height_in": 10.25, - "depth_in": 9.5 + "height_in": 12.25, + "depth_in": 9.5, + "description": "" }, { "id": "library-right-shelving-d1", "label": "D1", "zone": "", "width_in": 23.25, - "height_in": 10.25, - "depth_in": 9.5 + "height_in": 13.25, + "depth_in": 9.5, + "description": "" }, { "id": "library-right-shelving-e1", "label": "E1", "zone": "", "width_in": 23.25, - "height_in": 10.25, - "depth_in": 9.5 + "height_in": 19.0, + "depth_in": 9.5, + "description": "" } ] }, { "name": "Library Left Shelving", - "description": "To the right of the Kallax", + "description": "To the left of the Kallax", "openings": [ + { + "label": "Top", + "zone": "", + "width_in": 23.5, + "height_in": 25.0, + "depth_in": 9.5, + "id": "library-right-shelving-2-top" + }, { "label": "A1", "zone": "", @@ -183,32 +211,36 @@ "label": "B1", "zone": "", "width_in": 23.25, - "height_in": 10.25, + "height_in": 10.75, "depth_in": 9.5, + "description": "", "id": "library-right-shelving-2-b1" }, { "label": "C1", "zone": "", "width_in": 23.25, - "height_in": 10.25, + "height_in": 12.25, "depth_in": 9.5, + "description": "", "id": "library-right-shelving-2-c1" }, { "label": "D1", "zone": "", "width_in": 23.25, - "height_in": 10.25, + "height_in": 13.25, "depth_in": 9.5, + "description": "", "id": "library-right-shelving-2-d1" }, { "label": "E1", "zone": "", "width_in": 23.25, - "height_in": 10.25, + "height_in": 19.0, "depth_in": 9.5, + "description": "", "id": "library-right-shelving-2-e1" } ] diff --git a/data/zones_delta.csv b/data/zones_delta.csv new file mode 100644 index 0000000..555f8c2 --- /dev/null +++ b/data/zones_delta.csv @@ -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 diff --git a/docs/guide.md b/docs/guide.md index 8c97df6..f3bd819 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -157,8 +157,19 @@ keeps the whole layer out of the export. Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns `name,opening`, accepting opening ids or labels) — rejects are reported (unknown name, ambiguous copies, unknown opening, doesn't fit, lives -inside another box), never guessed, and re-imports preserve hand-entered -notes. `bggpipe dims` reports per-opening capacity, overfull warnings, and +inside another box), never guessed. Re-importing is an upsert, not a +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 model**: each opening holds a flat stack (thinnest axes summing against interior height, claiming the widest flat box's width) and a standing lane diff --git a/src/bggpipe/dims.py b/src/bggpipe/dims.py index ae2a111..590dd29 100644 --- a/src/bggpipe/dims.py +++ b/src/bggpipe/dims.py @@ -98,6 +98,10 @@ def run_dims_report(cfg: Config) -> dict: ) else: 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) by_opening: dict[str, list[tuple[str, dict]]] = {} for key, entry in games.items(): @@ -106,7 +110,9 @@ def run_dims_report(cfg: Config) -> dict: by_opening.setdefault(oid, []).append((key, entry)) typer.echo("\nOpenings:") 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")) dims_note = ( 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: misfit_names.append(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 = [ entry.get("name") or "?" for key, entry in games.items() diff --git a/src/bggpipe/shelves.py b/src/bggpipe/shelves.py index 1c0fc13..f2f9f62 100644 --- a/src/bggpipe/shelves.py +++ b/src/bggpipe/shelves.py @@ -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 (thinnest axes summing against interior HEIGHT, claiming the widest 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. 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") width = opening.get("width_in") lanes: dict[str, str] = {} @@ -251,10 +261,12 @@ def lane_split(opening: dict, physical: list[tuple[str, dict, str]]) -> dict: lanes[key] = "flat" return True - def try_standing(key, dims): + def try_standing(key, dims, relaxed=False): thickness = min(dims) 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 standing.append((key, thickness)) 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 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": placed = try_flat(key, dims) elif override == "standing": @@ -295,6 +312,7 @@ def opening_report( opening: dict, games_here: list[tuple[str, dict]], overrides: dict[str, str] | None = None, + acknowledged: set[str] | None = None, ) -> dict: """Capacity picture for one opening under the two-lane model. Games stored INSIDE another box are listed residents but occupy no shelf @@ -320,11 +338,12 @@ def opening_report( "misfits": [], "laneless": [], } - split = lane_split(opening, physical) + split = lane_split(opening, physical, acknowledged) + acknowledged = acknowledged or set() misfits = [ entry.get("name") or "?" - for _, entry, _o in physical - if fits_opening(entry, opening) is False + for key, entry, _o in physical + if fits_opening(entry, opening) is False and key not in acknowledged ] standing_budget = max(width - split["flat_claim"], 0.01) return { @@ -352,7 +371,12 @@ def opening_report( def import_assignments(cfg: Config, csv_path: Path) -> dict: """Bulk-assign from a name,opening CSV (opening ids or labels). 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(): typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.") raise typer.Exit(code=1) @@ -414,15 +438,37 @@ def import_assignments(cfg: Config, csv_path: Path) -> dict: ) continue 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( 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 - # 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} + # a re-imported plan must not erase hand-entered curation: + # the note always survives; the lane choice and a standing + # 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 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 db88321..0646d10 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -590,6 +590,12 @@ button.opening:hover, button.opening:focus-visible { border: 1px solid var(--board-edge); border-radius: 1rem; background: #fff; padding: .05rem .5rem; color: var(--ink-soft); } .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; flex-wrap: wrap; padding: .4rem 0; border-bottom: 1px solid var(--board-edge); } diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index 2d6f20d..bc631aa 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -30,7 +30,7 @@

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — 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 (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.

Queue — 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 retry N failed checkbox. Each queued row shows what upload did with it — pending, done, failed, or retired (a review decision since the last diff withdrew it). Finished rows stay listed until the next diff rebuilds the queue; the log below them is the permanent record.

-

Shelves — 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 series-mate or base game first (marked ♥ — every placement you confirm teaches the suggester your organization), always against the remaining 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 no packing fits, 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 bggpipe shelve --import plan.csv (name,opening) and check capacity anytime with bggpipe dims.

+

Shelves — 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 series-mate or base game first (marked ♥ — every placement you confirm teaches the suggester your organization), always against the remaining 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 no packing fits, not merely "tall stack" — and when the misfit is deliberate (boxes spine-out, proud of a shallow shelf) the accept overhang 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 bggpipe shelve --import plan.csv (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 bggpipe dims.

Library — 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, your 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 data/ and folded in by the next enrich).

diff --git a/src/bggpipe/templates/pages/shelves.html b/src/bggpipe/templates/pages/shelves.html index bf14bb9..cdd85b2 100644 --- a/src/bggpipe/templates/pages/shelves.html +++ b/src/bggpipe/templates/pages/shelves.html @@ -240,6 +240,14 @@ function sheetRender() { title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}"> ${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.lane_override ? " ✱" : ""}` : ""} + ${(g.misfit || g.laneless || g.acknowledged) && !g.inherited + ? (g.acknowledged + ? `` + : ``) + : ""} ${g.inherited ? `inside another box here` : ``} @@ -391,8 +399,12 @@ document.getElementById("sheetgames").addEventListener("click", e => { const b = e.target.closest(".unassign"); if (b) { post("/api/locate", {key: b.dataset.key, opening_id: ""}); return; } 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, + }); 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", () => { diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 6d11979..9328b45 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -269,10 +269,15 @@ class OpeningDeleteBody(BaseModel): class LocateBody(BaseModel): key: str 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 +class AcknowledgeBody(BaseModel): + key: str + acknowledged: bool # True = "doesn't fit, shelved anyway" is deliberate + + class StoredInBody(BaseModel): key: str # games.json key of the game being housed container: str = "" # container's bgg_id; "" = its own box again @@ -1103,13 +1108,14 @@ def create_app( 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")} out_units = [] for unit in units: unit = {**unit, "description": unit.get("description", "")} out_openings = [] for opening in unit.get("openings", []): here = residents.get(opening["id"], []) - report = opening_report(opening, here, lane_overrides) + report = opening_report(opening, here, lane_overrides, acked) out_openings.append( { **opening, @@ -1123,6 +1129,10 @@ def create_app( "measured": game_dims(e) is not None, "lane": report["lanes"].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( here, @@ -1163,10 +1173,12 @@ def create_app( # "fits" means the REMAINING opening: an opening whose # current packing already fails is never offered, and the # 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"]: 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"]: continue reunites = any(related(entry, e) for _, e in here) @@ -1470,19 +1482,46 @@ def create_app( opening_id = body.opening_id.strip() if opening_id and opening_id not in opening_index(load_furniture(cfg)): 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 = { "opening_id": opening_id or None, - "note": body.note.strip(), + "note": note, } if body.lane in ("flat", "standing"): 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 else: locations.pop(body.key, None) save_locations(cfg, locations) 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") def api_stored_in(body: StoredInBody) -> dict: """Declare (or clear) which box an already-cataloged game lives diff --git a/tests/test_shelves.py b/tests/test_shelves.py index 92a7d41..65ff363 100644 --- a/tests/test_shelves.py +++ b/tests/test_shelves.py @@ -13,9 +13,11 @@ from bggpipe.shelves import ( fits_opening, import_assignments, load_furniture, + load_locations, new_opening_id, opening_report, 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"] ] 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, + }