feat: QSO rate meter — per-operator AND team totals

GetQSORate now returns both the active operator's rate (their own performance)
and the whole station's rate (all operators combined). RecentRateBreakdown
computes both in one scan of the recent rows (per-operator + all-operators),
replacing RecentRate.
This commit is contained in:
2026-07-20 01:08:17 +02:00
parent d327db3f57
commit 8eb82d6cdb
3 changed files with 34 additions and 30 deletions
+11 -11
View File
@@ -4518,31 +4518,31 @@ func (a *App) GetOperators() ([]string, error) {
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were // QSORate is the live QSO-rate meter shown in the header: how many QSOs were
// logged in the trailing 10 and 60 minutes. // logged in the trailing 10 and 60 minutes.
type QSORate struct { type QSORate struct {
Last10 int `json:"last10"` Last10 int `json:"last10"` // active operator, last 10 min
Last60 int `json:"last60"` Last60 int `json:"last60"` // active operator, last 60 min
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
} }
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes. // GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// Cheap (scans only the most recent rows); polled by the header and refreshed on // for the active operator (their own performance) AND for all operators combined
// each qso:logged event. // (the team/station rate). Cheap (one scan of the most recent rows); polled by the
// header and refreshed on each qso:logged event.
func (a *App) GetQSORate() QSORate { func (a *App) GetQSORate() QSORate {
if a.qso == nil { if a.qso == nil {
return QSORate{} return QSORate{}
} }
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
// so each op sees their own performance, not the cumulative station rate. An
// empty operator (single-op / station owner) matches all their QSOs.
operator := "" operator := ""
if a.profiles != nil { if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil { if p, err := a.profiles.Active(a.ctx); err == nil {
operator = p.Operator operator = p.Operator
} }
} }
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(counts) < 2 { if err != nil || len(op) < 2 || len(all) < 2 {
return QSORate{} return QSORate{}
} }
return QSORate{Last10: counts[0], Last60: counts[1]} return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
} }
// GetContestRuns lists the (contest, year) pairs actually present in the log, so // GetContestRuns lists the (contest, year) pairs actually present in the log, so
+4
View File
@@ -2407,6 +2407,8 @@ export namespace main {
export class QSORate { export class QSORate {
last10: number; last10: number;
last60: number; last60: number;
team_last10: number;
team_last60: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSORate(source); return new QSORate(source);
@@ -2416,6 +2418,8 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.last10 = source["last10"]; this.last10 = source["last10"];
this.last60 = source["last60"]; this.last60 = source["last60"];
this.team_last10 = source["team_last10"];
this.team_last60 = source["team_last60"];
} }
} }
export class RelayAutoRule { export class RelayAutoRule {
+19 -19
View File
@@ -1921,21 +1921,20 @@ func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, boo
return time.Time{}, false return time.Time{}, false
} }
// RecentRate counts QSOs whose start time falls within each trailing window from // RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty // start time falls within each trailing window from `now` — for a specific operator
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op // (their own rate, `op`) AND for ALL operators combined (the team/station rate,
// sees their OWN performance, not the cumulative rate; empty operator matches every // `all`). The header rate meter shows both. It scans only recently inserted rows
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since // (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large // it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format // format agnostic).
// agnostic). func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) { op = make([]int, len(windows))
counts := make([]int, len(windows)) all = make([]int, len(windows))
// 2000 rows covers a full hour for one operator even in a busy multi-op run // 2000 rows covers a full hour even in a busy multi-op run.
// (other operators' rows are discarded before counting).
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`) rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil { if err != nil {
return counts, err return op, all, err
} }
defer rows.Close() defer rows.Close()
now = now.UTC() now = now.UTC()
@@ -1943,23 +1942,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w
for rows.Next() { for rows.Next() {
var oper, dateStr sql.NullString var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil { if err := rows.Scan(&oper, &dateStr); err != nil {
return counts, err return op, all, err
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue // a different operator's QSO — not part of my rate
} }
t := parseTimeLoose(dateStr.String).UTC() t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) { if t.IsZero() || t.After(now) {
continue continue
} }
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
age := now.Sub(t) age := now.Sub(t)
for i, w := range windows { for i, w := range windows {
if age <= w { if age <= w {
counts[i]++ all[i]++
if mine {
op[i]++
}
} }
} }
} }
return counts, rows.Err() return op, all, rows.Err()
} }
// ExistingDedupeKeys returns a set of every QSO key currently in the DB, // ExistingDedupeKeys returns a set of every QSO key currently in the DB,