feat(frontend): replace inventory pagination with one-shot load + incremental scroll render

Sort/filter always operate on the full result set server-side; the whole set
(up to 10k) loads in one request and rows render progressively on scroll.
Over-cap searches show a "narrow your filters" notice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 21:52:50 +02:00
parent e4960eae5e
commit 936628065c
16 changed files with 87 additions and 63 deletions

View file

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
import { COLUMNS, COLUMNS_LS_KEY, RENDER_CHUNK } from './constants';
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
@ -40,6 +40,9 @@ export function ResultsTable({ search, selected, onSelect }: {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
// Incremental rendering: the full result set is loaded in one request, but
// only renderCount rows are in the DOM; scrolling near the bottom grows it.
const [renderCount, setRenderCount] = useState(RENDER_CHUNK);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => {
const eff = new Set(visible);
@ -47,6 +50,21 @@ export function ResultsTable({ search, selected, onSelect }: {
return COLUMNS.filter(c => eff.has(c.key));
}, [visible, f.itemType]);
const items = result?.items ?? [];
const shown = items.slice(0, renderCount);
// New result set (search/sort/filter change) → jump back to the top.
useEffect(() => {
setRenderCount(RENDER_CHUNK);
scrollRef.current?.scrollTo(0, 0);
}, [result]);
const onScroll = () => {
const el = scrollRef.current;
if (!el || renderCount >= items.length) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 600) {
setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
}
};
useEffect(() => {
try {
@ -65,25 +83,24 @@ export function ResultsTable({ search, selected, onSelect }: {
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]);
if (items[next]) {
onSelect(items[next]);
// Keep keyboard navigation working past the rendered window.
if (next >= renderCount) setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect]);
}, [items, selected, onSelect, renderCount]);
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-results" ref={scrollRef} onScroll={onScroll}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}> Columns</button>
{pickerOpen && (
@ -108,7 +125,7 @@ export function ResultsTable({ search, selected, onSelect }: {
))}
</tr></thead>
<tbody>
{items.map((it, i) => (
{shown.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>)}
@ -117,12 +134,14 @@ export function ResultsTable({ search, selected, onSelect }: {
{!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>
{renderCount < items.length && (
<div className="inv-pager"><span className="inv-dim">Scroll for more ({shown.length.toLocaleString()} of {items.length.toLocaleString()} shown)</span></div>
)}
{result?.has_next && renderCount >= items.length && (
<div className="inv-pager"><span className="inv-dim">
Showing the first {items.length.toLocaleString()} of {result.total_count.toLocaleString()} matches narrow your filters to see the rest.
</span></div>
)}
</div>
);
}

View file

@ -101,5 +101,10 @@ export const COLUMNS: ColumnDef[] = [
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
];
export const PAGE_SIZE = 200;
// One-shot fetch: every search loads the full result set in a single request
// (no pagination); rows are rendered incrementally on scroll. Capped at 10k —
// a filterless all-characters browse is ~38k items / 65 MB JSON, which no
// browser should be asked to swallow; real searches are a few thousand rows.
export const PAGE_SIZE = 10000;
export const RENDER_CHUNK = 300;
export const COLUMNS_LS_KEY = 'inv.visibleColumns';