diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0ce4b8..a1d01ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1096,23 +1096,17 @@ export default function App() { // offline. Only shown when live-status publishing is enabled (Settings→General). const [liveStatusOn, setLiveStatusOn] = useState(false); const [onAir, setOnAir] = useState(false); - const lastQsoAtRef = useRef(0); useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]); useEffect(() => { - const LIVE_WINDOW = 5 * 60 * 1000; // 5 min, matches the backend - const evalOnAir = () => setOnAir(liveStatusOn && lastQsoAtRef.current > 0 && (Date.now() - lastQsoAtRef.current) < LIVE_WINDOW); - const off = EventsOn('qso:logged', () => { lastQsoAtRef.current = Date.now(); evalOnAir(); }); - // Seed from the DB at launch so a QSO logged just before starting OpsLog still - // counts (otherwise the badge showed offline until the next contact). - LiveLastQSOAgeSec().then((sec: number) => { - if (typeof sec === 'number' && sec >= 0) { - const at = Date.now() - sec * 1000; - if (at > lastQsoAtRef.current) lastQsoAtRef.current = at; - evalOnAir(); - } - }).catch(() => {}); - evalOnAir(); - const id = window.setInterval(evalOnAir, 10 * 1000); // flip to offline within ~10s of the window elapsing + // Read the ON-AIR state straight from the backend (single source of truth: + // liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll + // it + refresh on each logged QSO — no fragile frontend timestamp to drift. + const refresh = () => LiveLastQSOAgeSec() + .then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300)) + .catch(() => {}); + refresh(); + const off = EventsOn('qso:logged', refresh); + const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan) return () => { off(); window.clearInterval(id); }; }, [liveStatusOn]); // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General. diff --git a/livestatus.go b/livestatus.go index b7314a0..2e0588a 100644 --- a/livestatus.go +++ b/livestatus.go @@ -87,12 +87,29 @@ func (a *App) seedLiveLastQSO() { } } -// 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 { +// liveLastQSOTime is the authoritative "last contact" instant for this operator: +// the most recent of the in-memory stamp (this session's local logs, updated +// instantly) AND the DB (a contact that arrived via the SHARED logbook from another +// station, or one logged before launch). Used by both the published status and the +// UI badge so on-air/offline is right in every multi-op case. +func (a *App) liveLastQSOTime() time.Time { a.liveActMu.Lock() last := a.liveLastQSOAt a.liveActMu.Unlock() + if a.qso != nil { + if op, _ := a.liveStatusOperator(); op != "" { + if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) { + last = t + } + } + } + return last +} + +// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if +// none is known — the UI polls it for the "on air" badge. +func (a *App) LiveLastQSOAgeSec() int { + last := a.liveLastQSOTime() if last.IsZero() { return -1 } @@ -182,8 +199,8 @@ func (a *App) publishLiveStatus() { if mode == "" { mode = a.liveMode } - lastQSO := a.liveLastQSOAt a.liveActMu.Unlock() + lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB) // 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.