diff --git a/app.go b/app.go index f668702..d501756 100644 --- a/app.go +++ b/app.go @@ -3632,6 +3632,50 @@ func (a *App) UpdateQSO(q qso.QSO) error { return err } +// GetLogStats aggregates the logbook for the Statistics dashboard (operators, +// modes, bands, entities, activity over time), restricted to a period. +// +// fromISO/toISO are "YYYY-MM-DD" (or RFC3339); either may be empty for "no +// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date +// is stretched to 23:59:59 of that day, otherwise picking today as the end would +// silently drop today's QSOs. +func (a *App) GetLogStats(fromISO, toISO, contestID string, year int) (qso.Stats, error) { + if a.qso == nil { + return qso.Stats{}, fmt.Errorf("db not initialized") + } + from := parseStatsBound(fromISO, false) + to := parseStatsBound(toISO, true) + return a.qso.Stats(a.ctx, from, to, contestID, year) +} + +// GetContestRuns lists the (contest, year) pairs actually present in the log, so +// the Statistics picker only ever offers contests you really entered. +func (a *App) GetContestRuns() ([]qso.ContestRun, error) { + if a.qso == nil { + return nil, fmt.Errorf("db not initialized") + } + return a.qso.ContestRuns(a.ctx) +} + +// parseStatsBound turns a UI date into a UTC bound. endOfDay stretches a bare +// date to 23:59:59 so an inclusive "to" really includes that day. +func parseStatsBound(s string, endOfDay bool) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + if t, err := time.Parse("2006-01-02", s); err == nil { + if endOfDay { + return t.UTC().Add(24*time.Hour - time.Second) + } + return t.UTC() + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UTC() + } + return time.Time{} +} + func (a *App) DeleteQSO(id int64) error { if a.qso == nil { return fmt.Errorf("db not initialized") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d794d04..c2cfb3f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,7 @@ import { IcomPanel } from '@/components/IcomPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { AwardsPanel } from '@/components/AwardsPanel'; +import { StatsPanel } from '@/components/StatsPanel'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; @@ -616,6 +617,12 @@ export default function App() { setQslTabOpen(false); setActiveTab((t) => (t === 'qsl' ? 'recent' : t)); } + // Statistics is likewise a closable tab, opened from Tools → Statistics. + const [statsTabOpen, setStatsTabOpen] = useState(false); + function closeStatsTab() { + setStatsTabOpen(false); + setActiveTab((t) => (t === 'stats' ? 'recent' : t)); + } // Recent QSOs row cap, persisted. With AG Grid's virtual scroller // huge logs render OK once loaded, but a 25k+ logbook still takes a // couple of seconds to round-trip from SQLite at launch. Defaulting @@ -2455,6 +2462,7 @@ export default function App() { ]}, { name: 'tools', label: t('menu.tools'), items: [ { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, + { type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, @@ -2490,6 +2498,7 @@ export default function App() { case 'edit.bulkedit': openBulkEdit(selectedIds); break; case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; + case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; @@ -4083,6 +4092,21 @@ export default function App() { )} {catState.backend === 'flex' && FlexRadio} {catState.backend === 'icom' && Icom} + {statsTabOpen && ( + + {t('stats.tab')} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeStatsTab(); }} + > + + + + )} {qslTabOpen && ( QSL Manager @@ -4377,6 +4401,12 @@ export default function App() { setAwardsVersion((v) => v + 1)} /> + {statsTabOpen && ( + + + + )} + {contestTabEnabled && ( diff --git a/frontend/src/components/StatsPanel.tsx b/frontend/src/components/StatsPanel.tsx new file mode 100644 index 0000000..6040e46 --- /dev/null +++ b/frontend/src/components/StatsPanel.tsx @@ -0,0 +1,607 @@ +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[]; +}; + +// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break). +const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`); + +const CONTINENT_NAME: Record = { + EU: 'Europe', NA: 'North America', SA: 'South America', + AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica', +}; + +const nf = (n: number) => n.toLocaleString('en-US'); + +// ── Shell ──────────────────────────────────────────────────────────────────── + +function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) { + return ( +
+
+

{title}

+ {sub &&

{sub}

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

{label}

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

{value}

+ {sub &&

{sub}

} +
+ ); +} + +// ── Horizontal bars (long category names: modes, operators, entities) ───────── +// One series → one hue. The value is direct-labelled, so no reader ever depends +// on a tooltip to get a number. + +function HBars({ data, max, empty }: { data: Bucket[]; max?: number; empty: string }) { + const top = max ? data.slice(0, max) : data; + const peak = Math.max(1, ...top.map((d) => d.count)); + if (top.length === 0) return

