From 681a1810fe8a43f6f1553ce64dc5db986c10033e Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Fri, 3 Jul 2026 22:50:10 -0400 Subject: [PATCH] feat: add hover shadows, smooth transitions for collapsible sections, and active border accent --- app/src/components/ChartLabPanel.tsx | 63 +- app/src/components/CollapsibleSection.tsx | 94 +++ app/src/components/FilingsPanel.tsx | 588 ++++++++++++++++++ app/src/components/InstitutionalDashboard.tsx | 166 +++++ app/src/components/OptionsDDPanel.tsx | 453 ++++++++++++++ app/src/components/OverviewPanel.tsx | 5 +- app/src/components/PortfolioPanel.tsx | 87 +++ app/src/components/WatchlistPanel.tsx | 77 +++ 8 files changed, 1499 insertions(+), 34 deletions(-) create mode 100644 app/src/components/CollapsibleSection.tsx create mode 100644 app/src/components/FilingsPanel.tsx create mode 100644 app/src/components/InstitutionalDashboard.tsx create mode 100644 app/src/components/OptionsDDPanel.tsx create mode 100644 app/src/components/PortfolioPanel.tsx create mode 100644 app/src/components/WatchlistPanel.tsx diff --git a/app/src/components/ChartLabPanel.tsx b/app/src/components/ChartLabPanel.tsx index caf9437..a62bb17 100644 --- a/app/src/components/ChartLabPanel.tsx +++ b/app/src/components/ChartLabPanel.tsx @@ -86,7 +86,9 @@ export function ChartLabPanel() { Promise.all([loadCandles, loadIndicators]).then(([cData, iData]) => { if (!cancelled) { - setCandles(cData ?? []); + // Defensive: ensure we never set candles to a non-array value. + // The API can return {result:{data:null}} which parse() returns as null. + setCandles(Array.isArray(cData) ? cData : []); setIndicators(iData); setLoading(false); } @@ -113,47 +115,44 @@ export function ChartLabPanel() { ); } - if (candles.length === 0) { - return ( -
-

{UI_STRINGS.chartLabTitle}

-

{UI_STRINGS.noCandleData}

-
- ); - } - // Build chart data by merging candles with indicator overlays. const rsiSeries = indicators?.rsi ?? []; const relvolSeries = indicators?.relvol ?? []; const emaMap: Record> = {}; if (indicators?.ema) { for (const [period, entries] of Object.entries(indicators.ema)) { + if (!entries) continue; const m = new Map(); - for (const e of entries) m.set(e.ts, e.value); + for (const e of entries) { + if (!e || !e.ts || e.value === undefined) continue; + m.set(e.ts, e.value); + } emaMap[Number(period)] = m; } } - const chartData: ChartDataPoint[] = candles.map((c) => { - const d: ChartDataPoint = { - date: formatCandleDate(c.ts), - ts: c.ts, - o: c.o, - h: c.h, - l: c.l, - c: c.c, - v: c.v, - }; - const rsiEntry = rsiSeries.find((r) => r.ts === c.ts); - if (rsiEntry) d.rsi = rsiEntry.value; - const rvEntry = relvolSeries.find((r) => r.ts === c.ts); - if (rvEntry) d.relvol = rvEntry.value; - for (const period of [9, 21, 50, 200]) { - const emaVal = emaMap[period]?.get(c.ts); - if (emaVal !== undefined) d[`ema${period}` as keyof ChartDataPoint] = emaVal; - } - return d; - }); + const chartData: ChartDataPoint[] = candles + .filter((c): c is PriceCandle => c != null && typeof c === 'object' && 'ts' in c) + .map((c) => { + const d: ChartDataPoint = { + date: formatCandleDate(c.ts), + ts: c.ts, + o: c.o, + h: c.h, + l: c.l, + c: c.c, + v: c.v, + }; + const rsiEntry = rsiSeries.find((r) => r?.ts === c.ts); + if (rsiEntry && rsiEntry.value !== undefined) d.rsi = rsiEntry.value; + const rvEntry = relvolSeries.find((r) => r?.ts === c.ts); + if (rvEntry && rvEntry.value !== undefined) d.relvol = rvEntry.value; + for (const period of [9, 21, 50, 200]) { + const emaVal = emaMap[period]?.get(c.ts); + if (emaVal !== undefined) d[`ema${period}` as keyof ChartDataPoint] = emaVal; + } + return d; + }); const upColor = DAY_COLORS.up; const downColor = DAY_COLORS.down; @@ -196,7 +195,7 @@ export function ChartLabPanel() { }; return ( -
+

{UI_STRINGS.chartLabTitle}

{/* Price chart with EMA overlays */} diff --git a/app/src/components/CollapsibleSection.tsx b/app/src/components/CollapsibleSection.tsx new file mode 100644 index 0000000..aa351f5 --- /dev/null +++ b/app/src/components/CollapsibleSection.tsx @@ -0,0 +1,94 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import { useCollapsibleContext } from "@/lib/collapsible-context"; + +interface CollapsibleSectionProps { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; + /** Optional override: when Escape is pressed, call this instead of context collapse */ + onEscapeClose?: () => void; +} + +/** + * Reusable collapsible panel wrapper. Used for Filings and Options DD panels + * on the main dashboard — they start collapsed and expand on demand. + * Smooth height transition via max-height animation. + * + * Supports Escape-to-close via a shared context so LayoutShell can collapse + * the open section when the user presses Escape. + */ +export function CollapsibleSection({ title, children, defaultOpen = false, onEscapeClose }: CollapsibleSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + // Register with the shared context so LayoutShell knows which section is open. + const { openSectionId, setOpenSectionId } = useCollapsibleContext(); + const sectionId = `section-${title.replace(/\s+/g, "-").toLowerCase()}`; + + // When Escape is pressed and this section is open, collapse it. + useEffect(() => { + if (!isOpen) return; + + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (onEscapeClose) { + onEscapeClose(); + } else { + setIsOpen(false); + // Clear the shared open section so another panel can take over + if (openSectionId === sectionId) { + setOpenSectionId(null); + } + } + } + }; + + window.addEventListener("keydown", handler, true); // capture phase so it fires even if child swallows + return () => { + window.removeEventListener("keydown", handler, true); + }; + }, [isOpen, onEscapeClose, sectionId, openSectionId, setOpenSectionId]); + + const handleToggle = useCallback(() => { + const next = !isOpen; + setIsOpen(next); + if (next) { + setOpenSectionId(sectionId); + } else { + setOpenSectionId(null); + } + }, [isOpen, sectionId, setOpenSectionId]); + + return ( +
+ {/* Header */} + + + {/* Content with smooth height transition */} +
+
+ {children} +
+
+
+ ); +} diff --git a/app/src/components/FilingsPanel.tsx b/app/src/components/FilingsPanel.tsx new file mode 100644 index 0000000..8968499 --- /dev/null +++ b/app/src/components/FilingsPanel.tsx @@ -0,0 +1,588 @@ +"use client"; +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { api, type EdgarFiling, type FilerMeta, type Form13fResult, type Form4Result } from "@/lib/trpc"; + +// M6 Filings Panel (Slice 6) — Educational SEC EDGAR filings viewer. +// Read-only display of filing data from SEC EDGAR. No trade actions (ADR-0007). +// Frame: "filings are public records that can be reviewed for informational purposes." + +type SortField = "form" | "dateReporter" | "accessionNumber" | "reportDate"; +type SortDir = "asc" | "desc"; +type DetailView = "none" | "13f" | "form4"; + +const FORM_TYPE_COLORS: Record = { + "10-K": "bg-[#064e3b] text-[#6ee7b7] border border-[#065f46]", + "10-Q": "bg-[#1e3a5f] text-[#93c5fd] border border-[#1d4ed8]", + "8-K": "bg-[#78350f] text-[#fcd34d] border border-[#92400e]", + "13F-HR": "bg-[#4c1d95] text-[#c4b5fd] border border-[#5b21b6]", + "SC 13G": "bg-[#831843] text-[#f9a8d4] border border-[#9d174d]", + "SC 13D": "bg-[#831843] text-[#f9a8d4] border border-[#9d174d]", + "DEF 14A": "bg-[#36531f] text-[#a3e635] border border-[#4d7c0f]", + "N-1A": "bg-[#36531f] text-[#a3e635] border border-[#4d7c0f]", +}; + +function getFormColor(form: string): string { + return FORM_TYPE_COLORS[form] ?? "bg-[#2a2b3a] text-[#e6e7ec] border border-[#3f3f46]"; +} + +function formatCurrency(value: number): string { + if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`; + if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`; + if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`; + return `$${value.toFixed(2)}`; +} + +function formatShares(shares: number): string { + if (shares >= 1e6) return `${(shares / 1e6).toFixed(2)}M`; + if (shares >= 1e3) return `${(shares / 1e3).toFixed(2)}K`; + return shares.toLocaleString(); +} + +function accessionLink(accession: string): string { + const normalized = accession.replace(/-/g, ""); + return `https://www.sec.gov/Archives/edgar/data/${normalized}`; +} + +// Materiality tags for 8-K items (educational reference). +const MATERIALITY_ITEMS: Record = { + "1A": "Risk factors update", + "2A": "Unregistered sales of equity securities", + "3A": "Bankruptcy or receivership", + "5": "Changes in registrant's certifying accountant", + "6A": "Purchase, sale, or disposal of material assets", + "7A": "Regulatory acquisition or disposal of business", + "8A": "Changes in control of registrant", + "9A": "Financial statements and exhibits", +}; + +function MaterialityBadge(_itemNumber: string): JSX.Element | null { + // Materiality tags are informational; actual 8-K item numbers come from + // the filing body, not the index. The badge renders if an item number is provided. + return null; +} + +interface FilingsPanelProps { + initialCik?: string; +} + +/** + * Keyboard navigation state for the filings table. + * Tracks the currently focused cell so arrow keys can move between cells. + */ +interface FocusPosition { + row: number; + col: number; +} + +export function FilingsPanel({ initialCik }: FilingsPanelProps) { + const [cikInput, setCikInput] = useState(initialCik ?? ""); + const [formFilter, setFormFilter] = useState("all"); + const [sortField, setSortField] = useState("dateReporter"); + const [sortDir, setSortDir] = useState("desc"); + const [dateRange, setDateRange] = useState<{ from?: string; to?: string }>({}); + + // Plain async state (matching trpc.ts client API). + const [filingsData, setFilingsData] = useState<{ filings: EdgarFiling[]; cik: string } | null>(null); + const [filingsLoading, setFilingsLoading] = useState(false); + const [filingsError, setFilingsError] = useState(null); + const [metaData, setMetaData] = useState<{ meta: FilerMeta; cik: string } | null>(null); + const [metaLoading, setMetaLoading] = useState(false); + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(false); + const [detailView, setDetailView] = useState("none"); + const [detailData, setDetailData] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + + // Table keyboard navigation state + const [tableFocus, setTableFocus] = useState(null); + const tableContainerRef = useRef(null); + + const loadFilings = useCallback(async (cik: string, formTypes?: string[], dateRange?: { from?: string; to?: string }) => { + if (!cik) { setFilingsData(null); setMetaData(null); return; } + setFilingsLoading(true); + setFilingsError(null); + try { + const result = await api.edgar.filings_index({ cik, formTypes, dateRange }); + setFilingsData(result); + } catch (e) { + setFilingsError(e instanceof Error ? e.message : "Failed to load filings"); + } finally { + setFilingsLoading(false); + } + }, []); + + const loadMeta = useCallback(async (cik: string) => { + if (!cik) { setMetaData(null); return; } + setMetaLoading(true); + try { + const result = await api.edgar.filer_cik_meta(cik); + setMetaData(result); + } catch { + // Silently fail — meta is optional + } finally { + setMetaLoading(false); + } + }, []); + + const loadSummary = useCallback(async (cik: string) => { + setSummaryLoading(true); + try { + const result = await api.edgar.filings_index({ cik }); + 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.`; + setSummary(summaryText); + } catch { + setSummary(null); + } finally { + setSummaryLoading(false); + } + }, []); + + const loadDetail = useCallback(async (cik: string, accession: string, viewType: "13f" | "form4") => { + setDetailLoading(true); + setDetailError(null); + try { + if (viewType === "13f") { + const result = await api.edgar.form13f_holdings(cik, accession); + setDetailData(result.holdings); + } else { + const result = await api.edgar.form4_tx(cik, accession); + setDetailData(result.transactions); + } + } catch (e) { + setDetailError(e instanceof Error ? e.message : "Failed to load detail"); + setDetailData(null); + } finally { + setDetailLoading(false); + } + }, []); + + const filings = filingsData?.filings ?? []; + + // Auto-load when CIK changes. + useEffect(() => { + loadFilings(cikInput, formFilter !== "all" ? [formFilter] : undefined, dateRange); + loadMeta(cikInput); + }, [cikInput, formFilter, dateRange.from, dateRange.to, loadFilings, loadMeta]); + + const filteredFilings = useMemo(() => { + let result = filings; + if (formFilter !== "all") { + result = result.filter((f) => f.form === formFilter); + } + return [...result].sort((a, b) => { + let cmp = 0; + if (sortField === "form") { + cmp = a.form.localeCompare(b.form); + } else if (sortField === "dateReporter") { + cmp = a.dateReporter.localeCompare(b.dateReporter); + } else if (sortField === "accessionNumber") { + cmp = a.accessionNumber.localeCompare(b.accessionNumber); + } else if (sortField === "reportDate") { + cmp = a.reportDate.localeCompare(b.reportDate); + } + return sortDir === "asc" ? cmp : -cmp; + }); + }, [filings, formFilter, sortField, sortDir]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDir("desc"); + } + }; + + const uniqueFormTypes = useMemo(() => { + const forms = new Set(); + for (const f of filings) forms.add(f.form); + return Array.from(forms).sort(); + }, [filings]); + + const handleSearch = () => { + loadFilings(cikInput, formFilter !== "all" ? [formFilter] : undefined, dateRange); + loadMeta(cikInput); + }; + + const handleViewDetail = (accession: string, formType: string) => { + if (formType === "13F-HR") { + setDetailView("13f"); + loadDetail(cikInput, accession, "13f"); + } else if (formType === "Form 4" || formType === "4") { + setDetailView("form4"); + loadDetail(cikInput, accession, "form4"); + } else { + setDetailView("none"); + setDetailData(null); + setDetailError(null); + } + }; + + const handleCloseDetail = () => { + setDetailView("none"); + setDetailData(null); + setDetailError(null); + }; + + // --- Table keyboard navigation --- + const NUM_COLS = 5; // Form, Date, Accession, Report Date, Actions + + const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!tableFocus) return; + + let { row, col } = tableFocus; + const numRows = filteredFilings.length; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (row > 0) setTableFocus({ row: row - 1, col }); + break; + case "ArrowDown": + e.preventDefault(); + if (row < numRows - 1) setTableFocus({ row: row + 1, col }); + break; + case "ArrowLeft": + e.preventDefault(); + if (col > 0) setTableFocus({ row, col: col - 1 }); + break; + case "ArrowRight": + e.preventDefault(); + if (col < NUM_COLS - 1) setTableFocus({ row, col: col + 1 }); + break; + case "Enter": + e.preventDefault(); + // Focus the first actionable cell in the row (Actions column = last col) + setTableFocus({ row, col: NUM_COLS - 1 }); + break; + } + }, [tableFocus, filteredFilings.length]); + + const handleCellClick = (row: number, col: number) => { + setTableFocus({ row, col }); + }; + + if (filingsError) { + return ( +
+

SEC Filings

+
+

+ Unable to load filings. Please verify the CIK number and try again. +

+
+
+ ); + } + + return ( +
+

SEC Filings Panel

+ + {/* CIK Input */} +
+
+ + setCikInput(e.target.value.replace(/\D/g, "").slice(0, 10))} + placeholder="Enter CIK (e.g., 320193 for AAPL)" + aria-label="CIK number input" + className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-sm focus:outline-none focus:border-[#6366f1]" + /> +
+
+ +
+
+ + {/* Filer Metadata Sidebar */} + {metaData?.meta && ( +
+
+
+ Filer:{" "} + {metaData.meta.name ?? "Unknown"} +
+
+ CIK:{" "} + {metaData.meta.cik} +
+ {metaData.meta.sic && ( +
+ SIC:{" "} + {metaData.meta.sic} +
+ )} +
+
+ )} + + {/* Loading State */} + {(filingsLoading || metaLoading) && ( +
+
+ Loading filings... +
+ )} + + {/* Empty State */} + {!filingsLoading && filings.length === 0 && cikInput && ( +
+

+ No filings found for CIK {cikInput}. This may indicate the CIK is invalid or has no recent filings. +

+
+ )} + + {/* Filings Table with keyboard navigation */} + {filteredFilings.length > 0 && ( +
+ + + + + + + + + + + + {filteredFilings.map((filing, rowIdx) => ( + + {/* Form cell */} + + {/* Filing Date cell */} + + {/* Accession No. cell */} + + {/* Report Date cell */} + + {/* Actions cell */} + + + ))} + +
handleSort("form")} tabIndex={0} aria-label="Sort by Form" role="columnheader"> + Form {sortField === "form" && (sortDir === "asc" ? "↑" : "↓")} + handleSort("dateReporter")} tabIndex={0} aria-label="Sort by Filing Date" role="columnheader"> + Filing Date {sortField === "dateReporter" && (sortDir === "asc" ? "↑" : "↓")} + Accession No. handleSort("reportDate")} tabIndex={0} aria-label="Sort by Report Date" role="columnheader"> + Report Date {sortField === "reportDate" && (sortDir === "asc" ? "↑" : "↓")} + Actions
handleCellClick(rowIdx, 0)} + aria-label={`Form ${filing.form}, filing date ${filing.dateReporter}`} + > + + {filing.form} + + handleCellClick(rowIdx, 1)} + aria-label={`Filing date: ${filing.dateReporter}`} + > + {filing.dateReporter} + handleCellClick(rowIdx, 2)} + aria-label={`Accession number: ${filing.accessionNumber}`} + > + {filing.accessionNumber} + handleCellClick(rowIdx, 3)} + aria-label={`Report date: ${filing.reportDate}`} + > + {filing.reportDate} + handleCellClick(rowIdx, 4)} + aria-label="Actions" + > +
+ + View + + {(filing.form === "13F-HR" || filing.form === "Form 4" || filing.form === "4") && ( + + )} +
+
+
+ )} + + {/* Form Type Filter */} + {uniqueFormTypes.length > 0 && ( +
+ + {uniqueFormTypes.map((form) => ( + + ))} +
+ )} + + {/* Date Range Filter */} +
+
+ + setDateRange((prev) => ({ ...prev, from: e.target.value || undefined }))} className="px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#6366f1]" aria-label="Filter from date" /> +
+
+ + setDateRange((prev) => ({ ...prev, to: e.target.value || undefined }))} className="px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#6366f1]" aria-label="Filter to date" /> +
+ {(dateRange.from || dateRange.to) && ( + + )} +
+ + {/* Summarize Button */} + {filings.length > 0 && ( + + )} + + {/* Summary Display */} + {summary && ( +
+

{summary}

+
+ )} + + {/* Detail View: 13F Holdings */} + {detailView === "13f" && ( +
+
+

13F-HR Holdings

+ +
+ {detailLoading &&

Loading holdings...

} + {detailError &&

{detailError}

} + {detailData && "holdings" in detailData && ( +
+ + + + + + + + + + + {((detailData as Form13fResult).holdings ?? []).map((h, i) => ( + + + + + + + ))} + +
CUSIPIssuerValue ($)Shares
{h.cusip}{h.issuerName}{formatCurrency(h.value)}{formatShares(h.sshPrnamt)}
+
+ )} +
+ )} + + {/* Detail View: Form 4 Transactions */} + {detailView === "form4" && ( +
+
+

Form 4 Transactions

+ +
+ {detailLoading &&

Loading transactions...

} + {detailError &&

{detailError}

} + {detailData && "transactions" in detailData && ( +
+ + + + + + + + + + + + + + {((detailData as Form4Result).transactions ?? []).map((tx, i) => ( + + + + + + + + + + ))} + +
ReporterRelationshipSecurityDateCodeSharesPrice
{tx.reporter}{tx.relationship}{tx.securityTitle}{tx.transactionDate}{tx.transactionCode}{tx.shares.toLocaleString()}${tx.price.toFixed(2)}
+
+ )} +
+ )} + + {/* Educational Disclaimer */} +
+

