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;
|
on_air_minutes: number; off_air_minutes: number;
|
||||||
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||||
gaps: Gap[]; rate: Bucket[];
|
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).
|
// 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
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
// on a tooltip to get a number.
|
// 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 top = max ? data.slice(0, max) : data;
|
||||||
const peak = Math.max(1, ...top.map((d) => d.count));
|
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>;
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
<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>
|
</div>
|
||||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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) ────────────────────────
|
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||||
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||||
// is chaos and goes unread.
|
// is chaos and goes unread.
|
||||||
@@ -385,6 +512,7 @@ export function StatsPanel() {
|
|||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
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),
|
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: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
} as Stats);
|
} as Stats);
|
||||||
}
|
}
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
@@ -521,7 +649,7 @@ export function StatsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{stats.rate.length > 1
|
{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>}
|
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -551,6 +679,16 @@ export function StatsPanel() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -577,8 +715,13 @@ export function StatsPanel() {
|
|||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
<AreaTrend data={stats.by_month} empty={empty} />
|
||||||
</Card>
|
</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')}>
|
<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>
|
||||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||||
<Donut data={stats.by_continent} empty={empty} />
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
@@ -596,7 +739,9 @@ export function StatsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byStation')}>
|
<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>
|
</Card>
|
||||||
</div>
|
</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.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.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.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.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, …).',
|
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
|
||||||
'msg.expand': 'Click to read the full message',
|
'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.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.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.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.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, …).',
|
'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',
|
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||||
|
|||||||
@@ -3367,6 +3367,8 @@ export namespace qso {
|
|||||||
best_60: number;
|
best_60: number;
|
||||||
gaps: Gap[];
|
gaps: Gap[];
|
||||||
rate: Bucket[];
|
rate: Bucket[];
|
||||||
|
rate_ops: string[];
|
||||||
|
rate_by_op: number[][];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Stats(source);
|
return new Stats(source);
|
||||||
@@ -3404,6 +3406,8 @@ export namespace qso {
|
|||||||
this.best_60 = source["best_60"];
|
this.best_60 = source["best_60"];
|
||||||
this.gaps = this.convertValues(source["gaps"], Gap);
|
this.gaps = this.convertValues(source["gaps"], Gap);
|
||||||
this.rate = this.convertValues(source["rate"], Bucket);
|
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 {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|||||||
+105
-18
@@ -163,6 +163,21 @@ type Stats struct {
|
|||||||
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
|
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
|
||||||
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
|
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
|
||||||
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
|
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
|
||||||
|
|
||||||
|
// The contest RATE SHEET: hour by hour, who made the QSOs.
|
||||||
|
// RateOps are the operators, busiest first — that fixed order is also the
|
||||||
|
// colour/legend order, so an operator keeps their hue across the whole page.
|
||||||
|
// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
|
||||||
|
// the per-operator numbers always sum to the hour's total.
|
||||||
|
RateOps []string `json:"rate_ops"`
|
||||||
|
RateByOp [][]int `json:"rate_by_op"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry is one dated QSO with the operator who made it — the pair the contest
|
||||||
|
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
|
||||||
|
type entry struct {
|
||||||
|
t time.Time
|
||||||
|
op string
|
||||||
}
|
}
|
||||||
|
|
||||||
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
|
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
|
||||||
@@ -219,7 +234,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
entityC = map[string]int{}
|
entityC = map[string]int{}
|
||||||
yearC = map[string]int{}
|
yearC = map[string]int{}
|
||||||
monthC = map[string]int{}
|
monthC = map[string]int{}
|
||||||
times []time.Time // every dated QSO, for the rate / gap maths
|
times []entry // every dated QSO (+ its operator), for the rate / gap maths
|
||||||
first, last time.Time
|
first, last time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -318,7 +333,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
yearC[t.Format("2006")]++
|
yearC[t.Format("2006")]++
|
||||||
monthC[t.Format("2006-01")]++
|
monthC[t.Format("2006-01")]++
|
||||||
times = append(times, t)
|
times = append(times, entry{t: t, op: op})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return s, err
|
return s, err
|
||||||
@@ -404,6 +419,12 @@ func (s *Stats) ensureNonNil() {
|
|||||||
if s.Gaps == nil {
|
if s.Gaps == nil {
|
||||||
s.Gaps = []Gap{}
|
s.Gaps = []Gap{}
|
||||||
}
|
}
|
||||||
|
if s.RateOps == nil {
|
||||||
|
s.RateOps = []string{}
|
||||||
|
}
|
||||||
|
if s.RateByOp == nil {
|
||||||
|
s.RateByOp = [][]int{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// periodMetrics derives the rate / off-air figures that make a contest window
|
// periodMetrics derives the rate / off-air figures that make a contest window
|
||||||
@@ -415,11 +436,11 @@ func (s *Stats) ensureNonNil() {
|
|||||||
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||||
// how fast you go when you ARE at the radio.
|
// how fast you go when you ARE at the radio.
|
||||||
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
||||||
func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time) {
|
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
|
||||||
if len(times) == 0 {
|
if len(times) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
|
sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })
|
||||||
|
|
||||||
winStart, winEnd := from, to
|
winStart, winEnd := from, to
|
||||||
if winStart.IsZero() {
|
if winStart.IsZero() {
|
||||||
@@ -441,8 +462,8 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
|
|||||||
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
|
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
|
||||||
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
|
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
|
||||||
hourly := map[string]int{}
|
hourly := map[string]int{}
|
||||||
for _, t := range times {
|
for _, e := range times {
|
||||||
hourly[t.Format("2006-01-02 15")]++
|
hourly[e.t.Format("2006-01-02 15")]++
|
||||||
}
|
}
|
||||||
for k, v := range hourly {
|
for k, v := range hourly {
|
||||||
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
|
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
|
||||||
@@ -455,7 +476,7 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
|
|||||||
// actually quote. Two pointers over the sorted times: O(n).
|
// actually quote. Two pointers over the sorted times: O(n).
|
||||||
lo := 0
|
lo := 0
|
||||||
for hi := range times {
|
for hi := range times {
|
||||||
for times[hi].Sub(times[lo]) >= time.Hour {
|
for times[hi].t.Sub(times[lo].t) >= time.Hour {
|
||||||
lo++
|
lo++
|
||||||
}
|
}
|
||||||
if n := hi - lo + 1; n > s.Best60 {
|
if n := hi - lo + 1; n > s.Best60 {
|
||||||
@@ -481,11 +502,11 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
|
|||||||
Minutes: int(d.Minutes()),
|
Minutes: int(d.Minutes()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
addGap(winStart, times[0]) // lead-in
|
addGap(winStart, times[0].t) // lead-in
|
||||||
for i := 1; i < len(times); i++ { // the silences between QSOs
|
for i := 1; i < len(times); i++ { // the silences between QSOs
|
||||||
addGap(times[i-1], times[i])
|
addGap(times[i-1].t, times[i].t)
|
||||||
}
|
}
|
||||||
addGap(times[len(times)-1], winEnd) // tail
|
addGap(times[len(times)-1].t, winEnd) // tail
|
||||||
|
|
||||||
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
|
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
|
||||||
if s.OnAirMinutes < 0 {
|
if s.OnAirMinutes < 0 {
|
||||||
@@ -499,21 +520,87 @@ func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time
|
|||||||
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
|
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-hour rate timeline — the classic contest rate chart. Every hour of the
|
// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
|
||||||
// window, zeros included, so the silences are visible as silences.
|
// Every hour of the window, zeros included, so the silences read as silences.
|
||||||
if s.WindowHours <= rateMaxHours {
|
if s.WindowHours > rateMaxHours {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators, busiest first. That order is fixed and reused as the colour/legend
|
||||||
|
// order, so an operator keeps the same hue everywhere on the page — a chart that
|
||||||
|
// repaints its series when the filter changes is a chart nobody can trust.
|
||||||
|
opTotals := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
opTotals[e.op]++
|
||||||
|
}
|
||||||
|
s.RateOps = make([]string, 0, len(opTotals))
|
||||||
|
for op := range opTotals {
|
||||||
|
s.RateOps = append(s.RateOps, op)
|
||||||
|
}
|
||||||
|
sort.Slice(s.RateOps, func(i, j int) bool {
|
||||||
|
a, b := s.RateOps[i], s.RateOps[j]
|
||||||
|
if opTotals[a] != opTotals[b] {
|
||||||
|
return opTotals[a] > opTotals[b]
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
|
||||||
|
// is honest and still sums correctly.
|
||||||
|
const maxOps = 8
|
||||||
|
folded := false
|
||||||
|
if len(s.RateOps) > maxOps {
|
||||||
|
s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
|
||||||
|
folded = true
|
||||||
|
}
|
||||||
|
opIdx := map[string]int{}
|
||||||
|
for i, op := range s.RateOps {
|
||||||
|
opIdx[op] = i
|
||||||
|
}
|
||||||
|
slotFor := func(op string) int {
|
||||||
|
if i, ok := opIdx[op]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if folded {
|
||||||
|
return len(s.RateOps) - 1 // the "Other" bucket
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
|
||||||
|
// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
|
||||||
|
// is how a rate sheet ends up not adding up to its own total row.
|
||||||
|
hourOps := map[string][]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
k := e.t.Format("2006-01-02 15")
|
||||||
|
row, ok := hourOps[k]
|
||||||
|
if !ok {
|
||||||
|
row = make([]int, len(s.RateOps))
|
||||||
|
hourOps[k] = row
|
||||||
|
}
|
||||||
|
if i := slotFor(e.op); i >= 0 {
|
||||||
|
row[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cur := winStart.Truncate(time.Hour)
|
cur := winStart.Truncate(time.Hour)
|
||||||
end := winEnd.Truncate(time.Hour)
|
end := winEnd.Truncate(time.Hour)
|
||||||
for !cur.After(end) {
|
for !cur.After(end) {
|
||||||
s.Rate = append(s.Rate, Bucket{
|
k := cur.Format("2006-01-02 15")
|
||||||
Key: cur.Format("01-02 15"),
|
s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
|
||||||
Count: hourly[cur.Format("2006-01-02 15")],
|
row := hourOps[k]
|
||||||
})
|
if row == nil {
|
||||||
|
row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
|
||||||
|
}
|
||||||
|
s.RateByOp = append(s.RateByOp, row)
|
||||||
cur = cur.Add(time.Hour)
|
cur = cur.Add(time.Hour)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// otherOp is where operators past the 8th are folded. Generating a 9th colour is
|
||||||
|
// never the answer: under colour-blindness it is indistinguishable from one of the
|
||||||
|
// existing eight.
|
||||||
|
const otherOp = "Other"
|
||||||
|
|
||||||
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
|
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
|
||||||
// keeps only the top n.
|
// keeps only the top n.
|
||||||
func topBuckets(m map[string]int, n int) []Bucket {
|
func topBuckets(m map[string]int, n int) []Bucket {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestStatsNoNilSlices(t *testing.T) {
|
|||||||
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
|
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
|
||||||
// window that yields no hourly chart must still produce [] and not null.
|
// window that yields no hourly chart must still produce [] and not null.
|
||||||
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
times := []time.Time{base, base.Add(2 * time.Minute), base.Add(5 * time.Minute)}
|
times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
|
||||||
var s2 Stats
|
var s2 Stats
|
||||||
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
|
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
|
||||||
s2.ensureNonNil()
|
s2.ensureNonNil()
|
||||||
@@ -82,12 +82,12 @@ func TestContestPeriodMetrics(t *testing.T) {
|
|||||||
|
|
||||||
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
|
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
|
||||||
// then a 2-hour silence, then 3 more.
|
// then a 2-hour silence, then 3 more.
|
||||||
var times []time.Time
|
var times []entry
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
times = append(times, at(40+i*4)) // 12:40 … 13:16
|
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
|
||||||
}
|
}
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
times = append(times, at(240+i*5)) // 16:00 …
|
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
|
||||||
}
|
}
|
||||||
|
|
||||||
from := base // 12:00
|
from := base // 12:00
|
||||||
@@ -139,6 +139,51 @@ func TestContestPeriodMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The contest RATE SHEET: hour by hour, who made the QSOs.
|
||||||
|
//
|
||||||
|
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
|
||||||
|
// that hour's total. Derive the two separately and a rate sheet quietly stops
|
||||||
|
// adding up to its own total row — the sort of error nobody spots until someone
|
||||||
|
// checks the score by hand.
|
||||||
|
func TestRateSheetSumsToHourTotal(t *testing.T) {
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||||
|
|
||||||
|
times := []entry{
|
||||||
|
{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
|
||||||
|
{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
|
||||||
|
// hour 14 silent
|
||||||
|
{t: at(185), op: "F4BPO"}, // hour 15: 1
|
||||||
|
}
|
||||||
|
var s Stats
|
||||||
|
s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})
|
||||||
|
|
||||||
|
if len(s.Rate) != len(s.RateByOp) {
|
||||||
|
t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
|
||||||
|
}
|
||||||
|
// Both operators present, busiest first (they tie at 3 → alphabetical).
|
||||||
|
if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
|
||||||
|
t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
|
||||||
|
}
|
||||||
|
for h := range s.Rate {
|
||||||
|
sum := 0
|
||||||
|
for _, n := range s.RateByOp[h] {
|
||||||
|
sum += n
|
||||||
|
}
|
||||||
|
if sum != s.Rate[h].Count {
|
||||||
|
t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
|
||||||
|
s.Rate[h].Key, sum, s.Rate[h].Count)
|
||||||
|
}
|
||||||
|
if len(s.RateByOp[h]) != len(s.RateOps) {
|
||||||
|
t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The silent hour is a row of zeros, not a missing row.
|
||||||
|
if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
|
||||||
|
t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
|
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
|
||||||
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
|
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
|
||||||
// would invent activity that never happened.
|
// would invent activity that never happened.
|
||||||
|
|||||||
Reference in New Issue
Block a user