2026-04-02 10:12:04 -06:00
|
|
|
// IvantiCountsChart.js
|
|
|
|
|
// Collapsible trend panel for the Vulnerability Triage page.
|
2026-04-24 21:06:35 +00:00
|
|
|
// Shows open vs closed Ivanti finding counts over time (last sync per day),
|
|
|
|
|
// with a separate sparkline row for archived/returned finding activity.
|
2026-04-02 10:12:04 -06:00
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
LineChart, Line,
|
2026-04-24 21:06:35 +00:00
|
|
|
BarChart, Bar, Cell,
|
2026-04-02 10:12:04 -06:00
|
|
|
XAxis, YAxis, CartesianGrid,
|
2026-04-24 21:06:35 +00:00
|
|
|
Tooltip, Legend,
|
2026-04-02 10:12:04 -06:00
|
|
|
ResponsiveContainer,
|
|
|
|
|
} from 'recharts';
|
|
|
|
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
|
|
|
|
|
|
const AMBER = '#F59E0B';
|
|
|
|
|
const SKY = '#0EA5E9';
|
|
|
|
|
const GREEN = '#10B981';
|
|
|
|
|
const RED = '#EF4444';
|
2026-04-24 21:06:35 +00:00
|
|
|
const ROSE = '#F43F5E';
|
|
|
|
|
const TEAL = '#14B8A6';
|
2026-04-02 10:12:04 -06:00
|
|
|
|
|
|
|
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
|
|
|
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
|
|
|
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-24 21:06:35 +00:00
|
|
|
// Custom dark tooltip — main trend chart
|
2026-04-02 10:12:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function DarkTooltip({ active, payload, label }) {
|
|
|
|
|
if (!active || !payload?.length) return null;
|
|
|
|
|
|
|
|
|
|
const openVal = payload.find(p => p.dataKey === 'open_count')?.value;
|
|
|
|
|
const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'rgba(10,17,32,0.97)',
|
|
|
|
|
border: '1px solid rgba(245,158,11,0.3)',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
padding: '0.5rem 0.75rem',
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
minWidth: '160px',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
|
|
|
|
{label}
|
|
|
|
|
</div>
|
|
|
|
|
{payload.map(p => (
|
|
|
|
|
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
|
|
|
|
<span style={{ fontWeight: '700' }}>{p.value}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{openVal != null && closedVal != null && (
|
|
|
|
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span>total</span>
|
|
|
|
|
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 21:06:35 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Custom dark tooltip — archive activity sparkline
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function ArchiveTooltip({ active, payload, label }) {
|
|
|
|
|
if (!active || !payload?.length) return null;
|
|
|
|
|
|
|
|
|
|
const archived = payload.find(p => p.dataKey === 'archived')?.value || 0;
|
|
|
|
|
const returned = payload.find(p => p.dataKey === 'returned')?.value || 0;
|
|
|
|
|
|
|
|
|
|
// Parse classification if present
|
|
|
|
|
const dataPoint = payload[0]?.payload;
|
|
|
|
|
const classification = dataPoint?.classification;
|
2026-05-01 17:15:41 +00:00
|
|
|
const returnClassification = dataPoint?.return_classification;
|
2026-04-24 21:06:35 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'rgba(10,17,32,0.97)',
|
|
|
|
|
border: '1px solid rgba(244,63,94,0.3)',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
padding: '0.5rem 0.75rem',
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
minWidth: '180px',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ color: ROSE, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
|
|
|
|
{label}
|
|
|
|
|
</div>
|
|
|
|
|
{archived > 0 && (
|
|
|
|
|
<div style={{ color: ROSE, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Archived</span>
|
|
|
|
|
<span style={{ fontWeight: '700' }}>{archived}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{returned > 0 && (
|
|
|
|
|
<div style={{ color: TEAL, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Returned</span>
|
|
|
|
|
<span style={{ fontWeight: '700' }}>{returned}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{archived === 0 && returned === 0 && (
|
|
|
|
|
<div style={{ color: '#475569', marginTop: '0.125rem' }}>No archive activity</div>
|
|
|
|
|
)}
|
|
|
|
|
{classification && archived > 0 && (
|
|
|
|
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem' }}>
|
|
|
|
|
{classification.bu_reassignment > 0 && (
|
|
|
|
|
<div style={{ color: '#FB923C', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>BU reassignment</span>
|
|
|
|
|
<span>{classification.bu_reassignment}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{classification.severity_drift > 0 && (
|
|
|
|
|
<div style={{ color: '#A78BFA', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Severity drift</span>
|
|
|
|
|
<span>{classification.severity_drift}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{classification.closed_on_platform > 0 && (
|
|
|
|
|
<div style={{ color: SKY, fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Closed on platform</span>
|
|
|
|
|
<span>{classification.closed_on_platform}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{classification.decommissioned > 0 && (
|
|
|
|
|
<div style={{ color: '#94A3B8', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Decommissioned</span>
|
|
|
|
|
<span>{classification.decommissioned}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-01 17:15:41 +00:00
|
|
|
{returnClassification && returned > 0 && (returnClassification.bu_reassignment > 0 || returnClassification.severity_drift > 0 || returnClassification.closed_on_platform > 0 || returnClassification.decommissioned > 0) && (
|
|
|
|
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem' }}>
|
|
|
|
|
<div style={{ color: '#475569', fontSize: '0.58rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.2rem' }}>
|
|
|
|
|
Returned because
|
|
|
|
|
</div>
|
|
|
|
|
{returnClassification.bu_reassignment > 0 && (
|
|
|
|
|
<div style={{ color: '#FB923C', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>BU reassigned back</span>
|
|
|
|
|
<span>{returnClassification.bu_reassignment}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{returnClassification.severity_drift > 0 && (
|
|
|
|
|
<div style={{ color: '#A78BFA', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Severity re-escalated</span>
|
|
|
|
|
<span>{returnClassification.severity_drift}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{returnClassification.closed_on_platform > 0 && (
|
|
|
|
|
<div style={{ color: SKY, fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Reopened on platform</span>
|
|
|
|
|
<span>{returnClassification.closed_on_platform}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{returnClassification.decommissioned > 0 && (
|
|
|
|
|
<div style={{ color: '#94A3B8', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ opacity: 0.8 }}>Re-provisioned</span>
|
|
|
|
|
<span>{returnClassification.decommissioned}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-24 21:06:35 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:12:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-12 14:57:15 -06:00
|
|
|
// Shorten YYYY-MM-DD (or ISO datetime) to MM/DD/YY
|
2026-04-02 10:12:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function fmtDate(d) {
|
|
|
|
|
if (!d) return '';
|
2026-05-12 14:57:15 -06:00
|
|
|
// Handle ISO datetime strings (e.g. "2026-05-12T06:00:00.000Z") — extract date part first
|
|
|
|
|
const dateStr = String(d).split('T')[0];
|
|
|
|
|
const p = dateStr.split('-');
|
2026-04-02 10:12:04 -06:00
|
|
|
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
2026-05-12 14:57:15 -06:00
|
|
|
return dateStr;
|
2026-04-02 10:12:04 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 21:06:35 +00:00
|
|
|
// Extract YYYY-MM-DD from a datetime string
|
|
|
|
|
function extractDate(ts) {
|
|
|
|
|
if (!ts) return '';
|
|
|
|
|
return ts.split('T')[0].split(' ')[0];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:12:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Main component
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 13:38:38 -06:00
|
|
|
export default function IvantiCountsChart({ teamsParam }) {
|
2026-04-02 10:12:04 -06:00
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [history, setHistory] = useState([]);
|
2026-04-24 21:06:35 +00:00
|
|
|
const [anomalies, setAnomalies] = useState([]);
|
2026-04-02 10:12:04 -06:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const load = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2026-05-06 13:38:38 -06:00
|
|
|
const historyUrl = teamsParam
|
|
|
|
|
? `${API_BASE}/ivanti/findings/counts/history?teams=${encodeURIComponent(teamsParam)}`
|
|
|
|
|
: `${API_BASE}/ivanti/findings/counts/history`;
|
2026-04-24 21:06:35 +00:00
|
|
|
const [countsRes, anomalyRes] = await Promise.all([
|
2026-05-06 13:38:38 -06:00
|
|
|
fetch(historyUrl, { credentials: 'include' }),
|
2026-04-24 21:06:35 +00:00
|
|
|
fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }),
|
|
|
|
|
]);
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
if (countsRes.ok) {
|
|
|
|
|
const d = await countsRes.json();
|
|
|
|
|
setHistory(d.history || []);
|
|
|
|
|
}
|
|
|
|
|
if (anomalyRes.ok) {
|
|
|
|
|
const d = await anomalyRes.json();
|
|
|
|
|
setAnomalies(d.history || []);
|
|
|
|
|
}
|
2026-04-02 10:12:04 -06:00
|
|
|
}
|
|
|
|
|
} catch { /* silent — chart shows no-data state */ }
|
|
|
|
|
finally { if (!cancelled) setLoading(false); }
|
|
|
|
|
};
|
|
|
|
|
load();
|
|
|
|
|
return () => { cancelled = true; };
|
2026-05-06 13:38:38 -06:00
|
|
|
}, [teamsParam]);
|
2026-04-02 10:12:04 -06:00
|
|
|
|
|
|
|
|
const chartData = useMemo(
|
|
|
|
|
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
|
|
|
|
[history]
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-24 21:06:35 +00:00
|
|
|
// Build archive activity data aligned to the same date axis as the main chart.
|
2026-05-01 19:28:29 +00:00
|
|
|
// Aggregate anomaly rows by date — sum archived/returned counts and merge
|
|
|
|
|
// classifications across all syncs that day, then align to the chartData dates.
|
2026-04-24 21:06:35 +00:00
|
|
|
const archiveData = useMemo(() => {
|
|
|
|
|
if (!anomalies.length || !chartData.length) return [];
|
|
|
|
|
|
2026-05-01 19:28:29 +00:00
|
|
|
// Aggregate all anomaly rows per date (sum counts, merge classifications)
|
2026-04-24 21:06:35 +00:00
|
|
|
const byDate = {};
|
|
|
|
|
for (const a of anomalies) {
|
|
|
|
|
const rawDate = extractDate(a.sync_timestamp);
|
|
|
|
|
const dateKey = fmtDate(rawDate);
|
|
|
|
|
if (!byDate[dateKey]) {
|
2026-05-01 19:28:29 +00:00
|
|
|
byDate[dateKey] = {
|
|
|
|
|
archived: 0,
|
|
|
|
|
returned: 0,
|
|
|
|
|
classification: {},
|
|
|
|
|
return_classification: {},
|
|
|
|
|
is_significant: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const entry = byDate[dateKey];
|
|
|
|
|
entry.archived += (a.newly_archived_count || 0);
|
|
|
|
|
entry.returned += (a.returned_count || 0);
|
|
|
|
|
if (a.is_significant) entry.is_significant = true;
|
|
|
|
|
|
|
|
|
|
// Merge classification counts
|
|
|
|
|
for (const [key, val] of Object.entries(a.classification || {})) {
|
|
|
|
|
entry.classification[key] = (entry.classification[key] || 0) + (val || 0);
|
|
|
|
|
}
|
|
|
|
|
for (const [key, val] of Object.entries(a.return_classification || {})) {
|
|
|
|
|
entry.return_classification[key] = (entry.return_classification[key] || 0) + (val || 0);
|
2026-04-24 21:06:35 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map onto the chart date axis so both charts share the same X positions
|
|
|
|
|
return chartData.map(point => {
|
2026-05-01 19:28:29 +00:00
|
|
|
const agg = byDate[point.date];
|
|
|
|
|
if (agg) {
|
|
|
|
|
return { date: point.date, ...agg };
|
2026-04-24 21:06:35 +00:00
|
|
|
}
|
2026-05-01 17:15:41 +00:00
|
|
|
return { date: point.date, archived: 0, returned: 0, classification: {}, return_classification: {}, is_significant: false };
|
2026-04-24 21:06:35 +00:00
|
|
|
});
|
|
|
|
|
}, [anomalies, chartData]);
|
|
|
|
|
|
|
|
|
|
// Check if there's any archive activity worth showing
|
|
|
|
|
const hasArchiveActivity = useMemo(
|
|
|
|
|
() => archiveData.some(d => d.archived > 0 || d.returned > 0),
|
|
|
|
|
[archiveData]
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-02 10:12:04 -06:00
|
|
|
// Compute a simple delta label for the latest vs previous point
|
|
|
|
|
const deltaLabel = useMemo(() => {
|
|
|
|
|
if (chartData.length < 2) return null;
|
|
|
|
|
const latest = chartData[chartData.length - 1];
|
|
|
|
|
const prev = chartData[chartData.length - 2];
|
|
|
|
|
const delta = latest.open_count - prev.open_count;
|
|
|
|
|
if (delta === 0) return { text: 'no change in open', color: '#475569' };
|
|
|
|
|
if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN };
|
|
|
|
|
return { text: `▲ ${delta} open since ${prev.date}`, color: RED };
|
|
|
|
|
}, [chartData]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
|
|
|
|
|
|
|
|
{/* ── Header ────────────────────────────────────────────────── */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setCollapsed(c => !c)}
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
|
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
|
|
|
padding: '0 0 0.625rem 0',
|
|
|
|
|
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
|
|
|
|
|
marginBottom: collapsed ? 0 : '0.875rem',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
|
|
|
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
|
|
|
|
|
<span style={{
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
|
|
|
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
|
|
|
|
}}>
|
|
|
|
|
Findings Trend
|
|
|
|
|
</span>
|
|
|
|
|
{loading && (
|
|
|
|
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
|
|
|
|
)}
|
|
|
|
|
{!loading && deltaLabel && (
|
|
|
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
|
|
|
|
|
{deltaLabel.text}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{collapsed
|
|
|
|
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
|
|
|
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
|
|
|
|
}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
|
|
|
|
border: '1px solid rgba(245,158,11,0.15)',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
padding: '1rem 1.25rem 0.875rem',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
|
|
|
|
Open vs Closed — end-of-day snapshot per sync day
|
|
|
|
|
</div>
|
|
|
|
|
{chartData.length > 0 && (
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
|
|
|
|
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{chartData.length < 2 ? (
|
|
|
|
|
<div style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
height: '160px', color: '#334155',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
|
|
|
|
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
|
|
|
|
|
}}>
|
|
|
|
|
{chartData.length === 0
|
|
|
|
|
? 'Trend data begins accumulating after the first sync — check back tomorrow'
|
|
|
|
|
: 'Need at least 2 days of syncs to display a trend'}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-04-24 21:06:35 +00:00
|
|
|
<>
|
|
|
|
|
{/* ── Main trend chart ──────────────────────── */}
|
|
|
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
|
|
|
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
|
|
|
|
<CartesianGrid {...GRID_STYLE} />
|
|
|
|
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
|
|
|
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
|
|
|
|
<Tooltip content={<DarkTooltip />} />
|
|
|
|
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
|
|
|
|
<Line
|
|
|
|
|
type="monotone" dataKey="open_count" name="Open"
|
|
|
|
|
stroke={AMBER} strokeWidth={2}
|
|
|
|
|
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
|
|
|
|
|
activeDot={{ r: 5 }}
|
|
|
|
|
/>
|
|
|
|
|
<Line
|
|
|
|
|
type="monotone" dataKey="closed_count" name="Closed"
|
|
|
|
|
stroke={SKY} strokeWidth={2}
|
|
|
|
|
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
|
|
|
|
|
activeDot={{ r: 5 }}
|
|
|
|
|
/>
|
|
|
|
|
</LineChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
|
|
|
|
{/* ── Archive activity sparkline ────────────── */}
|
|
|
|
|
{hasArchiveActivity && (
|
|
|
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
|
|
|
<div style={{
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.58rem', color: '#334155',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
|
|
|
|
marginBottom: '0.25rem',
|
|
|
|
|
}}>
|
|
|
|
|
Archive Activity
|
|
|
|
|
</div>
|
2026-05-01 19:28:29 +00:00
|
|
|
<ResponsiveContainer width="100%" height={80}>
|
2026-04-24 21:06:35 +00:00
|
|
|
<BarChart data={archiveData} margin={{ top: 2, right: 12, bottom: 0, left: -12 }}>
|
|
|
|
|
<CartesianGrid {...GRID_STYLE} />
|
|
|
|
|
<XAxis dataKey="date" tick={false} axisLine={false} />
|
|
|
|
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} width={30} />
|
|
|
|
|
<Tooltip content={<ArchiveTooltip />} />
|
2026-05-01 19:28:29 +00:00
|
|
|
<Bar dataKey="archived" name="Archived" stackId="a" maxBarSize={14}>
|
2026-04-24 21:06:35 +00:00
|
|
|
{archiveData.map((entry, idx) => (
|
|
|
|
|
<Cell
|
|
|
|
|
key={`arch-${idx}`}
|
|
|
|
|
fill={entry.is_significant ? ROSE : 'rgba(244,63,94,0.5)'}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</Bar>
|
2026-05-01 19:28:29 +00:00
|
|
|
<Bar dataKey="returned" name="Returned" stackId="a" maxBarSize={14}>
|
|
|
|
|
{archiveData.map((entry, idx) => (
|
|
|
|
|
<Cell
|
|
|
|
|
key={`ret-${idx}`}
|
|
|
|
|
fill={TEAL}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</Bar>
|
2026-04-24 21:06:35 +00:00
|
|
|
</BarChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2026-04-02 10:12:04 -06:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|