feat(dashboard): Meet Kevin stocks list + per-ticker drill-down

This commit is contained in:
Viktor Barzin 2026-05-21 20:07:01 +00:00
parent 9ce0e44929
commit 6bcb6637a8
2 changed files with 277 additions and 0 deletions

View file

@ -0,0 +1,179 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import meetKevinApi from '../../api/meetKevin';
import { ActionChip, ConvictionBar } from '../../components/meetKevin';
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import type { StockMention } from '../../types/meetKevin';
interface TimelineBucket {
bucket_start: string;
net_action_score: number;
}
interface TimelineResponse {
buckets?: TimelineBucket[];
}
export default function MeetKevinStockDetail() {
const { symbol = '' } = useParams<{ symbol: string }>();
const sym = symbol.toUpperCase();
const { data: stock, isLoading } = useQuery({
queryKey: ['meet-kevin', 'stock', sym],
queryFn: () => meetKevinApi.getStock(sym),
});
const { data: timeline } = useQuery({
queryKey: ['meet-kevin', 'stock', sym, 'timeline'],
queryFn: () => meetKevinApi.getStockTimeline(sym, 'day'),
});
if (isLoading || !stock) {
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 tl = timeline as TimelineResponse | undefined;
const chart = (tl?.buckets ?? []).map((b) => ({
day: new Date(b.bucket_start).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
score: b.net_action_score,
}));
const mentions: StockMention[] = [...(stock.mentions ?? [])].sort(
(a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime()
);
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-1">
<Link
to="/meet-kevin/stocks"
className="text-sm text-slate-400 hover:text-slate-200 transition-colors"
>
All stocks
</Link>
<h2 className="text-3xl font-bold text-white font-mono">${sym}</h2>
<p className="text-sm text-slate-400">
{mentions.length} mention{mentions.length !== 1 ? 's' : ''}
</p>
</div>
{/* Timeline chart */}
{chart.length > 0 && (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 space-y-3">
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wide">
Net action score over time
</h3>
<ResponsiveContainer width="100%" height={180}>
<LineChart
data={chart}
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
>
<XAxis
dataKey="day"
tick={{ fontSize: 11, fill: '#94a3b8' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: '#94a3b8' }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
background: '#1e293b',
border: '1px solid #334155',
borderRadius: 8,
fontSize: 12,
color: '#e2e8f0',
}}
cursor={{ stroke: '#475569' }}
/>
<Line
type="monotone"
dataKey="score"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: '#60a5fa' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Mention list */}
{mentions.length === 0 ? (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-12 text-center text-slate-400">
No mentions found
</div>
) : (
<div className="space-y-3">
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wide">
Mentions
</h3>
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-700">
{mentions.map((m, idx) => (
<div key={idx} className="px-5 py-4 space-y-2">
{/* Top row: date + chip + horizon */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs text-slate-400">
{new Date(m.published_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<ActionChip action={m.action} />
<span className="text-xs text-slate-400 capitalize">
{m.time_horizon.replace('_', ' ')}
</span>
</div>
{/* Conviction bar */}
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xs">
<ConvictionBar value={m.conviction} />
</div>
<span className="text-xs text-slate-400">
{(m.conviction * 100).toFixed(0)}%
</span>
</div>
{/* Video title link */}
<Link
to={`/meet-kevin/videos/${m.video_id}`}
className="block text-sm text-blue-400 hover:text-blue-300 transition-colors leading-snug"
>
{m.video_title}
</Link>
{/* Rationale */}
{m.rationale_quote && (
<p className="text-xs text-slate-400 italic leading-snug">
"{m.rationale_quote}"
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,98 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import meetKevinApi from '../../api/meetKevin';
import { ActionChip, ConvictionBar } from '../../components/meetKevin';
export default function MeetKevinStocks() {
const { data, isLoading } = useQuery({
queryKey: ['meet-kevin', 'stocks'],
queryFn: () => meetKevinApi.listStocks(),
refetchInterval: 60_000,
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">Meet Kevin Stocks</h2>
<span className="text-sm text-slate-400">
{data?.stocks.length ?? 0} tickers
</span>
</div>
{isLoading ? (
<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>
) : !data || data.stocks.length === 0 ? (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-12 text-center text-slate-400">
No analyses yet
</div>
) : (
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-900/50">
<tr>
<th className="px-5 py-3 text-left text-xs text-slate-400 uppercase tracking-wide font-semibold">
Symbol
</th>
<th className="px-5 py-3 text-left text-xs text-slate-400 uppercase tracking-wide font-semibold">
Mentions
</th>
<th className="px-5 py-3 text-left text-xs text-slate-400 uppercase tracking-wide font-semibold">
Last seen
</th>
<th className="px-5 py-3 text-left text-xs text-slate-400 uppercase tracking-wide font-semibold">
Latest
</th>
<th className="px-5 py-3 text-left text-xs text-slate-400 uppercase tracking-wide font-semibold">
Avg conviction
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700">
{data.stocks.map((s) => (
<tr
key={s.symbol}
className="hover:bg-slate-700/30 transition-colors"
>
<td className="px-5 py-3.5">
<Link
to={`/meet-kevin/stocks/${s.symbol}`}
className="font-mono font-bold text-blue-400 hover:text-blue-300 transition-colors"
>
${s.symbol}
</Link>
</td>
<td className="px-5 py-3.5 text-slate-300">
{s.mention_count}
</td>
<td className="px-5 py-3.5 text-slate-400">
{new Date(s.last_seen_at).toLocaleDateString()}
</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-2">
<ActionChip action={s.latest_action} />
<span className="text-xs text-slate-400">
{(s.latest_conviction * 100).toFixed(0)}%
</span>
</div>
</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-2 min-w-[140px]">
<div className="flex-1">
<ConvictionBar value={s.avg_conviction} />
</div>
<span className="text-xs text-slate-400 shrink-0">
{(s.avg_conviction * 100).toFixed(0)}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}