The Shelves layer: where every box physically lives

Eric's spec, all nine points. Two committed local-only stores follow
the local_games.json pattern — furniture.json (units of openings with
interior dims; a dimensionless opening is a virtual spot like a travel
case) and locations.json (game key -> opening + note). Shelf layouts
are nobody's data but the owner's; nothing touches upload.

The Shelves page builds furniture without hand-editing JSON — the
acceptance bar (two double-wides above three rows of four cubes, two
bookcases, a travel case) is a TEST, driven entirely through the
endpoints the UI calls. Presets for Kallax/Billy/custom/virtual,
grid creation with A1-style labels, openings editable/deletable/
reorderable. Units render as grids: zone, count, fill bar (stacked
thinnest-axis vs interior height), ⚠ on overfull or any resident that
can't fit. Openings open as a modal — a bottom sheet at phone widths,
search-first with thumb-sized targets for the moving-day loop.
Unshelved games list alongside with one-tap suggestions (only openings
they verifiably fit, with room).

Containment composes: a game stored inside another box inherits its
container's location, rides along in the opening's resident list
(marked), and refuses direct assignment naming its container. The
detail page's where-it-lives card gains the picker (openings grouped
by unit, each labeled fits / doesn't fit / can't verify) plus virtual
notes ("lent to Sarah, June"); the Library list shows a location line,
filters by unit or unshelved, and search matches location text and
zones.

bggpipe dims drops its hardcoded Kallax for the user's actual
furniture: per-opening capacity, overfull and misfit warnings,
unshelved count. bggpipe shelve --import loads a name,opening CSV
(ids or labels), rejecting — never guessing — unknown names, ambiguous
copies, unknown/ambiguous openings, misfits, and contained games.

