feat(dashboard): Meet Kevin home page

This commit is contained in:
Viktor Barzin 2026-05-21 20:01:53 +00:00
parent a4d75e37c4
commit d4a1ca870e

View 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>
);
}