← back to Ken
kalshi-dash/src/pages/Intelligence.jsx
341 lines
import React, { useState, useEffect } from 'react';
import { api } from '../api';
/* ─── Source icon map (text-based, no emoji dependency issues) ─── */
const SOURCE_ICONS = {
reddit_hot: '🔴', reddit: '📋', google_news: '📰', hackernews: '🟠',
metaculus: '🔮', manifold: '📊', gdelt_tv: '📺', youtube: '▶️',
lemmy: '🟢', lobsters: '🦞', tildes: '~', bluesky: '🦋',
mastodon: '🐘', predictit: '💰', fred: '📈', nws: '⛈️',
govtrack: '🏛️', discord: '💬',
};
/* Colour-code source badges by type */
function sourceBadgeClass(source) {
if (['reddit_hot', 'reddit', 'lemmy'].includes(source)) return 'bg-red';
if (['google_news', 'govtrack', 'nws'].includes(source)) return 'bg-blue';
if (['metaculus', 'manifold', 'predictit'].includes(source)) return 'bg-purple';
if (['hackernews', 'lobsters', 'tildes'].includes(source)) return 'bg-orange';
if (['fred'].includes(source)) return 'bg-green';
if (['bluesky', 'mastodon', 'discord'].includes(source)) return 'bg-cyan';
return 'bg-blue';
}
/* Relevance / sentiment badge */
function SentimentBadge({ value }) {
const v = parseFloat(value) || 0;
if (v > 0.1) return <span className="badge bg-cyan" >+{(v * 100).toFixed(0)}%</span>;
if (v < -0.1) return <span className="badge bg-red" >{(v * 100).toFixed(0)}%</span>;
return <span className="badge bg-orange" >{(v * 100).toFixed(0)}%</span>;
}
/* Sortable column header */
function SortHead({ col, label, sortCol, sortDir, onSort }) {
const active = sortCol === col;
return (
<th
onClick={() => onSort(col)}
className="cursor-pointer select-none transition"
style={{ color: active ? 'var(--cyan)' : undefined }}
>
<span className="flex items-center gap-1">
{label}
{active
? <span style={{ color: 'var(--cyan)' }}>{sortDir === 'asc' ? '▲' : '▼'}</span>
: <span style={{ color: 'var(--border-light)' }}>⇅</span>
}
</span>
</th>
);
}
/* ─── Stat card ─── */
function StatCard({ label, value, color }) {
return (
<div className="card">
<p className="stat-label mb-2">{label}</p>
<p className="stat-v" style={{ color }}>{value}</p>
</div>
);
}
/* ─── Main Intelligence page ─── */
export default function Intelligence() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [sortCol, setSortCol] = useState('captured_at');
const [sortDir, setSortDir] = useState('desc');
useEffect(() => {
load();
const i = setInterval(load, 60000);
return () => clearInterval(i);
}, []);
async function load() {
try {
const r = await api('/intelligence');
setData(r);
} catch {}
setLoading(false);
}
function toggleSort(col) {
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortCol(col); setSortDir(col === 'captured_at' ? 'desc' : 'asc'); }
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div
className="animate-spin w-8 h-8 border-2 border-t-transparent rounded-full pulse-cyan"
style={{ borderColor: 'var(--cyan)', borderTopColor: 'transparent' }}
/>
</div>
);
}
const totals = data?.totals || {};
const sources = data?.sourceCounts || [];
const filtered = (data?.recent || []).filter(item =>
!filter ||
item.source.toLowerCase().includes(filter.toLowerCase()) ||
(item.post_title || '').toLowerCase().includes(filter.toLowerCase()) ||
(item.keyword || '').toLowerCase().includes(filter.toLowerCase())
);
function sortFn(a, b) {
let va, vb;
switch (sortCol) {
case 'captured_at': va = new Date(a.captured_at); vb = new Date(b.captured_at); break;
case 'source': va = a.source || ''; vb = b.source || ''; break;
case 'keyword': va = a.keyword || a.subreddit || ''; vb = b.keyword || b.subreddit || ''; break;
case 'post_title': va = (a.post_title || '').toLowerCase(); vb = (b.post_title || '').toLowerCase(); break;
case 'score': va = Number(a.score || 0); vb = Number(b.score || 0); break;
case 'sentiment': va = parseFloat(a.sentiment_score || 0); vb = parseFloat(b.sentiment_score || 0); break;
default: return 0;
}
if (va < vb) return sortDir === 'asc' ? -1 : 1;
if (va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
}
const items = [...filtered].sort(sortFn);
const avgSent = parseFloat(totals.avg_sentiment_1h) || 0;
const avgSentColor = avgSent > 0 ? 'var(--green)' : avgSent < 0 ? 'var(--red)' : 'var(--text-muted)';
const sortProps = { sortCol, sortDir, onSort: toggleSort };
return (
<div className="space-y-6 fade-in">
{/* ── Page Header ── */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
{/* Flask icon */}
<span className="text-2xl" style={{ color: 'var(--cyan)' }}>⚗</span>
<h1 className="heading text-3xl font-bold" style={{ color: 'var(--text)' }}>
Intel Feed
</h1>
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2 h-2 rounded-full pulse-cyan"
style={{ background: 'var(--cyan)' }}
/>
<span className="text-xs" style={{ color: 'var(--cyan)' }}>LIVE</span>
</span>
</div>
<p className="section-subtitle mt-1">Real-time data from 20+ sources</p>
</div>
<button onClick={load} className="btn btn-o btn-sm">↻ Refresh</button>
</div>
{/* ── Stats ── */}
<div className="grid grid-cols-4 gap-4">
<StatCard label="Total Items" value={Number(totals.total_items || 0).toLocaleString()} color="var(--orange)" />
<StatCard label="Last Hour" value={totals.last_hour || 0} color="var(--green)" />
<StatCard label="Last 4 Hours" value={totals.last_4h || 0} color="var(--blue)" />
<StatCard
label="Avg Sentiment 1h"
value={totals.avg_sentiment_1h ? `${(avgSent * 100).toFixed(1)}%` : '—'}
color={avgSentColor}
/>
</div>
{/* ── Source filter pills ── */}
<div className="card" style={{ padding: '1rem' }}>
<p className="section-title mb-3" style={{ fontSize: '0.9rem' }}>
Active Sources
</p>
<div className="flex flex-wrap gap-2">
{/* "All" pill */}
<button
onClick={() => setFilter('')}
className="badge transition cursor-pointer"
style={{
background: !filter ? 'var(--cyan-dim)' : 'rgba(42,42,53,0.6)',
color: !filter ? 'var(--cyan)' : 'var(--text-muted)',
border: !filter ? '1px solid rgba(0,240,255,0.3)' : '1px solid var(--border)',
}}
>
All
</button>
{sources.map((s, i) => {
const active = filter === s.source;
const sent = parseFloat(s.avg_sent) || 0;
return (
<button
key={i}
onClick={() => setFilter(active ? '' : s.source)}
className="badge transition cursor-pointer"
style={{
background: active ? 'var(--cyan-dim)' : 'rgba(42,42,53,0.6)',
color: active ? 'var(--cyan)' : 'var(--text-muted)',
border: active ? '1px solid rgba(0,240,255,0.3)' : '1px solid var(--border)',
}}
>
<span>{SOURCE_ICONS[s.source] || '📡'}</span>
<span className="font-medium">{s.source}</span>
<span style={{ color: 'var(--text-muted)', opacity: 0.7 }}>{s.cnt}</span>
<span
className="mono"
style={{ color: sent > 0.05 ? 'var(--green)' : sent < -0.05 ? 'var(--red)' : 'var(--text-muted)' }}
>
{sent > 0 ? '+' : ''}{(sent * 100).toFixed(0)}%
</span>
</button>
);
})}
</div>
</div>
{/* ── Search Bar ── */}
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm pointer-events-none"
style={{ color: 'var(--text-muted)' }}
>
⌕
</span>
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter by source, keyword, or title..."
className="input pl-8"
/>
</div>
{/* ── Intel Feed Table ── */}
<div className="card overflow-x-auto" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortHead col="captured_at" label="Time" {...sortProps} />
<SortHead col="source" label="Source" {...sortProps} />
<SortHead col="keyword" label="Topic" {...sortProps} />
<SortHead col="post_title" label="Headline" {...sortProps} />
<SortHead col="score" label="Score" {...sortProps} />
<SortHead col="sentiment" label="Sentiment" {...sortProps} />
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12" style={{ color: 'var(--text-muted)' }}>
<div className="flex flex-col items-center gap-2">
<span className="text-2xl">⚗</span>
<span className="text-sm">No items match your filter</span>
</div>
</td>
</tr>
) : (
items.slice(0, 50).map((item, i) => {
const sent = parseFloat(item.sentiment_score) || 0;
return (
<tr key={i}>
{/* Time */}
<td className="whitespace-nowrap">
<span className="mono text-xs" style={{ color: 'var(--text-muted)' }}>
{new Date(item.captured_at).toLocaleTimeString()}
</span>
</td>
{/* Source badge */}
<td>
<span className={`badge ${sourceBadgeClass(item.source)}`}>
{SOURCE_ICONS[item.source] || '📡'} {item.source}
</span>
</td>
{/* Topic / keyword */}
<td>
<span className="text-xs" style={{ color: 'var(--text-dim)' }}>
{item.keyword || item.subreddit || '—'}
</span>
</td>
{/* Headline */}
<td className="max-w-[400px]">
{item.post_url ? (
<a
href={item.post_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm truncate block hover:underline transition heading"
style={{ color: 'var(--text)' }}
>
{item.post_title}
</a>
) : (
<span className="text-sm truncate block heading" style={{ color: 'var(--text)' }}>
{item.post_title}
</span>
)}
</td>
{/* Score */}
<td>
<span className="mono text-xs" style={{ color: 'var(--text-dim)' }}>
{item.score || 0}
{item.num_comments ? (
<span style={{ color: 'var(--text-muted)' }}> / {item.num_comments}c</span>
) : null}
</span>
</td>
{/* Sentiment badge */}
<td>
<SentimentBadge value={item.sentiment_score} />
</td>
</tr>
);
})
)}
</tbody>
</table>
{/* Row count footer */}
{items.length > 0 && (
<div
className="px-4 py-3 text-xs"
style={{
color: 'var(--text-muted)',
borderTop: '1px solid var(--border)',
}}
>
Showing {Math.min(50, items.length)} of {items.length} items
{filter && ` matching "${filter}"`}
</div>
)}
</div>
</div>
);
}