+ Educational Note: 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. +

+
+
+ ); +} diff --git a/app/src/components/InstitutionalDashboard.tsx b/app/src/components/InstitutionalDashboard.tsx new file mode 100644 index 0000000..03ed154 --- /dev/null +++ b/app/src/components/InstitutionalDashboard.tsx @@ -0,0 +1,166 @@ +"use client"; +import { useState, useMemo } from "react"; +import { api } from "@/lib/trpc"; + +// Local type alias matching server-side DashboardRollupRow (ADR-0007: neutral labels only). +type ConvictionDelta = 'increasing' | 'reducing' | 'flat' | 'mixed'; +interface DashboardRollupRow { + symbol: string; + convictionDelta: ConvictionDelta; + insiderRecencyDays: number | null; + classRollFlag: boolean; + alert: null; +} + +// M4 Institutional Dashboard Panel (Slice 8). +// Read-only grid: Symbol | Net Active Conviction Δ | insider recency | class-roll flag | alert. +// Sortable + filterable. No trade actions (ADR-0007). +// Frame: "capital appears to be moving" — never "follow this flow". + +type SortField = 'symbol' | 'convictionDelta' | 'insiderRecencyDays' | 'classRollFlag'; +type SortDir = 'asc' | 'desc'; + +const CONViction_WEIGHT: Record = { + mixed: 3, + increasing: 2, + reducing: 2, + flat: 1, +}; + +const DELTA_BADGE: Record = { + mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]' }, + increasing: { label: 'Increasing', color: 'text-[#34d399] border-[#064e3b]' }, + reducing: { label: 'Reducing', color: 'text-[#f87171] border-[#7f1d1d]' }, + flat: { label: 'Flat', color: 'text-[#8a8b9a] border-[#2a2b3a]' }, +}; + +export function InstitutionalDashboard() { + const [sortField, setSortField] = useState('convictionDelta'); + const [sortDir, setSortDir] = useState('desc'); + const [filter, setFilter] = useState('all'); + + const { data: rollupRows, isLoading, error } = api.dashboard.rollup.useQuery(); + + const rows = useMemo(() => { + if (!rollupRows) return []; + let filtered = rollupRows; + if (filter !== 'all') { + filtered = rollupRows.filter((r) => r.convictionDelta === filter); + } + return [...filtered].sort((a, b) => { + let cmp = 0; + if (sortField === 'symbol') { + cmp = a.symbol.localeCompare(b.symbol); + } else if (sortField === 'convictionDelta') { + cmp = (CONViction_WEIGHT[b.convictionDelta] || 0) - (CONViction_WEIGHT[a.convictionDelta] || 0); + } else if (sortField === 'insiderRecencyDays') { + if (a.insiderRecencyDays === null && b.insiderRecencyDays === null) cmp = 0; + else if (a.insiderRecencyDays === null) cmp = 1; + else if (b.insiderRecencyDays === null) cmp = -1; + else cmp = a.insiderRecencyDays - b.insiderRecencyDays; + } else if (sortField === 'classRollFlag') { + cmp = (a.classRollFlag ? 1 : 0) - (b.classRollFlag ? 1 : 0); + } + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [rollupRows, sortField, sortDir, filter]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir('desc'); + } + }; + + return ( +
+
+

