learning_ai_invt_trdg/web/src/components/layout/RightPanel.tsx

269 lines
9.7 KiB
TypeScript

import { useState, useEffect } from 'react';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
import { fetchNews, type NewsArticle as MarketNewsArticle } from '../../lib/marketApi';
import { SkeletonBlock, SkeletonText } from '../Skeleton';
import { Button } from '../ui/Primitives';
// ─── Portfolio Summary ────────────────────────────────────────────────────────
function PortfolioSummary() {
const { botState } = useAppContext();
const positions = botState.positions ?? [];
const account = botState.accountSnapshot;
const totalValue = account
? account.cash + positions.reduce((s, p) => s + (p.marketValue ?? 0), 0)
: positions.reduce((s, p) => s + (p.marketValue ?? 0), 0);
const totalPnl = positions.reduce((s, p) => s + (p.unrealizedPnl ?? 0), 0);
const pnlPos = totalPnl >= 0;
const fmt$ = (n: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
return (
<div style={{ padding: '16px 16px 12px' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--foreground)' }}>Portfolio</span>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
View All <ArrowRight size={12} />
</Button>
</div>
{/* Total value */}
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--foreground)', letterSpacing: '-0.5px' }}>
{fmt$(totalValue)}
</div>
<div style={{
fontSize: 12, fontWeight: 600, marginTop: 2,
color: pnlPos ? 'var(--bl-success)' : 'var(--bl-danger)',
}}>
{pnlPos ? '+' : ''}{fmt$(totalPnl)} unrealized
</div>
</div>
{/* Column headers */}
<div style={{
display: 'grid',
gridTemplateColumns: '2fr 1.2fr 1fr 1.2fr',
gap: 4, paddingBottom: 6,
borderBottom: '1px solid var(--border)', marginBottom: 4,
}}>
{['Symbol','Price','Change','Value'].map(h => (
<span key={h} style={{ fontSize: 10, color: 'var(--muted-foreground)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</span>
))}
</div>
{/* Rows */}
{positions.length === 0 ? (
<div style={{
border: '1px dashed var(--border)',
borderRadius: 'var(--bl-radius-card)',
background: 'var(--card-elevated)',
color: 'var(--muted-foreground)',
padding: '18px 14px',
textAlign: 'center',
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--foreground)' }}>No open positions</div>
<div style={{ fontSize: 12, lineHeight: 1.5, marginTop: 4 }}>
Filled Trade Plans and manual entries will appear here.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{positions.slice(0, 6).map(pos => {
const pct = pos.unrealizedPnlPercent ?? 0;
const pos_ = pct >= 0;
return (
<div key={pos.id} style={{
display: 'grid',
gridTemplateColumns: '2fr 1.2fr 1fr 1.2fr',
gap: 4, padding: '8px 0',
borderBottom: '1px solid var(--border)',
alignItems: 'center',
}}>
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--foreground)' }}>{pos.symbol}</span>
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{pos.currentPrice?.toFixed(2) ?? '—'}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: pos_ ? 'var(--bl-success)' : 'var(--bl-danger)' }}>
{pos_ ? '+' : ''}{pct.toFixed(2)}%
</span>
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{fmt$(pos.marketValue ?? 0)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── News Feed ────────────────────────────────────────────────────────────────
type NewsArticle = MarketNewsArticle;
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function NewsCard({ article }: { article: NewsArticle }) {
const img = article.images?.[0]?.url;
return (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', gap: 10,
padding: '10px 16px',
textDecoration: 'none',
borderBottom: '1px solid var(--border)',
transition: 'background 0.1s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{img && (
<img
src={img} alt=""
style={{ width: 52, height: 44, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }}
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--foreground)',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: 4,
}}>
{article.headline}
</div>
<div style={{ fontSize: 10, color: 'var(--muted-foreground)' }}>
{article.source} · {timeAgo(article.created_at)}
</div>
</div>
</a>
);
}
function NewsFeedSkeleton() {
return (
<div role="status" aria-label="Loading news" style={{ padding: '4px 0 8px' }}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
display: 'flex',
gap: 10,
padding: '10px 16px',
borderBottom: i < 2 ? '1px solid var(--border)' : 'none',
}}
>
<SkeletonBlock width={48} height={48} radius={10} style={{ flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 7, paddingTop: 3 }}>
<SkeletonText width="92%" />
<SkeletonText width="68%" />
<SkeletonText width="36%" />
</div>
</div>
))}
</div>
);
}
function NewsFeed() {
const { activeSymbol } = useAppContext();
const [news, setNews] = useState<NewsArticle[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!activeSymbol) { setNews([]); return; }
let cancelled = false;
setLoading(true);
setError(null);
// Use the authenticated marketApi helper so the request carries the
// platform Bearer token; the raw `fetch()` we used before was hitting
// requireAuth and 401-ing on every render.
fetchNews(activeSymbol, 8)
.then(list => { if (!cancelled) setNews(list); })
.catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load news'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [activeSymbol]);
return (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 16px 8px',
}}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--foreground)' }}>Latest News</span>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
View All <ArrowRight size={12} />
</Button>
</div>
{loading && <NewsFeedSkeleton />}
{!loading && error && (
<div style={{ fontSize: 12, color: 'var(--bl-danger)', padding: '12px 16px' }}>{error}</div>
)}
{!loading && !error && news.length === 0 && (
<div style={{
border: '1px dashed var(--border)',
borderRadius: 'var(--bl-radius-card)',
background: 'var(--card-elevated)',
color: 'var(--muted-foreground)',
margin: '8px 16px 16px',
padding: '18px 14px',
textAlign: 'center',
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--foreground)' }}>
{activeSymbol ? `No news for ${activeSymbol}` : 'No symbol selected'}
</div>
<div style={{ fontSize: 12, lineHeight: 1.5, marginTop: 4 }}>
{activeSymbol ? 'Try another ticker or check back after the next market update.' : 'Search a ticker to load market news.'}
</div>
</div>
)}
{!loading && news.map((a, i) => <NewsCard key={a.id ?? a.url ?? i} article={a} />)}
</div>
);
}
// ─── Right Panel container ────────────────────────────────────────────────────
export function RightPanel() {
return (
<aside className="dashboard-right-panel" style={{
background: 'var(--card)',
borderLeft: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
flexShrink: 0,
}}>
<div style={{ borderBottom: '1px solid var(--border)' }}>
<PortfolioSummary />
</div>
<NewsFeed />
</aside>
);
}