From 6bcb6637a81afbbc66cf4ae75b2092bff7a416f2 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 21 May 2026 20:07:01 +0000 Subject: [PATCH] feat(dashboard): Meet Kevin stocks list + per-ticker drill-down --- dashboard/src/pages/meetKevin/StockDetail.tsx | 179 ++++++++++++++++++ dashboard/src/pages/meetKevin/Stocks.tsx | 98 ++++++++++ 2 files changed, 277 insertions(+) create mode 100644 dashboard/src/pages/meetKevin/StockDetail.tsx create mode 100644 dashboard/src/pages/meetKevin/Stocks.tsx diff --git a/dashboard/src/pages/meetKevin/StockDetail.tsx b/dashboard/src/pages/meetKevin/StockDetail.tsx new file mode 100644 index 0000000..c624183 --- /dev/null +++ b/dashboard/src/pages/meetKevin/StockDetail.tsx @@ -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 ( +
+ +
+ ); + } + + 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 ( +
+ {/* Header */} +
+ + ← All stocks + +

${sym}

+

+ {mentions.length} mention{mentions.length !== 1 ? 's' : ''} +

+
+ + {/* Timeline chart */} + {chart.length > 0 && ( +
+

+ Net action score over time +

+ + + + + + + + +
+ )} + + {/* Mention list */} + {mentions.length === 0 ? ( +
+ No mentions found +
+ ) : ( +
+

+ Mentions +

+
+ {mentions.map((m, idx) => ( +
+ {/* Top row: date + chip + horizon */} +
+ + {new Date(m.published_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + + + + {m.time_horizon.replace('_', ' ')} + +
+ + {/* Conviction bar */} +
+
+ +
+ + {(m.conviction * 100).toFixed(0)}% + +
+ + {/* Video title link */} + + {m.video_title} + + + {/* Rationale */} + {m.rationale_quote && ( +

+ "{m.rationale_quote}" +

+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/dashboard/src/pages/meetKevin/Stocks.tsx b/dashboard/src/pages/meetKevin/Stocks.tsx new file mode 100644 index 0000000..5efcac0 --- /dev/null +++ b/dashboard/src/pages/meetKevin/Stocks.tsx @@ -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 ( +
+
+

Meet Kevin — Stocks

+ + {data?.stocks.length ?? 0} tickers + +
+ + {isLoading ? ( +
+ +
+ ) : !data || data.stocks.length === 0 ? ( +
+ No analyses yet +
+ ) : ( +
+ + + + + + + + + + + + {data.stocks.map((s) => ( + + + + + + + + ))} + +
+ Symbol + + Mentions + + Last seen + + Latest + + Avg conviction +
+ + ${s.symbol} + + + {s.mention_count} + + {new Date(s.last_seen_at).toLocaleDateString()} + +
+ + + {(s.latest_conviction * 100).toFixed(0)}% + +
+
+
+
+ +
+ + {(s.avg_conviction * 100).toFixed(0)}% + +
+
+
+ )} +
+ ); +}