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:
co-authored by
Claude Fable 5
parent
4ce7706521
commit
7d7d3bb8da
+45
-13
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
+13
-2
@@ -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
|
||||
|
||||
+12
-1
@@ -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()
|
||||
|
||||
+59
-13
@@ -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)")
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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="/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="/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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 ? " ✱" : ""}</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
|
||||
? `<span class="meta">inside another box here</span>`
|
||||
: `<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");
|
||||
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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user