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
+34
View File
@@ -1863,6 +1863,40 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
return n, err
}
// RecentRate counts QSOs whose start time falls within each trailing window from
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
counts := make([]int, len(windows))
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
// QSO inside the trailing windows is among the most recently inserted.
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
if err != nil {
return counts, err
}
defer rows.Close()
now = now.UTC()
for rows.Next() {
var dateStr sql.NullString
if err := rows.Scan(&dateStr); err != nil {
return counts, err
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) {
continue
}
age := now.Sub(t)
for i, w := range windows {
if age <= w {
counts[i]++
}
}
}
return counts, rows.Err()
}
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
// used by the ADIF importer to skip records that would re-create the
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute