feat: QSO rate meter (10/60 min) in the header

Opt-in via Settings→General (portable pref opslog.showQsoRate). Shows the
contest-style QSO rate in QSOs/hour, projected from the trailing 10-minute
(count ×6) and 60-minute windows, between the widget icons and propagation.

Backend: qso.RecentRate counts QSOs whose start time falls in each trailing
window, scanning only the last 400 rows (cheap on a large log); App.GetQSORate
exposes the 10/60-min counts. Frontend refreshes on qso:logged and a 30s tick.

The meter shares the propagation grid cell — the header is a fixed 6-column
grid, so adding it as its own child pushed profile/band-map/compact onto a
second row. i18n EN + FR.
This commit is contained in:
2026-07-18 21:51:51 +02:00
parent d30b305ff2
commit 9d4ccb9254
6 changed files with 104 additions and 1 deletions
+21
View File
@@ -4501,6 +4501,27 @@ func (a *App) GetOperators() ([]string, error) {
return a.qso.Operators(a.ctx)
}
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
// logged in the trailing 10 and 60 minutes.
type QSORate struct {
Last10 int `json:"last10"`
Last60 int `json:"last60"`
}
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes.
// Cheap (scans only the most recent rows); polled by the header and refreshed on
// each qso:logged event.
func (a *App) GetQSORate() QSORate {
if a.qso == nil {
return QSORate{}
}
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute)
if err != nil || len(counts) < 2 {
return QSORate{}
}
return QSORate{Last10: counts[0], Last60: counts[1]}
}
// 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) {