{empty}

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

{empty}

; + // Thin the x labels once the bars get narrow. A label under EVERY one of 48 + // hourly bars is unreadable mush — the axis exists to orient, not to enumerate + // (the value is in the tooltip, and every number is in the table view). + const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12); + return ( + // The container includes the x-axis band — a fixed height that excludes it is + // how cards end up with a tiny nested scrollbar. +
+ {data.map((d, i) => ( +
+ + {nf(d.count)} + +
+ + {i % every === 0 ? d.key : ''} + +
+ ))} +
+ ); +} + +// ── Activity over time (single series → area, one hue) ──────────────────────── +// Only the PEAK and the LAST point are direct-labelled. A number on every point +// is chaos and goes unread. + +function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) { + const [hover, setHover] = useState(null); + if (data.length < 2) return

{empty}

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

{empty}

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

{title}

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

{t('stats.title')}

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

{t('stats.avgWindow')}

+

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

+
+
+

{t('stats.avgActive')}

+

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

+
+
+

{t('stats.best60')}

+

{nf(stats.best_60)}

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

{t('stats.activeHours')}

+

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

+
+
+ {stats.rate.length > 1 + ? + :

{t('stats.rateTooLong')}

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

{t('stats.noGaps')}

+ ) : ( +
    + {stats.gaps.map((g, i) => ( +
  • + + {g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)} + + + + + {dur(g.minutes)} +
  • + ))} +
