← 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>
  );
}