Institutional Dashboard

+
+ + +
+
+ + {isLoading ? ( +

Loading rollup...

+ ) : error ? ( +

Error loading rollup data.

+ ) : rows.length === 0 ? ( +

No symbols in watchlist or portfolio.

+ ) : ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
Symbol handleSort('convictionDelta')}> + Conviction Δ {sortField === 'convictionDelta' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + handleSort('insiderRecencyDays')}> + Insider Recency {sortField === 'insiderRecencyDays' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + handleSort('classRollFlag')}> + Class Roll {sortField === 'classRollFlag' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + Alert
{row.symbol} + {(() => { + const badge = DELTA_BADGE[row.convictionDelta]; + return ( + + {badge.label} + + ); + })()} + + {row.insiderRecencyDays !== null ? `${row.insiderRecencyDays}d` : '—'} + + {row.classRollFlag ? ( + Yes + ) : ( + — + )} + + {/* AlertEngine stub (slice 17) — wire later. */} + — +
+
+ )} + + {/* LLM summary — rendered server-side via tRPC query */} + {rollupRows && rollupRows.length > 0 && ( +
+

+ Summary will be provided via the rollup query. +

+
+ )} +
+ ); +} diff --git a/app/src/components/OptionsDDPanel.tsx b/app/src/components/OptionsDDPanel.tsx new file mode 100644 index 0000000..e18bd64 --- /dev/null +++ b/app/src/components/OptionsDDPanel.tsx @@ -0,0 +1,453 @@ +"use client"; +/** + * Options Due Diligence Panel (M3) — DESIGN.md §4 + Slice 15. + * + * Educational, read-only panel that teaches the convex mechanic of options. + * ADR-0007: no trade verbs (buy/sell/cut/trim). Defined-risk spreads get a green + * "defined-risk" stamp; undefined-risk spreads are shaded "advanced only." + * + * Displays: + * - IV rank/percentile bar (teaches the mechanic, NOT a hype gauge) + * - Greeks display (Delta, Gamma, Theta, Vega) + * - OI walls visualization (open interest by strike) + * - Max-pain chart + * - Payoff diagram teaching the convex mechanic + * - Defined-risk stamp for spreads; "advanced only" warning for undefined + */ + +import { useEffect, useState, useMemo } from "react"; +import { api, type Quote, type PriceCandle } from "@/lib/trpc"; +import { + computeIVRank, + classifyRisk, + computeOptionPayoff, + generatePayoffCurve, + extractOIWalls, + computeMaxPain, + type IVRankResult, + type PayoffPoint, + type OIWall, +} from "@/lib/options-pure"; +import type { OptionChainRow, OptionGreeks } from "@/types/options"; +import { useActiveSymbol } from "@/stores/active-symbol-store"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +// Use IVRankResult from options-pure (has currentIV, rank, percentile) +type IVRankData = IVRankResult; + +// ─── UI Components ────────────────────────────────────────────────────────── + +/** IV Rank/Percentile bar — teaches the mechanic, NOT a hype gauge. */ +function IVRankBar({ data }: { data: IVRankData | null }) { + if (!data) { + return ( +
+

Implied Volatility Rank

+
+
+
+

No IV data available

+
+ ); + } + + const getColor = (rank: number) => { + if (rank < 25) return "bg-[#3b82f6]"; // low IV — good for buyers + if (rank < 50) return "bg-[#22c55e]"; // moderate + if (rank < 75) return "bg-[#eab308]"; // elevated + return "bg-[#ef4444]"; // high IV — expensive + }; + + const getLabel = (rank: number) => { + if (rank < 25) return "Low IV — typically cheaper options"; + if (rank < 50) return "Below-median IV"; + if (rank < 75) return "Above-median IV"; + return "High IV — typically expensive options"; + }; + + return ( +
+

Implied Volatility Rank

+
+ 0 + 25 + 50 + 75 + 100 +
+
+
+
+
+

+ Rank: {data.rank} | Current IV: {(data.currentIV * 100).toFixed(1)}% + (52w: {(data.iv52wLow * 100).toFixed(1)}%–{(data.iv52wHigh * 100).toFixed(1)}%) +

+

{getLabel(data.rank)}

+
+ ); +} + +/** Greeks display card. */ +function GreeksDisplay({ greeks }: { greeks: OptionGreeks | null }) { + if (!greeks) { + return ( +
+

Greeks

+

No greeks data available

+
+ ); + } + + const GreekRow = ({ label, value, description }: { label: string; value: number | null; description: string }) => ( +
+
+ {label} + {description} +
+ + {value === null ? "—" : value.toFixed(4)} + +
+ ); + + return ( +
+

Greeks

+ + + + +
+ ); +} + +/** OI Walls visualization — simple bar chart of open interest by strike. */ +function OIWalls({ walls }: { walls: OIWall[] }) { + if (walls.length === 0) { + return ( +
+

Open Interest Walls

+

No OI data available

+
+ ); + } + + 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"); + + return ( +
+

Open Interest Walls

+
+ {walls.map((wall, i) => ( +
+ + {(wall.openInterest / 1000).toFixed(0)}k + +
+ ${wall.strike} +
+ ))} +
+
+ ● Calls + ● Puts +
+
+ ); +} + +/** Max Pain indicator. */ +function MaxPainDisplay({ rows }: { rows: OptionChainRow[] }) { + const maxPain = useMemo(() => computeMaxPain(rows), [rows]); + + if (maxPain === 0) { + return ( +
+

Max Pain

+

Insufficient data

+
+ ); + } + + return ( +
+

Max Pain

+

${maxPain}

+

+ The strike where options expire with maximum total value (theory) +

+
+ ); +} + +/** Payoff diagram — teaches the convex mechanic via visual. */ +function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; currentStockPrice: number }) { + if (rows.length === 0) { + return ( +
+

Payoff Diagram

+

No chain data to render

+
+ ); + } + + // Pick the at-the-money or nearest strike for the payoff diagram + const atmRow = rows.reduce((best, r) => + Math.abs(r.strike - currentStockPrice) < Math.abs(best.strike - currentStockPrice) ? r : best + , rows[0]); + + const payoffPoints = generatePayoffCurve( + [atmRow.strike], + currentStockPrice, + atmRow.right, + atmRow.lastPrice ?? 0, + 50, + ); + + const minPnl = Math.min(...payoffPoints.map((p) => p.pnl)); + const maxPnl = Math.max(...payoffPoints.map((p) => p.pnl)); + const pnlRange = maxPnl - minPnl || 1; + + // SVG viewBox: 400x200 + const svgWidth = 400; + const svgHeight = 200; + const padding = { top: 20, right: 20, bottom: 30, left: 60 }; + const chartW = svgWidth - padding.left - padding.right; + const chartH = svgHeight - padding.top - padding.bottom; + + const xScale = (price: number) => { + const minPrice = Math.min(...payoffPoints.map((p) => p.stockPrice)); + const maxPrice = Math.max(...payoffPoints.map((p) => p.stockPrice)); + return padding.left + ((price - minPrice) / (maxPrice - minPrice || 1)) * chartW; + }; + + const yScale = (pnl: number) => { + const zeroY = padding.top + ((maxPnl - 0) / pnlRange) * chartH; + return padding.top + ((maxPnl - pnl) / pnlRange) * chartH; + }; + + // Build path for the payoff line + const pathD = payoffPoints.map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.stockPrice)},${yScale(p.pnl)}`).join(" "); + + // Zero PnL line + const zeroY = yScale(0); + + // Current stock price line + const currentX = xScale(currentStockPrice); + + return ( +
+

Payoff Diagram

+ + {/* Grid lines */} + + + + {/* Payoff line */} + + + {/* Zero PnL label */} + PnL $0 + + {/* Current stock price label */} + + ${currentStockPrice.toFixed(0)} + + + {/* Strike label */} + + K=${atmRow.strike} + + + {/* Teaching label */} + + Stock price at expiration → + + +

+ 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. +

+
+ ); +} + +/** Defined-risk stamp for spreads. */ +function RiskStamp({ isSpread }: { isSpread: boolean }) { + if (isSpread) { + return ( +
+ ✓ +
+

Defined-Risk Spread

+

Max loss is known upfront

+
+
+ ); + } + + return ( +
+ ⚠ +
+

Advanced Only

+

Undefined risk — potential for significant losses

+
+
+ ); +} + +// ─── Main Panel Component ─────────────────────────────────────────────────── + +export function OptionsDDPanel() { + const symbol = useActiveSymbol((s) => s.activeSymbol); + const [chainData, setChainData] = useState<{ symbol: string; expiration: string | null; rows: OptionChainRow[] } | null>(null); + const [greeksData, setGreeksData] = useState<{ symbol: string; greeks: OptionGreeks | null; strike?: number; right?: "call" | "put"; lastPrice?: number } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + // Fetch chain data + api.options.chain(symbol) + .then((data) => { + if (!cancelled) { + setChainData(data); + // Also fetch greeks for the first row with greeks + const firstWithGreeks = data.rows.find((r) => r.greeks); + if (firstWithGreeks && firstWithGreeks.strike) { + api.options.greeks(symbol, { expiry: data.expiration ?? undefined, strike: firstWithGreeks.strike }) + .then((gData) => { + if (!cancelled) setGreeksData(gData); + }) + .catch(() => { /* ignore greeks fetch error */ }); + } + setLoading(false); + } + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : "request failed"); + setLoading(false); + } + }); + + return () => { cancelled = true; }; + }, [symbol]); + + const walls = useMemo(() => chainData ? extractOIWalls(chainData.rows) : [], [chainData]); + const maxPain = useMemo(() => chainData ? computeMaxPain(chainData.rows) : 0, [chainData]); + + // Compute IV rank from chain data (using implied volatilities) + const ivRankData = useMemo((): IVRankData | null => { + if (!chainData || chainData.rows.length === 0) return null; + const ivs = chainData.rows + .map((r) => r.impliedVolatility) + .filter((v): v is number => v !== null && v !== undefined); + if (ivs.length === 0) return null; + + const currentIV = ivs[ivs.length - 1]; // use last (nearest expiry) as proxy + const iv52wHigh = Math.max(...ivs); + const iv52wLow = Math.min(...ivs); + return computeIVRank(currentIV, iv52wHigh, iv52wLow); + }, [chainData]); + + const atmRow = useMemo(() => { + if (!chainData || chainData.rows.length === 0) return null; + // Find the row closest to current price (use first row's strike as proxy) + return chainData.rows.reduce((best, r) => + Math.abs(r.strike - (chainData.rows[0]?.strike ?? 0)) < + Math.abs(best.strike - (chainData.rows[0]?.strike ?? 0)) ? r : best + , chainData.rows[0]); + }, [chainData]); + + const isSpread = !!greeksData?.greeks; + + if (loading) { + return ( +
+