+ )} +
+
+ )} + + {view === 'table' ? ( +
+ + + + + + + +
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 98b493d..8b2c641 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -46,6 +46,28 @@ const en: Dict = { 'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', 'lang.english': 'English', 'lang.french': 'Français', 'settings.language': 'Language', 'settings.languageHint': 'Interface language.', + 'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…', + 'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh', + 'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities', + 'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed', + 'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size', + 'stats.byMode': 'By mode', 'stats.byOperator': 'By operator', + 'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)', + 'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent', + 'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year', + 'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month', + 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL', + + 'stats.byContinentSub': 'Share of the log', + 'stats.pAll': 'All', 'stats.pYTD': 'This year', 'stats.p12m': '12 months', 'stats.p30d': '30 days', 'stats.pCustom': 'Custom', + 'stats.rate': 'Rate', 'stats.rateSub': 'QSOs per hour across the period — silences included', + 'stats.rateTooLong': 'Period too long for an hourly rate chart (max 31 days)', + 'stats.avgWindow': 'Avg / hour', 'stats.avgWindowTip': 'QSOs ÷ the WHOLE period, breaks included. The honest rate.', + 'stats.avgActive': 'Avg / hour on air', 'stats.avgActiveTip': 'QSOs ÷ the hours you actually operated. Flattering — quoting only this is how an 8-hour effort gets sold as a 48-hour score.', + '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 —', 'msg.expand': 'Click to read the full message', 'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically', 'offline.tip': '{n} QSO(s) waiting — the database is unreachable', @@ -265,6 +287,28 @@ const fr: Dict = { 'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.', 'lang.english': 'English', 'lang.french': 'Français', 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", + 'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…', + 'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir', + 'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités', + 'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés', + 'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille", + 'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur', + 'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)', + 'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent', + 'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année', + 'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois', + 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier', + + 'stats.byContinentSub': 'Part du journal', + 'stats.pAll': 'Tout', 'stats.pYTD': 'Cette année', 'stats.p12m': '12 mois', 'stats.p30d': '30 jours', 'stats.pCustom': 'Personnalisé', + 'stats.rate': 'Cadence', 'stats.rateSub': 'QSO par heure sur la période — silences compris', + 'stats.rateTooLong': 'Période trop longue pour une courbe horaire (max 31 jours)', + 'stats.avgWindow': 'Moy. / heure', 'stats.avgWindowTip': 'QSO ÷ la période ENTIÈRE, pauses comprises. La vraie cadence.', + 'stats.avgActive': 'Moy. / heure en l’air', 'stats.avgActiveTip': 'QSO ÷ les heures réellement opérées. Flatteur — ne citer que celle-ci, c’est vendre 8 h d’effort comme un score de 48 h.', + '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 —', 'msg.expand': 'Cliquer pour lire le message en entier', 'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique', 'offline.tip': '{n} QSO en attente — la base est injoignable', diff --git a/frontend/src/style.css b/frontend/src/style.css index 5503f93..5b3d2b9 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -495,6 +495,63 @@ color-scheme: dark; } +/* ── Data-viz palette (Statistics dashboard) ──────────────────────────────── + A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes + it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never + reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead. + The dark column is the same eight hues re-stepped for a dark surface, NOT an + automatic inversion. + + --chart-1 is the DEFAULT single-series hue: a chart with one series uses it for + every bar. Colour is for identity, never to re-encode a length the bar already + shows. + + --chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light + themes it runs light→dark; on dark themes dark→light, so "near zero" always + recedes toward the surface instead of glowing. */ +:root, +[data-theme="light-warm"], +[data-theme="light-cool"], +[data-theme="light-sage"] { + --chart-1: #2a78d6; /* blue — default single-series hue */ + --chart-2: #1baf7a; /* aqua */ + --chart-3: #eda100; /* yellow */ + --chart-4: #008300; /* green */ + --chart-5: #4a3aa7; /* violet */ + --chart-6: #e34948; /* red */ + --chart-7: #e87ba4; /* magenta */ + --chart-8: #eb6834; /* orange */ + + --chart-seq-1: #cde2fb; + --chart-seq-2: #9ec5f4; + --chart-seq-3: #6da7ec; + --chart-seq-4: #3987e5; + --chart-seq-5: #256abf; + --chart-seq-6: #104281; +} + +[data-theme="dim-slate"], +[data-theme="dark-warm"], +[data-theme="dark-graphite"], +[data-theme="high-contrast"] { + --chart-1: #3987e5; + --chart-2: #199e70; + --chart-3: #c98500; + --chart-4: #008300; + --chart-5: #9085e9; + --chart-6: #e66767; + --chart-7: #d55181; + --chart-8: #d95926; + + /* Reversed: the lowest step must sink toward the dark surface. */ + --chart-seq-1: #0d366b; + --chart-seq-2: #184f95; + --chart-seq-3: #256abf; + --chart-seq-4: #3987e5; + --chart-seq-5: #6da7ec; + --chart-seq-6: #9ec5f4; +} + /* Map Tailwind's color utilities onto the semantic vars above. `inline` makes the generated utilities reference var(--…) directly, so overriding a var in a [data-theme] block re-skins every utility at runtime. */ diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 6503012..e4e5e1f 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -294,6 +294,8 @@ export function GetClusterAutoConnect():Promise; export function GetClusterStatus():Promise>; +export function GetContestRuns():Promise>; + export function GetCtyDatInfo():Promise; export function GetDBBackendStatus():Promise; @@ -326,6 +328,8 @@ export function GetLoTWUsersStatus():Promise; export function GetLogFilePath():Promise; +export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise; + export function GetLogbookRevision():Promise; export function GetLookupSettings():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 862ce37..43528ab 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -546,6 +546,10 @@ export function GetClusterStatus() { return window['go']['main']['App']['GetClusterStatus'](); } +export function GetContestRuns() { + return window['go']['main']['App']['GetContestRuns'](); +} + export function GetCtyDatInfo() { return window['go']['main']['App']['GetCtyDatInfo'](); } @@ -610,6 +614,10 @@ export function GetLogFilePath() { return window['go']['main']['App']['GetLogFilePath'](); } +export function GetLogStats(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4); +} + export function GetLogbookRevision() { return window['go']['main']['App']['GetLogbookRevision'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1b226c1..54a9239 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2922,6 +2922,20 @@ export namespace qso { this.status = source["status"]; } } + export class Bucket { + key: string; + count: number; + + static createFrom(source: any = {}) { + return new Bucket(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.key = source["key"]; + this.count = source["count"]; + } + } export class Condition { field: string; op: string; @@ -2938,6 +2952,42 @@ export namespace qso { this.value = source["value"]; } } + export class ContestRun { + id: string; + year: number; + count: number; + start: string; + end: string; + + static createFrom(source: any = {}) { + return new ContestRun(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.year = source["year"]; + this.count = source["count"]; + this.start = source["start"]; + this.end = source["end"]; + } + } + export class Gap { + start: string; + end: string; + minutes: number; + + static createFrom(source: any = {}) { + return new Gap(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.start = source["start"]; + this.end = source["end"]; + this.minutes = source["minutes"]; + } + } export class ListFilter { callsign?: string; band?: string; @@ -3286,6 +3336,94 @@ export namespace qso { return a; } } + export class 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[]; + 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[]; + + static createFrom(source: any = {}) { + return new Stats(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.total = source["total"]; + this.unique_calls = source["unique_calls"]; + this.entities = source["entities"]; + this.continents = source["continents"]; + this.first_qso = source["first_qso"]; + this.last_qso = source["last_qso"]; + this.confirmed_lotw = source["confirmed_lotw"]; + this.confirmed_eqsl = source["confirmed_eqsl"]; + this.confirmed_qsl = source["confirmed_qsl"]; + this.confirmed_any = source["confirmed_any"]; + this.by_mode = this.convertValues(source["by_mode"], Bucket); + this.by_band = this.convertValues(source["by_band"], Bucket); + this.by_operator = this.convertValues(source["by_operator"], Bucket); + this.by_station = this.convertValues(source["by_station"], Bucket); + this.by_continent = this.convertValues(source["by_continent"], Bucket); + this.top_entities = this.convertValues(source["top_entities"], Bucket); + this.by_year = this.convertValues(source["by_year"], Bucket); + this.by_month = this.convertValues(source["by_month"], Bucket); + this.window_start = source["window_start"]; + this.window_end = source["window_end"]; + this.window_hours = source["window_hours"]; + this.avg_per_hour = source["avg_per_hour"]; + this.avg_per_active = source["avg_per_active"]; + this.on_air_minutes = source["on_air_minutes"]; + this.off_air_minutes = source["off_air_minutes"]; + this.peak_hour_key = source["peak_hour_key"]; + this.peak_hour_count = source["peak_hour_count"]; + this.best_60 = source["best_60"]; + this.gaps = this.convertValues(source["gaps"], Gap); + this.rate = this.convertValues(source["rate"], Bucket); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class WorkedBefore { callsign: string; count: number; diff --git a/internal/qso/stats.go b/internal/qso/stats.go new file mode 100644 index 0000000..47e5ff4 --- /dev/null +++ b/internal/qso/stats.go @@ -0,0 +1,580 @@ +package qso + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "time" +) + +// Statistics over the whole logbook. +// +// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP +// BYs. Two reasons: the date maths (year / month / hour of day) would need +// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL — +// which is exactly the kind of thing that silently works on one backend and +// breaks on the other; and a single pass over a few columns of a 30k-row log is +// a few tens of milliseconds, so the complexity buys nothing. + +// Bucket is one labelled count (mode, band, operator, entity…). +type Bucket struct { + Key string `json:"key"` + Count int `json:"count"` +} + +// Gap is a stretch with no QSO at all — the off-air periods. In a contest these +// are the expensive minutes: they are where the score went. +type Gap struct { + Start string `json:"start"` // RFC3339 — the last QSO before the silence + End string `json:"end"` // the first QSO after it + Minutes int `json:"minutes"` +} + +// ContestRun is one contest the operator actually took part in, discovered FROM +// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so +// the picker only ever offers contests you really entered, and never an empty one. +type ContestRun struct { + ID string `json:"id"` + Year int `json:"year"` + Count int `json:"count"` + Start string `json:"start"` // first QSO, RFC3339 + End string `json:"end"` // last QSO +} + +// ContestRuns lists every (contest, year) pair present in the logbook, most +// recent first. +func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`) + if err != nil { + return nil, err + } + defer rows.Close() + + type key struct { + id string + year int + } + agg := map[key]*ContestRun{} + for rows.Next() { + var id, dateStr sql.NullString + if err := rows.Scan(&id, &dateStr); err != nil { + return nil, err + } + cid := strings.ToUpper(strings.TrimSpace(id.String)) + if cid == "" { + continue + } + t := parseTimeLoose(dateStr.String).UTC() + if t.IsZero() { + continue + } + k := key{cid, t.Year()} + c, ok := agg[k] + if !ok { + c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)} + agg[k] = c + } + c.Count++ + if t.Format(time.RFC3339) < c.Start { + c.Start = t.Format(time.RFC3339) + } + if t.Format(time.RFC3339) > c.End { + c.End = t.Format(time.RFC3339) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]ContestRun, 0, len(agg)) + for _, c := range agg { + out = append(out, *c) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Year != out[j].Year { + return out[i].Year > out[j].Year // most recent first + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +// gapThreshold is the silence that counts as "off the air". Short enough to catch +// a real break, long enough not to flag the normal pause between two QSOs. +const gapThreshold = 30 * time.Minute + +// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a +// week is generous. This is a READABILITY limit, not a memory one: at 30 days the +// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it +// looks broken, which is exactly how it first shipped. Past this the UI says +// "period too long for an hourly chart" instead of drawing mush. +const rateMaxHours = 7 * 24 + +// Stats is the whole dashboard payload. +type Stats struct { + // Headline figures. + Total int `json:"total"` + UniqueCalls int `json:"unique_calls"` + Entities int `json:"entities"` // distinct DXCC entities + Continents int `json:"continents"` // distinct continents + FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty + LastQSO string `json:"last_qso"` + + // Confirmations (of Total). + ConfirmedLoTW int `json:"confirmed_lotw"` + ConfirmedEQSL int `json:"confirmed_eqsl"` + ConfirmedQSL int `json:"confirmed_qsl"` + ConfirmedAny int `json:"confirmed_any"` + + // Breakdowns, each sorted most → least (bands keep frequency order). + ByMode []Bucket `json:"by_mode"` + ByBand []Bucket `json:"by_band"` + ByOperator []Bucket `json:"by_operator"` + ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air) + ByContinent []Bucket `json:"by_continent"` + TopEntities []Bucket `json:"top_entities"` + ByYear []Bucket `json:"by_year"` // chronological + ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological + + // ── Period / contest metrics ── + // Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says + // nothing, but across a contest weekend it is the score. The window is the + // requested [from,to] when given, else the span of the log. + WindowStart string `json:"window_start"` + WindowEnd string `json:"window_end"` + WindowHours float64 `json:"window_hours"` + AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate) + AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours + + // On-air and off-air are a TIME BUDGET and must add up to the window: + // OnAirMinutes + OffAirMinutes == window + // The first version counted "clock hours containing at least one QSO" as on-air, + // so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that + // gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers + // measured on incompatible bases can't be compared, and the operator rightly + // didn't believe either of them. + OnAirMinutes int `json:"on_air_minutes"` + OffAirMinutes int `json:"off_air_minutes"` + + PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference) + PeakHourCount int `json:"peak_hour_count"` + 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 + Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH") +} + +// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically, +// so the band chart reads like a band plan instead of a jumble. +var bandOrder = map[string]int{ + "2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7, + "20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14, + "2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19, +} + +// yes reports whether an ADIF confirmation flag means "confirmed". +func yes(s string) bool { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "Y", "V": // V = verified (LoTW) + return true + } + return false +} + +// Stats scans the logbook once and returns every breakdown the dashboard needs, +// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is +// the whole log). +// +// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL +// WHERE. qso_date is a text column whose format differs between the two backends, +// so a string comparison would quietly select the wrong rows on one of them. We +// already parse every date in this pass; filtering on the parsed value is both +// correct and free. +// contestID (with an optional year, 0 = any) narrows the log to one contest. When +// it is set and no explicit window is given, the window becomes the contest's own +// span — so rate, best-hour and off-air figures are computed over the contest +// itself without the operator having to look its dates up. +func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) { + var s Stats + contestID = strings.ToUpper(strings.TrimSpace(contestID)) + + rows, err := r.db.QueryContext(ctx, ` + SELECT callsign, qso_date, band, mode, cont, country, dxcc, + operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id + FROM qso`) + if err != nil { + return s, err + } + defer rows.Close() + + var ( + calls = map[string]struct{}{} + entities = map[int]struct{}{} + modeC = map[string]int{} + bandC = map[string]int{} + opC = map[string]int{} + stationC = map[string]int{} + contC = map[string]int{} + entityC = map[string]int{} + yearC = map[string]int{} + monthC = map[string]int{} + times []time.Time // every dated QSO, for the rate / gap maths + first, last time.Time + ) + + for rows.Next() { + var ( + call, band, mode, cont, country sql.NullString + oper, station sql.NullString + lotw, eqsl, paper sql.NullString + dxcc sql.NullInt64 + dateStr, contestID2 sql.NullString + ) + if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc, + &oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil { + return s, err + } + + // Contest filter first — same reasoning as the window below: a QSO that + // isn't in this contest must not reach ANY bucket. + if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID { + continue + } + + // Window first: a QSO outside the period must not reach ANY bucket. Doing + // this after the counting (the obvious mistake) would leave the mode/band/ + // operator charts showing the whole log while only the trend was filtered. + // parseTimeLoose is the repo's existing convention for qso_date — it copes + // with what each backend hands back (SQLite ISO string, MySQL DATETIME). + t := parseTimeLoose(dateStr.String).UTC() + dated := !t.IsZero() + if year > 0 && (!dated || t.Year() != year) { + continue + } + if !from.IsZero() && (!dated || t.Before(from)) { + continue + } + if !to.IsZero() && (!dated || t.After(to)) { + continue + } + + s.Total++ + + if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" { + calls[c] = struct{}{} + } + if dxcc.Valid && dxcc.Int64 > 0 { + entities[int(dxcc.Int64)] = struct{}{} + } + if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" { + modeC[m]++ + } + if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" { + bandC[b]++ + } + // An empty OPERATOR means "the station owner logged it himself" — bucket + // it explicitly rather than dropping the QSO from the operator chart. + op := strings.ToUpper(strings.TrimSpace(oper.String)) + if op == "" { + op = "—" + } + opC[op]++ + if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" { + stationC[st]++ + } + if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" { + contC[c]++ + } + if c := strings.TrimSpace(country.String); c != "" { + entityC[c]++ + } + + cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String) + if cl { + s.ConfirmedLoTW++ + } + if el { + s.ConfirmedEQSL++ + } + if pl { + s.ConfirmedQSL++ + } + if cl || el || pl { + s.ConfirmedAny++ + } + + // An undated QSO still counts in the mode/band/operator totals above, but + // it can't be placed on a time axis — leave it out of the trend rather than + // parking it at year zero. + if !dated { + continue + } + if first.IsZero() || t.Before(first) { + first = t + } + if last.IsZero() || t.After(last) { + last = t + } + yearC[t.Format("2006")]++ + monthC[t.Format("2006-01")]++ + times = append(times, t) + } + if err := rows.Err(); err != nil { + return s, err + } + + s.UniqueCalls = len(calls) + s.Entities = len(entities) + s.Continents = len(contC) + if !first.IsZero() { + s.FirstQSO = first.UTC().Format(time.RFC3339) + s.LastQSO = last.UTC().Format(time.RFC3339) + } + + s.ByMode = topBuckets(modeC, 0) + s.ByOperator = topBuckets(opC, 0) + s.ByStation = topBuckets(stationC, 0) + s.ByContinent = topBuckets(contC, 0) + s.TopEntities = topBuckets(entityC, 15) + + // Bands read in band-plan order, not by count — the shape of the chart IS + // the band plan, and re-sorting it by size would destroy that. + s.ByBand = sortedBuckets(bandC, func(a, b string) bool { + oa, ob := bandOrder[a], bandOrder[b] + if oa == 0 { + oa = 99 + } + if ob == 0 { + ob = 99 + } + if oa != ob { + return oa < ob + } + return a < b + }) + // The time axis must be CONTINUOUS. Emitting only the months that have QSOs + // would place, say, 2012-08 next to 2022-01 as if they were consecutive — the + // chart would invent activity that never happened. A gap in the log is real + // information: it belongs on the chart as zeros. + s.ByYear = fillYears(yearC, first, last) + s.ByMonth = fillMonths(monthC, first, last) + + s.periodMetrics(times, from, to, first, last) + s.ensureNonNil() + + return s, nil +} + +// ensureNonNil replaces every nil slice with an empty one. +// +// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the +// UI then calls .length / .map on null — a TypeError that unmounts the whole React +// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest +// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart +// (Rate nil). The awards code carries the same guard for the same reason. +func (s *Stats) ensureNonNil() { + if s.ByMode == nil { + s.ByMode = []Bucket{} + } + if s.ByBand == nil { + s.ByBand = []Bucket{} + } + if s.ByOperator == nil { + s.ByOperator = []Bucket{} + } + if s.ByStation == nil { + s.ByStation = []Bucket{} + } + if s.ByContinent == nil { + s.ByContinent = []Bucket{} + } + if s.TopEntities == nil { + s.TopEntities = []Bucket{} + } + if s.ByYear == nil { + s.ByYear = []Bucket{} + } + if s.ByMonth == nil { + s.ByMonth = []Bucket{} + } + if s.Rate == nil { + s.Rate = []Bucket{} + } + if s.Gaps == nil { + s.Gaps = []Gap{} + } +} + +// periodMetrics derives the rate / off-air figures that make a contest window +// readable. The window is the caller's [from,to] when given, else the span of the +// log itself. +// +// Two rates are reported on purpose, because a single one always flatters: +// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number. +// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you +// 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. +func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time) { + if len(times) == 0 { + return + } + sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) }) + + winStart, winEnd := from, to + if winStart.IsZero() { + winStart = first + } + if winEnd.IsZero() { + winEnd = last + } + if !winEnd.After(winStart) { + return + } + s.WindowStart = winStart.Format(time.RFC3339) + s.WindowEnd = winEnd.Format(time.RFC3339) + s.WindowHours = winEnd.Sub(winStart).Hours() + if s.WindowHours > 0 { + s.AvgPerHour = float64(len(times)) / s.WindowHours + } + + // 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. + hourly := map[string]int{} + for _, t := range times { + hourly[t.Format("2006-01-02 15")]++ + } + for k, v := range hourly { + if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) { + s.PeakHourKey, s.PeakHourCount = k, v + } + } + + // Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45– + // 14:45 is invisible to clock-hour bucketing, and it's the figure contesters + // actually quote. Two pointers over the sorted times: O(n). + lo := 0 + for hi := range times { + for times[hi].Sub(times[lo]) >= time.Hour { + lo++ + } + if n := hi - lo + 1; n > s.Best60 { + s.Best60 = n + } + } + + // Off-air is a TIME BUDGET, and it has to close on the window: + // OnAirMinutes + OffAirMinutes == window + // So every silence ≥ 30 min counts — including the lead-in before the first QSO + // and the tail after the last, when an explicit window was asked for. Skipping + // those (the first version did) makes "on air" and "off air" sum to more than + // the window, and then neither number is believable. + addGap := func(a, b time.Time) { + d := b.Sub(a) + if d < gapThreshold { + return + } + s.OffAirMinutes += int(d.Minutes()) + s.Gaps = append(s.Gaps, Gap{ + Start: a.Format(time.RFC3339), + End: b.Format(time.RFC3339), + Minutes: int(d.Minutes()), + }) + } + addGap(winStart, times[0]) // lead-in + for i := 1; i < len(times); i++ { // the silences between QSOs + addGap(times[i-1], times[i]) + } + addGap(times[len(times)-1], winEnd) // tail + + s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes + if s.OnAirMinutes < 0 { + s.OnAirMinutes = 0 + } + if s.OnAirMinutes > 0 { + s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60) + } + sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes }) + if len(s.Gaps) > 10 { + 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 + // window, zeros included, so the silences are visible as silences. + if s.WindowHours <= rateMaxHours { + cur := winStart.Truncate(time.Hour) + end := winEnd.Truncate(time.Hour) + for !cur.After(end) { + s.Rate = append(s.Rate, Bucket{ + Key: cur.Format("01-02 15"), + Count: hourly[cur.Format("2006-01-02 15")], + }) + cur = cur.Add(time.Hour) + } + } +} + +// topBuckets sorts a count map most → least (ties alphabetical) and optionally +// keeps only the top n. +func topBuckets(m map[string]int, n int) []Bucket { + out := make([]Bucket, 0, len(m)) + for k, v := range m { + out = append(out, Bucket{Key: k, Count: v}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Key < out[j].Key + }) + if n > 0 && len(out) > n { + out = out[:n] + } + return out +} + +// sortedBuckets keeps a caller-defined key order (band plan, chronology). +func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) }) + out := make([]Bucket, 0, len(keys)) + for _, k := range keys { + out = append(out, Bucket{Key: k, Count: m[k]}) + } + return out +} + + +// fillMonths emits EVERY month between the first and last QSO — zeros included — +// so the trend line's x-axis is real time rather than "months that happen to have +// data". A quiet decade must read as a decade at zero, not vanish. +func fillMonths(m map[string]int, first, last time.Time) []Bucket { + if first.IsZero() { + return nil + } + var out []Bucket + cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC) + end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC) + for !cur.After(end) { + k := cur.Format("2006-01") + out = append(out, Bucket{Key: k, Count: m[k]}) + cur = cur.AddDate(0, 1, 0) + } + return out +} + +// fillYears does the same for the yearly view. +func fillYears(m map[string]int, first, last time.Time) []Bucket { + if first.IsZero() { + return nil + } + var out []Bucket + for y := first.Year(); y <= last.Year(); y++ { + k := fmt.Sprintf("%04d", y) + out = append(out, Bucket{Key: k, Count: m[k]}) + } + return out +} diff --git a/internal/qso/stats_test.go b/internal/qso/stats_test.go new file mode 100644 index 0000000..bc99fb3 --- /dev/null +++ b/internal/qso/stats_test.go @@ -0,0 +1,183 @@ +package qso + +import ( + "testing" + "time" +) + +// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never +// alphabetically — the order of that chart IS the information. +func TestBandPlanOrder(t *testing.T) { + counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332} + got := sortedBuckets(counts, func(a, b string) bool { + oa, ob := bandOrder[a], bandOrder[b] + if oa == 0 { + oa = 99 + } + if ob == 0 { + ob = 99 + } + if oa != ob { + return oa < ob + } + return a < b + }) + want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"} + if len(got) != len(want) { + t.Fatalf("got %d buckets, want %d", len(got), len(want)) + } + for i := range want { + if got[i].Key != want[i] { + t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got) + } + } +} + +// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls +// .length/.map on null, which unmounts the whole React tree and leaves a WHITE +// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps +// nil), or a window too long for the hourly chart (Rate nil). Every slice the +// dashboard reads must therefore come back non-nil, even when empty. +func TestStatsNoNilSlices(t *testing.T) { + var s Stats // the worst case: nothing computed at all + s.ensureNonNil() + + checks := map[string]bool{ + "ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil, + "ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil, + "TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil, + "Rate": s.Rate == nil, "Gaps": s.Gaps == nil, + } + for name, isNil := range checks { + if isNil { + t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name) + } + } + + // 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. + 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)} + var s2 Stats + s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute)) + s2.ensureNonNil() + if s2.Gaps == nil { + t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened") + } + if len(s2.Gaps) != 0 { + t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps) + } +} + +// Contest metrics over a window. The two traps: +// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour — +// a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the +// rolling figure is the one contesters quote. +// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included) +// AND QSOs ÷ hours actually operated. Quoting only the latter is how an +// 8-hour effort gets sold as a 48-hour score. +func TestContestPeriodMetrics(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) } + + // 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. + var times []time.Time + for i := 0; i < 10; i++ { + times = append(times, at(40+i*4)) // 12:40 … 13:16 + } + for i := 0; i < 3; i++ { + times = append(times, at(240+i*5)) // 16:00 … + } + + from := base // 12:00 + to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window + var s Stats + s.periodMetrics(times, from, to, time.Time{}, time.Time{}) + + if s.WindowHours != 6 { + t.Errorf("window = %.1f h, want 6", s.WindowHours) + } + // 13 QSOs over a 6 h window. + if got := s.AvgPerHour; got < 2.16 || got > 2.17 { + t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got) + } + // The rolling hour must find the straddling run of 10 — a clock-hour bucket + // would only ever see part of it. + if s.Best60 != 10 { + t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60) + } + if s.PeakHourCount >= 10 { + t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount) + } + // THE INVARIANT: on-air + off-air must close on the window. The first version + // counted "clock hours containing a QSO" as on-air, which on a real 45 h contest + // reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on + // incompatible bases; the operator believed neither, and was right. + if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) { + t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close", + s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60)) + } + // Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) + + // the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit: + // ignoring the lead-in and tail is what broke the budget. + if s.OffAirMinutes != 40+164+110 { + t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110) + } + if len(s.Gaps) != 3 { + t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps) + } + if s.AvgPerActive <= s.AvgPerHour { + t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour) + } + // The rate timeline covers EVERY hour of the window, silences as zeros. + if len(s.Rate) != 7 { // 12,13,14,15,16,17,18 + t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate)) + } + if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 { + t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3]) + } +} + +// 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 +// would invent activity that never happened. +func TestTimeAxisIsContinuous(t *testing.T) { + first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC) + last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC) + + years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last) + if len(years) != 18 { // 2009..2026 inclusive + t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years)) + } + byKey := map[string]int{} + for _, b := range years { + byKey[b.Key] = b.Count + } + if byKey["2010"] != 0 || byKey["2018"] != 0 { + t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"]) + } + if byKey["2012"] != 1187 || byKey["2026"] != 14415 { + t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"]) + } + + months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last) + // May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months. + if len(months) != 207 { + t.Fatalf("months = %d, want 207 (continuous)", len(months)) + } + if months[0].Key != "2009-05" || months[0].Count != 49 { + t.Errorf("first month = %+v, want 2009-05 / 49", months[0]) + } + if months[len(months)-1].Key != "2026-07" { + t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key) + } + // Every step is exactly one month — no jumps. + for i := 1; i < len(months); i++ { + prev, _ := time.Parse("2006-01", months[i-1].Key) + cur, _ := time.Parse("2006-01", months[i].Key) + if !prev.AddDate(0, 1, 0).Equal(cur) { + t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key) + } + } +}