RECORDGOD/RECORDGOD_NAVIGATOR_PLAN.md
type-two 879a58992f feat(collections): Collections CRUD + fallback locator + Reorganize scan/apply/compress API + Collections UI
- 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>
2026-06-22 20:56:34 +10:00

15 KiB
Raw Blame History

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_purposestock (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.directionfront/back/left/right — which way the whole rack faces in the room.
  • virtual_crate.directionforward/back/left/right — which way THAT crate faces (data: forward 59 / back 57 / left 114 / right 122). This is the facing field — NOT rotation_y (it's 0.0 on every row).
  • A crate's world facing = rack.directioncrate.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 says front — 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 to pos_x/pos_z only 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], where rotatedArrows[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 only crate.direction + view-rotation apply.

Store view (renderStoreView, diagram.js):

  1. bounds over all racks in the active space (pos ± half), createWorldToCanvas(min,max,pad) → fit+centre.
  2. each rack = 4 corners {±halfW,±halfD}rotatePoint(yaw)+pos → toCanvas → filled rotated rect + name.
  3. portals = door-span line + facing triangle at (x/roomW·W, z/roomD·H), facing_deg; orange=paired, red=unpaired.
  4. 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 /search map. 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 20002009 → 12 crates; OLD SCHOOL RAP = year 19801986 → 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):

  1. exact location first — if the record has crate_id+slot_number, return that.
  2. else match the record's genre/style/label/format/price/year against active collections, priority DESC; first hit → return its crate_ids as the "should-be-here" zone ("House $1215 → 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 sku UPDATE 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

  1. Confirm virtual_rack_type_level + the crate-tag table actually came across in the migration (column names).
  2. Confirm the slot-ordering rule WowPlatter uses (pos_x ascending vs an explicit slot_number on the crate).
  3. Crate facing field / composition RESOLVED 2026-06-22 — full render convention captured in §3 ("Render convention"): directionYaw table, slot→grid placement, view-rotation matrix, the rotatedArrows facing table, and the world-facing composition. P1 copies it verbatim.