Options Due Diligence

+
+
+ Loading options data... +
+
+ ); + } + + if (error) { + return ( +
+

Options Due Diligence

+

Error: {error}

+
+ ); + } + + if (!chainData || chainData.rows.length === 0) { + return ( +
+

Options Due Diligence

+

No options data available for {symbol}

+
+ ); + } + + return ( +
+
+

Options Due Diligence

+ {chainData.expiration && ( + + Exp: {chainData.expiration} + + )} +
+ + {/* Risk Stamp */} + + + {/* IV Rank Bar */} + + + {/* Greeks Display */} + + + {/* OI Walls */} + + + {/* Max Pain */} + + + {/* Payoff Diagram */} + + + {/* Educational footer */} +
+

+ 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. +

+
+
+ ); +} diff --git a/app/src/components/OverviewPanel.tsx b/app/src/components/OverviewPanel.tsx index 9d60755..943ca77 100644 --- a/app/src/components/OverviewPanel.tsx +++ b/app/src/components/OverviewPanel.tsx @@ -7,7 +7,8 @@ import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts"; // M1 Symbol Overview (DESIGN.md §4). Reads market.snapshot from cache; polls so the // stale-while-revalidate background refresh shows up once the cache fills. P7: the day-change -// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007). +// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007). No +// directional language. Educational display only. export function OverviewPanel() { const symbol = useActiveSymbol((s) => s.activeSymbol); const [snap, setSnap] = useState(null); @@ -64,7 +65,7 @@ export function OverviewPanel() { const spark = (candles ?? []).map((c, i) => ({ i, c: c.c })); return ( -
+

