From 93982ac3dd714556a9657ed3f4552ed62c82e69c Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 06:06:41 +0200 Subject: [PATCH] docs(plan): inventory search redesign implementation plan Co-Authored-By: Claude Fable 5 --- .../2026-07-15-inventory-search-redesign.md | 986 ++++++++++++++++++ 1 file changed, 986 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-inventory-search-redesign.md diff --git a/docs/superpowers/plans/2026-07-15-inventory-search-redesign.md b/docs/superpowers/plans/2026-07-15-inventory-search-redesign.md new file mode 100644 index 00000000..e1730f51 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-inventory-search-redesign.md @@ -0,0 +1,986 @@ +# Inventory Search Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `static/inventory.html`/`inventory.js` with a dark-themed React full-page inventory search at `/?view=inventory` (facet sidebar, chips, instant search, detail panel), per the approved spec + mockup. + +**Architecture:** New self-contained component tree under `frontend/src/components/inventory/` with a single `useInventorySearch` hook owning filter state, debounce, aborting fetch, and URL serialization. Zero backend changes — everything talks to the existing `GET /api/inv/search/items` (plus `/api/inv/characters/list`, `/api/inv/sets/list`, `/api/live`). Spec: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-design.md`; visual ground truth: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html` (styling/spacing/colors MUST follow it). + +**Tech Stack:** React 19 + TypeScript + Vite (existing app, NO new npm deps). No test framework exists — per-task verification is `npm run build` (runs `tsc -b`) green; live verification is the final task. npm exists on this Windows machine; run builds via the Bash tool. + +**Working directory for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord` +**Build command (every task):** +```bash +cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build +``` +(If `node_modules` is missing, run `npm install` once first.) + +**API cheat-sheet (verified live):** +- `GET /api/inv/search/items?...` → `{items:[...], total_count, page, limit, has_next, has_previous}`. Item fields used here: `name, character_name, slot_name, value, burden, wield_level, workmanship, spell_names (string[]), is_equipped, is_bonded, is_attuned, is_rare, item_set_name, armor_level, max_damage, condition_percent, object_class_name, last_updated`. +- Params: `text, character, characters, include_all_characters, armor_only, jewelry_only, weapon_only, weapon_type, clothing_only, shirt_only, pants_only, slot_names, legendary_cantrips (CSV, AND), spell_contains, equipment_status (equipped|unequipped), bonded, attuned, is_rare, item_set, min_* rating params, max_level, min_value, min_workmanship, max_burden, sort_by, sort_dir, page, limit`. +- `GET /api/inv/characters/list` → `{characters:[{character_name, item_count, last_updated}]}` +- `GET /api/inv/sets/list` → `{sets:[{id, item_count, name}]}` — display `id` (e.g. "Defender's Set"); the `name` field has a broken "Unknown Set " prefix, ignore it. Send `item_set=`. +- `GET /api/live` → `{players:[{character_name, ...}]}` — online characters. + +--- + +### Task 1: Types + constants module + +**Files:** +- Create: `frontend/src/components/inventory/types.ts` +- Create: `frontend/src/components/inventory/constants.ts` + +- [ ] **Step 1: Create `types.ts`:** + +```ts +// Item shape returned by /api/inv/search/items (subset we render). +export interface InvItem { + name: string; + character_name: string; + slot_name: string | null; + value: number | null; + burden: number | null; + wield_level: number | null; + workmanship: number | null; + spell_names?: string[]; + is_equipped: boolean; + is_bonded: boolean; + is_attuned: boolean; + is_rare: boolean; + item_set_name?: string; + armor_level: number | null; + max_damage: number | null; + condition_percent: number | null; + object_class_name?: string; + material_name?: string; + last_updated?: string; +} + +export interface SearchResponse { + items: InvItem[]; + total_count: number; + page: number; + limit: number; + has_next: boolean; + has_previous: boolean; + error?: string; +} + +export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants'; + +export interface SearchFilters { + text: string; + /** 'all' → include_all_characters=true; otherwise explicit list. */ + characters: 'all' | string[]; + itemType: ItemType; + weaponType: string; // '' = all weapon types + slots: string[]; + cantrips: string[]; // full "Legendary X" value strings + spellContains: string; + /** min-rating param name -> value ('' = unset). */ + ratings: Record; + itemSet: string; // set id, '' = none + equippedOnly: boolean; + bonded: boolean; + attuned: boolean; + rare: boolean; + maxLevel: number | ''; + minValue: number | ''; + minWorkmanship: number | ''; + maxBurden: number | ''; + sortBy: string; + sortDir: 'asc' | 'desc'; + page: number; +} + +export const DEFAULT_FILTERS: SearchFilters = { + text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [], + cantrips: [], spellContains: '', ratings: {}, itemSet: '', + equippedOnly: false, bonded: false, attuned: false, rare: false, + maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '', + sortBy: 'name', sortDir: 'asc', page: 1, +}; +``` + +- [ ] **Step 2: Create `constants.ts`** — the 47 cantrip values are lifted verbatim from the old page's checkboxes (they are what the backend's `legendary_cantrips` matcher expects); labels drop the "Legendary " prefix: + +```ts +export interface CantripDef { value: string; label: string; } +export interface CantripGroup { group: string; items: CantripDef[]; } + +const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') }); + +export const CANTRIP_GROUPS: CantripGroup[] = [ + { group: 'Attributes', items: [ + c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'), + c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'), + ]}, + { group: 'Weapon skills', items: [ + c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'), + c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'), + c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'), + c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'), + c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'), + c('Legendary Defender'), c('Legendary Blood Thirst'), + ]}, + { group: 'Magic', items: [ + c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'), + c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'), + c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'), + c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'), + c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'), + ]}, + { group: 'Utility', items: [ + c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'), + c('Legendary Leadership'), c('Legendary Deception Prowess'), + c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'), + ]}, + { group: 'Defense', items: [ + c('Legendary Invulnerability'), c('Legendary Impenetrability'), + c('Legendary Impregnability'), c('Legendary Armor'), + ]}, + { group: 'Wards & Banes', items: [ + c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'), + c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'), + c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'), + ]}, +]; + +export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak']; +export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms', + 'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield']; + +export const WEAPON_TYPES: Array<{ value: string; label: string }> = [ + { value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' }, + { value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' }, + { value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' }, + { value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' }, + { value: 'caster', label: 'Wand/Staff/Orb' }, +]; + +export interface RatingDef { param: string; label: string; common?: boolean; } +export const RATING_DEFS: RatingDef[] = [ + { param: 'min_damage_rating', label: 'Damage rating', common: true }, + { param: 'min_crit_damage_rating', label: 'Crit damage', common: true }, + { param: 'min_heal_boost_rating', label: 'Heal boost', common: true }, + { param: 'min_vitality_rating', label: 'Vitality', common: true }, + { param: 'min_armor', label: 'Armor level' }, + { param: 'min_damage_resist_rating', label: 'Damage resist' }, + { param: 'min_crit_resist_rating', label: 'Crit resist' }, + { param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' }, + { param: 'min_healing_resist_rating', label: 'Healing resist' }, + { param: 'min_nether_resist_rating', label: 'Nether resist' }, + { param: 'min_healing_rating', label: 'Healing rating' }, + { param: 'min_dot_resist_rating', label: 'DoT resist' }, + { param: 'min_life_resist_rating', label: 'Life resist' }, + { param: 'min_sneak_attack_rating', label: 'Sneak attack' }, + { param: 'min_recklessness_rating', label: 'Recklessness' }, + { param: 'min_deception_rating', label: 'Deception' }, + { param: 'min_pk_damage_rating', label: 'PK damage' }, + { param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' }, + { param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' }, + { param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' }, + { param: 'min_tinks', label: 'Tinks' }, +]; + +export interface ColumnDef { + key: string; label: string; sortKey?: string; defaultVisible: boolean; +} +// sortKey values must exist in the backend's sortMapping (search.go). +export const COLUMNS: ColumnDef[] = [ + { key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true }, + { key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true }, + { key: 'slot_name', label: 'Slot', defaultVisible: true }, + { key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true }, + { key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true }, + { key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true }, + { key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false }, + { key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false }, + { key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false }, + { key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false }, + { key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false }, + { key: 'burden', label: 'Burden', defaultVisible: false }, + { key: 'condition_percent', label: 'Cond %', defaultVisible: false }, + { key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false }, +]; + +export const PAGE_SIZE = 200; +export const COLUMNS_LS_KEY = 'inv.visibleColumns'; +``` + +- [ ] **Step 3: Build** — expected green (files compile standalone). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/inventory/ +git commit -m "feat(frontend): inventory search types + filter/column constants" +``` + +--- + +### Task 2: useInventorySearch hook + +**Files:** +- Create: `frontend/src/components/inventory/useInventorySearch.ts` + +- [ ] **Step 1: Create the hook** — owns filters, URL (de)serialization (single `q` JSON param alongside `view=inventory`), 400 ms debounce, AbortController fetch: + +```ts +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types'; +import { PAGE_SIZE } from './constants'; + +/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */ +function filtersToUrl(f: SearchFilters): void { + const diff: Partial = {}; + for (const k of Object.keys(DEFAULT_FILTERS) as Array) { + if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k]; + } + const url = new URL(window.location.href); + url.searchParams.set('view', 'inventory'); + if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff)); + else url.searchParams.delete('q'); + window.history.replaceState(null, '', url); +} + +function filtersFromUrl(): SearchFilters { + try { + const q = new URLSearchParams(window.location.search).get('q'); + if (!q) return DEFAULT_FILTERS; + return { ...DEFAULT_FILTERS, ...JSON.parse(q) }; + } catch { + return DEFAULT_FILTERS; + } +} + +export function buildParams(f: SearchFilters): URLSearchParams { + const p = new URLSearchParams(); + if (f.characters === 'all') p.set('include_all_characters', 'true'); + else if (f.characters.length === 1) p.set('character', f.characters[0]); + else p.set('characters', f.characters.join(',')); + + if (f.text) p.set('text', f.text); + switch (f.itemType) { + case 'armor': p.set('armor_only', 'true'); break; + case 'jewelry': p.set('jewelry_only', 'true'); break; + case 'clothing': p.set('clothing_only', 'true'); break; + case 'shirt': p.set('shirt_only', 'true'); break; + case 'pants': p.set('pants_only', 'true'); break; + case 'weapon': + p.set('weapon_only', 'true'); + if (f.weaponType) p.set('weapon_type', f.weaponType); + break; + } + if (f.slots.length) p.set('slot_names', f.slots.join(',')); + if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(',')); + if (f.spellContains) p.set('spell_contains', f.spellContains); + for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v)); + if (f.itemSet) p.set('item_set', f.itemSet); + if (f.equippedOnly) p.set('equipment_status', 'equipped'); + if (f.bonded) p.set('bonded', 'true'); + if (f.attuned) p.set('attuned', 'true'); + if (f.rare) p.set('is_rare', 'true'); + if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel)); + if (f.minValue !== '') p.set('min_value', String(f.minValue)); + if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship)); + if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden)); + p.set('sort_by', f.sortBy); + p.set('sort_dir', f.sortDir); + p.set('page', String(f.page)); + p.set('limit', String(PAGE_SIZE)); + return p; +} + +export interface InventorySearch { + filters: SearchFilters; + /** Patch filters; resets page to 1 unless the patch itself sets page. */ + update: (patch: Partial) => void; + reset: () => void; + result: SearchResponse | null; + loading: boolean; + error: string | null; + queryMs: number | null; +} + +export function useInventorySearch(): InventorySearch { + const [filters, setFilters] = useState(filtersFromUrl); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [queryMs, setQueryMs] = useState(null); + const abortRef = useRef(null); + + const update = useCallback((patch: Partial) => { + setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch })); + }, []); + const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []); + + useEffect(() => { + filtersToUrl(filters); + const timer = setTimeout(async () => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + const t0 = performance.now(); + try { + const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, { + credentials: 'include', signal: ctrl.signal, + }); + if (!res.ok) throw new Error(`search: HTTP ${res.status}`); + const data: SearchResponse = await res.json(); + if (data.error) throw new Error(data.error); + setResult(data); + setError(null); + setQueryMs(Math.round(performance.now() - t0)); + } catch (e: any) { + if (e?.name !== 'AbortError') setError(String(e?.message ?? e)); + } finally { + if (abortRef.current === ctrl) setLoading(false); + } + }, 400); + return () => clearTimeout(timer); + }, [filters]); + + return { filters, update, reset, result, loading, error, queryMs }; +} +``` + +- [ ] **Step 2: Build** — green. +- [ ] **Step 3: Commit** — `feat(frontend): useInventorySearch hook (debounced abortable search, URL state)` + +--- + +### Task 3: Routing, page scaffold, CSS + +**Files:** +- Modify: `frontend/src/App.tsx` +- Create: `frontend/src/components/inventory/InventorySearchPage.tsx` +- Create: `frontend/src/styles/inventory.css` + +- [ ] **Step 1: Route.** In `App.tsx`, add the import and branch (current file branches only on `'dashboard'`): + +```tsx +import { InventorySearchPage } from './components/inventory/InventorySearchPage'; +import './styles/inventory.css'; +``` +and change the ternary to: +```tsx + {view === 'dashboard' ? + : view === 'inventory' ? + : } +``` +Also update the header comment to document `/?view=inventory`. + +- [ ] **Step 2: Page scaffold** — `InventorySearchPage.tsx`. Full layout with top bar wired to the hook; sidebar/table/detail rendered from components built in Tasks 4-6 (create with placeholder stubs now so this task builds; the stubs are replaced by the real components in their tasks): + +```tsx +import { useState } from 'react'; +import { useInventorySearch } from './useInventorySearch'; +import { FilterSidebar } from './FilterSidebar'; +import { ActiveChips } from './ActiveChips'; +import { ResultsTable } from './ResultsTable'; +import { DetailPanel } from './DetailPanel'; +import type { InvItem } from './types'; + +export function InventorySearchPage() { + const search = useInventorySearch(); + const [selected, setSelected] = useState(null); + + return ( +
+
+ ⚔ Inventory Search + search.update({ text: e.target.value })} + /> + + + {search.error ? {search.error} + : search.result ? <>{search.result.total_count.toLocaleString()} items + {search.queryMs != null && <> · {search.queryMs} ms} + {search.loading && ' · …'} + : 'loading…'} + +
+ +
+ + + {selected && setSelected(null)} />} +
+
+ ); +} +``` + +For this task only, create minimal stub files so the build passes (each replaced by its real task): +```tsx +// FilterSidebar.tsx (stub — replaced in Task 4) +import type { InventorySearch } from './useInventorySearch'; +export function FilterSidebar(_: { search: InventorySearch }) { return
; } +``` +```tsx +// ActiveChips.tsx (stub — replaced in Task 5) +import type { InventorySearch } from './useInventorySearch'; +export function ActiveChips(_: { search: InventorySearch }) { return null; } +``` +```tsx +// ResultsTable.tsx (stub — replaced in Task 5) +import type { InventorySearch } from './useInventorySearch'; +import type { InvItem } from './types'; +export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) { + return
; +} +``` +```tsx +// DetailPanel.tsx (stub — replaced in Task 6) +import type { InvItem } from './types'; +export function DetailPanel(_: { item: InvItem; onClose: () => void }) { return null; } +``` + +- [ ] **Step 3: CSS** — `frontend/src/styles/inventory.css`. Port the approved mockup's `