Files
investor-flow/app/src/components/SymbolAutocomplete.tsx
T
Investor Flow Build ac94acf9e3
CI / Test & Type-Check (push) Canceled after 0s
feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
Snapshot of in-progress module work across multiple slices:

- Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain,
  dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels
- Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers,
  fund holdings strip, live book, position capture ingest
- Options: BSM, NormalizedOptionSurface types, OptionsChainRouter,
  ConvexityGate, option legs panel
- Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk,
  mirror (fund_capture, fund_13f, mirror_diff)
- FINRA short interest adapter + queue integration
- SEC company tickers adapter + ingest (symbol search index seed)
- Vendor gate (rate-limit-first data plane, ADR-0009)
- CUSIP registry, reverse 13F refresh, stock float service
- LRU cache, portfolio backtest engine
- Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio,
  reports, screener, strategies, theses, guided-start, exits, more pages
- Volume profile, workspace profile, visibility-aware poll
- ADRs 0010 (mirror math not advice), 0011 (symbol search index)
- VENDOR_INTEGRATIONS.md, END_USER_TEST.md
- .gitignore: exclude DBs, .DS_Store, local config, agent scratch
2026-08-10 13:36:26 -04:00

183 lines
6.5 KiB
TypeScript

"use client";
// Shared symbol autocomplete (M22 Symbol Search Index).
//
// Debounced local-first search against the symbol index (ADR-0009 — no live vendor
// call on the request path). Renders a dropdown of matches; Enter picks the top
// (or sole) result. Emits:
// onPick(symbol) — a known-index symbol was chosen
// onSubmitRaw(raw) — the user submitted text that did NOT match any known
// symbol (caller owns the hybrid soft-block flow)
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type SymbolSearchResult } from "@/lib/trpc";
export interface SymbolAutocompleteProps {
placeholder?: string;
/** Called with an exact index symbol match (already validated against the index). */
onPick: (symbol: string) => void;
/** Called when the user submits text that has no exact index match. */
onSubmitRaw: (raw: string) => void;
/** Called when a search query is in flight / completes (for busy indicators). */
onBusyChange?: (busy: boolean) => void;
/** Externally sync the current query value (for parent filtering). */
onChange?: (query: string) => void;
/** Controlled value — when provided, the input is externally managed. */
value?: string;
autoFocus?: boolean;
className?: string;
inputClassName?: string;
id?: string;
ariaLabel?: string;
disabled?: boolean;
}
export function SymbolAutocomplete({
placeholder = "Add symbol…",
onPick,
onSubmitRaw,
onBusyChange,
onChange: externalOnChange,
value: controlledValue,
autoFocus,
className = "",
inputClassName = "",
id,
ariaLabel = "Add symbol",
disabled,
}: SymbolAutocompleteProps) {
const [internalQuery, setInternalQuery] = useState("");
const query = controlledValue ?? internalQuery;
const [results, setResults] = useState<SymbolSearchResult[]>([]);
const [active, setActive] = useState(0);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastQueryRef = useRef("");
const runSearch = useCallback((q: string) => {
if (!q.trim()) {
setResults([]);
setOpen(false);
setBusy(false);
onBusyChange?.(false);
return;
}
setBusy(true);
onBusyChange?.(true);
api.symbols
.search({ q, limit: 8 })
.then((r) => {
setResults(r.results);
setActive(0);
setOpen(r.results.length > 0);
})
.catch(() => {
setResults([]);
setOpen(false);
})
.finally(() => {
setBusy(false);
onBusyChange?.(false);
});
}, [onBusyChange]);
const onChange = useCallback((v: string) => {
setInternalQuery(v);
externalOnChange?.(v);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => runSearch(v), 150);
}, [runSearch, externalOnChange]);
// Click-outside closes the dropdown.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
const submitCurrent = useCallback(() => {
const q = query.trim();
if (!q) return;
lastQueryRef.current = q;
// Prefer the exact symbol match; else the top result; else raw submit.
const exact = results.find((r) => r.symbol === q.toUpperCase());
if (exact) {
setOpen(false);
setInternalQuery("");
setResults([]);
onPick(exact.symbol);
return;
}
if (results.length === 1) {
setOpen(false);
setInternalQuery("");
setResults([]);
onPick(results[0].symbol);
return;
}
if (results.length > 1 && active >= 0 && active < results.length) {
setOpen(false);
setInternalQuery("");
setResults([]);
onPick(results[active].symbol);
return;
}
// No known match — hand the raw text to the caller (hybrid soft-block).
setOpen(false);
setInternalQuery("");
setResults([]);
onSubmitRaw(q);
}, [query, results, active, onPick, onSubmitRaw]);
return (
<div ref={boxRef} className={`relative ${className}`}>
<input
id={id}
value={query}
onChange={(e) => onChange(e.target.value)}
onFocus={() => { if (results.length) setOpen(true); }}
onKeyDown={(e) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, results.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === "Enter") { e.preventDefault(); submitCurrent(); }
else if (e.key === "Escape") { setOpen(false); }
}}
placeholder={placeholder}
autoFocus={autoFocus}
disabled={disabled}
aria-label={ariaLabel}
aria-expanded={open}
className={inputClassName || "w-full bg-surface border border-line rounded-md px-3 py-2.5 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"}
/>
{busy && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-fg-muted animate-pulse">…</span>
)}
{open && results.length > 0 && (
<ul className="absolute left-0 right-0 z-30 mt-1 max-h-64 overflow-y-auto rounded-md border border-line bg-surface-raised shadow-lg">
{results.map((r, i) => (
<li key={r.symbol}>
<button
type="button"
data-idx={i}
onMouseEnter={() => setActive(i)}
onClick={() => { setInternalQuery(r.symbol); setOpen(false); onPick(r.symbol); }}
className={`w-full text-left px-3 py-2 flex items-baseline gap-2 ${i === active ? "bg-surface-sunken" : ""}`}
>
<span className="text-sm font-semibold tnum text-fg">{r.symbol}</span>
<span className="flex-1 truncate text-xs text-fg-muted">{r.name ?? ""}</span>
{r.ticker_kind && (
<span className="text-[10px] uppercase tracking-wide text-fg-faint border border-line rounded px-1">{r.ticker_kind}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
);
}