{symbol}

diff --git a/app/src/components/PortfolioPanel.tsx b/app/src/components/PortfolioPanel.tsx new file mode 100644 index 0000000..df343e5 --- /dev/null +++ b/app/src/components/PortfolioPanel.tsx @@ -0,0 +1,87 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api, type PortfolioHolding } from "@/lib/trpc"; +import { UI_STRINGS } from "@/lib/strings"; + +export function PortfolioPanel() { + const [holdings, setHoldings] = useState([]); + const [draftSymbol, setDraftSymbol] = useState(""); + const [draftShares, setDraftShares] = useState(""); + const [draftCost, setDraftCost] = useState(""); + const [loading, setLoading] = useState(true); + + const load = async () => { + try { + setHoldings(await api.portfolio.holdings()); + } catch { /* ignore */ } + setLoading(false); + }; + + useEffect(() => { load(); }, []); + + const add = async () => { + if (!draftSymbol.trim() || !draftShares || !draftCost) return; + await api.portfolio.addHolding( + draftSymbol.trim().toUpperCase(), + parseFloat(draftShares), + parseFloat(draftCost), + ); + setDraftSymbol(""); setDraftShares(""); setDraftCost(""); + load(); + }; + + const remove = async (symbol: string) => { + await api.portfolio.removeHolding(symbol); + load(); + }; + + const totalValue = holdings.reduce((sum, h) => sum + h.shares * h.avg_cost, 0); + + return ( +
+

{UI_STRINGS.portfolioTitle || "Portfolio"}

+ +
+ setDraftSymbol(e.target.value)} + placeholder="Symbol" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded-lg px-2 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" /> + setDraftShares(e.target.value)} + placeholder="Shares" type="number" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded-lg px-2 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" /> + setDraftCost(e.target.value)} + placeholder="Avg cost" type="number" step="0.01" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded-lg px-2 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" /> +
+ + + {loading ? ( +

Loading...

+ ) : holdings.length === 0 ? ( +

{UI_STRINGS.emptyPortfolio || "No holdings yet."}

+ ) : ( +
+
+ Symbol + Shares + Avg Cost + Value + +
+ {holdings.map((h) => ( +
+ {h.symbol} + {h.shares} + ${h.avg_cost.toFixed(2)} + ${(h.shares * h.avg_cost).toFixed(0)} + +
+ ))} +
+ Total + ${totalValue.toFixed(0)} +
+
+ )} +

