diff --git a/app.go b/app.go index 6b5cf21..2b0d23d 100644 --- a/app.go +++ b/app.go @@ -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 // logged in the trailing 10 and 60 minutes. type QSORate struct { - Last10 int `json:"last10"` - Last60 int `json:"last60"` + Last10 int `json:"last10"` // active operator, last 10 min + 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. -// Cheap (scans only the most recent rows); polled by the header and refreshed on -// each qso:logged event. +// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both +// for the active operator (their own performance) AND for all operators combined +// (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 { if a.qso == nil { 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 := "" if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { operator = p.Operator } } - counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) - if err != nil || len(counts) < 2 { + op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) + if err != nil || len(op) < 2 || len(all) < 2 { 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 diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e1e1be4..7cbd68c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2407,6 +2407,8 @@ export namespace main { export class QSORate { last10: number; last60: number; + team_last10: number; + team_last60: number; static createFrom(source: any = {}) { return new QSORate(source); @@ -2416,6 +2418,8 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.last10 = source["last10"]; this.last60 = source["last60"]; + this.team_last10 = source["team_last10"]; + this.team_last60 = source["team_last60"]; } } export class RelayAutoRule { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 5e9ed19..0e4a919 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1921,21 +1921,20 @@ func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, boo return time.Time{}, false } -// RecentRate counts QSOs whose start time falls within each trailing window from -// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty -// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op -// sees their OWN performance, not the cumulative rate; empty operator matches every -// QSO. 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, operator string, windows ...time.Duration) ([]int, error) { - counts := make([]int, len(windows)) - // 2000 rows covers a full hour for one operator even in a busy multi-op run - // (other operators' rows are discarded before counting). +// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose +// start time falls within each trailing window from `now` — for a specific operator +// (their own rate, `op`) AND for ALL operators combined (the team/station rate, +// `all`). The header rate meter shows both. It scans only recently inserted rows +// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so +// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend- +// format agnostic). +func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) { + op = make([]int, len(windows)) + all = make([]int, len(windows)) + // 2000 rows covers a full hour even in a busy multi-op run. rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`) if err != nil { - return counts, err + return op, all, err } defer rows.Close() now = now.UTC() @@ -1943,23 +1942,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w for rows.Next() { var oper, dateStr sql.NullString if err := rows.Scan(&oper, &dateStr); err != nil { - return counts, err - } - if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter { - continue // a different operator's QSO — not part of my rate + return op, all, err } t := parseTimeLoose(dateStr.String).UTC() if t.IsZero() || t.After(now) { continue } + mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter age := now.Sub(t) for i, w := range windows { 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,