import { useEffect, useMemo, useState } from 'react'; import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react'; import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; // ───────────────────────────────────────────────────────────────────────────── // Statistics dashboard. // // Colour rules this file obeys (they are not stylistic — they are what keeps the // charts readable and colour-blind-safe): // // • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour // encodes identity, never a magnitude the bar's length already shows. // • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where // the categories ARE the subject (the continent donut). The order is the // colour-blind-safety mechanism, so it is never shuffled or cycled. // • The donut is for part-to-whole read at a glance. It is NOT used for modes: // CW and SSB are nearly tied, and a donut hides exactly the comparison that // makes them interesting. Close values belong in bars. // • Every value is direct-labelled, so nothing is reachable only by hovering, and // a table view gives the WCAG-clean twin. // ───────────────────────────────────────────────────────────────────────────── type Bucket = { key: string; count: number }; type Gap = { start: string; end: string; minutes: number }; type ContestRun = { id: string; year: number; count: number; start: string; end: string }; type Stats = { total: number; unique_calls: number; entities: number; continents: number; first_qso: string; last_qso: string; confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number; by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[]; by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[]; // Period / contest metrics. window_start: string; window_end: string; window_hours: number; avg_per_hour: number; avg_per_active: number; on_air_minutes: number; off_air_minutes: number; peak_hour_key: string; peak_hour_count: number; best_60: number; gaps: Gap[]; rate: Bucket[]; rate_ops: string[]; rate_by_op: number[][]; }; // Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break). const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`); const CONTINENT_NAME: Record = { EU: 'Europe', NA: 'North America', SA: 'South America', AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica', }; const nf = (n: number) => n.toLocaleString('en-US'); // ── Shell ──────────────────────────────────────────────────────────────────── function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) { return (

{title}

{sub &&

{sub}

}
{children}
); } // A headline number IS the chart — a one-bar bar chart would be noise. function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) { return (

{label}

{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}

{value}

{sub &&

{sub}

}
); } // ── Horizontal bars (long category names: modes, operators, entities) ───────── // One series → one hue. The value is direct-labelled, so no reader ever depends // on a tooltip to get a number. function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) { // max is a display cap for long tails (top entities). Where EVERY row matters — // the operators of a multi-op — it is deliberately not set: a capped chart would // silently drop the 9th operator, and "who worked what" is the whole question. const top = max ? data.slice(0, max) : data; const peak = Math.max(1, ...top.map((d) => d.count)); const total = data.reduce((s, d) => s + d.count, 0); if (top.length === 0) return

{empty}

; return (
{top.map((d) => (
{d.key}
{nf(d.count)} {share && ( {total ? ((d.count / total) * 100).toFixed(0) : 0}% )}
))}
); } // ── Vertical columns (short ordered labels: bands in band-plan order, hours) ── // Sorting bands by COUNT would destroy the band-plan reading; the order is the // information. function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) { const peak = Math.max(1, ...data.map((d) => d.count)); if (data.length === 0) return

{empty}

; // Thin the x labels once the bars get narrow. A label under EVERY one of 48 // hourly bars is unreadable mush — the axis exists to orient, not to enumerate // (the value is in the tooltip, and every number is in the table view). const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12); return ( // The container includes the x-axis band — a fixed height that excludes it is // how cards end up with a tiny nested scrollbar.
{data.map((d, i) => (
{nf(d.count)}
{i % every === 0 ? d.key : ''}
))}
); } // ── Contest rate, stacked by operator ──────────────────────────────────────── // The categories (the operators) ARE the subject, so this is categorical colour, // in the FIXED order the backend sends (busiest first). An operator therefore // keeps the same hue everywhere on the page — a chart that repaints its series // when the filter changes is a chart nobody can trust. function RateStack({ rate, ops, byOp, empty, height = 130 }: { rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number; }) { const [hov, setHov] = useState(null); if (rate.length === 0) return

{empty}

; const peak = Math.max(1, ...rate.map((d) => d.count)); const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12); const single = ops.length <= 1; // one operator → one hue; a legend would be noise return (
{rate.map((d, i) => (
setHov(i)} onMouseLeave={() => setHov(null)}> {/* The column is the hour's total; the segments are who made it. */}
{(byOp[i] ?? []).map((n, o) => n > 0 && (
))}
{i % every === 0 ? d.key : ''}
))}
{/* Hover read-out: the hour, its total, and the split. Values are also in the rate sheet below, so nothing is reachable by hover alone. */}

{hov !== null && ( <> {rate[hov].key} {' · '}{nf(rate[hov].count)} QSO {!single && (byOp[hov] ?? []).map((n, o) => n > 0 && ( · {ops[o]} {n} ))} )}

