diff --git a/frontend/src/components/StatsPanel.tsx b/frontend/src/components/StatsPanel.tsx index 6f52adb..9ecc6ab 100644 --- a/frontend/src/components/StatsPanel.tsx +++ b/frontend/src/components/StatsPanel.tsx @@ -32,6 +32,7 @@ type Stats = { confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number; 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[]; + by_hour: Bucket[]; by_dow: Bucket[]; by_dom: Bucket[]; by_month_yr: Bucket[]; // Period / contest metrics. window_start: string; window_end: string; window_hours: number; avg_per_hour: number; avg_per_active: number; @@ -504,6 +505,38 @@ function periodRange(p: Period, from: string, to: string): [string, string] { } } +// ActivityCard — the "activity over time" chart with a granularity selector. Its +// own state, module-scoped so a parent re-render doesn't reset the choice. The +// timeline is the chronological month series; day/week/month/year are the cyclical +// distributions (hour-of-day, day-of-week, day-of-month, month-of-year). +const ACT_GRAN = [ + { key: 'timeline', tkey: 'stats.granTimeline' }, + { key: 'day', tkey: 'stats.granDay' }, + { key: 'week', tkey: 'stats.granWeek' }, + { key: 'month', tkey: 'stats.granMonth' }, + { key: 'year', tkey: 'stats.granYear' }, +] as const; +function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) { + const [g, setG] = useState('timeline'); + const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_dow : g === 'month' ? stats.by_dom : g === 'year' ? stats.by_month_yr : []; + return ( + +
+ {ACT_GRAN.map((o) => ( + + ))} +
+ {g === 'timeline' + ? + : } +
+ ); +} + // ── Table view (the WCAG-clean twin) ───────────────────────────────────────── function BucketTable({ title, data }: { title: string; data: Bucket[] }) { @@ -569,6 +602,7 @@ export function StatsPanel() { 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), + by_hour: arr(raw.by_hour), by_dow: arr(raw.by_dow), by_dom: arr(raw.by_dom), by_month_yr: arr(raw.by_month_yr), rate: arr(raw.rate), gaps: arr(raw.gaps), rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op), } as Stats); @@ -786,9 +820,7 @@ export function StatsPanel() { - - - + {/* 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 diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 1965706..8093208 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -65,7 +65,7 @@ const en: Dict = { '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.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or by hour / weekday / day / month', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year', 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL', 'stats.byContinentSub': 'Share of the log', @@ -379,7 +379,7 @@ const fr: Dict = { '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.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou par heure / jour sem. / jour / mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année', 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier', 'stats.byContinentSub': 'Part du journal', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3c8dd8e..51dd840 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3927,6 +3927,10 @@ export namespace qso { top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[]; + by_hour: Bucket[]; + by_dow: Bucket[]; + by_dom: Bucket[]; + by_month_yr: Bucket[]; window_start: string; window_end: string; window_hours: number; @@ -3967,6 +3971,10 @@ export namespace qso { 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.by_hour = this.convertValues(source["by_hour"], Bucket); + this.by_dow = this.convertValues(source["by_dow"], Bucket); + this.by_dom = this.convertValues(source["by_dom"], Bucket); + this.by_month_yr = this.convertValues(source["by_month_yr"], Bucket); this.window_start = source["window_start"]; this.window_end = source["window_end"]; this.window_hours = source["window_hours"]; diff --git a/internal/qso/stats.go b/internal/qso/stats.go index f3078fe..c252ba1 100644 --- a/internal/qso/stats.go +++ b/internal/qso/stats.go @@ -163,6 +163,13 @@ type Stats struct { ByYear []Bucket `json:"by_year"` // chronological ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological + // Cyclical activity distributions (UTC), for the "activity over time" chart's + // day/week/month/year granularity selector. + ByHour []Bucket `json:"by_hour"` // hour of day 00..23 + ByDOW []Bucket `json:"by_dow"` // day of week Mon..Sun + ByDOM []Bucket `json:"by_dom"` // day of month 1..31 + ByMonthYr []Bucket `json:"by_month_yr"` // month Jan..Dec + // ── 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 @@ -469,11 +476,43 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, s.ByMonth = fillMonths(monthC, first, last) s.periodMetrics(times, from, to, first, last) + s.timeDistributions(times) s.ensureNonNil() return s, nil } +// timeDistributions builds the cyclical activity buckets (hour-of-day, day-of-week, +// day-of-month, month-of-year) from the dated QSOs, in UTC. Each is a fixed-length +// series so the chart axis is stable even for empty slots. +func (s *Stats) timeDistributions(times []entry) { + hour := make([]int, 24) + dow := make([]int, 7) // 0 = Monday + dom := make([]int, 31) + moy := make([]int, 12) + for _, e := range times { + t := e.t.UTC() + hour[t.Hour()]++ + dow[(int(t.Weekday())+6)%7]++ // shift Sunday(0) → 6 so Monday is first + if d := t.Day(); d >= 1 && d <= 31 { + dom[d-1]++ + } + moy[int(t.Month())-1]++ + } + for h := 0; h < 24; h++ { + s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02d", h), Count: hour[h]}) + } + for i, n := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} { + s.ByDOW = append(s.ByDOW, Bucket{Key: n, Count: dow[i]}) + } + for d := 1; d <= 31; d++ { + s.ByDOM = append(s.ByDOM, Bucket{Key: fmt.Sprintf("%d", d), Count: dom[d-1]}) + } + for i, n := range []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} { + s.ByMonthYr = append(s.ByMonthYr, Bucket{Key: n, Count: moy[i]}) + } +} + // ensureNonNil replaces every nil slice with an empty one. // // This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the