fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix
- Add await ctx.cache.subscribe() to addSymbol mutation so symbols added via the sidebar get registered in symbol_demand and yfinance jobs are queued immediately - Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue - Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false to quoteSummary() to handle Yahoo schema drift - Add error detail logging for analyst ratings schema failures - Update .gitignore with common ignores
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SidebarNav } from "./SidebarNav";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
|
||||
/**
|
||||
* Admin layout — sidebar nav + auth guard.
|
||||
* Wraps all /admin/* pages with the same SidebarNav used elsewhere but without
|
||||
* SymbolHeader/WatchlistSidebar (not relevant for operator tooling).
|
||||
*/
|
||||
|
||||
export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.auth.me()
|
||||
.then((u) => { if (!cancelled) setUser(u); })
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-fg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !user.userId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-fg flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-lg font-medium text-error">Authentication required</p>
|
||||
<p className="text-sm text-fg-muted">Please sign in to access the admin console.</p>
|
||||
<a href="/settings" className="inline-block rounded-md border border-accent bg-accent text-fg px-4 py-2 text-sm font-medium hover:bg-accent/80 transition-colors">
|
||||
Go to Sign In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell user={user}>
|
||||
{children}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inner layout with sidebar — separates auth check from rendering. */
|
||||
function AdminShell({ user, children }: { user: AuthUser; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-fg flex">
|
||||
{/* Left Sidebar Navigation */}
|
||||
<SidebarNav />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Admin Header */}
|
||||
<header className="border-b border-line-strong pb-4 mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-fg flex items-center gap-2">
|
||||
<span className="text-lg">🛠️</span> Admin Console
|
||||
</h1>
|
||||
<div className="text-xs text-fg-faint">
|
||||
Logged in as {user.email ?? user.userId}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { api } from '@/lib/trpc';
|
||||
|
||||
interface Props {
|
||||
symbol: string | null;
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
const m = d.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return d;
|
||||
return `${m[2]}/${m[3]}/${m[1]}`;
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<string, string> = {
|
||||
up: 'text-up border-[#064e3b]',
|
||||
down: 'text-down border-[#7f1d1d]',
|
||||
main: 'text-fg-muted border-line',
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
up: 'Upgrade',
|
||||
down: 'Downgrade',
|
||||
main: 'Reiterated',
|
||||
reit: 'Reiterated',
|
||||
init: 'Initiated',
|
||||
'': '—',
|
||||
};
|
||||
|
||||
const GRADE_COLORS: Record<string, string> = {
|
||||
'Strong Buy': 'bg-green-900/40 text-green-400 border-green-700',
|
||||
'Buy': 'bg-green-950/30 text-green-400 border-green-800',
|
||||
'Overweight': 'bg-green-950/30 text-green-400 border-green-800',
|
||||
'Outperform': 'bg-green-950/30 text-green-400 border-green-800',
|
||||
'Neutral': 'bg-yellow-950/30 text-yellow-400 border-yellow-800',
|
||||
'Hold': 'bg-yellow-950/30 text-yellow-400 border-yellow-800',
|
||||
'Equal-Weight': 'bg-yellow-950/30 text-yellow-400 border-yellow-800',
|
||||
'Market Perform': 'bg-yellow-950/30 text-yellow-400 border-yellow-800',
|
||||
'Underperform': 'bg-red-950/30 text-red-400 border-red-800',
|
||||
'Sell': 'bg-red-900/40 text-red-400 border-red-700',
|
||||
'Strong Sell': 'bg-red-900/40 text-red-400 border-red-700',
|
||||
'Underweight': 'bg-red-950/30 text-red-400 border-red-800',
|
||||
};
|
||||
|
||||
function gradeBadge(grade: string | null): string {
|
||||
if (!grade) return 'bg-gray-900/40 text-fg-muted border-line';
|
||||
return GRADE_COLORS[grade] ?? 'bg-gray-900/40 text-fg-muted border-line';
|
||||
}
|
||||
|
||||
export function AnalystRatings({ symbol }: Props) {
|
||||
const [ratings, setRatings] = useState<Array<{ firm: string; action: string | null; gradeFrom: string | null; gradeTo: string | null; ratingDate: string }> | null>(null);
|
||||
const [consensus, setConsensus] = useState<{ strongBuy: number; buy: number; hold: number; sell: number; strongSell: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.institutional.analystRatings(symbol).then((res) => {
|
||||
if (res.error) { setError(res.error); setRatings(null); }
|
||||
else { setRatings(res.ratings); setConsensus(res.consensus); }
|
||||
}).catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [symbol]);
|
||||
|
||||
const topFirms = useMemo(() => {
|
||||
if (!ratings) return [];
|
||||
const seen = new Set<string>();
|
||||
return ratings.filter(r => {
|
||||
if (seen.size >= 4) return false;
|
||||
if (!seen.has(r.firm)) { seen.add(r.firm); return true; }
|
||||
return false;
|
||||
}).map(r => r.firm);
|
||||
}, [ratings]);
|
||||
|
||||
if (!symbol) return null;
|
||||
if (loading) return <div className="text-xs text-fg-muted py-2">Loading analyst ratings...</div>;
|
||||
if (error) return <div className="text-xs text-danger py-2">Analyst ratings: {error}</div>;
|
||||
if (!ratings || ratings.length === 0) return <div className="text-xs text-fg-muted py-2">No analyst ratings available for {symbol}.</div>;
|
||||
|
||||
const consensusText = consensus
|
||||
? [
|
||||
...(consensus.strongBuy > 0 ? [`${consensus.strongBuy} Strong Buy`] : []),
|
||||
...(consensus.buy > 0 ? [`${consensus.buy} Buy`] : []),
|
||||
...(consensus.hold > 0 ? [`${consensus.hold} Hold`] : []),
|
||||
...(consensus.sell > 0 ? [`${consensus.sell} Sell`] : []),
|
||||
...(consensus.strongSell > 0 ? [`${consensus.strongSell} Strong Sell`] : []),
|
||||
].join(', ')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-fg" title="Analyst upgrades/downgrades and consensus ratings from Yahoo Finance">Analyst Ratings</h4>
|
||||
{consensusText && (
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
<strong className="text-fg">Consensus: </strong>
|
||||
{consensusText}{' '}
|
||||
with {ratings.length} analyst action{ratings.length !== 1 ? 's' : ''}
|
||||
{topFirms.length > 0 && (
|
||||
<> from firms like {topFirms.join(', ')}</>
|
||||
)}.
|
||||
</p>
|
||||
)}
|
||||
<div className="overflow-x-auto max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-fg-muted">
|
||||
<th className="text-left py-1.5 px-2">Date</th>
|
||||
<th className="text-left py-1.5 px-2">Firm</th>
|
||||
<th className="text-left py-1.5 px-2">Action</th>
|
||||
<th className="text-left py-1.5 px-2">From</th>
|
||||
<th className="text-left py-1.5 px-2">To</th>
|
||||
<th className="text-right py-1.5 px-2">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ratings.slice(0, 50).map((r, i) => (
|
||||
<tr key={`${r.firm}-${r.ratingDate}-${i}`} className="border-b border-line">
|
||||
<td className="py-1.5 px-2 text-fg-muted whitespace-nowrap">{formatDate(r.ratingDate)}</td>
|
||||
<td className="py-1.5 px-2 text-fg whitespace-nowrap">{r.firm}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{r.action && (
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${ACTION_COLOR[r.action] ?? 'text-fg-muted border-line'}`}>
|
||||
{ACTION_LABEL[r.action] ?? r.action}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${gradeBadge(r.gradeFrom)}`}>
|
||||
{r.gradeFrom ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${gradeBadge(r.gradeTo)}`}>
|
||||
{r.gradeTo ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg whitespace-nowrap">
|
||||
{r.targetTo ? `$${r.targetTo}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
Analyst ratings from Yahoo Finance. Not investment advice.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type PriceCandle, type IndicatorResult } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Defs,
|
||||
Legend,
|
||||
ComposedChart,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -22,17 +21,25 @@ import {
|
||||
// relative volume for the active symbol. Educational display only — no trade signals,
|
||||
// no directional language, no buy/sell arrows.
|
||||
|
||||
const EMA_COLORS: Record<number, string> = {
|
||||
9: "#f472b6",
|
||||
21: "#a78bfa",
|
||||
50: "#60a5fa",
|
||||
200: "#34d399",
|
||||
};
|
||||
interface RangeOption {
|
||||
label: string;
|
||||
years: number | null; // null = ALL
|
||||
}
|
||||
const RANGE_OPTIONS: RangeOption[] = [
|
||||
{ label: "3M", years: 0.25 },
|
||||
{ label: "6M", years: 0.5 },
|
||||
{ label: "1Y", years: 1 },
|
||||
{ label: "5Y", years: 5 },
|
||||
{ label: "ALL", years: null },
|
||||
];
|
||||
|
||||
const DAY_COLORS = {
|
||||
up: "#34d399",
|
||||
down: "#f87171",
|
||||
};
|
||||
// Convert a range (in years) to a number of candles for the active timeframe,
|
||||
// so the Range labels stay meaningful across Daily / Weekly / Monthly.
|
||||
function barsForRange(years: number | null, timeframe: '1d' | '1wk' | '1mo'): number {
|
||||
if (years === null) return Infinity;
|
||||
const perYear = timeframe === '1mo' ? 12 : timeframe === '1wk' ? 52 : 252;
|
||||
return Math.round(years * perYear);
|
||||
}
|
||||
|
||||
function formatCandleDate(ts: string): string {
|
||||
try {
|
||||
@@ -43,6 +50,18 @@ function formatCandleDate(ts: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function quarterKey(d: string): string {
|
||||
const m = parseInt(d.slice(5, 7), 10);
|
||||
return `${d.slice(0, 4)}-Q${Math.ceil(m / 3)}`;
|
||||
}
|
||||
|
||||
function formatShares(value: number): string {
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${Math.round(value / 1_000)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
date: string;
|
||||
ts: string;
|
||||
@@ -51,90 +70,122 @@ interface ChartDataPoint {
|
||||
l: number;
|
||||
c: number;
|
||||
v: number;
|
||||
instVol: number;
|
||||
ema9?: number;
|
||||
ema21?: number;
|
||||
ema50?: number;
|
||||
ema200?: number;
|
||||
rsi?: number;
|
||||
relvol?: number;
|
||||
macdLine?: number;
|
||||
signalLine?: number;
|
||||
histogram?: number;
|
||||
}
|
||||
|
||||
interface BuyEvent {
|
||||
filerName: string | null;
|
||||
sharesAdded: number;
|
||||
estimatedPrice: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
type SeriesKey = 'close' | 'ema9' | 'ema21' | 'ema50' | 'ema200' | 'instBuys';
|
||||
|
||||
const SERIES_LABELS: Record<SeriesKey, string> = {
|
||||
close: 'Close',
|
||||
ema9: 'EMA 9',
|
||||
ema21: 'EMA 21',
|
||||
ema50: 'EMA 50',
|
||||
ema200: 'EMA 200',
|
||||
instBuys: 'Institutional Buys',
|
||||
};
|
||||
|
||||
const SERIES_COLORS: Record<SeriesKey, string> = {
|
||||
close: CHART.accent,
|
||||
ema9: CHART.emaColors[9],
|
||||
ema21: CHART.emaColors[21],
|
||||
ema50: CHART.emaColors[50],
|
||||
ema200: CHART.emaColors[200],
|
||||
instBuys: '#34d399',
|
||||
};
|
||||
|
||||
|
||||
export function ChartLabPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [timeframe, setTimeframe] = useState<'1d' | '1wk' | '1mo'>('1d');
|
||||
const [candles, setCandles] = useState<PriceCandle[]>([]);
|
||||
const [indicators, setIndicators] = useState<IndicatorResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buyEvents, setBuyEvents] = useState<BuyEvent[] | null>(null);
|
||||
const [visibleSeries, setVisibleSeries] = useState<Record<SeriesKey, boolean>>({
|
||||
close: true, ema9: true, ema21: true, ema50: true, ema200: true, instBuys: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const loadCandles = api.market.candles(symbol).catch((e) => {
|
||||
const loadCandles = api.market.candles(symbol, timeframe).catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
|
||||
return null;
|
||||
});
|
||||
const loadIndicators = api.market.indicators(symbol, {
|
||||
ema: [9, 21, 50, 200],
|
||||
rsi: { period: 14 },
|
||||
relvol: { period: 20 },
|
||||
periods: { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 },
|
||||
timeframe,
|
||||
}).catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
|
||||
return null;
|
||||
});
|
||||
const loadBuyEvents = api.institutional.buyEvents(symbol).catch(() => null);
|
||||
|
||||
Promise.all([loadCandles, loadIndicators]).then(([cData, iData]) => {
|
||||
Promise.all([loadCandles, loadIndicators, loadBuyEvents]).then(([cData, iData, bData]) => {
|
||||
if (!cancelled) {
|
||||
// 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);
|
||||
setBuyEvents(bData?.events ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.loadingSnapshot}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<p className="text-xs text-[#ef4444] py-8">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
// Build quarterly institutional buy volume lookup from buy events (13F = quarterly)
|
||||
const buyVolByQuarter = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!buyEvents) return map;
|
||||
for (const e of buyEvents) {
|
||||
const q = quarterKey(e.date);
|
||||
map.set(q, (map.get(q) ?? 0) + e.sharesAdded);
|
||||
}
|
||||
return map;
|
||||
}, [buyEvents]);
|
||||
|
||||
// Build chart data by merging candles with indicator overlays.
|
||||
const rsiSeries = indicators?.rsi ?? [];
|
||||
const relvolSeries = indicators?.relvol ?? [];
|
||||
const emaMap: Record<number, Map<string, number>> = {};
|
||||
// Backend returns positionally-aligned arrays indexed by candle order.
|
||||
const rsiSeries: Array<number | null> = indicators?.rsi ?? [];
|
||||
const relvolSeries: Array<number | null> = indicators?.relvol ?? [];
|
||||
const emaArrays: Record<number, Array<number | null>> = {};
|
||||
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) {
|
||||
if (!e || !e.ts || e.value === undefined) continue;
|
||||
m.set(e.ts, e.value);
|
||||
}
|
||||
emaMap[Number(period)] = m;
|
||||
emaArrays[Number(period)] = entries.map((v) => v ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
const chartData: ChartDataPoint[] = candles
|
||||
.filter((c): c is PriceCandle => c != null && typeof c === 'object' && 'ts' in c)
|
||||
.map((c) => {
|
||||
const d: ChartDataPoint = {
|
||||
const macdLineSeries: Array<number | null> = indicators?.macd?.macdLine?.map((v) => v ?? null) ?? [];
|
||||
const signalLineSeries: Array<number | null> = indicators?.macd?.signalLine?.map((v) => v ?? null) ?? [];
|
||||
const histogramSeries: Array<number | null> = indicators?.macd?.histogram?.map((v) => v ?? null) ?? [];
|
||||
|
||||
const rawCandles: PriceCandle[] = candles.filter(
|
||||
(c): c is PriceCandle => c != null && typeof c === 'object' && 'ts' in c,
|
||||
);
|
||||
|
||||
const chartData: ChartDataPoint[] = useMemo(() => {
|
||||
return rawCandles.map((c, i) => {
|
||||
const d: Record<string, any> = {
|
||||
date: formatCandleDate(c.ts),
|
||||
ts: c.ts,
|
||||
o: c.o,
|
||||
@@ -142,121 +193,159 @@ export function ChartLabPanel() {
|
||||
l: c.l,
|
||||
c: c.c,
|
||||
v: c.v,
|
||||
instVol: buyVolByQuarter.get(quarterKey(c.ts)) ?? 0,
|
||||
};
|
||||
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;
|
||||
const rsiVal = rsiSeries[i];
|
||||
if (rsiVal !== undefined && rsiVal !== null) d.rsi = rsiVal;
|
||||
const rvVal = relvolSeries[i];
|
||||
if (rvVal !== undefined && rvVal !== null) d.relvol = rvVal;
|
||||
const macdVal = macdLineSeries[i];
|
||||
if (macdVal !== undefined && macdVal !== null) d.macdLine = macdVal;
|
||||
const signalVal = signalLineSeries[i];
|
||||
if (signalVal !== undefined && signalVal !== null) d.signalLine = signalVal;
|
||||
const histVal = histogramSeries[i];
|
||||
if (histVal !== undefined && histVal !== null) d.histogram = histVal;
|
||||
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;
|
||||
const emaEntry = emaArrays[period]?.[i];
|
||||
if (emaEntry !== undefined && emaEntry !== null) d[`ema${period}`] = emaEntry;
|
||||
}
|
||||
return d;
|
||||
return d as ChartDataPoint;
|
||||
});
|
||||
}, [rawCandles, rsiSeries, relvolSeries, macdLineSeries, signalLineSeries, histogramSeries, emaArrays, buyVolByQuarter]);
|
||||
|
||||
const upColor = DAY_COLORS.up;
|
||||
const downColor = DAY_COLORS.down;
|
||||
function formatVolume(value: number): string {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${Math.round(value / 1_000)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
// Custom shape for OHLC candlestick rendering: wick (high→low line) + body (open↔close rectangle).
|
||||
const CandleShape = (props: any) => {
|
||||
const { x, y, width, height, payload } = props;
|
||||
if (!payload) return null;
|
||||
const isUp = payload.c >= payload.o;
|
||||
const fill = isUp ? upColor : downColor;
|
||||
const bodyTop = Math.min(payload.o, payload.c);
|
||||
const bodyBottom = Math.max(payload.o, payload.c);
|
||||
const bodyHeight = Math.max(bodyBottom - bodyTop, 1); // minimum 1px body
|
||||
const wickTop = payload.h;
|
||||
const wickBottom = payload.l;
|
||||
// Range selector — full indicator computation stays on the complete series (EMA-200 needs >=200 bars);
|
||||
// only the displayed chart slice is trimmed. Defaults to 1Y.
|
||||
const [range, setRange] = useState<string>("1Y");
|
||||
const displayData: ChartDataPoint[] = useMemo(() => {
|
||||
if (!chartData.length) return [];
|
||||
const option = RANGE_OPTIONS.find((o) => o.label === range);
|
||||
const maxBars = barsForRange(option?.years ?? null, timeframe);
|
||||
return chartData.slice(-maxBars);
|
||||
}, [chartData, range, timeframe]);
|
||||
|
||||
const tooltipFormatter = useCallback((value: any, name: string) => {
|
||||
if (!name || name === "date") return [value, ''];
|
||||
if (name === "c") return [`$${Number(value)?.toFixed(2)}`, "Close"];
|
||||
if (name === "instVol") return [formatShares(Number(value)), "Institutional Buys"];
|
||||
if (name.startsWith("ema")) {
|
||||
const period = name.replace("ema", "");
|
||||
return [`$${Number(value)?.toFixed(2)}`, `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`];
|
||||
}
|
||||
return [value, name];
|
||||
}, []);
|
||||
|
||||
// Price chart — candlesticks rendered as raw SVG inside <Customized> for correct pixel positioning,
|
||||
// with EMA lines overlaid via recharts <Line>. Display window trimmed by the range selector.
|
||||
const { minP: priceMin, maxP: priceMax } = useMemo(() => {
|
||||
if (!displayData.length) return { minP: 0, maxP: 1 };
|
||||
let mn = Infinity, mx = -Infinity;
|
||||
for (const d of displayData) {
|
||||
if (d.h > mx) mx = d.h;
|
||||
if (d.l < mn) mn = d.l;
|
||||
if (d.ema200 !== undefined && d.ema200 > mx) mx = d.ema200;
|
||||
}
|
||||
const pad = (mx - mn) * 0.1 || 1;
|
||||
return { minP: mn - pad, maxP: mx + pad };
|
||||
}, [displayData]);
|
||||
|
||||
const maxInstVol = useMemo(() => {
|
||||
let mx = 0;
|
||||
for (const d of displayData) {
|
||||
if (d.instVol > mx) mx = d.instVol;
|
||||
}
|
||||
return mx || 1;
|
||||
}, [displayData]);
|
||||
|
||||
const upColor = CHART.up;
|
||||
const downColor = CHART.down;
|
||||
|
||||
// Volume bar color helper — used inline in the chart below.
|
||||
const volumeFillColor = (entry: ChartDataPoint) => (entry.c >= entry.o ? upColor : downColor);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<g>
|
||||
{/* Wick: vertical line from high to low */}
|
||||
<line
|
||||
x1={x + width / 2}
|
||||
y1={wickTop}
|
||||
x2={x + width / 2}
|
||||
y2={wickBottom}
|
||||
stroke={fill}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* Body: rectangle from open to close */}
|
||||
<rect
|
||||
x={x + 1}
|
||||
y={bodyTop}
|
||||
width={Math.max(width - 2, 1)}
|
||||
height={bodyHeight}
|
||||
fill={isUp ? fill : fill}
|
||||
stroke={fill}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
</g>
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.chartsTitle}</h2>
|
||||
<p className="text-sm text-fg-muted py-8">{UI_STRINGS.loadingSnapshot}</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.chartsTitle}</h2>
|
||||
<p className="text-xs text-danger py-8">{error}</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">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 space-y-6 transition-shadow duration-300">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.chartsTitle}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex rounded-md border border-line overflow-hidden text-xs">
|
||||
{(['1d', '1wk', '1mo'] as const).map((tf) => (
|
||||
<button key={tf} type="button" onClick={() => setTimeframe(tf)}
|
||||
className={`px-3 py-1 ${timeframe === tf ? "bg-accent text-white" : "text-fg-muted hover:text-fg"}`}>
|
||||
{tf === '1d' ? 'Daily' : tf === '1wk' ? 'Weekly' : 'Monthly'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-line overflow-hidden text-xs">
|
||||
{RANGE_OPTIONS.map((o) => (
|
||||
<button key={o.label} type="button" onClick={() => setRange(o.label)}
|
||||
className={`px-3 py-1 ${range === o.label ? "bg-accent text-white" : "text-fg-muted hover:text-fg"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price chart with EMA overlays */}
|
||||
{/* Price chart with EMA overlays + Institutional Buy Events */}
|
||||
<div>
|
||||
<figure>
|
||||
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.priceChartCaption}</p>
|
||||
<p className="text-xs text-fg-muted mb-2">{UI_STRINGS.priceChartCaption}</p>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" />
|
||||
<YAxis domain={["auto", "auto"]} tick={{ fontSize: 10, fill: "#5a5b6a" }} />
|
||||
<Tooltip
|
||||
<ComposedChart data={displayData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[priceMin, priceMax]} tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v: number) => `$${v.toFixed(2)}`} />
|
||||
<YAxis yAxisId="inst" orientation="right" domain={[0, maxInstVol]} hide />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
formatter={(value: any, name: string) => {
|
||||
if (name === "candle") return [`$${value?.toFixed(2)}`, UI_STRINGS.priceLabel];
|
||||
if (name.startsWith("ema")) {
|
||||
const period = name.replace("ema", "");
|
||||
return [`$${value?.toFixed(2)}`, `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
formatter={tooltipFormatter as any}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value) => {
|
||||
if (value === "close") return UI_STRINGS.priceLabel;
|
||||
const period = value.replace("ema", "");
|
||||
return `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="c"
|
||||
shape={<CandleShape />}
|
||||
name="candle"
|
||||
opacity={0.85}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="c"
|
||||
stroke="#4f8cff"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
name="close"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
{Object.entries(EMA_COLORS).map(([period, color]) => (
|
||||
<Line
|
||||
key={period}
|
||||
type="monotone"
|
||||
dataKey={`ema${period}`}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
name={`ema${period}`}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
{visibleSeries.close && <Line dataKey="c" name="close" stroke={SERIES_COLORS.close} strokeWidth={1.5} dot={false} />}
|
||||
{visibleSeries.ema9 && <Line type="monotone" dataKey="ema9" stroke={SERIES_COLORS.ema9} strokeWidth={1} dot={false} name="ema9" />}
|
||||
{visibleSeries.ema21 && <Line type="monotone" dataKey="ema21" stroke={SERIES_COLORS.ema21} strokeWidth={1} dot={false} name="ema21" />}
|
||||
{visibleSeries.ema50 && <Line type="monotone" dataKey="ema50" stroke={SERIES_COLORS.ema50} strokeWidth={1} dot={false} name="ema50" />}
|
||||
{visibleSeries.ema200 && <Line type="monotone" dataKey="ema200" stroke={SERIES_COLORS.ema200} strokeWidth={1} dot={false} name="ema200" />}
|
||||
{visibleSeries.instBuys && <Bar dataKey="instVol" yAxisId="inst" fill={SERIES_COLORS.instBuys} opacity={0.35} radius={[0, 0, 0, 0]} />}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
|
||||
{/* Custom toggleable legend */}
|
||||
<div className="flex flex-wrap gap-3 mt-2 justify-center">
|
||||
{(Object.keys(SERIES_LABELS) as SeriesKey[]).map((key) => (
|
||||
<button key={key} type="button" onClick={() => setVisibleSeries((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
className="flex items-center gap-1.5 text-xs transition-opacity"
|
||||
style={{ opacity: visibleSeries[key] ? 1 : 0.4 }}>
|
||||
<span className={`${key === 'instBuys' ? 'w-3 h-2.5 rounded' : 'w-3 h-0.5 rounded-sm'} inline-block`} style={{ backgroundColor: SERIES_COLORS[key] }} />
|
||||
{SERIES_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-fg-muted mt-1">
|
||||
{UI_STRINGS.emaTooltip}
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -265,25 +354,25 @@ export function ChartLabPanel() {
|
||||
{/* Volume sub-chart */}
|
||||
<div>
|
||||
<figure>
|
||||
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.volumeLabel}</p>
|
||||
<p className="text-xs text-fg-muted mb-2">{UI_STRINGS.volumeLabel}</p>
|
||||
<div className="h-28">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" hide />
|
||||
<YAxis tick={{ fontSize: 10, fill: "#5a5b6a" }} hide />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toLocaleString()}`, UI_STRINGS.volumeLabel]}
|
||||
<BarChart data={displayData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" hide />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={formatVolume} />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
formatter={((value: number) => [`${value.toLocaleString()}`, UI_STRINGS.volumeLabel]) as any}
|
||||
/>
|
||||
<Bar dataKey="v" radius={[2, 2, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.c >= entry.o ? upColor : downColor} opacity={0.5} />
|
||||
{displayData.map((entry, index) => (
|
||||
<Cell key={index} fill={volumeFillColor(entry)} opacity={0.5} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
|
||||
<figcaption className="text-[11px] text-fg-muted mt-1">
|
||||
{UI_STRINGS.volumeCaption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -292,28 +381,36 @@ export function ChartLabPanel() {
|
||||
{/* RSI sub-chart */}
|
||||
<div>
|
||||
<figure>
|
||||
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.rsiLabel}</p>
|
||||
<p className="text-xs text-fg-muted mb-2">RSI + MACD</p>
|
||||
<div className="h-32">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" hide />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: "#5a5b6a" }} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value?.toFixed(2)}`, UI_STRINGS.rsiLabel]}
|
||||
<ComposedChart data={displayData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" hide />
|
||||
<YAxis yAxisId="rsi" domain={[0, 100]} tick={{ fontSize: 10, fill: CHART.axis }} />
|
||||
<YAxis yAxisId="macd" orientation="right" domain={['dataMin', 'dataMax']} tick={{ fontSize: 10, fill: CHART.axis }} hide />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
formatter={((value: number, name: string) => {
|
||||
if (name === 'rsi') return [`${value?.toFixed(2)}`, 'RSI'];
|
||||
if (name === 'macdLine') return [`${value?.toFixed(4)}`, 'MACD'];
|
||||
if (name === 'signalLine') return [`${value?.toFixed(4)}`, 'Signal'];
|
||||
if (name === 'histogram') return [`${value?.toFixed(4)}`, 'Histogram'];
|
||||
return [value, name];
|
||||
}) as any}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rsi"
|
||||
stroke="#f472b6"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
{/* MACD histogram bars (behind the lines) */}
|
||||
<Bar yAxisId="macd" dataKey="histogram" fill="#8884d8" opacity={0.4} radius={[0, 0, 0, 0]} />
|
||||
{/* RSI line */}
|
||||
<Line yAxisId="rsi" type="monotone" dataKey="rsi" stroke="#f472b6" strokeWidth={1.5} dot={false} name="rsi" />
|
||||
{/* MACD line */}
|
||||
<Line yAxisId="macd" type="monotone" dataKey="macdLine" stroke="#82ca9d" strokeWidth={1} dot={false} name="macdLine" />
|
||||
{/* Signal line */}
|
||||
<Line yAxisId="macd" type="monotone" dataKey="signalLine" stroke="#ff7300" strokeWidth={1} dot={false} name="signalLine" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
|
||||
{UI_STRINGS.rsiCaption}
|
||||
<figcaption className="text-[11px] text-fg-muted mt-1">
|
||||
{UI_STRINGS.rsiCaption} MACD(12,26,9): MACD line crossing above/below signal line indicates momentum shifts.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
@@ -321,30 +418,30 @@ export function ChartLabPanel() {
|
||||
{/* Relative Volume sub-chart */}
|
||||
<div>
|
||||
<figure>
|
||||
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.relvolLabel}</p>
|
||||
<p className="text-xs text-fg-muted mb-2">{UI_STRINGS.relvolLabel}</p>
|
||||
<div className="h-28">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2b3a" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#5a5b6a" }} interval="preserveStartEnd" hide />
|
||||
<YAxis domain={[0, "dataMax"]} tick={{ fontSize: 10, fill: "#5a5b6a" }} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => {
|
||||
<BarChart data={displayData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" hide />
|
||||
<YAxis domain={[0, "dataMax"]} tick={{ fontSize: 10, fill: CHART.axis }} />
|
||||
<Tooltip contentStyle={CHART.tooltip}
|
||||
formatter={((value: number) => {
|
||||
const label = value > 1 ? "above typical" : value < 1 ? "below typical" : "at typical";
|
||||
return [`${label} (${value?.toFixed(2)}x)`, UI_STRINGS.relvolLabel];
|
||||
}}
|
||||
}) as any}
|
||||
/>
|
||||
<Bar dataKey="relvol" radius={[2, 2, 0, 0]}>
|
||||
{chartData.map((entry, index) => {
|
||||
{displayData.map((entry, index) => {
|
||||
const rv = entry.relvol ?? 1;
|
||||
const fill = rv > 1 ? upColor : rv < 1 ? downColor : "#6b7280";
|
||||
const fill = rv > 1 ? upColor : rv < 1 ? downColor : CHART.neutral;
|
||||
return <Cell key={index} fill={fill} opacity={0.7} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">
|
||||
<figcaption className="text-[11px] text-fg-muted mt-1">
|
||||
{UI_STRINGS.relvolTooltip}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Home,
|
||||
LineChart,
|
||||
Building2,
|
||||
TrendingUp,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
XCircle,
|
||||
Target,
|
||||
FileText,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Users,
|
||||
Activity,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Search,
|
||||
CornerDownLeft,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
|
||||
interface NavCommand {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
goTo?: string;
|
||||
group: "Page";
|
||||
}
|
||||
|
||||
const NAV_COMMANDS: NavCommand[] = [
|
||||
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page" },
|
||||
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page" },
|
||||
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page" },
|
||||
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page" },
|
||||
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page" },
|
||||
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page" },
|
||||
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page" },
|
||||
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page" },
|
||||
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page" },
|
||||
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page" },
|
||||
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page" },
|
||||
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page" },
|
||||
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page" },
|
||||
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page" },
|
||||
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page" },
|
||||
];
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
group: string;
|
||||
icon: LucideIcon;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
function fuzzyScore(query: string, text: string): number {
|
||||
const q = query.toLowerCase().trim();
|
||||
const t = text.toLowerCase();
|
||||
if (!q) return 0;
|
||||
const idx = t.indexOf(q);
|
||||
if (idx >= 0) return 200 - idx;
|
||||
let qi = 0;
|
||||
let score = 0;
|
||||
let consecutive = 0;
|
||||
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
||||
if (t[ti] === q[qi]) {
|
||||
qi++;
|
||||
score += 1 + consecutive;
|
||||
consecutive++;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
return qi === q.length ? score : -1;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const [symbols, setSymbols] = useState<string[]>([]);
|
||||
const gPressedAt = useRef(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pull watchlist symbols for symbol-jump commands when opened.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
api.watchlists
|
||||
.list()
|
||||
.then((s) => setSymbols(s.map((x) => x.symbol)))
|
||||
.catch(() => setSymbols([]));
|
||||
}, [open]);
|
||||
|
||||
// Open on ⌘K / Ctrl+K and on custom event from the sidebar button.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
};
|
||||
const onOpenEvent = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("open-command-palette", onOpenEvent as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("open-command-palette", onOpenEvent as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// `g` then key go-to navigation (when palette closed, not typing in a field).
|
||||
useEffect(() => {
|
||||
const onGoTo = (e: KeyboardEvent) => {
|
||||
const el = document.activeElement;
|
||||
const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable;
|
||||
if (open || typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (gPressedAt.current && Date.now() - gPressedAt.current < 1200) {
|
||||
const map: Record<string, string> = {
|
||||
o: "/", c: "/chart-lab", i: "/institutional", m: "/market-outlook",
|
||||
t: "/trade-plan", e: "/execution", x: "/trade-closure", d: "/daily-focus",
|
||||
r: "/risk", f: "/filings", p: "/options-dd", s: "/settings", a: "/admin",
|
||||
};
|
||||
const href = map[e.key.toLowerCase()];
|
||||
if (href) {
|
||||
e.preventDefault();
|
||||
gPressedAt.current = 0;
|
||||
router.push(href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "g") {
|
||||
gPressedAt.current = Date.now();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onGoTo);
|
||||
return () => window.removeEventListener("keydown", onGoTo);
|
||||
}, [open, router]);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const pageCmds: Command[] = NAV_COMMANDS.map((c) => ({
|
||||
id: `page:${c.href}`,
|
||||
label: c.label,
|
||||
hint: c.goTo ? `g ${c.goTo}` : undefined,
|
||||
group: c.group,
|
||||
icon: c.icon,
|
||||
run: () => router.push(c.href),
|
||||
}));
|
||||
const symbolCmds: Command[] = symbols.map((sym) => ({
|
||||
id: `sym:${sym}`,
|
||||
label: `Open ${sym}`,
|
||||
hint: "symbol",
|
||||
group: "Symbol",
|
||||
icon: Building2,
|
||||
run: () => {
|
||||
useActiveSymbol.getState().setActive(sym);
|
||||
router.push("/");
|
||||
},
|
||||
}));
|
||||
return [...pageCmds, ...symbolCmds];
|
||||
}, [symbols, router]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return commands;
|
||||
return commands
|
||||
.map((c) => ({ c, score: fuzzyScore(query, `${c.label} ${c.group} ${c.hint ?? ""}`) }))
|
||||
.filter((r) => r.score >= 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((r) => r.c);
|
||||
}, [query, commands]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-idx="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const execute = (cmd: Command | undefined) => {
|
||||
if (!cmd) return;
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
cmd.run();
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Command[]>();
|
||||
for (const c of results) {
|
||||
if (!map.has(c.group)) map.set(c.group, []);
|
||||
map.get(c.group)!.push(c);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [results]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-[20%] z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 rounded-lg border border-line bg-surface-raised shadow-xl focus:outline-none overflow-hidden"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
execute(results[active]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Title className="sr-only">Command palette</Dialog.Title>
|
||||
<div className="flex items-center gap-2 border-b border-line px-3">
|
||||
<Search className="h-4 w-4 text-fg-muted shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search pages, symbols, actions…"
|
||||
className="w-full bg-transparent py-3 text-sm text-fg placeholder:text-fg-muted outline-none"
|
||||
/>
|
||||
<kbd className="hidden sm:block rounded-sm border border-line px-1.5 py-0.5 text-2xs text-fg-muted">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="max-h-80 overflow-y-auto p-1.5">
|
||||
{results.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-fg-muted">No matches</p>
|
||||
) : (
|
||||
grouped.map(([group, cmds]) => (
|
||||
<div key={group} className="mb-1">
|
||||
<p className="px-2 py-1 text-2xs uppercase tracking-wide text-fg-muted">{group}</p>
|
||||
{cmds.map((c) => {
|
||||
const idx = results.indexOf(c);
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
data-idx={idx}
|
||||
onClick={() => execute(c)}
|
||||
onMouseMove={() => setActive(idx)}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
idx === active ? "bg-surface-overlay text-fg" : "text-fg-muted hover:bg-surface-overlay"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2.5 min-w-0">
|
||||
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{c.label}</span>
|
||||
</span>
|
||||
{c.hint && (
|
||||
<kbd className="shrink-0 rounded-sm border border-line px-1.5 py-0.5 text-2xs text-fg-muted">
|
||||
{c.hint}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-line px-3 py-2 text-2xs text-fg-muted">
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" /> to select</span>
|
||||
<span>↑↓ to navigate</span>
|
||||
<span className="ml-auto">⌘K to toggle</span>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type Snapshot } from "@/lib/trpc";
|
||||
|
||||
// Company description + high-level data panel. Reads extended SymbolMeta (description,
|
||||
// website, marketCap, employees, country/city) and displays it educationally — no signals.
|
||||
export function CompanyPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true); setError(null);
|
||||
api.market.snapshot(symbol).then((s) => { if (!cancelled) setSnap(s); }).catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : "request failed"); }).finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
const sector = snap?.sector ?? null;
|
||||
if (loading) return <section className="rounded-md border border-line bg-surface-raised p-5"><p className="text-sm text-fg-muted">Loading company data…</p></section>;
|
||||
if (error || !sector || !sector.name) {
|
||||
const msg = error ?? `No company data for ${symbol}`;
|
||||
return <section className="rounded-md border border-line bg-surface-raised p-5"><p className="text-sm text-fg-muted">{msg}</p></section>;
|
||||
}
|
||||
|
||||
const marketCapStr = sector.marketCap != null ? formatMarketCap(sector.marketCap, sector.currency) : "–";
|
||||
const employeesStr = sector.employees ?? "–";
|
||||
const locationStr = [sector.city, sector.country].filter(Boolean).join(", ");
|
||||
const desc = sector.description?.trim();
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5">
|
||||
<header className="flex items-baseline justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{symbol}</h2>
|
||||
<p className="text-xs text-fg-muted">{sector.name} · {sector.sector ?? "—"}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* High-level data grid */}
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-3 mb-5">
|
||||
<MetaField label="Market cap">{marketCapStr}</MetaField>
|
||||
<MetaField label="Employees">{employeesStr}</MetaField>
|
||||
<MetaField label="Industry">{sector.industry ?? "—"}</MetaField>
|
||||
<MetaField label="Location">{locationStr || "—"}</MetaField>
|
||||
</dl>
|
||||
|
||||
{/* Description */}
|
||||
{desc && (
|
||||
<>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted mb-2">About</h3>
|
||||
<p className="text-sm leading-relaxed text-fg">{desc}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
{sector.website && (
|
||||
<div className="mt-4">
|
||||
<a href={sector.website.startsWith("http") ? sector.website : `https://${sector.website}`} target="_blank" rel="noopener noreferrer" className="text-xs text-link hover:underline">Visit company website ↗</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] leading-relaxed text-fg-muted border-t border-line pt-3">
|
||||
Company description and basic data — factual context, not a signal. Verify via the company's investor relations page or SEC filings before making decisions.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-fg-muted">{label}</dt>
|
||||
<dd className="text-sm font-medium text-fg">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMarketCap(value: number, currency?: string | null): string {
|
||||
if (!Number.isFinite(value)) return "–";
|
||||
const sym = currency === "USD" ? "$" : (currency ?? "");
|
||||
if (value >= 1e12) return `${sym}${(value / 1e12).toFixed(2)}T`;
|
||||
if (value >= 1e9) return `${sym}${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `${sym}${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `${sym}${(value / 1e3).toFixed(2)}K`;
|
||||
return `${sym}${value.toFixed(0)}`;
|
||||
}
|
||||
@@ -14,14 +14,14 @@ interface EmotionOption {
|
||||
}
|
||||
|
||||
const EMOTION_OPTIONS: EmotionOption[] = [
|
||||
{ label: "Frustrated", emoji: "🔴", color: "text-[#ef4444]", bgColor: "bg-[#ef4444]/10", borderColor: "border-[#ef4444]/30" },
|
||||
{ label: "FOMO", emoji: "🟠", color: "text-[#f97316]", bgColor: "bg-[#f97316]/10", borderColor: "border-[#f97316]/30" },
|
||||
{ label: "Revenge", emoji: "🟣", color: "text-[#a78bfa]", bgColor: "bg-[#a78bfa]/10", borderColor: "border-[#a78bfa]/30" },
|
||||
{ label: "Patient", emoji: "🔵", color: "text-[#4f8cff]", bgColor: "bg-[#4f8cff]/10", borderColor: "border-[#4f8cff]/30" },
|
||||
{ label: "Confused", emoji: "⚪", color: "text-[#8a8b9a]", bgColor: "bg-[#8a8b9a]/10", borderColor: "border-[#8a8b9a]/30" },
|
||||
{ label: "Confident", emoji: "🟢", color: "text-[#34d399]", bgColor: "bg-[#34d399]/10", borderColor: "border-[#34d399]/30" },
|
||||
{ label: "Anxious", emoji: "🟡", color: "text-[#fbbf24]", bgColor: "bg-[#fbbf24]/10", borderColor: "border-[#fbbf24]/30" },
|
||||
{ label: "Greedy", emoji: "🟤", color: "text-[#b45309]", bgColor: "bg-[#b45309]/10", borderColor: "border-[#b45309]/30" },
|
||||
{ label: "Frustrated", emoji: "🔴", color: "text-danger", bgColor: "bg-danger/10", borderColor: "border-danger/30" },
|
||||
{ label: "FOMO", emoji: "🟠", color: "text-orange-500", bgColor: "bg-orange-500/10", borderColor: "border-orange-500/30" },
|
||||
{ label: "Revenge", emoji: "🟣", color: "text-[#a78bfa]", bgColor: "bg-indigo-300/10", borderColor: "border-indigo-300/30" },
|
||||
{ label: "Patient", emoji: "🔵", color: "text-accent", bgColor: "bg-accent/10", borderColor: "border-accent/30" },
|
||||
{ label: "Confused", emoji: "⚪", color: "text-fg-muted", bgColor: "bg-fg-muted/10", borderColor: "border-fg-muted/30" },
|
||||
{ label: "Confident", emoji: "🟢", color: "text-up", bgColor: "bg-up/10", borderColor: "border-up/30" },
|
||||
{ label: "Anxious", emoji: "🟡", color: "text-[#fbbf24]", bgColor: "bg-warn/10", borderColor: "border-warn/30" },
|
||||
{ label: "Greedy", emoji: "🟤", color: "text-[#b45309]", bgColor: "bg-amber-700/10", borderColor: "border-amber-700/30" },
|
||||
];
|
||||
|
||||
// --- Stats helpers ---
|
||||
@@ -33,7 +33,7 @@ interface Stats {
|
||||
}
|
||||
|
||||
function computeStats(
|
||||
logs: { timestamp: number; priceAtEvent: number }[],
|
||||
logs: { timestamp: number; priceAtEvent: number; emotion: string }[],
|
||||
entryPrice: number | null,
|
||||
): Stats {
|
||||
const now = Date.now();
|
||||
@@ -63,7 +63,7 @@ function computeStats(
|
||||
avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
||||
}
|
||||
|
||||
return { totalToday, mostCommon, avgDelta };
|
||||
return { totalToday, mostCommon, avgDeltaFromEntry: avgDelta };
|
||||
}
|
||||
|
||||
// --- Format helpers ---
|
||||
@@ -120,11 +120,11 @@ export function EmotionLogger() {
|
||||
const hasActiveTrade = !!activeExecutionId;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
{/* Header — always visible, compact */}
|
||||
<div className="flex items-center justify-between px-5 py-3 rounded-t-xl">
|
||||
<h2 className="text-sm font-semibold text-[#e6e7ec]">Emotion Logger</h2>
|
||||
<span className="text-[10px] text-[#5a5b6a]">
|
||||
<h2 className="text-sm font-semibold text-fg">Emotion Logger</h2>
|
||||
<span className="text-[10px] text-fg-faint">
|
||||
{emotionLogs.length} log{emotionLogs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export function EmotionLogger() {
|
||||
key={opt.label}
|
||||
onClick={() => handleQuickAdd(opt.label)}
|
||||
aria-label={`Log ${opt.label} emotion`}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318] ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318] ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
|
||||
>
|
||||
<span aria-hidden="true">{opt.emoji}</span>
|
||||
<span>{opt.label}</span>
|
||||
@@ -155,7 +155,7 @@ export function EmotionLogger() {
|
||||
id="emotion-select"
|
||||
value={selectedEmotion}
|
||||
onChange={(e) => setSelectedEmotion(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
className="flex-1 rounded-lg border border-line-strong bg-surface-sunken text-fg text-xs px-3 py-2 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
<option value="">Select emotion…</option>
|
||||
{EMOTION_OPTIONS.map((opt) => (
|
||||
@@ -174,7 +174,7 @@ export function EmotionLogger() {
|
||||
placeholder="Price"
|
||||
value={priceInput}
|
||||
onChange={(e) => setPriceInput(e.target.value)}
|
||||
className="w-28 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
className="w-28 rounded-lg border border-line-strong bg-surface-sunken text-fg text-xs px-3 py-2 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
|
||||
{/* Submit button */}
|
||||
@@ -182,7 +182,7 @@ export function EmotionLogger() {
|
||||
type="submit"
|
||||
disabled={!selectedEmotion || !priceInput.trim()}
|
||||
aria-label="Log emotion entry"
|
||||
className="rounded-lg bg-[#2a2b3a] text-[#e6e7ec] text-xs px-4 py-2 disabled:opacity-30 hover:bg-[#3a3b4a] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
className="rounded-lg bg-surface-overlay text-fg text-xs px-4 py-2 disabled:opacity-30 hover:bg-[#3a3b4a] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
>
|
||||
Log
|
||||
</button>
|
||||
@@ -196,7 +196,7 @@ export function EmotionLogger() {
|
||||
onChange={(e) => setNoteInput(e.target.value)}
|
||||
placeholder="Add a note (optional)"
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 resize-none focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
className="w-full rounded-lg border border-line-strong bg-surface-sunken text-fg text-xs px-3 py-2 resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -212,24 +212,24 @@ export function EmotionLogger() {
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex items-start justify-between gap-2 px-3 py-2 rounded-lg border ${opt?.borderColor ?? "border-[#2a2b3a]"} ${opt?.bgColor ?? "bg-transparent"}`}
|
||||
className={`flex items-start justify-between gap-2 px-3 py-2 rounded-lg border ${opt?.borderColor ?? "border-line-strong"} ${opt?.bgColor ?? "bg-transparent"}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-medium ${opt?.color ?? "text-[#5a5b6a]"}`}>
|
||||
<span className={`text-xs font-medium ${opt?.color ?? "text-fg-faint"}`}>
|
||||
{opt?.emoji} {log.emotion}
|
||||
</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">{formatTime(log.timestamp)}</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">@ {formatPrice(log.priceAtEvent)}</span>
|
||||
<span className="text-[10px] text-fg-faint">{formatTime(log.timestamp)}</span>
|
||||
<span className="text-[10px] text-fg-faint">@ {formatPrice(log.priceAtEvent)}</span>
|
||||
</div>
|
||||
{log.note && (
|
||||
<p className="text-xs text-[#5a5b6a] mt-0.5 truncate">{log.note}</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5 truncate">{log.note}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeEmotionLog(originalIdx)}
|
||||
aria-label={`Remove ${log.emotion} entry`}
|
||||
className="text-[10px] text-[#5a5b6a] hover:text-[#ef4444] transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318] rounded"
|
||||
className="text-[10px] text-fg-faint hover:text-danger transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318] rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -238,24 +238,24 @@ export function EmotionLogger() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[#5a5b6a] italic">No emotion logs yet. Use the buttons above to start tracking.</p>
|
||||
<p className="text-xs text-fg-faint italic">No emotion logs yet. Use the buttons above to start tracking.</p>
|
||||
)}
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-4 px-1 py-2 border-t border-[#2a2b3a]">
|
||||
<span className="text-[10px] text-[#5a5b6a]">
|
||||
Today: <span className="text-[#e6e7ec] font-medium">{stats.totalToday}</span>
|
||||
<div className="flex items-center gap-4 px-1 py-2 border-t border-line-strong">
|
||||
<span className="text-[10px] text-fg-faint">
|
||||
Today: <span className="text-fg font-medium">{stats.totalToday}</span>
|
||||
</span>
|
||||
{stats.mostCommon && (
|
||||
<span className="text-[10px] text-[#5a5b6a]">
|
||||
Most common: <span className="text-[#e6e7ec] font-medium">{stats.mostCommon.label}</span>
|
||||
<span className="text-[#5a5b6a]"> ({stats.mostCommon.count})</span>
|
||||
<span className="text-[10px] text-fg-faint">
|
||||
Most common: <span className="text-fg font-medium">{stats.mostCommon.label}</span>
|
||||
<span className="text-fg-faint"> ({stats.mostCommon.count})</span>
|
||||
</span>
|
||||
)}
|
||||
{stats.avgDeltaFromEntry != null && (
|
||||
<span className="text-[10px] text-[#5a5b6a]">
|
||||
<span className="text-[10px] text-fg-faint">
|
||||
Avg delta:{" "}
|
||||
<span className={`font-medium ${stats.avgDeltaFromEntry >= 0 ? "text-[#34d399]" : "text-[#ef4444]"}`}>
|
||||
<span className={`font-medium ${stats.avgDeltaFromEntry >= 0 ? "text-up" : "text-danger"}`}>
|
||||
{stats.avgDeltaFromEntry >= 0 ? "+" : ""}
|
||||
{formatPrice(stats.avgDeltaFromEntry)}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, type ReactElement } from "react";
|
||||
import { api, type EdgarFiling, type FilerMeta, type Form13fResult, type Form4Result } from "@/lib/trpc";
|
||||
import { TablePager, DEFAULT_PAGE_SIZE } from "@/components/TablePager";
|
||||
|
||||
// M6 Filings Panel (Slice 6) — Educational SEC EDGAR filings viewer.
|
||||
// Read-only display of filing data from SEC EDGAR. No trade actions (ADR-0007).
|
||||
@@ -22,7 +23,7 @@ const FORM_TYPE_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function getFormColor(form: string): string {
|
||||
return FORM_TYPE_COLORS[form] ?? "bg-[#2a2b3a] text-[#e6e7ec] border border-[#3f3f46]";
|
||||
return FORM_TYPE_COLORS[form] ?? "bg-[#2a2b3a] text-fg border border-[#3f3f46]";
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
@@ -55,7 +56,7 @@ const MATERIALITY_ITEMS: Record<string, string> = {
|
||||
"9A": "Financial statements and exhibits",
|
||||
};
|
||||
|
||||
function MaterialityBadge(_itemNumber: string): JSX.Element | null {
|
||||
function MaterialityBadge(_itemNumber: string): ReactElement | 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;
|
||||
@@ -94,6 +95,14 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
// Pagination state for the detail views.
|
||||
const [detailCik, setDetailCik] = useState<string>("");
|
||||
const [detailAccession, setDetailAccession] = useState<string>("");
|
||||
const [holdingsPage, setHoldingsPage] = useState(1);
|
||||
const [holdingsPageSize, setHoldingsPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [form4Page, setForm4Page] = useState(1);
|
||||
const [form4PageSize, setForm4PageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
// Table keyboard navigation state
|
||||
const [tableFocus, setTableFocus] = useState<FocusPosition | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -141,13 +150,18 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (cik: string, accession: string, viewType: "13f" | "form4") => {
|
||||
const loadDetail = useCallback(async (
|
||||
cik: string,
|
||||
accession: string,
|
||||
viewType: "13f" | "form4",
|
||||
opts?: { limit?: number; offset?: number },
|
||||
) => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
try {
|
||||
if (viewType === "13f") {
|
||||
const result = await api.edgar.form13f_holdings(cik, accession);
|
||||
setDetailData(result.holdings);
|
||||
const result = await api.edgar.form13f_holdings(cik, accession, opts);
|
||||
setDetailData(result);
|
||||
} else {
|
||||
const result = await api.edgar.form4_tx(cik, accession);
|
||||
setDetailData(result.transactions);
|
||||
@@ -211,9 +225,15 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
const handleViewDetail = (accession: string, formType: string) => {
|
||||
if (formType === "13F-HR") {
|
||||
setDetailView("13f");
|
||||
loadDetail(cikInput, accession, "13f");
|
||||
setDetailCik(cikInput);
|
||||
setDetailAccession(accession);
|
||||
setHoldingsPage(1);
|
||||
loadDetail(cikInput, accession, "13f", { limit: holdingsPageSize, offset: 0 });
|
||||
} else if (formType === "Form 4" || formType === "4") {
|
||||
setDetailView("form4");
|
||||
setDetailCik(cikInput);
|
||||
setDetailAccession(accession);
|
||||
setForm4Page(1);
|
||||
loadDetail(cikInput, accession, "form4");
|
||||
} else {
|
||||
setDetailView("none");
|
||||
@@ -222,6 +242,22 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadHoldingsPage = useCallback((page: number, size: number) => {
|
||||
loadDetail(detailCik, detailAccession, "13f", { limit: size, offset: (page - 1) * size });
|
||||
}, [detailCik, detailAccession, loadDetail]);
|
||||
|
||||
// Form 4 transactions are paginated client-side (typically a few dozen rows).
|
||||
const form4Transactions = useMemo(() => {
|
||||
if (detailData && "transactions" in detailData) return detailData.transactions;
|
||||
return [];
|
||||
}, [detailData]);
|
||||
|
||||
const pagedForm4Transactions = useMemo(() => {
|
||||
if (form4PageSize === -1) return form4Transactions;
|
||||
const start = (form4Page - 1) * form4PageSize;
|
||||
return form4Transactions.slice(start, start + form4PageSize);
|
||||
}, [form4Transactions, form4Page, form4PageSize]);
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setDetailView("none");
|
||||
setDetailData(null);
|
||||
@@ -268,10 +304,10 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
|
||||
if (filingsError) {
|
||||
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">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec] mb-4">SEC Filings</h2>
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg mb-4">SEC Filings</h2>
|
||||
<div className="p-4 bg-[#1e1e2e] rounded-lg border border-[#ef4444]/30">
|
||||
<p className="text-[#ef4444] text-sm">
|
||||
<p className="text-danger text-sm">
|
||||
Unable to load filings. Please verify the CIK number and try again.
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,20 +316,20 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
}
|
||||
|
||||
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]">SEC Filings Panel</h2>
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 space-y-4 transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">SEC Filings Panel</h2>
|
||||
|
||||
{/* CIK Input */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="cik-input" className="block text-xs text-[#8a8b9a] mb-1">CIK Number</label>
|
||||
<label htmlFor="cik-input" className="block text-xs text-fg-muted mb-1">CIK Number</label>
|
||||
<input
|
||||
id="cik-input"
|
||||
type="text"
|
||||
value={cikInput}
|
||||
onChange={(e) => setCikInput(e.target.value.replace(/\D/g, "").slice(0, 10))}
|
||||
placeholder="Enter CIK (e.g., 320193 for AAPL)"
|
||||
className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-sm focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-fg text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
@@ -301,7 +337,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
onClick={handleSearch}
|
||||
disabled={!cikInput}
|
||||
aria-label="Load filings"
|
||||
className="px-4 py-2 bg-[#4f8cff] text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#3b7aed] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#3b7aed] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
>
|
||||
Load Filings
|
||||
</button>
|
||||
@@ -313,17 +349,17 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<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>
|
||||
<span className="text-fg-muted">Filer:</span>{" "}
|
||||
<span className="text-fg 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>
|
||||
<span className="text-fg-muted">CIK:</span>{" "}
|
||||
<span className="text-fg 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>
|
||||
<span className="text-fg-muted">SIC:</span>{" "}
|
||||
<span className="text-fg font-mono">{metaData.meta.sic}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,18 +369,18 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{/* 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-[#4f8cff]"></div>
|
||||
<span className="ml-3 text-[#8a8b9a] text-sm">Loading filings...</span>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent"></div>
|
||||
<span className="ml-3 text-fg-muted 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">
|
||||
<p className="text-fg-muted text-sm">
|
||||
No filings found for CIK {cikInput}.
|
||||
</p>
|
||||
<p className="text-xs text-[#5a5b6a] mt-1">
|
||||
<p className="text-xs text-fg-muted mt-1">
|
||||
Verify the CIK number is correct, or try a different entity.
|
||||
</p>
|
||||
</div>
|
||||
@@ -363,22 +399,22 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<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">
|
||||
<th className="text-left py-2 px-3 text-fg-muted font-medium cursor-pointer hover:text-fg" 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">
|
||||
<th className="text-left py-2 px-3 text-fg-muted font-medium cursor-pointer hover:text-fg" 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">
|
||||
<th className="text-left py-2 px-3 text-fg-muted font-medium" role="columnheader">Accession No.</th>
|
||||
<th className="text-left py-2 px-3 text-fg-muted font-medium cursor-pointer hover:text-fg" 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>
|
||||
<th className="text-left py-2 px-3 text-fg-muted 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">
|
||||
<tr key={filing.accessionNumber} className="border-b border-line hover:bg-[#1e1e2e]" role="row">
|
||||
{/* Form cell */}
|
||||
<td
|
||||
className="py-2 px-3"
|
||||
@@ -392,7 +428,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
</td>
|
||||
{/* Filing Date cell */}
|
||||
<td
|
||||
className="py-2 px-3 text-[#e6e7ec]"
|
||||
className="py-2 px-3 text-fg"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 1 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 1)}
|
||||
aria-label={`Filing date: ${filing.dateReporter}`}
|
||||
@@ -401,7 +437,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
</td>
|
||||
{/* Accession No. cell */}
|
||||
<td
|
||||
className="py-2 px-3 font-mono text-xs text-[#8a8b9a]"
|
||||
className="py-2 px-3 font-mono text-xs text-fg-muted"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 2 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 2)}
|
||||
aria-label={`Accession number: ${filing.accessionNumber}`}
|
||||
@@ -410,7 +446,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
</td>
|
||||
{/* Report Date cell */}
|
||||
<td
|
||||
className="py-2 px-3 text-[#e6e7ec]"
|
||||
className="py-2 px-3 text-fg"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 3 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 3)}
|
||||
aria-label={`Report date: ${filing.reportDate}`}
|
||||
@@ -429,7 +465,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
href={accessionLink(filing.accessionNumber)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#4f8cff] hover:text-[#6ba3ff] text-xs underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded"
|
||||
className="text-accent hover:text-[#6ba3ff] text-xs underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
aria-label={`View filing on SEC EDGAR for accession ${filing.accessionNumber}`}
|
||||
>
|
||||
View
|
||||
@@ -437,7 +473,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{(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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded"
|
||||
className="text-[#a78bfa] hover:text-[#c4b5fd] text-xs underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
aria-label={`View details for accession ${filing.accessionNumber}`}
|
||||
>
|
||||
Details
|
||||
@@ -455,11 +491,11 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{/* 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 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] ${formFilter === "all" ? "bg-[#4f8cff] text-white border-[#4f8cff]" : "bg-transparent text-[#8a8b9a] border-[#3f3f46] hover:border-[#4f8cff]"}`} aria-label={`Show all filings (${filings.length})`}>
|
||||
<button onClick={() => setFormFilter("all")} className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${formFilter === "all" ? "bg-accent text-white border-accent" : "bg-transparent text-fg-muted border-[#3f3f46] hover:border-accent"}`} 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 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] ${formFilter === form ? "bg-[#4f8cff] text-white border-[#4f8cff]" : getFormColor(form)}`} aria-label={`Filter by ${form}, ${filings.filter((f) => f.form === form).length} filings`}>
|
||||
<button key={form} onClick={() => setFormFilter(form)} className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${formFilter === form ? "bg-accent text-white border-accent" : getFormColor(form)}`} aria-label={`Filter by ${form}, ${filings.filter((f) => f.form === form).length} filings`}>
|
||||
{form} ({filings.filter((f) => f.form === form).length})
|
||||
</button>
|
||||
))}
|
||||
@@ -469,16 +505,16 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="filing-date-from" className="block text-xs text-[#8a8b9a] mb-1">From Date</label>
|
||||
<input id="filing-date-from" type="date" value={dateRange.from ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value || undefined }))} className="w-full px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]" />
|
||||
<label htmlFor="filing-date-from" className="block text-xs text-fg-muted mb-1">From Date</label>
|
||||
<input id="filing-date-from" type="date" value={dateRange.from ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value || undefined }))} className="w-full px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-fg text-xs focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="filing-date-to" className="block text-xs text-[#8a8b9a] mb-1">To Date</label>
|
||||
<input id="filing-date-to" type="date" value={dateRange.to ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value || undefined }))} className="w-full px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]" />
|
||||
<label htmlFor="filing-date-to" className="block text-xs text-fg-muted mb-1">To Date</label>
|
||||
<input id="filing-date-to" type="date" value={dateRange.to ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value || undefined }))} className="w-full px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-fg text-xs focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
{(dateRange.from || dateRange.to) && (
|
||||
<div className="flex items-end">
|
||||
<button onClick={() => setDateRange({})} className="px-3 py-1.5 text-xs text-[#8a8b9a] hover:text-[#e6e7ec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" aria-label="Clear date range filter">Clear</button>
|
||||
<button onClick={() => setDateRange({})} className="px-3 py-1.5 text-xs text-fg-muted hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded" aria-label="Clear date range filter">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -488,7 +524,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<button
|
||||
onClick={() => loadSummary(cikInput)}
|
||||
disabled={summaryLoading}
|
||||
className="px-4 py-2 bg-[#1a3a2a] text-[#34d399] rounded-lg text-sm font-medium border border-[#065f46] disabled:opacity-50 hover:bg-[#065f46] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
className="px-4 py-2 bg-[#1a3a2a] text-up rounded-lg text-sm font-medium border border-[#065f46] disabled:opacity-50 hover:bg-[#065f46] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
aria-label={summaryLoading ? "Summarizing filings" : "Summarize filings"}
|
||||
>
|
||||
{summaryLoading ? "Summarizing..." : "Summarize"}
|
||||
@@ -498,7 +534,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{/* Summary Display */}
|
||||
{summary && (
|
||||
<div className="p-3 bg-[#1a3a2a] rounded-lg border border-[#065f46]" role="status" aria-label="Filing summary">
|
||||
<p className="text-xs text-[#34d399] whitespace-pre-wrap font-mono">{summary}</p>
|
||||
<p className="text-xs text-up whitespace-pre-wrap font-mono">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -507,15 +543,16 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<div className="p-4 bg-[#2a1f3d] rounded-lg border border-[#5b21b6]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[#c4b5fd]">13F-HR Holdings</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" aria-label="Close 13F holdings detail">Close</button>
|
||||
<button onClick={handleCloseDetail} className="text-fg-muted hover:text-fg text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded" 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>}
|
||||
{detailLoading && <p className="text-fg-muted text-xs">Loading holdings...</p>}
|
||||
{detailError && <p className="text-down 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]">
|
||||
<tr className="border-b border-[#5b21b6] text-fg-muted">
|
||||
<th className="text-left py-1 px-2" scope="col">CUSIP</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Issuer</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Value ($)</th>
|
||||
@@ -526,7 +563,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{((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-fg">{h.issuerName}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#c4b5fd]">{formatCurrency(h.value)}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#c4b5fd]">{formatShares(h.sshPrnamt)}</td>
|
||||
</tr>
|
||||
@@ -534,6 +571,14 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager
|
||||
page={holdingsPage}
|
||||
pageSize={holdingsPageSize}
|
||||
total={(detailData as Form13fResult).total ?? (detailData as Form13fResult).holdings.length}
|
||||
onPageChange={(p) => { setHoldingsPage(p); loadHoldingsPage(p, holdingsPageSize); }}
|
||||
onPageSizeChange={(s) => { setHoldingsPageSize(s); setHoldingsPage(1); loadHoldingsPage(1, s); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -543,15 +588,16 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<div className="p-4 bg-[#2a2010] rounded-lg border border-[#92400e]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[#fcd34d]">Form 4 Transactions</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" aria-label="Close Form 4 transactions detail">Close</button>
|
||||
<button onClick={handleCloseDetail} className="text-fg-muted hover:text-fg text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded" 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>}
|
||||
{detailLoading && <p className="text-fg-muted text-xs">Loading transactions...</p>}
|
||||
{detailError && <p className="text-down 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]">
|
||||
<tr className="border-b border-[#92400e] text-fg-muted">
|
||||
<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>
|
||||
@@ -562,28 +608,36 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{((detailData as Form4Result).transactions ?? []).map((tx, i) => (
|
||||
{pagedForm4Transactions.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 text-fg">{tx.reporter}</td>
|
||||
<td className="py-1 px-2 text-fg-muted">{tx.relationship}</td>
|
||||
<td className="py-1 px-2 text-fg">{tx.securityTitle}</td>
|
||||
<td className="py-1 px-2 text-fg-muted">{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>
|
||||
<td className="py-1 px-2 text-right font-mono text-fg">{tx.shares.toLocaleString()}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-fg">${tx.price.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager
|
||||
page={form4Page}
|
||||
pageSize={form4PageSize}
|
||||
total={form4Transactions.length}
|
||||
onPageChange={setForm4Page}
|
||||
onPageSizeChange={setForm4PageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
<p className="text-xs text-fg-muted">
|
||||
<strong className="text-fg">Educational Note:</strong> SEC filings are public records
|
||||
available through the EDGAR database. This panel displays filing metadata for informational
|
||||
purposes. Always consult the original filing documents for complete information.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, Tooltip, ReferenceLine,
|
||||
ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import { api, type TruckSalesData } from '@/lib/trpc';
|
||||
|
||||
const HOT_THRESHOLD = 0.47;
|
||||
const COLD_THRESHOLD = 0.25;
|
||||
|
||||
function toM(value: number): number {
|
||||
if (value > 10000) return value / 1000000;
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatM(value: number): string {
|
||||
return `${toM(value).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
export function HeavyTruckSalesChart() {
|
||||
const [data, setData] = useState<TruckSalesData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.market.truckSales()
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">Loading truck sales data…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">Failed to load truck sales data.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.configured) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center space-y-2">
|
||||
<p className="text-xs text-fg-muted">FRED API key not configured.</p>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Add one in the <span className="font-mono text-link">Admin → X Accounts</span> panel to display live heavy truck sales data.
|
||||
</p>
|
||||
{data.error && <p className="text-[11px] text-fg-muted mt-1">{data.error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center space-y-2">
|
||||
<p className="text-xs text-warn">FRED API error</p>
|
||||
<p className="text-[11px] text-fg-muted">{data.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.observations.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">No truck sales data returned from FRED.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data.observations.map((o) => ({
|
||||
date: o.date,
|
||||
value: toM(o.value),
|
||||
}));
|
||||
|
||||
const current = chartData[chartData.length - 1]?.value ?? 0;
|
||||
const prior = chartData.length >= 4 ? chartData[chartData.length - 4]?.value ?? null : null;
|
||||
const threeMoChange = prior !== null ? current - prior : null;
|
||||
|
||||
// Data-driven summary
|
||||
const phase = current > HOT_THRESHOLD ? 'running hot' : current < COLD_THRESHOLD ? 'bottoming' : 'in the normal range';
|
||||
const phaseColor = current > HOT_THRESHOLD ? 'text-danger' : current < COLD_THRESHOLD ? 'text-up' : 'text-accent';
|
||||
const trendWord = threeMoChange === null
|
||||
? 'trend unavailable'
|
||||
: threeMoChange > 0.01
|
||||
? 'rising'
|
||||
: threeMoChange < -0.01
|
||||
? 'falling'
|
||||
: 'flat';
|
||||
const summary = (
|
||||
<p className="text-xs text-fg">
|
||||
Heavy truck sales <span className="font-mono font-semibold">{formatM(current)}</span> —{' '}
|
||||
<span className={phaseColor}>{phase}</span>
|
||||
{threeMoChange !== null && <>; 3-month trend {trendWord}</>}.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{summary}
|
||||
<div className="rounded-lg overflow-hidden border border-line bg-surface-sunken">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={chartData} margin={{ top: 16, right: 12, bottom: 4, left: 4 }}>
|
||||
<defs>
|
||||
<linearGradient id="hotZone" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-danger, #ef4444)" stopOpacity={0.12} />
|
||||
<stop offset="100%" stopColor="var(--color-danger, #ef4444)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-accent, #3b82f6)" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="var(--color-accent, #3b82f6)" stopOpacity={0.04} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-line, #e5e7eb)" opacity={0.4} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: 'var(--color-fg-muted, #9ca3af)' }}
|
||||
tickFormatter={(d) => d.slice(0, 4)}
|
||||
axisLine={{ stroke: 'var(--color-line, #e5e7eb)' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'var(--color-fg-muted, #9ca3af)' }}
|
||||
tickFormatter={(v: number) => `${v.toFixed(2)}M`}
|
||||
domain={[0.1, 0.6]}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface-raised, #1f2937)',
|
||||
border: '1px solid var(--color-line, #374151)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelFormatter={(label) => label}
|
||||
formatter={(value) => [`${Number(value).toFixed(3)}M`, 'Truck Sales']}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={HOT_THRESHOLD}
|
||||
stroke="var(--color-danger, #ef4444)"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={1.5}
|
||||
label={{ value: '0.47M', position: 'insideTopRight', fontSize: 10, fill: 'var(--color-danger, #ef4444)' }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={COLD_THRESHOLD}
|
||||
stroke="var(--color-up, #22c55e)"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={1.5}
|
||||
label={{ value: '0.25M', position: 'insideBottomLeft', fontSize: 10, fill: 'var(--color-up, #22c55e)' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--color-accent, #3b82f6)"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#areaFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: 'var(--color-accent, #3b82f6)' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-3 border-t-2 border-t-danger">
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Above 0.47M</p>
|
||||
<p className="text-xs text-fg">Economy running hot — play defensive, raise quality</p>
|
||||
</div>
|
||||
<div className={`rounded-lg bg-surface-sunken border border-line p-3 border-t-2 border-t-accent ${current >= COLD_THRESHOLD && current <= HOT_THRESHOLD ? 'ring-1 ring-accent' : ''}`}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">0.25M – 0.47M</p>
|
||||
<p className="text-xs text-fg font-medium">Normal range — stay the course, hold winners</p>
|
||||
<p className="text-[10px] text-fg-muted mt-1">Current: {formatM(current)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-3 border-t-2 border-t-up">
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Below 0.25M</p>
|
||||
<p className="text-xs text-fg">Economy bottoming — get aggressive, build wealth</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface InfoTooltipProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InfoTooltip({ children }: InfoTooltipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-fg-muted/20 text-fg-muted hover:bg-accent/30 hover:text-accent transition-colors cursor-help text-[9px] leading-none font-bold"
|
||||
aria-label="More info"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 left-0 top-5 w-72 p-3 rounded-lg border border-line bg-surface-raised shadow-lg text-xs text-fg-muted leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import React from 'react';
|
||||
|
||||
// Use permissive types that accept both InstitutionalDashboard's FilingGroupRow and QuarterlyFiling structures.
|
||||
interface FilingQuarterly {
|
||||
reportedQuarter: string;
|
||||
shares: number | null;
|
||||
delta: number;
|
||||
putCall?: string | null;
|
||||
form: string;
|
||||
}
|
||||
|
||||
interface FilingGroupRow {
|
||||
filerCik: string;
|
||||
filerName?: string | null;
|
||||
putCall?: string | null;
|
||||
form: string;
|
||||
shares?: number | null;
|
||||
netDelta: number;
|
||||
classification?: string;
|
||||
quarters: FilingQuarterly[];
|
||||
}
|
||||
|
||||
interface SortState { key: string; dir: 'asc' | 'desc'; }
|
||||
|
||||
const DELTA_BADGE_LABELS: Record<string, { label: string }> = {
|
||||
increasing: { label: 'Bought' },
|
||||
reducing: { label: 'Sold' },
|
||||
};
|
||||
|
||||
function formatShares(n: number): string {
|
||||
if (n === null || n === undefined) return '—';
|
||||
if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function formatQuarter(q: string): string {
|
||||
const match = q.match(/(\d{4})-Q?([1-4])/);
|
||||
if (!match) return q;
|
||||
const [, year, quarter] = match;
|
||||
return `Q${quarter} ${year}`;
|
||||
}
|
||||
|
||||
interface InstrumentTypeBadgeProps { putCall?: string | null }
|
||||
function InstrumentTypeBadge({ putCall }: InstrumentTypeBadgeProps) {
|
||||
const pc = putCall?.toLowerCase();
|
||||
if (!pc || (pc !== 'call' && pc !== 'put')) {
|
||||
return <span className="text-fg-muted text-[10px]">Common</span>;
|
||||
}
|
||||
const isCall = pc === 'call';
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[9px] font-medium border rounded px-1.5 py-0.5 ${
|
||||
isCall ? 'text-up border-[#064e3b]' : 'text-down border-[#7f1d1d]'
|
||||
}`}>
|
||||
{isCall ? 'CALL' : 'PUT'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormBadgeProps { form: string }
|
||||
function FormBadge({ form }: FormBadgeProps) {
|
||||
const isFiling = /^13[FGD]/.test(form);
|
||||
return (
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[9px] font-medium border ${
|
||||
isFiling ? 'text-[#fcd34d] border-[#78350f]' : 'text-fg-muted border-[#3f3f46]'
|
||||
}`}>{form}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function sortRows<T>(rows: T[], key: string, dir: 'asc' | 'desc', accessors: Record<string, (r: T) => number | string>): T[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const aVal = accessors[key]?.(a);
|
||||
const bVal = accessors[key]?.(b);
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return dir === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
const aStr = String(aVal ?? '');
|
||||
const bStr = String(bVal ?? '');
|
||||
return dir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filings: FilingGroupRow[];
|
||||
filerFilter: string;
|
||||
setFilerFilter: (v: string) => void;
|
||||
quarterFilter: string;
|
||||
setQuarterFilter: (v: string) => void;
|
||||
sourceFilter: string;
|
||||
setSourceFilter: (v: string) => void;
|
||||
filerSort: SortState;
|
||||
setFilerSort: (s: SortState) => void;
|
||||
instPage: number;
|
||||
setInstPage: (n: number) => void;
|
||||
instPageSize: number;
|
||||
setInstPageSize: (n: number) => void;
|
||||
expandedFilers: Set<string>;
|
||||
toggleFiler: (k: string) => void;
|
||||
}
|
||||
|
||||
export function InstitutionalAccordion({
|
||||
filings,
|
||||
filerFilter,
|
||||
setFilerFilter,
|
||||
quarterFilter,
|
||||
setQuarterFilter,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
filerSort,
|
||||
setFilerSort,
|
||||
instPage,
|
||||
setInstPage,
|
||||
instPageSize,
|
||||
setInstPageSize,
|
||||
expandedFilers,
|
||||
toggleFiler,
|
||||
}: Props) {
|
||||
const uniqueQuarters = [...new Set(filings.flatMap((g: FilingGroupRow) => g.quarters.map((q: FilingQuarterly) => q.reportedQuarter)))].sort().reverse();
|
||||
const uniqueSources = [...new Set(filings.map((g: FilingGroupRow) => g.form))];
|
||||
|
||||
const filteredGroups = filings.filter(g => {
|
||||
if (filerFilter && !(g.filerName ?? g.filerCik).toLowerCase().includes(filerFilter.toLowerCase())) return false;
|
||||
if (quarterFilter && !g.quarters.some((q: FilingQuarterly) => q.reportedQuarter === quarterFilter)) return false;
|
||||
if (sourceFilter && g.form !== sourceFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const accessors: Record<string, (r: FilingGroupRow) => number | string> = {
|
||||
filer: (r: FilingGroupRow) => r.filerName ?? r.filerCik ?? '',
|
||||
shares: (r: FilingGroupRow) => r.shares ?? 0,
|
||||
netDelta: (r: FilingGroupRow) => Math.abs(r.netDelta ?? 0),
|
||||
putCall: (r: FilingGroupRow) => r.putCall ?? '',
|
||||
form: (r: FilingGroupRow) => r.form ?? '',
|
||||
};
|
||||
|
||||
const sortedGroups = sortRows(filteredGroups, filerSort.key, filerSort.dir, accessors);
|
||||
const pagedGroups = instPageSize === -1 ? sortedGroups : sortedGroups.slice((instPage - 1) * instPageSize, instPage * instPageSize);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{/* Accordion table */}
|
||||
{pagedGroups.length === 0 ? (
|
||||
<p className="py-4 text-center text-fg-muted text-xs">No filings match the current filters.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto mt-2">
|
||||
<table className="w-full text-xs" role="table" aria-label="Institutional ownership breakdown by filer and instrument type">
|
||||
<thead>
|
||||
<tr className="border-b border-[#3f3f46] text-fg-muted">
|
||||
<th className="py-1 px-2 w-8"></th>
|
||||
<th className="py-1 px-2 text-left">
|
||||
<input
|
||||
type="text"
|
||||
value={filerFilter}
|
||||
onChange={(e) => { setFilerFilter(e.target.value); setInstPage(1); }}
|
||||
placeholder="Filter filers..."
|
||||
className="w-full bg-[#1e1f2a] border border-line text-fg rounded px-1 py-0.5 text-[10px] placeholder:text-fg-muted/50 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-1 px-2 text-left text-[10px]">Instrument</th>
|
||||
<th className="py-1 px-2 text-right cursor-pointer select-none hover:text-fg text-[10px]" onClick={() => { setFilerSort({ key: 'shares', dir: filerSort.dir === 'asc' ? 'desc' : 'asc' }); setInstPage(1); }}>
|
||||
Shares {filerSort.key === 'shares' && (filerSort.dir === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th className="py-1 px-2 text-left text-[10px]">Direction</th>
|
||||
<th className="py-1 px-2 text-left">
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => { setSourceFilter(e.target.value); setInstPage(1); }}
|
||||
className="w-full bg-[#1e1f2a] border border-line text-fg rounded px-1 py-0.5 text-[10px] focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{uniqueSources.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</th>
|
||||
<th className="py-1 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedGroups.map((group: FilingGroupRow, idx: number) => {
|
||||
const expandKey = `${group.filerCik}|${group.putCall}|${group.form}`;
|
||||
const isExpanded = expandedFilers.has(expandKey);
|
||||
const directionLabel = DELTA_BADGE_LABELS[group.classification ?? '']?.label ?? '—';
|
||||
|
||||
return (
|
||||
<React.Fragment key={expandKey}>
|
||||
{/* Top-level accordion row */}
|
||||
<tr className={`border-b border-line cursor-pointer hover:bg-[#1e1f2a]/50 transition-colors ${isExpanded ? '' : ''}`} onClick={() => toggleFiler(expandKey)}>
|
||||
<td className="py-1.5 px-2 text-center">
|
||||
<span className={`text-fg-muted transition-transform duration-150 inline-block ${isExpanded ? 'rotate-90' : ''}`} aria-hidden="true">▶</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-fg font-medium">{group.filerName ?? group.filerCik}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<InstrumentTypeBadge putCall={group.putCall} />
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(group.shares ?? 0)}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{group.netDelta !== 0 ? (
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-medium border rounded px-1.5 py-0.5 ${
|
||||
group.classification === 'increasing'
|
||||
? 'text-up border-[#064e3b]'
|
||||
: group.classification === 'reducing'
|
||||
? 'text-down border-[#7f1d1d]'
|
||||
: 'text-fg-muted border-line'
|
||||
}`}>
|
||||
{group.netDelta > 0 && '+'}
|
||||
{formatShares(group.netDelta)} ({directionLabel})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-muted text-[10px]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<FormBadge form={group.form} />
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right">
|
||||
{isExpanded ? (
|
||||
<span className="text-fg-muted text-[10px]">{group.quarters.length} filings</span>
|
||||
) : group.filerCik ? (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${group.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label={`View ${group.filerName ?? group.filerCik} filings on SEC EDGAR`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>↗</a>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded drill-down rows */}
|
||||
{isExpanded && group.quarters.map((qf: FilingQuarterly, qIdx: number) => (
|
||||
<tr key={`${expandKey}-q-${qIdx}`} className="border-b border-line/50 bg-[#121318]/60">
|
||||
<td className="py-1 px-4"></td>
|
||||
<td className="py-1 px-4 text-fg-muted text-[10px] pl-5">{formatQuarter(qf.reportedQuarter)}</td>
|
||||
<td className="py-1 px-4">
|
||||
<InstrumentTypeBadge putCall={qf.putCall} />
|
||||
</td>
|
||||
<td className="py-1 px-4 text-right font-mono text-fg">{formatShares(qf.shares ?? 0)}</td>
|
||||
<td className={`py-1 px-4 text-right font-mono text-xs ${qf.delta > 0 ? 'text-up' : qf.delta < 0 ? 'text-down' : 'text-fg-muted'}`}>
|
||||
{qf.delta === 0 ? '—' : `${qf.delta > 0 ? '+' : ''}${formatShares(qf.delta)}`}
|
||||
</td>
|
||||
<td className="py-1 px-4">
|
||||
<FormBadge form={qf.form} />
|
||||
</td>
|
||||
<td className="py-1 px-2 text-right">
|
||||
{group.filerCik && (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${group.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label={`View ${qf.reportedQuarter} filing on SEC EDGAR`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>↗</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-3 px-1">
|
||||
<span className="text-[10px] text-fg-muted">
|
||||
Showing {((instPage - 1) * instPageSize) + 1}-{Math.min(instPage * instPageSize, filteredGroups.length)} of {filteredGroups.length} groups
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setInstPage(Math.max(1, instPage - 1))}
|
||||
disabled={instPage === 1}
|
||||
className="text-[10px] px-2 py-0.5 border border-line rounded hover:bg-surface-raised disabled:opacity-30"
|
||||
>← Prev</button>
|
||||
<span className="text-[10px] text-fg-muted">Page {instPage}</span>
|
||||
<button
|
||||
onClick={() => setInstPage(instPage + 1)}
|
||||
disabled={instPage * instPageSize >= filteredGroups.length}
|
||||
className="text-[10px] px-2 py-0.5 border border-line rounded hover:bg-surface-raised disabled:opacity-30"
|
||||
>Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react";
|
||||
import { api, type LintResult } from "@/lib/trpc";
|
||||
import { BarChart, Bar, Cell, LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useActiveSymbol, useActiveSymbolGuarded } from "@/stores/active-symbol-store";
|
||||
import { TablePager, DEFAULT_PAGE_SIZE } from "@/components/TablePager";
|
||||
import { InstitutionalAccordion } from "./InstitutionalAccordion";
|
||||
|
||||
|
||||
// Local type alias matching server-side DashboardRollupRow (ADR-0007: neutral labels only).
|
||||
type ConvictionDelta = 'increasing' | 'reducing' | 'flat' | 'mixed';
|
||||
@@ -16,6 +18,63 @@ interface DashboardRollupRow {
|
||||
alert: null;
|
||||
}
|
||||
|
||||
interface SortState { key: string; dir: SortDir; }
|
||||
|
||||
function sortRows<T>(
|
||||
rows: T[],
|
||||
key: string,
|
||||
dir: SortDir,
|
||||
accessors: Record<string, (r: T) => number | string>,
|
||||
): T[] {
|
||||
const get = accessors[key] ?? (() => 0);
|
||||
return [...rows].sort((a, b) => {
|
||||
const va = get(a);
|
||||
const vb = get(b);
|
||||
const cmp = typeof va === 'number' && typeof vb === 'number'
|
||||
? va - vb
|
||||
: String(va).localeCompare(String(vb));
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
function SortTh({
|
||||
label,
|
||||
sortKey,
|
||||
sort,
|
||||
onSort,
|
||||
align = 'left',
|
||||
filter,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: string;
|
||||
sort: SortState;
|
||||
onSort: (k: string) => void;
|
||||
align?: 'left' | 'right';
|
||||
filter?: React.ReactNode;
|
||||
}) {
|
||||
const active = sort.key === sortKey;
|
||||
return (
|
||||
<th
|
||||
aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
className={`py-1.5 px-2 select-none ${align === 'right' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{filter ? (
|
||||
<div className="mt-0">{filter}</div>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => onSort(sortKey)}
|
||||
className="inline-flex items-center gap-1 cursor-pointer hover:text-fg"
|
||||
>
|
||||
{label}
|
||||
<span className={`text-[9px] ${active ? 'text-accent' : 'text-fg-muted/50'}`}>
|
||||
{active ? (sort.dir === 'asc' ? '▲' : '▼') : '↕'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -24,7 +83,6 @@ interface DashboardRollupRow {
|
||||
type SortField = 'symbol' | 'convictionDelta' | 'insiderRecencyDays' | 'classRollFlag';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
type ChartType = 'bar' | 'line';
|
||||
type GroupBy = 'quarter' | 'month';
|
||||
|
||||
interface FlowRow {
|
||||
filerCik: string;
|
||||
@@ -35,6 +93,7 @@ interface FlowRow {
|
||||
delta: number;
|
||||
classification: string;
|
||||
reportedQuarter: string;
|
||||
putCall: string;
|
||||
}
|
||||
|
||||
interface FilingRow {
|
||||
@@ -45,6 +104,7 @@ interface FilingRow {
|
||||
reportedQuarter: string;
|
||||
filedAt: string;
|
||||
form: string;
|
||||
putCall: string;
|
||||
}
|
||||
|
||||
interface QuarterlyAgg {
|
||||
@@ -63,12 +123,27 @@ interface InsiderMonthlyAgg {
|
||||
sells: number;
|
||||
}
|
||||
|
||||
const CONViction_WEIGHT: Record<string, number> = {
|
||||
mixed: 3,
|
||||
increasing: 2,
|
||||
reducing: 2,
|
||||
flat: 1,
|
||||
};
|
||||
// Filings accordion group — one row per (filer, instrument type, form)
|
||||
interface FilingGroupRow {
|
||||
filerCik: string;
|
||||
filerName: string | null;
|
||||
putCall: string; // instrument type label
|
||||
form: string; // filing source type (13F-HR, 13G, 13D)
|
||||
shares: number; // latest quarter aggregate shares for this combo
|
||||
netDelta: number; // total change vs earliest quarter of same combo
|
||||
classification: string; // 'increasing' | 'reducing' | 'flat'
|
||||
quarters: QuarterlyFiling[];
|
||||
}
|
||||
|
||||
interface QuarterlyFiling {
|
||||
reportedQuarter: string;
|
||||
shares: number;
|
||||
valueUsd: number;
|
||||
filedAt: string;
|
||||
form: string;
|
||||
putCall: string;
|
||||
delta: number; // vs prior quarter's same (filer, instrument, form)
|
||||
}
|
||||
|
||||
const DELTA_BADGE: Record<string, { label: string; color: string; shape: string }> = {
|
||||
mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]', shape: '?' },
|
||||
@@ -90,6 +165,36 @@ const ALERT_BADGE: Record<string, string> = {
|
||||
'mixed signal': 'bg-[#78350f] text-[#fcd34d] border border-[#78350f]',
|
||||
};
|
||||
|
||||
function FormBadge({ form }: { form: string }) {
|
||||
const is13F = form === '13F-HR';
|
||||
return (
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${
|
||||
is13F
|
||||
? 'text-fg-muted border-[#3f3f46]'
|
||||
: 'text-[#fcd34d] border-[#78350f]'
|
||||
}`}>
|
||||
{form}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InstrumentTypeBadge({ putCall }: { putCall: string }) {
|
||||
const pc = putCall?.toLowerCase();
|
||||
if (!putCall || pc === '') {
|
||||
return <span className="text-fg-muted text-[10px]">Common</span>;
|
||||
}
|
||||
const isCall = pc === 'call';
|
||||
return (
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${
|
||||
isCall
|
||||
? 'text-up border-[#064e3b] bg-[#064e3b]/20'
|
||||
: 'text-down border-[#7f1d1d] bg-[#7f1d1d]/20'
|
||||
}`}>
|
||||
{putCall}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Codes that are not voluntary market buys/sells (tax withholding, gifts, etc.)
|
||||
const NON_DISCRETIONARY_CODES = new Set(['F', 'G']);
|
||||
|
||||
@@ -116,37 +221,135 @@ function formatShares(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function aggregateFlowByMonth(filings: FilingRow[], flow: FlowRow[]): MonthlyAgg[] {
|
||||
// For each filing event, compute the filer's delta from their prior filing.
|
||||
// Group those deltas by the filing's month (YYYY-MM from filedAt).
|
||||
const byFiler = new Map<string, FilingRow[]>();
|
||||
/**
|
||||
* Group all filings into per-(filer, instrument type, form) rows for the accordion view.
|
||||
* Top-level: aggregate shares from latest quarter + net direction vs earliest quarter.
|
||||
*/
|
||||
function groupFilingsByFilerInstrument(filings: FilingRow[]): FilingGroupRow[] {
|
||||
// Group by (filerCik + putCall + form) — original behavior, no name normalization
|
||||
const byKey = new Map<string, FilingRow[]>();
|
||||
|
||||
for (const f of filings) {
|
||||
const arr = byFiler.get(f.filerCik) ?? [];
|
||||
const key = `${f.filerCik}|${f.putCall ?? ''}|${f.form}`;
|
||||
const arr = byKey.get(key) ?? [];
|
||||
arr.push(f);
|
||||
byFiler.set(f.filerCik, arr);
|
||||
byKey.set(key, arr);
|
||||
}
|
||||
const monthDelta = new Map<string, number>();
|
||||
for (const [, filerFilings] of byFiler) {
|
||||
const sorted = [...filerFilings].sort((a, b) => a.filedAt.localeCompare(b.filedAt));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i === 0) {
|
||||
const month = sorted[0].filedAt.slice(0, 7);
|
||||
monthDelta.set(month, (monthDelta.get(month) ?? 0) + sorted[0].shares);
|
||||
} else {
|
||||
const prev = sorted[i - 1];
|
||||
const curr = sorted[i];
|
||||
const delta = curr.shares - prev.shares;
|
||||
if (delta === 0) continue;
|
||||
const month = curr.filedAt.slice(0, 7);
|
||||
monthDelta.set(month, (monthDelta.get(month) ?? 0) + delta);
|
||||
|
||||
const result: FilingGroupRow[] = [];
|
||||
|
||||
for (const [key, groupFilings] of byKey) {
|
||||
// Sort ascending by filedAt within this bucket
|
||||
const sortedAsc = [...groupFilings].sort((a, b) => a.filedAt.localeCompare(b.filedAt));
|
||||
if (sortedAsc.length === 0) continue;
|
||||
|
||||
// Deduplicate: keep newest filing per (filerCik + reportedQuarter + form)
|
||||
const seenQuartersByForm = new Map<string, FilingRow[]>();
|
||||
for (const f of sortedAsc) {
|
||||
const qKey = `${f.reportedQuarter}|${f.form}`;
|
||||
const arr = seenQuartersByForm.get(qKey) ?? [];
|
||||
arr.push(f);
|
||||
seenQuartersByForm.set(qKey, arr);
|
||||
}
|
||||
|
||||
// Build quarterly filing entries (one per deduplicated quarter+form combo)
|
||||
interface QuarterEntry {
|
||||
reportedQuarter: string;
|
||||
form: string;
|
||||
shares: number;
|
||||
valueUsd: number;
|
||||
filedAt: string;
|
||||
putCall: string;
|
||||
}
|
||||
const quarterEntries: QuarterEntry[] = [];
|
||||
|
||||
for (const [, entries] of seenQuartersByForm) {
|
||||
// Within same quarter+form, prefer non-13F-HR (i.e. 13G/13D) over 13F-HR
|
||||
const preferred = entries.find(e => e.form !== '13F-HR') ?? entries[0];
|
||||
quarterEntries.push({
|
||||
reportedQuarter: preferred.reportedQuarter,
|
||||
form: preferred.form,
|
||||
shares: preferred.shares ?? 0,
|
||||
valueUsd: preferred.valueUsd ?? 0,
|
||||
filedAt: preferred.filedAt,
|
||||
putCall: preferred.putCall ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// Sort quarterly entries newest-first for drill-down display
|
||||
const quartersSortedDesc = quarterEntries.sort((a, b) => {
|
||||
if (a.reportedQuarter !== b.reportedQuarter) {
|
||||
return b.reportedQuarter.localeCompare(a.reportedQuarter);
|
||||
}
|
||||
return b.filedAt.localeCompare(a.filedAt);
|
||||
});
|
||||
|
||||
// Compute QoQ deltas within this bucket (comparing same form across quarters)
|
||||
const quarterlyFilings: QuarterlyFiling[] = [];
|
||||
for (let i = 0; i < quartersSortedDesc.length; i++) {
|
||||
const curr = quartersSortedDesc[i];
|
||||
let delta = 0;
|
||||
|
||||
// Compare each quarter to the next-older quarter with the same form
|
||||
const priorSameForm = quartersSortedDesc.slice(i + 1).find(p => p.form === curr.form);
|
||||
if (priorSameForm && priorSameForm.shares > 0) {
|
||||
delta = curr.shares - priorSameForm.shares;
|
||||
}
|
||||
|
||||
quarterlyFilings.push({
|
||||
reportedQuarter: curr.reportedQuarter,
|
||||
shares: curr.shares,
|
||||
valueUsd: curr.valueUsd,
|
||||
filedAt: curr.filedAt,
|
||||
form: curr.form,
|
||||
putCall: curr.putCall,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
|
||||
// Latest quarter = first in desc-sorted list
|
||||
const latestQuarter = quartersSortedDesc[0];
|
||||
const shares = latestQuarter.shares;
|
||||
|
||||
// Net direction: compare latest vs earliest (same form type)
|
||||
let netDelta = 0;
|
||||
if (quartersSortedDesc.length > 1) {
|
||||
const earliestSameForm = [...quartersSortedDesc].reverse().find(q => q.form === latestQuarter.form);
|
||||
if (earliestSameForm && earliestSameForm.shares > 0) {
|
||||
netDelta = shares - earliestSameForm.shares;
|
||||
}
|
||||
}
|
||||
|
||||
// Classification based on net delta direction
|
||||
let classification: string;
|
||||
if (netDelta > 0) classification = 'increasing';
|
||||
else if (netDelta < 0) classification = 'reducing';
|
||||
else classification = 'flat';
|
||||
|
||||
// Extract filerCik and putCall from key (format: cik|putCall|form)
|
||||
const [filerCik, putCall] = key.split('|');
|
||||
|
||||
result.push({
|
||||
filerCik,
|
||||
filerName: groupFilings[0].filerName ?? null,
|
||||
putCall: putCall || '',
|
||||
form: latestQuarter.form,
|
||||
shares,
|
||||
netDelta,
|
||||
classification,
|
||||
quarters: quarterlyFilings,
|
||||
});
|
||||
}
|
||||
return [...monthDelta.entries()]
|
||||
.map(([month, netDelta]) => ({ month, netDelta }))
|
||||
.sort((a, b) => b.month.localeCompare(a.month));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const DELTA_BADGE_LABELS: Record<string, { label: string }> = {
|
||||
increasing: { label: '▲ Increasing' },
|
||||
reducing: { label: '▼ Reducing' },
|
||||
flat: { label: '— Flat' },
|
||||
};
|
||||
|
||||
function formatMonth(m: string): string {
|
||||
const d = new Date(m + '-01');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
@@ -168,14 +371,14 @@ function aggregateFlowByQuarter(flow: FlowRow[]): QuarterlyAgg[] {
|
||||
});
|
||||
}
|
||||
|
||||
function aggregateInsiderByMonth(events: Array<{ transactionDate: string; transactionCode: string; shares: number }>): InsiderMonthlyAgg[] {
|
||||
function aggregateInsiderByMonth(events: Array<{ transactionDate: string; transactionType: string; shares: number }>): InsiderMonthlyAgg[] {
|
||||
const byMonth = new Map<string, { buys: number; sells: number }>();
|
||||
for (const e of events) {
|
||||
const month = e.transactionDate.slice(0, 7);
|
||||
if (month.length !== 7) continue;
|
||||
const acc = byMonth.get(month) ?? { buys: 0, sells: 0 };
|
||||
if (e.transactionCode === 'P') acc.buys += e.shares;
|
||||
else if (e.transactionCode === 'S') acc.sells += e.shares;
|
||||
if (e.transactionType === 'buy') acc.buys += e.shares;
|
||||
else if (e.transactionType === 'sell') acc.sells += e.shares;
|
||||
byMonth.set(month, acc);
|
||||
}
|
||||
return [...byMonth.entries()]
|
||||
@@ -184,26 +387,42 @@ function aggregateInsiderByMonth(events: Array<{ transactionDate: string; transa
|
||||
}
|
||||
|
||||
export function InstitutionalDashboard() {
|
||||
const [sortField, setSortField] = useState<SortField>('convictionDelta');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [symbolInput, setSymbolInput] = useState('');
|
||||
const [rollupRows, setRollupRows] = useState<DashboardRollupRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [rollupLoaded, setRollupLoaded] = useState(false);
|
||||
|
||||
// Detail panel state (symbol drill-down)
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const activeSymbol = useActiveSymbolGuarded();
|
||||
const [chartType, setChartType] = useState<ChartType>('bar');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('quarter');
|
||||
const [showInstitutionBreakdown, setShowInstitutionBreakdown] = useState(true);
|
||||
|
||||
// Filings accordion state — track which (filerCik|putCall|form) groups are expanded
|
||||
const [expandedFilers, setExpandedFilers] = useState<Set<string>>(new Set());
|
||||
const toggleFiler = useCallback((key: string) => {
|
||||
setExpandedFilers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Institution breakdown table — sort + pagination
|
||||
const [instPage, setInstPage] = useState(1);
|
||||
const [instPageSize, setInstPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [filerSort, setFilerSort] = useState<SortState>({ key: 'shares', dir: 'desc' });
|
||||
const [filerFilter, setFilerFilter] = useState('');
|
||||
const [quarterFilter, setQuarterFilter] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
|
||||
const [flowData, setFlowData] = useState<{ symbol: string; flow: FlowRow[]; quarters: string[]; filings: FilingRow[] } | null>(null);
|
||||
interface InsiderEvent {
|
||||
reporter: string;
|
||||
relationship?: string | null;
|
||||
transactionDate: string;
|
||||
transactionCode: string;
|
||||
transactionType: string;
|
||||
shares: number;
|
||||
price: number;
|
||||
}
|
||||
@@ -223,8 +442,8 @@ export function InstitutionalDashboard() {
|
||||
useEffect(() => {
|
||||
api.dashboard.rollup()
|
||||
.then((rows) => setRollupRows(rows as unknown as DashboardRollupRow[]))
|
||||
.catch((e: Error) => setError(e))
|
||||
.finally(() => setIsLoading(false));
|
||||
.catch(() => {})
|
||||
.finally(() => setRollupLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Keyboard listener: Escape closes detail panel
|
||||
@@ -241,8 +460,9 @@ export function InstitutionalDashboard() {
|
||||
// Fetch flow + insider data for selected symbol
|
||||
const fetchDetailData = useCallback(async (symbol: string) => {
|
||||
setChartType('bar');
|
||||
setGroupBy('quarter');
|
||||
setShowInstitutionBreakdown(true);
|
||||
setExpandedFilers(new Set());
|
||||
setFlowData(null);
|
||||
setInsiderData(null);
|
||||
setFlowLoading(true);
|
||||
setInsiderLoading(true);
|
||||
setFlowError(null);
|
||||
@@ -273,6 +493,7 @@ export function InstitutionalDashboard() {
|
||||
delta: number;
|
||||
classification: string;
|
||||
reportedQuarter: string;
|
||||
putCall: string;
|
||||
}>).map(r => ({ ...r, prevShares: r.prevShares ?? 0, currShares: r.currShares ?? 0, delta: r.delta ?? 0 })),
|
||||
quarters: flowResult.quarters,
|
||||
filings: ((flowResult as Record<string, unknown>).filings as FilingRow[]) ?? [],
|
||||
@@ -288,6 +509,7 @@ export function InstitutionalDashboard() {
|
||||
relationship?: string | null;
|
||||
transactionDate: string;
|
||||
transactionCode: string;
|
||||
transactionType: string;
|
||||
shares: number;
|
||||
price: number;
|
||||
}>),
|
||||
@@ -333,38 +555,15 @@ export function InstitutionalDashboard() {
|
||||
}
|
||||
}, [selectedSymbol]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!rollupRows || rollupRows.length === 0) 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 selectedRow = useMemo(() => {
|
||||
if (!rollupRows || !selectedSymbol) return null;
|
||||
return rollupRows.find(r => r.symbol === selectedSymbol) ?? null;
|
||||
}, [rollupRows, selectedSymbol]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
const handleSymbolSubmit = useCallback(() => {
|
||||
const sym = symbolInput.trim().toUpperCase();
|
||||
if (sym) setSelectedSymbol(sym);
|
||||
}, [symbolInput]);
|
||||
|
||||
const chartSection = useMemo(() => {
|
||||
if (flowLoading) {
|
||||
@@ -488,141 +687,59 @@ export function InstitutionalDashboard() {
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
|
||||
<header className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-base font-semibold text-fg">Institutional Dashboard</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="inst-filter" className="text-xs text-fg-muted">Filter:</label>
|
||||
<select
|
||||
id="inst-filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs bg-[#1e1f2a] border border-line text-fg rounded-lg px-2 py-1 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
<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>
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
|
||||
<header className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-fg">Institutional Dashboard</h2>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-fg-muted py-8">Loading rollup...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-danger py-8">Error loading rollup data.</p>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-fg-muted">No symbols in watchlist or portfolio.</p>
|
||||
<p className="text-xs text-fg-muted mt-1">Add symbols to your watchlist to see institutional data.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-fg-muted">
|
||||
<th
|
||||
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSort('symbol')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('symbol')}
|
||||
title="Sort by ticker symbol."
|
||||
>
|
||||
Symbol {sortField === 'symbol' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSort('convictionDelta')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('convictionDelta')}
|
||||
title={'Conviction = institutions (13F, reported quarterly) + insiders (Form 4, reported within 2 days) combined. Quarter-over-quarter: latest vs prior, +/-10% threshold. Increasing ▲ = net buying. Reducing ▼ = net selling. Mixed ? = institutions and insiders disagree. Flat — = little change.'}
|
||||
>
|
||||
Conviction Δ {sortField === 'convictionDelta' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSort('insiderRecencyDays')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('insiderRecencyDays')}
|
||||
title="Days since the last insider transaction of any kind (buys, sells, or grants). Blank if the company has no insider filings on record."
|
||||
>
|
||||
Insider Recency {sortField === 'insiderRecencyDays' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSort('classRollFlag')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('classRollFlag')}
|
||||
title="Whether the mix of institutional holder types (by industry/SIC) changed between the last two quarters. 'Yes' means new types of holders appeared or left."
|
||||
>
|
||||
Class Roll {sortField === 'classRollFlag' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3" title="Attention signal derived from conviction (quarter-over-quarter, combining 13F and Form 4). Accumulation = institutions net buying. Distribution = net selling. Mixed signal = institutions and insiders disagree. Blank when conviction is flat.">
|
||||
Alert
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.symbol}
|
||||
onClick={() => setSelectedSymbol(row.symbol)}
|
||||
className={`border-b border-[#1e1f2a] hover:bg-surface-sunken transition-colors cursor-pointer ${
|
||||
selectedSymbol === row.symbol ? 'bg-accent/10 border-l-2 border-l-[#4f8cff]' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3 font-mono text-fg">{row.symbol}</td>
|
||||
<td className="py-2 px-3">
|
||||
{(() => {
|
||||
const badge = DELTA_BADGE[row.convictionDelta];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs uppercase tracking-wider border rounded-lg px-2 py-0.5 ${badge.color}`}>
|
||||
<span aria-hidden="true">{badge.shape}</span>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-fg-muted">
|
||||
{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-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{row.alert ? (
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${ALERT_BADGE[row.alert] ?? 'text-fg-muted border-line'}`}>
|
||||
{row.alert}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-muted text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[10px] text-fg-muted">
|
||||
<span className="font-semibold uppercase tracking-wide text-fg-muted">Conviction:</span>
|
||||
<span className="font-semibold uppercase tracking-wide text-fg-muted">(quarter-over-quarter — 13F + Form 4)</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-up" aria-hidden="true">▲</span> Increasing — net buying vs prior quarter
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-down" aria-hidden="true">▼</span> Reducing — net selling vs prior quarter
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-[#fcd34d]" aria-hidden="true">?</span> Mixed — institutions & insiders disagree
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-fg-muted" aria-hidden="true">—</span> Flat — little change
|
||||
{selectedRow ? (
|
||||
<div className="flex items-center justify-between gap-4 p-3 bg-surface-sunken border border-line rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm font-semibold text-fg">{selectedRow.symbol}</span>
|
||||
<span className={`inline-flex items-center gap-1 text-xs uppercase tracking-wider border rounded-lg px-2 py-0.5 ${DELTA_BADGE[selectedRow.convictionDelta].color}`}>
|
||||
<span aria-hidden="true">{DELTA_BADGE[selectedRow.convictionDelta].shape}</span>
|
||||
{DELTA_BADGE[selectedRow.convictionDelta].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-fg-muted">
|
||||
<span title="Days since last insider transaction">
|
||||
Insider recency: <span className="text-fg font-mono">{selectedRow.insiderRecencyDays !== null ? `${selectedRow.insiderRecencyDays}d` : '—'}</span>
|
||||
</span>
|
||||
<span className="w-px h-4 bg-line" />
|
||||
<span title="Change in holder type composition">
|
||||
Class roll: <span className={selectedRow.classRollFlag ? 'text-[#fcd34d] font-mono' : 'text-fg-muted'}>{selectedRow.classRollFlag ? 'Yes' : '—'}</span>
|
||||
</span>
|
||||
{selectedRow.alert && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-line" />
|
||||
<span className={`px-1.5 py-0.5 rounded border text-[10px] ${ALERT_BADGE[selectedRow.alert] ?? 'text-fg-muted border-line'}`}>
|
||||
{selectedRow.alert}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-[10px] text-fg-muted" title="Conviction = institutions + insiders combined. ▲ Increasing (net buying), ▼ Reducing (net selling), ? Mixed (disagree), — Flat.">
|
||||
Conviction
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={symbolInput}
|
||||
onChange={(e) => setSymbolInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSymbolSubmit()}
|
||||
placeholder="Search symbol..."
|
||||
className="flex-1 bg-[#1e1f2a] border border-line text-fg rounded-lg px-3 py-1.5 text-sm placeholder:text-fg-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSymbolSubmit}
|
||||
className="text-xs px-3 py-1.5 bg-accent text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -631,26 +748,14 @@ export function InstitutionalDashboard() {
|
||||
<div ref={detailRef} className="mt-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-line pb-3">
|
||||
<h3 className="text-base font-semibold text-fg flex items-center gap-2">
|
||||
<span className="font-mono">{selectedSymbol}</span>
|
||||
{(() => {
|
||||
const row = rollupRows.find(r => r.symbol === selectedSymbol);
|
||||
const deltaKey = row?.convictionDelta ?? 'flat';
|
||||
const badge = DELTA_BADGE[deltaKey];
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 border rounded-lg ${badge?.color ?? 'text-fg-muted border-line'}`}>
|
||||
{badge?.label ?? '—'}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</h3>
|
||||
<h3 className="text-base font-semibold text-fg">Institutional Detail</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{lintResult?.holders && (
|
||||
<span className={`text-[10px] px-2 py-0.5 border rounded-lg ${lintResult.holders.status === 'ok' ? 'border-green-700 text-green-400 bg-green-950/30' : lintResult.holders.status === 'gaps_found' ? 'border-orange-600 text-orange-400 bg-orange-950/30' : lintResult.holders.stale ? 'border-yellow-700 text-yellow-400 bg-yellow-950/30' : 'border-red-700 text-red-400 bg-red-950/30'}`}>
|
||||
holders {lintResult.holders.status} ({lintResult.holders.backfilled} backfilled)
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => handleRunLint('sec-lint-holders')} disabled={lintLoading} className="text-[10px] px-2 py-0.5 border border-dashed border-line-strong text-fg-muted hover:border-accent/60 hover:text-accent rounded transition-colors disabled:opacity-30" title="Reconcile institutional holders for this symbol (backfills missing 13F filings)">
|
||||
<button onClick={() => handleRunLint('sec-lint-holders')} disabled={lintLoading} className="text-[10px] px-2 py-0.5 border border-dashed border-line-strong text-fg-muted hover:border-accent/60 hover:text-accent rounded transition-colors disabled:opacity-30" title="Reconcile institutional holders + SC 13D/13G filings for this symbol (backfills missing data)">
|
||||
{lintLoading ? '...' : 'Lint holders'}
|
||||
</button>
|
||||
<button onClick={() => handleCloseDetail()} aria-label="Close detail panel" className="text-fg-muted hover:text-fg text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded px-1.5 focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]">
|
||||
@@ -664,23 +769,22 @@ export function InstitutionalDashboard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-fg" title="Institutional holdings from 13F filings, reported quarterly 45 days after quarter end. Shows net position changes per filer.">Institutional Ownership (13F)</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-fg-muted px-2 py-1 border border-[#3f3f46] rounded-lg">By Quarter</span>
|
||||
{/* Chart type toggle */}
|
||||
<div className="inline-flex rounded-lg border border-[#3f3f46] overflow-hidden" role="group" aria-label="Chart type">
|
||||
{(['bar', 'line'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setChartType(type)}
|
||||
className={`px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
chartType === type
|
||||
? 'bg-accent text-white'
|
||||
: 'text-fg-muted hover:text-fg'
|
||||
}`}
|
||||
aria-pressed={chartType === type}
|
||||
>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{(['bar', 'line'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setChartType(type)}
|
||||
className={`px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
chartType === type
|
||||
? 'bg-accent text-white'
|
||||
: 'text-fg-muted hover:text-fg'
|
||||
}`}
|
||||
aria-pressed={chartType === type}
|
||||
>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -690,102 +794,25 @@ export function InstitutionalDashboard() {
|
||||
{chartSection}
|
||||
</div>
|
||||
|
||||
{/* Institution breakdown toggle */}
|
||||
{flowData && flowData.flow.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowInstitutionBreakdown(!showInstitutionBreakdown)}
|
||||
className="text-xs text-accent hover:text-[#6ba3ff] underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded px-1"
|
||||
>
|
||||
{showInstitutionBreakdown ? 'Hide' : 'Show'} {groupBy === 'month' ? 'filings' : 'institutions'} ({groupBy === 'month' ? flowData.filings.length : flowData.flow.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Institution breakdown table */}
|
||||
{showInstitutionBreakdown && flowData && (
|
||||
<div className="overflow-x-auto mt-2">
|
||||
{groupBy === 'quarter' ? (
|
||||
<table className="w-full text-xs" role="table" aria-label={`Institutional ownership breakdown for ${selectedSymbol}`}>
|
||||
<thead>
|
||||
<tr className="border-b border-[#3f3f46] text-fg-muted">
|
||||
<th className="text-left py-1.5 px-2">Filer</th>
|
||||
<th className="text-left py-1.5 px-2">Classification</th>
|
||||
<th className="text-right py-1.5 px-2">Prior Shares</th>
|
||||
<th className="text-right py-1.5 px-2">Current Shares</th>
|
||||
<th className="text-right py-1.5 px-2">Δ</th>
|
||||
<th className="text-left py-1.5 px-2">Quarter</th>
|
||||
<th className="py-1.5 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...flowData.flow].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)).map((inst, idx) => (
|
||||
<tr key={`${inst.filerCik}-${idx}`} className="border-b border-line">
|
||||
<td className="py-1.5 px-2 text-fg">{inst.filerName ?? inst.filerCik}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${CLASSIFICATION_BADGE[inst.classification] ?? 'text-fg-muted border-[#3f3f46]'}`}>
|
||||
{inst.classification}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(inst.prevShares)}</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(inst.currShares)}</td>
|
||||
<td className={`py-1.5 px-2 text-right font-mono ${inst.delta >= 0 ? 'text-up' : 'text-down'}`}>
|
||||
{inst.delta >= 0 ? '+' : ''}{formatShares(inst.delta)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-fg-muted">{formatQuarter(inst.reportedQuarter)}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{inst.filerCik && (
|
||||
<a
|
||||
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${inst.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label={`View ${inst.filerName ?? inst.filerCik} filings on SEC EDGAR`}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>↗</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full text-xs" role="table" aria-label={`Individual filings for ${selectedSymbol}`}>
|
||||
<thead>
|
||||
<tr className="border-b border-[#3f3f46] text-fg-muted">
|
||||
<th className="text-left py-1.5 px-2">Filer</th>
|
||||
<th className="text-right py-1.5 px-2">Shares</th>
|
||||
<th className="text-right py-1.5 px-2">Value ($)</th>
|
||||
<th className="text-left py-1.5 px-2">Filed</th>
|
||||
<th className="text-left py-1.5 px-2">Quarter</th>
|
||||
<th className="py-1.5 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{flowData.filings.length === 0 ? (
|
||||
<tr><td colSpan={99} className="py-4 text-center text-fg-muted text-xs">No individual filing data available.</td></tr>
|
||||
) : (
|
||||
[...flowData.filings]
|
||||
.sort((a, b) => b.filedAt.localeCompare(a.filedAt))
|
||||
.map((f, idx) => (
|
||||
<tr key={`${f.filerCik}-${f.filedAt}-${idx}`} className="border-b border-line">
|
||||
<td className="py-1.5 px-2 text-fg">{f.filerName ?? f.filerCik}</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(f.shares)}</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-fg">{(f.valueUsd / 1000).toFixed(0)}K</td>
|
||||
<td className="py-1.5 px-2 text-fg">{f.filedAt}</td>
|
||||
<td className="py-1.5 px-2 text-fg-muted">{formatQuarter(f.reportedQuarter)}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{f.filerCik && (
|
||||
<a href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${f.filerCik}&type=&date=&owner=include&count=40`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label={`View ${f.filerName ?? f.filerCik} filings on SEC EDGAR`}
|
||||
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>↗</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{/* Institution breakdown accordion: per-(filer, instrument, form) rows with expandable drill-down */}
|
||||
{flowData && flowData.filings.length > 0 && (
|
||||
<InstitutionalAccordion
|
||||
filings={groupFilingsByFilerInstrument(flowData.filings)}
|
||||
filerFilter={filerFilter}
|
||||
setFilerFilter={setFilerFilter}
|
||||
quarterFilter={quarterFilter}
|
||||
setQuarterFilter={setQuarterFilter}
|
||||
sourceFilter={sourceFilter}
|
||||
setSourceFilter={setSourceFilter}
|
||||
filerSort={filerSort}
|
||||
setFilerSort={setFilerSort}
|
||||
instPage={instPage}
|
||||
setInstPage={setInstPage}
|
||||
instPageSize={instPageSize}
|
||||
setInstPageSize={setInstPageSize}
|
||||
expandedFilers={expandedFilers}
|
||||
toggleFiler={toggleFiler}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -793,6 +820,10 @@ export function InstitutionalDashboard() {
|
||||
{flowData && flowData.filings.filter(f => f.form === '13D' || f.form === '13G').length > 0 && (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-fg" title="13D (activist) and 13G (passive) filings — triggered when a holder crosses 5% ownership. Filed within 10 days (13D) or 45 days after year end (13G).">Significant Ownership (>5%)</h4>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">
|
||||
<strong className="text-fg">13F-HR</strong> — All large institutional managers report their full portfolio quarterly.
|
||||
<strong className="text-[#fcd34d]">13G</strong> — Passive investors who own more than 5% of a company report just that holding. 13G shows ownership <em>percentage</em> but no dollar value.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs" role="table" aria-label="Significant ownership filings for selectedSymbol">
|
||||
<thead>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SidebarNav } from "./SidebarNav";
|
||||
import { WatchlistSidebar } from "./WatchlistSidebar";
|
||||
import { SymbolHeader } from "./SymbolHeader";
|
||||
import { MobileTabNav } from "./MobileTabNav";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { CollapsibleProvider, useCollapsibleContext } from "@/lib/collapsible-context";
|
||||
import { useKeyboardShortcuts } from "@/lib/useKeyboardShortcuts";
|
||||
|
||||
@@ -57,15 +58,18 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
}, [openSectionId, setOpenSectionId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0b0f] text-[#e6e7ec] flex flex-col">
|
||||
<div className="min-h-screen bg-surface text-fg flex flex-col">
|
||||
{/* Symbol Header — always visible */}
|
||||
<SymbolHeader />
|
||||
|
||||
{/* Global command palette (⌘K) */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* Main Content Area — responsive layout */}
|
||||
{isMobile ? (
|
||||
/* Mobile: single column, no sidebar */
|
||||
<>
|
||||
<main className="flex-1 overflow-y-auto p-4 pb-20">
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden p-4 pb-20">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
@@ -76,7 +80,7 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
/* Tablet: 2-column layout, watchlist accessible via compact bar */
|
||||
<div className="flex flex-1 overflow-hidden flex-col">
|
||||
{/* Compact horizontal watchlist bar */}
|
||||
<div className="hidden md:block border-b border-[#2a2b3a] bg-[#121318] px-4 py-2 overflow-x-auto">
|
||||
<div className="hidden md:block border-b border-line-strong bg-surface-raised px-4 sm:px-6 py-2 overflow-x-auto">
|
||||
<WatchlistSidebar compact />
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@@ -87,8 +91,8 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</main>
|
||||
{/* Right analytics — drops below on tablet */}
|
||||
<aside className="hidden lg:block w-80 border-l border-[#2a2b3a] bg-[#121318] overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
<aside className="hidden lg:block w-80 border-l border-line-strong bg-surface-raised overflow-y-auto">
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Right column content placeholder */}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -104,7 +108,7 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
<WatchlistSidebar />
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-4 xl:p-4 2xl:p-4">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type MacroCommentary } from '@/lib/trpc';
|
||||
|
||||
export function MacroCommentaryPanel() {
|
||||
const [commentary, setCommentary] = useState<MacroCommentary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.market.commentary()
|
||||
.then((c) => { if (!cancelled) { setCommentary(c); setLoading(false); } })
|
||||
.catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'request failed'); setLoading(false); } });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-fg-muted py-4">Loading macro commentary…</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted py-4">
|
||||
Macro commentary requires a FRED API key (Admin → X Accounts).
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!commentary) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-line bg-surface-sunken p-4">
|
||||
<h4 className="text-[10px] uppercase tracking-wider text-fg-muted mb-2">Near-term</h4>
|
||||
<p className="text-sm text-fg leading-relaxed">{commentary.shortTerm}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-surface-sunken p-4">
|
||||
<h4 className="text-[10px] uppercase tracking-wider text-fg-muted mb-2">Structural</h4>
|
||||
<p className="text-sm text-fg leading-relaxed">{commentary.longTerm}</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted italic">{commentary.disclaimer}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, Tooltip, ReferenceLine,
|
||||
ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import { api, type ManufacturingPmiData, type TruckSalesObservation } from '@/lib/trpc';
|
||||
|
||||
function formatLevel(value: number): string {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
export function ManufacturingPmiChart() {
|
||||
const [data, setData] = useState<ManufacturingPmiData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.market.manufacturingPmi()
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">Loading manufacturing activity data…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">Failed to load manufacturing activity data.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.configured) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center space-y-2">
|
||||
<p className="text-xs text-fg-muted">FRED API key not configured.</p>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Add one in the <span className="font-mono text-link">Admin → X Accounts</span> panel to display live manufacturing activity data.
|
||||
</p>
|
||||
{data.error && <p className="text-[11px] text-fg-muted mt-1">{data.error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center space-y-2">
|
||||
<p className="text-xs text-warn">FRED API error</p>
|
||||
<p className="text-[11px] text-fg-muted">{data.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.observations.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">No manufacturing activity data returned from FRED.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data.observations.map((o: TruckSalesObservation) => ({
|
||||
date: o.date,
|
||||
value: o.value,
|
||||
}));
|
||||
|
||||
const current = chartData[chartData.length - 1]?.value ?? 0;
|
||||
const prior3 = chartData.length >= 4 ? chartData[chartData.length - 4]?.value ?? null : null;
|
||||
const prior12 = chartData.length >= 13 ? chartData[chartData.length - 13]?.value ?? null : null;
|
||||
const threeMoDelta = prior3 !== null ? current - prior3 : null;
|
||||
const yoyPct = prior12 !== null ? ((current - prior12) / prior12) * 100 : null;
|
||||
|
||||
// Data-driven summary (expansion = output rising, contraction = output falling)
|
||||
const expanding = threeMoDelta !== null ? threeMoDelta > 0 : true;
|
||||
const phaseColor = expanding ? 'text-up' : 'text-danger';
|
||||
const trendWord = threeMoDelta === null
|
||||
? 'trend unavailable'
|
||||
: threeMoDelta > 0.1
|
||||
? 'rising'
|
||||
: threeMoDelta < -0.1
|
||||
? 'falling'
|
||||
: 'flat';
|
||||
const summary = (
|
||||
<p className="text-xs text-fg">
|
||||
Manufacturing output <span className="font-mono font-semibold">{formatLevel(current)}</span>{' '}
|
||||
<span className="text-fg-muted">(Index 2017=100)</span> —{' '}
|
||||
<span className={phaseColor}>
|
||||
{expanding ? 'rising — expansion' : 'falling — contraction'}
|
||||
</span>
|
||||
{yoyPct !== null && <>; {yoyPct >= 0 ? '+' : ''}{yoyPct.toFixed(1)}% YoY</>}.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{summary}
|
||||
<div className="rounded-lg overflow-hidden border border-line bg-surface-sunken">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={chartData} margin={{ top: 16, right: 12, bottom: 4, left: 4 }}>
|
||||
<defs>
|
||||
<linearGradient id="pmiAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-accent, #3b82f6)" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="var(--color-accent, #3b82f6)" stopOpacity={0.04} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-line, #e5e7eb)" opacity={0.4} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: 'var(--color-fg-muted, #9ca3af)' }}
|
||||
tickFormatter={(d) => d.slice(0, 4)}
|
||||
axisLine={{ stroke: 'var(--color-line, #e5e7eb)' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'var(--color-fg-muted, #9ca3af)' }}
|
||||
tickFormatter={(v: number) => v.toFixed(0)}
|
||||
domain={['auto', 'auto']}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface-raised, #1f2937)',
|
||||
border: '1px solid var(--color-line, #374151)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelFormatter={(label) => label}
|
||||
formatter={(value) => [Number(value).toFixed(1), 'Index']}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={100}
|
||||
stroke="var(--color-fg-muted, #9ca3af)"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={1.5}
|
||||
label={{ value: '100 (2017 base)', position: 'insideTopRight', fontSize: 10, fill: 'var(--color-fg-muted, #9ca3af)' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--color-accent, #3b82f6)"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#pmiAreaFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: 'var(--color-accent, #3b82f6)' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className={`rounded-lg bg-surface-sunken border border-line p-3 border-t-2 border-t-up ${expanding ? 'ring-1 ring-up' : ''}`}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Output rising</p>
|
||||
<p className="text-xs text-fg">Expansion — factories producing more month over month</p>
|
||||
<p className="text-[10px] text-fg-muted mt-1">Current: {formatLevel(current)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-3 border-t-2 border-t-danger">
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Output falling</p>
|
||||
<p className="text-xs text-fg">Contraction — real manufacturing output declining</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type MarketCondition } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui";
|
||||
|
||||
function fmtPct(v: number | null, digits = 1): string {
|
||||
if (v === null || !Number.isFinite(v)) return "—";
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return `${sign}${v.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
function regimeTone(regime: MarketCondition["regime"]): "up" | "warn" | "danger" | "neutral" {
|
||||
if (regime === "trending-up") return "up";
|
||||
if (regime === "trending-down") return "danger";
|
||||
return "warn";
|
||||
}
|
||||
|
||||
function regimeLabel(data: MarketCondition): string {
|
||||
return data.regimePlain
|
||||
?? (data.regime === "trending-up" ? "Trending up"
|
||||
: data.regime === "trending-down" ? "Trending down"
|
||||
: "Range-bound");
|
||||
}
|
||||
|
||||
/**
|
||||
* Market condition summary: regime, key metrics, and risk flags.
|
||||
*/
|
||||
export function MarketConditionStrip() {
|
||||
const [data, setData] = useState<MarketCondition | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.market.condition()
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : "request failed");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-4">
|
||||
<p className="text-sm text-fg-muted">Loading market condition…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-4">
|
||||
<p className="text-sm text-fg-muted">
|
||||
Market condition unavailable. Configure a FRED API key in Admin and ensure SPY history is cached.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const m = data.metrics;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 space-y-4">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">Market condition</h2>
|
||||
<p className="text-xs text-fg-muted mt-0.5">
|
||||
Regime snapshot and risk flags from available data. Updated{" "}
|
||||
{new Date(data.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={regimeTone(data.regime)}>{regimeLabel(data)}</Badge>
|
||||
<Badge tone="neutral">{data.confidence}% confidence</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
<Metric label="SPY (1M)" value={fmtPct(m.spy1M)} />
|
||||
<Metric label="VIX" value={m.vix != null ? m.vix.toFixed(1) : "—"} />
|
||||
<Metric label="10Y yield" value={m.treasury10Y != null ? `${m.treasury10Y.toFixed(2)}%` : "—"} />
|
||||
<Metric label="2Y yield" value={m.treasury2Y != null ? `${m.treasury2Y.toFixed(2)}%` : "—"} />
|
||||
<Metric
|
||||
label="10Y−2Y curve"
|
||||
value={m.curve10y2y != null ? `${m.curve10y2y.toFixed(2)} pp` : "—"}
|
||||
/>
|
||||
<Metric label="Unemployment" value={m.unemployment != null ? `${m.unemployment.toFixed(1)}%` : "—"} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-fg-muted mb-2">Risk flags</h3>
|
||||
<ul className="space-y-2">
|
||||
{data.risks.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="rounded-md border border-line bg-surface-sunken px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge
|
||||
tone={
|
||||
r.severity === "elevated" ? "danger"
|
||||
: r.severity === "warning" ? "warn"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{r.severity}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-fg">{r.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{r.detail}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-faint italic">{data.lesson}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{label}</p>
|
||||
<p className="text-sm font-semibold tnum text-fg mt-0.5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +1,144 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useActiveSymbol } from '@/stores/active-symbol-store';
|
||||
import { api, type Snapshot } from '@/lib/trpc';
|
||||
"use client";
|
||||
import { useMarketOutlookSections, visibleCount } from "@/stores/market-outlook-sections-store";
|
||||
import { HeavyTruckSalesChart } from "./HeavyTruckSalesChart";
|
||||
import { ManufacturingPmiChart } from "./ManufacturingPmiChart";
|
||||
import { MarketRotationPanel } from "./MarketRotationPanel";
|
||||
import { RotationSignalAlert } from "./RotationSignalAlert";
|
||||
import { MacroCommentaryPanel } from "./MacroCommentaryPanel";
|
||||
import { MarketConditionStrip } from "./MarketConditionStrip";
|
||||
import { SeasonalityPanel } from "./SeasonalityPanel";
|
||||
import { InfoTooltip } from "./InfoTooltip";
|
||||
import { SectionCollapse } from "./SectionCollapse";
|
||||
|
||||
/**
|
||||
* Market Outlook Panel — macro analysis and sector context for the active symbol.
|
||||
* Shows sector/industry info, ticker kind classification, and peer context.
|
||||
* Educational display only — no trade recommendations.
|
||||
* Market Outlook — market condition, relative leadership, seasonality, macro context.
|
||||
*/
|
||||
|
||||
interface SectorData {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
exchange: string | null;
|
||||
tickerKind: string;
|
||||
peers: string[] | null;
|
||||
}
|
||||
|
||||
export function MarketOutlookPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.market.snapshot(symbol)
|
||||
.then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } })
|
||||
.catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'request failed'); setLoading(false); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
const sector: SectorData | null = snap?.sector ?? null;
|
||||
const quote = snap?.quote ?? null;
|
||||
const candles = snap?.candles ?? [];
|
||||
|
||||
// Simple trend estimation from recent candles
|
||||
const trend = candles.length >= 20 ? (() => {
|
||||
const recent20 = candles.slice(-20);
|
||||
const firstHalf = recent20.slice(0, 10).reduce((s, c) => s + c.c, 0) / 10;
|
||||
const secondHalf = recent20.slice(10).reduce((s, c) => s + c.c, 0) / 10;
|
||||
const pctChange = ((secondHalf - firstHalf) / firstHalf) * 100;
|
||||
if (pctChange > 3) return { label: 'Trending Up', color: 'text-[#34d399]', icon: '▲' };
|
||||
if (pctChange < -3) return { label: 'Trending Down', color: 'text-[#ef4444]', icon: '▼' };
|
||||
return { label: 'Range-Bound', color: 'text-[#fbbf24]', icon: '—' };
|
||||
})() : null;
|
||||
|
||||
// Volatility estimate from recent candles
|
||||
const volatility = candles.length >= 20 ? (() => {
|
||||
const returns = candles.slice(-20).map((c, i, arr) => i > 0 ? (c.c - arr[i - 1].c) / arr[i - 1].c : 0).slice(1);
|
||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length;
|
||||
const dailyVol = Math.sqrt(variance);
|
||||
const annualizedVol = dailyVol * Math.sqrt(252) * 100;
|
||||
if (annualizedVol > 50) return { label: 'High', color: 'text-[#ef4444]', value: annualizedVol.toFixed(0) };
|
||||
if (annualizedVol > 25) return { label: 'Moderate', color: 'text-[#fbbf24]', value: annualizedVol.toFixed(0) };
|
||||
return { label: 'Low', color: 'text-[#34d399]', value: annualizedVol.toFixed(0) };
|
||||
})() : null;
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
|
||||
<p className="text-sm text-[#5a5b6a] py-8">Loading market data…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
|
||||
<p className="text-sm text-[#ef4444] py-8">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const open = useMarketOutlookSections((s) => s.open);
|
||||
const expandAll = useMarketOutlookSections((s) => s.expandAll);
|
||||
const collapseAll = useMarketOutlookSections((s) => s.collapseAll);
|
||||
const numVisible = visibleCount(open);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
|
||||
<span className="text-xs text-[#8a8b9a]">{symbol}</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-fg">Market Outlook</h1>
|
||||
<p className="text-sm text-fg-faint mt-1">
|
||||
Market regime, relative sector leadership, seasonal context, and macro indicators.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-fg-muted tabular-nums">{numVisible}/5 sections open</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={numVisible < 5 ? expandAll : collapseAll}
|
||||
className="text-[11px] text-link hover:underline"
|
||||
>
|
||||
{numVisible < 5 ? "Expand all" : "Collapse all"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sector & Industry */}
|
||||
{sector && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Sector</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.sector ?? '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Industry</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.industry ?? '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Type</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec] capitalize">{sector.tickerKind}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Exchange</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.exchange ?? '—'}</p>
|
||||
<MarketConditionStrip />
|
||||
|
||||
<SectionCollapse
|
||||
id="rotation"
|
||||
title="Sector & style leadership"
|
||||
info={
|
||||
<InfoTooltip>
|
||||
<strong>What it is:</strong> Relative performance of sector and style ETFs versus the broad market (SPY).<br />
|
||||
<strong>Why it matters:</strong> Shows where strength is concentrating across the market.<br />
|
||||
<strong>How to read it:</strong> Positive relative return means outperformance versus SPY; negative means underperformance. Start with the one-month column.
|
||||
</InfoTooltip>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Values are relative to the broad market (SPY), not absolute returns and not measured fund flows.
|
||||
</p>
|
||||
<MarketRotationPanel />
|
||||
<div className="border-t border-line pt-3">
|
||||
<p className="text-xs font-medium text-fg mb-2">Leadership alerts</p>
|
||||
<RotationSignalAlert />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCollapse>
|
||||
|
||||
{/* Trend & Volatility */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{trend && (
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">20-Day Trend</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg ${trend.color}`} aria-hidden="true">{trend.icon}</span>
|
||||
<span className={`text-sm font-semibold ${trend.color}`}>{trend.label}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#5a5b6a] mt-2">
|
||||
Based on the average closing price of the first 10 vs last 10 of the most recent 20 trading days.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<SectionCollapse
|
||||
id="seasonality"
|
||||
title="Seasonality & calendar"
|
||||
info={
|
||||
<InfoTooltip>
|
||||
<strong>What it is:</strong> Historical monthly patterns and recurring high-attention market dates.<br />
|
||||
<strong>Why it matters:</strong> Certain windows (Fed decisions, jobs, inflation prints) tend to raise volatility.<br />
|
||||
<strong>How to read it:</strong> Historical averages are context only; any given year can diverge.
|
||||
</InfoTooltip>
|
||||
}
|
||||
>
|
||||
<SeasonalityPanel />
|
||||
</SectionCollapse>
|
||||
|
||||
{volatility && (
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">Volatility (Annualized)</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold font-mono ${volatility.color}`}>{volatility.value}%</span>
|
||||
<span className={`text-xs ${volatility.color}`}>{volatility.label}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#5a5b6a] mt-2">
|
||||
Derived from daily returns over the last 20 trading days, annualized (×√252).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peers */}
|
||||
{sector?.peers && sector.peers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">Peers</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sector.peers.map((peer) => (
|
||||
<span key={peer} className="px-2 py-1 rounded-lg bg-[#1a1b24] border border-[#2a2b3a] text-xs text-[#8a8b9a] font-mono">
|
||||
{peer}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<SectionCollapse id="peers" title="Macro commentary">
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Near-term and structural commentary from public economic data (growth, inflation, labor, rates).
|
||||
</p>
|
||||
<MacroCommentaryPanel />
|
||||
</div>
|
||||
)}
|
||||
</SectionCollapse>
|
||||
|
||||
{/* Educational note */}
|
||||
<div className="pt-3 border-t border-[#2a2b3a]">
|
||||
<p className="text-[11px] text-[#5a5b6a]">
|
||||
This panel provides sector and statistical context for the active symbol. Trend and volatility
|
||||
estimates are based on recent price history and are for educational reference only.
|
||||
<SectionCollapse
|
||||
id="truck-sales"
|
||||
title="Heavy truck sales"
|
||||
info={
|
||||
<InfoTooltip>
|
||||
<strong>What it is:</strong> Monthly US heavy truck sales (FRED HTRUCKSSAAR).<br />
|
||||
<strong>Why it matters:</strong> A real-economy activity gauge that often turns before broader slowdowns or recoveries.<br />
|
||||
<strong>How to read it:</strong> Focus on multi-month direction rather than a single print.
|
||||
</InfoTooltip>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Source: FRED (
|
||||
<a href="https://fred.stlouisfed.org/series/HTRUCKSSAAR" target="_blank" rel="noopener noreferrer" className="text-link underline">
|
||||
HTRUCKSSAAR
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<HeavyTruckSalesChart />
|
||||
</div>
|
||||
</SectionCollapse>
|
||||
|
||||
<SectionCollapse
|
||||
id="manufacturing-pmi"
|
||||
title="Manufacturing output"
|
||||
info={
|
||||
<InfoTooltip>
|
||||
<strong>What it is:</strong> Federal Reserve industrial production: manufacturing (IPMAN).<br />
|
||||
<strong>Why it matters:</strong> Hard output measure of the goods-producing economy.<br />
|
||||
<strong>How to read it:</strong> Multi-month direction indicates expansion or contraction in factory activity.
|
||||
</InfoTooltip>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Source: FRED (
|
||||
<a href="https://fred.stlouisfed.org/series/IPMAN" target="_blank" rel="noopener noreferrer" className="text-link underline">
|
||||
IPMAN
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<ManufacturingPmiChart />
|
||||
</div>
|
||||
</SectionCollapse>
|
||||
|
||||
<footer className="pt-3 border-t border-line">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type RotationData, type SectorHolding } from "@/lib/trpc";
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
|
||||
function pct(v: number | null): string {
|
||||
if (v === null || !Number.isFinite(v)) return "—";
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return `${sign}${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function strengthColor(v: number | null): string {
|
||||
if (v === null) return "text-fg-muted";
|
||||
if (v > 1) return "text-up";
|
||||
if (v < -1) return "text-danger";
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
function heatBg(v: number | null): string {
|
||||
if (v === null) return "bg-surface-sunken";
|
||||
const clamped = Math.max(-15, Math.min(15, v));
|
||||
if (clamped >= 0) {
|
||||
const a = 0.08 + (clamped / 15) * 0.35;
|
||||
return `rgba(52, 211, 153, ${a.toFixed(2)})`;
|
||||
}
|
||||
const a = 0.08 + (Math.abs(clamped) / 15) * 0.35;
|
||||
return `rgba(248, 113, 113, ${a.toFixed(2)})`;
|
||||
}
|
||||
|
||||
const HORIZON_COLS: Array<{ key: keyof SectorRow; label: string; title: string }> = [
|
||||
{ key: "rsOneWeek", label: "1W RS", title: "Relative return vs SPY, 1 week" },
|
||||
{ key: "rsOneMonth", label: "1M RS", title: "Relative return vs SPY, 1 month" },
|
||||
{ key: "rsThreeMonth", label: "3M RS", title: "Relative return vs SPY, 3 months" },
|
||||
{ key: "rsSixMonth", label: "6M RS", title: "Relative return vs SPY, 6 months" },
|
||||
{ key: "rsOneYear", label: "1Y RS", title: "Relative return vs SPY, 1 year" },
|
||||
];
|
||||
|
||||
type SectorRow = RotationData["sectors"][number];
|
||||
|
||||
/**
|
||||
* Relative performance map: sector/style ETFs versus SPY.
|
||||
*/
|
||||
export function MarketRotationPanel() {
|
||||
const [data, setData] = useState<RotationData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEtf, setSelectedEtf] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "sector" | "style" | "region" | "thematic">("all");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.market.rotation()
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === "all") return data.sectors;
|
||||
return data.sectors.filter((s) => (s.kind ?? "sector") === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">Loading relative leadership map…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.sectors.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-6 text-center">
|
||||
<p className="text-xs text-fg-muted">
|
||||
Leadership data not yet available. Sector and SPY history will populate after the cache fills.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const outperforming = rows.filter((s) => s.leadership === "leading");
|
||||
const underperforming = rows.filter((s) => s.leadership === "lagging");
|
||||
const early = rows.filter((s) => s.earlyWatch);
|
||||
const selected = data.sectors.find((s) => s.symbol === selectedEtf) ?? null;
|
||||
|
||||
const strengthLabel =
|
||||
data.signal.strength === "strong" ? "Strong dispersion"
|
||||
: data.signal.strength === "moderate" ? "Moderate dispersion"
|
||||
: data.signal.strength === "weak" ? "Mild dispersion"
|
||||
: "Low dispersion";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-md border border-line bg-surface-sunken p-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={
|
||||
data.signal.strength === "strong" ? "up"
|
||||
: data.signal.strength === "moderate" ? "accent"
|
||||
: data.signal.strength === "weak" ? "warn"
|
||||
: "neutral"
|
||||
}>
|
||||
{strengthLabel}
|
||||
</Badge>
|
||||
<span className="text-xs text-fg-muted">
|
||||
Spread {data.signal.spread.toFixed(1)} pp · Leading:{" "}
|
||||
<span className="text-fg font-medium">{data.signal.leadingSubGroup || "—"}</span>
|
||||
{" · "}Lagging:{" "}
|
||||
<span className="text-fg font-medium">{data.signal.laggingSubGroup || "—"}</span>
|
||||
</span>
|
||||
</div>
|
||||
{data.signal.summary && (
|
||||
<p className="text-sm text-fg leading-relaxed">{data.signal.summary}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Benchmark {data.benchmark ?? "SPY"} · coverage {data.coveredSymbols ?? "—"}/
|
||||
{data.totalSymbols ?? "—"} symbols
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{([
|
||||
["all", "All"],
|
||||
["sector", "Sectors"],
|
||||
["style", "Style"],
|
||||
["region", "Region"],
|
||||
["thematic", "Thematic"],
|
||||
] as const).map(([f, label]) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs border transition-colors ${
|
||||
filter === f
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-line bg-surface-sunken text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-surface-sunken text-fg-muted">
|
||||
<th className="text-left font-medium px-3 py-2">#</th>
|
||||
<th className="text-left font-medium px-3 py-2">Symbol</th>
|
||||
<th className="text-left font-medium px-3 py-2">Group</th>
|
||||
{HORIZON_COLS.map((h) => (
|
||||
<th key={h.key} className="text-right font-medium px-2 py-2 tnum" title={h.title}>
|
||||
{h.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right font-medium px-2 py-2">Abs 1M</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr
|
||||
key={s.symbol}
|
||||
className={`border-b border-line/60 cursor-pointer hover:bg-surface-sunken/80 ${
|
||||
selectedEtf === s.symbol ? "bg-accent/10" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedEtf(selectedEtf === s.symbol ? null : s.symbol)}
|
||||
>
|
||||
<td className="px-3 py-1.5 tnum text-fg-muted">{s.rank1M ?? "—"}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="font-mono font-semibold text-fg">{s.symbol}</span>
|
||||
<span className="text-fg-muted ml-1.5">{s.name}</span>
|
||||
{s.earlyWatch && (
|
||||
<Badge tone="accent" className="ml-1.5">Early</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-fg-muted">{s.subGroup}</td>
|
||||
{HORIZON_COLS.map((h) => {
|
||||
const v = s[h.key] as number | null | undefined;
|
||||
return (
|
||||
<td
|
||||
key={h.key}
|
||||
className={`text-right px-2 py-1.5 tnum font-medium ${strengthColor(v ?? null)}`}
|
||||
style={{ background: heatBg(v ?? null) }}
|
||||
title={h.title}
|
||||
>
|
||||
{pct(v ?? null)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className={`text-right px-2 py-1.5 tnum ${strengthColor(s.oneMonth)}`}>
|
||||
{pct(s.oneMonth)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
RS = ETF return minus SPY return over the same window. Click a row for top holdings.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 rounded-full bg-up" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
Outperforming SPY (1M RS > +1pp)
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{outperforming.length === 0 ? (
|
||||
<p className="text-xs text-fg-faint">None above threshold.</p>
|
||||
) : outperforming.map((s) => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
type="button"
|
||||
onClick={() => setSelectedEtf(s.symbol)}
|
||||
className="px-2 py-1 rounded-md border border-up/30 bg-up/10 text-xs font-mono text-fg"
|
||||
>
|
||||
{s.symbol} <span className="text-up">{pct(s.rsOneMonth ?? null)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 rounded-full bg-danger" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
Underperforming SPY (1M RS < −1pp)
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{underperforming.length === 0 ? (
|
||||
<p className="text-xs text-fg-faint">None below threshold.</p>
|
||||
) : underperforming.map((s) => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
type="button"
|
||||
onClick={() => setSelectedEtf(s.symbol)}
|
||||
className="px-2 py-1 rounded-md border border-danger/30 bg-danger/10 text-xs font-mono text-fg"
|
||||
>
|
||||
{s.symbol} <span className="text-danger">{pct(s.rsOneMonth ?? null)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{early.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
||||
Early candidates
|
||||
</h4>
|
||||
<p className="text-[11px] text-fg-muted mb-2">
|
||||
Improving on a one-week relative basis versus the one-month trend — possible early leadership, not a forecast.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{early.map((s) => (
|
||||
<span
|
||||
key={s.symbol}
|
||||
className="px-2 py-1 rounded-md border border-accent/30 bg-accent/10 text-xs font-mono text-fg"
|
||||
>
|
||||
{s.symbol} · 1W RS {pct(s.rsOneWeek ?? null)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.earlyHistory && data.earlyHistory.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
||||
Early-candidate history (14 days)
|
||||
</h4>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1 text-xs text-fg-muted">
|
||||
{data.earlyHistory.map((h, i) => (
|
||||
<div key={`${h.date}-${h.symbol}-${i}`} className="flex gap-3">
|
||||
<span className="tnum w-24 shrink-0">{h.date}</span>
|
||||
<span className="font-mono text-fg">{h.symbol}</span>
|
||||
<span className="truncate">{h.name}</span>
|
||||
<span className="tnum ml-auto">{pct(h.rs1w)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<HoldingsPeek
|
||||
etfSymbol={selected.symbol}
|
||||
sectorData={selected}
|
||||
onClose={() => setSelectedEtf(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-fg-muted space-y-0.5 border-t border-line pt-3">
|
||||
<p>{data.lesson}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoldingsPeek({
|
||||
etfSymbol,
|
||||
sectorData,
|
||||
onClose,
|
||||
}: {
|
||||
etfSymbol: string;
|
||||
sectorData: SectorRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [holdings, setHoldings] = useState<SectorHolding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setHoldings([]);
|
||||
api.market.sectorHoldings(etfSymbol)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setHoldings(r.holdings ?? []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : "Failed to load holdings";
|
||||
setError(msg.includes("Too Many Requests") || msg.includes("rate")
|
||||
? "Vendor rate-limited. Try again in a moment."
|
||||
: "Could not load holdings right now.");
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [etfSymbol]);
|
||||
|
||||
const leadLabel =
|
||||
sectorData.leadership === "leading" ? "Outperforming"
|
||||
: sectorData.leadership === "lagging" ? "Underperforming"
|
||||
: "Inline";
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-sunken p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-fg">
|
||||
{sectorData.symbol} · {sectorData.name}
|
||||
</h4>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{leadLabel} · 1M RS {pct(sectorData.rsOneMonth ?? null)} · Abs 1M {pct(sectorData.oneMonth)}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-xs text-fg-muted">Loading holdings…</p>
|
||||
) : error ? (
|
||||
<p className="text-xs text-danger">{error}</p>
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">No top-holdings data for this ETF.</p>
|
||||
) : (
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{holdings.slice(0, 15).map((h) => (
|
||||
<div key={h.symbol} className="flex justify-between text-xs py-1 border-b border-line/50 gap-2">
|
||||
<span className="font-mono text-fg">{h.symbol}</span>
|
||||
<span className="text-fg-muted truncate flex-1">{h.name}</span>
|
||||
<span className="tnum text-fg">
|
||||
{h.weight > 1 ? h.weight.toFixed(1) : (h.weight * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span className={`tnum ${strengthColor(h.changePercent)}`}>{pct(h.changePercent)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,17 +27,17 @@ export function MobileTabNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex md:hidden border-t border-[#2a2b3a] bg-[#121318]">
|
||||
<nav className="flex md:hidden border-t border-line-strong bg-surface-raised">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = pathname === tab.href;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex-1 flex flex-col items-center justify-center py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-inset ${
|
||||
className={`flex-1 flex flex-col items-center justify-center py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset ${
|
||||
isActive
|
||||
? "text-[#4f8cff] border-b-2 border-b-[#4f8cff]"
|
||||
: "text-[#8a8b9a] hover:text-[#e6e7ec]"
|
||||
? "text-accent border-b-2 border-b-accent"
|
||||
: "text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type OptionRole,
|
||||
type PortfolioOptionLeg,
|
||||
} from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { Card, CardHeader, Button, Input, Badge } from "@/components/ui";
|
||||
|
||||
export interface OptionLegsPanelProps {
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: { value: OptionRole; label: string; right: "call" | "put"; side: "long" | "short" }[] = [
|
||||
{ value: "long_call", label: "Long call", right: "call", side: "long" },
|
||||
{ value: "long_put", label: "Long put", right: "put", side: "long" },
|
||||
{ value: "covered_call", label: "Covered call", right: "call", side: "short" },
|
||||
{ value: "cash_secured_put", label: "Cash-secured put", right: "put", side: "short" },
|
||||
];
|
||||
|
||||
function roleLabel(role: OptionRole): string {
|
||||
return ROLE_OPTIONS.find((r) => r.value === role)?.label ?? role;
|
||||
}
|
||||
|
||||
function formatLeg(leg: PortfolioOptionLeg): string {
|
||||
const rightCode = leg.right === "call" ? "C" : "P";
|
||||
return `${leg.underlying} ${leg.expiry} ${leg.strike}${rightCode} ×${leg.contracts} @ $${leg.premium.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function capitalHint(leg: PortfolioOptionLeg): string {
|
||||
const mult = leg.multiplier || 100;
|
||||
const premiumUsd = leg.premium * mult * leg.contracts;
|
||||
if (leg.side === "long") {
|
||||
return `Premium at risk $${premiumUsd.toFixed(0)}`;
|
||||
}
|
||||
if (leg.right === "put") {
|
||||
const reserved = Math.max(0, leg.strike * mult * leg.contracts - premiumUsd);
|
||||
return `Cash reserved ~$${reserved.toFixed(0)}`;
|
||||
}
|
||||
return `Credit $${premiumUsd.toFixed(0)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-owned option legs for risk contribution. ADR-0007: record / remove only.
|
||||
*/
|
||||
export function OptionLegsPanel({ onChange }: OptionLegsPanelProps) {
|
||||
const [legs, setLegs] = useState<PortfolioOptionLeg[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [role, setRole] = useState<OptionRole>("long_call");
|
||||
const [underlying, setUnderlying] = useState("");
|
||||
const [strike, setStrike] = useState("");
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [contracts, setContracts] = useState("1");
|
||||
const [premium, setPremium] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const next = await api.portfolio.optionLegs();
|
||||
setLegs(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not load option legs.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const add = async () => {
|
||||
const roleMeta = ROLE_OPTIONS.find((r) => r.value === role)!;
|
||||
const u = underlying.trim().toUpperCase();
|
||||
const k = parseFloat(strike);
|
||||
const c = parseFloat(contracts);
|
||||
const p = parseFloat(premium);
|
||||
if (!u || !expiry.trim() || !Number.isFinite(k) || k <= 0 || !Number.isFinite(c) || c <= 0 || !Number.isFinite(p) || p < 0) {
|
||||
setError("Underlying, strike, expiry, contracts, and premium are required.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.portfolio.addOptionLeg({
|
||||
underlying: u,
|
||||
right: roleMeta.right,
|
||||
side: roleMeta.side,
|
||||
strike: k,
|
||||
expiry: expiry.trim(),
|
||||
contracts: c,
|
||||
premium: p,
|
||||
role,
|
||||
});
|
||||
setLegs(res.legs);
|
||||
setUnderlying("");
|
||||
setStrike("");
|
||||
setExpiry("");
|
||||
setContracts("1");
|
||||
setPremium("");
|
||||
onChange?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not record option leg.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.portfolio.removeOptionLeg(id);
|
||||
await load();
|
||||
onChange?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not remove option leg.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const premiumAtRisk = legs
|
||||
.filter((l) => l.side === "long")
|
||||
.reduce((s, l) => s + l.premium * l.multiplier * l.contracts, 0);
|
||||
const cashReserved = legs
|
||||
.filter((l) => l.side === "short" && l.right === "put")
|
||||
.reduce((s, l) => {
|
||||
const credit = l.premium * l.multiplier * l.contracts;
|
||||
return s + Math.max(0, l.strike * l.multiplier * l.contracts - credit);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={UI_STRINGS.optionLegsTitle} />
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.optionLegsCaption}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Role
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as OptionRole)}
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-fg"
|
||||
disabled={busy}
|
||||
>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r.value} value={r.value}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Underlying
|
||||
<Input
|
||||
value={underlying}
|
||||
onChange={(e) => setUnderlying(e.target.value)}
|
||||
placeholder="e.g. AAPL"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Strike
|
||||
<Input
|
||||
value={strike}
|
||||
onChange={(e) => setStrike(e.target.value)}
|
||||
type="number"
|
||||
placeholder="150"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Expiry (YYYY-MM-DD)
|
||||
<Input
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
placeholder="2026-06-20"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Contracts
|
||||
<Input
|
||||
value={contracts}
|
||||
onChange={(e) => setContracts(e.target.value)}
|
||||
type="number"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Premium ($ / share)
|
||||
<Input
|
||||
value={premium}
|
||||
onChange={(e) => setPremium(e.target.value)}
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="4.20"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={add} disabled={busy}>
|
||||
{busy ? "Saving…" : "Record option leg"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
{!loading && legs.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2 text-xs">
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-3 py-2">
|
||||
<p className="text-fg-muted">{UI_STRINGS.optionPremiumAtRiskLabel}</p>
|
||||
<p className="tnum text-fg font-semibold mt-0.5">${premiumAtRisk.toFixed(0)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-3 py-2">
|
||||
<p className="text-fg-muted">{UI_STRINGS.optionCashReservedLabel}</p>
|
||||
<p className="tnum text-fg font-semibold mt-0.5">${cashReserved.toFixed(0)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-3 py-2">
|
||||
<p className="text-fg-muted">Open legs</p>
|
||||
<p className="tnum text-fg font-semibold mt-0.5">{legs.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="mt-4 text-sm text-fg-faint">Loading option legs…</p>
|
||||
) : legs.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-fg-faint">{UI_STRINGS.emptyOptionLegs}</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2 max-h-80 overflow-y-auto">
|
||||
{legs.map((leg) => (
|
||||
<div
|
||||
key={leg.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-line bg-surface-sunken px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-mono text-fg truncate">{formatLeg(leg)}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="neutral">{roleLabel(leg.role)}</Badge>
|
||||
<span className="text-xs text-fg-muted tnum">{capitalHint(leg)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(leg.id)}
|
||||
disabled={busy}
|
||||
className="text-xs text-fg-faint hover:text-danger disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/lib/options-pure";
|
||||
import type { OptionChainRow, OptionGreeks } from "@/types/options";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { chart } from "@/lib/chart-theme";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,20 +44,20 @@ 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">
|
||||
<h3 className="text-sm font-semibold text-fg">Implied Volatility Rank</h3>
|
||||
<div className="h-4 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#3a3b4a] w-full" />
|
||||
</div>
|
||||
<p className="text-xs text-[#5a5b6a]">No IV data available</p>
|
||||
<p className="text-xs text-fg-faint">No IV data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getColor = (rank: number) => {
|
||||
if (rank < 25) return "bg-[#4f8cff]"; // low IV — good for buyers
|
||||
if (rank < 50) return "bg-[#34d399]"; // moderate
|
||||
if (rank < 75) return "bg-[#fbbf24]"; // elevated
|
||||
return "bg-[#ef4444]"; // high IV — expensive
|
||||
if (rank < 25) return "bg-accent"; // low IV — good for buyers
|
||||
if (rank < 50) return "bg-up"; // moderate
|
||||
if (rank < 75) return "bg-warn"; // elevated
|
||||
return "bg-danger"; // high IV — expensive
|
||||
};
|
||||
|
||||
const getLabel = (rank: number) => {
|
||||
@@ -68,15 +69,15 @@ function IVRankBar({ data }: { data: IVRankData | null }) {
|
||||
|
||||
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]">
|
||||
<h3 className="text-sm font-semibold text-fg">Implied Volatility Rank</h3>
|
||||
<div className="flex justify-between text-xs text-fg-muted">
|
||||
<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-4 bg-surface-overlay rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full ${getColor(data.rank)} transition-all duration-500`}
|
||||
style={{ width: `${data.rank}%` }}
|
||||
@@ -86,11 +87,11 @@ function IVRankBar({ data }: { data: IVRankData | null }) {
|
||||
style={{ left: `${data.rank}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#8a8b9a]">
|
||||
<p className="text-xs text-fg-muted">
|
||||
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>
|
||||
<p className="text-xs text-fg-faint italic">{getLabel(data.rank)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,27 +101,27 @@ 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>
|
||||
<h3 className="text-sm font-semibold text-fg">Greeks</h3>
|
||||
<p className="text-xs text-fg-faint">No greeks data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GreekRow = ({ label, value, description }: { label: string; value: number | null; description: string }) => (
|
||||
const GreekRow = ({ label, value, description }: { label: string; value: number | null | undefined; 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>
|
||||
<span className="text-sm font-medium text-fg">{label}</span>
|
||||
<span className="text-xs text-fg-faint ml-2">{description}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-mono ${value === null ? "text-[#5a5b6a]" : "text-[#e6e7ec]"}`}>
|
||||
{value === null ? "—" : value.toFixed(4)}
|
||||
<span className={`text-sm font-mono ${value == null ? "text-fg-faint" : "text-fg"}`}>
|
||||
{value == null ? "—" : value.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Greeks</h3>
|
||||
<h3 className="text-sm font-semibold text-fg">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" />
|
||||
@@ -134,8 +135,8 @@ 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>
|
||||
<h3 className="text-sm font-semibold text-fg">Open Interest Walls</h3>
|
||||
<p className="text-xs text-fg-faint">No OI data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,24 +147,24 @@ function OIWalls({ walls }: { walls: OIWall[] }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Open Interest Walls</h3>
|
||||
<h3 className="text-sm font-semibold text-fg">Open Interest Walls</h3>
|
||||
<div className="flex gap-1 items-end" style={{ height: 120 }}>
|
||||
{walls.map((wall, i) => (
|
||||
<div key={`${wall.right}-${wall.strike}-${i}`} className="flex-1 flex flex-col items-center">
|
||||
<span className="text-[10px] text-[#8a8b9a] mb-1">
|
||||
<span className="text-[10px] text-fg-muted mb-1">
|
||||
{(wall.openInterest / 1000).toFixed(0)}k
|
||||
</span>
|
||||
<div
|
||||
className={`w-full rounded-t ${wall.right === "call" ? "bg-[#4f8cff]" : "bg-[#ef4444]"}`}
|
||||
className={`w-full rounded-t ${wall.right === "call" ? "bg-accent" : "bg-danger"}`}
|
||||
style={{ height: `${(wall.openInterest / maxOI) * 80}px`, minHeight: 2 }}
|
||||
/>
|
||||
<span className="text-[10px] text-[#5a5b6a] mt-1">${wall.strike}</span>
|
||||
<span className="text-[10px] text-fg-faint mt-1">${wall.strike}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-[#4f8cff]" aria-hidden="true" /> Calls</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-[#ef4444]" aria-hidden="true" /> Puts</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-accent" aria-hidden="true" /> Calls</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-danger" aria-hidden="true" /> Puts</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -176,17 +177,17 @@ function MaxPainDisplay({ rows }: { rows: OptionChainRow[] }) {
|
||||
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>
|
||||
<h3 className="text-sm font-semibold text-fg">Max Pain</h3>
|
||||
<p className="text-xs text-fg-faint">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]">
|
||||
<h3 className="text-sm font-semibold text-fg">Max Pain</h3>
|
||||
<p className="text-2xl font-bold text-warn">${maxPain}</p>
|
||||
<p className="text-xs text-fg-faint">
|
||||
The strike where options expire with maximum total value (theory)
|
||||
</p>
|
||||
</div>
|
||||
@@ -198,8 +199,8 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
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>
|
||||
<h3 className="text-sm font-semibold text-fg">Payoff Diagram</h3>
|
||||
<p className="text-xs text-fg-faint">No chain data to render</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -250,8 +251,8 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
|
||||
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]">
|
||||
<h3 className="text-sm font-semibold text-fg">Payoff Diagram</h3>
|
||||
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} className="w-full h-auto bg-surface rounded border border-line-strong">
|
||||
{/* Grid lines */}
|
||||
<line x1={padding.left} y1={zeroY} x2={svgWidth - padding.right} y2={zeroY}
|
||||
stroke="#3a3b4a" strokeWidth="1" strokeDasharray="4" />
|
||||
@@ -259,7 +260,7 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
stroke="#5a5b6a" strokeWidth="1" strokeDasharray="2" />
|
||||
|
||||
{/* Payoff line */}
|
||||
<path d={pathD} fill="none" stroke={atmRow.right === "call" ? "#4f8cff" : "#ef4444"} strokeWidth="2" />
|
||||
<path d={pathD} fill="none" stroke={atmRow.right === "call" ? chart.accent : chart.down} strokeWidth="2" />
|
||||
|
||||
{/* Zero PnL label */}
|
||||
<text x={padding.left + 5} y={zeroY - 5} fill="#5a5b6a" fontSize="10">PnL $0</text>
|
||||
@@ -279,7 +280,7 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
Stock price at expiration →
|
||||
</text>
|
||||
</svg>
|
||||
<p className="text-xs text-[#5a5b6a] italic">
|
||||
<p className="text-xs text-fg-faint italic">
|
||||
Shows P&L at expiration for a {atmRow.right} option with strike ${atmRow.strike}.
|
||||
The curve illustrates the convex payoff mechanic — gains accelerate as the stock moves
|
||||
in your favor past the strike.
|
||||
@@ -293,10 +294,10 @@ function RiskStamp({ isSpread }: { isSpread: boolean }) {
|
||||
if (isSpread) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#0a2e1a] border border-[#166534]">
|
||||
<span className="text-[#34d399] text-lg" aria-hidden="true">✓</span>
|
||||
<span className="text-up text-lg" aria-hidden="true">✓</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#34d399]">Defined-Risk Spread</p>
|
||||
<p className="text-xs text-[#34d399]/70">Max loss is known upfront</p>
|
||||
<p className="text-sm font-semibold text-up">Defined-Risk Spread</p>
|
||||
<p className="text-xs text-up/70">Max loss is known upfront</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -304,10 +305,10 @@ function RiskStamp({ isSpread }: { isSpread: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#2a1010] border border-[#991b1b]">
|
||||
<span className="text-[#ef4444] text-lg" aria-hidden="true">⚠</span>
|
||||
<span className="text-danger text-lg" aria-hidden="true">⚠</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#ef4444]">Advanced Only</p>
|
||||
<p className="text-xs text-[#ef4444]/70">Undefined risk — potential for significant losses</p>
|
||||
<p className="text-sm font-semibold text-danger">Advanced Only</p>
|
||||
<p className="text-xs text-danger/70">Undefined risk — potential for significant losses</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -384,10 +385,10 @@ export function OptionsDDPanel() {
|
||||
|
||||
if (loading) {
|
||||
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]">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" />
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">Options Due Diligence</h2>
|
||||
<div className="flex items-center gap-2 text-fg-faint text-sm">
|
||||
<div className="w-4 h-4 border-2 border-fg-faint border-t-transparent rounded-full animate-spin" />
|
||||
Loading options data...
|
||||
</div>
|
||||
</section>
|
||||
@@ -396,29 +397,29 @@ export function OptionsDDPanel() {
|
||||
|
||||
if (error) {
|
||||
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]">Options Due Diligence</h2>
|
||||
<p className="text-sm text-[#ef4444]">Error: {error}</p>
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">Options Due Diligence</h2>
|
||||
<p className="text-sm text-danger">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 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<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>
|
||||
<p className="text-xs text-[#5a5b6a]">This may be a crypto or index symbol without an options chain.</p>
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">Options Due Diligence</h2>
|
||||
<p className="text-sm text-fg-faint">No options data available for {symbol}.</p>
|
||||
<p className="text-xs text-fg-faint">This may be a crypto or index symbol without an options chain.</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">
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<h2 className="text-base font-semibold text-fg">Options Due Diligence</h2>
|
||||
{chainData.expiration && (
|
||||
<span className="text-xs text-[#8a8b9a] bg-[#1e1f2e] px-2 py-1 rounded-lg">
|
||||
<span className="text-xs text-fg-muted bg-surface-overlay px-2 py-1 rounded-lg">
|
||||
Exp: {chainData.expiration}
|
||||
</span>
|
||||
)}
|
||||
@@ -443,8 +444,8 @@ export function OptionsDDPanel() {
|
||||
<PayoffDiagram rows={chainData.rows} currentStockPrice={atmRow?.strike ?? 0} />
|
||||
|
||||
{/* Educational footer */}
|
||||
<div className="pt-4 border-t border-[#2a2b3a]">
|
||||
<p className="text-xs text-[#5a5b6a]">
|
||||
<div className="pt-4 border-t border-line-strong">
|
||||
<p className="text-xs text-fg-faint">
|
||||
This panel is educational. It does not recommend any specific trade. Options involve
|
||||
risk and are not suitable for all investors. Past performance does not guarantee future results.
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type Snapshot } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
|
||||
// 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
|
||||
@@ -64,15 +65,36 @@ export function OverviewPanel() {
|
||||
const up = (q?.change ?? 0) >= 0;
|
||||
const spark = (candles ?? []).map((c, i) => ({ i, c: c.c }));
|
||||
|
||||
const trend = (candles ?? []).length >= 20 ? (() => {
|
||||
const recent20 = (candles ?? []).slice(-20);
|
||||
const firstHalf = recent20.slice(0, 10).reduce((s, c) => s + c.c, 0) / 10;
|
||||
const secondHalf = recent20.slice(10).reduce((s, c) => s + c.c, 0) / 10;
|
||||
const pctChange = ((secondHalf - firstHalf) / firstHalf) * 100;
|
||||
if (pctChange > 3) return { label: 'Trending Up', color: 'text-up', icon: '▲' };
|
||||
if (pctChange < -3) return { label: 'Trending Down', color: 'text-danger', icon: '▼' };
|
||||
return { label: 'Range-Bound', color: 'text-warn', icon: '—' };
|
||||
})() : null;
|
||||
|
||||
const volatility = (candles ?? []).length >= 20 ? (() => {
|
||||
const returns = (candles ?? []).slice(-20).map((c, i, arr) => i > 0 ? (c.c - arr[i - 1].c) / arr[i - 1].c : 0).slice(1);
|
||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length;
|
||||
const dailyVol = Math.sqrt(variance);
|
||||
const annualizedVol = dailyVol * Math.sqrt(252) * 100;
|
||||
if (annualizedVol > 50) return { label: 'High', color: 'text-danger', value: annualizedVol.toFixed(0) };
|
||||
if (annualizedVol > 25) return { label: 'Moderate', color: 'text-warn', value: annualizedVol.toFixed(0) };
|
||||
return { label: 'Low', color: 'text-up', value: annualizedVol.toFixed(0) };
|
||||
})() : null;
|
||||
|
||||
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">
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
|
||||
<header className="flex items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{symbol}</h2>
|
||||
<p className="text-xs text-[#8a8b9a]">{sector?.name ?? sector?.sector ?? ""}</p>
|
||||
<h2 className="text-base font-semibold text-fg">{symbol}</h2>
|
||||
<p className="text-xs text-fg-muted">{sector?.name ?? sector?.sector ?? ""}</p>
|
||||
</div>
|
||||
{snap && (snap.stale.quote || snap.stale.candles || snap.stale.sector) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-[#fcd34d] border border-[#78350f] rounded px-2 py-0.5">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-warn border border-amber-800 rounded px-2 py-0.5">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
{UI_STRINGS.staleBadge}
|
||||
</span>
|
||||
@@ -80,21 +102,21 @@ export function OverviewPanel() {
|
||||
</header>
|
||||
|
||||
{loading && !snap ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.loadingSnapshot}</p>
|
||||
<p className="text-sm text-fg-muted py-8">{UI_STRINGS.loadingSnapshot}</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-[#ef4444] py-8">{error}</p>
|
||||
<p className="text-sm text-danger py-8">{error}</p>
|
||||
) : !q ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.noData}</p>
|
||||
<p className="text-sm text-fg-muted py-8">{UI_STRINGS.noData}</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-end gap-6">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">{UI_STRINGS.priceLabel}</p>
|
||||
<p className="text-2xl font-mono font-bold text-[#e6e7ec]">${q.price?.toFixed(2)}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted">{UI_STRINGS.priceLabel}</p>
|
||||
<p className="text-2xl font-mono font-bold text-fg">${q.price?.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">{UI_STRINGS.dayChangeLabel}</p>
|
||||
<p className={`text-sm font-mono font-semibold flex items-center gap-1 ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted">{UI_STRINGS.dayChangeLabel}</p>
|
||||
<p className={`text-sm font-mono font-semibold flex items-center gap-1 ${up ? "text-up" : "text-warn"}`}>
|
||||
<span aria-hidden>{up ? "▲" : "▼"}</span>
|
||||
<span>{up ? "+" : ""}{q.change?.toFixed(2)} ({up ? "+" : ""}{(q.changePercent ?? 0).toFixed(2)}%)</span>
|
||||
</p>
|
||||
@@ -107,23 +129,25 @@ export function OverviewPanel() {
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={spark} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
|
||||
<YAxis domain={["auto", "auto"]} hide />
|
||||
<Line type="monotone" dataKey="c" stroke="#4f8cff" strokeWidth={1.5} dot={false} />
|
||||
<Line type="monotone" dataKey="c" stroke={CHART.accent} strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">{UI_STRINGS.sparklineCaption}</figcaption>
|
||||
<figcaption className="text-[11px] text-fg-muted mt-1">{UI_STRINGS.sparklineCaption}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
{sector && (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.sectorLabel}</dt><dd className="text-[#e6e7ec]">{sector.sector ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.industryLabel}</dt><dd className="text-[#e6e7ec]">{sector.industry ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.tickerKindLabel}</dt><dd className="text-[#e6e7ec]">{sector.tickerKind}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.sectorLabel}</dt><dd className="text-fg">{sector.sector ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.industryLabel}</dt><dd className="text-fg">{sector.industry ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.tickerKindLabel}</dt><dd className="text-fg">{sector.tickerKind}</dd></div>
|
||||
{trend && <div className="flex justify-between"><dt className="text-fg-muted">Trend</dt><dd className={`font-mono font-medium ${trend.color}`}>{trend.icon} {trend.label}</dd></div>}
|
||||
{volatility && <div className="flex justify-between"><dt className="text-fg-muted">Volatility</dt><dd className={`font-mono font-medium ${volatility.color}`}>{volatility.label} ({volatility.value}%)</dd></div>}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{isCrypto && <p className="text-[11px] text-[#fcd34d] border border-[#78350f] rounded p-2">{UI_STRINGS.cryptoNotice}</p>}
|
||||
{isCrypto && <p className="text-[11px] text-warn border border-amber-800 rounded p-2">{UI_STRINGS.cryptoNotice}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,87 +1,187 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { Card, CardHeader, Button, Input } from "@/components/ui";
|
||||
|
||||
export function PortfolioPanel() {
|
||||
export interface PortfolioPanelProps {
|
||||
/** Called after holdings change so parents (e.g. Risk) can refresh posture. */
|
||||
onChange?: (holdings: PortfolioHolding[]) => void;
|
||||
/** When true, omit outer Card chrome (parent already provides a card). */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record open holdings (symbol / shares / avg cost). Feeds risk posture, not the watchlist.
|
||||
* ADR-0007: user-owned records only - no trade instructions.
|
||||
*/
|
||||
export function PortfolioPanel({ onChange, bare = false }: PortfolioPanelProps) {
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [draftSymbol, setDraftSymbol] = useState("");
|
||||
const [draftShares, setDraftShares] = useState("");
|
||||
const [draftCost, setDraftCost] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async (): Promise<PortfolioHolding[] | null> => {
|
||||
try {
|
||||
setHoldings(await api.portfolio.holdings());
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
const next = await api.portfolio.holdings();
|
||||
setHoldings(next);
|
||||
setError(null);
|
||||
return next;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not load holdings.");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const notifyChange = (next: PortfolioHolding[]) => {
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
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 symbol = draftSymbol.trim().toUpperCase();
|
||||
const shares = parseFloat(draftShares);
|
||||
const avgCost = parseFloat(draftCost);
|
||||
if (!symbol || !Number.isFinite(shares) || shares <= 0 || !Number.isFinite(avgCost) || avgCost < 0) {
|
||||
setError("Symbol, positive share count, and average cost are required.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.portfolio.addHolding(symbol, shares, avgCost);
|
||||
setDraftSymbol("");
|
||||
setDraftShares("");
|
||||
setDraftCost("");
|
||||
const next = await load();
|
||||
if (next) notifyChange(next);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not record holding.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (symbol: string) => {
|
||||
await api.portfolio.removeHolding(symbol);
|
||||
load();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.portfolio.removeHolding(symbol);
|
||||
const next = await load();
|
||||
if (next) notifyChange(next);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not remove holding.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
const body = (
|
||||
<>
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.portfolioCaption}</p>
|
||||
<p className="text-xs text-fg-faint mb-3">{UI_STRINGS.portfolioVsWatchlist}</p>
|
||||
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<label className="block text-xs text-fg-muted sm:col-span-1">
|
||||
Symbol
|
||||
<Input
|
||||
value={draftSymbol}
|
||||
onChange={(e) => setDraftSymbol(e.target.value)}
|
||||
placeholder="e.g. AAPL"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Shares
|
||||
<Input
|
||||
value={draftShares}
|
||||
onChange={(e) => setDraftShares(e.target.value)}
|
||||
placeholder="100"
|
||||
type="number"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Avg cost ($)
|
||||
<Input
|
||||
value={draftCost}
|
||||
onChange={(e) => setDraftCost(e.target.value)}
|
||||
placeholder="150.00"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="mt-1"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={add} disabled={busy} className="w-full">
|
||||
{busy ? "Saving…" : "Record holding"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={add}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm bg-[#4f8cff] text-white">Add Holding</button>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-[#5a5b6a]">Loading...</p>
|
||||
<p className="mt-4 text-sm text-fg-faint">Loading holdings…</p>
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-sm text-[#5a5b6a]">{UI_STRINGS.emptyPortfolio || "No holdings yet."}</p>
|
||||
<p className="mt-4 text-sm text-fg-faint">{UI_STRINGS.emptyPortfolio}</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]">
|
||||
<div className="mt-4 space-y-2 max-h-72 overflow-y-auto">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_4.5rem_5rem_5rem_4rem] gap-2 text-xs text-fg-faint pb-1 border-b border-line">
|
||||
<span>Symbol</span>
|
||||
<span>Shares</span>
|
||||
<span>Avg Cost</span>
|
||||
<span>Value</span>
|
||||
<span></span>
|
||||
<span className="text-right">Shares</span>
|
||||
<span className="text-right">Avg cost</span>
|
||||
<span className="text-right">Cost basis</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
|
||||
key={h.symbol}
|
||||
className="grid grid-cols-[minmax(0,1fr)_4.5rem_5rem_5rem_4rem] gap-2 items-center py-1.5 text-sm rounded hover:bg-surface-sunken transition-colors"
|
||||
>
|
||||
<span className="font-mono text-fg truncate">{h.symbol}</span>
|
||||
<span className="text-right tnum text-fg-muted">{h.shares}</span>
|
||||
<span className="text-right tnum text-fg-muted">${h.avg_cost.toFixed(2)}</span>
|
||||
<span className="text-right tnum text-fg">${(h.shares * h.avg_cost).toFixed(0)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(h.symbol)}
|
||||
disabled={busy}
|
||||
className="text-xs text-fg-faint hover:text-danger text-right disabled:opacity-50"
|
||||
>
|
||||
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 className="flex justify-between pt-2 border-t border-line text-sm font-semibold text-fg">
|
||||
<span>Cost-basis total</span>
|
||||
<span className="tnum">${totalValue.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-[#5a5b6a] italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
if (bare) return <div className="space-y-1">{body}</div>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={UI_STRINGS.portfolioTitle} />
|
||||
{body}
|
||||
<p className="mt-4 text-[10px] text-fg-faint italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type RiskPostureResult, type SizingResult } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { Card, CardHeader, Badge, Button, Input } from "@/components/ui";
|
||||
import { PortfolioPanel } from "@/components/PortfolioPanel";
|
||||
import { OptionLegsPanel } from "@/components/OptionLegsPanel";
|
||||
|
||||
/**
|
||||
* M20 Risk Posture - unified risk picture + educational sizing math.
|
||||
* ADR-0007: considerations + questions, never trade orders.
|
||||
*/
|
||||
|
||||
function asymmetryTone(asymmetry: number): "up" | "warn" | "danger" | "neutral" {
|
||||
if (!Number.isFinite(asymmetry)) return "neutral";
|
||||
if (asymmetry >= 2) return "up";
|
||||
if (asymmetry >= 1) return "warn";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function statusLabel(status: -1 | 0 | 1): string {
|
||||
if (status === 1) return "Breached";
|
||||
if (status === 0) return "At limit";
|
||||
return "Healthy";
|
||||
}
|
||||
|
||||
export function RiskPosturePanel() {
|
||||
const [equity, setEquity] = useState("100000");
|
||||
const [peakEquity, setPeakEquity] = useState("100000");
|
||||
const [regime, setRegime] = useState<"trending-up" | "trending-down" | "range-bound">("range-bound");
|
||||
const [posture, setPosture] = useState<RiskPostureResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Sizing calculator (teaching surface)
|
||||
const [sym, setSym] = useState("NVDA");
|
||||
const [tier, setTier] = useState<"A_STAR" | "A" | "B" | "C">("B");
|
||||
const [riskPct, setRiskPct] = useState("1");
|
||||
const [stop, setStop] = useState("5");
|
||||
const [sizing, setSizing] = useState<SizingResult | null>(null);
|
||||
const [sizingError, setSizingError] = useState<string | null>(null);
|
||||
|
||||
const loadPosture = useCallback(async () => {
|
||||
const eq = Number(equity);
|
||||
const peak = Number(peakEquity);
|
||||
if (!Number.isFinite(eq) || eq <= 0) {
|
||||
setError("Account equity must be a positive number.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.risk.posture({
|
||||
equity: eq,
|
||||
peakEquity: Number.isFinite(peak) && peak > 0 ? peak : eq,
|
||||
regime,
|
||||
});
|
||||
setPosture(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not load risk posture.");
|
||||
setPosture(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [equity, peakEquity, regime]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosture();
|
||||
}, [loadPosture]);
|
||||
|
||||
const onHoldingsChange = useCallback(() => {
|
||||
// Portfolio mutated - recompute posture so holdings/cluster cards stay in sync.
|
||||
void loadPosture();
|
||||
}, [loadPosture]);
|
||||
|
||||
const runSizing = async () => {
|
||||
const eq = Number(equity);
|
||||
const rf = Number(riskPct) / 100;
|
||||
const stopPerShare = Number(stop);
|
||||
if (!sym.trim() || !Number.isFinite(eq) || eq <= 0 || !Number.isFinite(rf) || rf <= 0 || !Number.isFinite(stopPerShare) || stopPerShare <= 0) {
|
||||
setSizingError("Symbol, equity, risk %, and stop per share are required.");
|
||||
return;
|
||||
}
|
||||
setSizingError(null);
|
||||
try {
|
||||
const result = await api.sizing.compute({
|
||||
symbol: sym.trim().toUpperCase(),
|
||||
tier,
|
||||
riskFraction: rf,
|
||||
stopPerShare,
|
||||
equity: eq,
|
||||
regime,
|
||||
});
|
||||
setSizing(result);
|
||||
} catch (e) {
|
||||
setSizingError(e instanceof Error ? e.message : "Sizing request failed.");
|
||||
setSizing(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-fg">{UI_STRINGS.riskPostureTitle}</h1>
|
||||
<p className="text-sm text-fg-faint mt-1">{UI_STRINGS.riskPostureSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Account inputs */}
|
||||
<Card>
|
||||
<CardHeader title="Account inputs" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
{UI_STRINGS.riskAccountInputsCaption}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Current equity ($)
|
||||
<Input
|
||||
value={equity}
|
||||
onChange={(e) => setEquity(e.target.value)}
|
||||
type="number"
|
||||
className="mt-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Peak equity ($)
|
||||
<Input
|
||||
value={peakEquity}
|
||||
onChange={(e) => setPeakEquity(e.target.value)}
|
||||
type="number"
|
||||
className="mt-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Macro regime
|
||||
<select
|
||||
value={regime}
|
||||
onChange={(e) => setRegime(e.target.value as typeof regime)}
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-fg"
|
||||
>
|
||||
<option value="trending-up">Trending up</option>
|
||||
<option value="range-bound">Range-bound</option>
|
||||
<option value="trending-down">Trending down</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={loadPosture} disabled={loading}>
|
||||
{loading ? "Refreshing…" : "Refresh posture"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-danger">{error}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Position book - feeds risk.posture */}
|
||||
<PortfolioPanel onChange={onHoldingsChange} />
|
||||
|
||||
{/* Option legs - premium / CSP capital contribution */}
|
||||
<OptionLegsPanel onChange={onHoldingsChange} />
|
||||
|
||||
{/* Posture summary */}
|
||||
{posture && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Holdings</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1">{posture.holdingsCount}</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
Exposure ${posture.totalExposureUsd.toFixed(0)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Options capital</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1">
|
||||
${(posture.options?.capitalCommittedUsd ?? 0).toFixed(0)}
|
||||
</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
{posture.options?.legsCount ?? 0} legs · premium $
|
||||
{(posture.options?.premiumAtRiskUsd ?? 0).toFixed(0)} · CSP $
|
||||
{(posture.options?.cashReservedUsd ?? 0).toFixed(0)}
|
||||
</p>
|
||||
{(posture.options?.uncoveredShortCallCount ?? 0) > 0 && (
|
||||
<Badge tone="warn" className="mt-1">
|
||||
{posture.options!.uncoveredShortCallCount} uncovered short call
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Asymmetry</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1 flex items-center gap-2">
|
||||
{Number.isFinite(posture.asymmetry) ? posture.asymmetry.toFixed(2) : "—"}
|
||||
<Badge tone={asymmetryTone(posture.asymmetry)}>
|
||||
{Number.isFinite(posture.asymmetry)
|
||||
? posture.asymmetry < 1
|
||||
? "Risk > reward"
|
||||
: "Reward ≥ risk"
|
||||
: "No stops"}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
Portfolio-weighted reward-to-risk when stops and targets are set.
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Drawdown vs tolerance</p>
|
||||
<p className="text-lg font-semibold tnum text-fg mt-1">
|
||||
{posture.drawdownVsTolerance.currentDrawdownPct.toFixed(1)}%
|
||||
<span className="text-sm font-normal text-fg-muted">
|
||||
{" "}/ {posture.drawdownVsTolerance.tolerancePct}%
|
||||
</span>
|
||||
</p>
|
||||
<Badge
|
||||
tone={
|
||||
posture.drawdownVsTolerance.status === 1
|
||||
? "danger"
|
||||
: posture.drawdownVsTolerance.status === 0
|
||||
? "warn"
|
||||
: "up"
|
||||
}
|
||||
>
|
||||
{statusLabel(posture.drawdownVsTolerance.status)}
|
||||
</Badge>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-2xs uppercase tracking-wide text-fg-muted">Gentle halt</p>
|
||||
<p className="text-lg font-semibold text-fg mt-1">
|
||||
{posture.halted ? "Active" : "Off"}
|
||||
</p>
|
||||
{posture.halt ? (
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
Until {new Date(posture.halt.haltedUntil).toLocaleString()} · {posture.halt.triggeredBy}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-fg-faint mt-0.5">
|
||||
New-entry cooldown when drawdown breaches your tolerance (24h).
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cluster exposure */}
|
||||
<Card>
|
||||
<CardHeader title="Cluster exposure" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
Holdings grouped by factor driver. Beginners default to a 25% equity cluster cap —
|
||||
the lesson is that five correlated names are often one bet.
|
||||
</p>
|
||||
{Object.keys(posture.clusterExposure).length === 0 ? (
|
||||
<p className="text-sm text-fg-faint">No open holdings yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(posture.clusterExposure).map(([cluster, usd]) => {
|
||||
const cap = posture.clusterCaps?.[cluster];
|
||||
const breached = cap !== undefined && usd > cap;
|
||||
return (
|
||||
<div
|
||||
key={cluster}
|
||||
className="flex items-center justify-between rounded-md border border-line bg-surface-sunken px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-fg">{cluster}</span>
|
||||
<span className="tnum text-fg">
|
||||
${usd.toFixed(0)}
|
||||
{cap !== undefined && (
|
||||
<span className="text-fg-muted"> / cap ${cap.toFixed(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
{breached && <Badge tone="warn">Over cap</Badge>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Considerations */}
|
||||
<Card>
|
||||
<CardHeader title="Considerations" />
|
||||
<p className="text-xs text-fg-muted mb-3">
|
||||
Trade-offs to think through — not instructions. Each item cites the math that raised it.
|
||||
</p>
|
||||
{posture.recommendedActions.length === 0 ? (
|
||||
<p className="text-sm text-fg-faint">
|
||||
No posture flags right now. Asymmetry, clusters, and drawdown look within your stated rules.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{posture.recommendedActions.map((a, i) => (
|
||||
<li
|
||||
key={`${a.id}-${i}`}
|
||||
className="rounded-md border border-line bg-surface-sunken p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge tone={a.severity === "warning" ? "warn" : "neutral"}>{a.id}</Badge>
|
||||
<Badge tone={a.severity === "warning" ? "warn" : "neutral"}>{a.severity}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-fg font-medium">{a.tradeOff}</p>
|
||||
<p className="text-xs text-fg-muted mt-1 whitespace-pre-wrap">{a.explanation}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sizing calculator */}
|
||||
<Card>
|
||||
<CardHeader title={UI_STRINGS.sizingTitle} />
|
||||
<p className="text-xs text-fg-muted mb-3">{UI_STRINGS.sizingCaption}</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Symbol
|
||||
<Input value={sym} onChange={(e) => setSym(e.target.value)} className="mt-1" />
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Conviction tier
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as typeof tier)}
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-fg"
|
||||
>
|
||||
<option value="C">C (×0.5)</option>
|
||||
<option value="B">B (×1)</option>
|
||||
<option value="A">A (×2)</option>
|
||||
<option value="A_STAR">A★ (×3, unlock required)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Risk per trade (%)
|
||||
<Input value={riskPct} onChange={(e) => setRiskPct(e.target.value)} type="number" className="mt-1" />
|
||||
</label>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
Stop width ($ / share)
|
||||
<Input value={stop} onChange={(e) => setStop(e.target.value)} type="number" className="mt-1" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={runSizing}>Show sizing math</Button>
|
||||
</div>
|
||||
{sizingError && <p className="mt-2 text-sm text-danger">{sizingError}</p>}
|
||||
{sizing && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{sizing.blocked ? (
|
||||
<p className="text-sm text-warn">
|
||||
Blocked: {sizing.blockReason}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-fg">
|
||||
The math implies <span className="font-semibold tnum">~{sizing.shares}</span> shares
|
||||
given your stop and risk %.
|
||||
</p>
|
||||
)}
|
||||
{sizing.halted && (
|
||||
<p className="text-xs text-warn">
|
||||
Gentle halt is active — risk fraction may be reduced for teaching the cooldown.
|
||||
</p>
|
||||
)}
|
||||
<ol className="space-y-1.5 list-decimal list-inside">
|
||||
{sizing.layers.map((layer) => (
|
||||
<li key={layer.name} className="text-xs text-fg-muted">
|
||||
<span className="font-medium text-fg">{layer.name}</span>: {layer.explanation}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{sizing.explanations.map((e, i) => (
|
||||
<p key={i} className="text-xs text-fg-faint">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<footer className="border-t border-line pt-4">
|
||||
<p className="text-[11px] text-fg-muted">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api, type RotationAlertCheckResult, type AlertEventRow } from '@/lib/trpc';
|
||||
|
||||
const SIGNAL_META = {
|
||||
none: { label: 'Low dispersion', color: 'text-fg-muted', bar: 'bg-fg-muted', width: 'w-0' },
|
||||
weak: { label: 'Mild dispersion', color: 'text-warn', bar: 'bg-warn', width: 'w-1/4' },
|
||||
moderate: { label: 'Moderate dispersion', color: 'text-accent', bar: 'bg-accent', width: 'w-2/4' },
|
||||
strong: { label: 'Strong dispersion', color: 'text-up', bar: 'bg-up', width: 'w-full' },
|
||||
} as const;
|
||||
|
||||
export function RotationSignalAlert() {
|
||||
const [checkResult, setCheckResult] = useState<RotationAlertCheckResult | null>(null);
|
||||
const [alerts, setAlerts] = useState<AlertEventRow[]>([]);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const refreshAlerts = useCallback(() => {
|
||||
api.alerts.list(5).then((r) => setAlerts(r ?? [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAlerts();
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await api.market.rotationCheckForAlert();
|
||||
if (!cancelled) {
|
||||
setCheckResult(result);
|
||||
refreshAlerts();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { if (!cancelled) setChecking(false); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [refreshAlerts]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await api.market.rotationCheckForAlert();
|
||||
setCheckResult(result);
|
||||
refreshAlerts();
|
||||
} catch { /* ignore */ }
|
||||
finally { setChecking(false); }
|
||||
};
|
||||
|
||||
const handleAckAll = async () => {
|
||||
await api.alerts.acknowledgeAll();
|
||||
setAlerts([]);
|
||||
};
|
||||
|
||||
const rotationAlerts = alerts.filter((a) => a.type === 'rotation_incipient' && !a.acknowledged);
|
||||
const latestRotation = rotationAlerts[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{checkResult && (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-fg-muted">Leadership signal</span>
|
||||
<span className={`text-xs font-semibold ${SIGNAL_META[checkResult.signal as keyof typeof SIGNAL_META]?.color ?? 'text-fg-muted'}`}>
|
||||
{SIGNAL_META[checkResult.signal as keyof typeof SIGNAL_META]?.label ?? checkResult.signal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-surface-overflow overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${SIGNAL_META[checkResult.signal as keyof typeof SIGNAL_META]?.bar ?? 'bg-fg-muted'} ${SIGNAL_META[checkResult.signal as keyof typeof SIGNAL_META]?.width ?? 'w-0'}`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 mt-3 text-[11px]">
|
||||
<div>
|
||||
<span className="text-fg-muted">Leadership spread</span>
|
||||
<p className={`font-mono font-medium ${checkResult.spread > 8 ? 'text-up' : checkResult.spread > 3 ? 'text-warn' : 'text-fg'}`}>
|
||||
{checkResult.spread > 0 ? '+' : ''}{checkResult.spread} pp
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-fg-muted">Outperforming</span>
|
||||
<p className="font-mono font-medium text-up">
|
||||
{checkResult.inflowAvg}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-fg-muted">Underperforming</span>
|
||||
<p className="font-mono font-medium text-danger">
|
||||
{checkResult.outflowAvg}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rotationAlerts.length > 0 && (
|
||||
<div className="rounded-lg border border-up/30 bg-up/5 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-up animate-pulse" />
|
||||
<span className="text-sm font-semibold text-up">Leadership shift detected</span>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{latestRotation.description}</p>
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{new Date(latestRotation.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleAckAll} className="text-[11px] text-link hover:underline shrink-0">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={checking}
|
||||
className="w-full rounded-md border border-line bg-surface-sunken py-2 text-xs text-fg-muted hover:text-fg hover:border-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{checking ? 'Checking…' : 'Refresh leadership signal'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type SeasonalityData } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Seasonal patterns and high-attention calendar context from market history.
|
||||
*/
|
||||
export function SeasonalityPanel() {
|
||||
const [data, setData] = useState<SeasonalityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.market.seasonality("SPY")
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-fg-muted py-4">Loading seasonal patterns…</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted py-4">
|
||||
Seasonal patterns require long SPY history. They appear once price history is cached.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const maxAbs = Math.max(
|
||||
1,
|
||||
...data.months.filter((m) => m.sampleYears > 0).map((m) => Math.abs(m.avgReturnPct)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-md border border-line bg-surface-sunken p-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-fg">Current calendar position</h4>
|
||||
<ul className="text-sm text-fg space-y-1.5">
|
||||
<li>
|
||||
<span className="text-fg-muted">Month: </span>
|
||||
<strong>{data.months[data.currentMonth - 1]?.monthName}</strong>
|
||||
{data.currentMonthAvgPct != null && data.currentMonthSampleYears > 0 && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}— historical average{" "}
|
||||
<span className={data.currentMonthAvgPct >= 0 ? "text-up" : "text-danger"}>
|
||||
{data.currentMonthAvgPct > 0 ? "+" : ""}
|
||||
{data.currentMonthAvgPct.toFixed(1)}%
|
||||
</span>
|
||||
{" "}({data.currentMonthSampleYears} years; positive{" "}
|
||||
{Math.round((data.currentMonthWinRate ?? 0) * 100)}% of years).
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-fg-muted">Half-year window: </span>
|
||||
<strong>
|
||||
{data.halfYear.whichHalf === "winter" ? "Nov–Apr" : "May–Oct"}
|
||||
</strong>
|
||||
{data.halfYear.winterAvgPct != null && data.halfYear.summerAvgPct != null && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}— historical monthly averages: Nov–Apr ~{data.halfYear.winterAvgPct.toFixed(1)}% ·
|
||||
May–Oct ~{data.halfYear.summerAvgPct.toFixed(1)}%.
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-fg-muted">Election cycle: </span>
|
||||
{data.electionCycle.year} — <strong>{data.electionCycle.label}</strong>.
|
||||
<span className="text-fg-muted"> Sample is noisy; treat as secondary context.</span>
|
||||
</li>
|
||||
{data.calendar.nearTurnOfMonth && (
|
||||
<li className="text-fg-muted">Near turn-of-month window.</li>
|
||||
)}
|
||||
{data.calendar.nearQuarterEnd && (
|
||||
<li className="text-fg-muted">
|
||||
Near quarter-end (Q{data.calendar.quarter}) — portfolio rebalancing can add short-term noise.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
||||
Average monthly return ({data.symbol})
|
||||
</h4>
|
||||
{!data.hasEnoughHistory && (
|
||||
<p className="text-xs text-warn mb-2">
|
||||
History is still short; averages will stabilize as more years load.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{data.months.map((m) => {
|
||||
const isCurrent = m.month === data.currentMonth;
|
||||
const w = m.sampleYears > 0 ? (Math.abs(m.avgReturnPct) / maxAbs) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={m.month}
|
||||
className={`flex items-center gap-2 text-xs ${isCurrent ? "font-semibold" : ""}`}
|
||||
>
|
||||
<span className={`w-20 shrink-0 ${isCurrent ? "text-accent" : "text-fg-muted"}`}>
|
||||
{m.monthName.slice(0, 3)}
|
||||
{isCurrent ? " · now" : ""}
|
||||
</span>
|
||||
<div className="flex-1 h-4 rounded bg-surface-sunken overflow-hidden relative">
|
||||
{m.sampleYears > 0 && (
|
||||
<div
|
||||
className={`absolute top-0 h-full ${m.avgReturnPct >= 0 ? "bg-up/50" : "bg-danger/50"}`}
|
||||
style={{
|
||||
width: `${Math.max(w / 2, 2)}%`,
|
||||
...(m.avgReturnPct >= 0
|
||||
? { left: "50%" }
|
||||
: { right: "50%" }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-line" />
|
||||
</div>
|
||||
<span className={`w-14 text-right tnum ${m.avgReturnPct >= 0 ? "text-up" : "text-danger"}`}>
|
||||
{m.sampleYears === 0
|
||||
? "—"
|
||||
: `${m.avgReturnPct > 0 ? "+" : ""}${m.avgReturnPct.toFixed(1)}%`}
|
||||
</span>
|
||||
<span className="w-10 text-right text-fg-faint tnum">
|
||||
{m.sampleYears > 0 ? `${m.sampleYears}y` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted mt-2">
|
||||
Historical average monthly returns for {data.symbol}. Green = average positive; red = average negative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
||||
High-attention dates
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{data.events.map((e) => (
|
||||
<li key={e.id} className="rounded-md border border-line bg-surface-sunken px-3 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge tone={e.impact === "high" ? "warn" : "neutral"}>
|
||||
{e.impact === "high" ? "High impact" : "Medium impact"}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-fg">{e.title}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{e.when}</p>
|
||||
<p className="text-xs text-fg-muted mt-1">{e.plainWhy}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-faint italic">{data.lesson}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
import { useMarketOutlookSections, type SectionId } from '@/stores/market-outlook-sections-store';
|
||||
|
||||
interface SectionCollapseProps {
|
||||
id: SectionId;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
info?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionCollapse({ id, title, children, info }: SectionCollapseProps) {
|
||||
const open = useMarketOutlookSections((s) => s.open[id]);
|
||||
const toggle = useMarketOutlookSections((s) => s.toggle);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); } }}
|
||||
className="flex items-center gap-2 w-full text-left group cursor-pointer"
|
||||
>
|
||||
<span className={`text-xs text-fg-muted transition-transform duration-200 ${open ? 'rotate-90' : ''}`}>
|
||||
▸
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-fg group-hover:text-accent transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{info && (
|
||||
<span onClick={(e) => e.stopPropagation()} className="inline-flex">
|
||||
{info}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open && <div className="animate-in fade-in duration-200 mt-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,49 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Microscope,
|
||||
Shield,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Search,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Left sidebar navigation with collapsible sections.
|
||||
* Organized by workflow phase: Research, Execution, Analytics, Settings.
|
||||
* Chrome is intentionally dimmed (muted text) so the content area reads as primary;
|
||||
* the active item gets an accent tint + left bar.
|
||||
*/
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
icon: string;
|
||||
items: { label: string; href: string }[];
|
||||
icon: LucideIcon;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Research",
|
||||
icon: "📊",
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{ label: "Overview", href: "/" },
|
||||
{ label: "Chart Lab", href: "/chart-lab" },
|
||||
{ label: "Institutional", href: "/institutional" },
|
||||
{ label: "Market Outlook", href: "/market-outlook" },
|
||||
{ label: "Institutional", href: "/institutional" },
|
||||
{ label: "Charts", href: "/chart-lab" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Execution",
|
||||
icon: "⚡",
|
||||
icon: Zap,
|
||||
items: [
|
||||
{ label: "Trade Plan", href: "/trade-plan" },
|
||||
{ label: "Execution", href: "/execution" },
|
||||
@@ -37,71 +53,129 @@ const SECTIONS: NavSection[] = [
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: "🔬",
|
||||
icon: Microscope,
|
||||
items: [
|
||||
{ label: "Risk Posture", href: "/risk" },
|
||||
{ label: "Filings", href: "/filings" },
|
||||
{ label: "Options DD", href: "/options-dd" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: "⚙️",
|
||||
title: "Admin",
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ label: "Profile / Auth", href: "/settings" },
|
||||
{ label: "Overview", href: "/admin" },
|
||||
{ label: "Users", href: "/admin/users" },
|
||||
{ label: "Queue", href: "/admin/queue" },
|
||||
{ label: "Audit Logs", href: "/admin/audit-logs" },
|
||||
{ label: "X Accounts", href: "/admin/x-accounts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
items: [{ label: "Profile / Auth", href: "/settings" }],
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "iflow-nav-collapsed";
|
||||
|
||||
function getInitialCollapsed(pathname: string): Record<string, boolean> {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const initial: Record<string, boolean> = {};
|
||||
const norm = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||
for (const section of SECTIONS) {
|
||||
const isActive = section.items.some((item) => item.href === norm);
|
||||
initial[section.title] = !isActive;
|
||||
}
|
||||
return initial;
|
||||
}
|
||||
|
||||
export function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(getInitialCollapsed(pathname));
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [collapsed, initialized]);
|
||||
|
||||
const toggleCollapse = (sectionTitle: string) => {
|
||||
setCollapsed(prev => ({ ...prev, [sectionTitle]: !prev[sectionTitle] }));
|
||||
setCollapsed((prev) => ({ ...prev, [sectionTitle]: !prev[sectionTitle] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-52 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block">
|
||||
<nav className="p-3 space-y-1">
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.title} className="mb-1">
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => toggleCollapse(section.title)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-[#8a8b9a] hover:text-[#e6e7ec] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded-lg"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{section.icon}</span>
|
||||
<span>{section.title}</span>
|
||||
</span>
|
||||
<span className="text-[10px] transition-transform duration-200" style={{ transform: collapsed[section.title] ? "rotate(-90deg)" : "none" }}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
const openPalette = () => window.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
|
||||
{/* Section Items */}
|
||||
{!collapsed[section.title] && (
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`block px-2 py-1.5 rounded-lg text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] ${
|
||||
isActive
|
||||
? "bg-[#4f8cff]/20 text-[#4f8cff] border-l-2 border-[#4f8cff]"
|
||||
: "text-[#8a8b9a] hover:text-[#e6e7ec] hover:bg-[#1a1b24]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<aside className="w-52 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block">
|
||||
<nav className="flex h-full flex-col p-3">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<div key={section.title} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleCollapse(section.title)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-2xs font-semibold uppercase tracking-wide text-fg-muted hover:text-fg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{section.title}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform duration-200 ${collapsed[section.title] ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!collapsed[section.title] && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = normalizedPath === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`block px-2 py-1.5 rounded-md text-xs transition-colors border-l-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
isActive
|
||||
? "bg-accent-soft text-fg border-accent font-medium"
|
||||
: "text-fg-muted hover:text-fg hover:bg-surface-overlay border-transparent"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search affordance — opens the command palette */}
|
||||
<button
|
||||
onClick={openPalette}
|
||||
className="mt-2 flex items-center gap-2 w-full rounded-md border border-line bg-surface-sunken px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-overlay transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<span>Search</span>
|
||||
<kbd className="ml-auto rounded-sm border border-line px-1 py-0.5 text-2xs">⌘K</kbd>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { XPost, XFedFeed } from "@/lib/trpc";
|
||||
|
||||
// Social feed panel: combines cashtag posts about $SYMBOL with tracked-account timelines.
|
||||
// ADR-0007 caveat banner included — crowd sentiment is not edge. Educational display only.
|
||||
export function SocialFeedPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [feed, setFeed] = useState<XFedFeed | null>(null);
|
||||
const [pages, setPages] = useState<XPost[][]>([]);
|
||||
const [cursors, setCursors] = useState<(string | null)[]>([]);
|
||||
const [pageIdx, setPageIdx] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const posts = pages[pageIdx] ?? [];
|
||||
const hasPrev = pageIdx > 0;
|
||||
const hasNext = cursors[pageIdx] != null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setPages([]);
|
||||
setCursors([]);
|
||||
setPageIdx(0);
|
||||
setLoading(true);
|
||||
api.x.feed(symbol).then((f) => {
|
||||
if (!cancelled) {
|
||||
setFeed(f);
|
||||
setPages([f.cashtagPosts]);
|
||||
setCursors([f.nextCursor]);
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
const goNext = useCallback(async () => {
|
||||
const c = cursors[pageIdx];
|
||||
if (!c) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = await api.x.feed(symbol, c);
|
||||
setFeed(f);
|
||||
setPages((prev) => [...prev, f.cashtagPosts]);
|
||||
setCursors((prev) => [...prev, f.nextCursor]);
|
||||
setPageIdx((prev) => prev + 1);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "request failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [symbol, cursors, pageIdx]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (pageIdx > 0) setPageIdx((prev) => prev - 1);
|
||||
}, [pageIdx]);
|
||||
|
||||
const configured = feed?.configured ?? false;
|
||||
const health = feed?.health;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5">
|
||||
<header className="flex items-baseline justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">X feed: {symbol}</h2>
|
||||
<p className="text-xs text-fg-muted">
|
||||
Tracked accounts mentioning ${symbol} — page {pageIdx + 1}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ADR-0007 caveat */}
|
||||
<div className="mb-4 rounded border border-warn/40 bg-warn/5 p-2.5">
|
||||
<p className="text-xs leading-relaxed text-fg-muted">
|
||||
Crowd sentiment is not edge — it's the crowd reading the same information you can access directly. Treat this as a window into what people are saying, never as a directional cue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!configured && !loading && (
|
||||
<p className="text-sm text-fg-muted mb-4">X credentials not configured — set them in Admin → X Accounts.</p>
|
||||
)}
|
||||
{configured && health?.healthy === 'degraded' && (
|
||||
<div className="mb-4 rounded border border-danger/40 bg-danger/5 p-2.5">
|
||||
<p className="text-xs leading-relaxed text-danger">
|
||||
X source degraded — {health.lastError || 'API unreachable'}. Re-enter cookies in Admin → X Accounts if this persists.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger mb-3">{error}</p>}
|
||||
|
||||
<FeedSection title={`About $${symbol}`} posts={posts} />
|
||||
|
||||
{!loading && posts.length > 0 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={!hasPrev}
|
||||
className="text-xs px-3 py-1.5 rounded border border-line text-fg-muted hover:text-fg hover:border-accent/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-[11px] text-fg-muted">Page {pageIdx + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!hasNext || loading}
|
||||
className="text-xs px-3 py-1.5 rounded border border-line text-fg-muted hover:text-fg hover:border-accent/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Next →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && posts.length === 0 && configured && (
|
||||
<p className="text-sm text-fg-muted">
|
||||
No recent posts from tracked accounts that mention ${symbol}. Both the poster (Admin → X Accounts) and the ticker must match.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && pages.length === 0 && <p className="text-sm text-fg-muted">Loading social feed…</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type FeedSort = 'age' | 'engagement';
|
||||
|
||||
function FeedSection({ title, posts }: { title: string; posts: XPost[] }) {
|
||||
const [sortBy, setSortBy] = useState<FeedSort>('age');
|
||||
const [sortDir, setSortDir] = useState<'desc' | 'asc'>('desc');
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = userFilter.trim().replace(/^@/, '').toLowerCase();
|
||||
let list = posts;
|
||||
if (q) {
|
||||
list = list.filter((p) =>
|
||||
(p.author_handle ?? '').toLowerCase().includes(q) ||
|
||||
(p.attribution ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
let cmp: number;
|
||||
if (sortBy === 'engagement') {
|
||||
cmp = (a.engagement ?? 0) - (b.engagement ?? 0);
|
||||
} else {
|
||||
cmp = new Date(a.posted_at).getTime() - new Date(b.posted_at).getTime();
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
}, [posts, userFilter, sortBy, sortDir]);
|
||||
|
||||
// Unique handles present in this feed (author + attribution), for the dropdown filter
|
||||
const handles = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const p of posts) {
|
||||
if (p.author_handle) set.add(p.author_handle);
|
||||
if (p.attribution && p.attribution !== p.author_handle) set.add(p.attribution);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
}, [posts]);
|
||||
|
||||
if (posts.length === 0) return null;
|
||||
|
||||
const toggleSort = (key: FeedSort) =>
|
||||
setSortBy((prev) => (prev === key ? prev : key));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted mb-2">{title}</h3>
|
||||
|
||||
{/* Filter + sort controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<select
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
aria-label="Filter posts by username"
|
||||
className="bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-fg focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All users</option>
|
||||
{handles.map((h) => (
|
||||
<option key={h} value={h}>@{h}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<span className="text-[11px] text-fg-muted">Sort:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('age')}
|
||||
className={`text-[11px] px-2 py-0.5 rounded border ${sortBy === 'age' ? 'text-link border-accent/50' : 'text-fg-muted border-line hover:text-fg'}`}
|
||||
>
|
||||
Age
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('engagement')}
|
||||
className={`text-[11px] px-2 py-0.5 rounded border ${sortBy === 'engagement' ? 'text-link border-accent/50' : 'text-fg-muted border-line hover:text-fg'}`}
|
||||
>
|
||||
Engagement
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||
aria-label="Toggle sort direction"
|
||||
className="text-[11px] px-1.5 py-0.5 rounded border border-line text-fg-muted hover:text-fg"
|
||||
>
|
||||
{sortDir === 'desc' ? '▼' : '▲'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{userFilter ? `No posts match @${userFilter.replace(/^@/, '')}.` : 'No recent posts.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{visible.map((p) => (
|
||||
<li key={p.post_id} className="rounded border border-line bg-surface p-3 text-sm leading-relaxed">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-medium text-fg">@{p.author_handle}</span>
|
||||
{p.attribution && p.attribution !== p.author_handle && (
|
||||
<span className="text-[11px] text-fg-muted">(via @{p.attribution})</span>
|
||||
)}
|
||||
<time className="ml-auto text-[11px] text-fg-muted tabular-nums" title={new Date(p.posted_at).toISOString()}>
|
||||
{formatRelativeTime(p.posted_at)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="text-sm">{p.body_text}</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-[11px] text-fg-muted">
|
||||
{p.engagement > 0 && <span>{formatEngagement(p.engagement)} engagement</span>}
|
||||
<a href={`https://x.com/${p.author_handle}/status/${p.post_id}`} target="_blank" rel="noopener noreferrer" className="text-link hover:underline">View on X ↗</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEngagement(n: number): string {
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
if (diffMs < 0) return "just now";
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 1) return "now";
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days}d`;
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 5) return `${weeks}w`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { api, type Snapshot } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
|
||||
/**
|
||||
* Persistent top bar showing the active symbol, price, change, and search.
|
||||
@@ -13,20 +14,24 @@ export function SymbolHeader() {
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [draft, setDraft] = useState(symbol);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [snap, setSnap] = useState<ReturnType<typeof api.market.snapshot> | null>(null);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
|
||||
// Fetch current price for display
|
||||
// Fetch current price for display — always keyed to the active symbol,
|
||||
// never the search draft (which caused stale prices to stick on the label).
|
||||
useEffect(() => {
|
||||
const sym = draft || symbol;
|
||||
api.market.snapshot(sym).then(setSnap).catch(() => {});
|
||||
}, [draft, symbol]);
|
||||
let cancelled = false;
|
||||
api.market.snapshot(symbol)
|
||||
.then((s) => { if (!cancelled) setSnap(s); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
const quote = snap?.quote ?? null;
|
||||
const sector = snap?.sector ?? null;
|
||||
|
||||
const up = (quote?.change ?? 0) >= 0;
|
||||
const priceLabel = quote ? `$${quote.price?.toFixed(2)}` : "";
|
||||
const changeLabel = quote && quote.change !== undefined
|
||||
const changeLabel = quote && quote.change != null
|
||||
? `${up ? "▲" : "▼"}${up ? "+" : ""}${quote.change.toFixed(2)} (${up ? "+" : ""}${(quote.changePercent ?? 0).toFixed(2)}%)`
|
||||
: "";
|
||||
const sectorLabel = sector ? `${sector.sector} · ${sector.tickerKind}` : "";
|
||||
@@ -38,13 +43,9 @@ export function SymbolHeader() {
|
||||
setShowSearch(false);
|
||||
}, [draft, symbol, setActive]);
|
||||
|
||||
// Keyboard shortcut: Ctrl+K to toggle search
|
||||
// Keyboard shortcut: Escape closes the inline search
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setShowSearch(prev => !prev);
|
||||
}
|
||||
if (e.key === "Escape" && showSearch) {
|
||||
setShowSearch(false);
|
||||
}
|
||||
@@ -59,28 +60,28 @@ export function SymbolHeader() {
|
||||
}, [symbol]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[#2a2b3a] bg-[#121318]/95 backdrop-blur-sm">
|
||||
<header className="sticky top-0 z-50 border-b border-line bg-surface-raised/95 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
{/* Left: Logo mark */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#4f8cff] flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-[#121318]">IF</span>
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-surface">IF</span>
|
||||
</div>
|
||||
<h1 className="hidden sm:block text-sm font-semibold text-[#e6e7ec] tracking-tight">
|
||||
<h1 className="hidden sm:block text-sm font-semibold text-fg tracking-tight">
|
||||
Investor Flow
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right: Ticker info + Search grouped together */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Right: Ticker info + Search + Theme grouped together */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Active symbol — compact, next to search */}
|
||||
<div className="hidden sm:flex items-baseline gap-2 text-xs">
|
||||
<span className="font-bold text-[#e6e7ec]">{symbol}</span>
|
||||
<span className="font-bold text-fg">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-[#8a8b9a]">{priceLabel}</span>
|
||||
<span className="font-mono text-fg-muted">{priceLabel}</span>
|
||||
)}
|
||||
{changeLabel && (
|
||||
<span className={`font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
<span className={`font-mono ${up ? "text-up" : "text-down"}`}>
|
||||
{changeLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -88,9 +89,9 @@ export function SymbolHeader() {
|
||||
|
||||
{/* Mobile: just ticker */}
|
||||
<div className="sm:hidden flex items-center gap-1.5 text-xs">
|
||||
<span className="font-bold text-[#e6e7ec]">{symbol}</span>
|
||||
<span className="font-bold text-fg">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-[#8a8b9a]">{priceLabel}</span>
|
||||
<span className="font-mono text-fg-muted">{priceLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -105,13 +106,16 @@ export function SymbolHeader() {
|
||||
onFocus={() => setShowSearch(true)}
|
||||
onBlur={() => setTimeout(() => setShowSearch(false), 200)}
|
||||
placeholder="Search…"
|
||||
className="w-full bg-[#1a1b24] border border-[#2a2b3a] rounded-lg px-3 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
className="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-[#5a5b6a] bg-[#0a0b0f] border border-[#2a2b3a] rounded px-1.5">
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-fg-muted bg-surface border border-line rounded px-1.5">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Theme switcher */}
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 25;
|
||||
export const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
|
||||
|
||||
export interface TablePagerProps {
|
||||
/** 1-based current page. */
|
||||
page: number;
|
||||
/** Rows per page. Use -1 for "All". */
|
||||
pageSize: number;
|
||||
/** Total row count across all pages. */
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
pageSizeOptions?: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared client/server pagination control. Controlled — the parent owns the
|
||||
* current page, page size, and the slicing logic (client-side slice or
|
||||
* server-side OFFSET). Renders a page-size selector, prev/next, and a
|
||||
* "showing X–Y of Z" summary.
|
||||
*/
|
||||
export function TablePager({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = PAGE_SIZE_OPTIONS,
|
||||
}: TablePagerProps) {
|
||||
const effectiveSize = pageSize === -1 ? total : pageSize;
|
||||
const pageCount = effectiveSize > 0 ? Math.max(1, Math.ceil(total / effectiveSize)) : 1;
|
||||
const clampedPage = Math.min(Math.max(1, page), pageCount);
|
||||
const start = total === 0 ? 0 : (clampedPage - 1) * effectiveSize + 1;
|
||||
const end = Math.min(clampedPage * effectiveSize, total);
|
||||
|
||||
// Keep the page in range if the total shrinks (e.g. after a data refresh).
|
||||
useEffect(() => {
|
||||
if (page > pageCount) onPageChange(pageCount);
|
||||
}, [page, pageCount, onPageChange]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-2 px-2 text-[10px] text-fg-muted">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor="pager-size" className="select-none">Rows:</label>
|
||||
<select
|
||||
id="pager-size"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
onPageSizeChange(v);
|
||||
onPageChange(1);
|
||||
}}
|
||||
className="bg-[#1e1f2a] border border-line text-fg rounded px-1.5 py-0.5 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
<option value={-1}>All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="tabular-nums">
|
||||
{total === 0 ? "No rows" : `Showing ${start}–${end} of ${total}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(clampedPage - 1)}
|
||||
disabled={clampedPage <= 1}
|
||||
aria-label="Previous page"
|
||||
className="px-2 py-0.5 rounded border border-line disabled:opacity-40 enabled:hover:text-fg enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-accent"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="tabular-nums px-1">{clampedPage} / {pageCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(clampedPage + 1)}
|
||||
disabled={clampedPage >= pageCount}
|
||||
aria-label="Next page"
|
||||
className="px-2 py-0.5 rounded border border-line disabled:opacity-40 enabled:hover:text-fg enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-accent"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Palette } from "lucide-react";
|
||||
import { THEMES, useTheme } from "@/stores/theme-store";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const current = THEMES.find((t) => t.id === theme) ?? THEMES[0];
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-line bg-surface-overlay px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label="Change color scheme"
|
||||
>
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{current.label}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-1 w-44 rounded-md border border-line bg-surface-raised shadow-xl p-1">
|
||||
<p className="px-2 py-1 text-2xs uppercase tracking-wide text-fg-muted">Color scheme</p>
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
setTheme(t.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
t.id === theme ? "bg-surface-overlay text-fg" : "text-fg-muted hover:bg-surface-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex gap-0.5 shrink-0">
|
||||
{t.preview.map((c, i) => (
|
||||
<span key={i} className="h-3 w-3 rounded-sm border border-line" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="truncate">{t.label}</span>
|
||||
</span>
|
||||
{t.id === theme && <span className="h-1.5 w-1.5 rounded-full bg-accent shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type TickerContext } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui";
|
||||
|
||||
function pct(v: number | null): string {
|
||||
if (v === null || !Number.isFinite(v)) return "—";
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return `${sign}${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function stanceTone(
|
||||
s: TickerContext["performance"]["marketStance1M"],
|
||||
): "up" | "danger" | "warn" | "neutral" {
|
||||
if (s === "outperforming") return "up";
|
||||
if (s === "underperforming") return "danger";
|
||||
if (s === "inline") return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function stanceLabel(s: TickerContext["performance"]["marketStance1M"]): string {
|
||||
if (s === "outperforming") return "Outperforming";
|
||||
if (s === "underperforming") return "Underperforming";
|
||||
if (s === "inline") return "In line";
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
function regimeLabel(regime: string | null): string {
|
||||
if (regime === "trending-up") return "Trending up";
|
||||
if (regime === "trending-down") return "Trending down";
|
||||
if (regime === "range-bound") return "Range-bound";
|
||||
return "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* Focused-ticker context: market regime, sector/theme, peer-relative performance.
|
||||
*/
|
||||
export function TickerContextPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [data, setData] = useState<TickerContext | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
api.market.tickerContext(symbol)
|
||||
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : "request failed");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5">
|
||||
<h2 className="text-base font-semibold text-fg mb-2">Market context</h2>
|
||||
<p className="text-sm text-fg-muted">Building relative context for {symbol}…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5">
|
||||
<h2 className="text-base font-semibold text-fg mb-2">Market context</h2>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Context unavailable{error ? `: ${error}` : ""}. Price history for the symbol, SPY, and sector proxies must be cached.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const p = data.performance;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-line bg-surface-raised p-5 space-y-5">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">Market context</h2>
|
||||
<p className="text-xs text-fg-muted mt-0.5">
|
||||
{data.symbol}
|
||||
{data.name ? ` · ${data.name}` : ""}
|
||||
{data.sector ? ` · ${data.sector}` : ""}
|
||||
{data.industry ? ` · ${data.industry}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge tone={stanceTone(p.marketStance1M)}>
|
||||
vs market · {stanceLabel(p.marketStance1M)}
|
||||
</Badge>
|
||||
<Badge tone={stanceTone(p.sectorStance1M)}>
|
||||
vs sector · {stanceLabel(p.sectorStance1M)}
|
||||
</Badge>
|
||||
<Badge tone={stanceTone(p.peerStance1M)}>
|
||||
vs peers · {stanceLabel(p.peerStance1M)}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Relative performance grid */}
|
||||
<div className="overflow-x-auto rounded-md border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-surface-sunken text-fg-muted">
|
||||
<th className="text-left font-medium px-3 py-2 sticky left-0 bg-surface-sunken z-10">Series</th>
|
||||
<th className="text-right font-medium px-2 py-2">1W</th>
|
||||
<th className="text-right font-medium px-2 py-2">1M</th>
|
||||
<th className="text-right font-medium px-2 py-2">3M</th>
|
||||
<th className="text-right font-medium px-2 py-2">6M</th>
|
||||
<th className="text-right font-medium px-2 py-2">1Y</th>
|
||||
<th className="text-right font-medium px-2 py-2">3Y</th>
|
||||
<th className="text-right font-medium px-2 py-2">5Y</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Row label={data.symbol} r={p.symbol} />
|
||||
<Row label="SPY (market)" r={data.market.spyReturns} />
|
||||
{data.sectorContext.etf && data.sectorContext.etfReturns && (
|
||||
<Row
|
||||
label={`${data.sectorContext.etf} (${data.sectorContext.label ?? "sector"})`}
|
||||
r={data.sectorContext.etfReturns}
|
||||
/>
|
||||
)}
|
||||
{data.themeContext.etf && data.themeContext.etfReturns && (
|
||||
<Row
|
||||
label={`${data.themeContext.etf} (${data.themeContext.label ?? "theme"})`}
|
||||
r={data.themeContext.etfReturns}
|
||||
/>
|
||||
)}
|
||||
<Row label="vs SPY (relative)" r={p.vsMarket} relative />
|
||||
{p.vsSector && (
|
||||
<Row label="vs sector (relative)" r={p.vsSector} relative />
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted -mt-3">
|
||||
Longer windows (3Y / 5Y) need multi-year price history in cache; cells show — until enough data is available.
|
||||
</p>
|
||||
|
||||
{/* Narrative blocks */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<Narrative title="Market" body={data.summary.market} meta={
|
||||
<>
|
||||
Regime: {regimeLabel(data.market.regime)}
|
||||
{data.market.confidence != null ? ` · ${data.market.confidence}% conf.` : ""}
|
||||
</>
|
||||
} />
|
||||
<Narrative title="Sector & theme" body={data.summary.sector} meta={
|
||||
data.themeContext.label
|
||||
? `Theme: ${data.themeContext.label}${data.themeContext.etf ? ` (${data.themeContext.etf})` : ""}`
|
||||
: data.sectorContext.leadership
|
||||
? `Group leadership vs SPY: ${data.sectorContext.leadership}`
|
||||
: undefined
|
||||
} />
|
||||
<Narrative title="Peers" body={data.summary.peers} className="lg:col-span-2" />
|
||||
</div>
|
||||
|
||||
{data.classification?.note && (
|
||||
<div className="rounded-md border border-warn/30 bg-warn/5 px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-warn mb-1">Classification note</p>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">{data.classification.note}</p>
|
||||
{(data.classification.vendorSector || data.classification.vendorIndustry) && (
|
||||
<p className="text-[10px] text-fg-faint mt-1">
|
||||
Vendor feed: {[data.classification.vendorSector, data.classification.vendorIndustry].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.peers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-fg-muted mb-2">Peer set (1M)</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.peers.map((peer) => (
|
||||
<span
|
||||
key={peer.symbol}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-line bg-surface-sunken text-xs"
|
||||
>
|
||||
<span className="font-mono text-fg">{peer.symbol}</span>
|
||||
<span className={`tnum ${
|
||||
peer.oneMonth == null ? "text-fg-muted"
|
||||
: peer.oneMonth >= 0 ? "text-up" : "text-danger"
|
||||
}`}>
|
||||
{pct(peer.oneMonth)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs text-fg-muted">
|
||||
{data.symbol} vs peers: {pct(p.vsPeers1M)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-line bg-surface-sunken p-4">
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-fg-muted mb-2">Synthesis</h3>
|
||||
<p className="text-sm text-fg leading-relaxed">{data.summary.synthesis}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-muted border-t border-line pt-3">
|
||||
Educational analysis, not investment advice. Relative performance is historical price context only.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
r,
|
||||
relative = false,
|
||||
}: {
|
||||
label: string;
|
||||
r: {
|
||||
oneWeek: number | null;
|
||||
oneMonth: number | null;
|
||||
threeMonth: number | null;
|
||||
sixMonth?: number | null;
|
||||
oneYear?: number | null;
|
||||
threeYear?: number | null;
|
||||
fiveYear?: number | null;
|
||||
};
|
||||
relative?: boolean;
|
||||
}) {
|
||||
const cell = (v: number | null | undefined) => {
|
||||
const cls =
|
||||
v == null ? "text-fg-muted"
|
||||
: v > 0 ? "text-up"
|
||||
: v < 0 ? "text-danger"
|
||||
: "text-fg-muted";
|
||||
return <td className={`text-right px-2 py-1.5 tnum font-medium ${cls}`}>{pct(v ?? null)}</td>;
|
||||
};
|
||||
return (
|
||||
<tr className="border-b border-line/60">
|
||||
<td className="text-left px-3 py-1.5 text-fg sticky left-0 bg-surface-raised z-10">
|
||||
{label}
|
||||
{relative && <span className="text-fg-faint"> · pp</span>}
|
||||
</td>
|
||||
{cell(r.oneWeek)}
|
||||
{cell(r.oneMonth)}
|
||||
{cell(r.threeMonth)}
|
||||
{cell(r.sixMonth)}
|
||||
{cell(r.oneYear)}
|
||||
{cell(r.threeYear)}
|
||||
{cell(r.fiveYear)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Narrative({
|
||||
title,
|
||||
body,
|
||||
meta,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
meta?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-md border border-line bg-surface-sunken p-4 ${className}`}>
|
||||
<div className="flex items-baseline justify-between gap-2 mb-2">
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-fg-muted">{title}</h3>
|
||||
{meta && <p className="text-[10px] text-fg-faint">{meta}</p>}
|
||||
</div>
|
||||
<p className="text-sm text-fg leading-relaxed">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,44 +34,44 @@ export function WatchlistPanel() {
|
||||
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>
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.watchlistTitle || "Watchlist"}</h2>
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-2">
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add symbol..."
|
||||
className="flex-1 bg-[#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>
|
||||
className="flex-1 bg-surface border border-line-strong rounded px-3 py-1.5 text-sm text-fg placeholder:text-fg-faint" />
|
||||
<button type="submit" className="px-3 py-1.5 rounded text-sm bg-accent text-white">Add</button>
|
||||
</form>
|
||||
{loading ? (
|
||||
<p className="text-sm text-[#5a5b6a]">Loading...</p>
|
||||
<p className="text-sm text-fg-faint">Loading...</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-sm text-[#5a5b6a]">{UI_STRINGS.emptyWatchlist || "Your watchlist is empty."}</p>
|
||||
<p className="text-sm text-fg-faint">{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]"
|
||||
? "bg-surface-sunken border-l-2 border-l-accent"
|
||||
: "hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-sm text-[#e6e7ec]">{e.symbol}</span>
|
||||
<span className="font-mono text-sm text-fg">{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]"
|
||||
className="text-xs text-fg-faint hover:text-accent"
|
||||
>
|
||||
{isActive(e.symbol) ? "Deselect" : "View"}
|
||||
</button>
|
||||
<button onClick={() => remove(e.symbol)}
|
||||
className="text-xs text-[#5a5b6a] hover:text-[#ef4444]">Remove</button>
|
||||
className="text-xs text-fg-faint hover:text-danger">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-[#5a5b6a] italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
<p className="text-[10px] text-fg-faint italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type WatchlistEntry, type Quote } from "@/lib/trpc";
|
||||
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
/**
|
||||
* Compact watchlist sidebar with mini sparklines and quick symbol switching.
|
||||
@@ -23,7 +25,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
const [quotes, setQuotes] = useState<Map<string, MiniQuote>>(new Map());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [holdings, setHoldings] = useState<ReturnType<typeof api.portfolio.holdings>[]>([]);
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
|
||||
// Portfolio summary
|
||||
useEffect(() => {
|
||||
@@ -73,10 +75,10 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
};
|
||||
|
||||
const containerClass = compact
|
||||
? "w-full border-b border-[#2a2b3a] bg-[#121318] flex-shrink-0"
|
||||
: "w-56 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block";
|
||||
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
|
||||
: "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block";
|
||||
|
||||
const contentClass = compact ? "flex items-center gap-3 px-4 py-2 overflow-x-auto" : "p-3 space-y-3";
|
||||
const contentClass = compact ? "flex items-center gap-3 px-4 py-2 overflow-x-auto" : "p-4 space-y-3";
|
||||
|
||||
return (
|
||||
<aside className={containerClass}>
|
||||
@@ -84,36 +86,42 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
{/* Portfolio Summary — compact mode shows minimal */}
|
||||
{compact ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">Portfolio</span>
|
||||
<span className="text-xs font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">{entries.length} sym</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-fg-muted">Portfolio</span>
|
||||
<span className="text-xs font-bold text-fg">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
||||
<span className="text-[10px] text-fg-muted">{entries.length} sym</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Portfolio</p>
|
||||
<p className="text-sm font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Portfolio</p>
|
||||
<p className="text-sm font-bold text-fg">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-[10px] text-[#5a5b6a]">{entries.length} symbols</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">{holdings?.length ?? 0} holdings</span>
|
||||
<span className="text-[10px] text-fg-muted">{entries.length} watchlist</span>
|
||||
<span className="text-[10px] text-fg-muted">{holdings?.length ?? 0} holdings</span>
|
||||
</div>
|
||||
<a
|
||||
href="/risk"
|
||||
className="mt-2 inline-block text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{UI_STRINGS.manageHoldingsLink}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Symbol — compact mode hides */}
|
||||
{/* Watchlist symbols only — not portfolio positions */}
|
||||
{!compact && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add symbol</label>
|
||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
|
||||
<input
|
||||
id="watchlist-add-symbol"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add symbol..."
|
||||
className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
placeholder="Watchlist symbol…"
|
||||
className="flex-1 bg-surface border border-line rounded px-2 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Add symbol to watchlist"
|
||||
className="px-2 py-1.5 rounded text-xs bg-[#4f8cff] text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f]"
|
||||
className="px-2 py-1.5 rounded text-xs bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f]"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -122,11 +130,11 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
|
||||
{/* Watchlist */}
|
||||
{loading ? (
|
||||
<p className="text-xs text-[#5a5b6a]">Loading…</p>
|
||||
<p className="text-xs text-fg-muted">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-[#5a5b6a]">No symbols tracked</p>
|
||||
<p className="text-[10px] text-[#5a5b6a] mt-1">Use the input above to add one</p>
|
||||
<p className="text-xs text-fg-muted">No symbols tracked</p>
|
||||
<p className="text-[10px] text-fg-muted mt-1">Use the input above to add one</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={compact ? "flex items-center gap-2 overflow-x-auto" : "space-y-0.5 max-h-[calc(100vh-320px)] overflow-y-auto"}>
|
||||
@@ -136,53 +144,60 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
const up = (quote?.change ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={e.symbol}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActive(e.symbol)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setActive(e.symbol); } }}
|
||||
aria-label={`Switch to ${e.symbol}`}
|
||||
className={`shrink-0 flex items-center gap-2 py-1 px-2 rounded-lg text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] ${
|
||||
className={`shrink-0 flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] w-full ${
|
||||
isActive
|
||||
? "bg-[#4f8cff]/20 border-l-2 border-[#4f8cff]"
|
||||
: "hover:bg-[#1a1b24]"
|
||||
? "bg-accent/20 border-l-2 border-accent"
|
||||
: "hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
<span className={`font-mono font-bold whitespace-nowrap ${isActive ? "text-[#4f8cff]" : "text-[#e6e7ec]"}`}>
|
||||
{/* Symbol — fixed width for alignment */}
|
||||
<span className={`font-mono font-bold whitespace-nowrap w-12 text-left ${isActive ? "text-accent" : "text-fg"}`}>
|
||||
{e.symbol}
|
||||
</span>
|
||||
{/* Mini sparkline with shape indicator */}
|
||||
{quote?.sparkline && quote.sparkline.length > 1 && (
|
||||
{/* Mini sparkline */}
|
||||
{quote && quote.sparkline && quote.sparkline.length > 1 && (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0" aria-hidden="true">
|
||||
<svg width="20" height="12" className="flex-shrink-0">
|
||||
<polyline
|
||||
points={quote.sparkline.map((v, i) => `${(i / (quote.sparkline!.length - 1)) * 20},${12 - ((v - Math.min(...quote.sparkline)) / (Math.max(...quote.sparkline) - Math.min(...quote.sparkline) || 1)) * 10}`).join(" ")}
|
||||
points={(quote.sparkline ?? []).map((v, i) => `${(i / ((quote.sparkline?.length ?? 1) - 1)) * 20},${12 - ((v - Math.min(...(quote.sparkline ?? []))) / (Math.max(...(quote.sparkline ?? [])) - Math.min(...(quote.sparkline ?? [])) || 1)) * 10}`).join(" ")}
|
||||
fill="none"
|
||||
stroke={up ? "#34d399" : "#fbbf24"}
|
||||
stroke={up ? CHART.up : CHART.down}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
<span className={`text-[8px] ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
<span className={`text-[8px] ${up ? "text-up" : "text-down"}`}>
|
||||
{up ? "▲" : "▼"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{quote && (
|
||||
<span className="flex items-center gap-0.5 font-mono text-[9px] whitespace-nowrap">
|
||||
<span className={up ? "text-[#34d399]" : "text-[#fbbf24]"} aria-hidden="true">{up ? "▲" : "▼"}</span>
|
||||
<span className={up ? "text-[#34d399]" : "text-[#fbbf24]"}>
|
||||
{/* Change percent — fixed width for alignment */}
|
||||
{quote ? (
|
||||
<span className="flex items-center gap-0.5 font-mono text-[9px] whitespace-nowrap w-14 justify-end">
|
||||
<span className={up ? "text-up" : "text-warn"} aria-hidden="true">{up ? "▲" : "▼"}</span>
|
||||
<span className={up ? "text-up" : "text-warn"}>
|
||||
{up ? "+" : ""}{quote.changePercent?.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-14" />
|
||||
)}
|
||||
{!compact && (
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
|
||||
aria-label={`Remove ${e.symbol} from watchlist`}
|
||||
className="text-[9px] text-[#5a5b6a] hover:text-[#ef4444] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded"
|
||||
className="text-[9px] text-fg-muted hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded ml-auto"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Daily Focus View - Placeholder component
|
||||
* TODO: Implement with actual data fetching from api.goals.* endpoints
|
||||
*/
|
||||
export function DailyFocusView() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Daily Focus View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — behavioral goals and daily tasks</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Execution View - Placeholder component
|
||||
* TODO: Implement with actual order management and trade execution tracking
|
||||
*/
|
||||
export function ExecutionView() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Execution View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — order management and trade execution</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Market Outlook View - Placeholder component
|
||||
* TODO: Implement with macro analysis and market regime classification
|
||||
*/
|
||||
export function MarketOutlookView() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Market Outlook View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — macro analysis and market regime</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Trade Closure View - Placeholder component
|
||||
* TODO: Implement with trade closure workflow and exit strategies
|
||||
*/
|
||||
export function TradeClosureView() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Trade Closure View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — trade closure and exit strategies</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Trade Plan View - Placeholder component
|
||||
* TODO: Implement with actual trade plan creation and management
|
||||
*/
|
||||
export function TradePlanView() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-fg mb-4">Trade Plan View</h2>
|
||||
<p className="text-sm text-fg-muted">Coming soon — trade plan creation and management</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type Tone = "neutral" | "accent" | "up" | "down" | "warn" | "danger" | "info";
|
||||
|
||||
const tones: Record<Tone, string> = {
|
||||
neutral: "bg-surface-overlay text-fg-muted border-line",
|
||||
accent: "bg-accent-soft text-accent border-accent/30",
|
||||
up: "bg-up/10 text-up border-up/30",
|
||||
down: "bg-down/10 text-down border-down/30",
|
||||
warn: "bg-warn/10 text-warn border-warn/30",
|
||||
danger: "bg-danger/10 text-danger border-danger/30",
|
||||
info: "bg-info/10 text-info border-info/30",
|
||||
};
|
||||
|
||||
const dotColor: Record<Tone, string> = {
|
||||
neutral: "bg-fg-faint",
|
||||
accent: "bg-accent",
|
||||
up: "bg-up",
|
||||
down: "bg-down",
|
||||
warn: "bg-warn",
|
||||
danger: "bg-danger",
|
||||
info: "bg-info",
|
||||
};
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode;
|
||||
tone?: Tone;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Badge({ children, tone = "neutral", dot = false, className = "" }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-sm border px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide ${tones[tone]} ${className}`}
|
||||
>
|
||||
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor[tone]}`} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type ButtonHTMLAttributes } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "danger" | "ghost";
|
||||
type Size = "sm" | "md";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-accent text-white hover:bg-accent-hover",
|
||||
secondary: "bg-surface-overlay text-fg border border-line hover:bg-surface-sunken",
|
||||
danger: "bg-transparent text-danger border border-danger hover:bg-danger/10",
|
||||
ghost: "bg-transparent text-fg-muted hover:text-fg hover:bg-surface-overlay",
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "text-xs px-2.5 py-1",
|
||||
md: "text-sm px-3 py-1.5",
|
||||
};
|
||||
|
||||
export function Button({ variant = "secondary", size = "md", className = "", ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Removes inner padding (for flush content like tables). */
|
||||
flush?: boolean;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", flush = false }: CardProps) {
|
||||
return (
|
||||
<section
|
||||
className={`bg-surface-raised border border-line rounded-md ${flush ? "" : "p-4"} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, actions, className = "" }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-3 mb-3 pb-2 border-b border-line ${className}`}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg truncate">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-fg-muted mt-0.5 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
render: (row: T) => ReactNode;
|
||||
/** Enables sorting when provided. */
|
||||
sortValue?: (row: T) => string | number;
|
||||
align?: "left" | "right" | "center";
|
||||
/** Applies tabular-nums (use for numeric columns). */
|
||||
numeric?: boolean;
|
||||
width?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
initialSort?: { key: string; dir: "asc" | "desc" };
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
dense?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
initialSort,
|
||||
emptyMessage = "No data",
|
||||
className = "",
|
||||
dense = false,
|
||||
}: DataTableProps<T>) {
|
||||
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(initialSort ?? null);
|
||||
|
||||
const sorted = [...rows];
|
||||
if (sort) {
|
||||
const col = columns.find((c) => c.key === sort.key);
|
||||
if (col?.sortValue) {
|
||||
sorted.sort((a, b) => {
|
||||
const av = col.sortValue!(a);
|
||||
const bv = col.sortValue!(b);
|
||||
const cmp =
|
||||
typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return sort.dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSort = (col: Column<T>) => {
|
||||
if (!col.sortValue) return;
|
||||
setSort((prev) =>
|
||||
prev?.key === col.key
|
||||
? { key: col.key, dir: prev.dir === "asc" ? "desc" : "asc" }
|
||||
: { key: col.key, dir: "asc" },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full overflow-x-auto rounded-md border border-line ${className}`}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-sunken">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={() => toggleSort(col)}
|
||||
style={{ width: col.width }}
|
||||
className={`px-3 ${dense ? "py-1.5" : "py-2"} text-xs font-medium text-fg-muted uppercase tracking-wide border-b border-line ${
|
||||
col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : "text-left"
|
||||
} ${col.sortValue ? "cursor-pointer select-none hover:text-fg" : ""}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{sort?.key === col.key && (
|
||||
<span className="text-accent">{sort.dir === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-6 text-center text-fg-muted text-sm">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={`border-b border-line/60 last:border-0 ${onRowClick ? "cursor-pointer hover:bg-surface-overlay" : ""}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 ${dense ? "py-1.5" : "py-2"} text-fg ${
|
||||
col.numeric ? "tnum tabular-nums" : ""
|
||||
} ${col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : "text-left"} ${col.className ?? ""}`}
|
||||
>
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = "", invalid = false, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full bg-surface-overlay border rounded-md px-3 py-1.5 text-sm text-fg placeholder:text-fg-faint outline-none transition-colors focus:border-accent disabled:opacity-50 ${invalid ? "border-danger" : "border-line"} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizes = { sm: "max-w-sm", md: "max-w-lg", lg: "max-w-2xl" };
|
||||
|
||||
export function Modal({ open, onOpenChange, title, description, children, footer, size = "md" }: ModalProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className={`fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-line bg-surface-raised p-5 shadow-xl focus:outline-none ${sizes[size]}`}
|
||||
>
|
||||
{title && <Dialog.Title className="text-base font-semibold text-fg">{title}</Dialog.Title>}
|
||||
{description && (
|
||||
<Dialog.Description className="mt-1 text-sm text-fg-muted">{description}</Dialog.Description>
|
||||
)}
|
||||
<div className="mt-4">{children}</div>
|
||||
{footer && <div className="mt-5 flex justify-end gap-2">{footer}</div>}
|
||||
<Dialog.Close className="absolute right-3 top-3 rounded-md p-1 text-fg-muted hover:bg-surface-overlay hover:text-fg focus:outline-none">
|
||||
<X className="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import * as Select from "@radix-ui/react-select";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SelectViewProps {
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectView({ value, onValueChange, options, placeholder, className = "" }: SelectViewProps) {
|
||||
return (
|
||||
<Select.Root value={value} onValueChange={onValueChange}>
|
||||
<Select.Trigger
|
||||
className={`inline-flex items-center justify-between gap-2 rounded-md border border-line bg-surface-overlay px-3 py-1.5 text-sm text-fg hover:bg-surface-sunken focus:outline-none focus:border-accent ${className}`}
|
||||
>
|
||||
<Select.Value placeholder={placeholder} />
|
||||
<Select.Icon>
|
||||
<ChevronDown className="h-4 w-4 text-fg-muted" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="z-50 overflow-hidden rounded-md border border-line bg-surface-raised shadow-xl">
|
||||
<Select.Viewport className="p-1">
|
||||
{options.map((opt) => (
|
||||
<Select.Item
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="flex items-center px-3 py-1.5 text-sm text-fg rounded-sm cursor-pointer outline-none data-[highlighted]:bg-surface-overlay data-[state=checked]:text-accent"
|
||||
>
|
||||
<Select.ItemText>{opt.label}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface TabItem {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface TabsViewProps {
|
||||
items: TabItem[];
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onValueChange?: (v: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabsView({ items, defaultValue, value, onValueChange, className = "" }: TabsViewProps) {
|
||||
return (
|
||||
<Tabs.Root
|
||||
defaultValue={defaultValue ?? items[0]?.value}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className={className}
|
||||
>
|
||||
<Tabs.List className="flex gap-1 border-b border-line mb-3">
|
||||
{items.map((it) => (
|
||||
<Tabs.Trigger
|
||||
key={it.value}
|
||||
value={it.value}
|
||||
className="px-3 py-1.5 text-sm text-fg-muted border-b-2 border-transparent -mb-px transition-colors hover:text-fg data-[state=active]:text-fg data-[state=active]:border-accent focus:outline-none"
|
||||
>
|
||||
{it.label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{items.map((it) => (
|
||||
<Tabs.Content key={it.value} value={it.value} className="focus:outline-none">
|
||||
{it.content}
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Card, CardHeader } from "./Card";
|
||||
export { Button } from "./Button";
|
||||
export { Badge } from "./Badge";
|
||||
export { Input } from "./Input";
|
||||
export { DataTable, type Column } from "./DataTable";
|
||||
export { Modal } from "./Modal";
|
||||
export { TabsView, type TabItem } from "./Tabs";
|
||||
export { SelectView, type SelectOption } from "./Select";
|
||||
Reference in New Issue
Block a user