- collections_routes.py: /nav/collections CRUD over virtual_collection + /nav/locate (exact-then-rules)
- navigator_routes.py: /nav/reorg/{scan,apply,compress} (atomic re-file)
- disc_cache gets year/genre_ids/style_ids cols for rule matching (backfill TODO)
- /search: collections list + locator fallback shows the should-be-here zone for unfiled hits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
RecordGod — Search & Inventory Management (Navigator) port
Faithful port of WowPlatter's Search & Inventory Management workstation onto RecordGod's
Postgres + the already-migrated virtual_* geometry. This is the real thing — Store→Rack→Crate→Item
drill-down, per-level slot layouts with orientation, scan-to-slot, reorganize, returns, stock finder —
NOT the toy /search that ships today (that becomes Phase 0's store view).
Source studied: wowplatter/admin/partials/search.php (844 lines), admin/js/search/main.js (8,482 lines,
the live renderer), admin/js/src/navigator/{diagram,scanner,views,state}.js,
includes/repositories/class-wowplatter-virtual-store-repository.php, schema-manager virtual tables.
UI rule: light/clean shop-admin theme (see memory recordgod-admin-light-ui), not the dark dash.
1. The workstation shape
- Top bar: Space selector (
booth-room (default)…), view zoom =Store · Rack · Crate · Item+ Auto, level controls◀ Level 1 of 2 (BOTTOM) ▶, rack-rotate, refresh. - 5 tabs:
Scanner · Returns · Reorganize · Collections · Stock Finder. - Center: the canvas diagram (changes with the zoom level).
- Right: Crate Contents list (items in the active crate) + collapsible side panels: Quick Insert · Release Info · Crate Info · Rack Info.
The 4 zoom levels are ONE canvas that re-renders:
- Store — top-down room: racks at their positions, doors/portals, compass. Click a rack → Rack.
- Rack — one level of the selected rack (prev/next level), crates laid in slots with an orientation arrow + genre label + slot number, BACK/FRONT/L/R around the rack. Click a crate → Crate.
- Crate — the crate's slot grid (rows×cols from crate_type) + Crate Contents (items by
slot_number). - Item — individual slot/record detail.
2. Data model — ALREADY in RecordGod (migrated verbatim by migrate_virtual.py)
Everything below is already in recordgod-db; the work is API + front-end, not schema.
| table | the bits that matter |
|---|---|
virtual_space |
room_width, room_depth, is_default, visible — the room |
virtual_rack_type |
width, height, depth, panel flags, material; levels via virtual_rack_type_level |
virtual_rack_type_level |
per-type level rows (level index, shelf height/thickness) — defines how many levels a rack has |
virtual_rack |
pos_x/pos_y/pos_z, rotation_x/y/z, space_id, rack_type_id, direction (front/back/left/right), genre_ids, style_ids, attach_wall, name |
virtual_crate_type |
width/height/depth, record_rows, record_columns (the slot grid), material |
virtual_crate |
rack_id, rack_level_index, crate_type_id, pos_x/y/z (relative to rack origin, m), direction (forward/back/left/right) = the crate's facing, rotation_x/y/z (all 0 in data — IGNORE for facing), layer (upper/lower/floor), name |
inventory |
crate_id + slot_number — each record's home crate + its slot |
Hierarchy: Space → Racks(positioned) → Levels(rack_level_index) → Crates(in slots, oriented) → Items(slot_number).
Genre/style: virtual_rack.genre_ids/style_ids are comma-sep id lists; crate-level tags via a tag table
(WowPlatter wowplatter_get_crate_tags). Confirm the migrated tag table name in P2.
Props vs stock (important, every phase): virtual_rack_type.rack_purpose ∈ stock (real record racks)
or prop (3D scenery — COLUMNs, AIRCON, STATION, DOOR, couch, trolley…). The functional navigator must
filter rack_purpose <> 'prop' everywhere (store-layout already does). type ∈ rack/bench/shelf/'' is the
geometry kind, NOT the stock/prop distinction — don't filter on type.
3. The geometry, decoded (the tricky x/y/z)
Units. Rack width/depth are cm (/100 = metres, /200 = half). Crate dims + crate pos_* are metres.
Rack pos_x/pos_z are room metres.
Facing composes — the slippery bit (confirmed in data 2026-06-22). There are TWO independent direction
columns and they STACK:
virtual_rack.direction∈front/back/left/right— which way the whole rack faces in the room.virtual_crate.direction∈forward/back/left/right— which way THAT crate faces (data: forward 59 / back 57 / left 114 / right 122). This is the facing field — NOTrotation_y(it's 0.0 on every row).- A crate's world facing =
rack.direction∘crate.direction. In the rack-level (front-on) view the orientation arrow is the crate's OWN direction in the rack frame; in the store/3D world view you must compose it with the rack's direction or the arrows point the wrong way. Never read one without the other. - Vocab differs on purpose: crate says
forward, rack saysfront— don't treat the strings as interchangeable.
Render convention (copied from production main.js, do not reinvent):
directionYaw(dir)→ radians:forward/front=0,back=π,left=π/2,right=−π/2. (north/east/south/west alias n=0/e=−π/2-ish via the same table.)- Crate placement in the rack-level view is by
slot_number, not pos_x. Grid:cell=0.34m,cols=floor(rackW/0.34),rows=round(rackD/0.34), slot is 1-indexed row-major;posX=-rackW/2 + cellX/2 + col*cellX,posZ=-rackD/2 + cellZ/2 + row*cellZ. Fall back topos_x/pos_zonly if no slot_number. - Crate rect yaw =
directionYaw(crate.direction) + crate.rotation_y(rotation_y≈0, so direction drives it). - View-rotation (the rack-rotate button, 0/90/180/270) composes on top:
rotatePoint=90:(-z,x) · 180:(-x,-z) · 270:(z,-x), effective W/D swap at 90/270. - Facing arrow =
rotatedArrows[viewRot][crate.direction], whererotatedArrows[0] = {forward:↓, back:↑, left:←, right:→}and 90/180/270 cycle it. The arrow (not the square) is what the eye reads. - Edge labels at viewRot 0: BACK top, FRONT bottom, L left, R right — rotate with the view.
- World facing (store/3D view) =
directionYaw(rack.direction) + directionYaw(crate.direction) + crate.rotation_y. In the drilldown you're head-on to the rack, so onlycrate.direction+ view-rotation apply.
Store view (renderStoreView, diagram.js):
- bounds over all racks in the active space (
pos ± half),createWorldToCanvas(min,max,pad)→ fit+centre. - each rack = 4 corners
{±halfW,±halfD}→rotatePoint(yaw)→+pos→ toCanvas → filled rotated rect + name. - portals = door-span line + facing triangle at
(x/roomW·W, z/roomD·H),facing_deg; orange=paired, red=unpaired. - compass top-right. Hit-test by polygon (
pointInPolygon) — crates first, then racks.
Rack view (renderShelfFrontView / renderRackDrilldown): show one rack_level_index at a time.
Crates on that level are ordered into slots along the rack width (virtual_crate.pos_x ascending);
each slot draws the crate square with: crate id, slot number (sequential), genre label, and an
orientation arrow derived from the crate's direction/rotation_y (← → ↑ ↓ = which way the spine faces).
BACK/FRONT and L/R labels come from the rack's direction. Level prev/next steps rack_level_index.
Crate view (renderCrateLookupDiagram): a record_rows × record_columns grid (from crate_type);
items placed by slot_number; empties dimmed. Crate Contents list = inventory WHERE crate_id=? ordered by slot_number.
4. API surface (RecordGod routes ← WowPlatter ajax actions)
All admin-gated, JSON, under /nav/* (new navigator_routes.py). Read side first, then writes.
| RecordGod route | replaces ajax | returns / does |
|---|---|---|
GET /nav/spaces |
get spaces | the room list for the selector |
GET /nav/store-layout?space_id= |
get_store_layout |
racks (+pos/rot/type/dims) + portals for the store view |
GET /nav/rack/{id} |
get_rack_details |
rack + its levels + crates-per-level (+pos/slot/orientation) |
GET /nav/crate/{id} |
get_crate_details + get_crate_contents |
crate info (name/label/rack/level/last-scanned) + items by slot |
GET /nav/crate/{id}/breakdown |
get_crate_content_breakdown/analytics |
genre/style mix, counts |
GET /nav/genres GET /nav/styles |
get_all_genres/styles |
tag pickers |
POST /nav/crate/{id} |
save crate | rename / relabel / set genres+styles |
POST /nav/rack/{id} |
save rack | rename / set genres+styles |
POST /nav/scan |
scan-to-slot | assign Release IDs/SKUs to a crate's slots sequentially (mode: replace/append) |
POST /nav/crate/{id}/compress |
compress_crate_slots |
close gaps in slot numbering |
POST /nav/compress-all |
compress_all_crate_slots |
compress every crate |
POST /nav/reorg |
apply_crate_reorganization |
move/merge crates between racks/levels; distribute items across slots/crates |
GET /nav/collections |
get_collections |
collection boxes (ties into the Discogs collection-box pricing flow) |
GET /nav/stock-finder?q= |
stock finder | locate any item → its crate/slot (this is today's /search logic, kept) |
inventory writes (crate_id/slot_number) reuse the non-destructive UPSERT rule — never clobber sold/hand-edited rows.
5. Front-end
New site/navigator.html (light theme) — replaces /search, keeps the same nav.js top bar.
One <canvas> + state.js-style module: { view, spaceId, rackId, levelIndex, crateId }.
Port the four renderers from main.js/diagram.js (store / rack-level-slots / crate-grid / item),
the polygon hit-test, level prev/next, rack-rotate, and the side panels (Crate Info / Rack Info editors,
Release Info, Quick Insert). Workflows: scanner scan-to-slot, returns, reorganize (move/merge/distribute),
compress, stock finder. release_id stays the universal key.
6. Phases (ship-thin, each usable)
- P0 — store view.
/nav/store-layout+ the top-down room render (racks at real pos/rot, click a rack). Upgrades today's flat/searchmap. Zero risk, immediate value. - P1 — rack drill-down + levels + slots.
/nav/rack/{id}; render one level, prev/next, slots with orientation arrows + genre labels. This is the tricky-geometry phase — do it carefully. - P2 — crate view + info editors. contents by slot, Crate/Rack Info panels (name/label/genre/style/last-scanned), compress slots.
- P3 — Scanner. active crate → scan Release IDs/SKUs → sequential slot assign (replace/append), Test/Process/Clear.
- P4 — Reorganize + Returns. move/merge crates, distribute items across slots; returns back to home crate.
- P5 — Collections + Stock Finder + polish. collection boxes, item view, rack-rotate, the "dope" pass.
Start P0→P1: that already gives a clickable store → rack → level → crate locate, which is the daily driver.
6b. Collections + the fallback location map (captured 2026-06-22)
Data already migrated: virtual_collection (14 rows). Model = a named zone:
name, color, crate_ids[] (JSON, in click order), priority, sort_method +
filing rules genre_ids[] / style_ids[] / label_ids[] / format_descriptions[] / price_min / price_max / year_min / year_max, is_active. Comment in the source: "grouping of crates with filing rules for smart
placement." Real examples: 2000s HOUSE = House style + price≥$15 + year 2000–2009 → 12 crates;
OLD SCHOOL RAP = year 1980–1986 → 1 crate; BREAKS 15+ = 4 breaks styles + $15+ → 10 crates.
Collections UI (Collections tab): list (colour swatch + crate count) → editor where you click crates on the
map and they're kept in click order as removable chips, set name/colour/priority, sort_method
(alpha_artist/title, year, price, date_added), filing rules, and a live match panel
(N matching · N located · N not located · N overlap). Save → virtual_collection.
THE fallback locator (John's headline ask). GET /nav/locate?release_id= (or sku):
- exact location first — if the record has
crate_id+slot_number, return that. - else match the record's
genre/style/label/format/price/yearagainst active collections,priorityDESC; first hit → return itscrate_idsas the "should-be-here" zone ("House $12–15 → these 4 crates"). The match panel's four numbers fall out of this: matching = records a collection's rules select, located = of those, how many actually sit in its crates, not located = match-but-misfiled, overlap = matched by >1 collection.
One data dependency to decide: collections match on discogs genre_ids/style_ids + year, which RecordGod's
slim disc_cache doesn't carry (only title/artist/thumb/weight). Options: (a) enrich disc_cache with
genre/style/year (backfill from discogs_full on ultra) so the matcher is self-contained — recommended; or
(b) denormalise genre/style/year onto inventory at intake. Price is already on inventory.
6c. Reorganize — the A-Z re-file planner (P4)
Server contract (thin; planning is client-side, copy WowPlatter's class-wowplatter-ajax-store-reorg.php):
POST /nav/reorg/scan {crate_ids[]}→ all in-stock items (sku, release_id, crate_id, slot, price, title, artist, year, thumb), ordered crate→slot. (Sources can be crate ids/ranges OR a collection's crates.)- Client plans: apply filters (price≤ / label / year-range) → pick a distribution target (start crate +
count + slots/crate) → sort by
sort_method→ fill target crates slot-by-slot → produce a move list[{sku, from_crate/slot, to_crate/slot}]+ leftovers. Card-by-card walkthrough modal (prev/next, MERGE, Undo). POST /nav/reorg/apply {matched[], leftovers[]}→ ONE transaction: per skuUPDATE inventory SET crate_id, slot_number, location_updated_*; leftovers →crate_id/slot=NULL, location=archive. Guard SKU-miss → roll back.- Slot housekeeping:
compress_crate_slots(close gaps),insert_with_shift(insert at slot, push others down).
Build order: Collections + fallback locator first (location-map foundation, data already there, every locate
benefits), then Reorganize (heavier planner UI). Both reuse the §4 /nav/* router.
7. Open checks before building
- Confirm
virtual_rack_type_level+ the crate-tag table actually came across in the migration (column names). - Confirm the slot-ordering rule WowPlatter uses (pos_x ascending vs an explicit
slot_numberon the crate). Crate facing field / compositionRESOLVED 2026-06-22 — full render convention captured in §3 ("Render convention"):directionYawtable, slot→grid placement, view-rotation matrix, therotatedArrowsfacing table, and the world-facing composition. P1 copies it verbatim.