feat: added extra stats for contests

This commit is contained in:
2026-07-13 16:53:37 +02:00
parent 68982e9a85
commit ae60d58893
5 changed files with 313 additions and 32 deletions
+149 -4
View File
@@ -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>
+2 -2
View File
@@ -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 lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + 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 saffiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
'msg.expand': 'Cliquer pour lire le message en entier',
+4
View File
@@ -3367,6 +3367,8 @@ export namespace qso {
best_60: number;
gaps: Gap[];
rate: Bucket[];
rate_ops: string[];
rate_by_op: number[][];
static createFrom(source: any = {}) {
return new Stats(source);
@@ -3404,6 +3406,8 @@ export namespace qso {
this.best_60 = source["best_60"];
this.gaps = this.convertValues(source["gaps"], Gap);
this.rate = this.convertValues(source["rate"], Bucket);
this.rate_ops = source["rate_ops"];
this.rate_by_op = source["rate_by_op"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {