feat(dashboard): Meet Kevin home page
This commit is contained in:
parent
a4d75e37c4
commit
d4a1ca870e
1 changed files with 247 additions and 0 deletions
247
dashboard/src/pages/meetKevin/Home.tsx
Normal file
247
dashboard/src/pages/meetKevin/Home.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import meetKevinApi from '../../api/meetKevin';
|
||||
import { ActionChip, ConvictionBar } from '../../components/meetKevin';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import type {
|
||||
DashboardData,
|
||||
MarketOutlook,
|
||||
PipelineHealth,
|
||||
} from '../../types/meetKevin';
|
||||
|
||||
const OUTLOOK_TO_NUMERIC: Record<string, number> = {
|
||||
bullish: 1,
|
||||
neutral: 0,
|
||||
mixed: 0,
|
||||
bearish: -1,
|
||||
};
|
||||
|
||||
const OUTLOOK_BADGE: Record<MarketOutlook, string> = {
|
||||
bullish: 'bg-green-600/30 text-green-300 border-green-500/40',
|
||||
bearish: 'bg-red-600/30 text-red-300 border-red-500/40',
|
||||
neutral: 'bg-slate-600/30 text-slate-300 border-slate-500/40',
|
||||
mixed: 'bg-purple-600/30 text-purple-300 border-purple-500/40',
|
||||
};
|
||||
|
||||
export default function MeetKevinHome() {
|
||||
const { data: dashboard, isLoading } = useQuery<DashboardData>({
|
||||
queryKey: ['meet-kevin', 'dashboard'],
|
||||
queryFn: meetKevinApi.dashboard,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const { data: health } = useQuery<PipelineHealth>({
|
||||
queryKey: ['meet-kevin', 'health'],
|
||||
queryFn: meetKevinApi.health,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const analyzedCount = health
|
||||
? Object.values(health.counts_by_status).reduce(
|
||||
(sum: number, v: number) => sum + v,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
const dailyCost = health?.daily_cost_usd ?? 0;
|
||||
const costCap = health?.daily_cost_cap_usd ?? 0;
|
||||
|
||||
const trendData = (dashboard?.outlook_trend_14d ?? []).map(
|
||||
(row: { day: string; direction: MarketOutlook; count: number }) => ({
|
||||
day: row.day.slice(5), // "MM-DD"
|
||||
value: OUTLOOK_TO_NUMERIC[row.direction] ?? 0,
|
||||
})
|
||||
);
|
||||
|
||||
const video = dashboard?.latest_video ?? null;
|
||||
const analysis = dashboard?.latest_analysis ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<h2 className="text-2xl font-bold text-white">Meet Kevin</h2>
|
||||
<div className="text-right text-xs text-slate-400 space-y-0.5">
|
||||
<div>{analyzedCount} videos analyzed</div>
|
||||
{costCap > 0 && (
|
||||
<div>
|
||||
${dailyCost.toFixed(2)} / ${costCap.toFixed(2)} today
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{!video ? (
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5">
|
||||
<p className="text-slate-400 text-sm">Waiting for first poll</p>
|
||||
{health?.last_poll_at && (
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Last poll: {new Date(health.last_poll_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Latest video hero */}
|
||||
<Link
|
||||
to={`/meet-kevin/videos/${video.id}`}
|
||||
className="block bg-slate-800 border border-slate-700 rounded-xl p-5 hover:border-slate-600 transition-colors"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{video.thumbnail_url && (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title}
|
||||
className="w-36 h-20 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{video.outlook && (
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-semibold uppercase rounded-md border ${OUTLOOK_BADGE[video.outlook]}`}
|
||||
>
|
||||
{video.outlook}
|
||||
</span>
|
||||
)}
|
||||
{video.top_tickers.slice(0, 5).map((ticker: string) => (
|
||||
<span
|
||||
key={ticker}
|
||||
className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-300 text-xs font-medium"
|
||||
>
|
||||
${ticker}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-white font-medium line-clamp-2 leading-snug">
|
||||
{video.title}
|
||||
</p>
|
||||
{video.one_line_summary && (
|
||||
<p className="text-slate-400 text-sm line-clamp-1">
|
||||
{video.one_line_summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Top conviction this week */}
|
||||
{dashboard && dashboard.top_conviction_week.length > 0 && (
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-300 uppercase tracking-wide">
|
||||
Top Conviction — Last 7 Days
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{dashboard.top_conviction_week.map(
|
||||
(row: {
|
||||
symbol: string;
|
||||
max_conviction: number;
|
||||
mention_count: number;
|
||||
}) => {
|
||||
const tickerMention = analysis?.tickers?.find(
|
||||
(t) => t.symbol === row.symbol
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={row.symbol}
|
||||
to={`/meet-kevin/stocks/${row.symbol}`}
|
||||
className="flex items-center gap-3 group"
|
||||
>
|
||||
<span className="w-16 text-sm font-semibold text-white group-hover:text-blue-400 transition-colors">
|
||||
${row.symbol}
|
||||
</span>
|
||||
{tickerMention && (
|
||||
<ActionChip action={tickerMention.action} />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<ConvictionBar value={row.max_conviction} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-8 text-right">
|
||||
{(row.max_conviction * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 w-10 text-right">
|
||||
{row.mention_count}x
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outlook trendline */}
|
||||
{trendData.length > 0 && (
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 uppercase tracking-wide mb-3">
|
||||
Outlook — 14 Days
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart
|
||||
data={trendData}
|
||||
margin={{ top: 4, right: 8, left: -24, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[-1, 1]}
|
||||
ticks={[-1, 0, 1]}
|
||||
tickFormatter={(v: number) =>
|
||||
v === 1 ? 'bull' : v === -1 ? 'bear' : 'neu'
|
||||
}
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
color: '#f1f5f9',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(v: string | number | undefined) => {
|
||||
const n = Number(v ?? 0);
|
||||
return n === 1
|
||||
? 'bullish'
|
||||
: n === -1
|
||||
? 'bearish'
|
||||
: 'neutral/mixed';
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3b82f6', r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue