From 19c91f32a010ab31376a7b53655f10215f00a30c Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 20 Jul 2026 21:20:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20stats=20=E2=80=94=20per-band=20mode=20s?= =?UTF-8?q?plit=20(CW=20/=20phone=20/=20data)=20stacked=20bar=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Stats.ByBandCategory (per band: CW/phone/data counts, band-plan order) via a modeCategory() bucketing in stats.go, and a StackedBandBars chart in the Stats panel showing the split per band with a legend. --- frontend/src/components/StatsPanel.tsx | 49 ++++++++++++++++++++++++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/models.ts | 22 +++++++++++ internal/qso/stats.go | 51 +++++++++++++++++++++++++- 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/StatsPanel.tsx b/frontend/src/components/StatsPanel.tsx index ab3529d..6f52adb 100644 --- a/frontend/src/components/StatsPanel.tsx +++ b/frontend/src/components/StatsPanel.tsx @@ -23,13 +23,14 @@ import { cn } from '@/lib/utils'; // ───────────────────────────────────────────────────────────────────────────── type Bucket = { key: string; count: number }; +type BandCat = { band: string; cw: number; phone: number; data: number; total: 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_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; 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; @@ -157,6 +158,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck ); } +// Per-band mode split (CW / phone / data) as stacked vertical bars. The three +// categories are the subject → categorical colour in a fixed order, matching the +// mode colours used elsewhere (CW gold, phone green, data blue). +const BAND_SPLIT = [ + { key: 'cw', label: 'CW', color: 'var(--chart-3)' }, + { key: 'phone', label: 'Phone', color: 'var(--chart-2)' }, + { key: 'data', label: 'Data', color: 'var(--chart-1)' }, +] as const; +function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) { + if (!data || data.length === 0) return

{empty}

; + const peak = Math.max(1, ...data.map((d) => d.total)); + return ( +
+
+ {data.map((d) => ( +
+ {nf(d.total)} +
+ {BAND_SPLIT.map((s) => { + const v = d[s.key]; + if (!v) return null; + return
; + })} +
+ {d.band} +
+ ))} +
+
+ {BAND_SPLIT.map((s) => ( + + {s.label} + + ))} +
+
+ ); +} + // ── 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 @@ -524,7 +566,7 @@ export function StatsPanel() { 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_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), 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), @@ -740,6 +782,9 @@ export function StatsPanel() { + + + diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 675f8e8..1965706 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -60,7 +60,7 @@ const en: Dict = { '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.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size', 'stats.bandSplit': 'Mode split by band', 'stats.bandSplitSub': 'CW / phone / data per band', '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', @@ -374,7 +374,7 @@ const fr: Dict = { '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.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille", 'stats.bandSplit': 'Répartition des modes par bande', 'stats.bandSplitSub': 'CW / phone / data par bande', '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', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e2bf92e..3c8dd8e 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3411,6 +3411,26 @@ export namespace qslcard { export namespace qso { + export class BandCategory { + band: string; + cw: number; + phone: number; + data: number; + total: number; + + static createFrom(source: any = {}) { + return new BandCategory(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.band = source["band"]; + this.cw = source["cw"]; + this.phone = source["phone"]; + this.data = source["data"]; + this.total = source["total"]; + } + } export class BandMode { band: string; mode: string; @@ -3900,6 +3920,7 @@ export namespace qso { confirmed_any: number; by_mode: Bucket[]; by_band: Bucket[]; + by_band_category: BandCategory[]; by_operator: Bucket[]; by_station: Bucket[]; by_continent: Bucket[]; @@ -3939,6 +3960,7 @@ export namespace qso { 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_band_category = this.convertValues(source["by_band_category"], BandCategory); 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); diff --git a/internal/qso/stats.go b/internal/qso/stats.go index 7931ecb..f3078fe 100644 --- a/internal/qso/stats.go +++ b/internal/qso/stats.go @@ -24,6 +24,30 @@ type Bucket struct { Count int `json:"count"` } +// BandCategory is one band's QSO count split by mode category (CW / phone / +// digital), for the per-band mode-split chart. +type BandCategory struct { + Band string `json:"band"` + CW int `json:"cw"` + Phone int `json:"phone"` + Data int `json:"data"` + Total int `json:"total"` +} + +// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes +// (SSB and the digital-voice family) count as phone; CW is CW; everything else is +// data. Mirrors the frontend's mode colouring. +func modeCategory(mode string) string { + switch strings.ToUpper(strings.TrimSpace(mode)) { + case "CW": + return "cw" + case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION": + return "phone" + default: + return "data" + } +} + // 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 { @@ -129,8 +153,9 @@ type Stats struct { ConfirmedAny int `json:"confirmed_any"` // Breakdowns, each sorted most → least (bands keep frequency order). - ByMode []Bucket `json:"by_mode"` - ByBand []Bucket `json:"by_band"` + ByMode []Bucket `json:"by_mode"` + ByBand []Bucket `json:"by_band"` + ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split ByOperator []Bucket `json:"by_operator"` ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air) ByContinent []Bucket `json:"by_continent"` @@ -268,6 +293,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, entities = map[int]struct{}{} modeC = map[string]int{} bandC = map[string]int{} + bandCat = map[string]*BandCategory{} // per band: cw/phone/data opC = map[string]int{} stationC = map[string]int{} contC = map[string]int{} @@ -338,6 +364,20 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, } if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" { bandC[b]++ + bc := bandCat[b] + if bc == nil { + bc = &BandCategory{Band: b} + bandCat[b] = bc + } + switch modeCategory(mode.String) { + case "cw": + bc.CW++ + case "phone": + bc.Phone++ + default: + bc.Data++ + } + bc.Total++ } // op was resolved above (with the operator filter applied). opC[op]++ @@ -414,6 +454,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, } return a < b }) + // Per-band CW/phone/data split, in the SAME band-plan order as ByBand. + s.ByBandCategory = []BandCategory{} + for _, b := range s.ByBand { + if bc := bandCat[b.Key]; bc != nil { + s.ByBandCategory = append(s.ByBandCategory, *bc) + } + } // 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