From 9d4ccb9254abef6187ccd7b2d3f228ed30fb2268 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 18 Jul 2026 21:51:51 +0200 Subject: [PATCH] feat: QSO rate meter (10/60 min) in the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 21 ++++++++++++ frontend/src/App.tsx | 40 ++++++++++++++++++++++- frontend/src/components/SettingsModal.tsx | 5 +++ frontend/src/lib/i18n.tsx | 4 +++ frontend/src/lib/uiPref.ts | 1 + internal/qso/qso.go | 34 +++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) diff --git a/app.go b/app.go index af3d8fc..31543d6 100644 --- a/app.go +++ b/app.go @@ -4501,6 +4501,27 @@ func (a *App) GetOperators() ([]string, error) { return a.qso.Operators(a.ctx) } +// 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"` +} + +// 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. +func (a *App) GetQSORate() QSORate { + if a.qso == nil { + return QSORate{} + } + counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute) + if err != nil || len(counts) < 2 { + return QSORate{} + } + return QSORate{Last10: counts[0], Last60: counts[1]} +} + // GetContestRuns lists the (contest, year) pairs actually present in the log, so // the Statistics picker only ever offers contests you really entered. func (a *App) GetContestRuns() ([]qso.ContestRun, error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4fb44fc..973707d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, + Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; @@ -28,6 +28,7 @@ import { ListClusterServers, ClusterSpotStatuses, SendClusterSpot, GetCATSettings, GetSolarData, + GetQSORate, LoTWUserInfo, OperatingDefaultForBand, LogUDPLoggedADIF, @@ -1085,6 +1086,20 @@ export default function App() { const [showSettings, setShowSettings] = useState(false); // Re-read the "beam on map" toggle when Preferences closes (it's edited there). useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); + // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General. + const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); + useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]); + const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 }); + useEffect(() => { + if (!showQsoRate) return; + const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); }; + load(); + // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the + // trailing windows roll forward even when nothing new is logged. + const off = EventsOn('qso:logged', load); + const id = window.setInterval(load, 30 * 1000); + return () => { off(); window.clearInterval(id); }; + }, [showQsoRate]); // Optional deep-link: which Preferences section to open. Cleared on // close so the next plain "Preferences" launch reverts to default. const [settingsSection, setSettingsSection] = useState(undefined); @@ -3738,6 +3753,28 @@ export default function App() { )} + {/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header + is a fixed 6-column grid, so adding the meter as its own child pushed + the last columns (profile / band map / compact) onto a 2nd row. */} +
+ {showQsoRate && ( +
+ + {/* Contest-style rate: QSOs/hour projected from each window + (10-min count ×6; the 60-min count is already per hour). */} + + 10′ + {qsoRate.last10 * 6} + + + 60′ + {qsoRate.last60} + + Q/h +
+ )} + {/* Space-weather / propagation — compact, in the header. Live from N0NBH (hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped onto each logged QSO. Always renders one element so the grid columns @@ -3765,6 +3802,7 @@ export default function App() { })()}
) : } +
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e50aa63..b3ac9bf 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -909,6 +909,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); + const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); // Password-encryption (secret vault) state. const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false }); @@ -4194,6 +4195,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} /> {t('gen.startEqEnd')} {t('gen.startEqEndHint')} +