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