feat(dashboard): Meet Kevin stocks list + per-ticker drill-down
This commit is contained in:
parent
9ce0e44929
commit
6bcb6637a8
2 changed files with 277 additions and 0 deletions
179
dashboard/src/pages/meetKevin/StockDetail.tsx
Normal file
179
dashboard/src/pages/meetKevin/StockDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
dashboard/src/pages/meetKevin/Stocks.tsx
Normal file
98
dashboard/src/pages/meetKevin/Stocks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue