This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View File

@@ -0,0 +1,714 @@
"use client";
import React, { useEffect, useState, useCallback, useRef } from "react";
import Sidebar from "../components/Sidebar";
import TopBar from "../components/TopBar";
import {
fetchTimeline,
fetchGroups,
raiseJiraTicket,
TimelineSignal,
Group,
formatRelativeTime,
getSeverityColor,
getSignalIcon,
parseMetaList,
} from "../lib/api";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("en-GB", {
day: "2-digit", month: "short", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return iso;
}
}
function formatDateGroup(iso: string): string {
if (!iso) return "Unknown Date";
try {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "long", day: "numeric", month: "long", year: "numeric",
});
} catch {
return iso.slice(0, 10);
}
}
function getDay(iso: string): string {
try { return new Date(iso).toISOString().slice(0, 10); } catch { return ""; }
}
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
const LENS_OPTIONS = ["dev", "product", "client", "community", "meet"];
const SIGNAL_TYPE_OPTIONS = [
"architecture_decision", "tech_debt", "blocker", "risk", "decision",
"action_item", "feature_request", "promise", "scope_creep", "knowledge_gap",
"sentiment_spike", "recurring_bug", "meet_decision", "meet_action_item",
"meet_blocker", "meet_risk", "meet_open_q",
];
// ─── Signal Card ──────────────────────────────────────────────────────────────
function SignalCard({
signal,
onRaiseJira,
raisingId,
raisedIds,
}: {
signal: TimelineSignal;
onRaiseJira: (sig: TimelineSignal) => void;
raisingId: string | null;
raisedIds: Set<string>;
}) {
const meta = signal.metadata;
const color = getSeverityColor(meta.severity);
const icon = getSignalIcon(meta.type);
const entities = parseMetaList(meta.entities);
const keywords = parseMetaList(meta.keywords);
const isRaised = raisedIds.has(signal.id);
const isRaising = raisingId === signal.id;
const raiseable = [
"tech_debt", "recurring_bug", "architecture_decision", "blocker", "risk",
"feature_request", "priority_conflict", "promise", "scope_creep",
"meet_action_item", "meet_blocker", "meet_risk", "meet_decision", "decision", "action_item",
].includes(meta.type);
return (
<div
className="neon-card-gradient rounded-xl border border-white/5 border-l-[3px] p-4 transition-all duration-200 hover:border-[rgba(167,139,250,0.2)] group"
style={{ borderLeftColor: color }}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
<div className="p-2 rounded-lg flex-shrink-0" style={{ background: `${color}18` }}>
<span className="material-symbols-outlined" style={{ fontSize: "16px", color }}>
{icon}
</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
>
{meta.type.replace(/_/g, " ")}
</span>
<span
className="text-[9px] font-bold uppercase tracking-[0.08em] px-2 py-0.5 rounded-full border border-white/10 text-zinc-500"
>
{meta.lens}
</span>
{signal.group_name && (
<span className="text-[9px] text-zinc-600 font-mono-data">
{signal.group_name}
</span>
)}
</div>
<p className="text-[13px] text-zinc-200 leading-relaxed">
{meta.summary || signal.document}
</p>
{meta.raw_quote && meta.raw_quote !== meta.summary && meta.type !== "meet_chunk_raw" && (
<p className="text-[11px] text-zinc-500 mt-1.5 italic border-l-2 border-zinc-700 pl-2">
"{meta.raw_quote.slice(0, 160)}"
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2 flex-shrink-0">
<span className="text-[10px] text-zinc-600 font-mono-data whitespace-nowrap">
{formatRelativeTime(meta.timestamp)}
</span>
{raiseable && !isRaised && (
<button
onClick={() => onRaiseJira(signal)}
disabled={isRaising}
className="text-[9px] text-zinc-600 hover:text-[#A78BFA] transition-colors flex items-center gap-1 opacity-0 group-hover:opacity-100"
title="Raise Jira ticket"
>
{isRaising ? (
<div className="w-3 h-3 border border-[#A78BFA]/40 border-t-[#A78BFA] rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>bug_report</span>
)}
Raise Jira
</button>
)}
{isRaised && (
<span className="text-[9px] text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: "12px" }}>check_circle</span>
Raised
</span>
)}
</div>
</div>
{(entities.length > 0 || keywords.length > 0) && (
<div className="flex flex-wrap gap-1 mt-3">
{entities.slice(0, 4).map((e, i) => (
<span key={i} className="text-[10px] text-[#A78BFA] bg-[rgba(167,139,250,0.08)] px-1.5 py-0.5 rounded">
{e}
</span>
))}
{keywords.slice(0, 4).map((k, i) => (
<span key={i} className="text-[10px] text-zinc-600 bg-[rgba(255,255,255,0.04)] px-1.5 py-0.5 rounded">
{k}
</span>
))}
</div>
)}
</div>
);
}
// ─── Timeline View ─────────────────────────────────────────────────────────────
function TimelineView({
signals,
onRaiseJira,
raisingId,
raisedIds,
}: {
signals: TimelineSignal[];
onRaiseJira: (sig: TimelineSignal) => void;
raisingId: string | null;
raisedIds: Set<string>;
}) {
// Group by day
const byDay: Map<string, TimelineSignal[]> = new Map();
for (const sig of signals) {
const day = getDay(sig.metadata.timestamp);
if (!byDay.has(day)) byDay.set(day, []);
byDay.get(day)!.push(sig);
}
if (signals.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
<span className="material-symbols-outlined text-4xl mb-2">chat_bubble</span>
<p className="text-[13px] uppercase tracking-wider">No signals found</p>
<p className="text-[10px] text-zinc-700 mt-1">Signals appear as your Telegram groups generate activity</p>
</div>
);
}
return (
<div className="space-y-8">
{Array.from(byDay.entries()).map(([day, daySigs]) => (
<div key={day}>
{/* Day separator */}
<div className="flex items-center gap-4 mb-4">
<div className="flex-1 h-px bg-white/5" />
<div className="flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/10 bg-[rgba(255,255,255,0.03)]">
<span className="material-symbols-outlined text-zinc-500" style={{ fontSize: "13px" }}>calendar_today</span>
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
{formatDateGroup(day)}
</span>
<span className="text-[10px] text-zinc-700">·</span>
<span className="text-[10px] text-zinc-600">{daySigs.length} signals</span>
</div>
<div className="flex-1 h-px bg-white/5" />
</div>
<div className="space-y-3">
{daySigs.map((sig) => (
<SignalCard
key={sig.id}
signal={sig}
onRaiseJira={onRaiseJira}
raisingId={raisingId}
raisedIds={raisedIds}
/>
))}
</div>
</div>
))}
</div>
);
}
// ─── Table View ────────────────────────────────────────────────────────────────
function TableView({
signals,
sortKey,
sortAsc,
onSort,
onRaiseJira,
raisingId,
raisedIds,
}: {
signals: TimelineSignal[];
sortKey: string;
sortAsc: boolean;
onSort: (key: string) => void;
onRaiseJira: (sig: TimelineSignal) => void;
raisingId: string | null;
raisedIds: Set<string>;
}) {
const SortIcon = ({ k }: { k: string }) => (
<span className="material-symbols-outlined" style={{ fontSize: "11px" }}>
{sortKey !== k ? "unfold_more" : sortAsc ? "expand_less" : "expand_more"}
</span>
);
if (signals.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-48 text-zinc-600">
<span className="material-symbols-outlined text-3xl mb-2">table_rows</span>
<p className="text-[12px] uppercase tracking-wider">No signals</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/5 bg-[rgba(255,255,255,0.02)]">
{[
{ label: "Type", key: "type" },
{ label: "Summary", key: null },
{ label: "Severity", key: "severity" },
{ label: "Lens", key: "lens" },
{ label: "Group", key: "group_name" },
{ label: "Time", key: "timestamp" },
{ label: "", key: null },
].map(({ label, key }) => (
<th
key={label}
className={`px-4 py-3 text-left text-[10px] font-bold text-zinc-500 uppercase tracking-wider ${key ? "cursor-pointer hover:text-zinc-300" : ""}`}
onClick={key ? () => onSort(key) : undefined}
>
<div className="flex items-center gap-1">
{label}
{key && <SortIcon k={key} />}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{signals.map((sig) => {
const meta = sig.metadata;
const color = getSeverityColor(meta.severity);
const isRaised = raisedIds.has(sig.id);
const isRaising = raisingId === sig.id;
const raiseable = [
"tech_debt", "recurring_bug", "architecture_decision", "blocker", "risk",
"feature_request", "priority_conflict", "promise", "scope_creep",
"meet_action_item", "meet_blocker", "meet_risk", "meet_decision", "decision", "action_item",
].includes(meta.type);
return (
<tr key={sig.id} className="border-b border-white/5 hover:bg-[rgba(167,139,250,0.04)] transition-colors group">
<td className="px-4 py-3">
<span
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
>
{meta.type.replace(/_/g, " ")}
</span>
</td>
<td className="px-4 py-3 max-w-[280px]">
<p className="text-[12px] text-zinc-200 truncate">{meta.summary || sig.document}</p>
</td>
<td className="px-4 py-3">
<span className="text-[11px] font-semibold" style={{ color }}>{meta.severity}</span>
</td>
<td className="px-4 py-3">
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">{meta.lens}</span>
</td>
<td className="px-4 py-3">
<span className="text-[11px] text-zinc-500">{sig.group_name || meta.group_id}</span>
</td>
<td className="px-4 py-3">
<span className="text-[10px] text-zinc-600 font-mono-data">{formatRelativeTime(meta.timestamp)}</span>
</td>
<td className="px-4 py-3">
{raiseable && !isRaised && (
<button
onClick={() => onRaiseJira(sig)}
disabled={isRaising}
className="text-zinc-600 hover:text-[#A78BFA] transition-colors opacity-0 group-hover:opacity-100"
title="Raise Jira ticket"
>
{isRaising ? (
<div className="w-3.5 h-3.5 border border-[#A78BFA]/40 border-t-[#A78BFA] rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>bug_report</span>
)}
</button>
)}
{isRaised && (
<span className="material-symbols-outlined text-emerald-500" style={{ fontSize: "15px" }}>
check_circle
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
export default function ChatsPage() {
const [signals, setSignals] = useState<TimelineSignal[]>([]);
const [total, setTotal] = useState(0);
const [truncated, setTruncated] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<"timeline" | "table">("timeline");
const [raisingId, setRaisingId] = useState<string | null>(null);
const [raisedIds, setRaisedIds] = useState<Set<string>>(new Set());
const [raiseMsg, setRaiseMsg] = useState("");
// Filters
const [filterGroup, setFilterGroup] = useState("");
const [filterSeverity, setFilterSeverity] = useState("");
const [filterLens, setFilterLens] = useState("");
const [filterType, setFilterType] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [search, setSearch] = useState("");
// Sort (table view)
const [sortKey, setSortKey] = useState("timestamp");
const [sortAsc, setSortAsc] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [result, grps] = await Promise.all([
fetchTimeline({
group_id: filterGroup || undefined,
severity: filterSeverity || undefined,
lens: filterLens || undefined,
signal_type: filterType || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
limit: 300,
}),
fetchGroups(),
]);
setSignals(result.signals);
setTotal(result.total);
setTruncated(result.truncated);
setGroups(grps);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [filterGroup, filterSeverity, filterLens, filterType, dateFrom, dateTo]);
useEffect(() => { load(); }, [load]);
const handleRaiseJira = async (sig: TimelineSignal) => {
setRaisingId(sig.id);
try {
const result = await raiseJiraTicket(sig.id, sig.metadata.group_id);
if (result.ok && result.key) {
setRaisedIds((prev) => new Set([...prev, sig.id]));
setRaiseMsg(`Ticket ${result.key} raised!`);
setTimeout(() => setRaiseMsg(""), 4000);
} else {
setRaiseMsg(result.reason === "already_raised" ? "Already raised" : (result.error || "Failed to raise ticket"));
setTimeout(() => setRaiseMsg(""), 4000);
}
} catch (e) {
console.error(e);
} finally {
setRaisingId(null);
}
};
// Client-side search filter
const filteredSignals = signals.filter((s) => {
if (!search) return true;
const q = search.toLowerCase();
const meta = s.metadata;
return (
(meta.summary || "").toLowerCase().includes(q) ||
(meta.type || "").toLowerCase().includes(q) ||
(meta.raw_quote || "").toLowerCase().includes(q) ||
(s.group_name || "").toLowerCase().includes(q)
);
});
// Sort for table view
const sortedSignals = [...filteredSignals].sort((a, b) => {
const metaA = a.metadata, metaB = b.metadata;
let cmp = 0;
if (sortKey === "timestamp") cmp = metaA.timestamp.localeCompare(metaB.timestamp);
else if (sortKey === "severity") {
const rank = { critical: 4, high: 3, medium: 2, low: 1 } as Record<string, number>;
cmp = (rank[metaA.severity] || 0) - (rank[metaB.severity] || 0);
} else if (sortKey === "type") cmp = metaA.type.localeCompare(metaB.type);
else if (sortKey === "lens") cmp = metaA.lens.localeCompare(metaB.lens);
else if (sortKey === "group_name") cmp = (a.group_name || "").localeCompare(b.group_name || "");
return sortAsc ? cmp : -cmp;
});
const toggleSort = (key: string) => {
if (sortKey === key) setSortAsc((a) => !a);
else { setSortKey(key); setSortAsc(false); }
};
const exportCSV = () => {
const header = ["Type", "Summary", "Severity", "Lens", "Group", "Timestamp"].join(",");
const rows = filteredSignals.map((s) => {
const m = s.metadata;
return [
m.type,
`"${(m.summary || "").replace(/"/g, '""').slice(0, 200)}"`,
m.severity,
m.lens,
s.group_name || m.group_id,
m.timestamp,
].join(",");
});
const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "chat_signals.csv"; a.click();
URL.revokeObjectURL(url);
};
const activeFilters = [filterGroup, filterSeverity, filterLens, filterType, dateFrom, dateTo, search].filter(Boolean).length;
const criticalCount = signals.filter(s => s.metadata.severity === "critical").length;
const highCount = signals.filter(s => s.metadata.severity === "high").length;
const uniqueGroups = new Set(signals.map(s => s.metadata.group_id)).size;
return (
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
<Sidebar />
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-8">
{/* Header */}
<div className="flex items-start justify-between mb-8 animate-fade-in-up opacity-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
<span className="material-symbols-outlined text-[#A78BFA]">chat</span>
</div>
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Chat History</h1>
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
Signal timeline · Cross-group intelligence
</p>
</div>
</div>
<div className="flex items-center gap-3">
{raiseMsg && (
<span className="text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-lg animate-fade-in-scale opacity-0">
{raiseMsg}
</span>
)}
<button
onClick={exportCSV}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 text-[11px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>download</span>
Export CSV
</button>
<div className="flex bg-[#141419] border border-white/10 rounded-lg p-0.5">
{(["timeline", "table"] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wider transition-all ${
viewMode === mode
? "bg-[#A78BFA] text-[#1a0040]"
: "text-zinc-500 hover:text-zinc-300"
}`}
>
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>
{mode === "timeline" ? "view_timeline" : "table_rows"}
</span>
{mode}
</button>
))}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
{[
{ label: "Total Signals", value: total, icon: "sensors", color: "#A78BFA" },
{ label: "Critical", value: criticalCount, icon: "error", color: "#EF4444" },
{ label: "High Priority", value: highCount, icon: "warning", color: "#F97316" },
{ label: "Active Groups", value: uniqueGroups, icon: "group", color: "#60A5FA" },
].map((s) => (
<div key={s.label} className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: `${s.color}18` }}>
<span className="material-symbols-outlined" style={{ fontSize: "18px", color: s.color }}>{s.icon}</span>
</div>
<div>
<p className="text-2xl font-bold text-white">{s.value}</p>
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{s.label}</p>
</div>
</div>
))}
</div>
{/* Filters */}
<div className="neon-card-gradient rounded-xl border border-white/5 p-4 mb-5 animate-fade-in-up opacity-0 delay-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-zinc-500" style={{ fontSize: "16px" }}>filter_alt</span>
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">Filters</span>
{activeFilters > 0 && (
<span className="text-[9px] font-bold text-[#A78BFA] bg-[rgba(167,139,250,0.15)] px-2 py-0.5 rounded-full">
{activeFilters} active
</span>
)}
</div>
{activeFilters > 0 && (
<button
onClick={() => { setFilterGroup(""); setFilterSeverity(""); setFilterLens(""); setFilterType(""); setDateFrom(""); setDateTo(""); setSearch(""); }}
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider flex items-center gap-1"
>
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>filter_alt_off</span>
Clear all
</button>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "14px" }}>search</span>
<input
type="text"
placeholder="Search signals..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-[#0C0C0E] border border-white/10 rounded-lg pl-8 pr-3 py-2 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 w-44"
/>
</div>
{/* Group */}
<select
value={filterGroup}
onChange={(e) => setFilterGroup(e.target.value)}
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
>
<option value="">All Groups</option>
{groups.map((g) => (
<option key={g.group_id} value={g.group_id}>{g.group_name}</option>
))}
</select>
{/* Severity */}
<select
value={filterSeverity}
onChange={(e) => setFilterSeverity(e.target.value)}
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
>
<option value="">All Severities</option>
{SEVERITY_OPTIONS.map((s) => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
{/* Lens */}
<select
value={filterLens}
onChange={(e) => setFilterLens(e.target.value)}
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
>
<option value="">All Lenses</option>
{LENS_OPTIONS.map((l) => (
<option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>
))}
</select>
{/* Signal type */}
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
>
<option value="">All Types</option>
{SIGNAL_TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t.replace(/_/g, " ")}</option>
))}
</select>
{/* Date range */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">From</span>
<input
type="date"
value={dateFrom ? dateFrom.slice(0, 10) : ""}
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-2 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
/>
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">To</span>
<input
type="date"
value={dateTo ? dateTo.slice(0, 10) : ""}
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-2 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
/>
</div>
</div>
</div>
{/* Count line */}
<div className="flex items-center justify-between mb-4">
<p className="text-[11px] text-zinc-600">
Showing <span className="text-zinc-400 font-semibold">{filteredSignals.length}</span>
{total > filteredSignals.length && ` of ${total}`} signals
{truncated && <span className="text-amber-500 ml-2">· Showing latest 300 apply filters to narrow down</span>}
</p>
<button onClick={load} className="flex items-center gap-1 text-[11px] text-zinc-500 hover:text-[#A78BFA] transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>refresh</span>
Refresh
</button>
</div>
{/* Content */}
{loading ? (
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
<p className="text-[11px] uppercase tracking-wider">Loading signals...</p>
</div>
) : viewMode === "timeline" ? (
<TimelineView
signals={filteredSignals}
onRaiseJira={handleRaiseJira}
raisingId={raisingId}
raisedIds={raisedIds}
/>
) : (
<div className="neon-card-gradient rounded-2xl border border-white/5 overflow-hidden">
<TableView
signals={sortedSignals}
sortKey={sortKey}
sortAsc={sortAsc}
onSort={toggleSort}
onRaiseJira={handleRaiseJira}
raisingId={raisingId}
raisedIds={raisedIds}
/>
</div>
)}
</main>
</div>
</div>
);
}