diff --git a/app.go b/app.go index 801b9b8..6b5cf21 100644 --- a/app.go +++ b/app.go @@ -505,6 +505,7 @@ type App struct { liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) liveBand string liveMode string + liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline awardSnapMu sync.Mutex // guards the award QSO snapshot awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations awardSnapRev string // logbook revision the snapshot was built at ("" = none) @@ -1871,6 +1872,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { if err == nil { q.ID = id a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh + a.noteLiveQSO() // multi-op: flip this operator back "online" // Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT). wruntime.EventsEmit(a.ctx, "qso:logged", id) a.saveQSORecording(&q) @@ -9287,6 +9289,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { return 0, fmt.Errorf("insert qso: %w", err) } q.ID = id + a.noteLiveQSO() // multi-op: flip this operator back "online" a.saveQSORecording(&q) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 0c8936d..b4d1bef 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1895,6 +1895,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) { return n, err } +// LastQSOTime returns the start time of the most recently LOGGED QSO for an +// operator (highest id wins) — used to seed the live "on air" state at launch so an +// operator who just worked someone before (re)starting OpsLog shows online right +// away instead of waiting for their next QSO. Empty operator matches every QSO. +func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) { + rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`) + if err != nil { + return time.Time{}, false + } + defer rows.Close() + opFilter := strings.ToUpper(strings.TrimSpace(operator)) + for rows.Next() { + var oper, dateStr sql.NullString + if err := rows.Scan(&oper, &dateStr); err != nil { + return time.Time{}, false + } + if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter { + continue + } + if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() { + return t, true + } + } + 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 diff --git a/livestatus.go b/livestatus.go index dc512be..b7314a0 100644 --- a/livestatus.go +++ b/livestatus.go @@ -19,6 +19,23 @@ import ( const keyLiveStatusEnabled = "livestatus.enabled" +// liveOnlineWindow is how long after the last logged contact an operator still +// counts as "on air". Leaving the log open without working anyone flips them +// offline once this elapses; logging a new QSO flips them back online. +const liveOnlineWindow = 5 * time.Minute + +// noteLiveQSO records that this operator just logged a new contact and pushes the +// live status right away, so they flip back to online the instant they work +// someone. Called from the logging paths (manual entry, UDP auto-log). +func (a *App) noteLiveQSO() { + a.liveActMu.Lock() + a.liveLastQSOAt = time.Now() + a.liveActMu.Unlock() + if a.liveStatusActive() { + go a.publishLiveStatus() + } +} + // GetLiveStatusEnabled reports whether this operator publishes live status. func (a *App) GetLiveStatusEnabled() bool { if a.settings == nil { @@ -50,11 +67,44 @@ func (a *App) SetLiveStatusEnabled(on bool) error { return nil } +// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who +// worked someone shortly before (re)starting OpsLog is shown "on air" right away +// instead of offline until their next QSO. +func (a *App) seedLiveLastQSO() { + if a.qso == nil { + return + } + op, _ := a.liveStatusOperator() + if op == "" { + return + } + if t, ok := a.qso.LastQSOTime(a.ctx, op); ok { + a.liveActMu.Lock() + if a.liveLastQSOAt.IsZero() { + a.liveLastQSOAt = t + } + a.liveActMu.Unlock() + } +} + +// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if +// none is known — the UI uses it to seed the "on air" badge at launch. +func (a *App) LiveLastQSOAgeSec() int { + a.liveActMu.Lock() + last := a.liveLastQSOAt + a.liveActMu.Unlock() + if last.IsZero() { + return -1 + } + return int(time.Since(last).Seconds()) +} + // liveStatusLoop heartbeats the current activity while enabled. Started once at // startup; cheap no-op when disabled or not on MySQL. func (a *App) liveStatusLoop() { defer func() { _ = recover() }() // never crash the app from here applog.Printf("livestatus: loop started") + a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO a.publishLiveStatus() // attempt immediately, don't wait the first tick t := time.NewTicker(15 * time.Second) defer t.Stop() @@ -132,34 +182,62 @@ func (a *App) publishLiveStatus() { if mode == "" { mode = a.liveMode } + lastQSO := a.liveLastQSOAt a.liveActMu.Unlock() + // Online = a new contact was logged within the window. An operator who leaves + // the log open but stops working shows offline after `liveOnlineWindow`; the + // next QSO flips them back on. never-logged (zero time) → offline. + online := 0 + var lastQSOArg any + if !lastQSO.IsZero() { + lastQSOArg = lastQSO.UTC() + if time.Since(lastQSO) < liveOnlineWindow { + online = 1 + } + } if err := a.ensureLiveStatusTable(); err != nil { applog.Printf("livestatus: CREATE TABLE failed: %v", err) return } _, err := a.logDb.ExecContext(a.ctx, - "INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+ - "VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ + "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ - "band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()", - op, station, freqHz, band, mode) + "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+ + "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", + op, station, freqHz, band, mode, online, lastQSOArg) if err != nil { applog.Printf("livestatus: INSERT failed: %v", err) return } - applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode) + applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online) } func (a *App) ensureLiveStatusTable() error { - _, err := a.logDb.ExecContext(a.ctx, + if _, err := a.logDb.ExecContext(a.ctx, "CREATE TABLE IF NOT EXISTS live_status ("+ "operator VARCHAR(32) PRIMARY KEY, "+ "station VARCHAR(32), "+ "freq_hz BIGINT, "+ "band VARCHAR(16), "+ "mode VARCHAR(16), "+ - "updated_at DATETIME)") - return err + "online TINYINT DEFAULT 0, "+ + "last_qso_at DATETIME NULL, "+ + "updated_at DATETIME)"); err != nil { + return err + } + // Add the online/last_qso_at columns to a table created by an older build. + // MySQL has no portable "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and + // ignore the duplicate-column error when they already exist. + for _, ddl := range []string{ + "ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0", + "ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL", + } { + if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") { + applog.Printf("livestatus: %q: %v", ddl, err) + } + } + return nil } // clearLiveStatus removes this operator's row (on disable / shutdown).