diff --git a/src/bggpipe/shelves.py b/src/bggpipe/shelves.py index 25422c8..04e7dd6 100644 --- a/src/bggpipe/shelves.py +++ b/src/bggpipe/shelves.py @@ -124,6 +124,22 @@ def fits_opening(entry: dict, opening: dict) -> bool | None: ) +def family_stem(entry: dict) -> str: + """The taxonomy-free kinship signal: the normalized pre-colon stem of + the name ("Castle Panic: The Wizard's Tower" -> "castle panic"). Base + games and their expansions share it without anyone defining a series.""" + name = (entry.get("name") or "").split(":")[0] + return normalize_title(name) + + +def related(entry: dict, other: dict) -> bool: + """Same family stem, or overlapping series field where enrich has one.""" + if family_stem(entry) and family_stem(entry) == family_stem(other): + return True + mine = set(entry.get("series") or []) + return bool(mine and mine & set(other.get("series") or [])) + + def stack_thickness(entry: dict) -> float | None: """A box on a shelf stack contributes its thinnest axis.""" dims = game_dims(entry) diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 52fa7f8..77425a2 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -549,13 +549,21 @@ select { .card.stack { display: block; } .unit .unithead { display: flex; align-items: center; gap: .7rem; justify-content: space-between; flex-wrap: wrap; } -.openings { display: grid; gap: .5rem; - grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); } +/* rows mirror the unit's PHYSICAL arrangement: one .orow per label + row (A, B, ...), cells sized by flex-grow proportional to interior + width — a double-wide reads double wide */ +.openings { display: flex; flex-direction: column; gap: .5rem; + margin-bottom: .8rem; } +.orow { display: flex; gap: .5rem; } +.formlabel { margin: 1.2rem 0 .35rem; } +.formlabel.small { font-weight: 700; font-size: .85rem; + color: var(--ink-soft); margin: .8rem 0 .3rem; } button.opening { font: inherit; text-align: left; cursor: pointer; background: #fff; border: 2px solid var(--board-edge); border-radius: var(--radius); padding: .5rem .6rem; display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem; + flex-basis: 0; min-width: 0; overflow: hidden; } button.opening:hover, button.opening:focus-visible { outline: 3px solid var(--accent); } @@ -576,6 +584,7 @@ button.suggest { font: inherit; font-size: .8rem; cursor: pointer; border: 2px solid var(--board-edge); border-radius: 1rem; background: var(--board); padding: .15rem .6rem; } button.suggest:hover { border-color: var(--accent); } +button.suggest.reunites { border-color: var(--accent); font-weight: 700; } /* the opening sheet: modal on desktop, bottom sheet on phones */ #opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45); diff --git a/src/bggpipe/templates/pages/shelves.html b/src/bggpipe/templates/pages/shelves.html index 11c8324..082724e 100644 --- a/src/bggpipe/templates/pages/shelves.html +++ b/src/bggpipe/templates/pages/shelves.html @@ -1,9 +1,8 @@

Shelves

-
- -
+
- -
${u.openings.map(openingCell).join("")}
`; } +// the unit draws its ACTUAL arrangement: openings group into rows by +// their label letters (A1 A2 | B1..B4), and each cell's width is +// proportional to its interior width — a double-wide reads double wide +function unitRows(openings) { + const rows = []; + let key = null; + for (const o of openings) { + const m = /^([A-Za-z]+)\d+$/.exec(o.label || ""); + const rowKey = m ? m[1].toUpperCase() : `single-${o.id}`; + if (rowKey !== key) { rows.push([]); key = rowKey; } + rows[rows.length - 1].push(o); + } + return `
` + rows.map(row => + `
${row.map(openingCell).join("")}
` + ).join("") + `
`; +} + function unshelvedRow(g) { const dims = g.measured ? "" : ` unmeasured`; const gos = g.suggestions.map(id => { @@ -133,12 +148,14 @@ function unshelvedRow(g) { return o ? `` : ""; }).join(""); + const gosOut = gos + || (g.measured ? `no matching openings yet` : ""); return `
${esc(g.name)}${dims} ${g.lost_home ? `shelf gone` : ""} ${g.note ? `${esc(g.note)}` : ""} - ${gos} + ${gosOut}
`; } @@ -194,8 +211,6 @@ let SHEET_OPENER = null; // the button to hand focus back to on close function openSheet(id) { OPEN = id; SHEET_OPENER = document.activeElement; - document.getElementById("unitform").hidden = true; // one layer at a time - document.querySelectorAll(".addopeningform").forEach(f => f.hidden = true); document.getElementById("sheetsearch").value = ""; document.getElementById("sheetmatches").innerHTML = ""; document.getElementById("opensheet").hidden = false; @@ -245,14 +260,6 @@ function wire() { if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`)) post("/api/furniture/delete-unit", {name: b.dataset.unit}); }); - document.querySelectorAll(".addopening").forEach(b => - b.onclick = () => { - const form = document.querySelector( - `.addopeningform[data-unit="${CSS.escape(b.dataset.unit)}"]`); - form.hidden = !form.hidden; - b.setAttribute("aria-expanded", String(!form.hidden)); - if (!form.hidden) form.elements.label.focus(); - }); document.querySelectorAll(".addopeningform").forEach(form => form.onsubmit = async e => { e.preventDefault(); @@ -358,18 +365,7 @@ const PRESETS = { virtual: {width_in: null, height_in: null, depth_in: null}, }; const unitForm = document.getElementById("unitform"); -const addUnitBtn = document.getElementById("addunit"); -addUnitBtn.addEventListener("click", () => { - unitForm.hidden = !unitForm.hidden; - addUnitBtn.setAttribute("aria-expanded", String(!unitForm.hidden)); - syncDims(); - if (!unitForm.hidden) unitForm.elements.name.focus(); -}); -document.getElementById("unitcancel").addEventListener("click", () => { - unitForm.hidden = true; - unitForm.reset(); - syncDims(); -}); +syncDims(); function syncDims() { const v = unitForm.elements.preset.value; document.getElementById("customdims").hidden = v !== "custom"; @@ -411,7 +407,6 @@ unitForm.addEventListener("submit", async e => { or add more openings for its other sections`); unitForm.reset(); syncDims(); - unitForm.hidden = true; } finally { createBtn.disabled = false; } diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 4bc64bf..241c035 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -71,8 +71,10 @@ from bggpipe.shelves import ( new_opening_id, opening_index, opening_report, + related, save_furniture, save_locations, + stack_thickness, ) @@ -1125,13 +1127,35 @@ def create_app( if key in located_keys or entry.get("stored_in"): continue note = (locations.get(key) or {}).get("note", "") + # relevance gates, in rank order. (1) Reunification beats + # geometry: an opening already holding a series-mate or base + # game ranks first — every confirmed placement teaches the + # suggester the owner's organization by example. (2) "Fits" + # means the REMAINING opening, not the empty one: the stack + # budget already spent is subtracted before offering, so + # honesty holds as shelves fill. Then tightest fit, so a + # small box is offered cubes, never the oversize row. + thickness = stack_thickness(entry) + fitting = [] + for oid, opening in openings.items(): + if not opening.get("width_in"): + continue + if fits_opening(entry, opening) is not True: + continue + here = residents.get(oid, []) + report = opening_report(opening, here) + remaining = opening["height_in"] - report["stacked_in"] + if thickness is None or thickness > remaining: + continue + reunites = any(related(entry, e) for _, e in here) + volume = ( + opening["width_in"] * opening["height_in"] * opening["depth_in"] + ) + fitting.append((0 if reunites else 1, volume, oid, reunites)) + fitting.sort(key=lambda item: item[:2]) suggestions = [ - oid - for oid, opening in openings.items() - if opening.get("width_in") - and fits_opening(entry, opening) is True - and not opening_report(opening, residents.get(oid, []))["overfull"] - ][:3] + {"id": oid, "reunites": reunites} for _, _, oid, reunites in fitting[:3] + ] unshelved.append( { "key": key, diff --git a/tests/test_shelves.py b/tests/test_shelves.py index 3e3c0dd..69b1ce8 100644 --- a/tests/test_shelves.py +++ b/tests/test_shelves.py @@ -617,3 +617,149 @@ def test_move_opening_boundaries_no_op(tmp_path): web.post("/api/furniture/move-opening", json={"id": ids[0], "direction": -1}) after = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]] assert after == ids # first can't move earlier: quiet no-op + + +def test_suggestions_prefer_the_tightest_verified_fit(tmp_path): + """The relevance gate: a small box is offered cubes, never the + oversize row; only a box too big for cubes gets the double-wide.""" + games = { + "1": { + "bgg_id": 1, + "name": "Small", + "dims": { + "width_in": 10, + "length_in": 10, + "depth_in": 2, + "source": "version", + }, + }, + "2": { + "bgg_id": 2, + "name": "Wide Boi", + "dims": { + "width_in": 11.5, + "length_in": 17, + "depth_in": 2.25, + "source": "version", + }, + }, + } + web, cfg = _web(tmp_path, games) + web.post("/api/furniture/add-unit", json={"name": "Wall"}) + # oversize row FIRST in list order — the gate must not care + web.post( + "/api/furniture/add-openings", + json={ + "unit": "Wall", + "label": "A1", + "zone": "oversize", + "width_in": 26.5, + "height_in": 13.25, + "depth_in": 15.4, + }, + ) + web.post( + "/api/furniture/add-openings", + json={ + "unit": "Wall", + "rows": 1, + "cols": 2, + "zone": "cubes", + "width_in": 13.25, + "height_in": 13.25, + "depth_in": 15.4, + }, + ) + state = web.get("/api/shelves").json() + ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]} + by_name = {g["name"]: g for g in state["unshelved"]} + # the small box: cubes first (tightest fit), the wide never leads + assert by_name["Small"]["suggestions"][0]["id"] in (ids["B1"], ids["B2"]) + # the 17" box fits ONLY the double-wide + assert [s["id"] for s in by_name["Wide Boi"]["suggestions"]] == [ids["A1"]] + + +def test_reunification_outranks_tightest_fit_and_full_shelves_stop_lying(tmp_path): + """The two ranking rules together: an opening holding a series-mate + ranks above a geometrically tighter empty cube, and an opening whose + stack budget is spent stops being offered at all.""" + games = { + "base": { + "bgg_id": 1, + "name": "Castle Panic", + "dims": { + "width_in": 10, + "length_in": 10, + "depth_in": 3, + "source": "version", + }, + }, + "exp": { + "bgg_id": 2, + "name": "Castle Panic: The Wizard's Tower", + "dims": { + "width_in": 10, + "length_in": 10, + "depth_in": 2, + "source": "version", + }, + }, + "fat": { + "bgg_id": 3, + "name": "Shelf Hog", + "dims": { + "width_in": 12, + "length_in": 12, + "depth_in": 11, + "source": "version", + }, + }, + } + web, cfg = _web(tmp_path, games) + web.post("/api/furniture/add-unit", json={"name": "Wall"}) + # C1 (holds the base game), C2 (empty, geometrically identical), + # and a BIGGER wide opening — reunification must beat both + web.post( + "/api/furniture/add-openings", + json={ + "unit": "Wall", + "rows": 1, + "cols": 2, + "zone": "cubes", + "width_in": 13.25, + "height_in": 13.25, + "depth_in": 15.4, + }, + ) + web.post( + "/api/furniture/add-openings", + json={ + "unit": "Wall", + "label": "wide", + "zone": "oversize", + "width_in": 26.5, + "height_in": 13.25, + "depth_in": 15.4, + }, + ) + state = web.get("/api/shelves").json() + ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]} + web.post("/api/locate", json={"key": "base", "opening_id": ids["A1"]}) + # nearly fill A1: the 11"-thick hog leaves ~0 budget behind the base + web.post("/api/locate", json={"key": "fat", "opening_id": ids["A1"]}) + + state = web.get("/api/shelves").json() + exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower")) + # A1 holds the series-mate BUT its remaining height (13.25 - 3 - 11 < 2) + # can't take the expansion: honesty beats reunification, so the empty + # twin cube leads and A1 is not offered at all + offered = [s["id"] for s in exp["suggestions"]] + assert ids["A1"] not in offered + assert offered[0] == ids["A2"] + + # free the space: reunification now wins over the identical empty cube + web.post("/api/locate", json={"key": "fat", "opening_id": ids["wide"]}) + state = web.get("/api/shelves").json() + exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower")) + first = exp["suggestions"][0] + assert first["id"] == ids["A1"] and first["reunites"] is True