{!single && (
{ops.map((op, o) => ( {op} ))}
)}
); } // ── The rate sheet: hour × operator, with the hour total ───────────────────── // Exact numbers, many of them — that is a table's job, not a chart's. Contesters // read rate sheets as tables, and every value here is also the one the chart draws. function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) { if (rate.length === 0) return null; const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0)); const grand = rate.reduce((s, d) => s + d.count, 0); return (
{ops.map((op, o) => ( ))} {shown.map(({ d, i }) => ( {ops.map((_, o) => ( ))} ))} {totals.map((n, o) => )}
UTC {op} Total
{d.key} {byOp[i]?.[o] ? nf(byOp[i][o]) : ·} {nf(d.count)}
Total{nf(n)}{nf(grand)}
); } // ── Activity over time (single series → area, one hue) ──────────────────────── // Only the PEAK and the LAST point are direct-labelled. A number on every point // is chaos and goes unread. function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) { const [hover, setHover] = useState(null); if (data.length < 2) return

{empty}

; const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count)); const x = (i: number) => (i / (data.length - 1)) * W; const y = (v: number) => H - (v / peak) * H; const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' '); const area = `${line} L${W},${H} L0,${H} Z`; const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0); const h = hover ?? -1; return (
setHover(null)}> {/* Recessive hairline grid — solid, never dashed. */} {[0, 0.5, 1].map((f) => ( ))} {h >= 0 && ( <> {/* 2px surface ring so the marker reads on top of the area. */} )} {/* Hit areas are far wider than the marks — pinpoint targets are unusable. */} {data.map((_, i) => ( setHover(i)} /> ))} {h >= 0 && (
{data[h].key}{' '} {nf(data[h].count)}
)}
{data[0].key} ↑ {data[peakIdx].key} · {nf(data[peakIdx].count)} {data[data.length - 1].key}
); } // ── Part-to-whole (continents) → the donut ─────────────────────────────────── // A pie/donut is legitimate for exactly this: part-to-whole, read at a glance, // few segments, one share obviously dominant. It is the WRONG form for comparing // close values (which is why modes stay bars — CW and SSB are nearly tied, and a // donut would hide precisely the fact that makes them interesting). // // The categories ARE the subject here, so this is the one chart using the // categorical slots — in FIXED order, with a legend and printed values, because // the dark palette sits in the CVD floor band where labels are mandatory. function Donut({ data, empty }: { data: Bucket[]; empty: string }) { const [hov, setHov] = useState(null); const total = data.reduce((s, d) => s + d.count, 0); if (total === 0) return

{empty}

; const slots = data.slice(0, 8); // never generate a 9th hue const R = 58, SW = 22, C = 2 * Math.PI * R; let acc = 0; const arcs = slots.map((d, i) => { const frac = d.count / total; // A 2px surface gap between segments — the correct separator, not a border. const len = Math.max(0, frac * C - 2); const a = { key: d.key, i, len, off: -acc * C, frac }; acc += frac; return a; }); return (
{arcs.map((a) => ( setHov(a.i)} onMouseLeave={() => setHov(null)} /> ))} {/* The hole is not decoration — it carries the total the slices add up to. */}
{hov === null ? ( <> {nf(total)} QSO ) : ( <> {(arcs[hov].frac * 100).toFixed(1)}% {CONTINENT_NAME[slots[hov].key] ?? slots[hov].key} )}
    {slots.map((d, i) => (
  • setHov(i)} onMouseLeave={() => setHov(null)} className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1', hov === i && 'bg-muted')}> {/* The swatch carries identity; the text stays in ink tokens. */} {CONTINENT_NAME[d.key] ?? d.key} {nf(d.count)} {((d.count / total) * 100).toFixed(0)}%
  • ))}
); } // ── A single ratio against a limit → a meter, not a 2-slice pie ─────────────── function Meter({ label, value, total }: { label: string; value: number; total: number }) { const pct = total > 0 ? (value / total) * 100 : 0; return (
{label} {pct.toFixed(1)}% · {nf(value)}
{/* Same-ramp track: the meter and its track are one hue, not two. */}
); } // ── Period ─────────────────────────────────────────────────────────────────── // ONE filter row above everything it scopes — never a control inside a card, or // the charts would each show a different slice of time. type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom'; const iso = (d: Date) => d.toISOString().slice(0, 10); // Resolve a preset to the [from, to] the backend expects. Empty = no bound. function periodRange(p: Period, from: string, to: string): [string, string] { const now = new Date(); switch (p) { case 'ytd': return [`${now.getUTCFullYear()}-01-01`, '']; case 'y12': { const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12); return [iso(d), '']; } case 'd30': { const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30); return [iso(d), '']; } case 'custom': return [from, to]; default: return ['', '']; } } // ── Table view (the WCAG-clean twin) ───────────────────────────────────────── function BucketTable({ title, data }: { title: string; data: Bucket[] }) { const total = data.reduce((s, d) => s + d.count, 0); return (

{title}

{data.map((d) => ( ))} {data.length === 0 && }
{d.key} {nf(d.count)} {total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
); } // ── Panel ──────────────────────────────────────────────────────────────────── export function StatsPanel() { const { t } = useI18n(); const [stats, setStats] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [view, setView] = useState<'charts' | 'table'>('charts'); const [period, setPeriod] = useState('all'); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); // Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to // that contest AND lets the window derive from its own span — no date typing. const [runs, setRuns] = useState([]); const [contest, setContest] = useState(''); useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []); const load = async (p: Period = period, f = from, t2 = to, c = contest) => { // A contest defines its own window (its first→last QSO), so we send no dates // with it — the backend derives them. Sending a period as well would be two // filters fighting over the same axis. const [cid, cyr] = c ? c.split('|') : ['', '0']; const [a, b] = c ? ['', ''] : periodRange(p, f, t2); setBusy(true); setErr(''); try { const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any; // Harden the boundary: a Go nil slice arrives as JSON null, and a single // .length on null unmounts the whole React tree — a white screen. Normalise // once, here, rather than guarding at every use site and missing one. const arr = (v: any) => (Array.isArray(v) ? v : []); setStats({ ...raw, by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator), by_station: arr(raw.by_station), by_continent: arr(raw.by_continent), top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month), rate: arr(raw.rate), gaps: arr(raw.gaps), rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op), } as Stats); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setBusy(false); } }; // Re-run whenever the filter changes. A custom range only fires once BOTH ends // are set — otherwise every keystroke in the date box would re-scan the log. useEffect(() => { if (!contest && period === 'custom' && !(from && to)) return; load(period, from, to, contest); // eslint-disable-next-line react-hooks/exhaustive-deps }, [period, from, to, contest]); // The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else. // Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break" // between two ordinary evenings — numbers that are true and completely useless. // So: show it for a picked contest, or for a hand-set range short enough to be a // real operating session (which is exactly when an hourly rate chart exists). const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1); const span = useMemo(() => { if (!stats?.first_qso) return ''; const f = new Date(stats.first_qso), l = new Date(stats.last_qso); return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`; }, [stats]); if (busy && !stats) { return
{t('stats.loading')}
; } if (err) return
{err}
; if (!stats) return null; const empty = t('stats.noData'); return (
{/* ONE filter/action row above everything it scopes — never per-card controls. */}

{t('stats.title')}

{span && {span}} {/* A contest is picked from what's actually IN the log (CONTEST_ID + year), never from a static list — so it can't offer a contest you never entered. Choosing one supersedes the period: it brings its own window. */}
{([ ['all', t('stats.pAll')], ['ytd', t('stats.pYTD')], ['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')], ] as [Period, string][]).map(([p, label], i) => ( ))}
{!contest && period === 'custom' && (
setFrom(e.target.value)} className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" /> setTo(e.target.value)} className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
)}
{/* Headline figures: stat tiles, not a grouped bar chart. */}
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across seventeen years is noise; across a contest weekend it IS the result. */} {windowed && stats.total > 0 && (
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}

{t('stats.avgWindow')}

{stats.avg_per_hour.toFixed(1)} /h

{t('stats.avgActive')}

{stats.avg_per_active.toFixed(1)} /h

{t('stats.best60')}

{nf(stats.best_60)}

{/* On-air + off-air = the window, by construction. The first version counted "clock hours containing a QSO", which gave 39 h on air AND 16 h off air inside a 45 h contest — and rightly wasn't believed. */}

{t('stats.activeHours')}

{dur(stats.on_air_minutes)} / {Math.round(stats.window_hours)} h

{stats.rate.length > 1 ? :

{t('stats.rateTooLong')}

}
{stats.gaps.length === 0 ? (

{t('stats.noGaps')}

) : (
    {stats.gaps.map((g, i) => (
  • {g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)} {dur(g.minutes)}
  • ))}
)}
{/* The rate sheet: exact numbers, many of them, hour by hour and operator by operator. That is a table's job, not a chart's — and it's how contesters actually read a run. Every value here is also what the stacked chart above draws, so the two can never disagree. */} {stats.rate.length > 1 && ( )}
)} {view === 'table' ? (
) : (
{/* EVERY operator, never a top-N: on a multi-op contest the point is who worked what, and a cap would quietly delete the 9th operator. Scrolls instead of truncating. */}
)}
); }