feat: QSO rate meter (10/60 min) in the header

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.
This commit is contained in:
2026-07-18 21:51:51 +02:00
parent d30b305ff2
commit 9d4ccb9254
6 changed files with 104 additions and 1 deletions
+21
View File
@@ -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) {
+39 -1
View File
@@ -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<string | undefined>(undefined);
@@ -3738,6 +3753,28 @@ export default function App() {
)}
</div>
{/* 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. */}
<div className="flex items-center gap-2">
{showQsoRate && (
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={t('rate.title')}>
<Activity className="size-3.5 text-muted-foreground" />
{/* Contest-style rate: QSOs/hour projected from each window
(10-min count ×6; the 60-min count is already per hour). */}
<span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span>
<span className="font-bold text-[12px] text-foreground">{qsoRate.last10 * 6}</span>
</span>
<span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span>
<span className="font-bold text-[12px] text-foreground">{qsoRate.last60}</span>
</span>
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
</div>
)}
{/* 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() {
})()}
</div>
) : <span />}
</div>
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
<Clock className="size-3" />
@@ -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
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
+4
View File
@@ -14,6 +14,7 @@ type Dict = Record<string, string>;
const en: Dict = {
// Menu bar
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
@@ -122,6 +123,7 @@ const en: Dict = {
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
'gen.showBeam': 'Show the antenna beam heading on the Main map',
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
'email.title': 'E-mail',
@@ -308,6 +310,7 @@ const en: Dict = {
const fr: Dict = {
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
@@ -410,6 +413,7 @@ const fr: Dict = {
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
'email.title': 'E-mail',
+1
View File
@@ -19,6 +19,7 @@ const PORTABLE_KEYS = [
'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
+34
View File
@@ -1863,6 +1863,40 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
return n, err
}
// RecentRate counts QSOs whose start time falls within each trailing window from
// `now` — the live "QSO rate" meter shown in the header. 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, windows ...time.Duration) ([]int, error) {
counts := make([]int, len(windows))
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
// QSO inside the trailing windows is among the most recently inserted.
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
if err != nil {
return counts, err
}
defer rows.Close()
now = now.UTC()
for rows.Next() {
var dateStr sql.NullString
if err := rows.Scan(&dateStr); err != nil {
return counts, err
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) {
continue
}
age := now.Sub(t)
for i, w := range windows {
if age <= w {
counts[i]++
}
}
}
return counts, rows.Err()
}
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
// used by the ADIF importer to skip records that would re-create the
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute