docs(plan): inventory search redesign implementation plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 06:06:41 +02:00
parent edf76f50db
commit 93982ac3dd

View file

@ -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=<id>`.
- `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<string, number | ''>;
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<SearchFilters> = {};
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
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<SearchFilters>) => void;
reset: () => void;
result: SearchResponse | null;
loading: boolean;
error: string | null;
queryMs: number | null;
}
export function useInventorySearch(): InventorySearch {
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
const [result, setResult] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queryMs, setQueryMs] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const update = useCallback((patch: Partial<SearchFilters>) => {
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' ? <PlayerDashboardFullPage />
: view === 'inventory' ? <InventorySearchPage />
: <DefaultApp />}
```
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<InvItem | null>(null);
return (
<div className="inv-page">
<div className="inv-topbar">
<span className="inv-title">⚔ Inventory Search</span>
<input
className="inv-searchbox"
placeholder="Search name or material…"
value={search.filters.text}
onChange={e => search.update({ text: e.target.value })}
/>
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
<span className="inv-count">
{search.error ? <span className="inv-error">{search.error}</span>
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
{search.queryMs != null && <> · {search.queryMs} ms</>}
{search.loading && ' · …'}</>
: 'loading…'}
</span>
</div>
<ActiveChips search={search} />
<div className="inv-main">
<FilterSidebar search={search} />
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
</div>
</div>
);
}
```
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 <div className="inv-sidebar" />; }
```
```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 <div className="inv-results" />;
}
```
```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 `<style>` block (see `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html`) with class names prefixed `inv-` and BEM-ish nesting flattened. Required tokens: page bg `#111`, panels `#1a1a1a`, borders `#333`, accent `#88f`, legendary gold `#fc6` (chip bg `#2a2418`, border `#a80`), selected row bg `#20203a`, detail bg `#161620`, font `"Segoe UI", sans-serif`, sidebar width 210px, detail width 250px, sticky table header on `#191919`. Copy the mockup's rules for: `.topbar→.inv-topbar`, `.searchbox→.inv-searchbox`, `.btn→.inv-btn`, `.chipsrow/.chip→.inv-chipsrow/.inv-chip(.gold)`, `.sidebar/.grp/.grp-head/.grp-body/.badge/.minisearch/.fitem/.subhead/.linky/.range→.inv-*`, table rules (`.inv-results table/th/td`, `tr.inv-sel`, `.inv-leg`, `.inv-spells`, `.inv-equipped`, `.inv-pager/.inv-pg(.cur)`), `.detail→.inv-detail` with `.inv-kv/.inv-sp(.leg)/.inv-sphead/.inv-hint/.inv-closex`. Add `.inv-page{display:flex;flex-direction:column;height:100vh;background:#111;color:#eee;font-family:"Segoe UI",sans-serif}` and `.inv-main{display:flex;flex:1;overflow:hidden}`, `.inv-error{color:#c66}`.
- [ ] **Step 4: Build** — green. Manually sanity-check nothing else broke: `npm run build` output lists the new css/js chunks.
- [ ] **Step 5: Commit**`feat(frontend): /?view=inventory route + inventory page scaffold + dark CSS`
---
### Task 4: FilterSidebar (all filter groups)
**Files:**
- Replace stub: `frontend/src/components/inventory/FilterSidebar.tsx`
- Create: `frontend/src/components/inventory/CharacterFilter.tsx`
- Create: `frontend/src/components/inventory/CantripFilter.tsx`
- [ ] **Step 1: Shared collapsible group.** At the top of `FilterSidebar.tsx`:
```tsx
import { useEffect, useState } from 'react';
import type { InventorySearch } from './useInventorySearch';
import { CharacterFilter } from './CharacterFilter';
import { CantripFilter } from './CantripFilter';
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
import type { ItemType } from './types';
import { apiFetch } from '../../api/client';
export function Group(props: {
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
}) {
const [open, setOpen] = useState(props.defaultOpen ?? false);
return (
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
<span className="inv-arrow"></span> {props.title}
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
</div>
{open && <div className="inv-grp-body">{props.children}</div>}
</div>
);
}
```
- [ ] **Step 2: `FilterSidebar` body** — groups in mockup order. Item type radios (All/Armor/Jewelry/Weapons+subtype select/Clothing/Shirts/Pants), Slots (jewelry slots always visible, armor slots behind a "show armor slots" toggle), Ratings (common four + "all ratings" toggle), Sets (fetched once from `/inv/sets/list`, searchable, single-select radio list sending `id`), Item state, Reqs & value:
```tsx
export function FilterSidebar({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [showArmorSlots, setShowArmorSlots] = useState(false);
const [allRatings, setAllRatings] = useState(false);
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
const [setQuery, setSetQuery] = useState('');
useEffect(() => {
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
.then(r => setSets(r.sets)).catch(() => {});
}, []);
const toggleSlot = (s: string) => update({
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
});
const setRating = (param: string, v: string) => update({
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
});
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
const stateCount = [f.equippedOnly, f.bonded, f.attuned, f.rare].filter(Boolean).length;
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
['shirt', 'Shirts'], ['pants', 'Pants']];
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
<input type="radio" name="inv-t" checked={f.itemType === v}
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
</label>
))}
{f.itemType === 'weapon' && (
<select className="inv-minisearch" value={f.weaponType}
onChange={e => update({ weaponType: e.target.value })}>
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
{JEWELRY_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
</span>
{showArmorSlots && ARMOR_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
</Group>
<CantripFilter search={search} />
<Group title="Ratings" badge={ratingCount || undefined}>
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
<div className="inv-range" key={r.param}>
<label>{r.label} ≥</label>
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
onChange={e => setRating(r.param, e.target.value)} />
</div>
))}
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
</span>
</Group>
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
onChange={e => setSetQuery(e.target.value)} />
<label className="inv-fitem">
<input type="radio" name="inv-set" checked={f.itemSet === ''}
onChange={() => update({ itemSet: '' })} /> Any set
</label>
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
<label className="inv-fitem" key={s.id}>
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
</label>
))}
</Group>
<Group title="Item state" badge={stateCount || undefined}>
<label className="inv-fitem"><input type="checkbox" checked={f.equippedOnly}
onChange={e => update({ equippedOnly: e.target.checked })} /> Equipped only</label>
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
</Group>
<Group title="Reqs & value" badge={reqCount || undefined}>
<div className="inv-range"><label>Wield lvl ≤</label>
<input type="number" value={f.maxLevel} placeholder="max"
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Value ≥</label>
<input type="number" value={f.minValue} placeholder="min"
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Workmanship ≥</label>
<input type="number" value={f.minWorkmanship} placeholder="min"
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Burden ≤</label>
<input type="number" value={f.maxBurden} placeholder="max"
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
</Group>
</div>
);
}
```
Add `.inv-dim{color:#666;font-size:10px}` to inventory.css.
- [ ] **Step 3: `CharacterFilter.tsx`** — full list from `/inv/characters/list`, online set from `/live` (one-shot fetches, no WebSocket), searchable, All/None/Online links:
```tsx
import { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../../api/client';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CharacterFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [names, setNames] = useState<string[]>([]);
const [online, setOnline] = useState<Set<string>>(new Set());
const [q, setQ] = useState('');
useEffect(() => {
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
.catch(() => {});
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
.catch(() => {});
}, []);
const checked = useMemo(() =>
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
const toggle = (n: string) => {
const next = new Set(checked);
if (next.has(n)) next.delete(n); else next.add(n);
update({ characters: next.size === names.length ? 'all' : [...next] });
};
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
return (
<Group title="Characters" defaultOpen
badge={f.characters === 'all' ? 'All' : f.characters.length}>
<input className="inv-minisearch" placeholder="filter characters…"
value={q} onChange={e => setQ(e.target.value)} />
<div className="inv-links">
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
</div>
{shown.map(n => (
<label className="inv-fitem" key={n}>
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
{n} {online.has(n) && <span className="inv-online" />}
</label>
))}
</Group>
);
}
```
Add to inventory.css: `.inv-online{width:6px;height:6px;border-radius:50%;background:#4c4;display:inline-block;margin-left:4px}` and `.inv-links{margin-bottom:4px;font-size:10px}`.
Note: `characters: []` (None) sends `characters=` empty → backend returns the "Empty characters list" error object; the hook surfaces it as an error string. Acceptable — the count area shows the message and choosing any character recovers. (Matches old-page behavior of requiring a character selection.)
- [ ] **Step 4: `CantripFilter.tsx`** — find-as-you-type, grouped, checked pinned to a Selected section, plus the any-tier `spell_contains` input:
```tsx
import { useState } from 'react';
import { CANTRIP_GROUPS } from './constants';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CantripFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [q, setQ] = useState('');
const toggle = (v: string) => update({
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
});
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
return (
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
value={q} onChange={e => setQ(e.target.value)} />
{f.cantrips.length > 0 && (<>
<div className="inv-subhead">Selected</div>
{f.cantrips.map(v => (
<label className="inv-fitem inv-gold" key={v}>
<input type="checkbox" checked onChange={() => toggle(v)} />
{v.replace(/^Legendary /, '')}
</label>
))}
</>)}
{CANTRIP_GROUPS.map(g => {
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
if (!items.length) return null;
return (
<div key={g.group}>
<div className="inv-subhead">{g.group}</div>
{items.map(i => (
<label className="inv-fitem" key={i.value}>
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
</label>
))}
</div>
);
})}
<div className="inv-subhead">Any-tier spell search</div>
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
</Group>
);
}
```
Add `.inv-gold{color:#fc6}` to inventory.css.
- [ ] **Step 5: Build** — green.
- [ ] **Step 6: Commit**`feat(frontend): inventory filter sidebar (characters, type, slots, cantrips, ratings, sets, state, reqs)`
---
### Task 5: ActiveChips + ResultsTable
**Files:**
- Replace stubs: `frontend/src/components/inventory/ActiveChips.tsx`, `frontend/src/components/inventory/ResultsTable.tsx`
- [ ] **Step 1: `ActiveChips.tsx`** — derive chips from filters; × patches the filter away; legendary chips gold:
```tsx
import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS } from './constants';
import { DEFAULT_FILTERS } from './types';
interface Chip { label: string; gold?: boolean; remove: () => void; }
export function ActiveChips({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const chips: Chip[] = [];
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
if (f.characters !== 'all') chips.push({
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
remove: () => update({ characters: 'all' }),
});
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponType: '' }),
});
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
for (const c of f.cantrips) chips.push({
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
});
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
if (!chips.length) return null;
return (
<div className="inv-chipsrow">
<span className="inv-chips-lbl">ACTIVE</span>
{chips.map((c, i) => (
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
</span>
))}
</div>
);
}
```
(Unused import `DEFAULT_FILTERS` must not remain — do not include it; shown here as a reminder NOT to add it.) Remove that import line before committing.
- [ ] **Step 2: `ResultsTable.tsx`** — sticky sortable header, column picker (localStorage), spells with gold legendaries, equipped marker, pagination:
```tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
function loadVisible(): Set<string> {
try {
const raw = localStorage.getItem(COLUMNS_LS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch { /* fall through */ }
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
}
function cell(item: InvItem, key: string): React.ReactNode {
switch (key) {
case 'name':
return <>{item.name}{item.is_equipped && <span className="inv-equipped"></span>}</>;
case 'spell_names':
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
))}</span>;
case 'value':
return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
default: {
const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v);
}
}
}
export function ResultsTable({ search, selected, onSelect }: {
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
}) {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
const items = result?.items ?? [];
useEffect(() => {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
if (e.key === 'Escape') { onSelect(null); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const idx = selected ? items.indexOf(selected) : -1;
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
if (items[next]) onSelect(items[next]);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect]);
const sortOn = (sortKey?: string) => {
if (!sortKey) return;
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
else update({ sortBy: sortKey, sortDir: 'asc' });
};
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
const goto = (p: number) => {
update({ page: Math.min(Math.max(1, p), totalPages) });
scrollRef.current?.scrollTo(0, 0);
};
return (
<div className="inv-results" ref={scrollRef}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
{pickerOpen && (
<div className="inv-colpicker">
{COLUMNS.map(c => (
<label className="inv-fitem" key={c.key}>
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
const next = new Set(visible);
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
setVisible(next);
}} /> {c.label}
</label>
))}
</div>
)}
</div>
<table>
<thead><tr>
{cols.map(c => (
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
))}
</tr></thead>
<tbody>
{items.map((it, i) => (
<tr key={i} className={it === selected ? 'inv-sel' : ''}
onClick={() => onSelect(it === selected ? null : it)}>
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
</tr>
))}
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
</tbody>
</table>
<div className="inv-pager">
<span className="inv-pg" onClick={() => goto(f.page - 1)}>◀</span>
<span>page {result?.page ?? 1} / {totalPages}</span>
<span className="inv-pg" onClick={() => goto(f.page + 1)}>▶</span>
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
</div>
</div>
);
}
```
Move the "⚙ Columns" button here (remove it from the top bar if Task 3 put one there — Task 3's top bar has no Columns button, correct). CSS additions: `.inv-colpicker-anchor{position:relative;align-self:flex-end;padding:4px 10px}`, `.inv-colpicker{position:absolute;right:10px;top:30px;background:#1a1a1a;border:1px solid #444;border-radius:4px;padding:8px;z-index:5;max-height:300px;overflow-y:auto}`, `.inv-sorted-asc::after{content:" ▲";font-size:9px}`, `.inv-sorted-desc::after{content:" ▼";font-size:9px}`, `.inv-pager-right{margin-left:auto}`.
- [ ] **Step 3: Build** — green.
- [ ] **Step 4: Commit**`feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard)`
---
### Task 6: DetailPanel
**Files:**
- Replace stub: `frontend/src/components/inventory/DetailPanel.tsx`
- [ ] **Step 1: Implement:**
```tsx
import type { InvItem } from './types';
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
}
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
return (
<div className="inv-detail">
<span className="inv-closex" onClick={onClose}>×</span>
<h3>{item.name}</h3>
<div className="inv-detail-sub">
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
{item.is_rare && ' · ★ Rare'}
</div>
<Row k="Character" v={item.character_name} />
<Row k="Value" v={item.value?.toLocaleString()} />
<Row k="Burden" v={item.burden} />
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
<Row k="Workmanship" v={item.workmanship ?? ''} />
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
<hr />
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
{(item.spell_names ?? []).map((s, i) => (
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
))}
<div className="inv-hint">↑/↓ next item · Esc close</div>
</div>
);
}
```
- [ ] **Step 2: Build** — green.
- [ ] **Step 3: Commit**`feat(frontend): inventory item detail panel`
---
### Task 7: Cutover — link + delete old page
**Files:**
- Modify: `frontend/src/components/map/Sidebar.tsx:101`
- Delete: `static/inventory.html`, `static/inventory.js`
- [ ] **Step 1:** In `Sidebar.tsx` line 101 change
`<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
to
`<a href="/?view=inventory" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
- [ ] **Step 2:** `git rm static/inventory.html static/inventory.js`
- [ ] **Step 3:** Grep for stragglers: `grep -rn "inventory.html" frontend/src static/*.html static/classic docs/superpowers/specs/2026-07-15*.md` — only historical docs/specs may reference it (leave those; they're records). If `static/classic/` links to it, update that link the same way.
- [ ] **Step 4: Build** — green.
- [ ] **Step 5: Commit**`feat(frontend)!: replace inventory.html with /?view=inventory (set analysis dropped)`
---
### Task 8: Deploy + live verification
- [ ] **Step 1:** `bash deploy-frontend.sh` from the repo root (runs the production build and copies `_build/` into `static/`).
- [ ] **Step 2:** `git add static/ && git commit -m "build(frontend): deploy inventory search redesign" && git push origin master`
- [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"` (bind mount serves it; no restart).
- [ ] **Step 4: Smoke checks** (unauthenticated, from local shell):
- `curl -s https://overlord.snakedesert.se/ | grep -o 'assets/index-[^"]*\.js'` — new asset hash present.
- `curl -s -o /dev/null -w '%{http_code}' https://overlord.snakedesert.se/inventory.html` — no longer the old page (SPA fallback 200 serving index.html is expected; verify body contains the React root div, not "Inventory Search - Dereth Tracker" title).
- [ ] **Step 5: User verification** (requires login — ask the user): open `/?view=inventory`, confirm: jewelry + Invuln + Summon → the known 2 items; Ring slot + Invuln → 56; chips remove correctly; sort by Value; column picker persists after reload; detail panel + arrow keys; a bookmarked URL with filters restores them.
---
## Self-review notes
- Spec coverage: every spec bullet maps to a task (routing→3, sidebar groups→4, chips→5, table/sort/columns/paging→5, detail+keyboard→5/6, URL sharing→2, cutover→7, deploy/verify→8). Set analysis intentionally absent.
- The `characters: []` edge intentionally surfaces the backend's error message (documented in Task 4 Step 3).
- Keyboard handling lives in ResultsTable (needs items+selected); spec said "page" — acceptable placement, noted.
- No new deps; no backend changes anywhere.