feat: added extra stats for contests
This commit is contained in:
@@ -37,6 +37,7 @@ type Stats = {
|
||||
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).
|
||||
@@ -79,9 +80,13 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
||||
// 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 }: { data: Bucket[]; max?: number; empty: string }) {
|
||||
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">
|
||||
@@ -95,6 +100,11 @@ function HBars({ data, max, empty }: { data: Bucket[]; max?: number; empty: stri
|
||||
/>
|
||||
</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>
|
||||
@@ -135,6 +145,123 @@ function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; h
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -385,6 +512,7 @@ export function StatsPanel() {
|
||||
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)); }
|
||||
@@ -521,7 +649,7 @@ export function StatsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
{stats.rate.length > 1
|
||||
? <VBars data={stats.rate} empty={empty} height={120} />
|
||||
? <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>
|
||||
|
||||
@@ -551,6 +679,16 @@ export function StatsPanel() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -577,8 +715,13 @@ export function StatsPanel() {
|
||||
<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')}>
|
||||
<HBars data={stats.by_operator} max={8} empty={empty} />
|
||||
<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} />
|
||||
@@ -596,7 +739,9 @@ export function StatsPanel() {
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byStation')}>
|
||||
<HBars data={stats.by_station} max={4} empty={empty} />
|
||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_station} empty={empty} share />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ const en: Dict = {
|
||||
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
|
||||
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
||||
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
||||
'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —',
|
||||
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —',
|
||||
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
|
||||
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
|
||||
'msg.expand': 'Click to read the full message',
|
||||
@@ -310,7 +310,7 @@ const fr: Dict = {
|
||||
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
|
||||
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
||||
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
||||
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —',
|
||||
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —',
|
||||
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
|
||||
'cluster.consoleEmpty': 'Le trafic brut du cluster s’affiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
|
||||
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||
|
||||
Reference in New Issue
Block a user