Ten new tests incl. the acceptance flow, inheritance, CSV rejects,
and a phone-sheet smoke; 372 total.

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 13:45:36 -04:00
co-authored by Claude Fable 5
parent e85f72c546
commit 4f446f6f2a
17 changed files with 1601 additions and 62 deletions
+2 -1
View File
@@ -15,7 +15,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) | | 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working | | 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working | | + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed) | working | | + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed); fit checks run against `data/furniture.json` openings | working |
| + | `bggpipe shelve --import` | bulk-assign games to openings from a name,opening CSV; rejects (unknown/ambiguous/doesn't-fit/contained) reported, never guessed | working |
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`. Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
+3 -3
View File
@@ -1452,7 +1452,7 @@
"weight_lb": null, "weight_lb": null,
"source": "version" "source": "version"
}, },
"stored_in": null "stored_in": "173634"
}, },
"39856:296529": { "39856:296529": {
"bgg_id": 39856, "bgg_id": 39856,
@@ -4390,7 +4390,7 @@
"weight_lb": 1.0, "weight_lb": 1.0,
"source": "version" "source": "version"
}, },
"stored_in": null "stored_in": "380837"
}, },
"417064:706510": { "417064:706510": {
"bgg_id": 417064, "bgg_id": 417064,
@@ -4458,7 +4458,7 @@
"weight_lb": 1.0, "weight_lb": 1.0,
"source": "version" "source": "version"
}, },
"stored_in": null "stored_in": "380837"
}, },
"104710:117685": { "104710:117685": {
"bgg_id": 104710, "bgg_id": 104710,
+3 -3
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -109,6 +109,29 @@ BGG application approval can take a week or more. Until then: `extract` works im
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1` - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion` - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
## Where everything lives
The **Shelves** page maps your physical storage: create units from presets
(IKEA Kallax cube 13.25″ × 13.25″ × 15.4″, Billy shelf, custom sizes) as
grids of rows × columns, add one-off openings (double-wides, a top shelf),
and no-size virtual spots ("travel case", "lent out"). Openings carry free-
text zones, are editable and reorderable, and everything lands in
`data/furniture.json` + `data/locations.json` — committed local stores;
your shelf layout is never sent anywhere.
Assign games by tapping an unshelved game's suggested openings (only
openings it actually fits, with room to spare), 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. On a phone over `--lan`, the opening
view docks as a bottom sheet — the moving-day loop is search, tap, done.
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. `bggpipe dims` reports per-opening
capacity, overfull warnings, and misfits against your real furniture.
## Shelf-space planning ## Shelf-space planning
Physical box dimensions live on BGG *versions*, not games, so enrich collects Physical box dimensions live on BGG *versions*, not games, so enrich collects
+17
View File
@@ -193,6 +193,23 @@ def enrich(
run_enrich(cfg, refresh=refresh) run_enrich(cfg, refresh=refresh)
@app.command()
def shelve(
import_csv: Annotated[
Path,
typer.Option("--import", help="CSV of name,opening (ids or labels)"),
],
config: ConfigOpt = None,
) -> None:
"""Bulk-assign games to shelf openings from a CSV plan."""
from bggpipe.shelves import import_assignments
cfg = load_config(config)
result = import_assignments(cfg, import_csv)
if result["rejects"]:
raise typer.Exit(code=1)
@app.command() @app.command()
def dims(config: ConfigOpt = None) -> None: def dims(config: ConfigOpt = None) -> None:
"""Shelf-space report: box sizes, biggest footprints, Kallax fit.""" """Shelf-space report: box sizes, biggest footprints, Kallax fit."""
+12
View File
@@ -107,6 +107,18 @@ class Config:
"accept the arrangement and silence this warning." "accept the arrangement and silence this warning."
) )
@property
def furniture_path(self) -> Path:
# storage units and their openings — the user's shelf layout is
# local-only data (never BGG's business), committed like the
# other curation stores
return self.data_dir / "furniture.json"
@property
def locations_path(self) -> Path:
# game key -> {opening_id, note}: where each box physically lives
return self.data_dir / "locations.json"
@property @property
def local_games_path(self) -> Path: def local_games_path(self) -> Path:
# hand-written metadata for games BGG doesn't have — the only # hand-written metadata for games BGG doesn't have — the only
+59 -32
View File
@@ -1,33 +1,29 @@
"""Shelf-space report over the box dimensions enrich collected. """Shelf-space report over the box dimensions enrich collected.
Reads games.json only — fully offline. The fit target is an IKEA Kallax Reads games.json and the local furniture/location stores — fully
cube: a box fits if SOME orientation puts two axes through the ~13.2" offline. Fit checks run against the openings the user actually owns
square opening with the third axis within the ~15.4" depth. "Can't (data/furniture.json); with no furniture on file the report covers
verify" is reported as exactly that, never as a fit. sizes only and says where to set shelves up. "Can't verify" is
reported as exactly that, never as a fit.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from itertools import permutations
import typer import typer
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.shelves import (
KALLAX_OPENING_IN = 13.2 load_furniture,
KALLAX_DEPTH_IN = 15.4 load_locations,
opening_index,
opening_report,
)
_AXES = ("width_in", "length_in", "depth_in") _AXES = ("width_in", "length_in", "depth_in")
def fits_kallax(width: float, length: float, depth: float) -> bool:
return any(
a <= KALLAX_OPENING_IN and b <= KALLAX_OPENING_IN and c <= KALLAX_DEPTH_IN
for a, b, c in permutations((width, length, depth))
)
def _footprint(dims: dict) -> float: def _footprint(dims: dict) -> float:
"""The two largest axes multiplied — the shelf area a box claims """The two largest axes multiplied — the shelf area a box claims
lying in its flattest orientation.""" lying in its flattest orientation."""
@@ -86,30 +82,61 @@ def run_dims_report(cfg: Config) -> dict:
f" ({w:g} × {length:g} × {d:g} in)" f" ({w:g} × {length:g} × {d:g} in)"
) )
misfits = [
(name, dims)
for name, dims in measured
if not fits_kallax(*(dims[a] for a in _AXES))
]
unknown = [ unknown = [
name name
for source in ("conflicting", "absent") for source in ("conflicting", "absent")
for name, _ in by_source.get(source, []) for name, _ in by_source.get(source, [])
] ]
typer.echo(
f'\nKallax check ({KALLAX_OPENING_IN:g}" × {KALLAX_OPENING_IN:g}" opening, ' units = load_furniture(cfg)
f'{KALLAX_DEPTH_IN:g}" deep):' misfit_names: list[str] = []
) if not units:
if misfits: typer.echo(
typer.echo(f" {len(misfits)} box(es) do NOT fit in any orientation:") "\nNo furniture on file — set up your shelves on the Shelves "
for name, dims in misfits: "page (bggpipe web) to get per-opening fit checks."
w, length, d = (dims[a] for a in _AXES) )
typer.echo(f" {name} ({w:g} × {length:g} × {d:g} in)")
else: else:
typer.echo(" every measured box fits.") locations = load_locations(cfg)
openings = opening_index(units)
by_opening: dict[str, list[tuple[str, dict]]] = {}
for key, entry in games.items():
oid = (locations.get(key) or {}).get("opening_id")
if oid:
by_opening.setdefault(oid, []).append((key, entry))
typer.echo("\nOpenings:")
for oid, opening in openings.items():
report = opening_report(opening, by_opening.get(oid, []))
dims_note = (
f"{opening['width_in']:g}×{opening['height_in']:g}"
f"×{opening['depth_in']:g} in"
if opening.get("width_in")
else "no size limits"
)
line = (
f" {opening['unit']} · {opening['label']}"
f" ({dims_note}): {report['games']} game(s)"
)
if report["stacked_in"]:
line += f', {report["stacked_in"]:g}" stacked'
if report["unmeasured"]:
line += f", {report['unmeasured']} unmeasured"
if report["overfull"]:
line += " ⚠ OVERFULL"
typer.echo(line)
for name in report["misfits"]:
misfit_names.append(name)
typer.echo(f" ⚠ doesn't fit: {name}")
homeless = [
entry.get("name") or "?"
for key, entry in games.items()
if not entry.get("stored_in")
and not (locations.get(key) or {}).get("opening_id")
]
if homeless:
typer.echo(f"\n{len(homeless)} game(s) unshelved.")
if unknown: if unknown:
typer.echo( typer.echo(
f" {len(unknown)} game(s) can't be verified (no trusted " f"\n{len(unknown)} game(s) can't be size-verified (no trusted "
"dimensions — not the same as fitting):" "dimensions — not the same as fitting):"
) )
for name in sorted(unknown): for name in sorted(unknown):
@@ -117,7 +144,7 @@ def run_dims_report(cfg: Config) -> dict:
return { return {
"by_source": {s: len(v) for s, v in by_source.items()}, "by_source": {s: len(v) for s, v in by_source.items()},
"misfits": [name for name, _ in misfits], "misfits": misfit_names,
"unknown": sorted(unknown), "unknown": sorted(unknown),
"contained": sorted(contained), "contained": sorted(contained),
} }
+253
View File
@@ -0,0 +1,253 @@
"""The physical-location layer: which opening of which shelf each box
lives in.
Local-only — a shelf layout is not BGG data — and additive to the
pipeline: nothing here touches upload. Two committed stores follow the
local_games.json pattern: furniture.json (units and their openings,
with interior dimensions; an opening without dims is a virtual location
like "travel case" that imposes no size limits) and locations.json
(games.json key -> {opening_id, note}).
Fit language matches the rest of the project: fits / doesn't fit /
can't verify — unknown is never reported as fitting. A game with
stored_in set inherits its container's location and cannot be assigned
directly.
"""
from __future__ import annotations
import csv
import json
import re
from itertools import permutations
from pathlib import Path
import typer
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.normalize import normalize_title
_AXES = ("width_in", "length_in", "depth_in")
_SLUG = re.compile(r"[^a-z0-9]+")
# -- stores -------------------------------------------------------------
def load_furniture(cfg: Config) -> list[dict]:
"""Units: {name, openings: [{id, label, zone, width_in, height_in,
depth_in}]}. Corrupt stores fail loud — this is hand-curated data."""
if not cfg.furniture_path.exists():
return []
try:
return json.loads(cfg.furniture_path.read_text())["units"]
except (json.JSONDecodeError, KeyError, TypeError) as err:
raise ValueError(
f"{cfg.furniture_path} is corrupt ({err}) — it holds your shelf "
"layout, so check git history before deleting"
) from err
def save_furniture(cfg: Config, units: list[dict]) -> None:
atomic_write_text(
cfg.furniture_path,
json.dumps({"units": units}, indent=2, ensure_ascii=False) + "\n",
)
def load_locations(cfg: Config) -> dict[str, dict]:
if not cfg.locations_path.exists():
return {}
try:
return json.loads(cfg.locations_path.read_text())
except json.JSONDecodeError as err:
raise ValueError(
f"{cfg.locations_path} is corrupt ({err}) — it records where "
"every game lives, so check git history before deleting"
) from err
def save_locations(cfg: Config, locations: dict[str, dict]) -> None:
atomic_write_text(
cfg.locations_path,
json.dumps(locations, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
)
def opening_index(units: list[dict]) -> dict[str, dict]:
"""opening_id -> opening (with its unit's name attached)."""
out: dict[str, dict] = {}
for unit in units:
for opening in unit.get("openings", []):
out[opening["id"]] = {**opening, "unit": unit["name"]}
return out
def new_opening_id(units: list[dict], unit_name: str, label: str) -> str:
"""Stable, readable, unique: unit-slug + label-slug, -2 on collision."""
base = f"{_SLUG.sub('-', unit_name.casefold()).strip('-')}-" + (
_SLUG.sub("-", label.casefold()).strip("-") or "x"
)
taken = set(opening_index(units))
candidate, n = base, 2
while candidate in taken:
candidate = f"{base}-{n}"
n += 1
return candidate
# -- fit epistemics -----------------------------------------------------
def game_dims(entry: dict) -> tuple[float, float, float] | None:
"""The trusted box dimensions, or None when they can't be verified."""
dims = entry.get("dims") or {}
values = [dims.get(a) for a in _AXES]
return tuple(values) if all(values) else None
def fits_opening(entry: dict, opening: dict) -> bool | None:
"""True/False when both sides have dimensions; None = can't verify
(missing game dims) or no limits (a virtual opening). Unknown is
never reported as fitting."""
interior = [opening.get(k) for k in ("width_in", "height_in", "depth_in")]
if not all(interior):
return None # virtual location: no size limits to check
dims = game_dims(entry)
if dims is None:
return None
width, height, depth = interior
return any(
a <= width and b <= height and c <= depth for a, b, c in permutations(dims)
)
def stack_thickness(entry: dict) -> float | None:
"""A box on a shelf stack contributes its thinnest axis."""
dims = game_dims(entry)
return min(dims) if dims else None
# -- the resolved picture ------------------------------------------------
def effective_location(
key: str, entry: dict, games: dict[str, dict], locations: dict[str, dict]
) -> tuple[str | None, str, str | None]:
"""(opening_id, note, inherited_from_key). A contained game inherits
its container's location; everything else reads locations.json."""
container_id = entry.get("stored_in")
if container_id:
for other_key, other in games.items():
if str(other.get("bgg_id")) == str(container_id):
loc = locations.get(other_key, {})
return loc.get("opening_id"), loc.get("note", ""), other_key
return None, "", None
loc = locations.get(key, {})
return loc.get("opening_id"), loc.get("note", ""), None
def opening_report(opening: dict, games_here: list[tuple[str, dict]]) -> dict:
"""Capacity picture for one opening: fill (stacked thickness vs
interior height), misfits, and how many boxes are unmeasured."""
thicknesses = [stack_thickness(e) for _, e in games_here]
stacked = sum(t for t in thicknesses if t)
unmeasured = sum(1 for t in thicknesses if t is None)
height = opening.get("height_in")
misfits = [
entry.get("name") or "?"
for _, entry in games_here
if fits_opening(entry, opening) is False
]
return {
"games": len(games_here),
"stacked_in": round(stacked, 2),
"unmeasured": unmeasured,
"fill": round(stacked / height, 2) if height and stacked else 0,
"overfull": bool(height and stacked > height),
"misfits": misfits,
}
# -- CSV import ---------------------------------------------------------
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."""
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = json.loads(cfg.games_path.read_text())
units = load_furniture(cfg)
if not units:
typer.echo(
f"{cfg.furniture_path} is empty — set up your shelves first "
"(the Shelves page, or bggpipe web)."
)
raise typer.Exit(code=1)
openings = opening_index(units)
by_label: dict[str, list[str]] = {}
for oid, opening in openings.items():
by_label.setdefault(opening["label"].casefold(), []).append(oid)
by_label.setdefault(
f"{opening['unit']} {opening['label']}".casefold(), []
).append(oid)
by_name: dict[str, list[str]] = {}
for key, entry in games.items():
by_name.setdefault(normalize_title(entry.get("name") or ""), []).append(key)
locations = load_locations(cfg)
assigned = 0
rejects: list[str] = []
with csv_path.open(newline="") as f:
for row in csv.DictReader(f):
name = (row.get("name") or "").strip()
opening_ref = (row.get("opening") or "").strip()
if not name or not opening_ref:
rejects.append(f"{name or '(blank)'}: name and opening both required")
continue
keys = by_name.get(normalize_title(name), [])
if not keys:
rejects.append(f"{name}: no game by that name in the library")
continue
if len(keys) > 1:
rejects.append(
f"{name}: matches {len(keys)} copies — assign those in the UI"
)
continue
key = keys[0]
if games[key].get("stored_in"):
rejects.append(
f"{name}: lives inside another box — assign the container"
)
continue
if opening_ref in openings:
oid = opening_ref
else:
oids = by_label.get(opening_ref.casefold(), [])
if not oids:
rejects.append(f"{name}: no opening {opening_ref!r} on file")
continue
if len(set(oids)) > 1:
rejects.append(
f"{name}: opening label {opening_ref!r} is ambiguous — "
"use the opening id or 'unit label'"
)
continue
oid = oids[0]
if fits_opening(games[key], openings[oid]) is False:
rejects.append(
f"{name}: doesn't fit {openings[oid]['unit']} "
f"{openings[oid]['label']} in any orientation"
)
continue
locations[key] = {"opening_id": oid, "note": ""}
assigned += 1
save_locations(cfg, locations)
typer.echo(f"assigned {assigned} game(s); {len(rejects)} reject(s)")
for line in rejects:
typer.echo(f" rejected — {line}")
return {"assigned": assigned, "rejects": rejects}
+72
View File
@@ -542,6 +542,78 @@ select {
#storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; } #storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; }
#storedform select { flex: 1 1 auto; } #storedform select { flex: 1 1 auto; }
/* -- shelves: units as grids of openings, sheet for one opening ------- */
.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)); }
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;
}
button.opening:hover, button.opening:focus-visible {
outline: 3px solid var(--accent); }
.olabel { font-weight: 700; display: flex; gap: .35rem; align-items: center; }
.ozone { font-size: .72rem; color: var(--accent); }
.ocount { font-size: .82rem; color: var(--ink-soft); }
.fillbar { height: .45rem; background: var(--board-edge);
border-radius: 1rem; overflow: hidden; }
.fillbar > div { height: 100%; background: var(--ok, #4a7c4e);
border-radius: 1rem; }
.fillbar > div.over { background: #b3372f; }
.unshelved-row { display: flex; gap: .6rem; align-items: center;
flex-wrap: wrap; padding: .4rem 0;
border-bottom: 1px solid var(--board-edge); }
.unshelved-row .gos { display: flex; gap: .4rem; flex-wrap: wrap;
margin-left: auto; }
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); }
/* the opening sheet: modal on desktop, bottom sheet on phones */
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
display: flex; align-items: center; justify-content: center; z-index: 40; }
#opensheet[hidden] { display: none; }
.sheetcard { background: var(--board); border-radius: var(--radius-lg);
border: var(--line); box-shadow: var(--shadow-raised);
width: min(34rem, 92vw); max-height: 86vh; overflow-y: auto;
padding: 1rem 1.1rem; }
.sheethead { display: flex; justify-content: space-between;
align-items: center; gap: .7rem; margin-bottom: .5rem; }
#sheetclose { font: inherit; font-size: 1.1rem; background: none;
border: none; cursor: pointer; padding: .5rem; }
.sheetrow { display: flex; gap: .6rem; align-items: center;
padding: .35rem 0; border-bottom: 1px solid var(--board-edge); }
.sheetrow img { width: 2.2rem; height: 2.2rem; object-fit: cover;
border-radius: .35rem; }
.sheetrow .unassign { margin-left: auto; }
#sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit;
font-size: 1rem; padding: .55rem .7rem;
border: 2px solid var(--board-edge); border-radius: var(--radius); }
button.sheetadd { display: flex; gap: .6rem; align-items: center;
width: 100%; text-align: left; font: inherit; cursor: pointer;
background: #fff; border: 2px solid var(--board-edge);
border-radius: var(--radius); padding: .5rem .6rem; margin: .25rem 0;
min-height: 2.9rem; }
button.sheetadd img { width: 2.2rem; height: 2.2rem; object-fit: cover;
border-radius: .35rem; }
button.sheetadd:hover, button.sheetadd:focus-visible {
outline: 3px solid var(--accent); }
@media (max-width: 900px) {
/* one-handed moving-day mode: the sheet docks to the bottom, search
first, tap targets stay thumb-sized */
#opensheet { align-items: flex-end; }
.sheetcard { width: 100vw; max-height: 78vh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
button.opening { min-height: 4.4rem; }
.unshelved-row .gos { margin-left: 0; width: 100%; }
button.suggest { padding: .5rem .8rem; }
}
.empty { .empty {
background: var(--board); border: 2px dashed var(--board-edge); background: var(--board); border: 2px dashed var(--board-edge);
border-radius: var(--radius-lg); padding: 2rem; text-align: center; border-radius: var(--radius-lg); padding: 2rem; text-align: center;
+1
View File
@@ -30,6 +30,7 @@
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p> <p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p> <p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p> <p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
<p><b><a href="/shelves">Shelves</a></b> — where everything physically lives. Describe your furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns, custom sizes, and no-size spots like a travel case), then assign games to openings — by tapping suggestions next to unshelved games, by searching inside an opening, or from any game's detail page. Fill bars show stacked thickness against interior height; ⚠ flags overfull openings and boxes that can't fit. Games stored inside another box ride along with their container. 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="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p> <p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
</div> </div>
+27 -1
View File
@@ -12,6 +12,11 @@
<label>plays with <label>plays with
<input type="number" id="libplayers" min="1" max="20" placeholder="any" <input type="number" id="libplayers" min="1" max="20" placeholder="any"
style="width:5em" aria-label="filter by player count"></label> style="width:5em" aria-label="filter by player count"></label>
<label>where
<select id="libwhere" aria-label="filter by location">
<option value="">anywhere</option>
<option value="unshelved">unshelved</option>
</select></label>
<label>sort <label>sort
<select id="libsort" aria-label="sort library"> <select id="libsort" aria-label="sort library">
<option value="name">name</option> <option value="name">name</option>
@@ -47,6 +52,8 @@ function gameCard(g) {
${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div> ${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div>
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div> <div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""} ${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
${g.location && g.location.text
? `<div class="gmeta gloc">📍 ${esc(g.location.text)}</div>` : ""}
</div> </div>
</a>`; </a>`;
} }
@@ -87,7 +94,14 @@ function render() {
(g.mechanics || []).join(" "), (g.mechanics || []).join(" "),
(g.categories || []).join(" "), (g.categories || []).join(" "),
g.version && g.version.name, g.version && g.version.name,
g.location && g.location.text,
g.location && g.location.zone,
].filter(Boolean).join(" ").toLowerCase().includes(q)); ].filter(Boolean).join(" ").toLowerCase().includes(q));
const where = document.getElementById("libwhere").value;
if (where === "unshelved")
rows = rows.filter(g => !(g.location && (g.location.text || g.location.via)));
else if (where)
rows = rows.filter(g => g.location && g.location.unit === where);
if (seats) rows = rows.filter(g => if (seats) rows = rows.filter(g =>
(g.min_players || g.max_players || 0) <= seats (g.min_players || g.max_players || 0) <= seats
&& seats <= (g.max_players || g.min_players || 0)); && seats <= (g.max_players || g.min_players || 0));
@@ -107,12 +121,24 @@ function render() {
const GATE = changeGate(); const GATE = changeGate();
async function refresh() { async function refresh() {
const games = await fetchJSON("/api/library"); const games = await fetchJSON("/api/library");
GATE(games, () => { GAMES = games; render(); }); GATE(games, () => {
GAMES = games;
const units = [...new Set(games.map(g => g.location && g.location.unit)
.filter(Boolean))].sort();
const pick = document.getElementById("libwhere");
const current = pick.value;
pick.innerHTML = `<option value="">anywhere</option>
<option value="unshelved">unshelved</option>` +
units.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).join("");
pick.value = current;
render();
});
} }
document.getElementById("libsearch").addEventListener("input", render); document.getElementById("libsearch").addEventListener("input", render);
document.getElementById("libplayers").addEventListener("input", render); document.getElementById("libplayers").addEventListener("input", render);
document.getElementById("libsort").addEventListener("change", render); document.getElementById("libsort").addEventListener("change", render);
document.getElementById("libwhere").addEventListener("change", render);
document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => { document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => {
KIND = b.dataset.kind; KIND = b.dataset.kind;
document.querySelectorAll("[data-kind]").forEach(o => document.querySelectorAll("[data-kind]").forEach(o =>
+42 -4
View File
@@ -70,6 +70,17 @@ function localForm(g) {
</div>`; </div>`;
} }
function wireLocation(g) {
document.getElementById("locsave")?.addEventListener("click", async () => {
const res = await apiPost("/api/locate", {
key: KEY,
opening_id: document.getElementById("locpick").value,
note: document.getElementById("locnote").value,
});
if (res) { showToast("shelved"); refresh(); }
});
}
function wireStored(g) { function wireStored(g) {
const btn = document.getElementById("editstored"); const btn = document.getElementById("editstored");
if (!btn) return; if (!btn) return;
@@ -195,18 +206,44 @@ function render(g) {
<div class="card prose"><p class="meta">No specific edition recorded — <div class="card prose"><p class="meta">No specific edition recorded —
set one from the <a href="/titles">Titles</a> page if you know it.</p></div>`; set one from the <a href="/titles">Titles</a> page if you know it.</p></div>`;
const loc = g.location || {};
const fitWord = f => f === true ? "fits" : f === false ? "doesn't fit"
: "can't verify";
const menu = (g.opening_menu || []).map(u => `
<optgroup label="${esc(u.unit)}">${u.openings.map(o => `
<option value="${esc(o.id)}" ${o.id === loc.opening_id ? "selected" : ""}>
${esc(o.label)}${o.zone ? `${esc(o.zone)}` : ""}
${o.virtual ? "" : ` (${fitWord(o.fit)})`}
</option>`).join("")}</optgroup>`).join("");
const locBlock = g.stored_in_game
? `<p>It rides with its box: see
<a href="/library/game/${encodeURIComponent(g.stored_in_game.key)}">
${esc(g.stored_in_game.name)}</a>${loc.text && !loc.text.startsWith("inside")
? ` — currently ${esc(loc.text)}` : ""}.</p>`
: `<p>${loc.text
? `Currently: <b>${esc(loc.text)}</b>${loc.note && loc.text !== loc.note
? ` <span class="meta">(${esc(loc.note)})</span>` : ""}`
: `<span class="meta">Unshelved.</span>`}</p>
<p id="locform">
<select id="locpick" aria-label="shelf opening">
<option value="">nowhere / a note only</option>${menu}
</select>
<input id="locnote" placeholder="note — lent to Sarah, June"
value="${esc(loc.note || "")}">
<button class="primary" id="locsave">save</button>
</p>`;
const boxed = `<h2>Where it lives</h2> const boxed = `<h2>Where it lives</h2>
<div class="card prose"> <div class="card prose">
${locBlock}
${g.stored_in_game ${g.stored_in_game
? `<p>This one has no box of its own — it lives inside ? `<p class="meta">This game has no box of its own — it lives inside
<a href="/library/game/${encodeURIComponent(g.stored_in_game.key)}"> ${esc(g.stored_in_game.name)}'s.
${esc(g.stored_in_game.name)}</a>.
<button class="linkish" id="editstored">change</button></p>` <button class="linkish" id="editstored">change</button></p>`
: g.stored_in : g.stored_in
? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)} ? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)}
(not in the library yet — run <b>enrich</b>). (not in the library yet — run <b>enrich</b>).
<button class="linkish" id="editstored">change</button></p>` <button class="linkish" id="editstored">change</button></p>`
: `<p class="meta">In its own box on a shelf. : `<p class="meta">In its own box.
<button class="linkish" id="editstored">it lives inside another box…</button></p>`} <button class="linkish" id="editstored">it lives inside another box…</button></p>`}
<p id="storedform" hidden> <p id="storedform" hidden>
<select id="storedpick"><option value="">its own box</option></select> <select id="storedpick"><option value="">its own box</option></select>
@@ -240,6 +277,7 @@ function render(g) {
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`; <div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
wireLocal(g); wireLocal(g);
wireStored(g); wireStored(g);
wireLocation(g);
} }
async function refresh() { async function refresh() {
+323
View File
@@ -0,0 +1,323 @@
<h1>Shelves</h1>
<div class="pagebar"><span id="shelfcount"></span>
<button id="addunit" aria-expanded="false">add a unit</button>
</div>
<form id="unitform" class="editform card" hidden>
<label>Unit name <input name="name" required placeholder="Living room Kallax"></label>
<label>Preset
<select name="preset">
<option value="kallax">IKEA Kallax cube (13.25 × 13.25 × 15.4)</option>
<option value="billy">IKEA Billy shelf (30.75 × 13 × 11)</option>
<option value="custom">custom size</option>
<option value="virtual">no size — travel case, lent out…</option>
</select></label>
<label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
<label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label>
<span id="customdims">
<label>W <input name="width_in" size="5"></label>
<label>H <input name="height_in" size="5"></label>
<label>D <input name="depth_in" size="5"></label>
</span>
<label>Zone <input name="zone" placeholder="party games"></label>
<span class="editactions">
<button type="submit" class="primary">create</button>
<button type="button" id="unitcancel">cancel</button>
</span>
<span class="edithint">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</span>
</form>
<div id="shelfbody"><p class="empty">Loading…</p></div>
<div id="opensheet" hidden>
<div class="sheetcard">
<div class="sheethead">
<b id="sheettitle"></b>
<button id="sheetclose" aria-label="close"></button>
</div>
<div id="sheetgames"></div>
<input type="search" id="sheetsearch" placeholder="add a game — search the library…">
<div id="sheetmatches"></div>
<details id="openingtools">
<summary>opening settings</summary>
<form id="openingform" class="editform">
<label>Label <input name="label"></label>
<label>Zone <input name="zone"></label>
<label>W <input name="width_in" size="5"></label>
<label>H <input name="height_in" size="5"></label>
<label>D <input name="depth_in" size="5"></label>
<span class="editactions">
<button type="submit" class="primary">save</button>
<button type="button" id="openingup"></button>
<button type="button" id="openingdown"></button>
<button type="button" id="openingdelete" class="danger">delete opening</button>
</span>
</form>
</details>
</div>
</div>
<script>
"use strict";
let SHELVES = {units: [], unshelved: []};
let LIBRARY = [];
let OPEN = null; // opening id shown in the sheet
function openingById(id) {
for (const u of SHELVES.units)
for (const o of u.openings) if (o.id === id) return {...o, unit: u.name};
return null;
}
function fillBar(o) {
if (!o.width_in) return `<span class="meta">no size limits</span>`;
const pct = Math.min(100, Math.round((o.fill || 0) * 100));
return `<div class="fillbar" role="img"
aria-label="${pct}% full${o.unmeasured ? `, ${o.unmeasured} unmeasured` : ""}">
<div style="width:${pct}%" class="${o.overfull ? "over" : ""}"></div>
</div>`;
}
function openingCell(o) {
const warn = o.overfull || o.misfits.length
? `<span class="chip no" title="${esc(o.overfull ? "stacked past the interior height" : "")
}${esc(o.misfits.length ? " doesn't fit: " + o.misfits.join(", ") : "")}">⚠</span>`
: "";
return `<button class="opening" data-id="${esc(o.id)}">
<span class="olabel">${esc(o.label)}${warn}</span>
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
<span class="ocount">${o.games || "—"}</span>
${fillBar(o)}
</button>`;
}
function unitBlock(u) {
const cols = Math.max(1, Math.round(Math.sqrt(u.openings.length)));
return `<section class="card unit">
<div class="unithead"><h2>${esc(u.name)}</h2>
<span class="editactions">
<button class="addopening" data-unit="${esc(u.name)}">add opening</button>
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div>
<div class="openings">${u.openings.map(openingCell).join("")}</div>
</section>`;
}
function unshelvedRow(g) {
const dims = g.measured ? "" : ` <span class="chip open" title="no trusted size — fit can't be verified">unmeasured</span>`;
const gos = g.suggestions.map(id => {
const o = openingById(id);
return o ? `<button class="suggest" data-key="${esc(g.key)}" data-id="${esc(id)}"
title="fits, with room to spare">→ ${esc(o.unit)} · ${esc(o.label)}</button>` : "";
}).join("");
return `<div class="unshelved-row">
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
<span class="gos">${gos}</span>
</div>`;
}
function render() {
const total = SHELVES.units.reduce((n, u) => n + u.openings.length, 0);
document.getElementById("shelfcount").innerHTML =
`<b>${SHELVES.units.length}</b> unit(s), <b>${total}</b> opening(s), ` +
`<b>${SHELVES.unshelved.length}</b> game(s) unshelved`;
const body = document.getElementById("shelfbody");
if (!SHELVES.units.length) {
body.innerHTML = `<p class="empty">No shelves yet. Every home's furniture is
different — describe yours with <b>add a unit</b>: pick a preset or custom
size, give it rows and columns, and repeat for each bookcase, cube unit,
or travel case. Then games get homes.</p>`;
return;
}
body.innerHTML = SHELVES.units.map(unitBlock).join("") +
(SHELVES.unshelved.length
? `<section class="card"><h2>Unshelved</h2>` +
SHELVES.unshelved.map(unshelvedRow).join("") + `</section>`
: `<p class="empty">Everything has a home. 🎲</p>`);
wire();
}
function sheetRender() {
const o = openingById(OPEN);
if (!o) return;
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 => `
<div class="sheetrow">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
${g.inherited
? `<span class="meta">inside another box here</span>`
: `<button class="unassign" data-key="${esc(g.key)}">remove</button>`}
</div>`).join("")
: `<p class="meta">Nothing here yet.</p>`;
const f = document.getElementById("openingform");
f.elements.label.value = o.label;
f.elements.zone.value = o.zone || "";
f.elements.width_in.value = o.width_in ?? "";
f.elements.height_in.value = o.height_in ?? "";
f.elements.depth_in.value = o.depth_in ?? "";
document.getElementById("sheetsearch").value = "";
document.getElementById("sheetmatches").innerHTML = "";
}
function openSheet(id) {
OPEN = id;
document.getElementById("opensheet").hidden = false;
sheetRender();
document.getElementById("sheetsearch").focus();
}
async function refresh() {
SHELVES = await fetchJSON("/api/shelves");
if (!LIBRARY.length) LIBRARY = await fetchJSON("/api/library");
render();
if (OPEN) sheetRender();
}
async function post(url, body) {
const res = await apiPost(url, body);
if (res) {
SHELVES = await res.json();
render();
if (OPEN) sheetRender();
}
return res;
}
function wire() {
document.querySelectorAll(".opening").forEach(b =>
b.onclick = () => openSheet(b.dataset.id));
document.querySelectorAll(".suggest").forEach(b =>
b.onclick = () => post("/api/locate", {key: b.dataset.key, opening_id: b.dataset.id}));
document.querySelectorAll(".delunit").forEach(b =>
b.onclick = () => {
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 label = prompt("Label for the new opening (e.g. wide top left):");
if (!label) return;
const w = prompt("Interior width in inches (blank = no size limits):");
const h = w ? prompt("Interior height in inches:") : "";
const d = w ? prompt("Interior depth in inches:") : "";
post("/api/furniture/add-openings", {
unit: b.dataset.unit, label,
width_in: parseFloat(w) || null, height_in: parseFloat(h) || null,
depth_in: parseFloat(d) || null,
});
});
}
// -- the sheet ----------------------------------------------------------
document.getElementById("sheetclose").addEventListener("click", () => {
document.getElementById("opensheet").hidden = true;
OPEN = null;
});
document.getElementById("opensheet").addEventListener("click", e => {
if (e.target.id === "opensheet") {
document.getElementById("opensheet").hidden = true;
OPEN = null;
}
});
document.getElementById("sheetgames").addEventListener("click", e => {
const b = e.target.closest(".unassign");
if (b) post("/api/locate", {key: b.dataset.key, opening_id: ""});
});
document.getElementById("sheetsearch").addEventListener("input", () => {
const q = document.getElementById("sheetsearch").value.trim().toLowerCase();
const box = document.getElementById("sheetmatches");
if (!q) { box.innerHTML = ""; return; }
const here = new Set((openingById(OPEN)?.resident_games || []).map(g => g.key));
const hits = LIBRARY.filter(g =>
!here.has(g.key) && (g.name || "").toLowerCase().includes(q)).slice(0, 8);
box.innerHTML = hits.map(g => `
<button class="sheetadd" data-key="${esc(g.key)}">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
${esc(g.name)}
${g.stored_in ? `<span class="meta">inside another box</span>` : ""}
</button>`).join("");
box.querySelectorAll(".sheetadd").forEach(b =>
b.onclick = () => post("/api/locate", {key: b.dataset.key, opening_id: OPEN}));
});
document.getElementById("openingform").addEventListener("submit", e => {
e.preventDefault();
const f = e.target.elements;
post("/api/furniture/edit-opening", {
id: OPEN, label: f.label.value, zone: f.zone.value,
width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null,
clear_dims: !f.width_in.value,
});
});
document.getElementById("openingup").addEventListener("click", () =>
post("/api/furniture/move-opening", {id: OPEN, direction: -1}));
document.getElementById("openingdown").addEventListener("click", () =>
post("/api/furniture/move-opening", {id: OPEN, direction: 1}));
document.getElementById("openingdelete").addEventListener("click", () => {
if (confirm("Delete this opening? Its games become unshelved.")) {
const gone = OPEN;
OPEN = null;
document.getElementById("opensheet").hidden = true;
post("/api/furniture/delete-opening", {id: gone});
}
});
// -- add-a-unit flow ----------------------------------------------------
const PRESETS = {
kallax: {width_in: 13.25, height_in: 13.25, depth_in: 15.4},
billy: {width_in: 30.75, height_in: 13, depth_in: 11},
custom: null,
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));
if (!unitForm.hidden) unitForm.elements.name.focus();
});
document.getElementById("unitcancel").addEventListener("click", () => {
unitForm.hidden = true;
unitForm.reset();
});
unitForm.elements.preset.addEventListener("change", () => {
const v = unitForm.elements.preset.value;
document.getElementById("customdims").hidden = v !== "custom";
if (v === "virtual") {
unitForm.elements.rows.value = 1;
unitForm.elements.cols.value = 1;
}
});
unitForm.addEventListener("submit", async e => {
e.preventDefault();
const f = unitForm.elements;
const preset = PRESETS[f.preset.value];
const dims = f.preset.value === "custom"
? {width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null}
: preset || {};
const made = await post("/api/furniture/add-unit", {name: f.name.value});
if (!made) return;
await post("/api/furniture/add-openings", {
unit: f.name.value.trim(), zone: f.zone.value,
rows: parseInt(f.rows.value) || 1, cols: parseInt(f.cols.value) || 1,
...dims,
});
showToast(`<b>${esc(f.name.value)}</b> is up — click any opening to fill it,
or add more openings for its other sections`);
unitForm.reset();
unitForm.hidden = true;
});
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 12000, () => showBanner(""));
</script>
+354
View File
@@ -62,6 +62,18 @@ from bggpipe.models import (
from bggpipe.normalize import normalize_title from bggpipe.normalize import normalize_title
from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches
from bggpipe.review import _BGG_ERRORS, ReviewSession from bggpipe.review import _BGG_ERRORS, ReviewSession
from bggpipe.shelves import (
effective_location,
fits_opening,
game_dims,
load_furniture,
load_locations,
new_opening_id,
opening_index,
opening_report,
save_furniture,
save_locations,
)
def load_thumbnails(cache_dir: Path) -> dict[int, str]: def load_thumbnails(cache_dir: Path) -> dict[int, str]:
@@ -211,6 +223,48 @@ class ResearchBody(BaseModel):
types: str | None = None # e.g. "rpgitem" to search RPGGeek types: str | None = None # e.g. "rpgitem" to search RPGGeek
class UnitBody(BaseModel):
name: str
class OpeningsBody(BaseModel):
"""One opening, or a rows × cols grid of identical ones."""
unit: str
zone: str = ""
label: str = "" # single mode
rows: int = 0 # grid mode when rows*cols > 0
cols: int = 0
width_in: float | None = None
height_in: float | None = None
depth_in: float | None = None
class OpeningEditBody(BaseModel):
id: str
label: str | None = None
zone: str | None = None
width_in: float | None = None
height_in: float | None = None
depth_in: float | None = None
clear_dims: bool = False # explicit: make it a virtual location
class OpeningMoveBody(BaseModel):
id: str
direction: int # -1 up, +1 down within its unit
class OpeningDeleteBody(BaseModel):
id: str
class LocateBody(BaseModel):
key: str
opening_id: str = "" # "" = unshelve
note: str = ""
class StoredInBody(BaseModel): class StoredInBody(BaseModel):
key: str # games.json key of the game being housed key: str # games.json key of the game being housed
container: str = "" # container's bgg_id; "" = its own box again container: str = "" # container's bgg_id; "" = its own box again
@@ -291,6 +345,7 @@ NAV_PAGES = (
("/review", "review", "Review", "review"), ("/review", "review", "Review", "review"),
("/queue", "queue", "Queue", "queue"), ("/queue", "queue", "Queue", "queue"),
("/library", "library", "Library", ""), ("/library", "library", "Library", ""),
("/shelves", "shelves", "Shelves", ""),
("/help", "help", "Help", ""), ("/help", "help", "Help", ""),
) )
@@ -788,6 +843,10 @@ def create_app(
# the fragment reads the key from its own URL; nothing interpolated # the fragment reads the key from its own URL; nothing interpolated
return render_page("librarygame", active="library") return render_page("librarygame", active="library")
@app.get("/shelves", response_class=HTMLResponse)
def shelves_page() -> str:
return render_page("shelves")
@app.get("/help", response_class=HTMLResponse) @app.get("/help", response_class=HTMLResponse)
def help_page() -> str: def help_page() -> str:
return render_page("help") return render_page("help")
@@ -865,13 +924,36 @@ def create_app(
photos_by_key.setdefault(key, []).extend( photos_by_key.setdefault(key, []).extend(
p for p in row["source_photos"].split(";") if p p for p in row["source_photos"].split(";") if p
) )
units = load_furniture(cfg)
locations = load_locations(cfg)
openings = opening_index(units)
out = {} out = {}
for key, game in games.items(): for key, game in games.items():
photos = game.get("source_photos") or photos_by_key.get(key, []) photos = game.get("source_photos") or photos_by_key.get(key, [])
oid, note, via = effective_location(key, game, games, locations)
opening = openings.get(oid) if oid else None
if opening:
where = f"{opening['unit']} · {opening['label']}"
elif via is not None:
container = games.get(via, {})
where = f"inside {container.get('name') or 'another box'}"
elif note:
where = note # a virtual location: "lent to Sarah, June"
else:
where = ""
out[key] = { out[key] = {
**game, **game,
"key": key, "key": key,
"photos": [p for p in dict.fromkeys(photos) if p in available], "photos": [p for p in dict.fromkeys(photos) if p in available],
"location": {
"opening_id": oid,
"unit": opening["unit"] if opening else None,
"label": opening["label"] if opening else None,
"zone": (opening.get("zone") or "") if opening else "",
"note": note,
"via": via,
"text": where,
},
} }
return out return out
@@ -966,6 +1048,256 @@ def create_app(
raise HTTPException(404, "no such image") raise HTTPException(404, "no such image")
return FileResponse(target) return FileResponse(target)
# -- the physical-location layer -----------------------------------
def _shelf_state() -> dict:
"""Units with per-opening capacity + resident games, the unshelved
list with fit-checked suggestions, and virtual locations."""
games = read_games()
units = load_furniture(cfg)
locations = load_locations(cfg)
openings = opening_index(units)
residents: dict[str, list[tuple[str, dict]]] = {}
located_keys: set[str] = set()
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)
elif via is not None:
located_keys.add(key) # inherits; container is unshelved
out_units = []
for unit in units:
out_openings = []
for opening in unit.get("openings", []):
here = residents.get(opening["id"], [])
report = opening_report(opening, here)
out_openings.append(
{
**opening,
**report,
"resident_games": [
{
"key": k,
"name": e.get("name"),
"thumbnail": e.get("thumbnail") or "",
"inherited": bool(e.get("stored_in")),
}
for k, e in sorted(
here,
key=lambda p: (p[1].get("name") or "").casefold(),
)
],
}
)
out_units.append({"name": unit["name"], "openings": out_openings})
unshelved = []
for key, entry in sorted(
games.items(), key=lambda p: (p[1].get("name") or "").casefold()
):
if key in located_keys or entry.get("stored_in"):
continue
if (locations.get(key) or {}).get("opening_id"):
continue # assigned to an opening that no longer exists? keep out
note = (locations.get(key) or {}).get("note", "")
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]
unshelved.append(
{
"key": key,
"name": entry.get("name"),
"thumbnail": entry.get("thumbnail") or "",
"note": note,
"measured": game_dims(entry) is not None,
"suggestions": suggestions,
}
)
return {"units": out_units, "unshelved": unshelved}
@app.get("/api/shelves")
def api_shelves() -> dict:
with lock:
freshen()
return _shelf_state()
def _mutate_furniture(fn) -> dict:
with lock:
revision["n"] += 1
units = load_furniture(cfg)
fn(units)
save_furniture(cfg, units)
return _shelf_state()
@app.post("/api/furniture/add-unit")
def api_add_unit(body: UnitBody) -> dict:
name = body.name.strip()
if not name:
raise HTTPException(400, "the unit needs a name")
def add(units):
if any(u["name"] == name for u in units):
raise HTTPException(409, f"a unit named {name!r} already exists")
units.append({"name": name, "openings": []})
return _mutate_furniture(add)
@app.post("/api/furniture/delete-unit")
def api_delete_unit(body: UnitBody) -> dict:
def drop(units):
unit = next((u for u in units if u["name"] == body.name), None)
if unit is None:
raise HTTPException(404, "no such unit")
gone = {o["id"] for o in unit["openings"]}
units.remove(unit)
locations = load_locations(cfg)
kept = {
k: v for k, v in locations.items() if v.get("opening_id") not in gone
}
if kept != locations:
save_locations(cfg, kept)
return _mutate_furniture(drop)
@app.post("/api/furniture/add-openings")
def api_add_openings(body: OpeningsBody) -> dict:
def add(units):
unit = next((u for u in units if u["name"] == body.unit), None)
if unit is None:
raise HTTPException(404, "no such unit")
dims = {
"width_in": body.width_in or None,
"height_in": body.height_in or None,
"depth_in": body.depth_in or None,
}
if body.rows and body.cols:
for r in range(body.rows):
for c in range(body.cols):
label = f"{chr(65 + r)}{c + 1}"
unit["openings"].append(
{
"id": new_opening_id(units, unit["name"], label),
"label": label,
"zone": body.zone.strip(),
**dims,
}
)
else:
label = body.label.strip()
if not label:
raise HTTPException(400, "the opening needs a label")
unit["openings"].append(
{
"id": new_opening_id(units, unit["name"], label),
"label": label,
"zone": body.zone.strip(),
**dims,
}
)
return _mutate_furniture(add)
@app.post("/api/furniture/edit-opening")
def api_edit_opening(body: OpeningEditBody) -> dict:
def apply(units):
opening = opening_index(units).get(body.id)
if opening is None:
raise HTTPException(404, "no such opening")
real = next(o for u in units for o in u["openings"] if o["id"] == body.id)
if body.label is not None:
real["label"] = body.label.strip() or real["label"]
if body.zone is not None:
real["zone"] = body.zone.strip()
if body.clear_dims:
real.update(width_in=None, height_in=None, depth_in=None)
else:
for field in ("width_in", "height_in", "depth_in"):
value = getattr(body, field)
if value is not None:
real[field] = value
return _mutate_furniture(apply)
@app.post("/api/furniture/delete-opening")
def api_delete_opening(body: OpeningDeleteBody) -> dict:
def drop(units):
for unit in units:
for opening in unit["openings"]:
if opening["id"] == body.id:
unit["openings"].remove(opening)
locations = load_locations(cfg)
kept = {
k: v
for k, v in locations.items()
if v.get("opening_id") != body.id
}
if kept != locations:
save_locations(cfg, kept)
return
raise HTTPException(404, "no such opening")
return _mutate_furniture(drop)
@app.post("/api/furniture/move-opening")
def api_move_opening(body: OpeningMoveBody) -> dict:
def move(units):
for unit in units:
ids = [o["id"] for o in unit["openings"]]
if body.id in ids:
i = ids.index(body.id)
j = i + (1 if body.direction > 0 else -1)
if 0 <= j < len(unit["openings"]):
ops = unit["openings"]
ops[i], ops[j] = ops[j], ops[i]
return
raise HTTPException(404, "no such opening")
return _mutate_furniture(move)
@app.post("/api/locate")
def api_locate(body: LocateBody) -> dict:
"""Assign a game to an opening (or unshelve it). Contained games
inherit their container's spot and refuse direct assignment —
the container is the thing on the shelf."""
with lock:
revision["n"] += 1
freshen()
games = read_games()
entry = games.get(body.key)
if entry is None:
raise HTTPException(404, "no such game in the library")
if entry.get("stored_in"):
container = next(
(
e.get("name")
for e in games.values()
if str(e.get("bgg_id")) == str(entry["stored_in"])
),
"its container",
)
raise HTTPException(
409,
f"this game lives inside {container} — shelve that box "
"instead; its contents ride along",
)
locations = load_locations(cfg)
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():
locations[body.key] = {
"opening_id": opening_id or None,
"note": body.note.strip(),
}
else:
locations.pop(body.key, None)
save_locations(cfg, locations)
return _shelf_state()
@app.post("/api/stored-in") @app.post("/api/stored-in")
def api_stored_in(body: StoredInBody) -> dict: def api_stored_in(body: StoredInBody) -> dict:
"""Declare (or clear) which box an already-cataloged game lives """Declare (or clear) which box an already-cataloged game lives
@@ -1049,6 +1381,28 @@ def create_app(
) )
if contents: if contents:
game = {**game, "contains": contents} game = {**game, "contains": contents}
# the location picker's menu: openings grouped by unit, with the
# honest fit verdict for THIS game precomputed per opening
units = load_furniture(cfg)
game = {
**game,
"opening_menu": [
{
"unit": unit["name"],
"openings": [
{
"id": o["id"],
"label": o["label"],
"zone": o.get("zone") or "",
"virtual": not o.get("width_in"),
"fit": fits_opening(game, o),
}
for o in unit.get("openings", [])
],
}
for unit in units
],
}
if key.startswith("local:"): if key.startswith("local:"):
# overlay the hand-written store LIVE: the edit form re-renders # overlay the hand-written store LIVE: the edit form re-renders
# from this payload, and pre-enrich values here would resubmit # from this payload, and pre-enrich values here would resubmit
+38 -17
View File
@@ -8,19 +8,8 @@ import pytest
import typer import typer
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.dims import fits_kallax, run_dims_report from bggpipe.dims import run_dims_report
from bggpipe.shelves import save_furniture, save_locations
def test_fits_kallax_tries_every_orientation():
assert fits_kallax(11.6, 11.6, 2.8) # ordinary big-box
# too tall to stand, but slides in lying down: depth axis takes 15.0
assert fits_kallax(15.0, 12.0, 3.0)
# two axes over the opening: no orientation works
assert not fits_kallax(16.2, 16.2, 4.0)
# fits the opening but too deep to close the wall behind it
assert not fits_kallax(12.0, 12.0, 15.5)
# exact boundary counts as fitting
assert fits_kallax(13.2, 13.2, 15.4)
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys): def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
@@ -63,6 +52,7 @@ def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
} }
) )
) )
# no furniture yet: sizes-only report, pointed at the Shelves page
summary = run_dims_report(cfg) summary = run_dims_report(cfg)
assert summary["by_source"] == { assert summary["by_source"] == {
"version": 1, "version": 1,
@@ -70,12 +60,43 @@ def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
"conflicting": 1, "conflicting": 1,
"absent": 1, "absent": 1,
} }
assert summary["misfits"] == ["Monster Box"] assert summary["misfits"] == []
# can't verify is NOT the same as fits: both unknowns are named
assert summary["unknown"] == ["Argued About", "Never Measured"] assert summary["unknown"] == ["Argued About", "Never Measured"]
out = capsys.readouterr().out out = capsys.readouterr().out
assert "do NOT fit" in out and "Monster Box" in out assert "No furniture on file" in out
assert "can't be verified" in out and "Never Measured" in out
# with furniture: per-opening capacity, misfit and overfull warnings
save_furniture(
cfg,
[
{
"name": "Den",
"openings": [
{
"id": "den-a1",
"label": "A1",
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(
cfg,
{
"1": {"opening_id": "den-a1", "note": ""},
"2": {"opening_id": "den-a1", "note": ""},
},
)
summary = run_dims_report(cfg)
assert summary["misfits"] == ["Monster Box"]
out = capsys.readouterr().out
assert "doesn't fit: Monster Box" in out
assert "unshelved" in out # the conflicting + absent games have no home
assert "can't be size-verified" in out and "Never Measured" in out
def test_report_without_games_json_exits_with_guidance(tmp_path): def test_report_without_games_json_exits_with_guidance(tmp_path):
+362
View File
@@ -0,0 +1,362 @@
"""The location layer: stores, fit epistemics, inheritance, CSV import."""
from __future__ import annotations
import json
import pytest
import typer
from bggpipe.config import Config
from bggpipe.shelves import (
effective_location,
fits_opening,
import_assignments,
load_furniture,
new_opening_id,
opening_report,
save_furniture,
)
def _entry(name="Game", w=11.6, length=11.6, d=2.8, **extra):
dims = (
{"width_in": w, "length_in": length, "depth_in": d, "source": "version"}
if w
else {"width_in": None, "length_in": None, "depth_in": None, "source": "absent"}
)
return {"name": name, "dims": dims, **extra}
KALLAX = {
"id": "k1",
"label": "A1",
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
DOUBLE_WIDE = {
"id": "dw",
"label": "wide top",
"zone": "big boxes",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
}
TRAVEL = {"id": "travel", "label": "travel case", "zone": ""}
def test_fits_opening_speaks_the_house_language():
assert fits_opening(_entry(), KALLAX) is True
# 17" box: fails the cube, fits the double-wide — the acceptance case
big = _entry("Bugs in the Kitchen", 11.5, 17, 2.25)
assert fits_opening(big, KALLAX) is False
assert fits_opening(big, DOUBLE_WIDE) is True
# no game dims -> can't verify, never "fits"
assert fits_opening(_entry("Mystery", w=None), KALLAX) is None
# a virtual location imposes no limits
assert fits_opening(big, TRAVEL) is None
def test_opening_report_counts_stack_and_warns():
thin = _entry("Thin", 11, 11, 2)
thick = _entry("Thick", 11, 11, 4)
unmeasured = _entry("Mystery", w=None)
report = opening_report(KALLAX, [("a", thin), ("b", thick), ("c", unmeasured)])
assert report["games"] == 3
assert report["stacked_in"] == 6
assert report["unmeasured"] == 1
assert report["overfull"] is False
# seven thick boxes overflow a 13.25" interior
stack = [(str(i), thick) for i in range(7)]
assert opening_report(KALLAX, stack)["overfull"] is True
# a misfit is named even when the stack has room
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
assert report["misfits"] == ["Too Long"]
def test_effective_location_inherits_from_container():
games = {
"173634": _entry("Trove", bgg_id=173634),
"999": _entry("Witchdoctor", bgg_id=999, stored_in="173634"),
"13": _entry("Catan", bgg_id=13),
}
locations = {"173634": {"opening_id": "k1", "note": "top shelf"}}
oid, note, via = effective_location("999", games["999"], games, locations)
assert (oid, note, via) == ("k1", "top shelf", "173634")
# the container itself reads its own record
assert effective_location("173634", games["173634"], games, locations)[2] is None
# unassigned, uncontained: unshelved
assert effective_location("13", games["13"], games, locations) == (None, "", None)
def test_opening_ids_are_stable_and_unique():
units = [
{
"name": "Den Kallax",
"openings": [{"id": "den-kallax-a1", "label": "A1", "zone": ""}],
}
]
assert new_opening_id(units, "Den Kallax", "A2") == "den-kallax-a2"
assert new_opening_id(units, "Den Kallax", "A1") == "den-kallax-a1-2"
def test_furniture_round_trip_and_corrupt_store(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
units = [{"name": "Den", "openings": [KALLAX, DOUBLE_WIDE]}]
save_furniture(cfg, units)
assert load_furniture(cfg) == units
cfg.furniture_path.write_text("{torn")
with pytest.raises(ValueError, match="furniture.json is corrupt"):
load_furniture(cfg)
def _import_cfg(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(
json.dumps(
{
"1": _entry("Catan", 11.6, 11.6, 3, bgg_id=1),
"2": _entry("Bugs in the Kitchen", 11.5, 17, 2.25, bgg_id=2),
"3": _entry("Witchdoctor", 8, 8, 2, bgg_id=3, stored_in="1"),
"4a": _entry("Twin", 8, 8, 2, bgg_id=40),
"4b": _entry("Twin", 8, 8, 2, bgg_id=41),
}
)
)
save_furniture(
cfg,
[
{"name": "Den Kallax", "openings": [KALLAX]},
{"name": "Loft", "openings": [DOUBLE_WIDE, TRAVEL]},
],
)
return cfg
def test_csv_import_assigns_and_rejects_honestly(tmp_path, capsys):
cfg = _import_cfg(tmp_path)
plan = tmp_path / "plan.csv"
plan.write_text(
"name,opening\n"
"Catan,A1\n" # by label
"Bugs in the Kitchen,dw\n" # by id, needs the double-wide
"Bugs in the Kitchen,A1\n" # doesn't fit the cube: reject
"Witchdoctor,A1\n" # contained: reject
"Twin,A1\n" # two copies: reject
"Ghost Game,A1\n" # unknown name: reject
"Catan,Z9\n" # unknown opening: reject
)
result = import_assignments(cfg, plan)
assert result["assigned"] == 2
assert len(result["rejects"]) == 5
saved = json.loads(cfg.locations_path.read_text())
assert saved["1"]["opening_id"] == "k1"
assert saved["2"]["opening_id"] == "dw"
out = capsys.readouterr().out
assert "doesn't fit" in out and "lives inside another box" in out
assert "matches 2 copies" in out and "no game by that name" in out
def test_import_without_furniture_exits_with_guidance(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text("{}")
plan = tmp_path / "plan.csv"
plan.write_text("name,opening\n")
with pytest.raises(typer.Exit):
import_assignments(cfg, plan)
# -- web layer ----------------------------------------------------------
def _web(tmp_path, games):
import httpx
from fastapi.testclient import TestClient
from bggpipe.bgg_client import BGGClient
from bggpipe.resolve import write_matches
from bggpipe.webreview import create_app
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
cfg.photos_dir.mkdir(parents=True)
cfg.data_dir.mkdir(parents=True)
write_matches(cfg.matches_path, [])
cfg.games_path.write_text(json.dumps(games))
client = BGGClient(
cache_dir=tmp_path / "no_cache",
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
return TestClient(create_app(cfg, client=client)), cfg
def test_acceptance_full_furniture_flow_without_touching_json(tmp_path):
"""The spec's bar: two double-wide openings above three rows of four
cubes, plus two bookcases of shelves, plus a travel case — expressed
entirely through the endpoints the UI calls."""
web, cfg = _web(tmp_path, {})
assert (
web.post("/api/furniture/add-unit", json={"name": "Den wall"}).status_code
== 200
)
# two double-wides, one at a time
for label in ("wide left", "wide right"):
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den wall",
"label": label,
"zone": "big boxes",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
)
# three rows of four cubes as a grid
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den wall",
"rows": 3,
"cols": 4,
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
for name in ("Bookcase north", "Bookcase south"):
web.post("/api/furniture/add-unit", json={"name": name})
web.post(
"/api/furniture/add-openings",
json={
"unit": name,
"rows": 5,
"cols": 1,
"zone": "long games",
"width_in": 30.75,
"height_in": 13,
"depth_in": 11,
},
)
web.post("/api/furniture/add-unit", json={"name": "Travel"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Travel",
"label": "travel case",
},
)
state = web.get("/api/shelves").json()
counts = {u["name"]: len(u["openings"]) for u in state["units"]}
assert counts == {
"Den wall": 14,
"Bookcase north": 5,
"Bookcase south": 5,
"Travel": 1,
}
# the store round-trips as plain committed JSON
saved = json.loads(cfg.furniture_path.read_text())["units"]
assert saved[0]["openings"][0]["label"] == "wide left"
assert saved[-1]["openings"][0].get("width_in") is None # virtual
# editable, deletable, reorderable
first_cube = saved[0]["openings"][2]["id"]
web.post("/api/furniture/edit-opening", json={"id": first_cube, "zone": "kids"})
web.post("/api/furniture/move-opening", json={"id": first_cube, "direction": -1})
web.post(
"/api/furniture/delete-opening", json={"id": saved[0]["openings"][3]["id"]}
)
saved2 = json.loads(cfg.furniture_path.read_text())["units"]
assert len(saved2[0]["openings"]) == 13
assert saved2[0]["openings"][1]["id"] == first_cube # moved up one
def test_locate_flow_and_stored_in_refusal(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "Trove",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 4,
"source": "version",
},
},
"2": {"bgg_id": 2, "name": "Witchdoctor", "stored_in": "1"},
"3": {
"bgg_id": 3,
"name": "Longboi",
"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": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"rows": 1,
"cols": 2,
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
# a contained game refuses direct assignment, naming its container
res = web.post("/api/locate", json={"key": "2", "opening_id": opening})
assert res.status_code == 409 and "Trove" in res.json()["detail"]
# the container shelves; its contents ride along into the opening
assert (
web.post("/api/locate", json={"key": "1", "opening_id": opening}).status_code
== 200
)
state = web.get("/api/shelves").json()
resident = state["units"][0]["openings"][0]["resident_games"]
assert {g["name"] for g in resident} == {"Trove", "Witchdoctor"}
assert next(g for g in resident if g["name"] == "Witchdoctor")["inherited"]
# suggestions never offer an opening the game can't fit
unshelved = state["unshelved"]
longboi = next(g for g in unshelved if g["name"] == "Longboi")
assert longboi["suggestions"] == [] # 17" beats every 13.25" cube
# the library list knows where everything lives
lib = {g["name"]: g for g in web.get("/api/library").json()}
assert lib["Trove"]["location"]["text"] == "Den · A1"
assert lib["Witchdoctor"]["location"]["via"] == "1"
assert lib["Longboi"]["location"]["text"] == ""
# a virtual note without an opening reads as the location
web.post("/api/locate", json={"key": "3", "note": "lent to Sarah, June"})
lib = {g["name"]: g for g in web.get("/api/library").json()}
assert lib["Longboi"]["location"]["text"] == "lent to Sarah, June"
def test_shelves_page_serves_with_phone_sheet(tmp_path):
"""Phone smoke: the page serves, and the template carries the
bottom-sheet structure the mobile styles dock to the thumb."""
web, _ = _web(tmp_path, {})
html = web.get("/shelves").text
assert 'id="opensheet"' in html and 'id="sheetsearch"' in html
assert 'href="/shelves" aria-current="page"' in html
css = web.get("/static/app.css").text
assert "max-width: 900px" in css and ".sheetcard" in css
+10 -1
View File
@@ -210,7 +210,16 @@ def test_stylesheet_is_served_and_linked_by_every_page(tmp_path):
def test_every_page_marks_itself_current_in_the_nav(tmp_path): def test_every_page_marks_itself_current_in_the_nav(tmp_path):
web = _app(_cfg(tmp_path)) web = _app(_cfg(tmp_path))
for path in ("/", "/photos", "/titles", "/review", "/queue", "/library", "/help"): for path in (
"/",
"/photos",
"/titles",
"/review",
"/queue",
"/library",
"/shelves",
"/help",
):
html = web.get(path).text html = web.get(path).text
assert f'href="{path}" aria-current="page"' in html, path assert f'href="{path}" aria-current="page"' in html, path
assert 'class="skip"' in html, path assert 'class="skip"' in html, path