753 lines
39 KiB
TypeScript
753 lines
39 KiB
TypeScript
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<string, string> = {
|
||
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 (
|
||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||
<header className="mb-3 shrink-0">
|
||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||
</header>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||
return (
|
||
<div className="flex flex-col gap-1.5 min-w-0">
|
||
{top.map((d) => (
|
||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||
<div
|
||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||
/>
|
||
</div>
|
||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||
{share && (
|
||
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||
// 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.
|
||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||
{data.map((d, i) => (
|
||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||
title={`${d.key} — ${nf(d.count)}`}>
|
||
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||
{nf(d.count)}
|
||
</span>
|
||
<div
|
||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||
/>
|
||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||
{i % every === 0 ? d.key : ''}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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<number | null>(null);
|
||
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||
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 (
|
||
<div className="min-w-0">
|
||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||
{rate.map((d, i) => (
|
||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||
{/* The column is the hour's total; the segments are who made it. */}
|
||
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||
<div key={o} style={{
|
||
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||
}} />
|
||
))}
|
||
</div>
|
||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||
{i % every === 0 ? d.key : ''}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||
{hov !== null && (
|
||
<>
|
||
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||
{' · '}{nf(rate[hov].count)} QSO
|
||
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||
))}
|
||
</>
|
||
)}
|
||
</p>
|
||
|
||
{!single && (
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||
{ops.map((op, o) => (
|
||
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||
{op}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||
<table className="w-full text-[11px] tabular-nums">
|
||
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||
<tr className="text-muted-foreground">
|
||
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||
{ops.map((op, o) => (
|
||
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||
{op}
|
||
</th>
|
||
))}
|
||
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{shown.map(({ d, i }) => (
|
||
<tr key={d.key} className="border-t border-border/50">
|
||
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||
{ops.map((_, o) => (
|
||
<td key={o} className="px-2 py-0.5 text-right">
|
||
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||
</td>
|
||
))}
|
||
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||
<tr className="border-t border-border font-semibold">
|
||
<td className="px-2 py-1">Total</td>
|
||
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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<number | null>(null);
|
||
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||
|
||
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 (
|
||
<div className="min-w-0">
|
||
<div className="relative" style={{ height }}>
|
||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||
onMouseLeave={() => setHover(null)}>
|
||
{/* Recessive hairline grid — solid, never dashed. */}
|
||
{[0, 0.5, 1].map((f) => (
|
||
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||
))}
|
||
<defs>
|
||
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path d={area} fill="url(#statsArea)" />
|
||
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||
strokeLinejoin="round" strokeLinecap="round" />
|
||
{h >= 0 && (
|
||
<>
|
||
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||
{/* 2px surface ring so the marker reads on top of the area. */}
|
||
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||
</>
|
||
)}
|
||
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||
{data.map((_, i) => (
|
||
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||
))}
|
||
</svg>
|
||
{h >= 0 && (
|
||
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||
<span>{data[0].key}</span>
|
||
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||
<span>{data[data.length - 1].key}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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<number | null>(null);
|
||
const total = data.reduce((s, d) => s + d.count, 0);
|
||
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||
|
||
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 (
|
||
<div className="flex items-center gap-4 min-w-0">
|
||
<div className="relative shrink-0">
|
||
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||
{arcs.map((a) => (
|
||
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||
className="transition-[stroke-width] duration-150 cursor-default"
|
||
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||
))}
|
||
</svg>
|
||
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||
{hov === null ? (
|
||
<>
|
||
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||
{slots.map((d, i) => (
|
||
<li key={d.key}
|
||
onMouseEnter={() => 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. */}
|
||
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||
{((d.count / total) * 100).toFixed(0)}%
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="min-w-0">
|
||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||
<span className="text-[11px] tabular-nums">
|
||
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||
</span>
|
||
</div>
|
||
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||
<div className="h-full rounded-full transition-[width] duration-500"
|
||
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||
<table className="w-full text-[11px]">
|
||
<tbody>
|
||
{data.map((d) => (
|
||
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||
<td className="px-3 py-1 truncate">{d.key}</td>
|
||
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Panel ────────────────────────────────────────────────────────────────────
|
||
|
||
export function StatsPanel() {
|
||
const { t } = useI18n();
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [err, setErr] = useState('');
|
||
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||
const [period, setPeriod] = useState<Period>('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<ContestRun[]>([]);
|
||
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 <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||
</div>;
|
||
}
|
||
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||
if (!stats) return null;
|
||
|
||
const empty = t('stats.noData');
|
||
|
||
return (
|
||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{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. */}
|
||
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||
<option value="">{t('stats.noContest')}</option>
|
||
{runs.map((r) => (
|
||
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||
{r.id} {r.year} — {nf(r.count)} QSO
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||
{([
|
||
['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) => (
|
||
<button key={p} onClick={() => setPeriod(p)}
|
||
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{!contest && period === 'custom' && (
|
||
<div className="inline-flex items-center gap-1">
|
||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||
<span className="text-xs text-muted-foreground">→</span>
|
||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1" />
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||
<button onClick={() => setView('charts')}
|
||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||
</button>
|
||
<button onClick={() => setView('table')}
|
||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||
<Table2 className="size-3.5" />{t('stats.table')}
|
||
</button>
|
||
</div>
|
||
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||
</div>
|
||
|
||
{/* 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 && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||
<div title={t('stats.avgWindowTip')}>
|
||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||
</div>
|
||
<div title={t('stats.avgActiveTip')}>
|
||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||
</div>
|
||
<div title={t('stats.best60Tip')}>
|
||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||
</div>
|
||
{/* 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. */}
|
||
<div title={t('stats.onAirTip')}>
|
||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||
<p className="text-[22px] leading-none font-semibold mt-1">
|
||
{dur(stats.on_air_minutes)}
|
||
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{stats.rate.length > 1
|
||
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||
</Card>
|
||
|
||
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||
{stats.gaps.length === 0 ? (
|
||
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||
) : (
|
||
<ul className="flex flex-col gap-1 min-w-0">
|
||
{stats.gaps.map((g, i) => (
|
||
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||
<span className="tabular-nums text-muted-foreground truncate">
|
||
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||
</span>
|
||
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||
style={{
|
||
background: 'var(--chart-seq-1)',
|
||
}}>
|
||
<span className="block h-full rounded-full"
|
||
style={{
|
||
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||
background: 'var(--chart-1)',
|
||
}} />
|
||
</span>
|
||
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</Card>
|
||
|
||
{/* 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 && (
|
||
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{view === 'table' ? (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||
<VBars data={stats.by_band} empty={empty} />
|
||
</Card>
|
||
<Card title={t('stats.byMode')}>
|
||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||
</Card>
|
||
|
||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||
<AreaTrend data={stats.by_month} empty={empty} />
|
||
</Card>
|
||
|
||
{/* 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. */}
|
||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||
<HBars data={stats.by_operator} empty={empty} share />
|
||
</div>
|
||
</Card>
|
||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||
<Donut data={stats.by_continent} empty={empty} />
|
||
</Card>
|
||
|
||
<Card title={t('stats.topEntities')}>
|
||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||
</Card>
|
||
<div className="flex flex-col gap-2.5 min-w-0">
|
||
<Card title={t('stats.confirmations')} className="flex-1">
|
||
<div className="flex flex-col gap-3 justify-center flex-1">
|
||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||
</div>
|
||
</Card>
|
||
<Card title={t('stats.byStation')}>
|
||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||
<HBars data={stats.by_station} empty={empty} share />
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|