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