learning_ai_invt_trdg/web/src/views/HomeView.tsx

811 lines
33 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Star, Bell, BarChart2, Loader2 } from 'lucide-react';
import {
AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, ReferenceLine,
} from 'recharts';
import { useAppContext } from '../context/AppContext';
import {
fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings,
type OHLCVBar,
} from '../lib/marketApi';
import { SkeletonBlock, SkeletonText } from '../components/Skeleton';
// ─── Time period config ───────────────────────────────────────────────────────
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
type Period = typeof PERIODS[number];
type IndicatorKey = 'rsi' | 'macd' | 'bollinger';
interface ChartPoint {
ts: number;
price: number;
label: string;
bollingerUpper?: number;
bollingerMiddle?: number;
bollingerLower?: number;
rsi?: number;
macd?: number;
macdSignal?: number;
macdHistogram?: number;
}
interface ResearchProfile {
companyName?: string;
sector?: string;
industry?: string;
description?: string;
website?: string;
mktCap?: number;
revenue?: number;
exchangeShortName?: string;
}
const INDICATORS: Array<{ key: IndicatorKey; label: string; hint: string }> = [
{ key: 'rsi', label: 'RSI', hint: '14-period momentum' },
{ key: 'macd', label: 'MACD', hint: '12/26 EMA trend' },
{ key: 'bollinger', label: 'Bollinger', hint: '20-period bands' },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatPriceLabel(ts: number, period: Period) {
const d = new Date(ts);
if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (period === '5D') return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function formatAsOfTimestamp(ts: number | null) {
if (ts == null) return 'Latest bar pending';
return new Date(ts).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function calculateRsi(closes: number[], period = 14): Array<number | undefined> {
const rsi: Array<number | undefined> = Array(closes.length).fill(undefined);
if (closes.length <= period) return rsi;
const fromAverages = (gain: number, loss: number) => {
if (gain === 0 && loss === 0) return 50;
if (loss === 0) return 100;
return 100 - (100 / (1 + gain / loss));
};
let gainSum = 0;
let lossSum = 0;
for (let i = 1; i <= period; i += 1) {
const change = closes[i] - closes[i - 1];
gainSum += Math.max(change, 0);
lossSum += Math.max(-change, 0);
}
let averageGain = gainSum / period;
let averageLoss = lossSum / period;
rsi[period] = fromAverages(averageGain, averageLoss);
for (let i = period + 1; i < closes.length; i += 1) {
const change = closes[i] - closes[i - 1];
averageGain = ((averageGain * (period - 1)) + Math.max(change, 0)) / period;
averageLoss = ((averageLoss * (period - 1)) + Math.max(-change, 0)) / period;
rsi[i] = fromAverages(averageGain, averageLoss);
}
return rsi;
}
function calculateEma(values: number[], period: number): Array<number | undefined> {
const ema: Array<number | undefined> = Array(values.length).fill(undefined);
if (values.length < period) return ema;
const multiplier = 2 / (period + 1);
ema[period - 1] = average(values.slice(0, period));
for (let i = period; i < values.length; i += 1) {
ema[i] = (values[i] - ema[i - 1]!) * multiplier + ema[i - 1]!;
}
return ema;
}
function calculateMacd(closes: number[]) {
const fast = calculateEma(closes, 12);
const slow = calculateEma(closes, 26);
const macd: Array<number | undefined> = closes.map((_, i) => (
fast[i] != null && slow[i] != null ? fast[i]! - slow[i]! : undefined
));
const signal: Array<number | undefined> = Array(closes.length).fill(undefined);
const signalPeriod = 9;
const signalMultiplier = 2 / (signalPeriod + 1);
for (let i = 0; i < macd.length; i += 1) {
if (macd[i] == null) continue;
const recentMacd = macd.slice(0, i + 1).filter((value): value is number => value != null);
if (recentMacd.length < signalPeriod) continue;
const previousSignal = signal[i - 1];
signal[i] = previousSignal == null
? average(recentMacd.slice(-signalPeriod))
: (macd[i]! - previousSignal) * signalMultiplier + previousSignal;
}
const histogram = macd.map((value, i) => (
value != null && signal[i] != null ? value - signal[i]! : undefined
));
return { macd, signal, histogram };
}
function calculateBollingerBands(closes: number[], period = 20, deviations = 2) {
const upper: Array<number | undefined> = Array(closes.length).fill(undefined);
const middle: Array<number | undefined> = Array(closes.length).fill(undefined);
const lower: Array<number | undefined> = Array(closes.length).fill(undefined);
for (let i = period - 1; i < closes.length; i += 1) {
const slice = closes.slice(i - period + 1, i + 1);
const mean = average(slice);
const variance = average(slice.map(value => (value - mean) ** 2));
const standardDeviation = Math.sqrt(variance);
middle[i] = mean;
upper[i] = mean + standardDeviation * deviations;
lower[i] = mean - standardDeviation * deviations;
}
return { upper, middle, lower };
}
function normalizeResearchProfile(profile: any): ResearchProfile | null {
return Array.isArray(profile) ? profile[0] : profile;
}
// ─── Ticker header ────────────────────────────────────────────────────────────
export function TickerHeader({
symbol,
profile,
latestBarTimestamp,
}: {
symbol: string;
profile?: ResearchProfile | null;
latestBarTimestamp?: number | null;
}) {
const navigate = useNavigate();
const { botState } = useAppContext();
const data = botState.symbols?.[symbol];
const price = data?.price ?? 0;
const change = data?.changeToday ?? 0;
const changePct = price > 0 ? (change / (price - change)) * 100 : 0;
const positive = change >= 0;
const company = profile?.companyName;
const companyName = typeof company === 'string' && company.trim() ? company.trim() : symbol;
return (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: '#111827', margin: 0 }}>
{symbol}
</h1>
<span style={{ fontSize: 13, color: '#6B7280', fontWeight: 500, marginTop: 4 }}>
{companyName}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button
type="button"
aria-label={`Open watchlist for ${symbol}`}
onClick={() => navigate('/watchlist')}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 12px', borderRadius: 20,
background: '#F0FDF4', border: '1px solid #86EFAC',
color: '#16A34A', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>
<Star size={13} fill="#16A34A" /> Watchlist
</button>
<button
type="button"
aria-label={`Open alerts for ${symbol}`}
onClick={() => navigate('/alerts')}
style={{
width: 32, height: 32, borderRadius: '50%',
border: '1px solid #E5E7EB', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: '#6B7280',
}}>
<Bell size={15} />
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<span style={{ fontSize: 32, fontWeight: 800, color: '#111827', letterSpacing: '-1px' }}>
{price > 0 ? price.toFixed(2) : '—'}
</span>
{price > 0 && (
<span style={{ fontSize: 15, fontWeight: 600, color: positive ? '#16A34A' : '#DC2626' }}>
{positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%)
</span>
)}
</div>
<div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 3 }}>
{formatAsOfTimestamp(latestBarTimestamp ?? null)} ET · NASDAQ
</div>
</div>
);
}
// ─── Stock chart ──────────────────────────────────────────────────────────────
function StockChart({
symbol,
onLatestBarTimestamp,
}: {
symbol: string;
onLatestBarTimestamp?: (timestamp: number | null) => void;
}) {
const [period, setPeriod] = useState<Period>('1Y');
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [enabledIndicators, setEnabledIndicators] = useState<Record<IndicatorKey, boolean>>({
rsi: false,
macd: false,
bollinger: false,
});
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setBars([]);
onLatestBarTimestamp?.(null);
fetchChartBars(symbol, period)
.then(data => {
if (!cancelled) {
setBars(data);
onLatestBarTimestamp?.(data.at(-1)?.ts ?? null);
}
})
.catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load chart'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [symbol, period, onLatestBarTimestamp]);
const closes = bars.map(b => b.close);
const rsi = calculateRsi(closes);
const macd = calculateMacd(closes);
const bollinger = calculateBollingerBands(closes);
const chartData: ChartPoint[] = bars.map((b, index) => ({
ts: b.ts,
price: b.close,
label: formatPriceLabel(b.ts, period),
bollingerUpper: bollinger.upper[index],
bollingerMiddle: bollinger.middle[index],
bollingerLower: bollinger.lower[index],
rsi: rsi[index],
macd: macd.macd[index],
macdSignal: macd.signal[index],
macdHistogram: macd.histogram[index],
}));
const firstPrice = chartData[0]?.price ?? 0;
const lastPrice = chartData[chartData.length - 1]?.price ?? 0;
const positive = lastPrice >= firstPrice;
const lineColor = positive ? '#2563EB' : '#DC2626';
const priceYValues = chartData.flatMap(d => [
d.price,
...(enabledIndicators.bollinger
? [d.bollingerUpper, d.bollingerMiddle, d.bollingerLower].filter((value): value is number => value != null)
: []),
]);
const macdValues = chartData.flatMap(d => (
[d.macd, d.macdSignal, d.macdHistogram].filter((value): value is number => value != null)
));
const minY = priceYValues.length ? Math.min(...priceYValues) : 0;
const maxY = priceYValues.length ? Math.max(...priceYValues) : 100;
const pad = (maxY - minY) * 0.1 || 10;
const macdMaxAbs = macdValues.length ? Math.max(...macdValues.map(value => Math.abs(value))) : 1;
const enabledCount = Object.values(enabledIndicators).filter(Boolean).length;
const toggleIndicator = (key: IndicatorKey) => {
setEnabledIndicators(prev => ({ ...prev, [key]: !prev[key] }));
};
return (
<div style={{
background: '#fff',
borderRadius: 12,
border: '1px solid #E5E7EB',
padding: '16px 20px 12px',
marginBottom: 20,
}}>
{/* Period selector + chart type */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
<div style={{ display: 'flex', gap: 2 }}>
{PERIODS.map(p => (
<button
key={p}
onClick={() => setPeriod(p)}
style={{
padding: '4px 9px',
border: 'none',
borderRadius: 6,
fontSize: 12,
fontWeight: period === p ? 700 : 500,
background: period === p ? '#EFF6FF' : 'transparent',
color: period === p ? '#2563EB' : '#6B7280',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{p}
</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, color: '#6B7280', fontSize: 12 }}>
<div style={{ display: 'flex', gap: 6 }}>
{INDICATORS.map(indicator => {
const active = enabledIndicators[indicator.key];
return (
<button
key={indicator.key}
onClick={() => toggleIndicator(indicator.key)}
title={indicator.hint}
aria-pressed={active}
style={{
padding: '5px 9px',
borderRadius: 999,
border: active ? '1px solid #93C5FD' : '1px solid #E5E7EB',
background: active ? '#EFF6FF' : '#fff',
color: active ? '#1D4ED8' : '#6B7280',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer',
boxShadow: active ? '0 2px 8px rgba(37,99,235,0.10)' : 'none',
}}
>
{indicator.label}
</button>
);
})}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<BarChart2 size={14} /> Line Chart
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontSize: 11, color: '#9CA3AF' }}>
Indicators: {enabledCount > 0 ? `${enabledCount} active` : 'none'}
</div>
<div style={{ fontSize: 11, color: '#9CA3AF' }}>
RSI 14 · MACD 12/26/9 · Bollinger 20/2
</div>
</div>
{/* Chart */}
{loading ? (
<div style={{
height: 220, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 10, color: '#9CA3AF',
}}>
<Loader2 size={28} color="#93C5FD" style={{ animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: 13 }}>Loading chart</span>
</div>
) : error ? (
<div style={{
height: 220, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 8, color: '#EF4444', fontSize: 13,
}}>
<BarChart2 size={32} color="#FCA5A5" />
<span>{error}</span>
</div>
) : chartData.length < 2 ? (
<div style={{
height: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#9CA3AF',
fontSize: 13,
gap: 8,
}}>
<BarChart2 size={32} color="#D1D5DB" />
<span>No price data available for {symbol}</span>
</div>
) : (
<div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={lineColor} stopOpacity={0.15} />
<stop offset="95%" stopColor={lineColor} stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={[minY - pad, maxY + pad]}
tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false}
axisLine={false}
width={55}
tickFormatter={v => `$${v.toFixed(0)}`}
/>
<Tooltip
contentStyle={{
background: '#fff',
border: '1px solid #E5E7EB',
borderRadius: 8,
fontSize: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
}}
formatter={(val: unknown, name: unknown) => {
const labels: Record<string, string> = {
price: 'Price',
bollingerUpper: 'BB Upper',
bollingerMiddle: 'BB Mid',
bollingerLower: 'BB Lower',
};
const key = String(name ?? '');
return [`$${Number(val).toFixed(2)}`, labels[key] ?? key];
}}
labelStyle={{ color: '#6B7280', fontSize: 11 }}
/>
{enabledIndicators.bollinger && (
<>
<Line type="monotone" dataKey="bollingerUpper" stroke="#F59E0B" strokeWidth={1.4} dot={false} strokeDasharray="4 4" connectNulls />
<Line type="monotone" dataKey="bollingerMiddle" stroke="#FBBF24" strokeWidth={1.2} dot={false} strokeDasharray="2 4" connectNulls />
<Line type="monotone" dataKey="bollingerLower" stroke="#F59E0B" strokeWidth={1.4} dot={false} strokeDasharray="4 4" connectNulls />
</>
)}
<Area
type="monotone"
dataKey="price"
stroke={lineColor}
strokeWidth={2}
fill="url(#chartGrad)"
dot={false}
activeDot={{ r: 4, fill: lineColor, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
{enabledIndicators.rsi && (
<div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #F3F4F6' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 11, color: '#6B7280', fontWeight: 700 }}>
<span>RSI (14)</span>
<span style={{ color: '#9CA3AF', fontWeight: 500 }}>70 overbought · 30 oversold</span>
</div>
<ResponsiveContainer width="100%" height={86}>
<LineChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
<XAxis dataKey="label" hide />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#9CA3AF' }} tickLine={false} axisLine={false} width={55} />
<Tooltip
contentStyle={{ background: '#fff', border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 12 }}
formatter={(val: unknown) => [Number(val).toFixed(1), 'RSI']}
labelStyle={{ color: '#6B7280', fontSize: 11 }}
/>
<ReferenceLine y={70} stroke="#FCA5A5" strokeDasharray="3 3" />
<ReferenceLine y={30} stroke="#93C5FD" strokeDasharray="3 3" />
<Line type="monotone" dataKey="rsi" stroke="#7C3AED" strokeWidth={1.8} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)}
{enabledIndicators.macd && (
<div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #F3F4F6' }}>
<div style={{ marginBottom: 4, fontSize: 11, color: '#6B7280', fontWeight: 700 }}>
MACD (12, 26, 9)
</div>
<ResponsiveContainer width="100%" height={106}>
<ComposedChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
<XAxis dataKey="label" hide />
<YAxis
domain={[-macdMaxAbs * 1.2, macdMaxAbs * 1.2]}
tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false}
axisLine={false}
width={55}
/>
<Tooltip
contentStyle={{ background: '#fff', border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 12 }}
formatter={(val: unknown, name: unknown) => {
const labels: Record<string, string> = {
macdHistogram: 'Histogram',
macd: 'MACD',
macdSignal: 'Signal',
};
const key = String(name ?? '');
return [Number(val).toFixed(3), labels[key] ?? key];
}}
labelStyle={{ color: '#6B7280', fontSize: 11 }}
/>
<ReferenceLine y={0} stroke="#CBD5E1" />
<Bar dataKey="macdHistogram" fill="#BFDBFE" radius={[2, 2, 0, 0]} />
<Line type="monotone" dataKey="macd" stroke="#2563EB" strokeWidth={1.7} dot={false} connectNulls />
<Line type="monotone" dataKey="macdSignal" stroke="#F97316" strokeWidth={1.5} dot={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
)}
</div>
);
}
// ─── Quick stats cards ────────────────────────────────────────────────────────
function QuickStats({ symbol }: { symbol: string }) {
const { botState } = useAppContext();
const d = botState.symbols?.[symbol];
const stats = [
{ label: 'RSI (1H)', value: d?.indicators?.rsi_1h?.toFixed(1) ?? '—' },
{ label: 'EMA 50', value: d?.indicators?.ema50_4h?.toFixed(2) ?? '—' },
{ label: 'EMA 200', value: d?.indicators?.ema200_4h?.toFixed(2) ?? '—' },
{ label: 'Signal', value: d?.signal ?? '—' },
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 20 }}>
{stats.map(s => (
<div key={s.label} style={{
background: '#fff',
borderRadius: 10,
border: '1px solid #E5E7EB',
padding: '12px 14px',
}}>
<div style={{ fontSize: 11, color: '#9CA3AF', fontWeight: 500, marginBottom: 4 }}>{s.label}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>{s.value}</div>
</div>
))}
</div>
);
}
// ─── Live research / financials cards (Phase 4) ───────────────────────────────
const fmtBig = (n: number | undefined) => {
if (n == null || n === 0) return '—';
if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
return `$${n.toFixed(2)}`;
};
function ResearchCards({
symbol,
profile,
profileLoading,
}: {
symbol: string;
profile: ResearchProfile | null;
profileLoading: boolean;
}) {
const [metrics, setMetrics] = useState<any>(null);
const [earnings, setEarnings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setMetrics(null); setEarnings([]);
Promise.allSettled([
fetchResearchMetrics(symbol),
fetchResearchEarnings(symbol),
]).then(([m, e]) => {
if (cancelled) return;
if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value);
if (e.status === 'fulfilled') setEarnings(e.value ?? []);
setLoading(false);
});
return () => { cancelled = true; };
}, [symbol]);
const nextEarnings = earnings.find(e => e.date && new Date(e.date) >= new Date());
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—';
const ValueSkeleton = ({ width = 58 }: { width?: number }) => <SkeletonBlock width={width} height={10} />;
const financialRows: [string, string][] = [
['Market Cap', fmtBig(profile?.mktCap)],
['Revenue (TTM)', fmtBig(metrics?.revenuePerShareTTM != null && metrics?.sharesWSOQuarterly != null
? metrics.revenuePerShareTTM * metrics.sharesWSOQuarterly
: profile?.revenue ?? undefined)],
['Net Income (TTM)', fmtBig(metrics?.netIncomePerShareTTM != null && metrics?.sharesWSOQuarterly != null
? metrics.netIncomePerShareTTM * metrics.sharesWSOQuarterly
: undefined)],
['P/E Ratio (TTM)', metrics?.peRatioTTM != null ? metrics.peRatioTTM.toFixed(1) : '—'],
['ROE (TTM)', metrics?.roeTTM != null ? `${(metrics.roeTTM * 100).toFixed(1)}%` : '—'],
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{/* Company Profile */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
📋 Company
</div>
{profileLoading ? (
<div role="status" aria-label="Loading company profile" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<SkeletonText width="70%" />
<SkeletonText width="96%" />
<SkeletonText width="88%" />
<SkeletonText width="55%" />
</div>
) : profile ? (
<>
<div style={{ fontSize: 12, color: '#374151', marginBottom: 6, lineHeight: 1.5 }}>
<strong>{profile.companyName ?? symbol}</strong>
{profile.sector && <> · {profile.sector}</>}
{profile.industry && <> · {profile.industry}</>}
</div>
<div style={{ fontSize: 11, color: '#6B7280', lineHeight: 1.6, marginBottom: 8,
display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>
{profile.description ?? ''}
</div>
{profile.website && (
<a href={profile.website} target="_blank" rel="noreferrer"
style={{ fontSize: 11, color: '#2563EB', textDecoration: 'none' }}>
{profile.website}
</a>
)}
</>
) : (
<div style={{ fontSize: 12, color: '#9CA3AF' }}>No profile data</div>
)}
</div>
{/* Financials */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
📊 Financials
</div>
{financialRows.map(([label, val]) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between',
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>
{loading || profileLoading ? <ValueSkeleton width={label.length > 10 ? 64 : 46} /> : val}
</span>
</div>
))}
</div>
{/* Events / Earnings */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
📅 Events
</div>
{[
['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)],
['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'],
['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'],
['Exchange', profileLoading ? '…' : profile?.exchangeShortName ?? '—'],
].map(([label, val]) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>
{val === '…' ? <ValueSkeleton width={label === 'Next Earnings' ? 82 : 52} /> : val}
</span>
</div>
))}
{!loading && earnings.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#374151', marginBottom: 4 }}>Past Earnings</div>
{earnings.slice(0,3).map((e, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#6B7280', padding: '2px 0' }}>
<span>{fmtDate(e.date)}</span>
<span style={{ color: e.eps >= (e.epsEstimated ?? e.eps) ? '#16A34A' : '#DC2626' }}>
EPS {e.eps != null ? `$${e.eps.toFixed(2)}` : '—'}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ─── Empty state ──────────────────────────────────────────────────────────────
function EmptyState({ onSelect }: { onSelect: (symbol: string) => void }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '60vh', gap: 16,
color: '#9CA3AF',
}}>
<div style={{ fontSize: 56 }}>📈</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#374151' }}>
Search a company to get started
</div>
<div style={{ fontSize: 14, textAlign: 'center', maxWidth: 360 }}>
Type a ticker symbol or company name in the search bar above to view charts, financials, and news.
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
{['AAPL','MSFT','GOOGL','AMZN','NVDA'].map(t => (
<span
key={t}
onClick={() => onSelect(t)}
style={{
padding: '4px 12px',
background: '#EFF6FF',
color: '#2563EB',
borderRadius: 20,
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
}}
>
{t}
</span>
))}
</div>
</div>
);
}
// ─── HomeView ─────────────────────────────────────────────────────────────────
export function HomeView() {
const { activeSymbol, setActiveSymbol } = useAppContext();
const [profile, setProfile] = useState<ResearchProfile | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [latestBarTimestamp, setLatestBarTimestamp] = useState<number | null>(null);
useEffect(() => {
if (!activeSymbol) {
setProfile(null);
setProfileLoading(false);
return;
}
let cancelled = false;
setProfile(null);
setProfileLoading(true);
fetchResearchProfile(activeSymbol)
.then(data => {
if (!cancelled) setProfile(normalizeResearchProfile(data));
})
.catch(() => {
if (!cancelled) setProfile(null);
})
.finally(() => {
if (!cancelled) setProfileLoading(false);
});
return () => { cancelled = true; };
}, [activeSymbol]);
if (!activeSymbol) return <EmptyState onSelect={setActiveSymbol} />;
return (
<div>
<TickerHeader symbol={activeSymbol} profile={profile} latestBarTimestamp={latestBarTimestamp} />
<StockChart symbol={activeSymbol} onLatestBarTimestamp={setLatestBarTimestamp} />
<QuickStats symbol={activeSymbol} />
<ResearchCards symbol={activeSymbol} profile={profile} profileLoading={profileLoading} />
</div>
);
}