feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
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
This commit is contained in:
@@ -54,12 +54,14 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
/** Inner layout with sidebar — separates auth check from rendering. */
|
||||
function AdminShell({ user, children }: { user: AuthUser; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-fg flex">
|
||||
// h-dvh + overflow-hidden: body is overflow-hidden, so the app shell must
|
||||
// constrain to the viewport and scroll inside <main> (document never scrolls).
|
||||
<div className="h-dvh min-h-0 bg-surface text-fg flex overflow-hidden">
|
||||
{/* Left Sidebar Navigation */}
|
||||
<SidebarNav />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
<main className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Admin Header */}
|
||||
<header className="border-b border-line-strong pb-4 mb-2">
|
||||
|
||||
@@ -48,7 +48,7 @@ function gradeBadge(grade: string | null): string {
|
||||
}
|
||||
|
||||
export function AnalystRatings({ symbol }: Props) {
|
||||
const [ratings, setRatings] = useState<Array<{ firm: string; action: string | null; gradeFrom: string | null; gradeTo: string | null; ratingDate: string }> | null>(null);
|
||||
const [ratings, setRatings] = useState<Array<{ firm: string; action: string | null; gradeFrom: string | null; gradeTo: string | null; ratingDate: string; targetFrom: number | null; targetTo: number | null }> | null>(null);
|
||||
const [consensus, setConsensus] = useState<{ strongBuy: number; buy: number; hold: number; sell: number; strongSell: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -157,9 +157,7 @@ export function AnalystRatings({ symbol }: Props) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
Analyst ratings from Yahoo Finance. Not investment advice.
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-muted">Source: Yahoo Finance.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type PriceCandle, type IndicatorResult } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { buildVolumeProfile } from "@/lib/volumeProfile";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -17,9 +18,8 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
// Chart Lab Panel (Slice 5c). Renders price candles, EMA overlays, volume, RSI, and
|
||||
// relative volume for the active symbol. Educational display only — no trade signals,
|
||||
// no directional language, no buy/sell arrows.
|
||||
// Chart Lab Panel (Slice 5c). Renders price candles, EMA overlays, volume, RSI,
|
||||
// relative volume, and equity volume-by-price for the active symbol.
|
||||
|
||||
interface RangeOption {
|
||||
label: string;
|
||||
@@ -44,7 +44,7 @@ function barsForRange(years: number | null, timeframe: '1d' | '1wk' | '1mo'): nu
|
||||
function formatCandleDate(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return ts.slice(0, 10);
|
||||
}
|
||||
@@ -222,6 +222,7 @@ export function ChartLabPanel() {
|
||||
// Range selector — full indicator computation stays on the complete series (EMA-200 needs >=200 bars);
|
||||
// only the displayed chart slice is trimmed. Defaults to 1Y.
|
||||
const [range, setRange] = useState<string>("1Y");
|
||||
const [showVolumeProfile, setShowVolumeProfile] = useState(true);
|
||||
const displayData: ChartDataPoint[] = useMemo(() => {
|
||||
if (!chartData.length) return [];
|
||||
const option = RANGE_OPTIONS.find((o) => o.label === range);
|
||||
@@ -229,6 +230,21 @@ export function ChartLabPanel() {
|
||||
return chartData.slice(-maxBars);
|
||||
}, [chartData, range, timeframe]);
|
||||
|
||||
const volumeProfile = useMemo(
|
||||
() =>
|
||||
buildVolumeProfile(
|
||||
displayData.map((d) => ({ h: d.h, l: d.l, c: d.c, v: d.v })),
|
||||
{ bins: 28, valueAreaPct: 0.7 },
|
||||
),
|
||||
[displayData],
|
||||
);
|
||||
|
||||
const volumeProfileMax = useMemo(() => {
|
||||
let m = 0;
|
||||
for (const b of volumeProfile.bins) m = Math.max(m, b.volume);
|
||||
return m || 1;
|
||||
}, [volumeProfile.bins]);
|
||||
|
||||
const tooltipFormatter = useCallback((value: any, name: string) => {
|
||||
if (!name || name === "date") return [value, ''];
|
||||
if (name === "c") return [`$${Number(value)?.toFixed(2)}`, "Close"];
|
||||
@@ -287,22 +303,22 @@ export function ChartLabPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 space-y-6 transition-shadow duration-300">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<section className="rounded-md border border-line bg-surface-raised p-3 sm:p-5 space-y-4 sm:space-y-6 transition-shadow duration-300">
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.chartsTitle}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex rounded-md border border-line overflow-hidden text-xs">
|
||||
<div className="flex flex-col xs:flex-row sm:flex-row items-stretch sm:items-center gap-2 w-full sm:w-auto">
|
||||
<div className="inline-flex rounded-md border border-line overflow-hidden text-xs w-full sm:w-auto">
|
||||
{(['1d', '1wk', '1mo'] as const).map((tf) => (
|
||||
<button key={tf} type="button" onClick={() => setTimeframe(tf)}
|
||||
className={`px-3 py-1 ${timeframe === tf ? "bg-accent text-white" : "text-fg-muted hover:text-fg"}`}>
|
||||
className={`flex-1 sm:flex-none min-h-[40px] sm:min-h-0 px-3 py-2 sm:py-1 ${timeframe === tf ? "bg-accent text-white" : "text-fg-muted hover:text-fg active:bg-surface-sunken"}`}>
|
||||
{tf === '1d' ? 'Daily' : tf === '1wk' ? 'Weekly' : 'Monthly'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-line overflow-hidden text-xs">
|
||||
<div className="inline-flex rounded-md border border-line overflow-x-auto text-xs w-full sm:w-auto">
|
||||
{RANGE_OPTIONS.map((o) => (
|
||||
<button key={o.label} type="button" onClick={() => setRange(o.label)}
|
||||
className={`px-3 py-1 ${range === o.label ? "bg-accent text-white" : "text-fg-muted hover:text-fg"}`}>
|
||||
className={`flex-1 sm:flex-none min-h-[40px] sm:min-h-0 px-3 py-2 sm:py-1 ${range === o.label ? "bg-accent text-white" : "text-fg-muted hover:text-fg active:bg-surface-sunken"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -314,12 +330,12 @@ export function ChartLabPanel() {
|
||||
<div>
|
||||
<figure>
|
||||
<p className="text-xs text-fg-muted mb-2">{UI_STRINGS.priceChartCaption}</p>
|
||||
<div className="h-72">
|
||||
<div className="h-56 sm:h-72 -mx-1 sm:mx-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={displayData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<ComposedChart data={displayData} margin={{ top: 8, right: 4, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[priceMin, priceMax]} tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v: number) => `$${v.toFixed(2)}`} />
|
||||
<YAxis domain={[priceMin, priceMax]} tick={{ fontSize: 10, fill: CHART.axis }} width={48} tickFormatter={(v: number) => `$${v.toFixed(0)}`} />
|
||||
<YAxis yAxisId="inst" orientation="right" domain={[0, maxInstVol]} hide />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
@@ -335,10 +351,10 @@ export function ChartLabPanel() {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Custom toggleable legend */}
|
||||
<div className="flex flex-wrap gap-3 mt-2 justify-center">
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3 mt-2 justify-center">
|
||||
{(Object.keys(SERIES_LABELS) as SeriesKey[]).map((key) => (
|
||||
<button key={key} type="button" onClick={() => setVisibleSeries((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
className="flex items-center gap-1.5 text-xs transition-opacity"
|
||||
className="flex items-center gap-1.5 text-xs min-h-[36px] sm:min-h-0 px-2 sm:px-0 rounded-md sm:rounded-none border border-line sm:border-0 transition-opacity active:bg-surface-sunken"
|
||||
style={{ opacity: visibleSeries[key] ? 1 : 0.4 }}>
|
||||
<span className={`${key === 'instBuys' ? 'w-3 h-2.5 rounded' : 'w-3 h-0.5 rounded-sm'} inline-block`} style={{ backgroundColor: SERIES_COLORS[key] }} />
|
||||
{SERIES_LABELS[key]}
|
||||
@@ -351,6 +367,91 @@ export function ChartLabPanel() {
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
{/* Equity volume-by-price (same visible range as price chart) */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<p className="text-xs text-fg-muted">{UI_STRINGS.volumeProfileLabel}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-[36px] px-2.5 rounded-lg text-xs border ${
|
||||
showVolumeProfile
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-line text-fg-muted"
|
||||
}`}
|
||||
onClick={() => setShowVolumeProfile((v) => !v)}
|
||||
aria-pressed={showVolumeProfile}
|
||||
>
|
||||
{showVolumeProfile ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
{showVolumeProfile && (
|
||||
<figure>
|
||||
{volumeProfile.bins.length === 0 ? (
|
||||
<p className="text-xs text-fg-faint py-4">{UI_STRINGS.volumeProfileEmpty}</p>
|
||||
) : (
|
||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-line bg-surface-sunken/40 p-2 space-y-0.5">
|
||||
{[...volumeProfile.bins].reverse().map((b) => {
|
||||
const isPoc =
|
||||
volumeProfile.pocMid != null &&
|
||||
Math.abs(b.mid - volumeProfile.pocMid) < 1e-9;
|
||||
const inVa =
|
||||
volumeProfile.val != null &&
|
||||
volumeProfile.vah != null &&
|
||||
b.priceLo >= volumeProfile.val - 1e-9 &&
|
||||
b.priceHi <= volumeProfile.vah + 1e-9;
|
||||
const w = Math.max(2, (b.volume / volumeProfileMax) * 100);
|
||||
return (
|
||||
<div
|
||||
key={`${b.priceLo}-${b.priceHi}`}
|
||||
className="flex items-center gap-2 min-h-[18px]"
|
||||
title={`${b.priceLo.toFixed(2)}–${b.priceHi.toFixed(2)} · ${Math.round(b.volume).toLocaleString()} shares`}
|
||||
>
|
||||
<span
|
||||
className={`w-14 shrink-0 text-right text-[10px] tabular-nums ${
|
||||
isPoc ? "text-accent font-semibold" : "text-fg-faint"
|
||||
}`}
|
||||
>
|
||||
{b.mid.toFixed(b.mid >= 100 ? 0 : 2)}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-surface-raised rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
isPoc
|
||||
? "bg-accent/90"
|
||||
: inVa
|
||||
? "bg-sky-500/55"
|
||||
: "bg-fg-muted/35"
|
||||
}`}
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
{isPoc && (
|
||||
<span className="text-[9px] uppercase tracking-wide text-accent shrink-0">
|
||||
{UI_STRINGS.volumeProfilePoc}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<figcaption className="text-[11px] text-fg-muted mt-1">
|
||||
{UI_STRINGS.volumeProfileCaption}
|
||||
{volumeProfile.pocMid != null && (
|
||||
<>
|
||||
{" "}
|
||||
POC ~${volumeProfile.pocMid.toFixed(2)}
|
||||
{volumeProfile.val != null && volumeProfile.vah != null
|
||||
? ` · value area ~$${volumeProfile.val.toFixed(2)}–$${volumeProfile.vah.toFixed(2)}`
|
||||
: ""}
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume sub-chart */}
|
||||
<div>
|
||||
<figure>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CollapsibleSection({ title, children, defaultOpen = false, onEsc
|
||||
}, [isOpen, sectionId, setOpenSectionId]);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]" aria-label={title}>
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] min-w-0 max-w-full" aria-label={title}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
@@ -85,7 +85,7 @@ export function CollapsibleSection({ title, children, defaultOpen = false, onEsc
|
||||
role="region"
|
||||
aria-label={`${title} content`}
|
||||
>
|
||||
<div className="px-5 pb-5">
|
||||
<div className="px-5 pb-5 min-w-0 max-w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Building2,
|
||||
TrendingUp,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
XCircle,
|
||||
Target,
|
||||
FileText,
|
||||
@@ -22,9 +21,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { api, type SymbolSearchResult } from "@/lib/trpc";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
|
||||
import type { NavItemId } from "@/lib/workspace-profile";
|
||||
|
||||
interface NavCommand {
|
||||
label: string;
|
||||
@@ -33,25 +34,27 @@ interface NavCommand {
|
||||
goTo?: string;
|
||||
group: "Page";
|
||||
module?: ModuleKey;
|
||||
navId?: NavItemId;
|
||||
}
|
||||
|
||||
const NAV_COMMANDS: NavCommand[] = [
|
||||
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page", module: "research" },
|
||||
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page", module: "research" },
|
||||
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page", module: "research" },
|
||||
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page", module: "research" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page", module: "execution" },
|
||||
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page", module: "execution" },
|
||||
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page", module: "execution" },
|
||||
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page", module: "execution" },
|
||||
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page", module: "analytics" },
|
||||
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page", module: "analytics" },
|
||||
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page", module: "analytics" },
|
||||
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page", module: "settings" },
|
||||
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page", module: "admin" },
|
||||
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page", module: "admin" },
|
||||
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page", module: "admin" },
|
||||
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page", module: "admin" },
|
||||
{ label: "Symbol Overview", href: "/", icon: Home, goTo: "o", group: "Page", module: "research", navId: "research" },
|
||||
{ label: "Portfolio", href: "/portfolio", icon: ClipboardList, goTo: "p", group: "Page", module: "execution", navId: "portfolio" },
|
||||
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page", module: "execution", navId: "risk" },
|
||||
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page", module: "research", navId: "market" },
|
||||
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page", module: "research", navId: "charts" },
|
||||
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page", module: "research", navId: "institutional" },
|
||||
{ label: "Get Started", href: "/guided-start", icon: Target, group: "Page", module: "execution", navId: "guided-start" },
|
||||
{ label: "Journal", href: "/journal", icon: XCircle, goTo: "j", group: "Page", module: "execution", navId: "journal" },
|
||||
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page", module: "execution", navId: "goals" },
|
||||
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page", module: "analytics", navId: "filings" },
|
||||
{ label: "Options DD", href: "/options-dd", icon: BarChart3, group: "Page", module: "analytics", navId: "options" },
|
||||
{ label: "More", href: "/more", icon: Search, group: "Page", module: "settings", navId: "settings" },
|
||||
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page", module: "settings", navId: "settings" },
|
||||
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page", module: "admin", navId: "admin" },
|
||||
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page", module: "admin", navId: "admin" },
|
||||
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page", module: "admin", navId: "admin" },
|
||||
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page", module: "admin", navId: "admin" },
|
||||
];
|
||||
|
||||
interface Command {
|
||||
@@ -87,10 +90,12 @@ function fuzzyScore(query: string, text: string): number {
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { modules } = useFeatureAccess();
|
||||
const { isNavVisible } = useWorkspaceProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const [symbols, setSymbols] = useState<string[]>([]);
|
||||
const [indexHits, setIndexHits] = useState<SymbolSearchResult[]>([]);
|
||||
const gPressedAt = useRef(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -103,12 +108,41 @@ export function CommandPalette() {
|
||||
.catch(() => setSymbols([]));
|
||||
}, [open]);
|
||||
|
||||
// Open on ⌘K / Ctrl+K and on custom event from the sidebar button.
|
||||
// M22: search the symbol index (local-first, ADR-0009) as the user types.
|
||||
useEffect(() => {
|
||||
if (!open || !query.trim()) { setIndexHits([]); return; }
|
||||
const t = setTimeout(() => {
|
||||
api.symbols
|
||||
.search({ q: query.trim(), limit: 6 })
|
||||
.then((r) => setIndexHits(r.results))
|
||||
.catch(() => setIndexHits([]));
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
}, [query, open]);
|
||||
|
||||
// Open on ⌘K / Ctrl+K, on custom event, or when the user types a printable
|
||||
// character anywhere on the page ("type to search" pattern).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// ⌘K / Ctrl+K toggles the palette.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if palette is already open, modifier held, or typing in a field.
|
||||
if (open || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const el = document.activeElement;
|
||||
const inField = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable;
|
||||
if (inField) return;
|
||||
|
||||
// Printable character (letter, digit, symbol, space) → open palette with that char.
|
||||
// Exclude 'g' to preserve the g-then-key go-to navigation shortcut.
|
||||
if (e.key.length === 1 && e.key.toLowerCase() !== "g" && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
setQuery(e.key);
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
const onOpenEvent = () => setOpen(true);
|
||||
@@ -118,7 +152,7 @@ export function CommandPalette() {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("open-command-palette", onOpenEvent as EventListener);
|
||||
};
|
||||
}, []);
|
||||
}, [open]);
|
||||
|
||||
// `g` then key go-to navigation (when palette closed, not typing in a field).
|
||||
useEffect(() => {
|
||||
@@ -132,9 +166,8 @@ export function CommandPalette() {
|
||||
{ key: "c", href: "/chart-lab", module: "research" },
|
||||
{ key: "i", href: "/institutional", module: "research" },
|
||||
{ key: "m", href: "/market-outlook", module: "research" },
|
||||
{ key: "t", href: "/trade-plan", module: "execution" },
|
||||
{ key: "e", href: "/execution", module: "execution" },
|
||||
{ key: "x", href: "/trade-closure", module: "execution" },
|
||||
{ key: "p", href: "/portfolio", module: "execution" },
|
||||
{ key: "j", href: "/journal", module: "execution" },
|
||||
{ key: "d", href: "/daily-focus", module: "execution" },
|
||||
{ key: "r", href: "/risk", module: "analytics" },
|
||||
{ key: "f", href: "/filings", module: "analytics" },
|
||||
@@ -160,14 +193,17 @@ export function CommandPalette() {
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const pageCmds: Command[] = NAV_COMMANDS
|
||||
.filter((c) => !c.module || modules.includes(c.module))
|
||||
.filter((c) => (!c.module || modules.includes(c.module)) && (!c.navId || isNavVisible(c.navId)))
|
||||
.map((c) => ({
|
||||
id: `page:${c.href}`,
|
||||
label: c.label,
|
||||
hint: c.goTo ? `g ${c.goTo}` : undefined,
|
||||
group: c.group,
|
||||
icon: c.icon,
|
||||
run: () => router.push(c.href),
|
||||
run: () => {
|
||||
router.push(c.href, { scroll: false });
|
||||
void import("@/lib/scroll-app-main").then((m) => m.scrollAppMainToTopSoon());
|
||||
},
|
||||
}));
|
||||
const symbolCmds: Command[] = symbols.map((sym) => ({
|
||||
id: `sym:${sym}`,
|
||||
@@ -177,11 +213,27 @@ export function CommandPalette() {
|
||||
icon: Building2,
|
||||
run: () => {
|
||||
useActiveSymbol.getState().setActive(sym);
|
||||
router.push("/");
|
||||
router.push("/", { scroll: false });
|
||||
void import("@/lib/scroll-app-main").then((m) => m.scrollAppMainToTopSoon());
|
||||
},
|
||||
}));
|
||||
return [...pageCmds, ...symbolCmds];
|
||||
}, [symbols, router, modules]);
|
||||
// M22: index matches (known symbols, incl. not-yet-watchlisted) rank above pages.
|
||||
const indexCmds: Command[] = query.trim()
|
||||
? indexHits.map((r) => ({
|
||||
id: `idx:${r.symbol}`,
|
||||
label: `Open ${r.symbol}`,
|
||||
hint: "symbol",
|
||||
group: "Symbol",
|
||||
icon: Building2,
|
||||
run: () => {
|
||||
useActiveSymbol.getState().setActive(r.symbol);
|
||||
router.push("/", { scroll: false });
|
||||
void import("@/lib/scroll-app-main").then((m) => m.scrollAppMainToTopSoon());
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
return [...indexCmds, ...pageCmds, ...symbolCmds];
|
||||
}, [symbols, indexHits, query, router, modules, isNavVisible]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return commands;
|
||||
@@ -248,6 +300,32 @@ export function CommandPalette() {
|
||||
<kbd className="hidden sm:block rounded-sm border border-line px-1.5 py-0.5 text-2xs text-fg-muted">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* Symbol autocomplete dropdown — same format as SymbolAutocomplete */}
|
||||
{query.trim() && indexHits.length > 0 && (
|
||||
<div className="border-b border-line">
|
||||
<p className="px-3 py-1.5 text-2xs uppercase tracking-wide text-fg-muted">Symbols</p>
|
||||
{indexHits.map((r) => (
|
||||
<button
|
||||
key={r.symbol}
|
||||
onClick={() => {
|
||||
useActiveSymbol.getState().setActive(r.symbol);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
router.push("/", { scroll: false });
|
||||
void import("@/lib/scroll-app-main").then((m) => m.scrollAppMainToTopSoon());
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 flex items-baseline gap-2 hover:bg-surface-overlay transition-colors"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="max-h-80 overflow-y-auto p-1.5">
|
||||
{results.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-fg-muted">No matches</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, type ReactElement } from "react";
|
||||
import { api, type EdgarFiling, type FilerMeta, type Form13fResult, type Form4Result } from "@/lib/trpc";
|
||||
import { SymbolAutocomplete } from "@/components/SymbolAutocomplete";
|
||||
import { TablePager, DEFAULT_PAGE_SIZE } from "@/components/TablePager";
|
||||
|
||||
// M6 Filings Panel (Slice 6) — Educational SEC EDGAR filings viewer.
|
||||
@@ -77,6 +78,8 @@ interface FocusPosition {
|
||||
|
||||
export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
const [cikInput, setCikInput] = useState(initialCik ?? "");
|
||||
const [resolvedCompany, setResolvedCompany] = useState<string | null>(null);
|
||||
const [autocompleteQuery, setAutocompleteQuery] = useState("");
|
||||
const [formFilter, setFormFilter] = useState<string>("all");
|
||||
const [sortField, setSortField] = useState<SortField>("dateReporter");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
@@ -141,7 +144,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
const filingText = result.filings.map((f) =>
|
||||
`${f.form} | Filed: ${f.dateReporter} | Report: ${f.reportDate} | Accession: ${f.accessionNumber}`
|
||||
).join("\n");
|
||||
const summaryText = `EDGAR filing summary for CIK ${cik}: ${result.filings.length} recent filing(s). ${filingText}\n\nEducational note: These are public SEC filings. Always consult original documents.`;
|
||||
const summaryText = `EDGAR filing summary for CIK ${cik}: ${result.filings.length} recent filing(s). ${filingText}`;
|
||||
setSummary(summaryText);
|
||||
} catch {
|
||||
setSummary(null);
|
||||
@@ -217,10 +220,37 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
return Array.from(forms).sort();
|
||||
}, [filings]);
|
||||
|
||||
const handleSearch = () => {
|
||||
loadFilings(cikInput, formFilter !== "all" ? [formFilter] : undefined, dateRange);
|
||||
loadMeta(cikInput);
|
||||
};
|
||||
const handlePickSymbol = useCallback(async (symbol: string) => {
|
||||
try {
|
||||
const { results } = await api.symbols.search({ q: symbol, limit: 1 });
|
||||
const exact = results.find((r: { symbol: string }) => r.symbol === symbol);
|
||||
const hit = exact ?? results[0];
|
||||
if (hit?.cik) {
|
||||
setResolvedCompany(hit.name ?? hit.symbol);
|
||||
setCikInput(hit.cik);
|
||||
} else {
|
||||
setResolvedCompany(null);
|
||||
setCikInput("");
|
||||
setFilingsError(`No CIK found for "${symbol}". Try a different symbol.`);
|
||||
}
|
||||
} catch {
|
||||
setResolvedCompany(null);
|
||||
setCikInput("");
|
||||
setFilingsError(`Failed to resolve "${symbol}". Check the symbol and try again.`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRawSubmit = useCallback((raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (/^\d{5,10}$/.test(trimmed)) {
|
||||
setResolvedCompany(null);
|
||||
setCikInput(trimmed);
|
||||
} else {
|
||||
setResolvedCompany(null);
|
||||
setCikInput("");
|
||||
setFilingsError(`"${trimmed}" is not a valid ticker or CIK.`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewDetail = (accession: string, formType: string) => {
|
||||
if (formType === "13F-HR") {
|
||||
@@ -308,7 +338,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<h2 className="text-base font-semibold text-fg mb-4">SEC Filings</h2>
|
||||
<div className="p-4 bg-[#1e1e2e] rounded-lg border border-[#ef4444]/30">
|
||||
<p className="text-danger text-sm">
|
||||
Unable to load filings. Please verify the CIK number and try again.
|
||||
{filingsError}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -319,30 +349,23 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 space-y-4 transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">SEC Filings Panel</h2>
|
||||
|
||||
{/* CIK Input */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="cik-input" className="block text-xs text-fg-muted mb-1">CIK Number</label>
|
||||
<input
|
||||
id="cik-input"
|
||||
type="text"
|
||||
value={cikInput}
|
||||
onChange={(e) => setCikInput(e.target.value.replace(/\D/g, "").slice(0, 10))}
|
||||
placeholder="Enter CIK (e.g., 320193 for AAPL)"
|
||||
className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-fg text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!cikInput}
|
||||
aria-label="Load filings"
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#3b7aed] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
>
|
||||
Load Filings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Symbol / CIK Autocomplete */}
|
||||
<SymbolAutocomplete
|
||||
placeholder="Search by ticker or CIK…"
|
||||
onPick={handlePickSymbol}
|
||||
onSubmitRaw={handleRawSubmit}
|
||||
value={autocompleteQuery}
|
||||
onChange={setAutocompleteQuery}
|
||||
autoFocus
|
||||
ariaLabel="Search by ticker or CIK"
|
||||
className="w-full"
|
||||
inputClassName="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-fg text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
{resolvedCompany && cikInput && (
|
||||
<p className="text-xs text-fg-faint truncate -mt-1">
|
||||
{resolvedCompany} <span className="text-fg-muted">(CIK {cikInput})</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Filer Metadata Sidebar */}
|
||||
{metaData?.meta && (
|
||||
@@ -378,10 +401,10 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{!filingsLoading && filings.length === 0 && cikInput && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-fg-muted text-sm">
|
||||
No filings found for CIK {cikInput}.
|
||||
No filings found for CIK {cikInput}{resolvedCompany ? ` (${resolvedCompany})` : ""}.
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted mt-1">
|
||||
Verify the CIK number is correct, or try a different entity.
|
||||
Verify the ticker or CIK is correct, or try a different entity.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -553,7 +576,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<table className="w-full text-xs" role="table" aria-label="13F holdings table">
|
||||
<thead>
|
||||
<tr className="border-b border-[#5b21b6] text-fg-muted">
|
||||
<th className="text-left py-1 px-2" scope="col">CUSIP</th>
|
||||
<th className="text-left py-1 px-2 sticky left-0 bg-[#2a1f3d] z-10" scope="col">CUSIP</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Issuer</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Value ($)</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Shares</th>
|
||||
@@ -562,7 +585,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<tbody>
|
||||
{((detailData as Form13fResult).holdings ?? []).map((h, i) => (
|
||||
<tr key={i} className="border-b border-[#5b21b6]/30">
|
||||
<td className="py-1 px-2 font-mono text-[#c4b5fd]">{h.cusip}</td>
|
||||
<td className="py-1 px-2 font-mono text-[#c4b5fd] sticky left-0 bg-[#2a1f3d] z-10">{h.cusip}</td>
|
||||
<td className="py-1 px-2 text-fg">{h.issuerName}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#c4b5fd]">{formatCurrency(h.value)}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#c4b5fd]">{formatShares(h.sshPrnamt)}</td>
|
||||
@@ -634,14 +657,6 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Educational Disclaimer */}
|
||||
<div className="p-3 bg-[#1e1e2e] rounded-lg border border-[#3f3f46]" role="note">
|
||||
<p className="text-xs text-fg-muted">
|
||||
<strong className="text-fg">Educational Note:</strong> SEC filings are public records
|
||||
available through the EDGAR database. This panel displays filing metadata for informational
|
||||
purposes. Always consult the original filing documents for complete information.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
// Two-tier "who holds this" strip (M22 Symbol Search Index, ADR-0011).
|
||||
//
|
||||
// Tier 1 — tracked funds (M21 `fund_position_records`): each fund that holds the
|
||||
// symbol, with its weight in the fund's disclosed book and the record source/as-of.
|
||||
// Tier 2 — the count of distinct 13F filers that reported the symbol (M4 cache).
|
||||
//
|
||||
// Facts only (ADR-0010 / ADR-0007): never "you should follow this fund".
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type FundHoldersResult } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui";
|
||||
|
||||
export function FundHoldingsStrip({ symbol }: { symbol: string | null }) {
|
||||
const [data, setData] = useState<FundHoldersResult | null>(null);
|
||||
const [showInstitutions, setShowInstitutions] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setData(null);
|
||||
setShowInstitutions(false);
|
||||
setError(false);
|
||||
if (!symbol) return;
|
||||
api.symbols
|
||||
.holders({ symbol })
|
||||
.then((d) => { if (!cancelled) setData(d); })
|
||||
.catch(() => { if (!cancelled) setError(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
if (error) return null; // silent — strip is auxiliary
|
||||
if (!data) return null;
|
||||
if (data.tracked.length === 0 && data.institutionCount === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-4">
|
||||
<h3 className="text-sm font-semibold text-fg mb-2">Who holds {symbol}</h3>
|
||||
|
||||
{data.tracked.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{data.tracked.map((t) => (
|
||||
<li key={t.fundId} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-fg">{t.fundName}</span>
|
||||
{t.weightPct != null && (
|
||||
<span className="text-xs text-fg-muted tnum">{t.weightPct.toFixed(1)}% of book</span>
|
||||
)}
|
||||
<Badge tone={t.source === "13f" ? "neutral" : "warn"}>
|
||||
{t.source === "13f" ? "SEC 13F" : t.source}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-fg-faint">as of {t.asOf}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data.institutionCount > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-2">
|
||||
<button
|
||||
onClick={() => setShowInstitutions((v) => !v)}
|
||||
className="text-xs text-fg-muted hover:text-fg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{data.institutionCount} other institution{data.institutionCount === 1 ? "" : "s"} report holding this (13F)</span>
|
||||
<span className="text-fg-faint">{showInstitutions ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{showInstitutions && (
|
||||
<p className="mt-1 text-[10px] text-fg-faint">
|
||||
From the shared 13F filings cache (M4 institutional module). This is every
|
||||
distinct filer that reported the symbol, passive and active alike.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { api, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
|
||||
import { densityAtLeast } from "@/lib/workspace-profile";
|
||||
import { OptionLegsPanel } from "@/components/OptionLegsPanel";
|
||||
import { scrollAppMainToTopSoon } from "@/lib/scroll-app-main";
|
||||
|
||||
type QuoteSnap = {
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
};
|
||||
|
||||
function fmtMoney(n: number, digits = 2): string {
|
||||
return n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtSignedMoney(n: number, digits = 2): string {
|
||||
const sign = n > 0 ? "+" : n < 0 ? "−" : "";
|
||||
return `${sign}$${fmtMoney(Math.abs(n), digits)}`;
|
||||
}
|
||||
|
||||
function fmtSignedPct(n: number): string {
|
||||
const sign = n > 0 ? "+" : n < 0 ? "−" : "";
|
||||
return `${sign}${Math.abs(n).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified portfolio book: holdings + live marks + add/remove.
|
||||
* Table layout keeps columns aligned in the main content column.
|
||||
*/
|
||||
export function HoldingsBookView() {
|
||||
const router = useRouter();
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const { density } = useWorkspaceProfile();
|
||||
const showConsiderations = densityAtLeast(density, "standard");
|
||||
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [quotes, setQuotes] = useState<Record<string, QuoteSnap>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [newSymbol, setNewSymbol] = useState("");
|
||||
const [newShares, setNewShares] = useState("");
|
||||
const [newPrice, setNewPrice] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadQuotes = useCallback(async (list: PortfolioHolding[]) => {
|
||||
if (list.length === 0) {
|
||||
setQuotes({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use batched snapshots to avoid N+1 API calls
|
||||
try {
|
||||
const symbols = list.map((h) => h.symbol);
|
||||
const results = await api.market.snapshots(symbols);
|
||||
const qs: Record<string, QuoteSnap> = {};
|
||||
for (const result of results) {
|
||||
if (result.quote) {
|
||||
qs[result.symbol] = {
|
||||
price: result.quote.price,
|
||||
change: result.quote.change ?? 0,
|
||||
changePercent: result.quote.changePercent ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
setQuotes(qs);
|
||||
} catch (e) {
|
||||
console.error('[HoldingsBookView] Failed to load batched quotes:', e);
|
||||
setQuotes({});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
try {
|
||||
const data = (await api.portfolio.holdings()) ?? [];
|
||||
setHoldings(data);
|
||||
await loadQuotes(data);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not load portfolio.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[loadQuotes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let cost = 0;
|
||||
let market = 0;
|
||||
let hasMarks = false;
|
||||
for (const h of holdings) {
|
||||
cost += h.shares * h.avg_cost;
|
||||
const q = quotes[h.symbol];
|
||||
if (q) {
|
||||
hasMarks = true;
|
||||
market += q.price * h.shares;
|
||||
}
|
||||
}
|
||||
const pnl = hasMarks ? market - cost : null;
|
||||
const pnlPct = hasMarks && cost > 0 ? ((market - cost) / cost) * 100 : null;
|
||||
return { cost, market, hasMarks, pnl, pnlPct, count: holdings.length };
|
||||
}, [holdings, quotes]);
|
||||
|
||||
async function handleAdd() {
|
||||
setError(null);
|
||||
const symbol = newSymbol.trim().toUpperCase();
|
||||
const shares = parseFloat(newShares);
|
||||
const avgCost = parseFloat(newPrice);
|
||||
if (!symbol || !Number.isFinite(shares) || shares <= 0 || !Number.isFinite(avgCost) || avgCost < 0) {
|
||||
setError("Symbol, positive shares, and average cost are required.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.portfolio.addHolding(symbol, shares, avgCost);
|
||||
setNewSymbol("");
|
||||
setNewShares("");
|
||||
setNewPrice("");
|
||||
setAdding(false);
|
||||
await load(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not add holding.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(symbol: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.portfolio.removeHolding(symbol);
|
||||
await load(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not remove holding.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openResearch(symbol: string) {
|
||||
setActive(symbol);
|
||||
// scroll=false: shell owns nested <main> scroll; push alone leaves mid-page.
|
||||
router.push("/", { scroll: false });
|
||||
scrollAppMainToTopSoon();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
{/* Header */}
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold text-fg">Portfolio</h1>
|
||||
<p className="text-sm text-fg-faint mt-1 max-w-xl hidden sm:block">
|
||||
Equity holdings and option legs on the book. Marks for stocks when quotes load.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{holdings.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(true)}
|
||||
disabled={refreshing}
|
||||
className="inline-flex min-h-[44px] sm:min-h-0 sm:h-8 items-center gap-1.5 rounded-md border border-line px-3 text-xs text-fg-muted hover:text-fg hover:bg-surface-overlay disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
<span className="hidden xs:inline sm:inline">Refresh</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding((v) => !v)}
|
||||
className="inline-flex min-h-[44px] sm:min-h-0 sm:h-8 items-center rounded-md bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
{adding ? "Cancel" : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-danger rounded-md border border-danger/30 bg-danger/5 px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="rounded-md border border-line bg-surface-raised p-4">
|
||||
<p className="text-xs font-semibold text-fg mb-3">Add to book</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 max-w-xl">
|
||||
<label className="block text-[11px] text-fg-muted">
|
||||
Symbol
|
||||
<input
|
||||
type="text"
|
||||
value={newSymbol}
|
||||
onChange={(e) => setNewSymbol(e.target.value.toUpperCase())}
|
||||
placeholder="VTI"
|
||||
autoCapitalize="characters"
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm font-mono text-fg"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-[11px] text-fg-muted">
|
||||
Shares
|
||||
<input
|
||||
type="number"
|
||||
value={newShares}
|
||||
onChange={(e) => setNewShares(e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm tnum text-fg"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-[11px] text-fg-muted">
|
||||
Avg cost
|
||||
<input
|
||||
type="number"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="any"
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm tnum text-fg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={handleAdd}
|
||||
className="mt-3 min-h-[44px] sm:min-h-0 sm:h-8 w-full sm:w-auto rounded-md bg-accent px-4 text-sm sm:text-xs font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Save holding"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{holdings.length === 0 ? (
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-6 py-12 text-center">
|
||||
<p className="text-sm text-fg-muted">No holdings in your book yet.</p>
|
||||
<p className="text-xs text-fg-faint mt-1">
|
||||
Add positions you hold (or model paper size). Marks appear after symbols are in the book.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col sm:flex-row justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="min-h-[44px] sm:min-h-0 sm:h-8 rounded-md bg-accent px-3 text-sm sm:text-xs font-medium text-white"
|
||||
>
|
||||
Add holding
|
||||
</button>
|
||||
<a
|
||||
href="/guided-start"
|
||||
className="inline-flex min-h-[44px] sm:min-h-0 sm:h-8 items-center justify-center rounded-md border border-line px-3 text-sm sm:text-xs text-fg hover:bg-surface-overlay"
|
||||
>
|
||||
Strategy templates
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Snapshot — 2×2 on phone, 4-col from sm */}
|
||||
<div className="rounded-md border border-line bg-surface-raised overflow-hidden">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 sm:divide-x divide-line [&>*:nth-child(odd)]:border-r [&>*:nth-child(odd)]:border-line sm:[&>*:nth-child(odd)]:border-r-0 [&>*:nth-child(-n+2)]:border-b [&>*:nth-child(-n+2)]:border-line sm:[&>*]:border-b-0">
|
||||
<SnapshotCell label="Positions" value={String(summary.count)} />
|
||||
<SnapshotCell label="Cost basis" value={`$${fmtMoney(summary.cost, 0)}`} />
|
||||
<SnapshotCell
|
||||
label="Market value"
|
||||
value={summary.hasMarks ? `$${fmtMoney(summary.market, 0)}` : "—"}
|
||||
/>
|
||||
<SnapshotCell
|
||||
label="Unrealized"
|
||||
value={
|
||||
summary.pnl == null
|
||||
? "—"
|
||||
: `${fmtSignedMoney(summary.pnl, 0)}${
|
||||
summary.pnlPct != null ? ` (${fmtSignedPct(summary.pnlPct)})` : ""
|
||||
}`
|
||||
}
|
||||
tone={
|
||||
summary.pnl == null ? "neutral" : summary.pnl >= 0 ? "up" : "down"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{holdings.map((h) => {
|
||||
const q = quotes[h.symbol];
|
||||
const costBasis = h.shares * h.avg_cost;
|
||||
const marketValue = q ? q.price * h.shares : null;
|
||||
const pnl = marketValue != null ? marketValue - costBasis : null;
|
||||
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
|
||||
const dayUp = (q?.change ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={h.symbol}
|
||||
className="rounded-lg border border-line bg-surface-raised p-3.5 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openResearch(h.symbol)}
|
||||
className="text-left min-h-[44px] flex flex-col justify-center"
|
||||
>
|
||||
<span className="font-mono text-base font-semibold text-fg">{h.symbol}</span>
|
||||
<span className="text-xs text-fg-muted tnum">
|
||||
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)} sh · avg $
|
||||
{fmtMoney(h.avg_cost)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="font-mono text-sm font-semibold tnum text-fg">
|
||||
{q ? `$${fmtMoney(q.price)}` : "—"}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs tnum ${
|
||||
!q ? "text-fg-faint" : dayUp ? "text-up" : "text-danger"
|
||||
}`}
|
||||
>
|
||||
{q ? fmtSignedPct(q.changePercent) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">Market</p>
|
||||
<p className="tnum font-medium text-fg">
|
||||
{marketValue != null ? `$${fmtMoney(marketValue)}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">P&L</p>
|
||||
<p
|
||||
className={`tnum font-medium ${
|
||||
pnl == null ? "text-fg-faint" : pnl >= 0 ? "text-up" : "text-danger"
|
||||
}`}
|
||||
>
|
||||
{pnl == null
|
||||
? "—"
|
||||
: `${fmtSignedMoney(pnl)}${
|
||||
pnlPct != null ? ` (${fmtSignedPct(pnlPct)})` : ""
|
||||
}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1 border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openResearch(h.symbol)}
|
||||
className="flex-1 min-h-[44px] rounded-md border border-line text-sm font-medium text-accent active:bg-surface-sunken"
|
||||
>
|
||||
Research
|
||||
</button>
|
||||
{showConsiderations && (
|
||||
<a
|
||||
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
|
||||
className="flex-1 min-h-[44px] inline-flex items-center justify-center rounded-md border border-line text-sm text-fg-muted active:bg-surface-sunken"
|
||||
>
|
||||
Consider
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => handleRemove(h.symbol)}
|
||||
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-danger disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop/tablet table */}
|
||||
<div className="hidden md:block rounded-md border border-line bg-surface-raised overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
<th className="text-left font-medium px-4 py-2.5 w-[7rem]">Symbol</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[5.5rem]">Shares</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[6.5rem]">Avg cost</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[6.5rem]">Last</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[7rem]">Day</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[7rem]">Cost</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[7rem]">Market</th>
|
||||
<th className="text-right font-medium px-3 py-2.5 w-[8rem]">P&L</th>
|
||||
<th className="text-right font-medium px-4 py-2.5 w-[9rem]"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.map((h) => {
|
||||
const q = quotes[h.symbol];
|
||||
const costBasis = h.shares * h.avg_cost;
|
||||
const marketValue = q ? q.price * h.shares : null;
|
||||
const pnl = marketValue != null ? marketValue - costBasis : null;
|
||||
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
|
||||
const dayUp = (q?.change ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={h.symbol}
|
||||
className="border-b border-line last:border-b-0 hover:bg-surface-overlay/40"
|
||||
>
|
||||
<td className="px-4 py-3 align-middle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openResearch(h.symbol)}
|
||||
className="font-mono font-semibold text-fg hover:text-accent"
|
||||
>
|
||||
{h.symbol}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
||||
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right tnum text-fg-muted align-middle">
|
||||
${fmtMoney(h.avg_cost)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
||||
{q ? `$${fmtMoney(q.price)}` : "—"}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-3 text-right tnum align-middle ${
|
||||
!q ? "text-fg-faint" : dayUp ? "text-up" : "text-danger"
|
||||
}`}
|
||||
>
|
||||
{q
|
||||
? `${fmtSignedMoney(q.change)} (${fmtSignedPct(q.changePercent)})`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right tnum text-fg-muted align-middle">
|
||||
${fmtMoney(costBasis)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
||||
{marketValue != null ? `$${fmtMoney(marketValue)}` : "—"}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-3 text-right tnum align-middle ${
|
||||
pnl == null ? "text-fg-faint" : pnl >= 0 ? "text-up" : "text-danger"
|
||||
}`}
|
||||
>
|
||||
{pnl == null
|
||||
? "—"
|
||||
: `${fmtSignedMoney(pnl)}${
|
||||
pnlPct != null ? ` (${fmtSignedPct(pnlPct)})` : ""
|
||||
}`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right align-middle whitespace-nowrap">
|
||||
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openResearch(h.symbol)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Research
|
||||
</button>
|
||||
<span className="text-fg-faint" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => handleRemove(h.symbol)}
|
||||
className="text-fg-muted hover:text-danger hover:underline disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{showConsiderations && (
|
||||
<>
|
||||
<span className="text-fg-faint" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
|
||||
className="text-fg-muted hover:text-fg hover:underline"
|
||||
>
|
||||
Consider
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Option legs - puts/calls on the book (feeds risk when open) */}
|
||||
<OptionLegsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotCell({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "up" | "down";
|
||||
}) {
|
||||
const valueClass =
|
||||
tone === "up" ? "text-up" : tone === "down" ? "text-danger" : "text-fg";
|
||||
return (
|
||||
<div className="px-3 sm:px-4 py-3 min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted truncate">{label}</p>
|
||||
<p className={`mt-1 text-sm sm:text-base font-semibold tnum tabular-nums truncate ${valueClass}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import React from 'react';
|
||||
// Use permissive types that accept both InstitutionalDashboard's FilingGroupRow and QuarterlyFiling structures.
|
||||
interface FilingQuarterly {
|
||||
reportedQuarter: string;
|
||||
filedAt?: string | null;
|
||||
shares: number | null;
|
||||
delta: number;
|
||||
isOpening?: boolean;
|
||||
putCall?: string | null;
|
||||
form: string;
|
||||
}
|
||||
@@ -17,14 +19,18 @@ interface FilingGroupRow {
|
||||
shares?: number | null;
|
||||
netDelta: number;
|
||||
classification?: string;
|
||||
/** Most recent SEC file date (YYYY-MM-DD) for sort / "new filings first". */
|
||||
latestFiledAt?: string | null;
|
||||
quarters: FilingQuarterly[];
|
||||
}
|
||||
|
||||
interface SortState { key: string; dir: 'asc' | 'desc'; }
|
||||
|
||||
/** Direction = share-count change vs prior quarter, not a trade log. */
|
||||
const DELTA_BADGE_LABELS: Record<string, { label: string }> = {
|
||||
increasing: { label: 'Bought' },
|
||||
reducing: { label: 'Sold' },
|
||||
increasing: { label: 'Added vs prior Q' },
|
||||
reducing: { label: 'Trimmed vs prior Q' },
|
||||
flat: { label: 'Same as prior Q' },
|
||||
};
|
||||
|
||||
function formatShares(n: number): string {
|
||||
@@ -67,19 +73,80 @@ function FormBadge({ form }: FormBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function sortRows<T>(rows: T[], key: string, dir: 'asc' | 'desc', accessors: Record<string, (r: T) => number | string>): T[] {
|
||||
type SortDir = 'asc' | 'desc';
|
||||
interface SortKey {
|
||||
key: string;
|
||||
dir: SortDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-key sort like SQL: ORDER BY filed_at DESC, shares DESC, ...
|
||||
* Empty / invalid file dates always sort last regardless of dir.
|
||||
*/
|
||||
function compareField(
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
key: string,
|
||||
dir: SortDir,
|
||||
): number {
|
||||
if (key === 'latestFiledAt') {
|
||||
const aStr = String(a ?? '').slice(0, 10);
|
||||
const bStr = String(b ?? '').slice(0, 10);
|
||||
const aOk = /^\d{4}-\d{2}-\d{2}$/.test(aStr);
|
||||
const bOk = /^\d{4}-\d{2}-\d{2}$/.test(bStr);
|
||||
if (!aOk && !bOk) return 0;
|
||||
if (!aOk) return 1; // missing dates last
|
||||
if (!bOk) return -1;
|
||||
const cmp = aStr.localeCompare(bStr);
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
}
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
if (a === b) return 0;
|
||||
const cmp = a < b ? -1 : 1;
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
}
|
||||
const aStr = String(a ?? '');
|
||||
const bStr = String(b ?? '');
|
||||
const cmp = aStr.localeCompare(bStr);
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
}
|
||||
|
||||
function sortRowsMulti<T>(
|
||||
rows: T[],
|
||||
keys: SortKey[],
|
||||
accessors: Record<string, (r: T) => number | string>,
|
||||
): T[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const aVal = accessors[key]?.(a);
|
||||
const bVal = accessors[key]?.(b);
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return dir === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
for (const { key, dir } of keys) {
|
||||
const cmp = compareField(accessors[key]?.(a), accessors[key]?.(b), key, dir);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
const aStr = String(aVal ?? '');
|
||||
const bStr = String(bVal ?? '');
|
||||
return dir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** SQL-style default: ORDER BY latest_filed_at DESC, shares DESC */
|
||||
function buildSortKeys(primary: string, dir: SortDir): SortKey[] {
|
||||
if (primary === 'latestFiledAt') {
|
||||
return [
|
||||
{ key: 'latestFiledAt', dir },
|
||||
{ key: 'shares', dir: 'desc' },
|
||||
];
|
||||
}
|
||||
if (primary === 'shares') {
|
||||
return [
|
||||
{ key: 'shares', dir },
|
||||
{ key: 'latestFiledAt', dir: 'desc' },
|
||||
];
|
||||
}
|
||||
// Other columns still break ties by file date then shares (newest + largest first).
|
||||
return [
|
||||
{ key: primary, dir },
|
||||
{ key: 'latestFiledAt', dir: 'desc' },
|
||||
{ key: 'shares', dir: 'desc' },
|
||||
];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filings: FilingGroupRow[];
|
||||
filerFilter: string;
|
||||
@@ -96,6 +163,8 @@ interface Props {
|
||||
setInstPageSize: (n: number) => void;
|
||||
expandedFilers: Set<string>;
|
||||
toggleFiler: (k: string) => void;
|
||||
/** Institutional ownership % of outstanding shares (all tracked funds combined). null if unknown. */
|
||||
ownershipPct: number | null;
|
||||
}
|
||||
|
||||
export function InstitutionalAccordion({
|
||||
@@ -114,8 +183,8 @@ export function InstitutionalAccordion({
|
||||
setInstPageSize,
|
||||
expandedFilers,
|
||||
toggleFiler,
|
||||
ownershipPct,
|
||||
}: Props) {
|
||||
const uniqueQuarters = [...new Set(filings.flatMap((g: FilingGroupRow) => g.quarters.map((q: FilingQuarterly) => q.reportedQuarter)))].sort().reverse();
|
||||
const uniqueSources = [...new Set(filings.map((g: FilingGroupRow) => g.form))];
|
||||
|
||||
const filteredGroups = filings.filter(g => {
|
||||
@@ -131,83 +200,419 @@ export function InstitutionalAccordion({
|
||||
netDelta: (r: FilingGroupRow) => Math.abs(r.netDelta ?? 0),
|
||||
putCall: (r: FilingGroupRow) => r.putCall ?? '',
|
||||
form: (r: FilingGroupRow) => r.form ?? '',
|
||||
// ISO dates sort lexicographically; missing dates sink to the bottom when desc.
|
||||
latestFiledAt: (r: FilingGroupRow) => r.latestFiledAt ?? r.quarters[0]?.filedAt ?? '',
|
||||
};
|
||||
|
||||
const sortedGroups = sortRows(filteredGroups, filerSort.key, filerSort.dir, accessors);
|
||||
function toggleSort(key: string) {
|
||||
setFilerSort({
|
||||
key,
|
||||
dir: filerSort.key === key ? (filerSort.dir === 'asc' ? 'desc' : 'asc') : 'desc',
|
||||
});
|
||||
setInstPage(1);
|
||||
}
|
||||
|
||||
function sortIndicator(key: string) {
|
||||
// Default mode is filed date primary + shares secondary — both show as active.
|
||||
if (filerSort.key === 'latestFiledAt') {
|
||||
if (key === 'latestFiledAt') return filerSort.dir === 'asc' ? '▲' : '▼';
|
||||
if (key === 'shares') return '▼'; // always shares DESC as secondary
|
||||
return '↕';
|
||||
}
|
||||
if (filerSort.key === 'shares') {
|
||||
if (key === 'shares') return filerSort.dir === 'asc' ? '▲' : '▼';
|
||||
if (key === 'latestFiledAt') return '▼';
|
||||
return '↕';
|
||||
}
|
||||
if (filerSort.key !== key) return '↕';
|
||||
return filerSort.dir === 'asc' ? '▲' : '▼';
|
||||
}
|
||||
|
||||
function formatFiledDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
// Prefer YYYY-MM-DD display from SEC file_date
|
||||
const d = iso.slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(d) ? d : iso;
|
||||
}
|
||||
|
||||
const sortKeys = buildSortKeys(filerSort.key, filerSort.dir);
|
||||
const sortedGroups = sortRowsMulti(filteredGroups, sortKeys, accessors);
|
||||
const pagedGroups = instPageSize === -1 ? sortedGroups : sortedGroups.slice((instPage - 1) * instPageSize, instPage * instPageSize);
|
||||
|
||||
const showingFrom = filteredGroups.length === 0 ? 0 : ((instPage - 1) * instPageSize) + 1;
|
||||
const showingTo = Math.min(instPage * instPageSize, filteredGroups.length);
|
||||
|
||||
const sortChip = (key: string, label: string) => {
|
||||
// Filed date + shares are both "active" in the default composite sort.
|
||||
const active =
|
||||
filerSort.key === key ||
|
||||
(filerSort.key === 'latestFiledAt' && key === 'shares') ||
|
||||
(filerSort.key === 'shares' && key === 'latestFiledAt');
|
||||
const primary = filerSort.key === key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
aria-pressed={primary}
|
||||
className={`min-h-[36px] sm:min-h-0 px-3 py-1.5 rounded-md border text-xs sm:text-[11px] font-medium transition-colors ${
|
||||
primary
|
||||
? 'border-accent bg-accent/15 text-fg'
|
||||
: active
|
||||
? 'border-accent/40 bg-accent/5 text-fg'
|
||||
: 'border-line text-fg-muted hover:text-fg hover:border-line-strong'
|
||||
}`}
|
||||
title={
|
||||
key === 'latestFiledAt'
|
||||
? 'Primary: SEC file date (then shares DESC)'
|
||||
: key === 'shares'
|
||||
? 'Primary: shares (then file date DESC)'
|
||||
: `Sort by ${label}, then file date, then shares`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
<span className={`ml-1 ${primary || active ? 'text-accent' : 'text-fg-muted/60'}`}>
|
||||
{sortIndicator(key)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const filterBar = (
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filerFilter}
|
||||
onChange={(e) => {
|
||||
setFilerFilter(e.target.value);
|
||||
setInstPage(1);
|
||||
}}
|
||||
placeholder="Filter filers..."
|
||||
className="flex-1 bg-surface-sunken border border-line text-fg rounded-md px-3 py-2.5 sm:py-1.5 text-base sm:text-xs placeholder:text-fg-muted/50 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => {
|
||||
setSourceFilter(e.target.value);
|
||||
setInstPage(1);
|
||||
}}
|
||||
className="bg-surface-sunken border border-line text-fg rounded-md px-3 py-2.5 sm:py-1.5 text-base sm:text-xs focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All forms</option>
|
||||
{uniqueSources.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label="Sort ownership list">
|
||||
<span className="text-[10px] uppercase tracking-wide text-fg-faint mr-0.5">Sort</span>
|
||||
{sortChip('latestFiledAt', 'Filed date')}
|
||||
{sortChip('shares', 'Shares')}
|
||||
{sortChip('filer', 'Filer')}
|
||||
{sortChip('netDelta', 'Change')}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-faint">
|
||||
{filerSort.key === 'shares'
|
||||
? 'ORDER BY shares DESC, filed_date DESC'
|
||||
: filerSort.key === 'latestFiledAt'
|
||||
? `ORDER BY filed_date ${filerSort.dir.toUpperCase()}, shares DESC`
|
||||
: `ORDER BY ${filerSort.key} ${filerSort.dir.toUpperCase()}, filed_date DESC, shares DESC`}
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-muted leading-relaxed max-w-3xl">
|
||||
<span className="font-medium text-fg">13F is a snapshot, not a buy ticket.</span>{' '}
|
||||
<strong className="font-medium text-fg">Shares</strong> = how many they reported owning at that
|
||||
quarter-end. If they hold the same amount next quarter, they report the same share count again.
|
||||
<strong className="font-medium text-fg"> Direction</strong> only appears when we have a prior
|
||||
quarter to compare (share count up / down / flat). One row with no history is just
|
||||
"held" - not proof they bought that day.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const pager = (
|
||||
<div className="flex items-center justify-between mt-3 px-1 gap-2">
|
||||
<span className="text-[10px] text-fg-muted">
|
||||
{filteredGroups.length === 0
|
||||
? "0 groups"
|
||||
: `Showing ${showingFrom}-${showingTo} of ${filteredGroups.length} groups`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInstPage(Math.max(1, instPage - 1))}
|
||||
disabled={instPage === 1 || filteredGroups.length === 0}
|
||||
className="min-h-[36px] sm:min-h-0 text-xs sm:text-[10px] px-3 sm:px-2 py-1 border border-line rounded hover:bg-surface-raised disabled:opacity-30"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-[10px] text-fg-muted">Page {instPage}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInstPage(instPage + 1)}
|
||||
disabled={instPage * instPageSize >= filteredGroups.length || filteredGroups.length === 0}
|
||||
className="min-h-[36px] sm:min-h-0 text-xs sm:text-[10px] px-3 sm:px-2 py-1 border border-line rounded hover:bg-surface-raised disabled:opacity-30"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{/* Accordion table */}
|
||||
{pagedGroups.length === 0 ? (
|
||||
<p className="py-4 text-center text-fg-muted text-xs">No filings match the current filters.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto mt-2">
|
||||
<table className="w-full text-xs" role="table" aria-label="Institutional ownership breakdown by filer and instrument type">
|
||||
<thead>
|
||||
<tr className="border-b border-[#3f3f46] text-fg-muted">
|
||||
<th className="py-1 px-2 w-8"></th>
|
||||
<th className="py-1 px-2 text-left">
|
||||
<input
|
||||
type="text"
|
||||
value={filerFilter}
|
||||
onChange={(e) => { setFilerFilter(e.target.value); setInstPage(1); }}
|
||||
placeholder="Filter filers..."
|
||||
className="w-full bg-[#1e1f2a] border border-line text-fg rounded px-1 py-0.5 text-[10px] placeholder:text-fg-muted/50 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-1 px-2 text-left text-[10px]">Instrument</th>
|
||||
<th className="py-1 px-2 text-right cursor-pointer select-none hover:text-fg text-[10px]" onClick={() => { setFilerSort({ key: 'shares', dir: filerSort.dir === 'asc' ? 'desc' : 'asc' }); setInstPage(1); }}>
|
||||
Shares {filerSort.key === 'shares' && (filerSort.dir === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th className="py-1 px-2 text-left text-[10px]">Direction</th>
|
||||
<th className="py-1 px-2 text-left">
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => { setSourceFilter(e.target.value); setInstPage(1); }}
|
||||
className="w-full bg-[#1e1f2a] border border-line text-fg rounded px-1 py-0.5 text-[10px] focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{uniqueSources.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
{filterBar}
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="md:hidden space-y-2" aria-label="Institutional ownership">
|
||||
{pagedGroups.length === 0 ? (
|
||||
<p className="py-6 text-center text-fg-muted text-xs">No filings match the current filters.</p>
|
||||
) : (
|
||||
pagedGroups.map((group: FilingGroupRow) => {
|
||||
const expandKey = `${group.filerCik}|${group.putCall}|${group.form}`;
|
||||
const isExpanded = expandedFilers.has(expandKey);
|
||||
const onlyOneQuarter = group.quarters.length < 2;
|
||||
const directionLabel = onlyOneQuarter
|
||||
? 'Held (no prior Q)'
|
||||
: DELTA_BADGE_LABELS[group.classification ?? '']?.label ?? '—';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={expandKey}
|
||||
className="rounded-lg border border-line bg-surface-raised overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFiler(expandKey)}
|
||||
className="w-full text-left p-3 min-h-[52px] active:bg-surface-sunken"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg truncate">
|
||||
{group.filerName ?? group.filerCik}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<InstrumentTypeBadge putCall={group.putCall} />
|
||||
<FormBadge form={group.form} />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-fg-muted text-xs transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 text-xs gap-2">
|
||||
<span className="font-mono tnum text-fg" title="Position at latest quarter-end">
|
||||
{formatShares(group.shares ?? 0)} held
|
||||
{ownershipPct != null && (
|
||||
<span className="ml-1.5 text-[10px] font-medium text-fg-muted" title={`Institutional ownership: ${(ownershipPct * 100).toFixed(2)}% of shares outstanding`}>
|
||||
· {(ownershipPct * 100).toFixed(1)}% out.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-fg-muted tnum">
|
||||
{formatFiledDate(group.latestFiledAt ?? group.quarters[0]?.filedAt)}
|
||||
</span>
|
||||
{onlyOneQuarter ? (
|
||||
<span className="text-fg-muted text-[10px]">Held (no prior Q)</span>
|
||||
) : group.netDelta !== 0 ? (
|
||||
<span
|
||||
className={`font-medium ${
|
||||
group.classification === "increasing"
|
||||
? "text-up"
|
||||
: group.classification === "reducing"
|
||||
? "text-down"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{group.netDelta > 0 && "+"}
|
||||
{formatShares(group.netDelta)} ({directionLabel})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-muted text-[10px]">Same as prior Q</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-line bg-surface-sunken/50 px-3 py-2 space-y-1.5">
|
||||
<p className="text-[10px] text-fg-faint">
|
||||
Each row = position at quarter-end. Δ = change vs prior quarter (not buys that week).
|
||||
</p>
|
||||
{group.quarters.map((qf: FilingQuarterly, qIdx: number) => (
|
||||
<div
|
||||
key={`${expandKey}-q-${qIdx}`}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="text-fg-muted">{formatQuarter(qf.reportedQuarter)}</span>
|
||||
<span className="text-fg-muted tnum text-[10px]">
|
||||
{formatFiledDate(qf.filedAt)}
|
||||
</span>
|
||||
<span className="font-mono tnum text-fg">
|
||||
{formatShares(qf.shares ?? 0)} held
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono tnum ${
|
||||
qf.delta > 0 ? "text-up" : qf.delta < 0 ? "text-down" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{qf.isOpening
|
||||
? 'first report'
|
||||
: qf.delta === 0
|
||||
? 'flat'
|
||||
: `${qf.delta > 0 ? '+' : ''}${formatShares(qf.delta)}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</select>
|
||||
</th>
|
||||
<th className="py-1 px-2"></th>
|
||||
{group.filerCik && (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${group.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex min-h-[36px] items-center text-xs text-accent"
|
||||
>
|
||||
EDGAR ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{pager}
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block overflow-x-auto mt-2">
|
||||
<table
|
||||
className="w-full text-xs"
|
||||
role="table"
|
||||
aria-label="Institutional ownership breakdown by filer and instrument type"
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-[#3f3f46] text-fg-muted">
|
||||
<th className="py-1 px-2 w-8"></th>
|
||||
<th
|
||||
className="py-1 px-2 text-left cursor-pointer select-none hover:text-fg text-[10px]"
|
||||
onClick={() => toggleSort('filer')}
|
||||
>
|
||||
Filer {sortIndicator('filer')}
|
||||
</th>
|
||||
<th className="py-1 px-2 text-left text-[10px]">Instrument</th>
|
||||
<th
|
||||
className="py-1 px-2 text-left cursor-pointer select-none hover:text-fg text-[10px]"
|
||||
onClick={() => toggleSort('latestFiledAt')}
|
||||
title="SEC file date — newest first by default"
|
||||
>
|
||||
Filed {sortIndicator('latestFiledAt')}
|
||||
</th>
|
||||
<th
|
||||
className="py-1 px-2 text-right cursor-pointer select-none hover:text-fg text-[10px]"
|
||||
onClick={() => toggleSort('shares')}
|
||||
title="Shares held at latest quarter-end (position snapshot, not purchase volume)"
|
||||
>
|
||||
Held {sortIndicator('shares')}
|
||||
</th>
|
||||
<th className="py-1 px-2 text-right text-fg-muted text-[10px]" title="Institutional ownership % of total shares outstanding (all tracked funds combined)">
|
||||
Out.
|
||||
</th>
|
||||
<th
|
||||
className="py-1 px-2 text-left cursor-pointer select-none hover:text-fg text-[10px]"
|
||||
onClick={() => toggleSort('netDelta')}
|
||||
title="Change in share count vs prior quarter we have. Flat / no prior Q ≠ no ownership."
|
||||
>
|
||||
vs prior Q {sortIndicator('netDelta')}
|
||||
</th>
|
||||
<th
|
||||
className="py-1 px-2 text-left cursor-pointer select-none hover:text-fg text-[10px]"
|
||||
onClick={() => toggleSort('form')}
|
||||
>
|
||||
Form {sortIndicator('form')}
|
||||
</th>
|
||||
<th className="py-1 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedGroups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-6 text-center text-fg-muted text-xs">
|
||||
No filings match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedGroups.map((group: FilingGroupRow, idx: number) => {
|
||||
) : (
|
||||
pagedGroups.map((group: FilingGroupRow) => {
|
||||
const expandKey = `${group.filerCik}|${group.putCall}|${group.form}`;
|
||||
const isExpanded = expandedFilers.has(expandKey);
|
||||
const directionLabel = DELTA_BADGE_LABELS[group.classification ?? '']?.label ?? '—';
|
||||
const onlyOneQuarter = group.quarters.length < 2;
|
||||
const directionLabel = onlyOneQuarter
|
||||
? 'Held (no prior Q)'
|
||||
: DELTA_BADGE_LABELS[group.classification ?? '']?.label ?? '—';
|
||||
|
||||
return (
|
||||
<React.Fragment key={expandKey}>
|
||||
{/* Top-level accordion row */}
|
||||
<tr className={`border-b border-line cursor-pointer hover:bg-[#1e1f2a]/50 transition-colors ${isExpanded ? '' : ''}`} onClick={() => toggleFiler(expandKey)}>
|
||||
<tr
|
||||
className="border-b border-line cursor-pointer hover:bg-[#1e1f2a]/50 transition-colors"
|
||||
onClick={() => toggleFiler(expandKey)}
|
||||
>
|
||||
<td className="py-1.5 px-2 text-center">
|
||||
<span className={`text-fg-muted transition-transform duration-150 inline-block ${isExpanded ? 'rotate-90' : ''}`} aria-hidden="true">▶</span>
|
||||
<span
|
||||
className={`text-fg-muted transition-transform duration-150 inline-block ${isExpanded ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-fg font-medium sticky left-0 bg-surface-raised z-10">
|
||||
{group.filerName ?? group.filerCik}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-fg font-medium">{group.filerName ?? group.filerCik}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<InstrumentTypeBadge putCall={group.putCall} />
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(group.shares ?? 0)}</td>
|
||||
<td className="py-1.5 px-2 text-left font-mono tnum text-fg-muted whitespace-nowrap">
|
||||
{formatFiledDate(group.latestFiledAt ?? group.quarters[0]?.filedAt)}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-2 text-right font-mono text-fg"
|
||||
title="Position at latest quarter-end"
|
||||
>
|
||||
{formatShares(group.shares ?? 0)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono tnum text-xs whitespace-nowrap">
|
||||
{ownershipPct != null ? (
|
||||
<span title={`Institutional ownership: ${(ownershipPct * 100).toFixed(2)}% of shares outstanding`} className={ownershipPct >= 0.05 ? "text-warning font-medium" : "text-fg-muted"}>
|
||||
{(ownershipPct * 100).toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-faint">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{group.netDelta !== 0 ? (
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-medium border rounded px-1.5 py-0.5 ${
|
||||
group.classification === 'increasing'
|
||||
? 'text-up border-[#064e3b]'
|
||||
: group.classification === 'reducing'
|
||||
? 'text-down border-[#7f1d1d]'
|
||||
: 'text-fg-muted border-line'
|
||||
}`}>
|
||||
{group.netDelta > 0 && '+'}
|
||||
{onlyOneQuarter ? (
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-medium border border-line rounded px-1.5 py-0.5 text-fg-muted"
|
||||
title="Only one quarter on file. Shares are ownership, not a new buy."
|
||||
>
|
||||
Held (no prior Q)
|
||||
</span>
|
||||
) : group.netDelta !== 0 ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[10px] font-medium border rounded px-1.5 py-0.5 ${
|
||||
group.classification === "increasing"
|
||||
? "text-up border-[#064e3b]"
|
||||
: group.classification === "reducing"
|
||||
? "text-down border-[#7f1d1d]"
|
||||
: "text-fg-muted border-line"
|
||||
}`}
|
||||
title="Share count change vs prior quarter (inferred, not a trade list)"
|
||||
>
|
||||
{group.netDelta > 0 && "+"}
|
||||
{formatShares(group.netDelta)} ({directionLabel})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-muted text-[10px]">—</span>
|
||||
<span className="text-fg-muted text-[10px]">Same as prior Q</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
@@ -215,74 +620,90 @@ export function InstitutionalAccordion({
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right">
|
||||
{isExpanded ? (
|
||||
<span className="text-fg-muted text-[10px]">{group.quarters.length} filings</span>
|
||||
<span className="text-fg-muted text-[10px]">
|
||||
{group.quarters.length} filings
|
||||
</span>
|
||||
) : group.filerCik ? (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${group.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View ${group.filerName ?? group.filerCik} filings on SEC EDGAR`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>↗</a>
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded drill-down rows */}
|
||||
{isExpanded && group.quarters.map((qf: FilingQuarterly, qIdx: number) => (
|
||||
<tr key={`${expandKey}-q-${qIdx}`} className="border-b border-line/50 bg-[#121318]/60">
|
||||
<td className="py-1 px-4"></td>
|
||||
<td className="py-1 px-4 text-fg-muted text-[10px] pl-5">{formatQuarter(qf.reportedQuarter)}</td>
|
||||
<td className="py-1 px-4">
|
||||
<InstrumentTypeBadge putCall={qf.putCall} />
|
||||
</td>
|
||||
<td className="py-1 px-4 text-right font-mono text-fg">{formatShares(qf.shares ?? 0)}</td>
|
||||
<td className={`py-1 px-4 text-right font-mono text-xs ${qf.delta > 0 ? 'text-up' : qf.delta < 0 ? 'text-down' : 'text-fg-muted'}`}>
|
||||
{qf.delta === 0 ? '—' : `${qf.delta > 0 ? '+' : ''}${formatShares(qf.delta)}`}
|
||||
</td>
|
||||
<td className="py-1 px-4">
|
||||
<FormBadge form={qf.form} />
|
||||
</td>
|
||||
<td className="py-1 px-2 text-right">
|
||||
{group.filerCik && (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${group.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label={`View ${qf.reportedQuarter} filing on SEC EDGAR`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>↗</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{isExpanded &&
|
||||
group.quarters.map((qf: FilingQuarterly, qIdx: number) => (
|
||||
<tr
|
||||
key={`${expandKey}-q-${qIdx}`}
|
||||
className="border-b border-line/50 bg-[#121318]/60"
|
||||
>
|
||||
<td className="py-1 px-4"></td>
|
||||
<td className="py-1 px-4 text-fg-muted text-[10px] pl-5">
|
||||
{formatQuarter(qf.reportedQuarter)}
|
||||
</td>
|
||||
<td className="py-1 px-4">
|
||||
<InstrumentTypeBadge putCall={qf.putCall} />
|
||||
</td>
|
||||
<td
|
||||
className="py-1 px-4 text-right font-mono text-fg"
|
||||
title="Shares held at that quarter-end"
|
||||
>
|
||||
{formatShares(qf.shares ?? 0)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-1 px-4 text-right font-mono text-xs ${
|
||||
qf.delta > 0
|
||||
? "text-up"
|
||||
: qf.delta < 0
|
||||
? "text-down"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
title={
|
||||
qf.isOpening
|
||||
? 'First quarter in our history - not a buy signal'
|
||||
: 'Change in share count vs prior quarter'
|
||||
}
|
||||
>
|
||||
{qf.isOpening
|
||||
? 'first report'
|
||||
: qf.delta === 0
|
||||
? 'flat'
|
||||
: `${qf.delta > 0 ? '+' : ''}${formatShares(qf.delta)}`}
|
||||
</td>
|
||||
<td className="py-1 px-4">
|
||||
<FormBadge form={qf.form} />
|
||||
</td>
|
||||
<td className="py-1 px-2 text-right">
|
||||
{group.filerCik && (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${group.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View ${qf.reportedQuarter} filing on SEC EDGAR`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-3 px-1">
|
||||
<span className="text-[10px] text-fg-muted">
|
||||
Showing {((instPage - 1) * instPageSize) + 1}-{Math.min(instPage * instPageSize, filteredGroups.length)} of {filteredGroups.length} groups
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setInstPage(Math.max(1, instPage - 1))}
|
||||
disabled={instPage === 1}
|
||||
className="text-[10px] px-2 py-0.5 border border-line rounded hover:bg-surface-raised disabled:opacity-30"
|
||||
>← Prev</button>
|
||||
<span className="text-[10px] text-fg-muted">Page {instPage}</span>
|
||||
<button
|
||||
onClick={() => setInstPage(instPage + 1)}
|
||||
disabled={instPage * instPageSize >= filteredGroups.length}
|
||||
className="text-[10px] px-2 py-0.5 border border-line rounded hover:bg-surface-raised disabled:opacity-30"
|
||||
>Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{pager}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,12 @@ interface FilingRow {
|
||||
|
||||
interface QuarterlyAgg {
|
||||
quarter: string;
|
||||
/** Net share-count change vs prior quarter among filers we can pair (matched book). */
|
||||
netDelta: number;
|
||||
/** Distinct filers contributing to this quarter's bar. */
|
||||
filerCount: number;
|
||||
/** True when this quarter has far fewer filers than the prior one (13F season incomplete). */
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
interface MonthlyAgg {
|
||||
@@ -132,6 +137,8 @@ interface FilingGroupRow {
|
||||
shares: number; // latest quarter aggregate shares for this combo
|
||||
netDelta: number; // total change vs earliest quarter of same combo
|
||||
classification: string; // 'increasing' | 'reducing' | 'flat'
|
||||
/** Most recent SEC file date in this group (for "new filings first" sort). */
|
||||
latestFiledAt: string;
|
||||
quarters: QuarterlyFiling[];
|
||||
}
|
||||
|
||||
@@ -142,13 +149,17 @@ interface QuarterlyFiling {
|
||||
filedAt: string;
|
||||
form: string;
|
||||
putCall: string;
|
||||
delta: number; // vs prior quarter's same (filer, instrument, form)
|
||||
/** QoQ share change vs prior filing in this series. 0 = unchanged. */
|
||||
delta: number;
|
||||
/** True when this is the first filing in the series (no prior quarter to compare). */
|
||||
isOpening: boolean;
|
||||
}
|
||||
|
||||
/** UI labels for rollup keys (ADR-0007: no "distributing" / trade imperatives). */
|
||||
const DELTA_BADGE: Record<string, { label: string; color: string; shape: string }> = {
|
||||
mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]', shape: '?' },
|
||||
increasing: { label: 'Increasing', color: 'text-up border-[#064e3b]', shape: '▲' },
|
||||
reducing: { label: 'Reducing', color: 'text-down border-[#7f1d1d]', shape: '▼' },
|
||||
increasing: { label: 'Adding', color: 'text-up border-[#064e3b]', shape: '▲' },
|
||||
reducing: { label: 'Lightening', color: 'text-down border-[#7f1d1d]', shape: '▼' },
|
||||
flat: { label: 'Flat', color: 'text-fg-muted border-line', shape: '—' },
|
||||
};
|
||||
|
||||
@@ -165,6 +176,13 @@ const ALERT_BADGE: Record<string, string> = {
|
||||
'mixed signal': 'bg-[#78350f] text-[#fcd34d] border border-[#78350f]',
|
||||
};
|
||||
|
||||
/** Human labels for rollup alert keys (never show raw "distribution"). */
|
||||
const ALERT_LABELS: Record<string, string> = {
|
||||
accumulation: 'Adding',
|
||||
distribution: 'Lightening',
|
||||
'mixed signal': 'Mixed',
|
||||
};
|
||||
|
||||
function FormBadge({ form }: { form: string }) {
|
||||
const is13F = form === '13F-HR';
|
||||
return (
|
||||
@@ -216,9 +234,74 @@ function formatQuarter(q: string): string {
|
||||
}
|
||||
|
||||
function formatShares(n: number): string {
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1e6) return `${(abs / 1e6).toFixed(2)}M`;
|
||||
if (abs >= 1e3) return `${(abs / 1e3).toFixed(1)}K`;
|
||||
return abs.toLocaleString();
|
||||
}
|
||||
|
||||
/** Compact with sign for chart axis / tooltips (deltas). */
|
||||
function formatSharesSigned(n: number): string {
|
||||
if (n === 0) return '0';
|
||||
const sign = n < 0 ? '−' : '+';
|
||||
return `${sign}${formatShares(n)}`;
|
||||
}
|
||||
|
||||
/** Full integer share count with sign for tooltips (axis stays compact). */
|
||||
function formatSharesExact(n: number): string {
|
||||
const rounded = Math.round(n);
|
||||
if (rounded === 0) return '0';
|
||||
const sign = rounded < 0 ? '−' : '+';
|
||||
return `${sign}${Math.abs(rounded).toLocaleString('en-US')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom tooltip — Recharts default item text often inherits bad contrast
|
||||
* (contentStyle.color does not always apply to the value row).
|
||||
*/
|
||||
function InstitutionalFlowTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value?: number; payload?: QuarterlyAgg }>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]?.payload;
|
||||
const n = typeof payload[0]?.value === 'number' ? payload[0].value : row?.netDelta ?? 0;
|
||||
const exact = formatSharesExact(n);
|
||||
const compact = formatSharesSigned(n);
|
||||
const quarter = formatQuarter(String(label ?? row?.quarter ?? ''));
|
||||
const filers = row?.filerCount ?? 0;
|
||||
const incomplete = row?.incomplete ?? false;
|
||||
const deltaColor =
|
||||
n > 0 ? 'var(--color-up)' : n < 0 ? 'var(--color-down)' : 'var(--color-fg-muted)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line bg-surface-raised px-3 py-2 shadow-lg min-w-[12rem]"
|
||||
style={{ color: 'var(--color-fg)' }}
|
||||
>
|
||||
<p className="text-[11px] font-medium text-fg mb-1">
|
||||
{quarter}
|
||||
{incomplete ? ' · incomplete' : ''}
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums" style={{ color: deltaColor }}>
|
||||
{exact}
|
||||
<span className="text-fg-muted font-normal text-xs ml-1.5">shares</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 tabular-nums">
|
||||
Compact: {compact}
|
||||
{incomplete ? ' · partial filings' : ''}
|
||||
</p>
|
||||
<p className="text-[11px] text-fg-muted mt-1">
|
||||
{filers.toLocaleString('en-US')} filer{filers === 1 ? '' : 's'}
|
||||
{incomplete ? ' so far' : ''} · matched book net Δ
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,14 +347,17 @@ function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
const quarterEntries: QuarterEntry[] = [];
|
||||
|
||||
for (const [, entries] of seenQuartersByForm) {
|
||||
// Within same quarter+form, prefer non-13F-HR (i.e. 13G/13D) over 13F-HR
|
||||
const preferred = entries.find(e => e.form !== '13F-HR') ?? entries[0];
|
||||
// Within same quarter+form: newest file date wins (entries were pushed in filedAt ASC).
|
||||
// Prefer non-13F-HR (13G/13D) when present; otherwise last = latest amendment.
|
||||
const preferred =
|
||||
[...entries].reverse().find((e) => e.form !== '13F-HR') ??
|
||||
entries[entries.length - 1];
|
||||
quarterEntries.push({
|
||||
reportedQuarter: preferred.reportedQuarter,
|
||||
form: preferred.form,
|
||||
shares: preferred.shares ?? 0,
|
||||
valueUsd: preferred.valueUsd ?? 0,
|
||||
filedAt: preferred.filedAt,
|
||||
filedAt: (preferred.filedAt ?? '').slice(0, 10),
|
||||
putCall: preferred.putCall ?? '',
|
||||
});
|
||||
}
|
||||
@@ -284,17 +370,21 @@ function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
return b.filedAt.localeCompare(a.filedAt);
|
||||
});
|
||||
|
||||
// Compute QoQ deltas within this bucket (comparing same form across quarters)
|
||||
// Compute QoQ deltas within this bucket (comparing same form across quarters).
|
||||
// 13F reports end-of-quarter *positions*, not purchase volume — so equal
|
||||
// share counts across quarters mean the position was held flat (delta 0),
|
||||
// not that the filer bought that amount again.
|
||||
const quarterlyFilings: QuarterlyFiling[] = [];
|
||||
for (let i = 0; i < quartersSortedDesc.length; i++) {
|
||||
const curr = quartersSortedDesc[i];
|
||||
let delta = 0;
|
||||
|
||||
// Compare each quarter to the next-older quarter with the same form
|
||||
// Next-older filing with the same form (list is newest-first)
|
||||
const priorSameForm = quartersSortedDesc.slice(i + 1).find(p => p.form === curr.form);
|
||||
if (priorSameForm && priorSameForm.shares > 0) {
|
||||
delta = curr.shares - priorSameForm.shares;
|
||||
}
|
||||
const isOpening = !priorSameForm;
|
||||
// Only a true QoQ delta when we have a prior position to compare against.
|
||||
// Opening rows are not counted as a full-size "buy" of the position.
|
||||
const delta = priorSameForm
|
||||
? curr.shares - (priorSameForm.shares ?? 0)
|
||||
: 0;
|
||||
|
||||
quarterlyFilings.push({
|
||||
reportedQuarter: curr.reportedQuarter,
|
||||
@@ -304,19 +394,21 @@ function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
form: curr.form,
|
||||
putCall: curr.putCall,
|
||||
delta,
|
||||
isOpening,
|
||||
});
|
||||
}
|
||||
|
||||
// Latest quarter = first in desc-sorted list
|
||||
// Latest quarter = first in desc-sorted list — "Held" is the current position
|
||||
const latestQuarter = quartersSortedDesc[0];
|
||||
const shares = latestQuarter.shares;
|
||||
|
||||
// Net direction: compare latest vs earliest (same form type)
|
||||
// Net direction across the observed window: latest held vs earliest held
|
||||
// (same form). Per-quarter QoQ lives on each drill-down row.
|
||||
let netDelta = 0;
|
||||
if (quartersSortedDesc.length > 1) {
|
||||
const earliestSameForm = [...quartersSortedDesc].reverse().find(q => q.form === latestQuarter.form);
|
||||
if (earliestSameForm && earliestSameForm.shares > 0) {
|
||||
netDelta = shares - earliestSameForm.shares;
|
||||
if (earliestSameForm) {
|
||||
netDelta = shares - (earliestSameForm.shares ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +421,13 @@ function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
// Extract filerCik and putCall from key (format: cik|putCall|form)
|
||||
const [filerCik, putCall] = key.split('|');
|
||||
|
||||
// Max SEC file date across the series (YYYY-MM-DD) — drives ORDER BY filed_date.
|
||||
const latestFiledAt = quarterlyFilings.reduce((max, q) => {
|
||||
const d = (q.filedAt ?? '').slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return max;
|
||||
return !max || d > max ? d : max;
|
||||
}, '');
|
||||
|
||||
result.push({
|
||||
filerCik,
|
||||
filerName: groupFilings[0].filerName ?? null,
|
||||
@@ -337,6 +436,7 @@ function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
shares,
|
||||
netDelta,
|
||||
classification,
|
||||
latestFiledAt,
|
||||
quarters: quarterlyFilings,
|
||||
});
|
||||
}
|
||||
@@ -345,8 +445,8 @@ function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
}
|
||||
|
||||
const DELTA_BADGE_LABELS: Record<string, { label: string }> = {
|
||||
increasing: { label: '▲ Increasing' },
|
||||
reducing: { label: '▼ Reducing' },
|
||||
increasing: { label: '▲ Adding' },
|
||||
reducing: { label: '▼ Lightening' },
|
||||
flat: { label: '— Flat' },
|
||||
};
|
||||
|
||||
@@ -355,6 +455,81 @@ function formatMonth(m: string): string {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Parse "YYYY-Qn" (or "YYYY-n") into [year, quarter] for ordering. */
|
||||
function parseQuarterKey(q: string): [number, number] {
|
||||
const match = q.match(/^(\d{4})-Q?([1-4])$/i);
|
||||
if (!match) return [0, 0];
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chart series from raw filings: matched adjacent-quarter share deltas.
|
||||
* Does NOT use raw SUM(shares) per quarter (that falsely "lightens" incomplete seasons).
|
||||
*/
|
||||
function aggregateFlowByQuarterFromFilings(filings: FilingRow[]): QuarterlyAgg[] {
|
||||
// filer → quarter → max shares (13F-HR equity book; skip pure option legs if putCall set? keep all for now)
|
||||
const byFiler = new Map<string, Map<string, number>>();
|
||||
const filersPerQuarter = new Map<string, Set<string>>();
|
||||
|
||||
for (const f of filings) {
|
||||
if (f.form && f.form !== '13F-HR' && !f.form.startsWith('13F')) continue;
|
||||
const q = f.reportedQuarter;
|
||||
if (!q) continue;
|
||||
let qMap = byFiler.get(f.filerCik);
|
||||
if (!qMap) {
|
||||
qMap = new Map();
|
||||
byFiler.set(f.filerCik, qMap);
|
||||
}
|
||||
qMap.set(q, Math.max(qMap.get(q) ?? 0, f.shares ?? 0));
|
||||
let set = filersPerQuarter.get(q);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
filersPerQuarter.set(q, set);
|
||||
}
|
||||
set.add(f.filerCik);
|
||||
}
|
||||
|
||||
const deltaByQ = new Map<string, { net: number; filers: number }>();
|
||||
for (const qMap of byFiler.values()) {
|
||||
const qs = [...qMap.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (let i = 1; i < qs.length; i++) {
|
||||
const prevQ = qs[i - 1];
|
||||
const currQ = qs[i];
|
||||
const delta = (qMap.get(currQ) ?? 0) - (qMap.get(prevQ) ?? 0);
|
||||
if (delta === 0) continue;
|
||||
const acc = deltaByQ.get(currQ) ?? { net: 0, filers: 0 };
|
||||
acc.net += delta;
|
||||
acc.filers += 1;
|
||||
deltaByQ.set(currQ, acc);
|
||||
}
|
||||
}
|
||||
|
||||
const quarters = [...new Set([...filersPerQuarter.keys(), ...deltaByQ.keys()])].sort((a, b) => {
|
||||
const [yA, qA] = parseQuarterKey(a);
|
||||
const [yB, qB] = parseQuarterKey(b);
|
||||
if (yA !== yB) return yB - yA;
|
||||
return qB - qA;
|
||||
});
|
||||
|
||||
return quarters
|
||||
.map((quarter, idx) => {
|
||||
const n = filersPerQuarter.get(quarter)?.size ?? 0;
|
||||
// Next older quarter in the sorted-desc list
|
||||
const older = quarters[idx + 1];
|
||||
const nOlder = older ? (filersPerQuarter.get(older)?.size ?? 0) : 0;
|
||||
const incomplete = nOlder > 0 && n < nOlder * 0.75;
|
||||
const d = deltaByQ.get(quarter);
|
||||
return {
|
||||
quarter,
|
||||
netDelta: d?.net ?? 0,
|
||||
filerCount: n,
|
||||
incomplete,
|
||||
};
|
||||
})
|
||||
.filter((r) => r.netDelta !== 0 || r.filerCount > 0);
|
||||
}
|
||||
|
||||
/** @deprecated use aggregateFlowByQuarterFromFilings — kept for flow-row fallback */
|
||||
function aggregateFlowByQuarter(flow: FlowRow[]): QuarterlyAgg[] {
|
||||
const quarterMap = new Map<string, number>();
|
||||
for (const row of flow) {
|
||||
@@ -362,12 +537,17 @@ function aggregateFlowByQuarter(flow: FlowRow[]): QuarterlyAgg[] {
|
||||
quarterMap.set(row.reportedQuarter, existing + row.delta);
|
||||
}
|
||||
return [...quarterMap.entries()]
|
||||
.map(([quarter, netDelta]) => ({ quarter, netDelta }))
|
||||
.map(([quarter, netDelta]) => ({
|
||||
quarter,
|
||||
netDelta,
|
||||
filerCount: 0,
|
||||
incomplete: false,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const [yA, qA] = a.quarter.split('-');
|
||||
const [yB, qB] = b.quarter.split('-');
|
||||
if (yA !== yB) return Number(yB) - Number(yA);
|
||||
return Number(qB) - Number(qA);
|
||||
const [yA, qA] = parseQuarterKey(a.quarter);
|
||||
const [yB, qB] = parseQuarterKey(b.quarter);
|
||||
if (yA !== yB) return yB - yA;
|
||||
return qB - qA;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,12 +591,14 @@ export function InstitutionalDashboard() {
|
||||
// Institution breakdown table — sort + pagination
|
||||
const [instPage, setInstPage] = useState(1);
|
||||
const [instPageSize, setInstPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [filerSort, setFilerSort] = useState<SortState>({ key: 'shares', dir: 'desc' });
|
||||
// Default: ORDER BY filed_date DESC, shares DESC (see InstitutionalAccordion multi-key sort).
|
||||
const [filerSort, setFilerSort] = useState<SortState>({ key: 'latestFiledAt', dir: 'desc' });
|
||||
const [filerFilter, setFilerFilter] = useState('');
|
||||
const [quarterFilter, setQuarterFilter] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
|
||||
const [flowData, setFlowData] = useState<{ symbol: string; flow: FlowRow[]; quarters: string[]; filings: FilingRow[] } | null>(null);
|
||||
interface OwnershipInfo { heldShares: number; outstanding: number | null; float: number | null; pctOutstanding: number | null; pctFloat: number | null; }
|
||||
const [flowData, setFlowData] = useState<{ symbol: string; flow: FlowRow[]; quarters: string[]; filings: FilingRow[]; ownership: OwnershipInfo | null } | null>(null);
|
||||
interface InsiderEvent {
|
||||
reporter: string;
|
||||
relationship?: string | null;
|
||||
@@ -497,6 +679,7 @@ export function InstitutionalDashboard() {
|
||||
}>).map(r => ({ ...r, prevShares: r.prevShares ?? 0, currShares: r.currShares ?? 0, delta: r.delta ?? 0 })),
|
||||
quarters: flowResult.quarters,
|
||||
filings: ((flowResult as Record<string, unknown>).filings as FilingRow[]) ?? [],
|
||||
ownership: (flowResult as Record<string, unknown>).ownership as OwnershipInfo | null || null,
|
||||
});
|
||||
} else {
|
||||
setFlowData(null);
|
||||
@@ -575,37 +758,62 @@ export function InstitutionalDashboard() {
|
||||
if (!flowData || flowData.quarters.length === 0) {
|
||||
return <div className="flex items-center justify-center h-full text-sm text-fg-muted">No institutional ownership data available for {selectedSymbol}.</div>;
|
||||
}
|
||||
const chartData = aggregateFlowByQuarter(flowData.flow);
|
||||
// Prefer filings-based matched QoQ (not raw quarter totals, not single latest pair).
|
||||
const chartData =
|
||||
flowData.filings.length > 0
|
||||
? aggregateFlowByQuarterFromFilings(flowData.filings)
|
||||
: aggregateFlowByQuarter(flowData.flow);
|
||||
const hasIncomplete = chartData.some((d) => d.incomplete);
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={chartData as any} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="quarter" tickFormatter={formatQuarter} tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatShares(v)} width={80} />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
formatter={((value: number) => [`${formatShares(value)}`, 'Net Shares']) as any}
|
||||
labelFormatter={(label) => `Quarter: ${formatQuarter(label as string)}`}
|
||||
/>
|
||||
<Bar dataKey="netDelta" radius={[3, 3, 0, 0]}>
|
||||
{(chartData as unknown as Array<Record<string, unknown>>).map((entry) => (
|
||||
<Cell key={String(entry.quarter)} fill={(entry.netDelta as number) >= 0 ? CHART.up : CHART.down} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={chartData as any} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="quarter" tickFormatter={formatQuarter} tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatShares(v)} width={80} />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
formatter={((value: number) => [`${formatShares(value)}`, 'Net Shares']) as any}
|
||||
labelFormatter={(label) => `Quarter: ${formatQuarter(label as string)}`}
|
||||
/>
|
||||
<Line type="monotone" dataKey="netDelta" stroke={CHART.accent} strokeWidth={2} dot={{ fill: CHART.accent, r: 3 }} />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
<div className="h-full flex flex-col">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={chartData as any} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis
|
||||
dataKey="quarter"
|
||||
tickFormatter={(q) => {
|
||||
const row = chartData.find((d) => d.quarter === q);
|
||||
const label = formatQuarter(q);
|
||||
return row?.incomplete ? `${label}*` : label;
|
||||
}}
|
||||
tick={{ fontSize: 10, fill: CHART.axis }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatSharesSigned(v)} width={80} />
|
||||
<Tooltip
|
||||
content={<InstitutionalFlowTooltip />}
|
||||
cursor={{ fill: 'var(--color-surface-sunken)', fillOpacity: 0.45 }}
|
||||
/>
|
||||
<Bar dataKey="netDelta" radius={[3, 3, 0, 0]}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell
|
||||
key={String(entry.quarter)}
|
||||
fill={entry.netDelta >= 0 ? CHART.up : CHART.down}
|
||||
fillOpacity={entry.incomplete ? 0.55 : 1}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={chartData as any} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="quarter" tickFormatter={formatQuarter} tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatSharesSigned(v)} width={80} />
|
||||
<Tooltip
|
||||
content={<InstitutionalFlowTooltip />}
|
||||
cursor={{ stroke: 'var(--color-line-strong)', strokeWidth: 1 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="netDelta" stroke={CHART.accent} strokeWidth={2} dot={{ fill: CHART.accent, r: 3 }} />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-fg-faint px-1 pt-1 leading-relaxed">
|
||||
Bars = net change in share count for filers present in that quarter and a prior one (matched book).
|
||||
Not total shares held, and not a buy/sell blotter.
|
||||
{hasIncomplete ? ' * = filing window still open (fewer managers reported than prior quarter).' : ''}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}, [flowLoading, flowError, flowData, chartType, selectedSymbol]);
|
||||
|
||||
@@ -696,9 +904,9 @@ export function InstitutionalDashboard() {
|
||||
<div className="flex items-center justify-between gap-4 p-3 bg-surface-sunken border border-line rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm font-semibold text-fg">{selectedRow.symbol}</span>
|
||||
<span className={`inline-flex items-center gap-1 text-xs uppercase tracking-wider border rounded-lg px-2 py-0.5 ${DELTA_BADGE[selectedRow.convictionDelta].color}`}>
|
||||
<span aria-hidden="true">{DELTA_BADGE[selectedRow.convictionDelta].shape}</span>
|
||||
{DELTA_BADGE[selectedRow.convictionDelta].label}
|
||||
<span className={`inline-flex items-center gap-1 text-xs uppercase tracking-wider border rounded-lg px-2 py-0.5 ${DELTA_BADGE[selectedRow.convictionDelta]?.color ?? 'text-fg-muted border-line'}`}>
|
||||
<span aria-hidden="true">{DELTA_BADGE[selectedRow.convictionDelta]?.shape ?? '—'}</span>
|
||||
{DELTA_BADGE[selectedRow.convictionDelta]?.label ?? selectedRow.convictionDelta}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-fg-muted">
|
||||
@@ -712,15 +920,18 @@ export function InstitutionalDashboard() {
|
||||
{selectedRow.alert && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-line" />
|
||||
<span className={`px-1.5 py-0.5 rounded border text-[10px] ${ALERT_BADGE[selectedRow.alert] ?? 'text-fg-muted border-line'}`}>
|
||||
{selectedRow.alert}
|
||||
<span className={`px-1.5 py-0.5 rounded border text-[10px] uppercase tracking-wider ${ALERT_BADGE[selectedRow.alert] ?? 'text-fg-muted border-line'}`}>
|
||||
{ALERT_LABELS[selectedRow.alert] ?? selectedRow.alert}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-[10px] text-fg-muted" title="Conviction = institutions + insiders combined. ▲ Increasing (net buying), ▼ Reducing (net selling), ? Mixed (disagree), — Flat.">
|
||||
Conviction
|
||||
<span
|
||||
className="text-[10px] text-fg-muted"
|
||||
title="Net picture from institutions + insiders. ▲ Adding (more shares reported), ▼ Lightening (fewer shares), ? Mixed (sources disagree), — Flat."
|
||||
>
|
||||
Net flow
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -767,7 +978,12 @@ export function InstitutionalDashboard() {
|
||||
{/* Institutional Flow Section (13F data — quarterly only) */}
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-fg" title="Institutional holdings from 13F filings, reported quarterly 45 days after quarter end. Shows net position changes per filer.">Institutional Ownership (13F)</h4>
|
||||
<h4
|
||||
className="text-sm font-semibold text-fg"
|
||||
title="Chart: matched QoQ share-count change. Table Held = position at quarter-end. Incomplete latest quarter is marked so it is not read as a full-season dump."
|
||||
>
|
||||
Institutional Ownership
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Chart type toggle */}
|
||||
<div className="inline-flex rounded-lg border border-[#3f3f46] overflow-hidden" role="group" aria-label="Chart type">
|
||||
@@ -812,6 +1028,7 @@ export function InstitutionalDashboard() {
|
||||
setInstPageSize={setInstPageSize}
|
||||
expandedFilers={expandedFilers}
|
||||
toggleFiler={toggleFiler}
|
||||
ownershipPct={flowData?.ownership?.pctOutstanding ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SidebarNav } from "./SidebarNav";
|
||||
import { WatchlistSidebar } from "./WatchlistSidebar";
|
||||
import { SymbolHeader } from "./SymbolHeader";
|
||||
@@ -7,31 +8,36 @@ import { MobileTabNav } from "./MobileTabNav";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { CollapsibleProvider, useCollapsibleContext } from "@/lib/collapsible-context";
|
||||
import { useKeyboardShortcuts } from "@/lib/useKeyboardShortcuts";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const TABLET_BREAKPOINT = 1024;
|
||||
|
||||
function useViewport() {
|
||||
const [width, setWidth] = useState(typeof window !== "undefined" ? window.innerWidth : 1200);
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return width;
|
||||
}
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import {
|
||||
disableHistoryScrollRestoration,
|
||||
scrollAppMainToTop,
|
||||
scrollAppMainToTopSoon,
|
||||
} from "@/lib/scroll-app-main";
|
||||
|
||||
/**
|
||||
* Inner component that attaches the global Escape listener.
|
||||
* Wrapped in CollapsibleProvider so all CollapsibleSections can register.
|
||||
*
|
||||
* Layout is CSS-first (no JS viewport width) so mobile never flashes desktop chrome:
|
||||
* - Mobile (<md): header + scroll main + bottom tabs
|
||||
* - Tablet (md–lg): compact watchlist bar + main
|
||||
* - Desktop (lg+): sidebar + watchlist + main
|
||||
*
|
||||
* Shell is locked to 100dvh so only main scrolls; bottom tabs stay put on iPhone Air.
|
||||
* Next.js scroll restoration targets window - our overflow is on <main data-app-scroll>.
|
||||
* Same-route clicks (e.g. sidebar "Symbol: IREN" while already on /) need an explicit
|
||||
* scroll via nav onClick (see SidebarNav) because pathname/symbol deps do not change.
|
||||
*/
|
||||
function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
const { openSectionId, setOpenSectionId } = useCollapsibleContext();
|
||||
const viewportWidth = useViewport();
|
||||
const isMobile = viewportWidth < MOBILE_BREAKPOINT;
|
||||
const isTablet = viewportWidth >= MOBILE_BREAKPOINT && viewportWidth < TABLET_BREAKPOINT;
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [watchlistCollapsed, setWatchlistCollapsed] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const pathname = usePathname();
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
|
||||
// Keyboard shortcuts: Escape closes open sections, modals
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: "Escape",
|
||||
@@ -43,87 +49,84 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
]);
|
||||
|
||||
// Global Escape listener: collapse the currently open section.
|
||||
useEffect(() => {
|
||||
disableHistoryScrollRestoration();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && openSectionId) {
|
||||
e.preventDefault();
|
||||
// Collapse the currently open section by clearing the shared state.
|
||||
// Individual CollapsibleSection Escape handlers also respond to this.
|
||||
setOpenSectionId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, true); // capture phase
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [openSectionId, setOpenSectionId]);
|
||||
|
||||
// Route or ticker change: pin content pane to top (nested scroller, not window).
|
||||
// Focus Next's first-heading restore with preventScroll on our sentinel first.
|
||||
useLayoutEffect(() => {
|
||||
disableHistoryScrollRestoration();
|
||||
topSentinelRef.current?.focus({ preventScroll: true });
|
||||
if (mainRef.current) mainRef.current.scrollTop = 0;
|
||||
scrollAppMainToTop();
|
||||
scrollAppMainToTopSoon();
|
||||
}, [pathname, activeSymbol]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-fg flex flex-col">
|
||||
<div className="h-dvh min-h-0 bg-surface text-fg flex flex-col overflow-hidden safe-area-pt">
|
||||
{/* Symbol Header — always visible */}
|
||||
<SymbolHeader />
|
||||
|
||||
{/* Global command palette (⌘K) */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* Main Content Area — responsive layout */}
|
||||
{isMobile ? (
|
||||
/* Mobile: single column, no sidebar */
|
||||
<>
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden p-4 pb-20">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<MobileTabNav />
|
||||
</>
|
||||
) : isTablet ? (
|
||||
/* Tablet: 2-column layout, watchlist accessible via compact bar */
|
||||
<div className="flex flex-1 overflow-hidden flex-col">
|
||||
{/* Compact horizontal watchlist bar */}
|
||||
<div className="hidden md:block border-b border-line-strong bg-surface-raised px-4 sm:px-6 py-2 overflow-x-auto">
|
||||
<WatchlistSidebar compact />
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Center content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
{/* Right analytics — drops below on tablet */}
|
||||
<aside className="hidden lg:block w-80 border-l border-line-strong bg-surface-raised overflow-y-auto">
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Right column content placeholder */}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: 3-column layout */
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar Navigation */}
|
||||
<SidebarNav />
|
||||
{/* Tablet: compact horizontal watchlist (md only; desktop has full rail) */}
|
||||
<div className="hidden md:block lg:hidden shrink-0 border-b border-line-strong bg-surface-raised px-4 sm:px-6 py-2 overflow-x-auto">
|
||||
<WatchlistSidebar compact />
|
||||
</div>
|
||||
|
||||
{/* Watchlist Sidebar */}
|
||||
<WatchlistSidebar />
|
||||
{/* Main row: sidebars (lg+) + content */}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<SidebarNav
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<WatchlistSidebar
|
||||
collapsed={watchlistCollapsed}
|
||||
onToggle={() => setWatchlistCollapsed(!watchlistCollapsed)}
|
||||
/>
|
||||
<main
|
||||
ref={mainRef}
|
||||
data-app-scroll
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden overscroll-y-contain overscroll-none p-3 sm:p-5 lg:p-6 xl:p-8"
|
||||
style={{ overflowAnchor: "none" }}
|
||||
>
|
||||
{/* Focus target at true top so route changes do not scrollIntoView a mid-page heading */}
|
||||
<div
|
||||
ref={topSentinelRef}
|
||||
tabIndex={-1}
|
||||
className="sr-only"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="max-w-7xl mx-auto space-y-6 sm:space-y-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-4 xl:p-4 2xl:p-4">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile bottom tabs — shrink-0 sibling so they never scroll away */}
|
||||
<MobileTabNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main layout shell for the redesigned terminal.
|
||||
* - Desktop (>1024px): Sidebar + Watchlist + Header + Content (3-column)
|
||||
* - Tablet (768-1024px): Watchlist + Header + Content (2-column, sidebar hidden)
|
||||
* - Mobile (<768px): Header + Content (single column) + MobileTabNav
|
||||
* - Desktop (lg+): Sidebar + Watchlist + Header + Content
|
||||
* - Tablet (md–lg): Watchlist bar + Header + Content
|
||||
* - Mobile (<md): Header + Content + MobileTabNav
|
||||
*/
|
||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
|
||||
|
||||
/**
|
||||
* Compact management status for Research home — links into Portfolio / Risk / process.
|
||||
*/
|
||||
export function ManagementHomeStrip() {
|
||||
const { density, onboarded } = useWorkspaceProfile();
|
||||
const [holdingsCount, setHoldingsCount] = useState<number | null>(null);
|
||||
const [alerts, setAlerts] = useState(0);
|
||||
const [plans, setPlans] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onboarded) return;
|
||||
api.portfolio.holdings().then((h) => setHoldingsCount((h ?? []).length)).catch(() => setHoldingsCount(0));
|
||||
api.alerts.unackedCount().then((r) => setAlerts(r.count)).catch(() => {});
|
||||
api.trades.list().then((t) => {
|
||||
const open = (t ?? []).filter((x) => x.status === "planned" || x.status === "active");
|
||||
setPlans(open.length);
|
||||
}).catch(() => {});
|
||||
}, [onboarded]);
|
||||
|
||||
if (!onboarded) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-surface-raised p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-fg">Your book</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5">
|
||||
Manage positions and risk first. Research below is for the active symbol.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wide text-fg-faint capitalize">{density}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<Link href="/portfolio" className="rounded border border-line bg-surface-sunken px-2 py-2 hover:border-accent/40">
|
||||
<p className="text-lg font-semibold tnum text-fg">{holdingsCount ?? "—"}</p>
|
||||
<p className="text-[10px] text-fg-muted">Holdings</p>
|
||||
</Link>
|
||||
<Link href="/plan" className="rounded border border-line bg-surface-sunken px-2 py-2 hover:border-accent/40">
|
||||
<p className="text-lg font-semibold tnum text-fg">{plans}</p>
|
||||
<p className="text-[10px] text-fg-muted">Open plans</p>
|
||||
</Link>
|
||||
<Link href="/alerts" className="rounded border border-line bg-surface-sunken px-2 py-2 hover:border-accent/40">
|
||||
<p className="text-lg font-semibold tnum text-fg">{alerts}</p>
|
||||
<p className="text-[10px] text-fg-muted">Alerts</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px]">
|
||||
<Link href="/portfolio" className="text-accent hover:underline">Portfolio</Link>
|
||||
<span className="text-fg-faint">·</span>
|
||||
<Link href="/risk" className="text-accent hover:underline">Risk</Link>
|
||||
<span className="text-fg-faint">·</span>
|
||||
<Link href="/theses" className="text-accent hover:underline">Theses</Link>
|
||||
<span className="text-fg-faint">·</span>
|
||||
<Link href="/journal" className="text-accent hover:underline">Journal</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type MarketCondition } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui";
|
||||
|
||||
@@ -44,6 +44,45 @@ export function MarketConditionStrip() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Periodic refresh every 5 minutes; pause while the document is hidden.
|
||||
useEffect(() => {
|
||||
let id: ReturnType<typeof setInterval> | null = null;
|
||||
const tick = () => {
|
||||
api.market.condition()
|
||||
.then((d) => setData(d))
|
||||
.catch(() => {});
|
||||
};
|
||||
const start = () => {
|
||||
if (id != null) return;
|
||||
id = setInterval(tick, 300_000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (id != null) {
|
||||
clearInterval(id);
|
||||
id = null;
|
||||
}
|
||||
};
|
||||
const onVis = () => {
|
||||
if (document.hidden) stop();
|
||||
else {
|
||||
tick();
|
||||
start();
|
||||
}
|
||||
};
|
||||
if (!document.hidden) start();
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
try {
|
||||
setData(await api.market.condition());
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-4">
|
||||
@@ -77,19 +116,28 @@ export function MarketConditionStrip() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={regimeTone(data.regime)}>{regimeLabel(data)}</Badge>
|
||||
<Badge tone="neutral">{data.confidence}% confidence</Badge>
|
||||
<button onClick={handleRefresh} className="text-[10px] text-fg-muted hover:text-fg transition-colors" title="Refresh">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
<Metric label="SPY (1M)" value={fmtPct(m.spy1M)} />
|
||||
<Metric label="VIX" value={m.vix != null ? m.vix.toFixed(1) : "—"} />
|
||||
<Metric label="10Y yield" value={m.treasury10Y != null ? `${m.treasury10Y.toFixed(2)}%` : "—"} />
|
||||
<Metric label="2Y yield" value={m.treasury2Y != null ? `${m.treasury2Y.toFixed(2)}%` : "—"} />
|
||||
<Metric label="SPY (1M)" value={fmtPct(m.spy1M)} context={m.context?.spy1M} />
|
||||
<Metric label="VIX" value={m.vix != null ? m.vix.toFixed(1) : "—"} context={m.context?.vix} />
|
||||
<Metric label="10Y yield" value={m.treasury10Y != null ? `${m.treasury10Y.toFixed(2)}%` : "—"} context={m.context?.treasury10Y} />
|
||||
<Metric label="2Y yield" value={m.treasury2Y != null ? `${m.treasury2Y.toFixed(2)}%` : "—"} context={m.context?.treasury2Y} />
|
||||
<Metric
|
||||
label="10Y−2Y curve"
|
||||
value={m.curve10y2y != null ? `${m.curve10y2y.toFixed(2)} pp` : "—"}
|
||||
context={m.context?.curve10y2y}
|
||||
/>
|
||||
<Metric label="Unemployment" value={m.unemployment != null ? `${m.unemployment.toFixed(1)}%` : "—"} context={m.context?.unemployment} />
|
||||
<Metric
|
||||
label="Consumer sentiment"
|
||||
context={m.context?.consumerSentiment ?? "UMCSENT — University of Michigan Consumer Sentiment Index (FRED). Source: FRED."}
|
||||
value={
|
||||
m.consumerSentiment != null ? `${m.consumerSentiment.toFixed(0)}` : "—"
|
||||
}
|
||||
/>
|
||||
<Metric label="Unemployment" value={m.unemployment != null ? `${m.unemployment.toFixed(1)}%` : "—"} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -118,16 +166,19 @@ export function MarketConditionStrip() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-faint italic">{data.lesson}</p>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
function Metric({ label, value, context }: { label: string; value: string; context?: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{label}</p>
|
||||
<p className="text-sm font-semibold tnum text-fg mt-0.5">{value}</p>
|
||||
{context ? (
|
||||
<p className="text-[10px] text-fg-muted mt-0.5 italic leading-tight">{context}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,11 +134,6 @@ export function MarketOutlookPanel() {
|
||||
</div>
|
||||
</SectionCollapse>
|
||||
|
||||
<footer className="pt-3 border-t border-line">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api, type RotationData, type SectorHolding } from "@/lib/trpc";
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
import { Badge, Button, Input } from "@/components/ui";
|
||||
|
||||
function pct(v: number | null): string {
|
||||
if (v === null || !Number.isFinite(v)) return "—";
|
||||
@@ -28,6 +28,7 @@ function heatBg(v: number | null): string {
|
||||
}
|
||||
|
||||
const HORIZON_COLS: Array<{ key: keyof SectorRow; label: string; title: string }> = [
|
||||
{ key: "rsOneDay", label: "1D RS", title: "Relative return vs SPY, last trading day (intraday quote if available)" },
|
||||
{ key: "rsOneWeek", label: "1W RS", title: "Relative return vs SPY, 1 week" },
|
||||
{ key: "rsOneMonth", label: "1M RS", title: "Relative return vs SPY, 1 month" },
|
||||
{ key: "rsThreeMonth", label: "3M RS", title: "Relative return vs SPY, 3 months" },
|
||||
@@ -44,7 +45,9 @@ export function MarketRotationPanel() {
|
||||
const [data, setData] = useState<RotationData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEtf, setSelectedEtf] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "sector" | "style" | "region" | "thematic">("all");
|
||||
const [filter, setFilter] = useState<"all" | "sector" | "style" | "region" | "thematic" | "custom" | "auto">("all");
|
||||
const [customSymbol, setCustomSymbol] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -58,6 +61,12 @@ export function MarketRotationPanel() {
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === "all") return data.sectors;
|
||||
// Auto = ETFs linked to open portfolio holdings (may also appear as sector/thematic).
|
||||
if (filter === "auto") {
|
||||
return data.sectors.filter(
|
||||
(s) => s.portfolioLinked === true || (s.kind ?? "") === "auto",
|
||||
);
|
||||
}
|
||||
return data.sectors.filter((s) => (s.kind ?? "sector") === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
@@ -125,6 +134,8 @@ export function MarketRotationPanel() {
|
||||
["style", "Style"],
|
||||
["region", "Region"],
|
||||
["thematic", "Thematic"],
|
||||
["custom", "Custom"],
|
||||
["auto", "Auto"],
|
||||
] as const).map(([f, label]) => (
|
||||
<button
|
||||
key={f}
|
||||
@@ -139,14 +150,128 @@ export function MarketRotationPanel() {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{filter === "custom" && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Input
|
||||
placeholder="Add ETF symbol..."
|
||||
value={customSymbol}
|
||||
onChange={(e) => setCustomSymbol(e.target.value.toUpperCase())}
|
||||
className="w-28"
|
||||
/>
|
||||
{adding ? (
|
||||
<span className="text-xs text-fg-muted">Adding…</span>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={async () => {
|
||||
const sym = customSymbol.trim();
|
||||
if (!sym) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await api.market.addCustomEtf(sym);
|
||||
setCustomSymbol("");
|
||||
// re-fetch
|
||||
const d = await api.market.rotation();
|
||||
setData(d);
|
||||
} catch { /* ignore */ }
|
||||
setAdding(false);
|
||||
}}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-line">
|
||||
{filter === "auto" && rows.length === 0 && (
|
||||
<p className="text-xs text-fg-muted rounded-md border border-line bg-surface-sunken p-3">
|
||||
Auto lists sector and theme ETFs linked to your open portfolio holdings.
|
||||
Add holdings under Portfolio, then refresh this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Mobile: card list with key horizons (Today + 1M RS) */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{rows.map((s) => {
|
||||
const selected = selectedEtf === s.symbol;
|
||||
return (
|
||||
<button
|
||||
key={s.symbol}
|
||||
type="button"
|
||||
onClick={() => setSelectedEtf(selected ? null : s.symbol)}
|
||||
className={`w-full text-left rounded-lg border p-3 space-y-2 transition-colors active:bg-surface-sunken ${
|
||||
selected ? "border-accent/40 bg-accent/10" : "border-line bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-mono font-semibold text-fg">{s.symbol}</span>
|
||||
{s.earlyWatch && <Badge tone="accent">Early</Badge>}
|
||||
{(s.portfolioLinked || s.kind === "auto") && (
|
||||
<Badge tone="accent">Portfolio</Badge>
|
||||
)}
|
||||
{s.kind === "custom" && <Badge tone="neutral">Custom</Badge>}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted truncate mt-0.5">
|
||||
{s.name}
|
||||
{s.subGroup ? ` · ${s.subGroup}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-fg-faint tnum shrink-0">
|
||||
#{s.rank1M ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
className="rounded-md px-2 py-1.5"
|
||||
style={{ background: heatBg(s.rsOneDay ?? null) }}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">Today RS</p>
|
||||
<p className={`text-sm font-semibold tnum ${strengthColor(s.rsOneDay ?? null)}`}>
|
||||
{pct(s.rsOneDay ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md px-2 py-1.5"
|
||||
style={{ background: heatBg(s.rsOneMonth ?? null) }}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">1M RS</p>
|
||||
<p className={`text-sm font-semibold tnum ${strengthColor(s.rsOneMonth ?? null)}`}>
|
||||
{pct(s.rsOneMonth ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="grid grid-cols-3 gap-1.5 pt-1 border-t border-line/60">
|
||||
{(
|
||||
[
|
||||
["1W", s.rsOneWeek],
|
||||
["3M", s.rsThreeMonth],
|
||||
["1Y", s.rsOneYear],
|
||||
] as const
|
||||
).map(([label, v]) => (
|
||||
<div key={label} className="text-center">
|
||||
<p className="text-[9px] text-fg-muted">{label}</p>
|
||||
<p className={`text-xs tnum font-medium ${strengthColor(v ?? null)}`}>
|
||||
{pct(v ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop/tablet: full horizon table, sticky symbol */}
|
||||
<div className="hidden md:block overflow-x-auto rounded-md border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-surface-sunken text-fg-muted">
|
||||
<th className="text-left font-medium px-3 py-2">#</th>
|
||||
<th className="text-left font-medium px-3 py-2">Symbol</th>
|
||||
<th className="text-left font-medium px-3 py-2 sticky left-0 z-10 bg-surface-sunken">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="text-left font-medium px-3 py-2">Group</th>
|
||||
{HORIZON_COLS.map((h) => (
|
||||
<th key={h.key} className="text-right font-medium px-2 py-2 tnum" title={h.title}>
|
||||
@@ -166,12 +291,31 @@ export function MarketRotationPanel() {
|
||||
onClick={() => setSelectedEtf(selectedEtf === s.symbol ? null : s.symbol)}
|
||||
>
|
||||
<td className="px-3 py-1.5 tnum text-fg-muted">{s.rank1M ?? "—"}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<td
|
||||
className={`px-3 py-1.5 sticky left-0 z-10 ${
|
||||
selectedEtf === s.symbol ? "bg-accent/10" : "bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-semibold text-fg">{s.symbol}</span>
|
||||
<span className="text-fg-muted ml-1.5">{s.name}</span>
|
||||
{s.earlyWatch && (
|
||||
<Badge tone="accent" className="ml-1.5">Early</Badge>
|
||||
)}
|
||||
{s.kind === "custom" && (
|
||||
<Badge tone="neutral" className="ml-1.5">Custom</Badge>
|
||||
)}
|
||||
{(s.portfolioLinked || s.kind === "auto") && (
|
||||
<span
|
||||
className="ml-1.5"
|
||||
title={
|
||||
s.portfolioHoldings?.length
|
||||
? `From holdings: ${s.portfolioHoldings.join(", ")}`
|
||||
: "Linked to your portfolio"
|
||||
}
|
||||
>
|
||||
<Badge tone="accent">Portfolio</Badge>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-fg-muted">{s.subGroup}</td>
|
||||
{HORIZON_COLS.map((h) => {
|
||||
@@ -196,7 +340,7 @@ export function MarketRotationPanel() {
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
RS = ETF return minus SPY return over the same window. Click a row for top holdings.
|
||||
RS = ETF return minus SPY return over the same window. Tap a row for top holdings.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -293,13 +437,26 @@ export function MarketRotationPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-fg-muted space-y-0.5 border-t border-line pt-3">
|
||||
<p>{data.lesson}</p>
|
||||
</div>
|
||||
{filter === "custom" && (
|
||||
<ManageCustomEtfs onUpdate={(d) => setData(d)} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatWeight(w: number): string {
|
||||
const pctPts = w > 1 ? w : w * 100;
|
||||
return `${pctPts.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatPrice(p: number | null): string {
|
||||
if (p === null || !Number.isFinite(p)) return "—";
|
||||
if (p >= 1000) return `$${p.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||
if (p >= 100) return `$${p.toFixed(1)}`;
|
||||
return `$${p.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function HoldingsPeek({
|
||||
etfSymbol,
|
||||
sectorData,
|
||||
@@ -342,13 +499,13 @@ function HoldingsPeek({
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-sunken p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold text-fg">
|
||||
{sectorData.symbol} · {sectorData.name}
|
||||
</h4>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{leadLabel} · 1M RS {pct(sectorData.rsOneMonth ?? null)} · Abs 1M {pct(sectorData.oneMonth)}
|
||||
Top holdings · {leadLabel} · 1M RS {pct(sectorData.rsOneMonth ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>Close</Button>
|
||||
@@ -360,15 +517,128 @@ function HoldingsPeek({
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">No top-holdings data for this ETF.</p>
|
||||
) : (
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{holdings.slice(0, 15).map((h) => (
|
||||
<div key={h.symbol} className="flex justify-between text-xs py-1 border-b border-line/50 gap-2">
|
||||
<span className="font-mono text-fg">{h.symbol}</span>
|
||||
<span className="text-fg-muted truncate flex-1">{h.name}</span>
|
||||
<span className="tnum text-fg">
|
||||
{h.weight > 1 ? h.weight.toFixed(1) : (h.weight * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span className={`tnum ${strengthColor(h.changePercent)}`}>{pct(h.changePercent)}</span>
|
||||
<>
|
||||
{/* Mobile holding cards */}
|
||||
<div className="md:hidden max-h-72 overflow-auto space-y-1.5">
|
||||
{holdings.slice(0, 15).map((h) => (
|
||||
<div
|
||||
key={h.symbol}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-line bg-surface px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-semibold text-fg text-sm">{h.symbol}</p>
|
||||
<p className="text-[11px] text-fg-muted truncate">{h.name}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-xs tnum text-fg">{formatWeight(h.weight)}</p>
|
||||
<p className="text-xs tnum text-fg-muted">{formatPrice(h.price)}</p>
|
||||
<p className={`text-[11px] tnum font-medium ${strengthColor(h.changePercent)}`}>
|
||||
{pct(h.changePercent)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block max-h-72 overflow-auto rounded-md border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-surface-sunken text-fg-muted">
|
||||
<th className="text-left font-medium px-3 py-2 sticky left-0 bg-surface-sunken z-10">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="text-left font-medium px-3 py-2">Name</th>
|
||||
<th className="text-right font-medium px-2 py-2 tnum">Weight</th>
|
||||
<th className="text-right font-medium px-2 py-2 tnum">Price</th>
|
||||
<th className="text-right font-medium px-2 py-2 tnum" title="Day change %">
|
||||
Day
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.slice(0, 15).map((h) => (
|
||||
<tr key={h.symbol} className="border-b border-line/60 last:border-0">
|
||||
<td className="px-3 py-1.5 sticky left-0 bg-surface-raised z-10">
|
||||
<span className="font-mono font-semibold text-fg">{h.symbol}</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-fg-muted truncate max-w-[14rem]" title={h.name}>
|
||||
{h.name}
|
||||
</td>
|
||||
<td className="text-right px-2 py-1.5 tnum text-fg">
|
||||
{formatWeight(h.weight)}
|
||||
</td>
|
||||
<td className="text-right px-2 py-1.5 tnum text-fg">
|
||||
{formatPrice(h.price)}
|
||||
</td>
|
||||
<td className={`text-right px-2 py-1.5 tnum font-medium ${strengthColor(h.changePercent)}`}>
|
||||
{pct(h.changePercent)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
Prices from the local quote cache (or last daily close). Day % is session change when available.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageCustomEtfs({ onUpdate }: { onUpdate: (d: RotationData) => void }) {
|
||||
const [customs, setCustoms] = useState<Array<{ symbol: string; name: string; grp: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [list, rot] = await Promise.all([
|
||||
api.market.listCustomEtfs(),
|
||||
api.market.rotation(),
|
||||
]);
|
||||
setCustoms(list);
|
||||
onUpdate(rot);
|
||||
} catch { /* ignore */ }
|
||||
}, [onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
api.market.listCustomEtfs().then(setCustoms).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p className="text-xs text-fg-muted">Loading custom ETFs…</p>;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-sunken p-4 space-y-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted">Manage Custom ETFs</h4>
|
||||
{customs.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">
|
||||
No custom ETFs. Add one with the field above when "Custom" filter is selected.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{customs.map((c) => (
|
||||
<div key={c.symbol} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono font-semibold text-fg">{c.symbol}</span>
|
||||
<span className="text-fg-muted flex-1 truncate">{c.name}</span>
|
||||
<Badge tone="neutral">{c.grp}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={removing === c.symbol}
|
||||
onClick={async () => {
|
||||
setRemoving(c.symbol);
|
||||
try {
|
||||
await api.market.removeCustomEtf(c.symbol);
|
||||
await refresh();
|
||||
} catch { /* ignore */ }
|
||||
setRemoving(null);
|
||||
}}
|
||||
>
|
||||
{removing === c.symbol ? "…" : "Remove"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
|
||||
import type { NavItemId } from "@/lib/workspace-profile";
|
||||
import { scrollAppMainToTopSoon } from "@/lib/scroll-app-main";
|
||||
|
||||
/**
|
||||
* Mobile tab navigation bar.
|
||||
* Replaces the sidebar on screens <768px.
|
||||
* Displays icon + label for each section; active tab highlighted with accent color (#4f8cff).
|
||||
* Mobile bottom nav — Job A first: Portfolio · Risk · Alerts · More,
|
||||
* plus Research for active-symbol glance. Hidden from md up.
|
||||
*/
|
||||
|
||||
interface TabItem {
|
||||
@@ -14,42 +16,72 @@ interface TabItem {
|
||||
href: string;
|
||||
icon: string;
|
||||
module: ModuleKey;
|
||||
navId: NavItemId;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ label: "Overview", href: "/", icon: "📊", module: "research" },
|
||||
{ label: "Filings", href: "/filings", icon: "📄", module: "analytics" },
|
||||
{ label: "Options", href: "/options-dd", icon: "🔮", module: "analytics" },
|
||||
{ label: "Alerts", href: "/alerts", icon: "🔔", module: "analytics" },
|
||||
{ label: "Execution", href: "/execution", icon: "⚡", module: "execution" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: "📋", module: "execution" },
|
||||
{ label: "Settings", href: "/settings", icon: "⚙️", module: "settings" },
|
||||
const PRIMARY_TABS: TabItem[] = [
|
||||
{ label: "Portfolio", href: "/portfolio", icon: "📋", module: "execution", navId: "portfolio" },
|
||||
{ label: "Risk", href: "/risk", icon: "🛡", module: "execution", navId: "risk" },
|
||||
{ label: "Research", href: "/", icon: "📊", module: "research", navId: "research" },
|
||||
{ label: "Alerts", href: "/alerts", icon: "🔔", module: "analytics", navId: "alerts" },
|
||||
{ label: "More", href: "/more", icon: "⋯", module: "settings", navId: "settings" },
|
||||
];
|
||||
|
||||
export function MobileTabNav() {
|
||||
const pathname = usePathname();
|
||||
const { modules } = useFeatureAccess();
|
||||
const visibleTabs = TABS.filter((t) => modules.includes(t.module));
|
||||
const { isNavVisible } = useWorkspaceProfile();
|
||||
|
||||
const visibleTabs = PRIMARY_TABS.filter((t) => {
|
||||
if (t.navId === "settings") return true;
|
||||
if (t.navId === "research" && modules.includes("research")) return isNavVisible(t.navId);
|
||||
if (!modules.includes(t.module)) return false;
|
||||
return isNavVisible(t.navId);
|
||||
});
|
||||
|
||||
const tabs =
|
||||
visibleTabs.length > 0
|
||||
? visibleTabs
|
||||
: PRIMARY_TABS.filter((t) => t.navId === "portfolio" || t.navId === "settings");
|
||||
|
||||
return (
|
||||
<nav className="flex md:hidden border-t border-line-strong bg-surface-raised">
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = pathname === tab.href;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex-1 flex flex-col items-center justify-center py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset ${
|
||||
isActive
|
||||
? "text-accent border-b-2 border-b-accent"
|
||||
: "text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span className="text-[10px] mt-0.5">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<nav
|
||||
className="md:hidden shrink-0 z-40 border-t border-line-strong bg-surface-raised/95 backdrop-blur-sm safe-area-pb safe-area-px"
|
||||
aria-label="Primary"
|
||||
>
|
||||
<div className="flex items-stretch min-h-[3.25rem]">
|
||||
{tabs.map((tab) => {
|
||||
const isActive =
|
||||
tab.href === "/"
|
||||
? pathname === "/"
|
||||
: (pathname || "").startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
scroll={false}
|
||||
onClick={() => {
|
||||
try {
|
||||
sessionStorage.setItem("iflow-nav-scroll-top", "1");
|
||||
} catch { /* ignore */ }
|
||||
scrollAppMainToTopSoon();
|
||||
}}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 min-h-[44px] min-w-0 px-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset ${
|
||||
isActive
|
||||
? "text-accent bg-accent-soft/40 border-t-2 border-t-accent"
|
||||
: "text-fg-muted border-t-2 border-t-transparent active:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
{tab.icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium truncate max-w-full px-0.5">
|
||||
{tab.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, List } from "lucide-react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useActiveWatchlist } from "@/stores/active-watchlist-store";
|
||||
import { api, type PortfolioHolding, type WatchlistEntry } from "@/lib/trpc";
|
||||
|
||||
type QuoteSnap = { price: number; changePercent: number };
|
||||
|
||||
/**
|
||||
* Mobile bottom sheet: portfolio + active watchlist for quick symbol switching.
|
||||
* Desktop keeps the watchlist rail; this is the phone path (Phase 3).
|
||||
*/
|
||||
export function MobileWatchlistSheet({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const activeWatchlist = useActiveWatchlist((s) => s.activeWatchlist);
|
||||
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
||||
const [quotes, setQuotes] = useState<Record<string, QuoteSnap>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadQuotes = useCallback(async (symbols: string[]) => {
|
||||
const uniq = [...new Set(symbols.filter(Boolean))];
|
||||
if (uniq.length === 0) {
|
||||
setQuotes({});
|
||||
return;
|
||||
}
|
||||
const next: Record<string, QuoteSnap> = {};
|
||||
await Promise.all(
|
||||
uniq.map(async (sym) => {
|
||||
try {
|
||||
const snap = await api.market.snapshot(sym);
|
||||
if (snap?.quote) {
|
||||
next[sym] = {
|
||||
price: snap.quote.price,
|
||||
changePercent: snap.quote.changePercent ?? 0,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}),
|
||||
);
|
||||
setQuotes(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const [h, e] = await Promise.all([
|
||||
api.portfolio.holdings().catch(() => [] as PortfolioHolding[]),
|
||||
api.watchlists.listByWatchlist(activeWatchlist).catch(() => [] as WatchlistEntry[]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setHoldings(h ?? []);
|
||||
setEntries(e ?? []);
|
||||
const symbols = [
|
||||
...(h ?? []).map((x) => x.symbol),
|
||||
...(e ?? []).map((x) => x.symbol),
|
||||
];
|
||||
await loadQuotes(symbols);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, activeWatchlist, loadQuotes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Lock body scroll while sheet is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function selectSymbol(symbol: string, goResearch = false) {
|
||||
setActive(symbol);
|
||||
onClose();
|
||||
if (goResearch) router.push("/");
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const holdingSyms = new Set(holdings.map((h) => h.symbol));
|
||||
const watchOnly = entries.filter((e) => !holdingSyms.has(e.symbol));
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed inset-0 z-[60]" role="dialog" aria-modal="true" aria-label="Lists">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-[2px]"
|
||||
aria-label="Close lists"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 max-h-[min(85dvh,640px)] flex flex-col rounded-t-2xl border border-line border-b-0 bg-surface-raised shadow-2xl safe-area-pb">
|
||||
<div className="flex items-center justify-between gap-2 px-4 pt-3 pb-2 border-b border-line shrink-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-fg">Lists</p>
|
||||
<p className="text-[11px] text-fg-muted truncate">
|
||||
Book + {activeWatchlist || "watchlist"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-fg-muted active:bg-surface-sunken"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-y-contain px-3 py-3 space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-fg-muted text-center py-8">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<section>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted px-1 mb-2">
|
||||
Portfolio
|
||||
</h3>
|
||||
{holdings.length === 0 ? (
|
||||
<p className="text-xs text-fg-faint px-1">No holdings yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{holdings.map((h) => (
|
||||
<SymbolRow
|
||||
key={`p-${h.symbol}`}
|
||||
symbol={h.symbol}
|
||||
subtitle={`${h.shares} sh`}
|
||||
quote={quotes[h.symbol]}
|
||||
active={h.symbol === activeSymbol}
|
||||
onSelect={() => selectSymbol(h.symbol)}
|
||||
onResearch={() => selectSymbol(h.symbol, true)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted px-1 mb-2">
|
||||
Watchlist
|
||||
</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-fg-faint px-1">Watchlist is empty.</p>
|
||||
) : watchOnly.length === 0 && holdings.length > 0 ? (
|
||||
<p className="text-xs text-fg-faint px-1">
|
||||
All watchlist names are already on the book.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{(watchOnly.length > 0 ? watchOnly : entries).map((e) => (
|
||||
<SymbolRow
|
||||
key={`w-${e.symbol}`}
|
||||
symbol={e.symbol}
|
||||
subtitle={e.notes || undefined}
|
||||
quote={quotes[e.symbol]}
|
||||
active={e.symbol === activeSymbol}
|
||||
onSelect={() => selectSymbol(e.symbol)}
|
||||
onResearch={() => selectSymbol(e.symbol, true)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SymbolRow({
|
||||
symbol,
|
||||
subtitle,
|
||||
quote,
|
||||
active,
|
||||
onSelect,
|
||||
onResearch,
|
||||
}: {
|
||||
symbol: string;
|
||||
subtitle?: string;
|
||||
quote?: QuoteSnap;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
onResearch: () => void;
|
||||
}) {
|
||||
const up = (quote?.changePercent ?? 0) >= 0;
|
||||
return (
|
||||
<li
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 min-h-[52px] ${
|
||||
active
|
||||
? "border-accent/40 bg-accent/10"
|
||||
: "border-line bg-surface active:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
<button type="button" onClick={onSelect} className="flex-1 min-w-0 text-left py-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`font-mono font-semibold ${active ? "text-accent" : "text-fg"}`}>
|
||||
{symbol}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-[11px] text-fg-faint truncate">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
{quote && (
|
||||
<p className="text-xs tnum text-fg-muted mt-0.5">
|
||||
${quote.price.toFixed(2)}{" "}
|
||||
<span className={up ? "text-up" : "text-danger"}>
|
||||
{up ? "+" : ""}
|
||||
{quote.changePercent.toFixed(2)}%
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResearch}
|
||||
className="shrink-0 min-h-[44px] px-3 text-xs font-medium text-accent"
|
||||
>
|
||||
Research
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Header trigger button for the lists sheet. */
|
||||
export function MobileListsButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="md:hidden flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors"
|
||||
aria-label="Open lists"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { api, type AlertEventRow } from "@/lib/trpc";
|
||||
import { useVisibilityAwarePoll } from "@/lib/useVisibilityAwarePoll";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
|
||||
@@ -30,11 +31,11 @@ export function NotificationBell() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
const id = setInterval(fetchAlerts, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
void fetchAlerts();
|
||||
}, [fetchAlerts]);
|
||||
|
||||
useVisibilityAwarePoll(fetchAlerts, POLL_INTERVAL);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
@@ -73,20 +74,20 @@ export function NotificationBell() {
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={`Notifications${unackedCount > 0 ? ` (${unackedCount} unread)` : ""}`}
|
||||
className="relative rounded-lg p-1.5 text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
className="relative flex items-center justify-center min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 rounded-lg p-1.5 text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
<Bell className="h-5 w-5 sm:h-4 sm:w-4" />
|
||||
{unackedCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white leading-none">
|
||||
<span className="absolute right-0.5 top-0.5 sm:-right-0.5 sm:-top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white leading-none">
|
||||
{unackedCount > 99 ? "99+" : unackedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 z-50 w-80 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||
<span className="text-xs font-semibold text-fg">Notifications</span>
|
||||
<div className="fixed inset-x-3 top-[calc(env(safe-area-inset-top,0px)+3.5rem)] z-50 sm:absolute sm:inset-x-auto sm:right-0 sm:top-full sm:mt-2 sm:w-80 max-w-[calc(100vw-1.5rem)] rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<span className="text-sm font-semibold text-fg">Notifications</span>
|
||||
{unackedCount > 0 && (
|
||||
<span className="rounded-full bg-danger/20 px-2 py-0.5 text-[10px] font-medium text-danger">
|
||||
{unackedCount} unread
|
||||
@@ -94,7 +95,7 @@ export function NotificationBell() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<div className="max-h-[min(24rem,50dvh)] overflow-y-auto overscroll-y-contain">
|
||||
{unackedAlerts.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Bell className="mx-auto h-5 w-5 text-fg-faint/40 mb-2" />
|
||||
@@ -103,7 +104,7 @@ export function NotificationBell() {
|
||||
) : (
|
||||
<div className="divide-y divide-line">
|
||||
{unackedAlerts.map((a) => (
|
||||
<div key={a.id} className="px-4 py-2.5 hover:bg-surface-sunken transition-colors">
|
||||
<div key={a.id} className="px-4 py-3 hover:bg-surface-sunken transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
|
||||
@@ -111,14 +112,14 @@ export function NotificationBell() {
|
||||
<span className="text-xs font-medium text-fg truncate">{a.title}</span>
|
||||
{a.symbol && <span className="rounded bg-surface-dark px-1 py-0.5 text-[9px] text-fg-faint">{a.symbol}</span>}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-faint line-clamp-2">{a.description}</p>
|
||||
<p className="text-[9px] text-fg-faint/50 mt-0.5">
|
||||
<p className="text-xs text-fg-faint line-clamp-2">{a.description}</p>
|
||||
<p className="text-[10px] text-fg-faint/50 mt-0.5">
|
||||
{new Date(a.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(a.id)}
|
||||
className="shrink-0 rounded border border-line px-1.5 py-0.5 text-[9px] text-fg-faint hover:text-fg hover:bg-surface-dark transition-colors"
|
||||
className="shrink-0 min-h-[44px] sm:min-h-0 rounded-md border border-line px-3 py-1.5 text-xs text-fg-faint hover:text-fg hover:bg-surface-dark transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -132,7 +133,7 @@ export function NotificationBell() {
|
||||
<Link
|
||||
href="/alerts"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block border-t border-line px-4 py-2 text-[10px] font-medium text-accent hover:bg-surface-sunken transition-colors text-center"
|
||||
className="block border-t border-line px-4 min-h-[44px] leading-[44px] text-sm font-medium text-accent hover:bg-surface-sunken transition-colors text-center"
|
||||
>
|
||||
View all alerts
|
||||
</Link>
|
||||
|
||||
@@ -136,8 +136,10 @@ export function OptionLegsPanel({ onChange }: OptionLegsPanelProps) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={UI_STRINGS.optionLegsTitle} />
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.optionLegsCaption}</p>
|
||||
<CardHeader
|
||||
title={UI_STRINGS.optionLegsTitle}
|
||||
subtitle={UI_STRINGS.optionLegsCaption}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* - Defined-risk stamp for spreads; "advanced only" warning for undefined
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo, type ReactNode } from "react";
|
||||
import { api, type Quote, type PriceCandle } from "@/lib/trpc";
|
||||
import {
|
||||
computeIVRank,
|
||||
@@ -130,41 +130,214 @@ function GreeksDisplay({ greeks }: { greeks: OptionGreeks | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** OI Walls visualization — simple bar chart of open interest by strike. */
|
||||
function OIWalls({ walls }: { walls: OIWall[] }) {
|
||||
type OIWallsMode = "top" | "near";
|
||||
|
||||
/** Hover / focus tooltip for compact controls (desktop hover + keyboard focus). */
|
||||
function HoverTip({
|
||||
children,
|
||||
tip,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tip: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocusCapture={() => setOpen(true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<div
|
||||
role="tooltip"
|
||||
className="absolute z-50 right-0 top-full mt-1.5 w-64 sm:w-72 p-3 rounded-lg border border-line bg-surface-raised shadow-lg text-xs text-fg-muted leading-relaxed pointer-events-none"
|
||||
>
|
||||
<span className="sr-only">{label}: </span>
|
||||
{tip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatOI(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 10_000) return `${(n / 1000).toFixed(0)}k`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* OI walls as a ranked horizontal list (not a dense strip of every strike).
|
||||
* Shows the levels that usually matter: highest OI, or OI near a reference price.
|
||||
*/
|
||||
function OIWalls({ walls, refPrice }: { walls: OIWall[]; refPrice: number | null }) {
|
||||
const [mode, setMode] = useState<OIWallsMode>("top");
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
if (walls.length === 0) return [] as OIWall[];
|
||||
const TOP_N = 10;
|
||||
|
||||
if (mode === "near" && refPrice != null && refPrice > 0) {
|
||||
const band = walls.filter(
|
||||
(w) => Math.abs(w.strike - refPrice) / refPrice <= 0.2,
|
||||
);
|
||||
const pool = band.length >= 4 ? band : walls;
|
||||
return [...pool]
|
||||
.sort((a, b) => b.openInterest - a.openInterest)
|
||||
.slice(0, TOP_N);
|
||||
}
|
||||
|
||||
return [...walls]
|
||||
.sort((a, b) => b.openInterest - a.openInterest)
|
||||
.slice(0, TOP_N);
|
||||
}, [walls, mode, refPrice]);
|
||||
|
||||
if (walls.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-fg">Open Interest Walls</h3>
|
||||
<p className="text-xs text-fg-faint">No OI data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxOI = Math.max(...walls.map((w) => w.openInterest));
|
||||
const calls = walls.filter((w) => w.right === "call");
|
||||
const puts = walls.filter((w) => w.right === "put");
|
||||
const maxOI = Math.max(...ranked.map((w) => w.openInterest), 1);
|
||||
const nearDisabled = refPrice == null || refPrice <= 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-fg">Open Interest Walls</h3>
|
||||
<div className="flex gap-1 items-end" style={{ height: 120 }}>
|
||||
{walls.map((wall, i) => (
|
||||
<div key={`${wall.right}-${wall.strike}-${i}`} className="flex-1 flex flex-col items-center">
|
||||
<span className="text-[10px] text-fg-muted mb-1">
|
||||
{(wall.openInterest / 1000).toFixed(0)}k
|
||||
</span>
|
||||
<div
|
||||
className={`w-full rounded-t ${wall.right === "call" ? "bg-accent" : "bg-danger"}`}
|
||||
style={{ height: `${(wall.openInterest / maxOI) * 80}px`, minHeight: 2 }}
|
||||
/>
|
||||
<span className="text-[10px] text-fg-faint mt-1">${wall.strike}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-3 min-w-0 max-w-full">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-fg">Open Interest Walls</h3>
|
||||
<p className="text-xs text-fg-faint mt-0.5 max-w-prose">
|
||||
Largest open-interest concentrations often act as friction or magnet zones. Biggest
|
||||
levels only, not the full chain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex rounded-lg border border-line shrink-0 isolate">
|
||||
<HoverTip
|
||||
label={`Top ${Math.min(10, walls.length)}`}
|
||||
tip={
|
||||
<>
|
||||
<span className="font-semibold text-fg">Top walls</span>
|
||||
<br />
|
||||
Ranks every call and put on this expiry by open interest, then shows the{" "}
|
||||
{Math.min(10, walls.length)} largest. Useful for finding the biggest OI
|
||||
concentrations anywhere on the chain (often far from the current price).
|
||||
</>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("top")}
|
||||
className={`min-h-[36px] px-3 text-xs font-medium transition-colors rounded-l-lg ${
|
||||
mode === "top"
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-surface-sunken text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
aria-pressed={mode === "top"}
|
||||
>
|
||||
Top {Math.min(10, walls.length)}
|
||||
</button>
|
||||
</HoverTip>
|
||||
<HoverTip
|
||||
label="Near spot"
|
||||
tip={
|
||||
nearDisabled ? (
|
||||
<>
|
||||
<span className="font-semibold text-fg">Near spot</span>
|
||||
<br />
|
||||
Needs a reference price (ATM estimate) before it can filter. Load a chain
|
||||
with valid strikes first.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-fg">Near spot</span>
|
||||
<br />
|
||||
Same ranking by open interest, but only among strikes within about{" "}
|
||||
<span className="text-fg">±20%</span> of the reference price
|
||||
{refPrice != null ? ` (~$${refPrice.toFixed(2)})` : ""}. Useful for walls
|
||||
that are more likely to matter for near-term price action.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nearDisabled}
|
||||
onClick={() => setMode("near")}
|
||||
className={`min-h-[36px] px-3 text-xs font-medium border-l border-line transition-colors disabled:opacity-40 rounded-r-lg ${
|
||||
mode === "near"
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-surface-sunken text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
aria-pressed={mode === "near"}
|
||||
>
|
||||
Near spot
|
||||
</button>
|
||||
</HoverTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-accent" aria-hidden="true" /> Calls</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-danger" aria-hidden="true" /> Puts</span>
|
||||
|
||||
<ul className="space-y-2" aria-label="Open interest by strike">
|
||||
{ranked.map((wall, i) => {
|
||||
const pct = Math.max(4, (wall.openInterest / maxOI) * 100);
|
||||
const isCall = wall.right === "call";
|
||||
return (
|
||||
<li
|
||||
key={`${wall.right}-${wall.strike}-${i}`}
|
||||
className="grid grid-cols-[4.5rem_3.25rem_1fr_3.5rem] sm:grid-cols-[5rem_3.5rem_1fr_4rem] items-center gap-2 min-h-[36px]"
|
||||
>
|
||||
<span className="text-sm font-semibold tabular-nums text-fg text-right">
|
||||
${wall.strike}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-medium uppercase tracking-wide text-center rounded px-1.5 py-0.5 ${
|
||||
isCall
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-danger/15 text-danger"
|
||||
}`}
|
||||
>
|
||||
{isCall ? "Call" : "Put"}
|
||||
</span>
|
||||
<div className="min-w-0 h-3 rounded-full bg-surface-sunken overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isCall ? "bg-accent" : "bg-danger"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
role="img"
|
||||
aria-label={`Open interest ${wall.openInterest.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-fg-muted text-right">
|
||||
{formatOI(wall.openInterest)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-fg-faint">
|
||||
<span>
|
||||
Showing {ranked.length} of {walls.length} contracts with OI
|
||||
{mode === "near" && refPrice != null ? ` · ref ~$${refPrice.toFixed(2)}` : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-accent shrink-0" aria-hidden />{" "}
|
||||
Calls
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-danger shrink-0" aria-hidden />{" "}
|
||||
Puts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -280,10 +453,8 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
Stock price at expiration →
|
||||
</text>
|
||||
</svg>
|
||||
<p className="text-xs text-fg-faint italic">
|
||||
Shows P&L at expiration for a {atmRow.right} option with strike ${atmRow.strike}.
|
||||
The curve illustrates the convex payoff mechanic — gains accelerate as the stock moves
|
||||
in your favor past the strike.
|
||||
<p className="text-xs text-fg-faint">
|
||||
P&L at expiration · {atmRow.right} · strike ${atmRow.strike}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -415,9 +586,9 @@ export function OptionsDDPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-fg">Options Due Diligence</h2>
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300 min-w-0 max-w-full overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<h2 className="text-base font-semibold text-fg shrink-0">Options Due Diligence</h2>
|
||||
{chainData.expiration && (
|
||||
<span className="text-xs text-fg-muted bg-surface-overlay px-2 py-1 rounded-lg">
|
||||
Exp: {chainData.expiration}
|
||||
@@ -434,8 +605,8 @@ export function OptionsDDPanel() {
|
||||
{/* Greeks Display */}
|
||||
<GreeksDisplay greeks={greeksData?.greeks ?? null} />
|
||||
|
||||
{/* OI Walls */}
|
||||
<OIWalls walls={walls} />
|
||||
{/* OI Walls — ranked list (readable); not every strike */}
|
||||
<OIWalls walls={walls} refPrice={atmRow?.strike ?? null} />
|
||||
|
||||
{/* Max Pain */}
|
||||
<MaxPainDisplay rows={chainData.rows} />
|
||||
@@ -443,13 +614,6 @@ export function OptionsDDPanel() {
|
||||
{/* Payoff Diagram */}
|
||||
<PayoffDiagram rows={chainData.rows} currentStockPrice={atmRow?.strike ?? 0} />
|
||||
|
||||
{/* Educational footer */}
|
||||
<div className="pt-4 border-t border-line-strong">
|
||||
<p className="text-xs text-fg-faint">
|
||||
This panel is educational. It does not recommend any specific trade. Options involve
|
||||
risk and are not suitable for all investors. Past performance does not guarantee future results.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,16 @@ export function OverviewPanel() {
|
||||
let consecutiveErrors = 0;
|
||||
const BASE_INTERVAL = 5_000;
|
||||
const MAX_INTERVAL = 30_000;
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const load = () => {
|
||||
if (cancelled || (typeof document !== "undefined" && document.hidden)) return;
|
||||
api.market.snapshot(symbol)
|
||||
.then((s) => {
|
||||
if (!cancelled) {
|
||||
setSnap(s);
|
||||
setLoading(false);
|
||||
consecutiveErrors = 0; // reset backoff on success
|
||||
consecutiveErrors = 0;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -39,22 +42,39 @@ export function OverviewPanel() {
|
||||
}
|
||||
});
|
||||
};
|
||||
load();
|
||||
// Jittered backoff: 5s base, up to 30s max, with +/- 20% jitter.
|
||||
|
||||
// Jittered backoff: 5s base, up to 30s max; pause while tab/app is hidden.
|
||||
const scheduleNext = () => {
|
||||
if (cancelled) return;
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
const backoffMs = Math.min(
|
||||
BASE_INTERVAL * Math.pow(2, Math.min(consecutiveErrors - 1, 3)),
|
||||
BASE_INTERVAL * Math.pow(2, Math.min(Math.max(consecutiveErrors - 1, 0), 3)),
|
||||
MAX_INTERVAL
|
||||
);
|
||||
const jitter = backoffMs * (0.8 + Math.random() * 0.4);
|
||||
t = setTimeout(scheduleNext, jitter);
|
||||
t = setTimeout(() => {
|
||||
load();
|
||||
scheduleNext();
|
||||
}, jitter);
|
||||
};
|
||||
let t: ReturnType<typeof setTimeout>;
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
if (t) clearTimeout(t);
|
||||
t = undefined;
|
||||
} else {
|
||||
load();
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
scheduleNext();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (t) clearTimeout(t);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [symbol]);
|
||||
|
||||
|
||||
@@ -5,16 +5,10 @@ import { UI_STRINGS } from "@/lib/strings";
|
||||
import { Card, CardHeader, Button, Input } from "@/components/ui";
|
||||
|
||||
export interface PortfolioPanelProps {
|
||||
/** Called after holdings change so parents (e.g. Risk) can refresh posture. */
|
||||
onChange?: (holdings: PortfolioHolding[]) => void;
|
||||
/** When true, omit outer Card chrome (parent already provides a card). */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record open holdings (symbol / shares / avg cost). Feeds risk posture, not the watchlist.
|
||||
* ADR-0007: user-owned records only - no trade instructions.
|
||||
*/
|
||||
export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps) {
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [draftSymbol, setDraftSymbol] = useState("");
|
||||
@@ -23,6 +17,9 @@ export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editShares, setEditShares] = useState("");
|
||||
const [editCost, setEditCost] = useState("");
|
||||
|
||||
const load = useCallback(async (): Promise<PortfolioHolding[] | null> => {
|
||||
try {
|
||||
@@ -84,19 +81,54 @@ export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps)
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (h: PortfolioHolding) => {
|
||||
setEditing(h.symbol);
|
||||
setEditShares(String(h.shares));
|
||||
setEditCost(String(h.avg_cost));
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing(null);
|
||||
setEditShares("");
|
||||
setEditCost("");
|
||||
};
|
||||
|
||||
const saveEdit = async (symbol: string) => {
|
||||
const shares = parseFloat(editShares);
|
||||
const avgCost = parseFloat(editCost);
|
||||
if (!Number.isFinite(shares) || shares <= 0 || !Number.isFinite(avgCost) || avgCost < 0) {
|
||||
setError("Shares must be positive and avg cost must be 0 or more.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.portfolio.updateHolding(symbol, { shares, avgCost });
|
||||
setEditing(null);
|
||||
const next = await load();
|
||||
if (next) notifyChange(next);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not update holding.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalValue = holdings.reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.portfolioCaption}</p>
|
||||
<p className="text-xs text-fg-faint mb-3">{UI_STRINGS.portfolioVsWatchlist}</p>
|
||||
{UI_STRINGS.portfolioCaption ? (
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.portfolioCaption}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<label className="block text-xs text-fg-muted sm:col-span-1">
|
||||
Symbol
|
||||
<Input
|
||||
value={draftSymbol}
|
||||
onChange={(e) => setDraftSymbol(e.target.value)}
|
||||
onChange={(e) => { setDraftSymbol(e.target.value); setError(null); }}
|
||||
placeholder="e.g. AAPL"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
@@ -106,7 +138,7 @@ export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps)
|
||||
Shares
|
||||
<Input
|
||||
value={draftShares}
|
||||
onChange={(e) => setDraftShares(e.target.value)}
|
||||
onChange={(e) => { setDraftShares(e.target.value); setError(null); }}
|
||||
placeholder="100"
|
||||
type="number"
|
||||
className="mt-1"
|
||||
@@ -117,7 +149,7 @@ export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps)
|
||||
Avg cost ($)
|
||||
<Input
|
||||
value={draftCost}
|
||||
onChange={(e) => setDraftCost(e.target.value)}
|
||||
onChange={(e) => { setDraftCost(e.target.value); setError(null); }}
|
||||
placeholder="150.00"
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -145,26 +177,59 @@ export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps)
|
||||
<span className="text-right">Shares</span>
|
||||
<span className="text-right">Avg cost</span>
|
||||
<span className="text-right">Cost basis</span>
|
||||
<span />
|
||||
<span className="text-right" />
|
||||
</div>
|
||||
{holdings.map((h) => (
|
||||
<div
|
||||
key={h.symbol}
|
||||
className="grid grid-cols-[minmax(0,1fr)_4.5rem_5rem_5rem_4rem] gap-2 items-center py-1.5 text-sm rounded hover:bg-surface-sunken transition-colors"
|
||||
>
|
||||
<span className="font-mono text-fg truncate">{h.symbol}</span>
|
||||
<span className="text-right tnum text-fg-muted">{h.shares}</span>
|
||||
<span className="text-right tnum text-fg-muted">${h.avg_cost.toFixed(2)}</span>
|
||||
<span className="text-right tnum text-fg">${(h.shares * h.avg_cost).toFixed(0)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(h.symbol)}
|
||||
disabled={busy}
|
||||
className="text-xs text-fg-faint hover:text-danger text-right disabled:opacity-50"
|
||||
editing === h.symbol ? (
|
||||
<div key={h.symbol} className="grid grid-cols-[minmax(0,1fr)_4.5rem_5rem_5rem_4rem] gap-2 items-center py-1.5 text-sm rounded bg-surface-sunken">
|
||||
<span className="font-mono text-fg truncate">{h.symbol}</span>
|
||||
<Input
|
||||
value={editShares}
|
||||
onChange={(e) => setEditShares(e.target.value)}
|
||||
type="number"
|
||||
className="text-right"
|
||||
disabled={busy}
|
||||
/>
|
||||
<Input
|
||||
value={editCost}
|
||||
onChange={(e) => setEditCost(e.target.value)}
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="text-right"
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="text-right tnum text-fg">
|
||||
${(parseFloat(editShares || "0") * parseFloat(editCost || "0")).toFixed(0)}
|
||||
</span>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={() => saveEdit(h.symbol)} disabled={busy}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={h.symbol}
|
||||
className="grid grid-cols-[minmax(0,1fr)_4.5rem_5rem_5rem_4rem] gap-2 items-center py-1.5 text-sm rounded hover:bg-surface-sunken transition-colors cursor-pointer"
|
||||
onClick={() => startEdit(h)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<span className="font-mono text-fg truncate">{h.symbol}</span>
|
||||
<span className="text-right tnum text-fg-muted">{h.shares}</span>
|
||||
<span className="text-right tnum text-fg-muted">${h.avg_cost.toFixed(2)}</span>
|
||||
<span className="text-right tnum text-fg">${(h.shares * h.avg_cost).toFixed(0)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); remove(h.symbol); }}
|
||||
disabled={busy}
|
||||
className="text-xs text-fg-faint hover:text-danger text-right disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
<div className="flex justify-between pt-2 border-t border-line text-sm font-semibold text-fg">
|
||||
<span>Cost-basis total</span>
|
||||
@@ -181,7 +246,6 @@ export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps)
|
||||
<Card>
|
||||
<CardHeader title={UI_STRINGS.portfolioTitle} />
|
||||
{body}
|
||||
<p className="mt-4 text-[10px] text-fg-faint italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api, type RiskPostureResult, type SizingResult } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { Card, CardHeader, Badge, Button, Input } from "@/components/ui";
|
||||
import { PortfolioPanel } from "@/components/PortfolioPanel";
|
||||
import { OptionLegsPanel } from "@/components/OptionLegsPanel";
|
||||
|
||||
/**
|
||||
* M20 Risk Posture - unified risk picture + educational sizing math.
|
||||
* ADR-0007: considerations + questions, never trade orders.
|
||||
*/
|
||||
|
||||
function asymmetryTone(asymmetry: number): "up" | "warn" | "danger" | "neutral" {
|
||||
if (!Number.isFinite(asymmetry)) return "neutral";
|
||||
if (asymmetry >= 2) return "up";
|
||||
@@ -19,11 +15,19 @@ function asymmetryTone(asymmetry: number): "up" | "warn" | "danger" | "neutral"
|
||||
}
|
||||
|
||||
function statusLabel(status: -1 | 0 | 1): string {
|
||||
if (status === 1) return "Breached";
|
||||
if (status === 1) return "Over limit";
|
||||
if (status === 0) return "At limit";
|
||||
return "Healthy";
|
||||
return "Within limit";
|
||||
}
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk page — operational view of drawdown, exposure, clusters, and size calc.
|
||||
* Management tool; not a lesson surface.
|
||||
*/
|
||||
export function RiskPosturePanel() {
|
||||
const [equity, setEquity] = useState("100000");
|
||||
const [peakEquity, setPeakEquity] = useState("100000");
|
||||
@@ -32,8 +36,7 @@ export function RiskPosturePanel() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Sizing calculator (teaching surface)
|
||||
const [sym, setSym] = useState("NVDA");
|
||||
const [sym, setSym] = useState("");
|
||||
const [tier, setTier] = useState<"A_STAR" | "A" | "B" | "C">("B");
|
||||
const [riskPct, setRiskPct] = useState("1");
|
||||
const [stop, setStop] = useState("5");
|
||||
@@ -44,7 +47,7 @@ export function RiskPosturePanel() {
|
||||
const eq = Number(equity);
|
||||
const peak = Number(peakEquity);
|
||||
if (!Number.isFinite(eq) || eq <= 0) {
|
||||
setError("Account equity must be a positive number.");
|
||||
setError("Enter a positive account equity.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -57,7 +60,7 @@ export function RiskPosturePanel() {
|
||||
});
|
||||
setPosture(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not load risk posture.");
|
||||
setError(e instanceof Error ? e.message : "Could not load risk.");
|
||||
setPosture(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -69,7 +72,6 @@ export function RiskPosturePanel() {
|
||||
}, [loadPosture]);
|
||||
|
||||
const onHoldingsChange = useCallback(() => {
|
||||
// Portfolio mutated - recompute posture so holdings/cluster cards stay in sync.
|
||||
void loadPosture();
|
||||
}, [loadPosture]);
|
||||
|
||||
@@ -78,7 +80,7 @@ export function RiskPosturePanel() {
|
||||
const rf = Number(riskPct) / 100;
|
||||
const stopPerShare = Number(stop);
|
||||
if (!sym.trim() || !Number.isFinite(eq) || eq <= 0 || !Number.isFinite(rf) || rf <= 0 || !Number.isFinite(stopPerShare) || stopPerShare <= 0) {
|
||||
setSizingError("Symbol, equity, risk %, and stop per share are required.");
|
||||
setSizingError("Need symbol, equity, risk %, and stop distance ($/share).");
|
||||
return;
|
||||
}
|
||||
setSizingError(null);
|
||||
@@ -93,24 +95,125 @@ export function RiskPosturePanel() {
|
||||
});
|
||||
setSizing(result);
|
||||
} catch (e) {
|
||||
setSizingError(e instanceof Error ? e.message : "Sizing request failed.");
|
||||
setSizingError(e instanceof Error ? e.message : "Sizing failed.");
|
||||
setSizing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dd = posture?.drawdownVsTolerance;
|
||||
const haltActive = posture?.halted || posture?.halt;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="space-y-5 max-w-4xl">
|
||||
<header>
|
||||
<h1 className="text-xl font-bold text-fg">{UI_STRINGS.riskPostureTitle}</h1>
|
||||
<p className="text-sm text-fg-faint mt-1">{UI_STRINGS.riskPostureSubtitle}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Account inputs */}
|
||||
{/* 1. Status — what matters now */}
|
||||
{posture && (
|
||||
<section className="rounded-md border border-line bg-surface-raised overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-line flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-fg-muted">Status</p>
|
||||
{haltActive ? (
|
||||
<Badge tone="danger">New entries halted</Badge>
|
||||
) : dd ? (
|
||||
<Badge
|
||||
tone={
|
||||
dd.status === 1 ? "danger" : dd.status === 0 ? "warn" : "up"
|
||||
}
|
||||
>
|
||||
Drawdown {statusLabel(dd.status)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 divide-y sm:divide-y-0 sm:divide-x divide-line">
|
||||
<StatusCell
|
||||
label="Drawdown"
|
||||
value={
|
||||
dd
|
||||
? `${dd.currentDrawdownPct.toFixed(1)}% / ${dd.tolerancePct}%`
|
||||
: "—"
|
||||
}
|
||||
hint="Current vs max you set"
|
||||
/>
|
||||
<StatusCell
|
||||
label="Exposure"
|
||||
value={`$${fmtUsd(posture.totalExposureUsd)}`}
|
||||
hint={
|
||||
posture.optionRisk && posture.optionRisk.capitalCommittedUsd > 0
|
||||
? `${posture.holdingsCount} equity · ${posture.optionLegsCount ?? 0} option legs`
|
||||
: `${posture.holdingsCount} holdings (cost basis)`
|
||||
}
|
||||
/>
|
||||
<StatusCell
|
||||
label="Reward / risk"
|
||||
value={Number.isFinite(posture.asymmetry) ? posture.asymmetry.toFixed(2) : "—"}
|
||||
hint={
|
||||
Number.isFinite(posture.asymmetry)
|
||||
? posture.asymmetry < 1
|
||||
? "Below 1: risk exceeds reward"
|
||||
: "At or above 1"
|
||||
: "Needs stops/targets on plans"
|
||||
}
|
||||
badge={
|
||||
Number.isFinite(posture.asymmetry) ? (
|
||||
<Badge tone={asymmetryTone(posture.asymmetry)}>
|
||||
{posture.asymmetry < 1 ? "Tight" : "OK"}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<StatusCell
|
||||
label="Clusters"
|
||||
value={String(Object.keys(posture.clusterExposure).length)}
|
||||
hint="Groups with shared factor risk"
|
||||
/>
|
||||
</div>
|
||||
{posture.optionRisk &&
|
||||
(posture.optionRisk.premiumAtRiskUsd > 0 ||
|
||||
posture.optionRisk.cashReservedUsd > 0 ||
|
||||
posture.optionRisk.uncoveredShortCallCount > 0) && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-line border-t border-line">
|
||||
<StatusCell
|
||||
label="Premium at risk"
|
||||
value={`$${fmtUsd(posture.optionRisk.premiumAtRiskUsd)}`}
|
||||
hint="Long option debits"
|
||||
/>
|
||||
<StatusCell
|
||||
label="Cash reserved (CSPs)"
|
||||
value={`$${fmtUsd(posture.optionRisk.cashReservedUsd)}`}
|
||||
hint="Strike collateral net of credit"
|
||||
/>
|
||||
<StatusCell
|
||||
label="Uncovered short calls"
|
||||
value={String(posture.optionRisk.uncoveredShortCallCount)}
|
||||
hint={
|
||||
posture.optionRisk.uncoveredShortCallCount > 0
|
||||
? "Missing matching long stock"
|
||||
: "Covered by book stock"
|
||||
}
|
||||
badge={
|
||||
posture.optionRisk.uncoveredShortCallCount > 0 ? (
|
||||
<Badge tone="warn">Check</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posture.halt && (
|
||||
<p className="px-4 py-2 text-xs text-danger border-t border-line bg-danger/5">
|
||||
Halt active until {new Date(posture.halt.haltedUntil).toLocaleString()}
|
||||
{posture.halt.triggeredBy ? ` (${posture.halt.triggeredBy})` : ""}.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 2. Account inputs */}
|
||||
<Card>
|
||||
<CardHeader title="Account inputs" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
{UI_STRINGS.riskAccountInputsCaption}
|
||||
</p>
|
||||
<CardHeader title="Account" />
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.riskAccountInputsCaption}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Current equity ($)
|
||||
@@ -131,7 +234,7 @@ export function RiskPosturePanel() {
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Macro regime
|
||||
Market regime
|
||||
<select
|
||||
value={regime}
|
||||
onChange={(e) => setRegime(e.target.value as typeof regime)}
|
||||
@@ -143,187 +246,131 @@ export function RiskPosturePanel() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button onClick={loadPosture} disabled={loading}>
|
||||
{loading ? "Refreshing…" : "Refresh posture"}
|
||||
{loading ? "Updating…" : "Update risk"}
|
||||
</Button>
|
||||
<Link href="/portfolio" className="text-xs text-accent hover:underline">
|
||||
Edit book on Portfolio
|
||||
</Link>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-danger">{error}</p>
|
||||
)}
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
</Card>
|
||||
|
||||
{/* Position book - feeds risk.posture */}
|
||||
<PortfolioPanel onChange={onHoldingsChange} />
|
||||
|
||||
{/* Option legs - premium / CSP capital contribution */}
|
||||
<OptionLegsPanel onChange={onHoldingsChange} />
|
||||
|
||||
{/* Posture summary */}
|
||||
{/* 3. Flags / actions */}
|
||||
{posture && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Holdings</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1">{posture.holdingsCount}</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
Exposure ${posture.totalExposureUsd.toFixed(0)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Options capital</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1">
|
||||
${(posture.options?.capitalCommittedUsd ?? 0).toFixed(0)}
|
||||
</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
{posture.options?.legsCount ?? 0} legs · premium $
|
||||
{(posture.options?.premiumAtRiskUsd ?? 0).toFixed(0)} · CSP $
|
||||
{(posture.options?.cashReservedUsd ?? 0).toFixed(0)}
|
||||
</p>
|
||||
{(posture.options?.uncoveredShortCallCount ?? 0) > 0 && (
|
||||
<Badge tone="warn" className="mt-1">
|
||||
{posture.options!.uncoveredShortCallCount} uncovered short call
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Asymmetry</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1 flex items-center gap-2">
|
||||
{Number.isFinite(posture.asymmetry) ? posture.asymmetry.toFixed(2) : "—"}
|
||||
<Badge tone={asymmetryTone(posture.asymmetry)}>
|
||||
{Number.isFinite(posture.asymmetry)
|
||||
? posture.asymmetry < 1
|
||||
? "Risk > reward"
|
||||
: "Reward ≥ risk"
|
||||
: "No stops"}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
Portfolio-weighted reward-to-risk when stops and targets are set.
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Drawdown vs tolerance</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1">
|
||||
{posture.drawdownVsTolerance.currentDrawdownPct.toFixed(1)}%
|
||||
<span className="text-sm font-normal text-fg-muted">
|
||||
{" "}/ {posture.drawdownVsTolerance.tolerancePct}%
|
||||
</span>
|
||||
</p>
|
||||
<Badge
|
||||
tone={
|
||||
posture.drawdownVsTolerance.status === 1
|
||||
? "danger"
|
||||
: posture.drawdownVsTolerance.status === 0
|
||||
? "warn"
|
||||
: "up"
|
||||
}
|
||||
>
|
||||
{statusLabel(posture.drawdownVsTolerance.status)}
|
||||
</Badge>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Gentle halt</p>
|
||||
<p className="text-lg font-semibold text-fg mt-1">
|
||||
{posture.halted ? "Active" : "Off"}
|
||||
</p>
|
||||
{posture.halt ? (
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
Until {new Date(posture.halt.haltedUntil).toLocaleString()} · {posture.halt.triggeredBy}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
New-entry cooldown when drawdown breaches your tolerance (24h).
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cluster exposure */}
|
||||
<Card>
|
||||
<CardHeader title="Cluster exposure" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
Holdings grouped by factor driver. Beginners default to a 25% equity cluster cap —
|
||||
the lesson is that five correlated names are often one bet.
|
||||
<Card>
|
||||
<CardHeader title="Flags" />
|
||||
{posture.recommendedActions.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
No flags. Drawdown, clusters, and exposure look within your rules.
|
||||
</p>
|
||||
{Object.keys(posture.clusterExposure).length === 0 ? (
|
||||
<p className="text-sm text-fg-faint">No open holdings yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(posture.clusterExposure).map(([cluster, usd]) => {
|
||||
const cap = posture.clusterCaps?.[cluster];
|
||||
const breached = cap !== undefined && usd > cap;
|
||||
return (
|
||||
<div
|
||||
key={cluster}
|
||||
className="flex items-center justify-between rounded-md border border-line bg-surface-sunken px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-fg">{cluster}</span>
|
||||
<span className="tnum text-fg">
|
||||
${usd.toFixed(0)}
|
||||
{cap !== undefined && (
|
||||
<span className="text-fg-muted"> / cap ${cap.toFixed(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
{breached && <Badge tone="warn">Over cap</Badge>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Considerations */}
|
||||
<Card>
|
||||
<CardHeader title="Considerations" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
Trade-offs to think through — not instructions. Each item cites the math that raised it.
|
||||
</p>
|
||||
{posture.recommendedActions.length === 0 ? (
|
||||
<p className="text-sm text-fg-faint">
|
||||
No posture flags right now. Asymmetry, clusters, and drawdown look within your stated rules.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{posture.recommendedActions.map((a, i) => (
|
||||
<li
|
||||
key={`${a.id}-${i}`}
|
||||
className="rounded-md border border-line bg-surface-sunken p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge tone={a.severity === "warning" ? "warn" : "neutral"}>{a.id}</Badge>
|
||||
<Badge tone={a.severity === "warning" ? "warn" : "neutral"}>{a.severity}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-fg font-medium">{a.tradeOff}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{posture.recommendedActions.map((a, i) => (
|
||||
<li
|
||||
key={`${a.id}-${i}`}
|
||||
className="rounded-md border border-line bg-surface-sunken px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge tone={a.severity === "warning" ? "warn" : "neutral"}>
|
||||
{a.severity === "warning" ? "Warning" : "Note"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-fg font-medium">{a.tradeOff}</p>
|
||||
{a.explanation && (
|
||||
<p className="text-xs text-fg-muted mt-1 whitespace-pre-wrap">{a.explanation}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sizing calculator */}
|
||||
{/* 4. Concentration */}
|
||||
{posture && (
|
||||
<Card>
|
||||
<CardHeader title="Concentration by cluster" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
Holdings grouped by shared factor. Cap is the limit for that group.
|
||||
</p>
|
||||
{Object.keys(posture.clusterExposure).length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
No holdings yet.{" "}
|
||||
<Link href="/portfolio" className="text-accent hover:underline">
|
||||
Add positions
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
<th className="text-left font-medium px-3 py-2">Cluster</th>
|
||||
<th className="text-right font-medium px-3 py-2">Exposure</th>
|
||||
<th className="text-right font-medium px-3 py-2">Cap</th>
|
||||
<th className="text-right font-medium px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(posture.clusterExposure).map(([cluster, usd]) => {
|
||||
const cap = posture.clusterCaps?.[cluster];
|
||||
const breached = cap !== undefined && usd > cap;
|
||||
return (
|
||||
<tr key={cluster} className="border-b border-line last:border-0">
|
||||
<td className="px-3 py-2 font-mono text-fg">{cluster}</td>
|
||||
<td className="px-3 py-2 text-right tnum text-fg">${fmtUsd(usd)}</td>
|
||||
<td className="px-3 py-2 text-right tnum text-fg-muted">
|
||||
{cap !== undefined ? `$${fmtUsd(cap)}` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{breached ? <Badge tone="warn">Over cap</Badge> : (
|
||||
<span className="text-xs text-fg-faint">OK</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 5. Equity book used for risk (compact; full edit on Portfolio) */}
|
||||
<PortfolioPanel onChange={onHoldingsChange} />
|
||||
|
||||
{/* 6. Option legs - puts/calls feed premium-at-risk and CSP cash reserve */}
|
||||
<OptionLegsPanel onChange={onHoldingsChange} />
|
||||
|
||||
{/* 7. Size calculator */}
|
||||
<Card>
|
||||
<CardHeader title={UI_STRINGS.sizingTitle} />
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.sizingCaption}</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Symbol
|
||||
<Input value={sym} onChange={(e) => setSym(e.target.value)} className="mt-1" />
|
||||
<Input
|
||||
value={sym}
|
||||
onChange={(e) => setSym(e.target.value.toUpperCase())}
|
||||
placeholder="e.g. VTI"
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Conviction tier
|
||||
Conviction
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as typeof tier)}
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-fg"
|
||||
>
|
||||
<option value="C">C (×0.5)</option>
|
||||
<option value="B">B (×1)</option>
|
||||
<option value="A">A (×2)</option>
|
||||
<option value="A_STAR">A★ (×3, unlock required)</option>
|
||||
<option value="C">C (0.5× size)</option>
|
||||
<option value="B">B (1× size)</option>
|
||||
<option value="A">A (2× size)</option>
|
||||
<option value="A_STAR">A★ (3× size)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
@@ -331,48 +378,63 @@ export function RiskPosturePanel() {
|
||||
<Input value={riskPct} onChange={(e) => setRiskPct(e.target.value)} type="number" className="mt-1" />
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Stop width ($ / share)
|
||||
Stop distance ($/share)
|
||||
<Input value={stop} onChange={(e) => setStop(e.target.value)} type="number" className="mt-1" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={runSizing}>Show sizing math</Button>
|
||||
<Button onClick={runSizing}>Calculate size</Button>
|
||||
</div>
|
||||
{sizingError && <p className="mt-2 text-sm text-danger">{sizingError}</p>}
|
||||
{sizing && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{sizing.blocked ? (
|
||||
<p className="text-sm text-warn">
|
||||
Blocked: {sizing.blockReason}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-fg">
|
||||
The math implies <span className="font-semibold tnum">~{sizing.shares}</span> shares
|
||||
given your stop and risk %.
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-line bg-surface-sunken px-4 py-3 space-y-2">
|
||||
<p className="text-sm text-fg">
|
||||
Result:{" "}
|
||||
<span className="font-semibold tnum text-lg">~{sizing.shares}</span> shares
|
||||
<span className="text-fg-muted text-xs ml-2">
|
||||
at {riskPct}% risk and ${stop} stop
|
||||
</span>
|
||||
</p>
|
||||
{sizing.layers.length > 0 && (
|
||||
<ul className="text-xs text-fg-muted space-y-1 border-t border-line pt-2">
|
||||
{sizing.layers.map((layer) => (
|
||||
<li key={layer.name} className="flex justify-between gap-4">
|
||||
<span className="text-fg-faint">{layer.name}</span>
|
||||
<span className="text-right">{layer.explanation}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{sizing.halted && (
|
||||
<p className="text-xs text-warn">
|
||||
Gentle halt is active — risk fraction may be reduced for teaching the cooldown.
|
||||
</p>
|
||||
)}
|
||||
<ol className="space-y-1.5 list-decimal list-inside">
|
||||
{sizing.layers.map((layer) => (
|
||||
<li key={layer.name} className="text-xs text-fg-muted">
|
||||
<span className="font-medium text-fg">{layer.name}</span>: {layer.explanation}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{sizing.explanations.map((e, i) => (
|
||||
<p key={i} className="text-xs text-fg-faint">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<footer className="border-t border-line pt-4">
|
||||
<p className="text-[11px] text-fg-muted">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</footer>
|
||||
<p className="text-[10px] text-fg-faint italic text-center">
|
||||
{UI_STRINGS.primaryRuleFooter}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCell({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
badge?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{label}</p>
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<p className="text-base font-semibold tnum text-fg">{value}</p>
|
||||
{badge}
|
||||
</div>
|
||||
{hint && <p className="text-[11px] text-fg-faint mt-0.5 leading-snug">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function SeasonalityPanel() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-faint italic">{data.lesson}</p>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { AreaChart, Area, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { chart as chartTheme } from '@/lib/chart-theme';
|
||||
|
||||
interface HistoryEntry {
|
||||
settlementDate: string;
|
||||
@@ -9,6 +11,21 @@ interface HistoryEntry {
|
||||
daysToCover: number;
|
||||
}
|
||||
|
||||
interface FinraBiweeklyEntry {
|
||||
settlementDate: string;
|
||||
currentShortPosition: number;
|
||||
previousShortPosition: number | null;
|
||||
daysToCover: number | null;
|
||||
changePercent: number | null;
|
||||
}
|
||||
|
||||
interface FinraWeeklyEntry {
|
||||
weekEnding: string;
|
||||
shortVolume: number;
|
||||
totalVolume: number;
|
||||
shortPct: number;
|
||||
}
|
||||
|
||||
interface ShortInterestData {
|
||||
symbol: string;
|
||||
sharesShort: number | null;
|
||||
@@ -22,6 +39,8 @@ interface ShortInterestData {
|
||||
daysToCover: number | null;
|
||||
settlementDate: string | null;
|
||||
history: HistoryEntry[] | null;
|
||||
finraBiweeklyHistory: FinraBiweeklyEntry[];
|
||||
finraWeekly: FinraWeeklyEntry[];
|
||||
discrepancyPct: number | null;
|
||||
discrepancyBetween: string | null;
|
||||
sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean }; finra: { available: boolean; stale: boolean } };
|
||||
@@ -41,6 +60,11 @@ function formatPct(n: number | null): string {
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatMonthYear(v: string): string {
|
||||
if (!v) return '';
|
||||
return v.slice(0, 7).replace('-', '/');
|
||||
}
|
||||
|
||||
export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
|
||||
const [data, setData] = useState<ShortInterestData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -49,18 +73,10 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
api.institutional.shortInterest(symbol).then((res) => {
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
}).catch((e: Error) => {
|
||||
if (cancelled) return;
|
||||
setError(e.message);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
setLoading(true); setError(null); let cancelled = false;
|
||||
api.institutional.shortInterest(symbol).then((res) => { if (cancelled) return; setData(res); })
|
||||
.catch((e: Error) => { if (cancelled) return; setError(e.message); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol, retryKey]);
|
||||
|
||||
@@ -69,34 +85,24 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
|
||||
if (error) return (
|
||||
<div className="text-xs text-danger py-2 flex items-center gap-2">
|
||||
<span>Short interest: {error}</span>
|
||||
<button className="text-up hover:underline" onClick={() => setRetryKey((k) => k + 1)}>Retry</button>
|
||||
<button className="text-up hover:underline" onClick={() => setRetryKey(k => k + 1)}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
if (!data) return <div className="text-xs text-fg-muted py-2">No short interest data available for {symbol}.</div>;
|
||||
|
||||
const dateLabel = data.settlementDate
|
||||
? `Settlement: ${data.settlementDate}`
|
||||
: data.dateShortInterest
|
||||
? `Yahoo: ${data.dateShortInterest}`
|
||||
: null;
|
||||
|
||||
const dateLabel = data.settlementDate ? `Settlement: ${data.settlementDate}` : data.dateShortInterest ? `Yahoo: ${data.dateShortInterest}` : null;
|
||||
const sourceTags = [] as string[];
|
||||
if (data.sources.nasdaq.available) sourceTags.push('NASDAQ');
|
||||
if (data.sources.finra.available) sourceTags.push('FINRA');
|
||||
if (data.sources.yfinance.available) sourceTags.push('Yahoo');
|
||||
const sourceLabel = sourceTags.length ? sourceTags.join(' + ') : '—';
|
||||
|
||||
const historySlice = data.history ? data.history.slice(0, 6) : [];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-fg">Short Interest</h4>
|
||||
{dateLabel && (
|
||||
<p className="text-[10px] text-fg-muted">{dateLabel}{data.stale ? ' — stale' : ''} ({sourceLabel})</p>
|
||||
)}
|
||||
{dateLabel && <p className="text-[10px] text-fg-muted">{dateLabel}{data.stale ? ' — stale' : ''} ({sourceTags.join(' + ') || '—'})</p>}
|
||||
{data.discrepancyPct !== null && (
|
||||
<div className="bg-warning/10 border border-warning/30 rounded px-2 py-1 text-[10px] text-warning">
|
||||
{data.discrepancyPct}% discrepancy in shares short between sources ({data.discrepancyBetween})
|
||||
{data.discrepancyPct}% discrepancy in shares short ({data.discrepancyBetween})
|
||||
</div>
|
||||
)}
|
||||
{data.sharesShort !== null && (
|
||||
@@ -118,28 +124,44 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{historySlice.length > 0 && (
|
||||
|
||||
{data.finraBiweeklyHistory && data.finraBiweeklyHistory.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] text-fg-muted mb-1">6-month history (NASDAQ source)</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px] text-fg-muted">
|
||||
<thead><tr><th className="text-left py-0.5">Date</th><th className="text-right py-0.5">Short</th><th className="text-right py-0.5">Days</th></tr></thead>
|
||||
<tbody>
|
||||
{historySlice.map((h, i) => (
|
||||
<tr key={i} className="border-b border-line/40">
|
||||
<td className="py-0.5">{h.settlementDate}</td>
|
||||
<td className="text-right font-mono">{formatNumber(h.sharesShort)}</td>
|
||||
<td className="text-right font-mono">{h.daysToCover.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted mb-1">Short interest — outstanding (bi-monthly, {data.finraBiweeklyHistory.length} pts)</p>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={data.finraBiweeklyHistory}>
|
||||
<defs><linearGradient id="siGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.3}/><stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0}/></linearGradient></defs>
|
||||
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="settlementDate" tick={{fontSize:9,fill:chartTheme.axis}} tickFormatter={formatMonthYear} />
|
||||
<YAxis tick={{fontSize:9,fill:chartTheme.axis}} tickFormatter={(v:number)=>formatNumber(v)} width={50} />
|
||||
<Tooltip contentStyle={chartTheme.tooltip} labelFormatter={(v: any)=>formatMonthYear(v)} formatter={(v: any)=>[formatNumber(v),'Shares Short']} />
|
||||
<Area type="monotone" dataKey="currentShortPosition" stroke="var(--color-accent)" fill="url(#siGrad)" strokeWidth={1.5} name="Short Interest" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.finraWeekly && data.finraWeekly.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] text-fg-muted mb-1">Short-selling activity — weekly volume ({data.finraWeekly.length} wks)</p>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<ComposedChart data={data.finraWeekly}>
|
||||
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="weekEnding" tick={{fontSize:9,fill:chartTheme.axis}} tickFormatter={(v:string)=>v.slice(5,10)} />
|
||||
<YAxis yAxisId="left" tick={{fontSize:9,fill:chartTheme.axis}} tickFormatter={(v:number)=>formatNumber(v)} width={50} orientation="left" />
|
||||
<YAxis yAxisId="right" tick={{fontSize:9,fill:chartTheme.axis}} tickFormatter={(v:number)=>`${v}%`} width={30} orientation="right" domain={[0,100]} />
|
||||
<Tooltip contentStyle={chartTheme.tooltip} labelFormatter={(v: any)=>`Week ending ${v}`} formatter={(v: any, n: any)=> n==='shortPct'? [`${v}%`,'Short %']:[formatNumber(v), n==='shortVolume'?'Volume':'Total']} />
|
||||
<Legend wrapperStyle={{fontSize:10}} />
|
||||
<Bar yAxisId="left" dataKey="shortVolume" fill="var(--color-accent)" opacity={0.6} radius={[2,2,0,0]} name="Short Volume" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="shortPct" stroke="var(--color-down)" strokeWidth={2} dot={false} name="Short %" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-fg-muted mt-1">
|
||||
Reconciled across FINRA (regulatory, shares short), NASDAQ (days-to-cover, history), and Yahoo Finance (% float, universal coverage). Educational display only.
|
||||
FINRA bi-monthly = short interest outstanding. FINRA weekly = short-sale volume. NASDAQ/Yahoo = cross-check.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,29 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Briefcase,
|
||||
Compass,
|
||||
Microscope,
|
||||
Shield,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Search,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
|
||||
import { useActiveSymbolGuarded } from "@/stores/active-symbol-store";
|
||||
import type { NavItemId } from "@/lib/workspace-profile";
|
||||
import { scrollAppMainToTopSoon } from "@/lib/scroll-app-main";
|
||||
|
||||
/**
|
||||
* Left sidebar navigation with collapsible sections.
|
||||
* Chrome is intentionally dimmed (muted text) so the content area reads as primary;
|
||||
* the active item gets an accent tint + left bar.
|
||||
* Left sidebar — management-centered sections, filtered by module access + workspace density.
|
||||
*/
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
navId: NavItemId;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
@@ -39,32 +42,48 @@ const SECTIONS: NavSection[] = [
|
||||
icon: BarChart3,
|
||||
module: "research",
|
||||
items: [
|
||||
{ label: "Overview", href: "/" },
|
||||
{ label: "Market Outlook", href: "/market-outlook" },
|
||||
{ label: "Institutional", href: "/institutional" },
|
||||
{ label: "Charts", href: "/chart-lab" },
|
||||
{ label: "Symbol", href: "/", navId: "research" },
|
||||
{ label: "Market Outlook", href: "/market-outlook", navId: "market" },
|
||||
{ label: "Charts", href: "/chart-lab", navId: "charts" },
|
||||
{ label: "Screener", href: "/screener", navId: "screener" },
|
||||
{ label: "Institutional", href: "/institutional", navId: "institutional" },
|
||||
{ label: "Dealer Flow", href: "/dealer-flow", navId: "dealer-flow" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Execution",
|
||||
icon: Zap,
|
||||
title: "Book",
|
||||
icon: Briefcase,
|
||||
module: "execution",
|
||||
items: [
|
||||
{ label: "Trade Plan", href: "/trade-plan" },
|
||||
{ label: "Execution", href: "/execution" },
|
||||
{ label: "Trade Closure", href: "/trade-closure" },
|
||||
{ label: "Daily Focus", href: "/daily-focus" },
|
||||
{ label: "Portfolio", href: "/portfolio", navId: "portfolio" },
|
||||
{ label: "Risk", href: "/risk", navId: "risk" },
|
||||
{ label: "Tracked Funds", href: "/funds", navId: "funds" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
title: "Process",
|
||||
icon: Compass,
|
||||
module: "execution",
|
||||
items: [
|
||||
{ label: "Get Started", href: "/guided-start", navId: "guided-start" },
|
||||
{ label: "Decision plan", href: "/plan", navId: "plan" },
|
||||
{ label: "Theses", href: "/theses", navId: "theses" },
|
||||
{ label: "Journal", href: "/journal", navId: "journal" },
|
||||
{ label: "Strategies", href: "/strategies", navId: "strategies" },
|
||||
{ label: "Strategy lab", href: "/lab", navId: "lab" },
|
||||
{ label: "Position considerations", href: "/exits", navId: "exits" },
|
||||
{ label: "Goals", href: "/daily-focus", navId: "goals" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tools",
|
||||
icon: Microscope,
|
||||
module: "analytics",
|
||||
items: [
|
||||
{ label: "Risk Posture", href: "/risk" },
|
||||
{ label: "Filings", href: "/filings" },
|
||||
{ label: "Options DD", href: "/options-dd" },
|
||||
{ label: "Alerts", href: "/alerts" },
|
||||
{ label: "Alerts", href: "/alerts", navId: "alerts" },
|
||||
{ label: "Reports", href: "/reports", navId: "reports" },
|
||||
{ label: "Filings", href: "/filings", navId: "filings" },
|
||||
{ label: "Options DD", href: "/options-dd", navId: "options" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -72,19 +91,19 @@ const SECTIONS: NavSection[] = [
|
||||
icon: Shield,
|
||||
module: "admin",
|
||||
items: [
|
||||
{ label: "Overview", href: "/admin" },
|
||||
{ label: "Users", href: "/admin/users" },
|
||||
{ label: "Queue", href: "/admin/queue" },
|
||||
{ label: "Audit Logs", href: "/admin/audit-logs" },
|
||||
{ label: "X Accounts", href: "/admin/x-accounts" },
|
||||
{ label: "SMTP", href: "/admin/smtp" },
|
||||
{ label: "Overview", href: "/admin", navId: "admin" },
|
||||
{ label: "Users", href: "/admin/users", navId: "admin" },
|
||||
{ label: "Queue", href: "/admin/queue", navId: "admin" },
|
||||
{ label: "Audit Logs", href: "/admin/audit-logs", navId: "admin" },
|
||||
{ label: "X Accounts", href: "/admin/x-accounts", navId: "admin" },
|
||||
{ label: "SMTP", href: "/admin/smtp", navId: "admin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
module: "settings",
|
||||
items: [{ label: "Profile / Auth", href: "/settings" }],
|
||||
items: [{ label: "Profile / Auth", href: "/settings", navId: "settings" }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -106,20 +125,32 @@ function getInitialCollapsed(pathname: string, visibleSections: NavSection[]): R
|
||||
return initial;
|
||||
}
|
||||
|
||||
export function SidebarNav() {
|
||||
export function SidebarNav({ collapsed: panelCollapsed, onToggle }: { collapsed?: boolean; onToggle?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||
const { modules, loading } = useFeatureAccess();
|
||||
const { isNavVisible, density } = useWorkspaceProfile();
|
||||
const activeSymbol = useActiveSymbolGuarded();
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [unackedCount, setUnackedCount] = useState(0);
|
||||
|
||||
const visibleSections = SECTIONS.filter((s) => modules.includes(s.module));
|
||||
// Research → "Symbol" shows the focused ticker, e.g. "Symbol: IREN"
|
||||
const itemLabel = (item: { label: string }) =>
|
||||
item.label === "Symbol" && activeSymbol ? `Symbol: ${activeSymbol}` : item.label;
|
||||
|
||||
const visibleSections = SECTIONS
|
||||
.filter((s) => modules.includes(s.module))
|
||||
.map((s) => ({
|
||||
...s,
|
||||
items: s.items.filter((item) => isNavVisible(item.navId)),
|
||||
}))
|
||||
.filter((s) => s.items.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = () => api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
|
||||
fetch();
|
||||
const handler = () => fetch();
|
||||
const fetchCount = () => api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
|
||||
fetchCount();
|
||||
const handler = () => fetchCount();
|
||||
window.addEventListener("alert-dismissed", handler);
|
||||
return () => window.removeEventListener("alert-dismissed", handler);
|
||||
}, []);
|
||||
@@ -127,7 +158,8 @@ export function SidebarNav() {
|
||||
useEffect(() => {
|
||||
setCollapsed(getInitialCollapsed(pathname, visibleSections));
|
||||
setInitialized(true);
|
||||
}, [loading]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-init when module/density loading settles
|
||||
}, [loading, density]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
@@ -141,70 +173,106 @@ export function SidebarNav() {
|
||||
setCollapsed((prev) => ({ ...prev, [sectionTitle]: !prev[sectionTitle] }));
|
||||
};
|
||||
|
||||
const openPalette = () => window.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
|
||||
return (
|
||||
<aside className="w-52 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block">
|
||||
<nav className="flex h-full flex-col p-3">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<div key={section.title} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleCollapse(section.title)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-2xs font-semibold uppercase tracking-wide text-fg-muted hover:text-fg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{section.title}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform duration-200 ${collapsed[section.title] ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!collapsed[section.title] && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = normalizedPath === item.href;
|
||||
const showBadge = item.label === "Alerts" && unackedCount > 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`block px-2 py-1.5 rounded-md text-xs transition-colors border-l-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
isActive
|
||||
? "bg-accent-soft text-fg border-accent font-medium"
|
||||
: "text-fg-muted hover:text-fg hover:bg-surface-overlay border-transparent"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span>{item.label}</span>
|
||||
{showBadge && (
|
||||
<span className="rounded-full bg-accent px-1.5 py-0.5 text-2xs text-white">{unackedCount}</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<aside className={`border-r border-line bg-surface-raised flex-shrink-0 hidden lg:flex items-stretch transition-all duration-200 ${panelCollapsed ? "w-14" : "w-52"}`}>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<nav className={`h-full ${panelCollapsed ? "p-2" : "p-3"}`}>
|
||||
{panelCollapsed ? (
|
||||
<div className="space-y-2">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<div key={section.title} className="flex justify-center">
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1.5 rounded text-fg-muted hover:text-fg hover:bg-surface-overlay transition-colors"
|
||||
title={section.title}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="absolute left-full ml-1 top-0 z-50 hidden group-hover:block">
|
||||
<div className="rounded border border-line bg-surface-raised px-2 py-1 text-2xs text-fg whitespace-nowrap shadow-xl">
|
||||
{section.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<div key={section.title} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleCollapse(section.title)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-2xs font-semibold uppercase tracking-wide text-fg-muted hover:text-fg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{section.title}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform duration-200 ${collapsed[section.title] ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Search affordance -- opens the command palette */}
|
||||
<button
|
||||
onClick={openPalette}
|
||||
className="mt-2 flex items-center gap-2 w-full rounded-md border border-line bg-surface-sunken px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-overlay transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<span>Search</span>
|
||||
<kbd className="ml-auto rounded-sm border border-line px-1 py-0.5 text-2xs">⌘K</kbd>
|
||||
</button>
|
||||
</nav>
|
||||
{!collapsed[section.title] && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const itemPath = item.href.split("#")[0] || "/";
|
||||
const isActive =
|
||||
itemPath === "/"
|
||||
? normalizedPath === "/"
|
||||
: normalizedPath === itemPath;
|
||||
const showBadge = item.label === "Alerts" && unackedCount > 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
// Nested <main> is the scroller; Next window restore is a no-op.
|
||||
// Same-route + cross-page: force main to top after click.
|
||||
scroll={false}
|
||||
onClick={() => {
|
||||
// Mark intent so home page can reassert after its own mount.
|
||||
try {
|
||||
sessionStorage.setItem("iflow-nav-scroll-top", "1");
|
||||
} catch { /* ignore */ }
|
||||
scrollAppMainToTopSoon();
|
||||
}}
|
||||
className={`block px-2 py-1.5 rounded-md text-xs transition-colors border-l-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
isActive
|
||||
? "bg-accent-soft text-fg border-accent font-medium"
|
||||
: "text-fg-muted hover:text-fg hover:bg-surface-overlay border-transparent"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span>{itemLabel(item)}</span>
|
||||
{showBadge && (
|
||||
<span className="rounded-full bg-accent px-1.5 py-0.5 text-2xs text-white">{unackedCount}</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-3.5 shrink-0 border-l border-line bg-surface-raised hover:bg-surface-overlay cursor-pointer transition-colors self-stretch"
|
||||
title={panelCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function SocialFeedPanel() {
|
||||
{/* ADR-0007 caveat */}
|
||||
<div className="mb-4 rounded border border-warn/40 bg-warn/5 p-2.5">
|
||||
<p className="text-xs leading-relaxed text-fg-muted">
|
||||
Crowd sentiment is not edge — it's the crowd reading the same information you can access directly. Treat this as a window into what people are saying, never as a directional cue.
|
||||
Posts mentioning this symbol.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, type StrategyPreset, type TickerSuggestion } from '@/lib/trpc';
|
||||
|
||||
function safeJson<T>(raw: string | null | undefined, fallback: T): T {
|
||||
if (!raw) return fallback;
|
||||
try { return JSON.parse(raw) as T; } catch { return fallback; }
|
||||
}
|
||||
|
||||
export function StrategyOverview({
|
||||
presetId,
|
||||
strategyId,
|
||||
strategyName,
|
||||
onBack,
|
||||
}: {
|
||||
presetId: string;
|
||||
strategyId: string;
|
||||
strategyName: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [preset, setPreset] = useState<StrategyPreset | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<TickerSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.strategies.getPreset(presetId).catch(() => null),
|
||||
api.strategies.suggestTickers(presetId).catch(() => [] as TickerSuggestion[]),
|
||||
]).then(([p, s]) => {
|
||||
setPreset(p);
|
||||
setSuggestions(s);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [presetId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-3 text-sm text-fg-muted">Loading strategy...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entryRules = preset ? safeJson<Array<Record<string, unknown>>>(preset.entry_rules, []) : [];
|
||||
const exitProfile = preset ? safeJson<Array<Record<string, unknown>>>(preset.exit_profile, []) : [];
|
||||
const riskPolicy = preset ? safeJson<Record<string, unknown>>(preset.risk_policy, {}) : {};
|
||||
const portfolioTmpl = preset ? safeJson<Array<Record<string, unknown>> | null>(preset.portfolio_template, null) : null;
|
||||
const scope = preset?.scope ?? 'single';
|
||||
const category = preset?.category ?? 'growth';
|
||||
const complexity = preset?.complexity ?? 'beginner';
|
||||
|
||||
const categoryLabel: Record<string, string> = { growth: 'Growth', income: 'Income', hedging: 'Hedging', speculation: 'Speculation', portfolio: 'Portfolio' };
|
||||
const complexityColor: Record<string, string> = { beginner: 'bg-green-900/40 text-green-400', intermediate: 'bg-blue-900/40 text-blue-400', advanced: 'bg-orange-900/40 text-orange-400' };
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-fg">{strategyName}</h1>
|
||||
<p className="text-sm text-fg-muted mt-1">{preset?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded-sm bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted border border-line">{categoryLabel[category] ?? category}</span>
|
||||
<span className={`rounded-sm px-2 py-0.5 text-2xs font-medium ${complexityColor[complexity] ?? 'bg-surface-sunken text-fg-muted'}`}>{complexity}</span>
|
||||
<span className="rounded-sm bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted border border-line">{scope === 'portfolio' ? 'Portfolio' : 'Single stock'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">How it works</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{entryRules.length > 0 && (
|
||||
<div className="rounded-lg border border-line bg-surface p-3 space-y-1.5">
|
||||
<h3 className="text-2xs font-semibold uppercase tracking-wide text-fg-muted">Entry rules</h3>
|
||||
<ul className="space-y-1">
|
||||
{entryRules.flatMap((r, i) => {
|
||||
const conds = (r as Record<string, unknown>).conditions as string[] | undefined;
|
||||
return (conds ?? []).map((c, j) => <li key={`${i}-${j}`} className="text-xs text-fg-muted flex gap-2"><span className="text-accent shrink-0">✓</span>{c}</li>);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{exitProfile.length > 0 && (
|
||||
<div className="rounded-lg border border-line bg-surface p-3 space-y-1.5">
|
||||
<h3 className="text-2xs font-semibold uppercase tracking-wide text-fg-muted">When to exit</h3>
|
||||
<ul className="space-y-1">
|
||||
{exitProfile.map((r, i) => (
|
||||
<li key={i} className="text-xs text-fg-muted flex gap-2">
|
||||
<span className="text-warn shrink-0">▶</span>
|
||||
{(r as Record<string, unknown>).action as string ?? ''}
|
||||
{(r as Record<string, unknown>).reason ? <span className="text-fg-faint"> — {(r as Record<string, unknown>).reason as string}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio allocation */}
|
||||
{portfolioTmpl && portfolioTmpl.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">Portfolio allocation</h2>
|
||||
<div className="rounded-lg border border-line bg-surface p-4 space-y-3">
|
||||
{portfolioTmpl.map((slot, i) => {
|
||||
const target = (slot as Record<string, unknown>).target as number;
|
||||
const role = (slot as Record<string, unknown>).role as string;
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-fg">{role}</span>
|
||||
<span className="text-fg-muted font-mono">{target}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full bg-surface-sunken overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all" style={{
|
||||
width: `${target}%`,
|
||||
backgroundColor: i === 0 ? 'var(--color-accent, #4f8cff)' : i === 1 ? 'var(--color-up, #22c55e)' : 'var(--color-fg-faint, #5a5b6a)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example holdings */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
Suggested holdings
|
||||
{suggestions.some(s => s.source === 'live') && <span className="ml-2 text-fg-faint font-normal normal-case">based on current market data</span>}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-line bg-surface overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-surface-sunken">
|
||||
<th className="text-left px-3 py-2 font-medium text-fg-muted">Ticker</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-fg-muted">Name</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-fg-muted">Role</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-fg-muted">Allocation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suggestions.map((s) => (
|
||||
<tr key={s.symbol} className="border-b border-line/50 last:border-0">
|
||||
<td className="px-3 py-2 font-mono font-semibold text-fg">{s.symbol}</td>
|
||||
<td className="px-3 py-2 text-fg-muted">{s.name ?? '-'}</td>
|
||||
<td className="px-3 py-2 text-fg-muted">{s.role}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-fg">{s.allocationPct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-3 py-2 bg-surface-sunken border-t border-line text-[10px] text-fg-faint flex items-center gap-2">
|
||||
{suggestions.some(s => s.source === 'live') ? (
|
||||
<><span className="inline-block w-1.5 h-1.5 rounded-full bg-up" /> Based on current market data — you can override any pick</>
|
||||
) : (
|
||||
<><span className="inline-block w-1.5 h-1.5 rounded-full bg-fg-faint" /> Suggested picks — you can override any pick</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk limits */}
|
||||
{Boolean(riskPolicy.maxPositionPct || riskPolicy.stopLossPct || riskPolicy.takeProfitPct || riskPolicy.maxDrawdownPct) && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">Risk limits</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(riskPolicy.maxPositionPct as number) ? <RiskBadge label="Max position per holding" value={`${((riskPolicy.maxPositionPct as number) * 100).toFixed(0)}%`} /> : null}
|
||||
{(riskPolicy.stopLossPct as number) ? <RiskBadge label="Stop loss" value={`${((riskPolicy.stopLossPct as number) * 100).toFixed(0)}%`} /> : null}
|
||||
{(riskPolicy.takeProfitPct as number) ? <RiskBadge label="Take profit" value={`${((riskPolicy.takeProfitPct as number) * 100).toFixed(0)}%`} /> : null}
|
||||
{(riskPolicy.maxDrawdownPct as number) ? <RiskBadge label="Max drawdown" value={`${((riskPolicy.maxDrawdownPct as number) * 100).toFixed(0)}%`} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Link
|
||||
href={`/portfolio?strategyId=${strategyId}`}
|
||||
className="flex-1 rounded bg-accent px-4 py-2.5 text-sm font-medium text-white text-center hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
Set Up My Portfolio
|
||||
</Link>
|
||||
<button onClick={onBack} className="rounded border border-line px-4 py-2.5 text-sm text-fg hover:bg-surface-overlay transition-colors">
|
||||
Browse Other Strategies
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RiskBadge({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface p-2.5 flex-1 min-w-[120px]">
|
||||
<p className="text-[10px] text-fg-faint">{label}</p>
|
||||
<p className="text-xs font-semibold text-fg mt-0.5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,74 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type Snapshot } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { SymbolAutocomplete } from "@/components/SymbolAutocomplete";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { NotificationBell } from "@/components/NotificationBell";
|
||||
import {
|
||||
MobileListsButton,
|
||||
MobileWatchlistSheet,
|
||||
} from "@/components/MobileWatchlistSheet";
|
||||
|
||||
/**
|
||||
* Persistent top bar showing the active symbol, price, change, and search.
|
||||
* This is the "central nervous system" — switching symbols updates everything globally.
|
||||
* Mobile: compact row + expandable full-width search overlay (no ⌘K chrome)
|
||||
* + lists sheet for portfolio/watchlist symbol switching.
|
||||
*/
|
||||
export function SymbolHeader() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [draft, setDraft] = useState(symbol);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showLists, setShowLists] = useState(false);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
|
||||
// Fetch current price for display — always keyed to the active symbol,
|
||||
// never the search draft (which caused stale prices to stick on the label).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.market.snapshot(symbol)
|
||||
.then((s) => { if (!cancelled) setSnap(s); })
|
||||
api.market
|
||||
.snapshot(symbol)
|
||||
.then((s) => {
|
||||
if (!cancelled) setSnap(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [symbol]);
|
||||
|
||||
const quote = snap?.quote ?? null;
|
||||
const sector = snap?.sector ?? null;
|
||||
|
||||
const up = (quote?.change ?? 0) >= 0;
|
||||
const priceLabel = quote ? `$${quote.price?.toFixed(2)}` : "";
|
||||
const changeLabel = quote && quote.change != null
|
||||
? `${up ? "▲" : "▼"}${up ? "+" : ""}${quote.change.toFixed(2)} (${up ? "+" : ""}${(quote.changePercent ?? 0).toFixed(2)}%)`
|
||||
: "";
|
||||
const sectorLabel = sector ? `${sector.sector} · ${sector.tickerKind}` : "";
|
||||
const sessionBadge =
|
||||
quote?.session === "post" ? "AH" : quote?.session === "pre" ? "PRE" : "";
|
||||
const changeLabel =
|
||||
quote && quote.change != null
|
||||
? `${up ? "▲" : "▼"}${up ? "+" : ""}${quote.change.toFixed(2)} (${up ? "+" : ""}${(quote.changePercent ?? 0).toFixed(2)}%)`
|
||||
: "";
|
||||
const changePctLabel =
|
||||
quote && quote.changePercent != null
|
||||
? `${up ? "+" : ""}${(quote.changePercent ?? 0).toFixed(2)}%`
|
||||
: "";
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const sym = draft.trim().toUpperCase() || symbol;
|
||||
setActive(sym);
|
||||
setShowSearch(false);
|
||||
}, [draft, symbol, setActive]);
|
||||
const handlePickSymbol = useCallback(
|
||||
(sym: string) => {
|
||||
setActive(sym.toUpperCase());
|
||||
setShowSearch(false);
|
||||
},
|
||||
[setActive],
|
||||
);
|
||||
|
||||
const handleRawSubmit = useCallback(
|
||||
(raw: string) => {
|
||||
const sym = raw.trim().toUpperCase();
|
||||
if (sym) {
|
||||
setActive(sym);
|
||||
setShowSearch(false);
|
||||
}
|
||||
},
|
||||
[setActive],
|
||||
);
|
||||
|
||||
// Keyboard shortcut: Escape closes the inline search
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && showSearch) {
|
||||
@@ -55,17 +79,12 @@ export function SymbolHeader() {
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [showSearch]);
|
||||
|
||||
// Sync draft when active symbol changes externally
|
||||
useEffect(() => {
|
||||
setDraft(symbol);
|
||||
}, [symbol]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-line bg-surface-raised/95 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<header className="sticky top-0 z-50 shrink-0 border-b border-line bg-surface-raised/95 backdrop-blur-sm safe-area-px">
|
||||
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 py-2 sm:py-3 min-h-[48px]">
|
||||
{/* Left: Logo mark */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-7 sm:h-7 rounded-lg bg-accent flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-surface">IF</span>
|
||||
</div>
|
||||
<h1 className="hidden sm:block text-sm font-semibold text-fg tracking-tight">
|
||||
@@ -73,53 +92,117 @@ export function SymbolHeader() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right: Ticker info + Search + Theme grouped together */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Active symbol — compact, next to search */}
|
||||
<div className="hidden sm:flex items-baseline gap-2 text-xs">
|
||||
{/* Center/right: ticker + actions */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-3 min-w-0">
|
||||
{/* Desktop: full ticker strip */}
|
||||
<div className="hidden sm:flex items-baseline gap-2 text-xs min-w-0">
|
||||
<span className="font-bold text-fg">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-fg-muted">{priceLabel}</span>
|
||||
{priceLabel && <span className="font-mono text-fg-muted">{priceLabel}</span>}
|
||||
{sessionBadge && (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-fg-faint border border-line rounded px-1 py-0.5">
|
||||
{sessionBadge}
|
||||
</span>
|
||||
)}
|
||||
{changeLabel && (
|
||||
<span className={`font-mono ${up ? "text-up" : "text-down"}`}>
|
||||
{changeLabel}
|
||||
<span className={`font-mono ${up ? "text-up" : "text-down"}`}>{changeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact ticker */}
|
||||
<div className="sm:hidden flex flex-col items-end min-w-0 mr-1">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-bold text-fg font-mono">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-fg-muted tabular-nums">{priceLabel}</span>
|
||||
)}
|
||||
{sessionBadge && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-wide text-fg-faint">
|
||||
{sessionBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{changePctLabel && (
|
||||
<span
|
||||
className={`font-mono text-[10px] tabular-nums leading-tight ${
|
||||
up ? "text-up" : "text-down"
|
||||
}`}
|
||||
>
|
||||
{changePctLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: just ticker */}
|
||||
<div className="sm:hidden flex items-center gap-1.5 text-xs">
|
||||
<span className="font-bold text-fg">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-fg-muted">{priceLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSubmit} className="flex items-center">
|
||||
<label className="sr-only" htmlFor="symbol-search">Search symbol</label>
|
||||
<div className={`relative transition-all duration-200 ${showSearch ? "w-56" : "w-40"}`}>
|
||||
<input
|
||||
{/* Desktop search */}
|
||||
<div className="hidden sm:flex items-center">
|
||||
<label className="sr-only" htmlFor="symbol-search">
|
||||
Search symbol
|
||||
</label>
|
||||
<div className="relative w-48">
|
||||
<SymbolAutocomplete
|
||||
id="symbol-search"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={() => setShowSearch(true)}
|
||||
onBlur={() => setTimeout(() => setShowSearch(false), 200)}
|
||||
placeholder="Search…"
|
||||
className="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
onPick={handlePickSymbol}
|
||||
onSubmitRaw={handleRawSubmit}
|
||||
ariaLabel="Search symbol"
|
||||
inputClassName="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 pr-12 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-fg-muted bg-surface border border-line rounded px-1.5">
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-fg-muted bg-surface border border-line rounded px-1.5 pointer-events-none">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Mobile: lists + search */}
|
||||
<MobileListsButton
|
||||
onClick={() => {
|
||||
setShowSearch(false);
|
||||
setShowLists(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLists(false);
|
||||
setShowSearch(true);
|
||||
}}
|
||||
className="sm:hidden flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors"
|
||||
aria-label="Search symbol"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Theme switcher */}
|
||||
<NotificationBell />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileWatchlistSheet open={showLists} onClose={() => setShowLists(false)} />
|
||||
|
||||
{/* Mobile search overlay */}
|
||||
{showSearch && (
|
||||
<div className="sm:hidden border-t border-line bg-surface-raised px-3 py-2 safe-area-px">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<SymbolAutocomplete
|
||||
placeholder="Ticker symbol…"
|
||||
onPick={handlePickSymbol}
|
||||
onSubmitRaw={handleRawSubmit}
|
||||
autoFocus
|
||||
ariaLabel="Search symbol"
|
||||
inputClassName="w-full bg-surface-sunken border border-line rounded-lg px-3 py-2.5 text-base text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSearch(false)}
|
||||
className="shrink-0 flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-fg-muted hover:bg-surface-sunken"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ export function ThemeSwitcher() {
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-line bg-surface-overlay px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
className="flex items-center justify-center gap-1.5 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 rounded-md border border-line bg-surface-overlay px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label="Change color scheme"
|
||||
>
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
<Palette className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
|
||||
<span className="hidden sm:inline">{current.label}</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ export function TickerContextPanel() {
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-muted border-t border-line pt-3">
|
||||
Educational analysis, not investment advice. Relative performance is historical price context only.
|
||||
Relative performance vs market and sector.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type WatchlistEntry } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { SymbolAutocomplete } from "./SymbolAutocomplete";
|
||||
|
||||
export function WatchlistPanel() {
|
||||
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pendingRaw, setPendingRaw] = useState<string | null>(null);
|
||||
const [activeSymbol, setActiveSymbol] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -18,11 +19,16 @@ export function WatchlistPanel() {
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const add = async () => {
|
||||
if (!draft.trim()) return;
|
||||
const sym = draft.trim().toUpperCase();
|
||||
await api.watchlists.addSymbol(sym);
|
||||
setDraft("");
|
||||
// Hybrid resolve (ADR-0011).
|
||||
const addKnown = async (symbol: string) => {
|
||||
await api.watchlists.addSymbol(symbol);
|
||||
load();
|
||||
};
|
||||
const onRawSubmit = (raw: string) => setPendingRaw(raw.trim().toUpperCase());
|
||||
const confirmRaw = async () => {
|
||||
if (!pendingRaw) return;
|
||||
await api.watchlists.addSymbol(pendingRaw);
|
||||
setPendingRaw(null);
|
||||
load();
|
||||
};
|
||||
|
||||
@@ -36,12 +42,23 @@ export function WatchlistPanel() {
|
||||
return (
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.watchlistTitle || "Watchlist"}</h2>
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-2">
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add symbol..."
|
||||
className="flex-1 bg-surface border border-line-strong rounded px-3 py-1.5 text-sm text-fg placeholder:text-fg-faint" />
|
||||
<button type="submit" className="px-3 py-1.5 rounded text-sm bg-accent text-white">Add</button>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
<SymbolAutocomplete
|
||||
placeholder="Add symbol..."
|
||||
onPick={(sym) => { void addKnown(sym); }}
|
||||
onSubmitRaw={onRawSubmit}
|
||||
className="w-full"
|
||||
/>
|
||||
{pendingRaw && (
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-2.5 py-2 text-xs">
|
||||
<p className="text-fg">{pendingRaw} isn't in the SEC registry — add anyway?</p>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button onClick={() => { void confirmRaw(); }} className="rounded bg-accent px-2 py-1 text-white text-xs">Add anyway</button>
|
||||
<button onClick={() => setPendingRaw(null)} className="rounded border border-line px-2 py-1 text-fg-muted text-xs">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-fg-faint">Loading...</p>
|
||||
) : entries.length === 0 ? (
|
||||
@@ -71,7 +88,6 @@ export function WatchlistPanel() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-faint italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useActiveWatchlist } from "@/stores/active-watchlist-store";
|
||||
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
import { SymbolAutocomplete } from "./SymbolAutocomplete";
|
||||
interface MiniQuote extends Quote {
|
||||
sparkline?: number[];
|
||||
}
|
||||
@@ -23,36 +22,103 @@ interface WatchlistMeta {
|
||||
|
||||
interface WatchlistSidebarProps {
|
||||
compact?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
export function WatchlistSidebar({ compact = false, collapsed = false, onToggle }: WatchlistSidebarProps) {
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
||||
const [quotes, setQuotes] = useState<Map<string, MiniQuote>>(new Map());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pendingRaw, setPendingRaw] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [holdingQuotes, setHoldingQuotes] = useState<Map<string, number>>(new Map());
|
||||
const [watchlists, setWatchlists] = useState<WatchlistMeta[]>([]);
|
||||
const activeWatchlist = useActiveWatchlist((s) => s.activeWatchlist);
|
||||
const setActiveWatchlist = useActiveWatchlist((s) => s.setActiveWatchlist);
|
||||
const [showListPicker, setShowListPicker] = useState(false);
|
||||
const [newListName, setNewListName] = useState("");
|
||||
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null);
|
||||
const [displayPickerFor, setDisplayPickerFor] = useState<string | null>(null);
|
||||
const [displayPickerPos, setDisplayPickerPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const displayModeRef = useRef<HTMLDivElement>(null);
|
||||
const [displayMode, setDisplayMode] = useState<'percent' | 'dollar'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (localStorage.getItem('wl-display-mode') as 'percent' | 'dollar') ?? 'percent';
|
||||
}
|
||||
return 'percent';
|
||||
});
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const userLists = watchlists.filter((w) => w.kind !== 'sector' && w.kind !== 'thematic' && w.kind !== 'style' && w.kind !== 'region');
|
||||
const systemLists = watchlists.filter((w) => w.kind === 'sector' || w.kind === 'thematic' || w.kind === 'style' || w.kind === 'region');
|
||||
const isSystemList = (name: string) => systemLists.some((w) => w.name === name);
|
||||
const activeUserList = userLists.find((w) => w.name === activeWatchlist) ? activeWatchlist : 'default';
|
||||
const isSystemKind = (kind?: string) =>
|
||||
kind === 'sector' || kind === 'thematic' || kind === 'style' || kind === 'region' || kind === 'portfolio';
|
||||
const portfolioList = watchlists.find((w) => w.kind === 'portfolio');
|
||||
const userLists = watchlists.filter((w) => !isSystemKind(w.kind));
|
||||
const systemLists = watchlists.filter(
|
||||
(w) => w.kind === 'sector' || w.kind === 'thematic' || w.kind === 'style' || w.kind === 'region',
|
||||
);
|
||||
const isSystemList = (name: string) =>
|
||||
name === 'Portfolio' ||
|
||||
portfolioList?.name === name ||
|
||||
systemLists.some((w) => w.name === name);
|
||||
|
||||
// Portfolio summary
|
||||
const refreshPortfolioSummary = async () => {
|
||||
try {
|
||||
const next = await api.portfolio.holdings();
|
||||
setHoldings(next ?? []);
|
||||
if ((next ?? []).length === 0) {
|
||||
setHoldingQuotes(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
// Use batched snapshots to avoid N+1 API calls
|
||||
try {
|
||||
const symbols = (next ?? []).map((h) => h.symbol);
|
||||
const results = await api.market.snapshots(symbols);
|
||||
const updates = new Map<string, number>();
|
||||
for (const result of results) {
|
||||
if (result.quote?.price != null && Number.isFinite(result.quote.price)) {
|
||||
updates.set(result.symbol, result.quote.price);
|
||||
}
|
||||
}
|
||||
setHoldingQuotes(updates);
|
||||
} catch {
|
||||
/* quote optional for summary */
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
// Portfolio summary (cost basis + unrealized PnL from live marks when available)
|
||||
useEffect(() => {
|
||||
api.portfolio.holdings().then(setHoldings).catch(() => {});
|
||||
void refreshPortfolioSummary();
|
||||
}, []);
|
||||
|
||||
const totalValue = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||
const costBasis = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||
let marketValue = 0;
|
||||
let quotedCount = 0;
|
||||
for (const h of holdings ?? []) {
|
||||
const px = holdingQuotes.get(h.symbol);
|
||||
if (px != null && Number.isFinite(px)) {
|
||||
marketValue += h.shares * px;
|
||||
quotedCount += 1;
|
||||
}
|
||||
}
|
||||
const hasPnl = (holdings?.length ?? 0) > 0 && quotedCount > 0;
|
||||
// When some marks are missing, only sum cost for quoted names so PnL is apples-to-apples.
|
||||
let pnlCostBasis = 0;
|
||||
if (hasPnl) {
|
||||
for (const h of holdings ?? []) {
|
||||
if (holdingQuotes.has(h.symbol)) pnlCostBasis += h.shares * h.avg_cost;
|
||||
}
|
||||
}
|
||||
const unrealizedPnl = hasPnl ? marketValue - pnlCostBasis : null;
|
||||
const unrealizedPnlPct =
|
||||
unrealizedPnl != null && pnlCostBasis > 0 ? (unrealizedPnl / pnlCostBasis) * 100 : null;
|
||||
|
||||
const loadWatchlists = async () => {
|
||||
try {
|
||||
@@ -81,30 +147,74 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
load();
|
||||
}, [activeWatchlist]);
|
||||
|
||||
// Fetch quotes for watchlist symbols
|
||||
// Fetch quotes for watchlist symbols. Quote alone is enough for the mark;
|
||||
// candles only improve the sparkline (queue may deliver quote first).
|
||||
useEffect(() => {
|
||||
const fetchQuotes = async () => {
|
||||
const quoteMap = new Map<string, MiniQuote>();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const snap = await api.market.snapshot(entry.symbol);
|
||||
if (snap.quote && snap.candles) {
|
||||
const sparkline = snap.candles.slice(-20).map(c => c.c);
|
||||
quoteMap.set(entry.symbol, { ...snap.quote, symbol: entry.symbol, sparkline });
|
||||
let cancelled = false;
|
||||
|
||||
const fetchQuotes = async (): Promise<Set<string>> => {
|
||||
const got = new Set<string>();
|
||||
const updates = new Map<string, MiniQuote>();
|
||||
|
||||
try {
|
||||
// Use batched snapshots to avoid N+1 API calls
|
||||
const symbols = entries.map((e) => e.symbol);
|
||||
const results = await api.market.snapshots(symbols);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.quote) {
|
||||
const sparkline = result.candles?.length
|
||||
? result.candles.slice(-20).map((c) => c.c)
|
||||
: undefined;
|
||||
updates.set(result.symbol, { ...result.quote, symbol: result.symbol, sparkline });
|
||||
got.add(result.symbol);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[watchlist] batched snapshot fail:`, (e as Error).message.slice(0, 50));
|
||||
}
|
||||
setQuotes(quoteMap);
|
||||
|
||||
if (!cancelled && updates.size > 0) {
|
||||
setQuotes((prev) => {
|
||||
const m = new Map(prev);
|
||||
for (const [k, v] of updates) m.set(k, v);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return got;
|
||||
};
|
||||
if (entries.length > 0) fetchQuotes();
|
||||
|
||||
if (entries.length === 0) return () => { cancelled = true; };
|
||||
|
||||
void (async () => {
|
||||
let got = await fetchQuotes();
|
||||
// Newly added symbols often land after a short queue delay - retry a few times.
|
||||
for (let attempt = 0; attempt < 6 && !cancelled; attempt++) {
|
||||
if (entries.every((e) => got.has(e.symbol))) break;
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
if (cancelled) break;
|
||||
const next = await fetchQuotes();
|
||||
got = new Set([...got, ...next]);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [entries]);
|
||||
|
||||
const add = async () => {
|
||||
if (!draft.trim()) return;
|
||||
// Hybrid resolve (ADR-0011): known index symbol adds instantly; unmatched text
|
||||
// soft-blocks with an explicit "add anyway?" before adding (background hydration
|
||||
// then picks the row up via the existing demand pipeline).
|
||||
const addKnown = async (symbol: string) => {
|
||||
if (isSystemList(activeWatchlist)) return;
|
||||
const sym = draft.trim().toUpperCase();
|
||||
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
setDraft("");
|
||||
await api.watchlists.addSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
load();
|
||||
loadWatchlists();
|
||||
};
|
||||
const onRawSubmit = (raw: string) => setPendingRaw(raw.trim().toUpperCase());
|
||||
const confirmRaw = async () => {
|
||||
if (!pendingRaw || isSystemList(activeWatchlist)) return;
|
||||
await api.watchlists.addSymbol(pendingRaw, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
setPendingRaw(null);
|
||||
load();
|
||||
loadWatchlists();
|
||||
};
|
||||
@@ -167,74 +277,225 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [menuOpenFor]);
|
||||
|
||||
// Persist display mode
|
||||
useEffect(() => {
|
||||
localStorage.setItem('wl-display-mode', displayMode);
|
||||
}, [displayMode]);
|
||||
|
||||
// Close display picker on outside click
|
||||
useEffect(() => {
|
||||
if (!displayPickerFor) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (displayModeRef.current && !displayModeRef.current.contains(e.target as Node)) {
|
||||
setDisplayPickerFor(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [displayPickerFor]);
|
||||
|
||||
const fmtUsd = (n: number, digits = 0) =>
|
||||
n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
|
||||
const fmtSignedUsd = (n: number, digits = 0) => {
|
||||
const abs = fmtUsd(Math.abs(n), digits);
|
||||
if (n > 0) return `+$${abs}`;
|
||||
if (n < 0) return `−$${abs}`;
|
||||
return `$${abs}`;
|
||||
};
|
||||
|
||||
const holdingCount = holdings?.length ?? 0;
|
||||
const marketDisplay = hasPnl ? `$${fmtUsd(Math.round(marketValue))}` : holdingCount > 0 ? "…" : "—";
|
||||
const pnlDisplay = unrealizedPnl != null ? fmtSignedUsd(Math.round(unrealizedPnl)) : holdingCount > 0 ? "…" : "—";
|
||||
const pnlTone =
|
||||
unrealizedPnl == null ? "text-fg" : unrealizedPnl >= 0 ? "text-up" : "text-danger";
|
||||
|
||||
const containerClass = compact
|
||||
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
|
||||
: "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block";
|
||||
|
||||
const contentClass = compact ? "flex items-center gap-3 px-4 py-2 overflow-x-auto" : "p-4 space-y-3";
|
||||
: `hidden lg:flex items-stretch transition-all duration-200 ${collapsed ? 'w-4' : 'w-72'}`;
|
||||
|
||||
return (
|
||||
<aside className={containerClass}>
|
||||
<div className={contentClass}>
|
||||
{/* Watchlist selector + Portfolio summary */}
|
||||
{compact ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-fg-muted">Portfolio</span>
|
||||
<span className="text-xs font-bold text-fg">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
||||
<span className="text-[10px] text-fg-muted">{entries.length} sym</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Portfolio</p>
|
||||
<p className="text-sm font-bold text-fg">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-[10px] text-fg-muted">{holdings?.length ?? 0} holdings</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
{collapsed ? null : compact ? (
|
||||
<div className="flex-1 min-w-0 overflow-x-auto border-r border-line bg-surface-raised">
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
<a
|
||||
href="/risk"
|
||||
className="mt-2 inline-block text-[10px] text-accent hover:underline"
|
||||
href="/portfolio"
|
||||
className="flex items-baseline gap-2 shrink-0 hover:opacity-90"
|
||||
title="Open portfolio"
|
||||
>
|
||||
{UI_STRINGS.manageHoldingsLink}
|
||||
<span className="text-2xs uppercase tracking-[0.12em] text-fg-muted">Book</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-fg">
|
||||
{hasPnl ? `$${fmtUsd(Math.round(marketValue))}` : `$${fmtUsd(costBasis)}`}
|
||||
</span>
|
||||
{unrealizedPnl != null && (
|
||||
<span className={`text-xs font-medium tabular-nums ${pnlTone}`}>
|
||||
{fmtSignedUsd(Math.round(unrealizedPnl))}
|
||||
{unrealizedPnlPct != null
|
||||
? ` (${unrealizedPnlPct >= 0 ? "+" : "−"}${Math.abs(unrealizedPnlPct).toFixed(1)}%)`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<div className="h-4 w-px bg-line-strong shrink-0" />
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{entries.map((e) => {
|
||||
const quote = quotes.get(e.symbol);
|
||||
const isActive = e.symbol === activeSymbol;
|
||||
const up = (quote?.change ?? 0) >= 0;
|
||||
return (
|
||||
<button
|
||||
key={e.symbol}
|
||||
type="button"
|
||||
onClick={() => setActive(e.symbol)}
|
||||
className={`shrink-0 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'border-accent/40 bg-accent/15 text-accent'
|
||||
: 'border-line bg-surface text-fg hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-semibold">{e.symbol}</span>
|
||||
{quote && (
|
||||
<span className="tabular-nums text-fg">
|
||||
${quote.price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{quote && (
|
||||
<span className={`tabular-nums ${up ? 'text-up' : 'text-danger'}`}>
|
||||
{up ? '+' : ''}{quote.changePercent?.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-w-0 flex flex-col h-full overflow-hidden border-r border-line bg-surface-raised">
|
||||
{/* Portfolio snapshot — aligned rows, one clear CTA */}
|
||||
<div className="shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="rounded-md border border-line bg-surface-sunken/80 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-line">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
Portfolio
|
||||
</p>
|
||||
<span className="text-[10px] tabular-nums text-fg-faint">
|
||||
{holdingCount} {holdingCount === 1 ? "holding" : "holdings"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="px-3 py-2.5 space-y-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-3 gap-y-0">
|
||||
<dt className="text-[11px] text-fg-muted">Cost basis</dt>
|
||||
<dd className="text-[13px] font-semibold tabular-nums text-fg text-right">
|
||||
${fmtUsd(Math.round(costBasis))}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-3">
|
||||
<dt className="text-[11px] text-fg-muted">Market value</dt>
|
||||
<dd className="text-[13px] font-semibold tabular-nums text-fg text-right">
|
||||
{marketDisplay}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-3">
|
||||
<dt className="text-[11px] text-fg-muted">Unrealized</dt>
|
||||
<dd className={`text-[13px] font-semibold tabular-nums text-right ${pnlTone}`}>
|
||||
{pnlDisplay}
|
||||
{unrealizedPnl != null && unrealizedPnlPct != null && (
|
||||
<span className="ml-1.5 text-[11px] font-medium opacity-90">
|
||||
({unrealizedPnlPct >= 0 ? "+" : "−"}
|
||||
{Math.abs(unrealizedPnlPct).toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{hasPnl && quotedCount < holdingCount && (
|
||||
<p className="px-3 pb-1 text-[10px] text-fg-faint">
|
||||
Marks on {quotedCount}/{holdingCount} positions
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2 border-t border-line">
|
||||
<a
|
||||
href="/portfolio"
|
||||
className="block w-full text-center rounded-md border border-line bg-surface px-2 py-1.5 text-[11px] font-medium text-fg hover:border-accent/40 hover:text-accent transition-colors"
|
||||
>
|
||||
Open portfolio
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watchlist controls */}
|
||||
<div className="shrink-0 px-4 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-2xs font-medium uppercase tracking-[0.14em] text-fg-muted">Watchlist</p>
|
||||
<span className="text-2xs tabular-nums text-fg-faint">
|
||||
{watchlists.length > 0
|
||||
? watchlists.find((w) => w.name === activeWatchlist)?.symbol_count ?? entries.length
|
||||
: entries.length}{' '}
|
||||
symbols
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watchlist selector dropdown */}
|
||||
{!compact && (
|
||||
<div className="relative" ref={pickerRef}>
|
||||
<button
|
||||
onClick={() => setShowListPicker(!showListPicker)}
|
||||
className="w-full flex items-center gap-2 rounded-lg border border-line bg-surface px-2 py-1.5 text-xs text-fg hover:bg-surface-sunken"
|
||||
onClick={() => {
|
||||
const next = !showListPicker;
|
||||
setShowListPicker(next);
|
||||
if (next) void loadWatchlists();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-fg hover:bg-surface-sunken transition-colors"
|
||||
>
|
||||
<span className="flex-1 text-left truncate">{activeWatchlist}</span>
|
||||
<span className="text-fg-faint">{watchlists.length > 0 ? watchlists.find((w) => w.name === activeWatchlist)?.symbol_count ?? entries.length : entries.length}</span>
|
||||
<svg className="h-3 w-3 text-fg-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
<span className="flex-1 text-left truncate font-medium">{activeWatchlist}</span>
|
||||
{isSystemList(activeWatchlist) && (
|
||||
<span className="text-2xs uppercase tracking-wide text-fg-faint">system</span>
|
||||
)}
|
||||
<svg className="h-3.5 w-3.5 text-fg-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
{showListPicker && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden max-h-60 overflow-y-auto">
|
||||
{systemLists.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-lg border border-line-strong bg-surface-raised shadow-xl overflow-hidden max-h-72 overflow-y-auto">
|
||||
{portfolioList && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted bg-surface-sunken">Sectors & Themes</div>
|
||||
<div className="px-3 py-2 text-2xs uppercase tracking-[0.12em] text-fg-muted bg-surface-sunken">System</div>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-sunken cursor-pointer text-sm"
|
||||
onClick={() => { setActiveWatchlist(portfolioList.name); setShowListPicker(false); }}
|
||||
>
|
||||
<span className={`flex-1 truncate ${portfolioList.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>
|
||||
{portfolioList.name}
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-fg-faint">{portfolioList.symbol_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemLists.length > 0 && (
|
||||
<div className={portfolioList ? "border-t border-line" : ""}>
|
||||
<div className="px-3 py-2 text-2xs uppercase tracking-[0.12em] text-fg-muted bg-surface-sunken">Sectors & Themes</div>
|
||||
{systemLists.map((wl) => (
|
||||
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<div key={wl.id} className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-sunken cursor-pointer text-sm" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
|
||||
<span className="text-fg-faint">{wl.symbol_count}</span>
|
||||
<span className="text-[8px] text-fg-muted">●</span>
|
||||
<span className="text-2xs tabular-nums text-fg-faint">{wl.symbol_count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={systemLists.length > 0 ? "border-t border-line" : ""}>
|
||||
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted bg-surface-sunken">My Lists</div>
|
||||
<div className={(portfolioList || systemLists.length > 0) ? "border-t border-line" : ""}>
|
||||
<div className="px-3 py-2 text-2xs uppercase tracking-[0.12em] text-fg-muted bg-surface-sunken">My Lists</div>
|
||||
{userLists.map((wl) => (
|
||||
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<div key={wl.id} className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-sunken cursor-pointer text-sm" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
|
||||
<span className="text-fg-faint">{wl.symbol_count}</span>
|
||||
<span className="text-2xs tabular-nums text-fg-faint">{wl.symbol_count}</span>
|
||||
{wl.name !== "default" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteWatchlist(wl.name); }}
|
||||
className="text-fg-muted hover:text-danger ml-1"
|
||||
className="text-fg-muted hover:text-danger ml-1 px-1"
|
||||
aria-label={`Delete ${wl.name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -242,133 +503,217 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-line p-2">
|
||||
<form onSubmit={(e) => { e.preventDefault(); createWatchlist(); }} className="flex gap-1">
|
||||
<div className="border-t border-line p-3">
|
||||
<form onSubmit={(e) => { e.preventDefault(); createWatchlist(); }} className="flex gap-2">
|
||||
<input
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
placeholder="New list..."
|
||||
className="flex-1 bg-surface border border-line rounded px-1.5 py-1 text-[10px] text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent"
|
||||
placeholder="New list…"
|
||||
className="flex-1 bg-surface border border-line rounded-md px-2.5 py-2 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button type="submit" className="px-1.5 py-1 rounded text-[10px] bg-accent text-white">+</button>
|
||||
<button type="submit" className="px-2.5 py-2 rounded-md text-xs bg-accent text-white hover:bg-accent-hover">+</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add symbol form — hidden for read-only system/sector lists */}
|
||||
{!compact && !isSystemList(activeWatchlist) && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
|
||||
<input
|
||||
id="watchlist-add-symbol"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Watchlist symbol…"
|
||||
className="flex-1 bg-surface border border-line rounded px-2 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Add symbol to watchlist"
|
||||
className="px-2 py-1.5 rounded text-xs bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f]"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Watchlist */}
|
||||
{loading ? (
|
||||
<p className="text-xs text-fg-muted">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-fg-muted">No symbols tracked</p>
|
||||
<p className="text-[10px] text-fg-muted mt-1">Use the input above to add one</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={compact ? "flex items-center gap-2 overflow-x-auto" : "space-y-0.5 max-h-[calc(100vh-320px)] overflow-y-auto"}>
|
||||
{entries.map((e) => {
|
||||
const quote = quotes.get(e.symbol);
|
||||
const isActive = e.symbol === activeSymbol;
|
||||
const up = (quote?.change ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={e.symbol}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActive(e.symbol)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setActive(e.symbol); } }}
|
||||
aria-label={`Switch to ${e.symbol}`}
|
||||
className={`shrink-0 flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] w-full ${
|
||||
isActive
|
||||
? "bg-accent/20 border-l-2 border-accent"
|
||||
: "hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
<span className={`font-mono font-bold whitespace-nowrap w-12 text-left ${isActive ? "text-accent" : "text-fg"}`}>
|
||||
{e.symbol}
|
||||
</span>
|
||||
{quote && quote.sparkline && quote.sparkline.length > 1 && (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0" aria-hidden="true">
|
||||
<svg width="20" height="12" className="flex-shrink-0">
|
||||
<polyline
|
||||
points={(quote.sparkline ?? []).map((v, i) => `${(i / ((quote.sparkline?.length ?? 1) - 1)) * 20},${12 - ((v - Math.min(...(quote.sparkline ?? []))) / (Math.max(...(quote.sparkline ?? [])) - Math.min(...(quote.sparkline ?? [])) || 1)) * 10}`).join(" ")}
|
||||
fill="none"
|
||||
stroke={up ? CHART.up : CHART.down}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
<span className={`text-[8px] ${up ? "text-up" : "text-down"}`}>
|
||||
{up ? "▲" : "▼"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{quote ? (
|
||||
<span className="flex items-center gap-0.5 font-mono text-[9px] whitespace-nowrap w-14 justify-end">
|
||||
<span className={up ? "text-up" : "text-warn"} aria-hidden="true">{up ? "▲" : "▼"}</span>
|
||||
<span className={up ? "text-up" : "text-warn"}>
|
||||
{up ? "+" : ""}{quote.changePercent?.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-14" />
|
||||
)}
|
||||
{!compact && !isSystemList(activeWatchlist) && (
|
||||
<div className="relative" ref={menuOpenFor === e.symbol ? menuRef : undefined}>
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); setMenuOpenFor(menuOpenFor === e.symbol ? null : e.symbol); }}
|
||||
aria-label={`Actions for ${e.symbol}`}
|
||||
className="text-[9px] text-fg-muted hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded ml-1"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpenFor === e.symbol && (
|
||||
<div ref={menuRef} className="absolute right-0 top-full mt-1 z-50 rounded-md border border-line-strong bg-surface-raised shadow-xl py-1 w-36">
|
||||
{userLists.filter((w) => w.name !== activeWatchlist).length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted">Move to…</div>
|
||||
{userLists.filter((w) => w.name !== activeWatchlist).map((wl) => (
|
||||
<button key={wl.id} onClick={(ev) => { ev.stopPropagation(); moveSymbol(e.symbol, wl.name); }} className="block w-full text-left px-3 py-1.5 text-xs text-fg hover:bg-surface-sunken transition">{wl.name}</button>
|
||||
))}
|
||||
<hr className="my-1 border-line" />
|
||||
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-surface-sunken transition">Remove from list</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-surface-sunken transition">Remove from list</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Add symbol form — hidden for read-only system/sector lists */}
|
||||
{!isSystemList(activeWatchlist) && (
|
||||
<div className="space-y-2">
|
||||
<SymbolAutocomplete
|
||||
id="watchlist-add-symbol"
|
||||
ariaLabel="Add watchlist symbol"
|
||||
placeholder="Add symbol…"
|
||||
onPick={(sym) => { void addKnown(sym); }}
|
||||
onSubmitRaw={onRawSubmit}
|
||||
className="w-full"
|
||||
/>
|
||||
{pendingRaw && (
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-2.5 py-2 text-xs">
|
||||
<p className="text-fg">
|
||||
{pendingRaw} isn't in the SEC registry — add anyway?
|
||||
</p>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
onClick={() => { void confirmRaw(); }}
|
||||
className="rounded bg-accent px-2 py-1 text-white text-xs"
|
||||
>
|
||||
Add anyway
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingRaw(null)}
|
||||
className="rounded border border-line px-2 py-1 text-fg-muted text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-4 border-t border-line" />
|
||||
|
||||
{/* Symbol list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
||||
{loading ? (
|
||||
<p className="px-3 py-6 text-sm text-fg-muted">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<p className="text-sm text-fg-muted">No symbols tracked</p>
|
||||
<p className="text-xs text-fg-faint mt-2 leading-relaxed max-w-[14rem] mx-auto">
|
||||
{isSystemList(activeWatchlist) && activeWatchlist === 'Portfolio'
|
||||
? 'Record holdings on Risk to populate this list'
|
||||
: isSystemList(activeWatchlist)
|
||||
? 'This system list is empty'
|
||||
: 'Use the field above to add a symbol'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{entries.map((e) => {
|
||||
const quote = quotes.get(e.symbol);
|
||||
const isActive = e.symbol === activeSymbol;
|
||||
const up = (quote?.change ?? 0) >= 0;
|
||||
const spark = quote?.sparkline ?? [];
|
||||
const sparkMin = spark.length ? Math.min(...spark) : 0;
|
||||
const sparkMax = spark.length ? Math.max(...spark) : 1;
|
||||
const sparkRange = sparkMax - sparkMin || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={e.symbol}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActive(e.symbol)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setActive(e.symbol); } }}
|
||||
aria-label={`Switch to ${e.symbol}`}
|
||||
className={`group flex items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] w-full ${
|
||||
isActive
|
||||
? "bg-accent/15 ring-1 ring-inset ring-accent/30"
|
||||
: "hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
<span className={`font-mono font-semibold tracking-tight w-[3.25rem] shrink-0 ${isActive ? "text-accent" : "text-fg"}`}>
|
||||
{e.symbol}
|
||||
</span>
|
||||
|
||||
{spark.length > 1 ? (
|
||||
<span className="flex-1 min-w-0 flex justify-center" aria-hidden="true">
|
||||
<svg width="36" height="16" className="overflow-visible">
|
||||
<polyline
|
||||
points={spark.map((v, i) =>
|
||||
`${(i / (spark.length - 1)) * 36},${14 - ((v - sparkMin) / sparkRange) * 12}`,
|
||||
).join(" ")}
|
||||
fill="none"
|
||||
stroke={up ? CHART.up : CHART.down}
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
|
||||
{quote ? (
|
||||
<span className="font-mono text-xs tabular-nums text-fg shrink-0">
|
||||
${quote.price.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 w-10 text-right text-2xs text-fg-faint">—</span>
|
||||
)}
|
||||
{quote ? (
|
||||
<button
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
setDisplayPickerPos({ top: rect.bottom + 4, left: Math.max(4, rect.right - 128) });
|
||||
setDisplayPickerFor(displayPickerFor === e.symbol ? null : e.symbol);
|
||||
}}
|
||||
className={`shrink-0 flex items-center gap-1 font-mono text-xs tabular-nums whitespace-nowrap cursor-pointer hover:opacity-80 transition-opacity ${up ? "text-up" : "text-danger"}`}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[0.65rem]">{up ? "▲" : "▼"}</span>
|
||||
<span>
|
||||
{displayMode === 'percent'
|
||||
? `${up ? "+" : ""}${quote.changePercent?.toFixed(1)}%`
|
||||
: `${up ? "+" : ""}${quote.change?.toFixed(2)}`
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="shrink-0 w-12 text-right text-2xs text-fg-faint">—</span>
|
||||
)}
|
||||
|
||||
{!isSystemList(activeWatchlist) && (
|
||||
<div className="relative shrink-0" ref={menuOpenFor === e.symbol ? menuRef : undefined}>
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); setMenuOpenFor(menuOpenFor === e.symbol ? null : e.symbol); }}
|
||||
aria-label={`Actions for ${e.symbol}`}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 text-fg-muted hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded px-1 transition-opacity"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpenFor === e.symbol && (
|
||||
<div ref={menuRef} className="absolute right-0 top-full mt-1 z-50 rounded-md border border-line-strong bg-surface-raised shadow-xl py-1.5 w-40">
|
||||
{userLists.filter((w) => w.name !== activeWatchlist).length > 0 ? (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-2xs uppercase tracking-[0.12em] text-fg-muted">Move to…</div>
|
||||
{userLists.filter((w) => w.name !== activeWatchlist).map((wl) => (
|
||||
<button key={wl.id} onClick={(ev) => { ev.stopPropagation(); moveSymbol(e.symbol, wl.name); }} className="block w-full text-left px-3 py-2 text-sm text-fg hover:bg-surface-sunken transition">{wl.name}</button>
|
||||
))}
|
||||
<hr className="my-1.5 border-line" />
|
||||
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-2 text-sm text-danger hover:bg-surface-sunken transition">Remove from list</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-2 text-sm text-danger hover:bg-surface-sunken transition">Remove from list</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse tab — thin strip on right edge */}
|
||||
{!compact && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-3.5 shrink-0 border-l border-line bg-surface-raised hover:bg-surface-overlay cursor-pointer transition-colors self-stretch"
|
||||
title={collapsed ? 'Expand watchlist' : 'Collapse watchlist'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display mode picker — fixed position to avoid clip from scroll container */}
|
||||
{displayPickerFor && displayPickerPos && (
|
||||
<div
|
||||
ref={displayModeRef}
|
||||
style={{ position: 'fixed', top: displayPickerPos.top, left: displayPickerPos.left, zIndex: 9999 }}
|
||||
className="rounded-md border border-line-strong bg-surface-raised shadow-xl py-1.5 w-36"
|
||||
>
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); setDisplayMode('percent'); setDisplayPickerFor(null); }}
|
||||
className={`block w-full text-left px-3 py-2 text-sm transition ${displayMode === 'percent' ? 'text-accent bg-accent/10' : 'text-fg hover:bg-surface-sunken'}`}
|
||||
>
|
||||
Percent
|
||||
</button>
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); setDisplayMode('dollar'); setDisplayPickerFor(null); }}
|
||||
className={`block w-full text-left px-3 py-2 text-sm transition ${displayMode === 'dollar' ? 'text-accent bg-accent/10' : 'text-fg hover:bg-surface-sunken'}`}
|
||||
>
|
||||
Dollar Amount
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
/** Compact Research strip: key dealer levels + deep-link to /dealer-flow. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api, type DealerMapLevelsResult } from "@/lib/trpc";
|
||||
import { useActiveSymbolGuarded } from "@/stores/active-symbol-store";
|
||||
|
||||
export function DealerLevelsStrip() {
|
||||
const symbol = useActiveSymbolGuarded();
|
||||
const [data, setData] = useState<DealerMapLevelsResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
let cancelled = false;
|
||||
api.dealerMap
|
||||
.levels(symbol)
|
||||
.then((r) => {
|
||||
if (!cancelled) setData(r);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setData(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [symbol]);
|
||||
|
||||
if (!symbol) return null;
|
||||
|
||||
const levels = data?.levels;
|
||||
const has =
|
||||
levels &&
|
||||
(levels.king || levels.flip != null || levels.callWall != null || levels.putWall != null);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-surface-raised px-3 py-2.5 flex flex-wrap items-center gap-2 min-h-[48px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-faint">Dealer levels</p>
|
||||
{has ? (
|
||||
<p className="text-xs text-fg-muted truncate">
|
||||
{levels!.king ? `King ${levels!.king.strike}` : null}
|
||||
{levels!.flip != null ? ` · Flip ${levels!.flip}` : ""}
|
||||
{levels!.callWall != null ? ` · Call ${levels!.callWall}` : ""}
|
||||
{levels!.putWall != null ? ` · Put ${levels!.putWall}` : ""}
|
||||
{data?.regimeAtSpot ? ` · ${data.regimeAtSpot}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-fg-faint">
|
||||
{data?.status === "pending"
|
||||
? "Map refresh scheduled…"
|
||||
: "Open Dealer Flow for gamma/vanna structure"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/dealer-flow?symbol=${encodeURIComponent(symbol)}`}
|
||||
className="shrink-0 min-h-[40px] px-3 rounded-lg border border-accent/40 text-accent text-xs font-medium flex items-center"
|
||||
>
|
||||
Open map
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Study Desk - practice setups + auto-grade + mentor ledger.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type DealerStudyProposal,
|
||||
type DealerStudyRow,
|
||||
type DealerStudyScorecardRow,
|
||||
type MentorCallRow,
|
||||
} from "@/lib/trpc";
|
||||
|
||||
function biasLabel(bias: string): string {
|
||||
if (bias === "educational_long") return "Long study";
|
||||
if (bias === "educational_short") return "Short study";
|
||||
if (bias === "educational_range") return "Range study";
|
||||
return bias;
|
||||
}
|
||||
|
||||
function gradeTone(grade: string | null): string {
|
||||
if (grade === "win") return "text-emerald-600";
|
||||
if (grade === "loss") return "text-fuchsia-600";
|
||||
if (grade === "expired") return "text-fg-muted";
|
||||
return "text-fg-faint";
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, csv: string) {
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const MENTOR_BLEND_KEY = "iflow-study-use-mentor-history";
|
||||
|
||||
export function StudyDeskPanel({ symbol }: { symbol: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"study" | "mentor">("study");
|
||||
const [proposals, setProposals] = useState<DealerStudyProposal[]>([]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<DealerStudyRow[]>([]);
|
||||
const [scorecard, setScorecard] = useState<DealerStudyScorecardRow[]>([]);
|
||||
const [mentorRows, setMentorRows] = useState<MentorCallRow[]>([]);
|
||||
const [mentorCard, setMentorCard] = useState<
|
||||
Array<{
|
||||
handle: string;
|
||||
claimType: string;
|
||||
n: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
winRate: number | null;
|
||||
avgScore: number | null;
|
||||
}>
|
||||
>([]);
|
||||
const [useMentorHistory, setUseMentorHistory] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [logFlash, setLogFlash] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setUseMentorHistory(localStorage.getItem(MENTOR_BLEND_KEY) === "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshLists = useCallback(async () => {
|
||||
try {
|
||||
const [list, card] = await Promise.all([
|
||||
api.dealerStudy.list({ symbol, limit: 15 }),
|
||||
api.dealerStudy.scorecard(),
|
||||
]);
|
||||
setRows(list as DealerStudyRow[]);
|
||||
setScorecard(card);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [symbol]);
|
||||
|
||||
const refreshMentor = useCallback(async () => {
|
||||
try {
|
||||
const [list, card] = await Promise.all([
|
||||
api.mentorLedger.list({ limit: 30 }),
|
||||
api.mentorLedger.scorecard(),
|
||||
]);
|
||||
setMentorRows(list as MentorCallRow[]);
|
||||
setMentorCard(card);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void refreshLists();
|
||||
// Auto grade-due when opening desk (stretch A5)
|
||||
void api.dealerStudy.gradeDue().then(() => refreshLists()).catch(() => {});
|
||||
}, [open, refreshLists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || tab !== "mentor") return;
|
||||
void refreshMentor();
|
||||
}, [open, tab, refreshMentor]);
|
||||
|
||||
const propose = async (mentorBlend = useMentorHistory) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.dealerStudy.propose(symbol, { useMentorHistory: mentorBlend });
|
||||
setProposals(r.proposals);
|
||||
setMessage(r.message);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMentorBlend = (on: boolean) => {
|
||||
setUseMentorHistory(on);
|
||||
try {
|
||||
localStorage.setItem(MENTOR_BLEND_KEY, on ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Re-rank open proposals if any
|
||||
if (proposals.length > 0) void propose(on);
|
||||
};
|
||||
|
||||
const logSetup = async (p: DealerStudyProposal) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.dealerStudy.log({ ...p, symbol });
|
||||
setLogFlash(`Logged study ${r.id.slice(0, 8)}…`);
|
||||
setTimeout(() => setLogFlash(null), 3000);
|
||||
await refreshLists();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const gradeOne = async (id: string) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.dealerStudy.grade(id);
|
||||
await refreshLists();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const gradeDue = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.dealerStudy.gradeDue();
|
||||
setLogFlash(`Graded ${r.graded} · still open ${r.incomplete}`);
|
||||
setTimeout(() => setLogFlash(null), 4000);
|
||||
await refreshLists();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const promote = async (studyId: string) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.dealerStudy.promoteToJournal(studyId);
|
||||
setLogFlash(r.message);
|
||||
setTimeout(() => setLogFlash(null), 5000);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportCsv = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.dealerStudy.exportCsv({ symbol, limit: 500 });
|
||||
downloadCsv(r.filename, r.csv);
|
||||
setLogFlash(`Exported ${r.rowCount} rows`);
|
||||
setTimeout(() => setLogFlash(null), 3000);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importMentor = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.mentorLedger.importFromHarvest({ maxFiles: 300 });
|
||||
setLogFlash(
|
||||
r.message ??
|
||||
`Imported ${r.imported} mentor drafts (skipped ${r.skipped}). Confirm before grading.`,
|
||||
);
|
||||
setTimeout(() => setLogFlash(null), 6000);
|
||||
await refreshMentor();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-line bg-surface-raised p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-fg">Study Desk</h2>
|
||||
<p className="text-[11px] text-fg-faint mt-0.5">
|
||||
Practice setups from the map.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-[40px] px-3 rounded-lg border border-line text-xs text-fg-muted"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "Hide" : "Open"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">
|
||||
A win means price hit the target before the wrong-side level.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-[36px] px-3 rounded-lg text-xs border ${
|
||||
tab === "study"
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-line text-fg-muted"
|
||||
}`}
|
||||
onClick={() => setTab("study")}
|
||||
>
|
||||
Your studies
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-[36px] px-3 rounded-lg text-xs border ${
|
||||
tab === "mentor"
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-line text-fg-muted"
|
||||
}`}
|
||||
onClick={() => setTab("mentor")}
|
||||
>
|
||||
Mentor ledger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-warn">{err}</p>}
|
||||
{logFlash && <p className="text-xs text-accent">{logFlash}</p>}
|
||||
|
||||
{tab === "study" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[40px] px-3 rounded-lg bg-accent text-accent-fg text-xs font-medium disabled:opacity-50"
|
||||
onClick={() => void propose()}
|
||||
>
|
||||
{busy ? "Working…" : "Propose from map"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[40px] px-3 rounded-lg border border-line text-xs text-fg-muted disabled:opacity-50"
|
||||
onClick={() => void gradeDue()}
|
||||
>
|
||||
Grade due studies
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[40px] px-3 rounded-lg border border-line text-xs text-fg-muted disabled:opacity-50"
|
||||
onClick={() => void exportCsv()}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<label className="flex items-center gap-2 min-h-[40px] px-2 text-[11px] text-fg-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-line"
|
||||
checked={useMentorHistory}
|
||||
onChange={(e) => toggleMentorBlend(e.target.checked)}
|
||||
/>
|
||||
Rank with mentor history
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-faint leading-relaxed">
|
||||
Optional. Uses mentor hit rates when ranking (needs 20+ graded calls of that type).
|
||||
Off by default.
|
||||
</p>
|
||||
|
||||
{message && <p className="text-xs text-fg-faint">{message}</p>}
|
||||
|
||||
{proposals.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-faint">
|
||||
Suggested studies
|
||||
</p>
|
||||
{proposals.map((p) => (
|
||||
<div
|
||||
key={p.hypothesisId}
|
||||
className="rounded-lg border border-line bg-surface-sunken/50 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg">{p.title}</p>
|
||||
<p className="text-[10px] text-fg-faint">
|
||||
{biasLabel(p.bias)} · {p.horizonDays}d · map fit{" "}
|
||||
{(p.confidence * 100).toFixed(0)}%
|
||||
{typeof p.rankScore === "number"
|
||||
? ` · rank ${(p.rankScore * 100).toFixed(0)}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-faint mt-0.5">
|
||||
{p.histN != null && p.histN >= 5 && p.histWinRate != null
|
||||
? `Your hit rate ${(p.histWinRate * 100).toFixed(0)}% (${p.histN} studies)`
|
||||
: "Your hit rate: need 5+ graded studies"}
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-faint">
|
||||
{p.mentorN != null && p.mentorN > 0 && p.mentorWinRate != null
|
||||
? `Mentor hit rate ${(p.mentorWinRate * 100).toFixed(0)}% (${p.mentorN}${
|
||||
p.mentorClaimType ? ` · ${p.mentorClaimType}` : ""
|
||||
})${
|
||||
useMentorHistory
|
||||
? p.mentorN >= 20
|
||||
? " · used in ranking"
|
||||
: " · need 20+ for ranking"
|
||||
: " · not in ranking"
|
||||
}`
|
||||
: "Mentor: no graded calls of this type yet"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[36px] px-2 rounded-md border border-accent/40 text-accent text-xs disabled:opacity-50"
|
||||
onClick={() => void logSetup(p)}
|
||||
>
|
||||
Log study
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">{p.mapReading}</p>
|
||||
<p className="text-xs text-fg leading-relaxed">{p.studyQuestion}</p>
|
||||
<dl className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div className="rounded bg-surface-raised border border-line px-2 py-1">
|
||||
<dt className="text-fg-faint">Entry zone</dt>
|
||||
<dd className="font-semibold tabular-nums text-fg">{p.entry}</dd>
|
||||
</div>
|
||||
<div className="rounded bg-surface-raised border border-line px-2 py-1">
|
||||
<dt className="text-fg-faint">Wrong if</dt>
|
||||
<dd className="font-semibold tabular-nums text-fg">{p.invalidation}</dd>
|
||||
</div>
|
||||
<div className="rounded bg-surface-raised border border-line px-2 py-1">
|
||||
<dt className="text-fg-faint">Target</dt>
|
||||
<dd className="font-semibold tabular-nums text-fg">{p.target}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-faint">Logged studies</p>
|
||||
<ul className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="rounded-lg border border-line px-3 py-2 text-xs space-y-1"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium text-fg">
|
||||
{r.symbol} · {r.title}
|
||||
</span>
|
||||
<span className={`font-semibold ${gradeTone(r.grade)}`}>
|
||||
{r.grade ?? r.status}
|
||||
{typeof r.score === "number" ? ` · ${r.score}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-fg-faint">
|
||||
{biasLabel(r.bias)} · logged {new Date(r.logged_at).toLocaleString()} · E{" "}
|
||||
{r.entry} / X {r.invalidation} / T {r.target}
|
||||
</p>
|
||||
{r.grade_summary && (
|
||||
<p className="text-fg-muted leading-relaxed">{r.grade_summary}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(r.status === "open" || r.grade === "incomplete") && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[32px] px-2 rounded border border-line text-fg-muted disabled:opacity-50"
|
||||
onClick={() => void gradeOne(r.id)}
|
||||
>
|
||||
Grade now
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[32px] px-2 rounded border border-line text-fg-muted disabled:opacity-50"
|
||||
onClick={() => void promote(r.id)}
|
||||
>
|
||||
Copy to journal
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scorecard.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-faint">
|
||||
Your scorecard
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] text-left">
|
||||
<thead className="text-fg-faint">
|
||||
<tr>
|
||||
<th className="py-1 pr-2 font-medium">Setup</th>
|
||||
<th className="py-1 pr-2 font-medium">N</th>
|
||||
<th className="py-1 pr-2 font-medium">W/L</th>
|
||||
<th className="py-1 pr-2 font-medium">Win%</th>
|
||||
<th className="py-1 font-medium">Avg score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-fg-muted">
|
||||
{scorecard.map((s) => (
|
||||
<tr key={s.hypothesisId} className="border-t border-line/60">
|
||||
<td className="py-1 pr-2 font-mono text-[10px]">{s.hypothesisId}</td>
|
||||
<td className="py-1 pr-2 tabular-nums">{s.n}</td>
|
||||
<td className="py-1 pr-2 tabular-nums">
|
||||
{s.wins}/{s.losses}
|
||||
</td>
|
||||
<td className="py-1 pr-2 tabular-nums">
|
||||
{s.winRate != null ? `${(s.winRate * 100).toFixed(0)}%` : "—"}
|
||||
</td>
|
||||
<td className="py-1 tabular-nums">
|
||||
{s.avgScore != null ? s.avgScore.toFixed(0) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "mentor" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
Import mentor level calls from your local harvest. Confirm drafts, then grade the
|
||||
same way as your studies. Stays on this device.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[40px] px-3 rounded-lg bg-accent text-accent-fg text-xs font-medium disabled:opacity-50"
|
||||
onClick={() => void importMentor()}
|
||||
>
|
||||
Import from harvest
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[40px] px-3 rounded-lg border border-line text-xs text-fg-muted disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.mentorLedger.gradeDue();
|
||||
setLogFlash(`Mentor graded ${r.graded} · incomplete ${r.incomplete}`);
|
||||
await refreshMentor();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grade due mentor calls
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mentorRows.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{mentorRows.slice(0, 20).map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="rounded-lg border border-line px-3 py-2 text-xs space-y-1"
|
||||
>
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-medium text-fg">
|
||||
@{m.handle} · {m.symbol ?? "?"} · {m.claim_type}
|
||||
</span>
|
||||
<span className={gradeTone(m.grade)}>
|
||||
{m.grade ?? m.status}
|
||||
{typeof m.score === "number" ? ` · ${m.score}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-fg-faint">
|
||||
{biasLabel(m.bias)} · E {m.entry} / X {m.invalidation} / T {m.target} ·{" "}
|
||||
{m.horizon_days}d · conf{" "}
|
||||
{m.extract_confidence != null
|
||||
? `${(m.extract_confidence * 100).toFixed(0)}%`
|
||||
: "—"}
|
||||
</p>
|
||||
{m.excerpt && (
|
||||
<p className="text-fg-muted line-clamp-2 leading-relaxed">{m.excerpt}</p>
|
||||
)}
|
||||
{m.grade_summary && (
|
||||
<p className="text-fg-muted">{m.grade_summary}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{m.status === "draft" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[32px] px-2 rounded border border-line text-fg-muted disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.mentorLedger.confirm({ id: m.id });
|
||||
await refreshMentor();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[32px] px-2 rounded border border-line text-fg-muted disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.mentorLedger.discard(m.id);
|
||||
await refreshMentor();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(m.status === "confirmed" || m.grade === "incomplete") && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-[32px] px-2 rounded border border-line text-fg-muted disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.mentorLedger.grade(m.id);
|
||||
await refreshMentor();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grade
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{mentorCard.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-faint">
|
||||
Mentor scorecard
|
||||
</p>
|
||||
<table className="w-full text-[11px] text-left">
|
||||
<thead className="text-fg-faint">
|
||||
<tr>
|
||||
<th className="py-1 pr-2">Handle</th>
|
||||
<th className="py-1 pr-2">Claim</th>
|
||||
<th className="py-1 pr-2">N</th>
|
||||
<th className="py-1 pr-2">W/L</th>
|
||||
<th className="py-1">Win%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-fg-muted">
|
||||
{mentorCard.map((s) => (
|
||||
<tr key={`${s.handle}-${s.claimType}`} className="border-t border-line/60">
|
||||
<td className="py-1 pr-2">@{s.handle}</td>
|
||||
<td className="py-1 pr-2 font-mono text-[10px]">{s.claimType}</td>
|
||||
<td className="py-1 pr-2 tabular-nums">{s.n}</td>
|
||||
<td className="py-1 pr-2 tabular-nums">
|
||||
{s.wins}/{s.losses}
|
||||
</td>
|
||||
<td className="py-1 tabular-nums">
|
||||
{s.winRate != null ? `${(s.winRate * 100).toFixed(0)}%` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Execution View - Placeholder component
|
||||
* TODO: Implement with actual order management and trade execution tracking
|
||||
* Legacy execution surface — redirect to journal (decision closes).
|
||||
*/
|
||||
export function ExecutionView() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace('/journal');
|
||||
}, [router]);
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Execution View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — order management and trade execution</p>
|
||||
<p className="text-sm text-fg-muted">Redirecting to Journal…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { MarketOutlookPanel } from '@/components/MarketOutlookPanel';
|
||||
|
||||
/**
|
||||
* Market Outlook View - Placeholder component
|
||||
* TODO: Implement with macro analysis and market regime classification
|
||||
* Alias for Market Outlook — live panel, not a placeholder.
|
||||
*/
|
||||
export function MarketOutlookView() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Market Outlook View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — macro analysis and market regime</p>
|
||||
</div>
|
||||
);
|
||||
return <MarketOutlookPanel />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,176 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Trade Closure View - Placeholder component
|
||||
* TODO: Implement with trade closure workflow and exit strategies
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Check, TrendingUp, TrendingDown, DollarSign } from 'lucide-react';
|
||||
|
||||
type TradeRow = Record<string, unknown>;
|
||||
|
||||
const TIER_ORDER: Record<string, number> = { C: 0, B: 1, A: 2, A_STAR: 3 };
|
||||
|
||||
export function TradeClosureView() {
|
||||
const [trades, setTrades] = useState<TradeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [pnlInput, setPnlInput] = useState('');
|
||||
const [reflection, setReflection] = useState('');
|
||||
const [closingId, setClosingId] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<{ totalClosed: number; profitable: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.trades.list().then((data) => { setTrades(data ?? []); setLoading(false); }).catch(() => setLoading(false));
|
||||
api.trades.stats().then(setStats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const active = trades.filter((t) => t.status === 'active' || t.status === 'planned');
|
||||
const closed = trades.filter((t) => t.status === 'closed');
|
||||
const selected = trades.find((t) => t.id === selectedId);
|
||||
|
||||
function handleClose() {
|
||||
if (!selectedId || !pnlInput) return;
|
||||
setClosingId(selectedId);
|
||||
api.trades.close(selectedId, parseFloat(pnlInput), reflection.trim() || undefined)
|
||||
.then(() => {
|
||||
setTrades((prev) => prev.map((t) => t.id === selectedId ? { ...t, status: 'closed', realized_pnl: parseFloat(pnlInput) } : t));
|
||||
setPnlInput('');
|
||||
setReflection('');
|
||||
setSelectedId(null);
|
||||
setClosingId(null);
|
||||
api.trades.stats().then(setStats).catch(() => {});
|
||||
})
|
||||
.catch(() => setClosingId(null));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-3 text-sm text-fg-muted">Loading trades...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Trade Closure View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — trade closure and exit strategies</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-fg">Decision journal</h1>
|
||||
<p className="text-sm text-fg-faint mt-1">
|
||||
Close planned/active decisions, log P&L, and write a short reflection for next time.
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted mt-1">
|
||||
<a href="/plan" className="text-accent hover:underline">New decision plan</a>
|
||||
{" · "}
|
||||
<a href="/theses" className="text-accent hover:underline">Theses</a>
|
||||
</p>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="flex gap-4 text-xs text-fg-muted">
|
||||
<span>Closed: <strong className="text-fg">{stats.totalClosed}</strong></span>
|
||||
<span>Profitable: <strong className="text-up">{stats.profitable}</strong></span>
|
||||
<span>Win rate: <strong className="text-fg">{stats.totalClosed > 0 ? ((stats.profitable / stats.totalClosed) * 100).toFixed(0) : '--'}%</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active trades */}
|
||||
{active.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="section-title">Active / Planned</h2>
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{active.map((t) => (
|
||||
<div key={t.id as string} className="flex items-center justify-between bg-surface-sunken rounded p-2.5 text-xs">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-mono font-semibold text-fg">{t.symbol as string}</span>
|
||||
<Badge tone={t.tier === 'A_STAR' ? 'up' : t.tier === 'A' ? 'accent' : t.tier === 'B' ? 'warn' : 'neutral'}>{t.tier as string}</Badge>
|
||||
<span className="text-fg-faint">{t.trade_id as string}</span>
|
||||
</div>
|
||||
<span className={`badge ${t.status === 'active' ? 'bg-[#064e3b] text-up' : 'bg-surface-sunken text-fg-faint'}`}>
|
||||
{(t.status as string).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close a trade */}
|
||||
<div className="card">
|
||||
<h2 className="section-title">Close a decision</h2>
|
||||
<p className="text-xs text-fg-faint mb-3">Select an active/planned item, log realized P&L, and optional reflection.</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-[12rem]">
|
||||
<label className="label">Select</label>
|
||||
<select value={selectedId ?? ''} onChange={e => setSelectedId(e.target.value || null)}
|
||||
className="input text-xs w-full">
|
||||
<option value="">--</option>
|
||||
{active.map((t) => (
|
||||
<option key={t.id as string} value={t.id as string}>
|
||||
{t.symbol as string} — {t.trade_id as string} ({t.tier as string})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="label">Realized P&L ($)</label>
|
||||
<input type="number" step="any" value={pnlInput} onChange={e => setPnlInput(e.target.value)}
|
||||
className="input text-xs w-full" placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
<label className="label block">
|
||||
What will you do differently next time? (optional)
|
||||
<textarea
|
||||
value={reflection}
|
||||
onChange={(e) => setReflection(e.target.value)}
|
||||
rows={2}
|
||||
className="input text-xs w-full mt-1"
|
||||
placeholder="Process note for your future self"
|
||||
/>
|
||||
</label>
|
||||
<button onClick={handleClose} disabled={!selectedId || !pnlInput || !!closingId}
|
||||
className="btn-primary text-xs h-[34px]">
|
||||
{closingId ? <span className="h-3 w-3 border border-white border-t-transparent rounded-full animate-spin" /> : 'Close decision'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Closed trades */}
|
||||
{closed.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="section-title">Closed Trades</h2>
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{closed.map((t) => {
|
||||
const pnl = t.realized_pnl as number | undefined;
|
||||
return (
|
||||
<div key={t.id as string} className="flex items-center justify-between bg-surface-sunken rounded p-2.5 text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-fg">{t.symbol as string}</span>
|
||||
<Badge tone={t.tier === 'A_STAR' ? 'up' : t.tier === 'A' ? 'accent' : t.tier === 'B' ? 'warn' : 'neutral'}>{t.tier as string}</Badge>
|
||||
<span className="text-fg-faint">{t.trade_id as string}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{pnl !== undefined && (
|
||||
<span className={`flex items-center gap-1 font-semibold ${pnl >= 0 ? 'text-up' : 'text-danger'}`}>
|
||||
{pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
${pnl.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={pnl !== undefined && pnl > 0 ? 'up' : 'neutral'}>CLOSED</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trades.length === 0 && (
|
||||
<div className="rounded-lg border border-line bg-surface-sunken p-8 text-center">
|
||||
<p className="text-sm text-fg-muted">No trades yet. Create a trade plan and save it to start tracking.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Trade Plan View - Placeholder component
|
||||
* TODO: Implement with actual trade plan creation and management
|
||||
* Legacy trade-plan surface — redirect to server-persisted Decision plan.
|
||||
*/
|
||||
export function TradePlanView() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace('/plan');
|
||||
}, [router]);
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Trade Plan View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — trade plan creation and management</p>
|
||||
<p className="text-sm text-fg-muted">Redirecting to Decision plan…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user