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:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+82
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+282 -185
View File
@@ -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>
+282
View File
@@ -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>
);
}
+89
View File
@@ -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&apos;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)}`;
}
+33 -33
View File
@@ -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>
+117 -63
View File
@@ -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>
+192
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+364 -333
View File
@@ -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 &amp; 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 (&gt;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.&nbsp;
<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>
+10 -6
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+126 -146
View File
@@ -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>
);
}
+378
View File
@@ -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 &gt; +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 &lt; −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>
);
}
+4 -4
View File
@@ -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>
+267
View File
@@ -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>
);
}
+62 -61
View File
@@ -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&amp;L at expiration for a {atmRow.right} option with strike ${atmRow.strike}.
The curve illustrates the convex payoff mechanic — gains accelerate as the stock moves
in your favor past the strike.
@@ -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>
+41 -17
View File
@@ -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>
+150 -50
View File
@@ -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>
);
}
+378
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+161
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+127 -53
View File
@@ -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>
);
+274
View File
@@ -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&apos;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);
}
}
+29 -25
View File
@@ -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>
+92
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+278
View File
@@ -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>
);
}
+12 -12
View File
@@ -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>
);
}
+52 -37
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+30
View File
@@ -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}
/>
);
}
+37
View File
@@ -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>
);
}
+120
View File
@@ -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>
);
}
+16
View File
@@ -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";
+39
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+8
View File
@@ -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";