The space view lists boxes, not their contents

Eric, looking at the Red Dragon Inn shelf: a game stored inside
another box doesn't need its own row in the space view — it's clutter.
Right: the shelf holds physical boxes; what's inside them is the
container's business, already listed on its game page.

The opening sheet now renders only physical residents, collapsing
contents into a "holds N inside" hint on the container's row (hover
for the names). Eric's real B2 goes from 20 rows to 5 — Speechless,
Superfight, the Character Trove ("holds 15 inside"), Smorgasbox, Throw
Throw Avocado. The exported wall page mirrors it with "(+N inside)"
after the container's link. The payload keeps every rider (with a new
via field naming its box) so the sheet's add-a-game search still
excludes games already present through containment, and riders still
never appear in the unshelved backlog.

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 16:21:58 -04:00
co-authored by Claude Fable 5
parent 0ed2145f80
commit 7ed21de82a
7 changed files with 480 additions and 20 deletions
@@ -0,0 +1,399 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Shelves" [level=1] [ref=e23]
- generic [ref=e24]: 3 unit(s), 27 opening(s), 7 game(s) unshelved
- heading "Add a unit" [level=2] [ref=e26]
- generic [ref=e27]:
- generic [ref=e28]:
- text: Unit name
- textbox "Unit name" [ref=e29]:
- /placeholder: Living room Kallax
- generic [ref=e30]:
- text: Preset
- combobox "Preset" [ref=e31]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=e32]:
- text: Rows
- textbox "Rows" [ref=e33]: "1"
- generic [ref=e34]:
- text: Columns
- textbox "Columns" [ref=e35]: "1"
- generic [ref=e36]:
- text: Zone
- textbox "Zone" [ref=e37]:
- /placeholder: party games
- generic [ref=e38]:
- text: Description
- textbox "Description" [ref=e39]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=e41] [cursor=pointer]
- generic [ref=e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]:
- heading "Library Kallax" [level=2] [ref=e46]
- generic [ref=e47]:
- button "duplicate" [ref=e48] [cursor=pointer]
- button "rename" [ref=e49] [cursor=pointer]
- button "remove unit" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]:
- generic [ref=e52]: Centerpiece of the game wall in the library
- button "edit description" [ref=e53] [cursor=pointer]
- generic [ref=e54]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=e56] [cursor=pointer]:
- generic [ref=e57]: Top
- generic [ref=e58]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e59]:
- 'generic "flat stack: 0% of interior height"'
- generic [ref=e61]:
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=e62] [cursor=pointer]:
- generic [ref=e63]: A1
- generic [ref=e64]: oversize
- generic [ref=e65]: 7 · 1 unmeasured
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=e66]:
- 'generic "flat stack: 99% of interior height" [ref=e67]'
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=e70] [cursor=pointer]:
- generic [ref=e71]: A2
- generic [ref=e72]: oversize
- generic [ref=e73]: 6 · 1 unmeasured
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=e74]:
- 'generic "flat stack: 89% of interior height" [ref=e75]'
- generic [ref=e78]:
- button "B1 party 7 flat stack 98% of height, standing lane 0% of width" [ref=e79] [cursor=pointer]:
- generic [ref=e80]: B1
- generic [ref=e81]: party
- generic [ref=e82]: "7"
- img "flat stack 98% of height, standing lane 0% of width" [ref=e83]:
- 'generic "flat stack: 98% of interior height" [ref=e84]'
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=e86] [cursor=pointer]':
- generic [ref=e87]:
- text: B2
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=e88]':
- generic [ref=e89]: party
- generic [ref=e90]: "5"
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=e91]:
- 'generic "flat stack: 89% of interior height" [ref=e92]'
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=e94] [cursor=pointer]:
- generic [ref=e95]: B3
- generic [ref=e96]: party
- generic [ref=e97]: 16 · 8 unmeasured
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=e98]:
- 'generic "flat stack: 36% of interior height" [ref=e99]'
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=e102] [cursor=pointer]:
- generic [ref=e103]: B4
- generic [ref=e104]: two-player
- generic [ref=e105]: 6 · 1 unmeasured
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=e106]:
- 'generic "flat stack: 39% of interior height" [ref=e107]'
- generic [ref=e110]:
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 11 · 2 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 2 unmeasured boxes not counted" [ref=e111] [cursor=pointer]':
- generic [ref=e112]:
- text: C1
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=e113]':
- generic [ref=e114]: general
- generic [ref=e115]: 11 · 2 unmeasured
- img "flat stack 100% of height, standing lane 60% of width, overfull, 2 unmeasured boxes not counted" [ref=e116]:
- 'generic "flat stack: 100% of interior height" [ref=e117]'
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=e120] [cursor=pointer]':
- generic [ref=e121]:
- text: C2
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=e122]':
- generic [ref=e123]: general
- generic [ref=e124]: 14 · 3 unmeasured
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=e125]:
- 'generic "flat stack: 95% of interior height" [ref=e126]'
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=e129] [cursor=pointer]':
- generic [ref=e130]:
- text: C3
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=e131]':
- generic [ref=e132]: general
- generic [ref=e133]: 14 · 3 unmeasured
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=e134]:
- 'generic "flat stack: 97% of interior height" [ref=e135]'
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=e138] [cursor=pointer]':
- generic [ref=e139]:
- text: C4
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=e140]':
- generic [ref=e141]: general
- generic [ref=e142]: 13 · 4 unmeasured
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=e143]:
- 'generic "flat stack: 90% of interior height" [ref=e144]'
- generic [ref=e147]:
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=e148] [cursor=pointer]':
- generic [ref=e149]:
- text: D1
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=e150]':
- generic [ref=e151]: general
- generic [ref=e152]: 11 · 2 unmeasured
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=e153]:
- 'generic "flat stack: 94% of interior height" [ref=e154]'
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=e157] [cursor=pointer]:
- generic [ref=e158]: D2
- generic [ref=e159]: vintage wargames
- generic [ref=e160]: "7"
- img "flat stack 75% of height, standing lane 100% of width" [ref=e161]:
- 'generic "flat stack: 75% of interior height" [ref=e162]'
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=e165] [cursor=pointer]:
- generic [ref=e166]: D3
- generic [ref=e167]: heavy
- generic [ref=e168]: "3"
- img "flat stack 42% of height, standing lane 94% of width" [ref=e169]:
- 'generic "flat stack: 42% of interior height" [ref=e170]'
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=e173] [cursor=pointer]:
- generic [ref=e174]: D4
- generic [ref=e175]: campaign
- generic [ref=e176]: "4"
- img "flat stack 83% of height, standing lane 92% of width" [ref=e177]:
- 'generic "flat stack: 83% of interior height" [ref=e178]'
- paragraph [ref=e181]: Add an opening
- generic [ref=e182]:
- generic [ref=e183]:
- text: Rows
- textbox "Rows" [ref=e184]: "1"
- generic [ref=e185]:
- text: Columns
- textbox "Columns" [ref=e186]: "1"
- generic [ref=e187]:
- text: Label
- textbox "Label" [ref=e188]:
- /placeholder: wide top — for a single opening
- generic [ref=e189]:
- text: Zone
- textbox "Zone" [ref=e190]
- generic [ref=e191]:
- checkbox "at the top" [ref=e192]
- text: at the top
- generic [ref=e193]:
- text: W
- textbox "W" [ref=e194]:
- /placeholder: "13.25"
- generic [ref=e195]:
- text: H
- textbox "H" [ref=e196]:
- /placeholder: "13.25"
- generic [ref=e197]:
- text: D
- textbox "D" [ref=e198]:
- /placeholder: "15.4"
- button "add" [ref=e200] [cursor=pointer]
- generic [ref=e201]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=e202]:
- generic [ref=e203]:
- heading "Library Right Shelving" [level=2] [ref=e204]
- generic [ref=e205]:
- button "duplicate" [ref=e206] [cursor=pointer]
- button "rename" [ref=e207] [cursor=pointer]
- button "remove unit" [ref=e208] [cursor=pointer]
- paragraph [ref=e209]:
- generic [ref=e210]: To the right of the Kallax
- button "edit description" [ref=e211] [cursor=pointer]
- generic [ref=e212]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=e214] [cursor=pointer]:
- generic [ref=e215]: Top
- generic [ref=e216]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e217]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=e220] [cursor=pointer]:
- generic [ref=e221]: A1
- generic [ref=e222]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e223]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=e226] [cursor=pointer]:
- generic [ref=e227]: B1
- generic [ref=e228]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e229]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=e232] [cursor=pointer]:
- generic [ref=e233]: C1
- generic [ref=e234]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e235]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=e238] [cursor=pointer]:
- generic [ref=e239]: D1
- generic [ref=e240]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e241]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=e244] [cursor=pointer]:
- generic [ref=e245]: E1
- generic [ref=e246]: 6 · 3 unmeasured
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=e247]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=e250]: Add an opening
- generic [ref=e251]:
- generic [ref=e252]:
- text: Rows
- textbox "Rows" [ref=e253]: "1"
- generic [ref=e254]:
- text: Columns
- textbox "Columns" [ref=e255]: "1"
- generic [ref=e256]:
- text: Label
- textbox "Label" [ref=e257]:
- /placeholder: wide top — for a single opening
- generic [ref=e258]:
- text: Zone
- textbox "Zone" [ref=e259]
- generic [ref=e260]:
- checkbox "at the top" [ref=e261]
- text: at the top
- generic [ref=e262]:
- text: W
- textbox "W" [ref=e263]:
- /placeholder: "13.25"
- generic [ref=e264]:
- text: H
- textbox "H" [ref=e265]:
- /placeholder: "13.25"
- generic [ref=e266]:
- text: D
- textbox "D" [ref=e267]:
- /placeholder: "15.4"
- button "add" [ref=e269] [cursor=pointer]
- generic [ref=e270]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=e271]:
- generic [ref=e272]:
- heading "Library Left Shelving" [level=2] [ref=e273]
- generic [ref=e274]:
- button "duplicate" [ref=e275] [cursor=pointer]
- button "rename" [ref=e276] [cursor=pointer]
- button "remove unit" [ref=e277] [cursor=pointer]
- paragraph [ref=e278]:
- generic [ref=e279]: To the left of the Kallax
- button "edit description" [ref=e280] [cursor=pointer]
- generic [ref=e281]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=e283] [cursor=pointer]:
- generic [ref=e284]: Top
- generic [ref=e285]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e286]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=e289] [cursor=pointer]:
- generic [ref=e290]: A1
- generic [ref=e291]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e292]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=e295] [cursor=pointer]:
- generic [ref=e296]: B1
- generic [ref=e297]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e298]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=e301] [cursor=pointer]:
- generic [ref=e302]: C1
- generic [ref=e303]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e304]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=e307] [cursor=pointer]:
- generic [ref=e308]: D1
- generic [ref=e309]: 4 · 4 unmeasured
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=e310]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 1 flat stack 0% of height, standing lane 42% of width" [ref=e313] [cursor=pointer]:
- generic [ref=e314]: E1
- generic [ref=e315]: "1"
- img "flat stack 0% of height, standing lane 42% of width" [ref=e316]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=e319]: Add an opening
- generic [ref=e320]:
- generic [ref=e321]:
- text: Rows
- textbox "Rows" [ref=e322]: "1"
- generic [ref=e323]:
- text: Columns
- textbox "Columns" [ref=e324]: "1"
- generic [ref=e325]:
- text: Label
- textbox "Label" [ref=e326]:
- /placeholder: wide top — for a single opening
- generic [ref=e327]:
- text: Zone
- textbox "Zone" [ref=e328]
- generic [ref=e329]:
- checkbox "at the top" [ref=e330]
- text: at the top
- generic [ref=e331]:
- text: W
- textbox "W" [ref=e332]:
- /placeholder: "13.25"
- generic [ref=e333]:
- text: H
- textbox "H" [ref=e334]:
- /placeholder: "13.25"
- generic [ref=e335]:
- text: D
- textbox "D" [ref=e336]:
- /placeholder: "15.4"
- button "add" [ref=e338] [cursor=pointer]
- generic [ref=e339]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=e340]:
- heading "Unshelved" [level=2] [ref=e341]
- generic [ref=e342]:
- link "DUNGEONS & DRAGONS" [ref=e343] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=e344]: unmeasured
- generic [ref=e345]:
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=e346] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=e347]: unmeasured
- generic [ref=e348]:
- 'link "Etherfields: Awakened Items & Actions" [ref=e349] [cursor=pointer]':
- /url: /library/game/355873%3A597861
- generic [ref=e350]:
- button "♥ Library Right Shelving · E1" [ref=e351] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=e352] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=e353] [cursor=pointer]
- generic [ref=e354]:
- 'link "Etherfields: Search Mini Deck" [ref=e355] [cursor=pointer]':
- /url: /library/game/309202%3A507805
- generic [ref=e356]:
- button "♥ Library Right Shelving · E1" [ref=e357] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=e358] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=e359] [cursor=pointer]
- generic [ref=e360]:
- 'link "Etherfields: Shifting Story Promo Deck" [ref=e361] [cursor=pointer]':
- /url: /library/game/381209%3A651213
- generic [ref=e362]:
- button "♥ Library Right Shelving · E1" [ref=e363] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=e364] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=e365] [cursor=pointer]
- generic [ref=e366]:
- 'link "Etherfields: Story Dice Expansion" [ref=e367] [cursor=pointer]':
- /url: /library/game/386805%3A658656
- generic "no trusted size — fit can't be verified" [ref=e368]: unmeasured
- generic [ref=e369]:
- 'link "Etherfields: Thorn Knight" [ref=e370] [cursor=pointer]':
- /url: /library/game/291126%3A475383
- generic "no trusted size — fit can't be verified" [ref=e371]: unmeasured
+4 -1
View File
@@ -144,7 +144,10 @@ because can't-verify is not the same as fits. You can also assign by
searching inside an opening, or from a game's detail page (openings
grouped by unit, each labeled fits / doesn't fit / can't verify). Games
stored inside another box inherit their container's spot — through whole
chains (minis in an insert in a big box live where the big box does). On a
chains (minis in an insert in a big box live where the big box does)
and the space view lists only the physical boxes: contents collapse into
a "holds N inside" hint on their container's row (hover for names; the
full list lives on the container's game page). On a
phone over `--lan`, the opening view docks as a bottom sheet — the
moving-day loop is search, tap, done.
+9 -1
View File
@@ -411,9 +411,16 @@ def _shelves_body(
each opening listing its residents as links."""
openings = opening_index(units)
residents: dict[str, list[tuple[str, dict]]] = {}
holds: dict[str, int] = {} # physical box key -> games riding inside
for key, entry in games.items():
oid, _, _via = effective_location(key, entry, games, locations)
oid, _, via = effective_location(key, entry, games, locations)
if oid and oid in openings:
if via is not None:
# contents ride inside a box that IS listed — the box on
# the shelf is the physical object; its contents live on
# its game page, not as wall clutter
holds[via] = holds.get(via, 0) + 1
else:
residents.setdefault(oid, []).append((key, entry))
parts = []
for unit in units:
@@ -427,6 +434,7 @@ def _shelves_body(
)
links = " · ".join(
f'<a href="../{slugs[k]}/">{html.escape(e.get("name") or "?")}</a>'
+ (f" (+{holds[k]} inside)" if holds.get(k) else "")
for k, e in here
)
zone = (
+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="/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" — and the packer is deliberately conservative (one flat pile, one standing row) while real shelves are 3-D. When it refuses a placement you can see with your own eyes, the <b>it's here anyway</b> button overrules it: the box takes a lane where one works (spine-out overhangs land "standing, proud") and otherwise is marked <b>wedged in</b> — really there, outside the capacity bars, never warned about. Unmeasured boxes get an <b>unmeasured — measure it</b> button: enter your own tape-measure numbers and they outrank BGG's everywhere sizes are used. And where a game <i>lives</i> vs where it <i>is</i> can differ: the <b>Right now</b> field on any game's detail page marks a box out (travel case, loaned to a friend) without touching its home — the spot stays reserved, contents ride along, and blanking the field brings it back. 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="/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 the packer is deliberately conservative (one flat pile, one standing row) while real shelves are 3-D. When it refuses a placement you can see with your own eyes, the <b>it's here anyway</b> button overrules it: the box takes a lane where one works (spine-out overhangs land "standing, proud") and otherwise is marked <b>wedged in</b> — really there, outside the capacity bars, never warned about. Unmeasured boxes get an <b>unmeasured — measure it</b> button: enter your own tape-measure numbers and they outrank BGG's everywhere sizes are used. And where a game <i>lives</i> vs where it <i>is</i> can differ: the <b>Right now</b> field on any game's detail page marks a box out (travel case, loaned to a friend) without touching its home — the spot stays reserved, contents ride along, and blanking the field brings it back. Games stored inside another box ride along with their container, through whole chains — and the space view lists only the physical boxes, collapsing contents into a "holds N inside" hint on the container's row (the full list lives on its game page). 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>
+15 -7
View File
@@ -229,11 +229,21 @@ function sheetRender() {
document.getElementById("sheettitle").textContent =
`${o.unit} · ${o.label}` + (o.width_in
? ` (${o.width_in}×${o.height_in}×${o.depth_in} in)` : "");
document.getElementById("sheetgames").innerHTML = o.resident_games.length
? o.resident_games.map(g => `
// contents ride inside a box that IS listed — the box on the shelf is
// the physical object; what's inside collapses to a "holds N" hint
// (its games stay on the container's page and in the search exclusion)
const inside = {};
o.resident_games.filter(g => g.inherited).forEach(g => {
(inside[g.via] = inside[g.via] || []).push(g.name);
});
const physical = o.resident_games.filter(g => !g.inherited);
document.getElementById("sheetgames").innerHTML = physical.length
? physical.map(g => `
<div class="sheetrow">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
${inside[g.key] ? `<span class="meta" title="${esc(inside[g.key].join(", "))}">
holds ${inside[g.key].length} inside</span>` : ""}
${g.away ? `<span class="chip open" title="this is its home spot — the box
itself is elsewhere right now (set from the game's page)">out: ${esc(g.away)}</span>` : ""}
${g.measured ? "" : `<button class="measurebtn" data-key="${esc(g.key)}"
@@ -247,13 +257,13 @@ a lane — tap to undo and let the packer choose again">no room ✱ undo</button
? `<span class="chip open" title="confirmed here in some arrangement the
packer can't model (crosswise, on top of a pile…) — outside the capacity bars,
never warned about">wedged in</span>` : ""}
${g.lane && !g.inherited
${g.lane
? `<button class="lanebtn" data-key="${esc(g.key)}"
data-next="${g.lane === "flat" ? "standing" : "flat"}"
title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}">
${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.proud ? ", proud" : ""}${g.lane_override ? " ✱" : ""}</button>`
: ""}
${!g.inherited && (g.acknowledged || g.laneless)
${g.acknowledged || g.laneless
? (g.acknowledged
? `<button class="ackbtn on" data-key="${esc(g.key)}" data-ack="false"
title="you confirmed this is really here — tap to retract">here ✓</button>`
@@ -261,9 +271,7 @@ never warned about">wedged in</span>` : ""}
title="the packer can't place this box, but shelves are 3-D and
you can see yours — confirm it's really here and the warning stands down">it's here anyway</button>`)
: ""}
${g.inherited
? `<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>
</div>`).join("")
: `<p class="meta">Nothing here yet.</p>`;
document.getElementById("sheetdesc").textContent = o.description || "";
+4
View File
@@ -1112,11 +1112,14 @@ def create_app(
residents: dict[str, list[tuple[str, dict]]] = {}
located_keys: set[str] = set()
lost_home: set[str] = set()
rides_in: dict[str, str] = {} # inherited resident -> its box's key
for key, entry in games.items():
oid, _, via = effective_location(key, entry, games, locations)
if oid and oid in openings:
residents.setdefault(oid, []).append((key, entry))
located_keys.add(key)
if via is not None:
rides_in[key] = via
elif oid and via is None:
# assigned to an opening that no longer exists (a store
# edited or reverted by hand): SURFACE it, never hide it
@@ -1154,6 +1157,7 @@ def create_app(
"wedged": (e.get("name") or "?") in report["wedged"],
"proud": (e.get("name") or "?") in report["proud"],
"away": effective_away(k, e, games, locations),
"via": rides_in.get(k, ""),
}
for k, e in sorted(
here,
+46 -8
View File
@@ -1045,9 +1045,10 @@ def test_acknowledged_overhang(tmp_path):
report = opening_report(tower, crowd, acknowledged={k for k, _ in crowd})
assert not report["overfull"]
assert report["wedged"]
assert len(report["wedged"]) + len(
[lane for lane in report["lanes"].values() if lane]
) == 9
assert (
len(report["wedged"]) + len([lane for lane in report["lanes"].values() if lane])
== 9
)
def test_import_acknowledge_column(tmp_path):
@@ -1143,9 +1144,7 @@ def test_here_anyway_wedges_the_unplaceable(tmp_path):
squeezed = next(g for g in a1["resident_games"] if g["name"] == "Squeezed Out")
assert squeezed["laneless"] is True and squeezed["wedged"] is False
assert a1["overfull"] is True
state = web.post(
"/api/acknowledge", json={"key": "2", "acknowledged": True}
).json()
state = web.post("/api/acknowledge", json={"key": "2", "acknowledged": True}).json()
a1 = state["units"][0]["openings"][0]
squeezed = next(g for g in a1["resident_games"] if g["name"] == "Squeezed Out")
assert squeezed["wedged"] is True and squeezed["laneless"] is False
@@ -1187,8 +1186,7 @@ def test_measurements_overlay(tmp_path):
assert g["measured"] is True and g["lane"] == "flat"
# partial numbers are refused, never guessed
assert (
web.post("/api/measure", json={"key": "1", "width_in": 10.0}).status_code
== 400
web.post("/api/measure", json={"key": "1", "width_in": 10.0}).status_code == 400
)
# clearing (all empty) restores honest ignorance
state = web.post("/api/measure", json={"key": "1"}).json()
@@ -1252,3 +1250,43 @@ def test_away_whereabouts(tmp_path):
}
web.post("/api/away", json={"key": "2", "where": ""})
assert "2" not in load_locations(cfg)
def test_contained_games_collapse_in_sheet_payload(tmp_path):
"""The space view lists physical boxes; contents carry `via` so the
UI can collapse them into the container's "holds N" hint — and they
still count as located (never re-offered for shelving)."""
web, cfg = _web(
tmp_path,
{
"1": _entry("Red Dragon Inn", 12.0, 12.0, 4.0, bgg_id="4991"),
"2": _entry("RDI: Allies", 8.0, 8.0, 1.0, stored_in="4991"),
},
)
save_furniture(
cfg,
[
{
"name": "Wall",
"openings": [
{
"id": "big",
"label": "A1",
"zone": "",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(cfg, {"1": {"opening_id": "big", "note": ""}})
state = web.get("/api/shelves").json()
rows = state["units"][0]["openings"][0]["resident_games"]
box = next(g for g in rows if g["name"] == "Red Dragon Inn")
allies = next(g for g in rows if g["name"] == "RDI: Allies")
assert box["via"] == "" and allies["via"] == "1"
assert allies["inherited"] is True
# riding along is being located: never in the unshelved backlog
assert all(u["key"] != "2" for u in state["unshelved"])