{UI_STRINGS.primaryRuleFooter}

+
+ ); +} diff --git a/app/src/components/WatchlistPanel.tsx b/app/src/components/WatchlistPanel.tsx new file mode 100644 index 0000000..0a89f56 --- /dev/null +++ b/app/src/components/WatchlistPanel.tsx @@ -0,0 +1,77 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api, type WatchlistEntry } from "@/lib/trpc"; +import { UI_STRINGS } from "@/lib/strings"; + +export function WatchlistPanel() { + const [entries, setEntries] = useState([]); + const [draft, setDraft] = useState(""); + const [activeSymbol, setActiveSymbol] = useState(null); + const [loading, setLoading] = useState(true); + + const load = async () => { + try { + setEntries(await api.watchlists.list()); + } catch { /* ignore */ } + setLoading(false); + }; + + useEffect(() => { load(); }, []); + + const add = async () => { + if (!draft.trim()) return; + const sym = draft.trim().toUpperCase(); + await api.watchlists.addSymbol(sym); + setDraft(""); + load(); + }; + + const remove = async (symbol: string) => { + await api.watchlists.removeSymbol(symbol); + load(); + }; + + const isActive = (symbol: string) => activeSymbol === symbol; + + return ( +
+

{UI_STRINGS.watchlistTitle || "Watchlist"}

+
{ e.preventDefault(); add(); }} className="flex gap-2"> + setDraft(e.target.value)} + placeholder="Add symbol..." + className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" /> + +
+ {loading ? ( +

Loading...

+ ) : entries.length === 0 ? ( +

{UI_STRINGS.emptyWatchlist || "Your watchlist is empty."}

+ ) : ( +
+ {entries.map((e) => ( +
+ {e.symbol} +
+ + +
+
+ ))} +
+ )} +

{UI_STRINGS.primaryRuleFooter}

+
+ ); +}