feat: add hover shadows, smooth transitions for collapsible sections, and active border accent

This commit is contained in:
Investor Flow Build
2026-07-03 22:50:10 -04:00
parent aa79812158
commit 681a1810fe
8 changed files with 1499 additions and 34 deletions
+16 -17
View File
@@ -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,28 +115,25 @@ export function ChartLabPanel() {
);
}
if (candles.length === 0) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.noCandleData}</p>
</section>
);
}
// Build chart data by merging candles with indicator overlays.
const rsiSeries = indicators?.rsi ?? [];
const relvolSeries = indicators?.relvol ?? [];
const emaMap: Record<number, Map<string, number>> = {};
if (indicators?.ema) {
for (const [period, entries] of Object.entries(indicators.ema)) {
if (!entries) continue;
const m = new Map<string, number>();
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 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,
@@ -144,10 +143,10 @@ export function ChartLabPanel() {
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;
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;
@@ -196,7 +195,7 @@ export function ChartLabPanel() {
};
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6">
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
{/* Price chart with EMA overlays */}
+94
View File
@@ -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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]" aria-label={title}>
{/* Header */}
<button
onClick={handleToggle}
aria-expanded={isOpen}
aria-label={`${title} — ${isOpen ? "collapsed" : "expanded"}. Press Enter to toggle, Escape to close.`}
className="w-full flex items-center justify-between px-5 py-3 rounded-t-xl hover:bg-[#1a1b24] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff] focus:ring-offset-2 focus:ring-offset-[#0a0b0f]"
tabIndex={0}
>
<h2 className="text-sm font-semibold text-[#e6e7ec]">{title}</h2>
<span className={`text-[10px] text-[#5a5b6a] transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`} aria-hidden="true">
▼
</span>
</button>
{/* Content with smooth height transition */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isOpen ? "max-h-[9999px] opacity-100" : "max-h-0 opacity-0"
}`}
role="region"
aria-label={`${title} content`}
>
<div className="px-5 pb-5">
{children}
</div>
</div>
</section>
);
}
+588
View File
@@ -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<string, string> = {
"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<string, string> = {
"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<string>("all");
const [sortField, setSortField] = useState<SortField>("dateReporter");
const [sortDir, setSortDir] = useState<SortDir>("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<string | null>(null);
const [metaData, setMetaData] = useState<{ meta: FilerMeta; cik: string } | null>(null);
const [metaLoading, setMetaLoading] = useState(false);
const [summary, setSummary] = useState<string | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [detailView, setDetailView] = useState<DetailView>("none");
const [detailData, setDetailData] = useState<Form13fResult | Form4Result | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
// Table keyboard navigation state
const [tableFocus, setTableFocus] = useState<FocusPosition | null>(null);
const tableContainerRef = useRef<HTMLDivElement>(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<string>();
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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<h2 className="text-lg font-bold text-[#e6e7ec] mb-4">SEC Filings</h2>
<div className="p-4 bg-[#1e1e2e] rounded-lg border border-[#f87171]/30">
<p className="text-[#f87171] text-sm">
Unable to load filings. Please verify the CIK number and try again.
</p>
</div>
</section>
);
}
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
<h2 className="text-lg font-bold text-[#e6e7ec]">SEC Filings Panel</h2>
{/* CIK Input */}
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-[#8a8b9a] mb-1">CIK Number</label>
<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)"
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]"
/>
</div>
<div className="flex items-end gap-2">
<button
onClick={handleSearch}
disabled={!cikInput}
aria-label="Load filings"
className="px-4 py-2 bg-[#6366f1] text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#4f46e5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff]"
>
Load Filings
</button>
</div>
</div>
{/* Filer Metadata Sidebar */}
{metaData?.meta && (
<div className="p-3 bg-[#1e1e2e] rounded-lg border border-[#3f3f46]">
<div className="flex items-center gap-4 text-sm">
<div>
<span className="text-[#8a8b9a]">Filer:</span>{" "}
<span className="text-[#e6e7ec] font-medium">{metaData.meta.name ?? "Unknown"}</span>
</div>
<div>
<span className="text-[#8a8b9a]">CIK:</span>{" "}
<span className="text-[#e6e7ec] font-mono">{metaData.meta.cik}</span>
</div>
{metaData.meta.sic && (
<div>
<span className="text-[#8a8b9a]">SIC:</span>{" "}
<span className="text-[#e6e7ec] font-mono">{metaData.meta.sic}</span>
</div>
)}
</div>
</div>
)}
{/* Loading State */}
{(filingsLoading || metaLoading) && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#6366f1]"></div>
<span className="ml-3 text-[#8a8b9a] text-sm">Loading filings...</span>
</div>
)}
{/* Empty State */}
{!filingsLoading && filings.length === 0 && cikInput && (
<div className="text-center py-8">
<p className="text-[#8a8b9a] text-sm">
No filings found for CIK {cikInput}. This may indicate the CIK is invalid or has no recent filings.
</p>
</div>
)}
{/* Filings Table with keyboard navigation */}
{filteredFilings.length > 0 && (
<div
ref={tableContainerRef}
className="overflow-x-auto"
onKeyDown={handleTableKeyDown}
tabIndex={0}
role="grid"
aria-label="SEC filings table. Use arrow keys to navigate between cells."
>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#3f3f46]">
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium cursor-pointer hover:text-[#e6e7ec]" onClick={() => handleSort("form")} tabIndex={0} aria-label="Sort by Form" role="columnheader">
Form {sortField === "form" && (sortDir === "asc" ? "↑" : "↓")}
</th>
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium cursor-pointer hover:text-[#e6e7ec]" onClick={() => handleSort("dateReporter")} tabIndex={0} aria-label="Sort by Filing Date" role="columnheader">
Filing Date {sortField === "dateReporter" && (sortDir === "asc" ? "↑" : "↓")}
</th>
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium" role="columnheader">Accession No.</th>
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium cursor-pointer hover:text-[#e6e7ec]" onClick={() => handleSort("reportDate")} tabIndex={0} aria-label="Sort by Report Date" role="columnheader">
Report Date {sortField === "reportDate" && (sortDir === "asc" ? "↑" : "↓")}
</th>
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium" role="columnheader">Actions</th>
</tr>
</thead>
<tbody>
{filteredFilings.map((filing, rowIdx) => (
<tr key={filing.accessionNumber} className="border-b border-[#2a2b3a] hover:bg-[#1e1e2e]" role="row">
{/* Form cell */}
<td
className="py-2 px-3"
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 0 ? 0 : -1}
onClick={() => handleCellClick(rowIdx, 0)}
aria-label={`Form ${filing.form}, filing date ${filing.dateReporter}`}
>
<span className={`inline-flex px-2 py-1 rounded text-xs font-medium ${getFormColor(filing.form)}`}>
{filing.form}
</span>
</td>
{/* Filing Date cell */}
<td
className="py-2 px-3 text-[#e6e7ec]"
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 1 ? 0 : -1}
onClick={() => handleCellClick(rowIdx, 1)}
aria-label={`Filing date: ${filing.dateReporter}`}
>
{filing.dateReporter}
</td>
{/* Accession No. cell */}
<td
className="py-2 px-3 font-mono text-xs text-[#8a8b9a]"
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 2 ? 0 : -1}
onClick={() => handleCellClick(rowIdx, 2)}
aria-label={`Accession number: ${filing.accessionNumber}`}
>
{filing.accessionNumber}
</td>
{/* Report Date cell */}
<td
className="py-2 px-3 text-[#e6e7ec]"
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 3 ? 0 : -1}
onClick={() => handleCellClick(rowIdx, 3)}
aria-label={`Report date: ${filing.reportDate}`}
>
{filing.reportDate}
</td>
{/* Actions cell */}
<td
className="py-2 px-3"
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 4 ? 0 : -1}
onClick={() => handleCellClick(rowIdx, 4)}
aria-label="Actions"
>
<div className="flex gap-2">
<a
href={accessionLink(filing.accessionNumber)}
target="_blank"
rel="noopener noreferrer"
className="text-[#6366f1] hover:text-[#818cf8] text-xs underline"
aria-label={`View filing on SEC EDGAR for accession ${filing.accessionNumber}`}
>
View
</a>
{(filing.form === "13F-HR" || filing.form === "Form 4" || filing.form === "4") && (
<button
onClick={() => handleViewDetail(filing.accessionNumber, filing.form)}
className="text-[#a78bfa] hover:text-[#c4b5fd] text-xs underline"
aria-label={`View details for accession ${filing.accessionNumber}`}
>
Details
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Form Type Filter */}
{uniqueFormTypes.length > 0 && (
<div className="flex flex-wrap gap-2" role="group" aria-label="Filter by form type">
<button onClick={() => setFormFilter("all")} className={`px-3 py-1 rounded-full text-xs font-medium border ${formFilter === "all" ? "bg-[#6366f1] text-white border-[#6366f1]" : "bg-transparent text-[#8a8b9a] border-[#3f3f46] hover:border-[#6366f1]"}`} aria-label={`Show all filings (${filings.length})`}>
All ({filings.length})
</button>
{uniqueFormTypes.map((form) => (
<button key={form} onClick={() => setFormFilter(form)} className={`px-3 py-1 rounded-full text-xs font-medium border ${formFilter === form ? "bg-[#6366f1] text-white border-[#6366f1]" : getFormColor(form)}`} aria-label={`Filter by ${form}, ${filings.filter((f) => f.form === form).length} filings`}>
{form} ({filings.filter((f) => f.form === form).length})
</button>
))}
</div>
)}
{/* Date Range Filter */}
<div className="flex flex-wrap gap-3">
<div>
<label className="block text-xs text-[#8a8b9a] mb-1">From Date</label>
<input type="date" value={dateRange.from ?? ""} onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs text-[#8a8b9a] mb-1">To Date</label>
<input type="date" value={dateRange.to ?? ""} onChange={(e) => 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" />
</div>
{(dateRange.from || dateRange.to) && (
<button onClick={() => setDateRange({})} className="px-3 py-1.5 text-xs text-[#8a8b9a] hover:text-[#e6e7ec]" aria-label="Clear date range filter">Clear</button>
)}
</div>
{/* Summarize Button */}
{filings.length > 0 && (
<button
onClick={() => loadSummary(cikInput)}
disabled={summaryLoading}
className="px-4 py-2 bg-[#064e3b] text-[#6ee7b7] rounded-lg text-sm font-medium border border-[#065f46] disabled:opacity-50 hover:bg-[#065f46] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff]"
aria-label={summaryLoading ? "Summarizing filings" : "Summarize filings"}
>
{summaryLoading ? "Summarizing..." : "Summarize"}
</button>
)}
{/* Summary Display */}
{summary && (
<div className="p-3 bg-[#064e3b]/20 rounded-lg border border-[#065f46]" role="status" aria-label="Filing summary">
<p className="text-xs text-[#6ee7b7] whitespace-pre-wrap font-mono">{summary}</p>
</div>
)}
{/* Detail View: 13F Holdings */}
{detailView === "13f" && (
<div className="p-4 bg-[#4c1d95]/10 rounded-lg border border-[#5b21b6]">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-[#c4b5fd]">13F-HR Holdings</h3>
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs" aria-label="Close 13F holdings detail">Close</button>
</div>
{detailLoading && <p className="text-[#8a8b9a] text-xs">Loading holdings...</p>}
{detailError && <p className="text-[#f87171] text-xs">{detailError}</p>}
{detailData && "holdings" in detailData && (
<div className="overflow-x-auto">
<table className="w-full text-xs" role="table" aria-label="13F holdings table">
<thead>
<tr className="border-b border-[#5b21b6] text-[#8a8b9a]">
<th className="text-left py-1 px-2" 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>
</tr>
</thead>
<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 text-[#e6e7ec]">{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>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Detail View: Form 4 Transactions */}
{detailView === "form4" && (
<div className="p-4 bg-[#78350f]/10 rounded-lg border border-[#92400e]">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-[#fcd34d]">Form 4 Transactions</h3>
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs" aria-label="Close Form 4 transactions detail">Close</button>
</div>
{detailLoading && <p className="text-[#8a8b9a] text-xs">Loading transactions...</p>}
{detailError && <p className="text-[#f87171] text-xs">{detailError}</p>}
{detailData && "transactions" in detailData && (
<div className="overflow-x-auto">
<table className="w-full text-xs" role="table" aria-label="Form 4 transactions table">
<thead>
<tr className="border-b border-[#92400e] text-[#8a8b9a]">
<th className="text-left py-1 px-2" scope="col">Reporter</th>
<th className="text-left py-1 px-2" scope="col">Relationship</th>
<th className="text-left py-1 px-2" scope="col">Security</th>
<th className="text-left py-1 px-2" scope="col">Date</th>
<th className="text-left py-1 px-2" scope="col">Code</th>
<th className="text-right py-1 px-2" scope="col">Shares</th>
<th className="text-right py-1 px-2" scope="col">Price</th>
</tr>
</thead>
<tbody>
{((detailData as Form4Result).transactions ?? []).map((tx, i) => (
<tr key={i} className="border-b border-[#92400e]/30">
<td className="py-1 px-2 text-[#e6e7ec]">{tx.reporter}</td>
<td className="py-1 px-2 text-[#8a8b9a]">{tx.relationship}</td>
<td className="py-1 px-2 text-[#e6e7ec]">{tx.securityTitle}</td>
<td className="py-1 px-2 text-[#8a8b9a]">{tx.transactionDate}</td>
<td className="py-1 px-2 font-mono text-[#fcd34d]">{tx.transactionCode}</td>
<td className="py-1 px-2 text-right font-mono text-[#e6e7ec]">{tx.shares.toLocaleString()}</td>
<td className="py-1 px-2 text-right font-mono text-[#e6e7ec]">${tx.price.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Educational Disclaimer */}
<div className="p-3 bg-[#1e1e2e] rounded-lg border border-[#3f3f46]" role="note">
<p className="text-xs text-[#8a8b9a]">
<strong className="text-[#e6e7ec]">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,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<string, number> = {
mixed: 3,
increasing: 2,
reducing: 2,
flat: 1,
};
const DELTA_BADGE: Record<string, { label: string; color: string }> = {
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<SortField>('convictionDelta');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [filter, setFilter] = useState<string>('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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<header className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-lg font-bold text-[#e6e7ec]">Institutional Dashboard</h2>
<div className="flex items-center gap-2">
<label className="text-xs text-[#8a8b9a]">Filter:</label>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="text-xs bg-[#1e1f2a] border border-[#2a2b3a] text-[#e6e7ec] rounded px-2 py-1"
>
<option value="all">All</option>
<option value="mixed">Mixed</option>
<option value="increasing">Increasing</option>
<option value="reducing">Reducing</option>
<option value="flat">Flat</option>
</select>
</div>
</header>
{isLoading ? (
<p className="text-sm text-[#5a5b6a] py-8">Loading rollup...</p>
) : error ? (
<p className="text-sm text-[#ef4444] py-8">Error loading rollup data.</p>
) : rows.length === 0 ? (
<p className="text-sm text-[#5a5b6a] py-8">No symbols in watchlist or portfolio.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2b3a] text-[#8a8b9a]">
<th className="text-left py-2 px-3">Symbol</th>
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('convictionDelta')}>
Conviction Δ {sortField === 'convictionDelta' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('insiderRecencyDays')}>
Insider Recency {sortField === 'insiderRecencyDays' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('classRollFlag')}>
Class Roll {sortField === 'classRollFlag' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th className="text-left py-2 px-3">Alert</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.symbol} className="border-b border-[#1e1f2a] hover:bg-[#1a1b24] transition-colors">
<td className="py-2 px-3 font-mono text-[#e6e7ec]">{row.symbol}</td>
<td className="py-2 px-3">
{(() => {
const badge = DELTA_BADGE[row.convictionDelta];
return (
<span className={`text-xs uppercase tracking-wider border rounded px-2 py-0.5 ${badge.color}`}>
{badge.label}
</span>
);
})()}
</td>
<td className="py-2 px-3 text-[#8a8b9a]">
{row.insiderRecencyDays !== null ? `${row.insiderRecencyDays}d` : '—'}
</td>
<td className="py-2 px-3">
{row.classRollFlag ? (
<span className="text-[#fcd34d] text-xs">Yes</span>
) : (
<span className="text-[#5a5b6a]">—</span>
)}
</td>
<td className="py-2 px-3">
{/* AlertEngine stub (slice 17) — wire later. */}
<span className="text-[#5a5b6a] text-xs">—</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* LLM summary — rendered server-side via tRPC query */}
{rollupRows && rollupRows.length > 0 && (
<div className="mt-4 p-3 bg-[#1a1b24] border border-[#2a2b3a] rounded">
<p className="text-xs text-[#8a8b9a]">
Summary will be provided via the rollup query.
</p>
</div>
)}
</section>
);
}
+453
View File
@@ -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 (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Implied Volatility Rank</h3>
<div className="h-4 bg-[#1e1f2e] rounded-full overflow-hidden">
<div className="h-full bg-[#3a3b4a] w-full" />
</div>
<p className="text-xs text-[#5a5b6a]">No IV data available</p>
</div>
);
}
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 (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Implied Volatility Rank</h3>
<div className="flex justify-between text-xs text-[#8a8b9a]">
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100</span>
</div>
<div className="h-4 bg-[#1e1f2e] rounded-full overflow-hidden relative">
<div
className={`h-full ${getColor(data.rank)} transition-all duration-500`}
style={{ width: `${data.rank}%` }}
/>
<div
className="absolute top-0 h-4 w-0.5 bg-white opacity-60"
style={{ left: `${data.rank}%` }}
/>
</div>
<p className="text-xs text-[#8a8b9a]">
Rank: {data.rank} | Current IV: {(data.currentIV * 100).toFixed(1)}%
(52w: {(data.iv52wLow * 100).toFixed(1)}%–{(data.iv52wHigh * 100).toFixed(1)}%)
</p>
<p className="text-xs text-[#5a5b6a] italic">{getLabel(data.rank)}</p>
</div>
);
}
/** Greeks display card. */
function GreeksDisplay({ greeks }: { greeks: OptionGreeks | null }) {
if (!greeks) {
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Greeks</h3>
<p className="text-xs text-[#5a5b6a]">No greeks data available</p>
</div>
);
}
const GreekRow = ({ label, value, description }: { label: string; value: number | null; description: string }) => (
<div className="flex justify-between items-center py-1">
<div>
<span className="text-sm font-medium text-[#e6e7ec]">{label}</span>
<span className="text-xs text-[#5a5b6a] ml-2">{description}</span>
</div>
<span className={`text-sm font-mono ${value === null ? "text-[#5a5b6a]" : "text-[#e6e7ec]"}`}>
{value === null ? "—" : value.toFixed(4)}
</span>
</div>
);
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Greeks</h3>
<GreekRow label="Delta" value={greeks.delta} description="Sensitivity to stock price" />
<GreekRow label="Gamma" value={greeks.gamma} description="Rate of change of delta" />
<GreekRow label="Theta" value={greeks.theta} description="Time decay per day" />
<GreekRow label="Vega" value={greeks.vega} description="Sensitivity to IV (per 1%)" />
</div>
);
}
/** OI Walls visualization — simple bar chart of open interest by strike. */
function OIWalls({ walls }: { walls: OIWall[] }) {
if (walls.length === 0) {
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Open Interest Walls</h3>
<p className="text-xs text-[#5a5b6a]">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");
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">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-[#8a8b9a] mb-1">
{(wall.openInterest / 1000).toFixed(0)}k
</span>
<div
className={`w-full rounded-t ${wall.right === "call" ? "bg-[#3b82f6]" : "bg-[#ef4444]"}`}
style={{ height: `${(wall.openInterest / maxOI) * 80}px`, minHeight: 2 }}
/>
<span className="text-[10px] text-[#5a5b6a] mt-1">${wall.strike}</span>
</div>
))}
</div>
<div className="flex gap-4 text-xs">
<span className="text-[#3b82f6]">● Calls</span>
<span className="text-[#ef4444]">● Puts</span>
</div>
</div>
);
}
/** Max Pain indicator. */
function MaxPainDisplay({ rows }: { rows: OptionChainRow[] }) {
const maxPain = useMemo(() => computeMaxPain(rows), [rows]);
if (maxPain === 0) {
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Max Pain</h3>
<p className="text-xs text-[#5a5b6a]">Insufficient data</p>
</div>
);
}
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Max Pain</h3>
<p className="text-2xl font-bold text-[#eab308]">${maxPain}</p>
<p className="text-xs text-[#5a5b6a]">
The strike where options expire with maximum total value (theory)
</p>
</div>
);
}
/** Payoff diagram — teaches the convex mechanic via visual. */
function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; currentStockPrice: number }) {
if (rows.length === 0) {
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Payoff Diagram</h3>
<p className="text-xs text-[#5a5b6a]">No chain data to render</p>
</div>
);
}
// 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 (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[#e6e7ec]">Payoff Diagram</h3>
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} className="w-full h-auto bg-[#0a0b0f] rounded border border-[#2a2b3a]">
{/* Grid lines */}
<line x1={padding.left} y1={zeroY} x2={svgWidth - padding.right} y2={zeroY}
stroke="#3a3b4a" strokeWidth="1" strokeDasharray="4" />
<line x1={currentX} y1={padding.top} x2={currentX} y2={svgHeight - padding.bottom}
stroke="#5a5b6a" strokeWidth="1" strokeDasharray="2" />
{/* Payoff line */}
<path d={pathD} fill="none" stroke={atmRow.right === "call" ? "#3b82f6" : "#ef4444"} strokeWidth="2" />
{/* Zero PnL label */}
<text x={padding.left + 5} y={zeroY - 5} fill="#5a5b6a" fontSize="10">PnL $0</text>
{/* Current stock price label */}
<text x={currentX + 5} y={padding.top + 12} fill="#8a8b9a" fontSize="10">
${currentStockPrice.toFixed(0)}
</text>
{/* Strike label */}
<text x={xScale(atmRow.strike) - 15} y={svgHeight - 5} fill="#8a8b9a" fontSize="10">
K=${atmRow.strike}
</text>
{/* Teaching label */}
<text x={svgWidth / 2 - 60} y={svgHeight - 5} fill="#5a5b6a" fontSize="10">
Stock price at expiration →
</text>
</svg>
<p className="text-xs text-[#5a5b6a] italic">
Shows P&amp;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>
</div>
);
}
/** Defined-risk stamp for spreads. */
function RiskStamp({ isSpread }: { isSpread: boolean }) {
if (isSpread) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#052e1a] border border-[#166534]">
<span className="text-green-400 text-lg">✓</span>
<div>
<p className="text-sm font-semibold text-green-400">Defined-Risk Spread</p>
<p className="text-xs text-green-300/70">Max loss is known upfront</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#4c1d1d] border border-[#991b1b]">
<span className="text-red-400 text-lg">⚠</span>
<div>
<p className="text-sm font-semibold text-red-400">Advanced Only</p>
<p className="text-xs text-red-300/70">Undefined risk — potential for significant losses</p>
</div>
</div>
);
}
// ─── 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<string | null>(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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
<div className="flex items-center gap-2 text-[#5a5b6a] text-sm">
<div className="w-4 h-4 border-2 border-[#5a5b6a] border-t-transparent rounded-full animate-spin" />
Loading options data...
</div>
</section>
);
}
if (error) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
<p className="text-sm text-red-400">Error: {error}</p>
</section>
);
}
if (!chainData || chainData.rows.length === 0) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
<p className="text-sm text-[#5a5b6a]">No options data available for {symbol}</p>
</section>
);
}
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] 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-[#e6e7ec]">Options Due Diligence</h2>
{chainData.expiration && (
<span className="text-xs text-[#8a8b9a] bg-[#1e1f2e] px-2 py-1 rounded">
Exp: {chainData.expiration}
</span>
)}
</div>
{/* Risk Stamp */}
<RiskStamp isSpread={isSpread} />
{/* IV Rank Bar */}
<IVRankBar data={ivRankData} />
{/* Greeks Display */}
<GreeksDisplay greeks={greeksData?.greeks ?? null} />
{/* OI Walls */}
<OIWalls walls={walls} />
{/* Max Pain */}
<MaxPainDisplay rows={chainData.rows} />
{/* Payoff Diagram */}
<PayoffDiagram rows={chainData.rows} currentStockPrice={atmRow?.strike ?? 0} />
{/* Educational footer */}
<div className="pt-4 border-t border-[#2a2b3a]">
<p className="text-xs text-[#5a5b6a]">
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>
);
}
+3 -2
View File
@@ -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<Snapshot | null>(null);
@@ -64,7 +65,7 @@ export function OverviewPanel() {
const spark = (candles ?? []).map((c, i) => ({ i, c: c.c }));
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<header className="flex items-baseline justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[#e6e7ec]">{symbol}</h2>
+87
View File
@@ -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<PortfolioHolding[]>([]);
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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] 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-[#e6e7ec]">{UI_STRINGS.portfolioTitle || "Portfolio"}</h2>
<div className="grid grid-cols-3 gap-2">
<input value={draftSymbol} onChange={(e) => 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]" />
<input value={draftShares} onChange={(e) => 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]" />
<input value={draftCost} onChange={(e) => 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]" />
</div>
<button onClick={add}
className="w-full px-3 py-1.5 rounded-lg text-sm bg-[#4f8cff] text-white">Add Holding</button>
{loading ? (
<p className="text-sm text-[#5a5b6a]">Loading...</p>
) : holdings.length === 0 ? (
<p className="text-sm text-[#5a5b6a]">{UI_STRINGS.emptyPortfolio || "No holdings yet."}</p>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
<div className="flex justify-between text-xs text-[#5a5b6a] pb-1 border-b border-[#2a2b3a]">
<span>Symbol</span>
<span>Shares</span>
<span>Avg Cost</span>
<span>Value</span>
<span></span>
</div>
{holdings.map((h) => (
<div key={h.symbol} className="flex items-center justify-between py-1 text-sm rounded hover:bg-[#1a1b24] transition-colors">
<span className="font-mono w-16 text-[#e6e7ec]">{h.symbol}</span>
<span className="w-16 text-right text-[#8a8b9a]">{h.shares}</span>
<span className="w-20 text-right text-[#8a8b9a]">${h.avg_cost.toFixed(2)}</span>
<span className="w-20 text-right text-[#e6e7ec]">${(h.shares * h.avg_cost).toFixed(0)}</span>
<button onClick={() => remove(h.symbol)}
className="text-xs text-[#5a5b6a] hover:text-[#ef4444]">Remove</button>
</div>
))}
<div className="flex justify-between pt-2 border-t border-[#2a2b3a] text-sm font-semibold text-[#e6e7ec]">
<span>Total</span>
<span>${totalValue.toFixed(0)}</span>
</div>
</div>
)}
<p className="text-[10px] text-[#5a5b6a] italic">{UI_STRINGS.primaryRuleFooter}</p>
</section>
);
}
+77
View File
@@ -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<WatchlistEntry[]>([]);
const [draft, setDraft] = useState("");
const [activeSymbol, setActiveSymbol] = useState<string | null>(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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] 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-[#e6e7ec]">{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-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" />
<button type="submit" className="px-3 py-1.5 rounded text-sm bg-[#4f8cff] text-white">Add</button>
</form>
{loading ? (
<p className="text-sm text-[#5a5b6a]">Loading...</p>
) : entries.length === 0 ? (
<p className="text-sm text-[#5a5b6a]">{UI_STRINGS.emptyWatchlist || "Your watchlist is empty."}</p>
) : (
<div className="space-y-1 max-h-60 overflow-y-auto">
{entries.map((e) => (
<div key={e.symbol}
className={`flex items-center justify-between py-1 px-2 rounded transition-colors ${
isActive(e.symbol)
? "bg-[#1a1b24] border-l-2 border-l-[#4f8cff]"
: "hover:bg-[#1a1b24]"
}`}
>
<span className="font-mono text-sm text-[#e6e7ec]">{e.symbol}</span>
<div className="flex items-center gap-2">
<button
onClick={() => setActiveSymbol(isActive(e.symbol) ? null : e.symbol)}
className="text-xs text-[#5a5b6a] hover:text-[#4f8cff]"
>
{isActive(e.symbol) ? "Deselect" : "View"}
</button>
<button onClick={() => remove(e.symbol)}
className="text-xs text-[#5a5b6a] hover:text-[#ef4444]">Remove</button>
</div>
</div>
))}
</div>
)}
<p className="text-[10px] text-[#5a5b6a] italic">{UI_STRINGS.primaryRuleFooter}</p>
</section>
);
}