trading/dashboard/src/pages/meetKevin/Videos.tsx
Viktor Barzin d5359691b1
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(dashboard): show actions + convictions + outlook on Videos cards
API already returns top_tickers as [{symbol, action, conviction}] but
the dashboard's VideoSummary type was string[] — so the rich data
was dropped at the type boundary and the cards only showed bare
ticker symbols.

Changes:
- VideoSummary.top_tickers: string[] → VideoTopTicker[]
- Videos.tsx card now renders:
  * outlook pill (bullish/bearish/neutral/mixed, color-coded)
  * action-colored chip per ticker (buy=green, sell=red, watch=yellow,
    avoid=rose, hold=slate) with conviction% inline
  * one-line summary excerpt
- Home.tsx (only other top_tickers consumer) updated to use .symbol
  instead of treating each entry as a string

Effect: skim /meet-kevin/videos to see at a glance "this video says
buy NVDA 85% + watch SPCX 65%, outlook bullish" without clicking in.
2026-05-27 17:22:53 +00:00

230 lines
8.5 KiB
TypeScript

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import meetKevinApi from '../../api/meetKevin';
import type { MarketOutlook, TickerAction, VideoStatus } from '../../types/meetKevin';
const STATUS_OPTIONS: { value: VideoStatus | ''; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'analyzed', label: 'Analyzed' },
{ value: 'captioned', label: 'Captioned' },
{ value: 'discovered', label: 'Discovered' },
{ value: 'failed', label: 'Failed' },
{ value: 'skipped', label: 'Skipped' },
];
function statusColor(status: VideoStatus): string {
if (status === 'analyzed') return 'text-green-400';
if (status === 'failed') return 'text-red-400';
return 'text-yellow-300';
}
const ACTION_CHIP: Record<TickerAction, string> = {
buy: 'bg-green-600/30 text-green-300 border-green-500/40',
sell: 'bg-red-600/30 text-red-300 border-red-500/40',
hold: 'bg-slate-600/30 text-slate-300 border-slate-500/40',
watch: 'bg-yellow-600/30 text-yellow-200 border-yellow-500/40',
avoid: 'bg-rose-600/40 text-rose-200 border-rose-500/40',
};
const OUTLOOK_PILL: Record<MarketOutlook, string> = {
bullish: 'bg-green-600/20 text-green-300 border-green-500/40',
bearish: 'bg-red-600/20 text-red-300 border-red-500/40',
neutral: 'bg-slate-600/20 text-slate-300 border-slate-500/40',
mixed: 'bg-purple-600/20 text-purple-300 border-purple-500/40',
};
export default function MeetKevinVideos() {
const [status, setStatus] = useState<VideoStatus | ''>('');
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const per_page = 20;
const { data, isLoading } = useQuery({
queryKey: ['meet-kevin', 'videos', status, q, page],
queryFn: () =>
meetKevinApi.listVideos({
status: status || undefined,
q: q || undefined,
page,
per_page,
}),
});
const totalPages = data ? Math.ceil(data.total / per_page) : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">Meet Kevin Videos</h2>
<span className="text-sm text-slate-400">{data?.total ?? 0} videos</span>
</div>
{/* Filters */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-xs text-slate-400 mb-1">Status</label>
<select
value={status}
onChange={(e) => {
setStatus(e.target.value as VideoStatus | '');
setPage(1);
}}
className="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex-1 max-w-sm">
<label className="block text-xs text-slate-400 mb-1">
Search title
</label>
<input
type="text"
value={q}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
}}
placeholder="e.g. Fed, rate cut…"
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{(status || q) && (
<button
onClick={() => {
setStatus('');
setQ('');
setPage(1);
}}
className="px-3 py-2 text-sm text-slate-400 hover:text-white bg-slate-700 rounded-lg transition-colors"
>
Clear
</button>
)}
</div>
</div>
{/* Cards */}
{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.videos.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.videos.map((video) => (
<Link
key={video.id}
to={`/meet-kevin/videos/${video.id}`}
className="bg-slate-800 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition-colors flex gap-4"
>
{/* Thumbnail */}
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt=""
className="w-32 h-20 object-cover rounded-lg flex-shrink-0"
/>
) : (
<div className="w-32 h-20 bg-slate-700 rounded-lg flex-shrink-0 flex items-center justify-center">
<span className="text-slate-500 text-xs">No image</span>
</div>
)}
{/* Details */}
<div className="flex-1 min-w-0 space-y-1.5">
<p className="text-white text-sm font-medium leading-snug line-clamp-2">
{video.title}
</p>
<div className="flex items-center gap-2 text-xs text-slate-400">
<span>
{new Date(video.published_at).toLocaleDateString()}
</span>
<span
className={`font-semibold ${statusColor(video.status)}`}
>
{video.status}
</span>
</div>
{video.failure_reason && (
<p className="text-xs text-red-400 truncate">
{video.failure_reason}
</p>
)}
{video.outlook && (
<div>
<span
className={`inline-block px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide rounded border ${OUTLOOK_PILL[video.outlook]}`}
>
{video.outlook}
</span>
</div>
)}
{video.top_tickers.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{video.top_tickers.map((t) => (
<span
key={t.symbol}
title={`${t.action} · conviction ${Math.round(t.conviction * 100)}%`}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-xs ${ACTION_CHIP[t.action]}`}
>
<span className="font-mono font-semibold">{t.symbol}</span>
<span className="opacity-70">
{Math.round(t.conviction * 100)}
</span>
</span>
))}
</div>
)}
{video.one_line_summary && (
<p className="text-xs text-slate-500 line-clamp-2 pt-0.5">
{video.one_line_summary}
</p>
)}
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-16 text-slate-400 bg-slate-800 rounded-xl border border-slate-700">
No videos found
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
>
Previous
</button>
<span className="text-sm text-slate-400">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}