Compare commits

...
4 Commits
Author SHA1 Message Date
rouggy 4ab4f70349 fix: no duplicate logs on repeat Enter + ESC stops the callsign lookup
Duplicate logs: `saving` is React state (async), so a burst of Enter presses /
clicks fired before the re-render each ran a full AddQSO and logged the same
QSO several times when a slow lookup made logging take a moment. A synchronous
savingRef guard now ignores the repeat immediately.

ESC: clearing the entry (ESC) left the "looking up" spinner turning and let a
still in-flight lookup re-populate the field afterwards. resetEntry now cancels
the pending lookup debounce, hides the spinner at once, and bumps a generation
counter so a late lookup result is discarded instead of applied.
2026-07-19 17:42:27 +02:00
rouggy 64e80986ea fix: QSL/recording no longer revert a QSO's upload status + faster logging
Status clobber: sending an OpsLog QSL card (or stamping a recording) read the
QSO, did slow work (e-mail send), then wrote the WHOLE row back from that now-
stale copy — silently reverting clublog/qrz_qso_upload_status from Y to R that a
concurrent auto-upload had just set. That's why QSOs "showed R again, but only
with QSL sending on", intermittently. New Repo.SetExtra does a targeted
`UPDATE qso SET extras = ?` that touches only the extras column, so an app-field
stamp can never clobber another column. Used by SendEQSL, markRecordingSent and
the recording-path stamp.

Logging speed: the log path no longer runs a callsign lookup at all — the e-mail
already arrives on the QSO from the entry lookup, so the second one was
redundant and stalled logging on a slow/not-found QRZ. And the entry lookup
(LookupCallsign) is now bounded to ~2 s: providers get 2 s, then it falls
through to cty.dat instead of spinning 10 s+ on a call that isn't in QRZ.
2026-07-19 17:42:14 +02:00
rouggy 816c6ffcf1 fix: revert upload serialisation — it stranded QSOs at R (regression)
The single-worker upload queue introduced head-of-line blocking: one slow
upload (LoTW signing via TQSL, or a service on a 30 s timeout) stalled the
whole queue, so every following QSO's Club Log upload waited and the rows sat
at "R". Operators on the older, concurrent build had no such problem — the
tell that this was a fresh regression.

Back to one goroutine per upload (never serialised), so a slow upload never
holds up the rest, while keeping the transient-failure retry with back-off
(concurrent, so it can't block anything).
2026-07-19 17:41:48 +02:00
rouggy 2166d1aa4b fix: IC-7610 VFO A over OmniRig + faster Icom LAN reconnect
OmniRig: on the IC-7610 the generic Freq property reports the wrong VFO (its
Main/Sub model confuses the stock ini), so OpsLog showed VFO B. Detect the rig
by RigType and, in simplex, read VFO A explicitly — matching Log4OM. Only the
7610 is affected; other rigs keep using the generic Freq.

Icom network: when the rig tears the session down (control/CI-V 0x05) OpsLog
only logged it and kept the half-dead link until the 6 s liveness timeout
expired. Mark the link dead on 0x05 so Alive() fails on the next poll and the
manager reconnects cleanly right away.
2026-07-19 17:00:11 +02:00
7 changed files with 176 additions and 114 deletions
+32 -22
View File
@@ -1850,13 +1850,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
// Fill the contacted operator's e-mail from the (cached) lookup so the
// recording can be auto-sent. Cheap: the entry already looked the call up.
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
q.Email = lr.Email
}
}
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
// form carries it here. There is deliberately NO second lookup at log time: it
// was redundant with the entry lookup and only slowed logging (a call not yet in
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
id, err = a.qso.Add(a.ctx, q)
if err != nil && db.IsConnLost(err) {
// The database is UNREACHABLE (not a data error) — park the QSO in the
@@ -4528,7 +4527,16 @@ 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)
// 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 {
return QSORate{}
}
@@ -5292,7 +5300,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
if a.lookup == nil {
return lookup.Result{}, fmt.Errorf("lookup not initialized")
}
r, err := a.lookup.Lookup(a.ctx, callsign)
// Bound the whole lookup: give the providers a couple of seconds, then let
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
defer cancel()
r, err := a.lookup.Lookup(ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found")
}
@@ -5822,8 +5837,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING"] = name
if err := a.qso.Update(a.ctx, *q); err != nil {
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
// Persist ONLY this extras key (targeted) — a full-row Update from this
// in-memory copy could revert a column a concurrent post-log action changed.
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
applog.Printf("qso-rec: store recording path: %v", err)
}
}
@@ -6695,17 +6712,10 @@ func (a *App) markRecordingSent(id int64) {
if a.qso == nil || id == 0 {
return
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
return
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
// Targeted extras write — never a full-row Update, which (from a stale copy)
// could revert a clublog/qrz upload-status another action just stamped.
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
applog.Printf("qso-rec: mark sent %d: %v", id, err)
}
}
+10 -11
View File
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err
}
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
// independent. q came straight from GetByID, so a full Update rewrites the
// row unchanged apart from this field.
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("eQSL sent but status not saved: %w", err)
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
//
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
// read up top is now stale after the slow e-mail send, and rewriting the whole
// row would revert any column an auto-upload changed meanwhile — that's how
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("QSL card sent but status not saved: %w", err)
}
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
+34 -8
View File
@@ -605,6 +605,11 @@ export default function App() {
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
const [saving, setSaving] = useState(false);
// Synchronous re-entrancy guard: `saving` is React state (updates async), so it
// can't stop a burst of Enter presses / clicks fired before the re-render — each
// would run a full AddQSO and log the SAME contact several times when a slow QRZ
// lookup made the log take seconds. This ref blocks the repeat immediately.
const savingRef = useRef(false);
const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
const [filterOpen, setFilterOpen] = useState(false);
@@ -1153,6 +1158,10 @@ export default function App() {
const [lookupError, setLookupError] = useState('');
const lookupTimerRef = useRef<number | null>(null);
const wbTimerRef = useRef<number | null>(null);
// Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
// in-flight lookup discards its result when it finally returns instead of
// re-populating a field the operator just cleared.
const lookupGenRef = useRef(0);
const [wb, setWb] = useState<WB | null>(null);
const [wbBusy, setWbBusy] = useState(false);
@@ -2155,7 +2164,9 @@ export default function App() {
}, [spots]);
async function save() {
if (savingRef.current) return; // a log is already in flight — ignore the repeat
if (!callsign.trim()) { setError('Callsign required'); return; }
savingRef.current = true;
setSaving(true); setError('');
try {
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
@@ -2225,13 +2236,21 @@ export default function App() {
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally { setSaving(false); }
} finally { setSaving(false); savingRef.current = false; }
}
// resetEntry clears the form for the next QSO. Triggered after a
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
// are preserved so backdated batches stay productive.
function resetEntry() {
// Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
// up" spinner immediately, and invalidate any request already in flight so its
// late result can't re-fill the field we're about to clear (the "ESC clears the
// QSO but the QRZ lookup keeps going" bug).
if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
lookupGenRef.current++;
setLookupBusy(false);
// Discard any in-progress QSO recording (no-op if it was already saved on
// log, or if the recorder is off).
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
@@ -2436,14 +2455,15 @@ export default function App() {
}
async function runLookup(call: string) {
if (call !== lastLookedUpRef.current) resetAutoFill();
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
setLookupBusy(true);
try {
const r = await LookupCallsign(call);
// Discard a STALE result: the operator already moved to another call
// (clicked a new spot / typed) while this lookup was in flight. Applying it
// would clobber the current call's fields and zoom the map to the wrong
// station — the bug where replacing a call didn't re-zoom the map.
if (call !== callsignValRef.current.trim().toUpperCase()) return;
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
// was in flight. Applying it would clobber the current fields and zoom the
// map to the wrong station.
if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
lastLookedUpRef.current = call;
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
@@ -2496,9 +2516,15 @@ export default function App() {
QSOAudioBegin().then(setRecording).catch(() => {});
}
} catch (e: any) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
} finally { setLookupBusy(false); }
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
}
} finally {
// Only clear the spinner if we're still the current lookup — a newer one
// (or an ESC that already reset it) owns the busy state otherwise.
if (gen === lookupGenRef.current) setLookupBusy(false);
}
}
function scheduleLookup(value: string, force?: boolean) {
setLookupError('');
+10
View File
@@ -136,6 +136,11 @@ type icomNet struct {
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
lastRx atomic.Int64
// dead is set when the rig explicitly tears the session down (control 0x05):
// Alive() then returns false immediately so ReadState fails on the next poll and
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
dead atomic.Bool
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
// Torn down alongside the CI-V/control streams in Close.
audio *icomAudio
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
func (n *icomNet) Alive() bool {
if n.dead.Load() {
return false // rig sent an explicit disconnect — reconnect now, don't wait
}
last := n.lastRx.Load()
if last == 0 {
return true // just connected, nothing received yet — give it a chance
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
n.ctrlResend(icnLE.Uint16(buf[6:]))
}
case 0x05: // rig-initiated disconnect — it dropped US
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
default:
// Anything else on the control stream is (almost always) the rig's
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
n.resend(icnLE.Uint16(buf[6:]))
}
case typ == 0x05: // rig-initiated disconnect — it dropped US
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
+17 -1
View File
@@ -32,6 +32,7 @@ type OmniRig struct {
omnirig *ole.IDispatch
rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
@@ -80,11 +81,20 @@ func (o *OmniRig) Connect() error {
o.rig = rigVar.ToIDispatch()
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
o.rigType = rt.ToString()
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
}
return nil
}
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
// shows.
func (o *OmniRig) isIC7610() bool {
return strings.Contains(strings.ToUpper(o.rigType), "7610")
}
func (o *OmniRig) Disconnect() {
if o.rig != nil {
o.rig.Release()
@@ -199,6 +209,12 @@ func (o *OmniRig) ReadState() (RigState, error) {
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
// explicitly for the 7610 — what the operator actually wants to see.
if o.isIC7610() && freqA != 0 {
s.FreqHz = freqA
}
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
+24 -62
View File
@@ -87,77 +87,39 @@ type Manager struct {
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
// uploadCh serialises immediate auto-uploads through a single worker. Firing a
// goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens
// of concurrent requests at once — Club Log's nginx answers that with 403, the
// upload is counted as failed and the QSO stays at "R" despite the others going
// through. One-at-a-time with a small gap keeps every upload under the limit.
uploadCh chan uploadJob
}
// uploadJob is one queued auto-upload.
type uploadJob struct {
svc Service
id int64
cfg ServiceConfig
attempt int // 0 on first try; incremented on each retry
}
// uploadGap spaces serialized uploads so a burst never trips a service's per-IP
// rate limiter. maxUploadAttempts bounds retries of a transient failure.
const (
uploadGap = 250 * time.Millisecond
maxUploadAttempts = 4
)
// maxUploadAttempts bounds retries of a transient upload failure.
const maxUploadAttempts = 4
func NewManager(deps Deps) *Manager {
if deps.Client == nil {
deps.Client = &http.Client{Timeout: 20 * time.Second}
}
m := &Manager{
return &Manager{
deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
uploadCh: make(chan uploadJob, 4096),
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
}
go m.uploadWorker()
return m
}
// uploadWorker drains the queue one upload at a time, spacing them so a burst of
// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient
// failure is re-queued with an exponential back-off, so a QSO that hit a
// momentary rate-limit still ends up marked instead of stuck at "R".
func (m *Manager) uploadWorker() {
for job := range m.uploadCh {
ok, retryable := m.upload(job.svc, job.id, job.cfg)
if !ok && retryable && job.attempt+1 < maxUploadAttempts {
next := job
next.attempt++
backoff := time.Duration(1<<uint(job.attempt)) * time.Second // 1s, 2s, 4s…
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", job.svc, job.id, next.attempt+1, backoff)
time.AfterFunc(backoff, func() { m.enqueueJob(next) })
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
// independent — never serialised through a shared worker, because a single slow
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
// every following QSO's upload and strand them all at "R" (the regression that hit
// the operator on the newest build while everyone on the old concurrent path was
// fine).
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
go func() {
ok, retryable := m.upload(svc, id, cfg)
if !ok && retryable && attempt+1 < maxUploadAttempts {
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
}
time.Sleep(uploadGap)
}
}
// enqueueUpload queues a first-attempt upload without blocking the logging path.
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
}
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
// enormous burst), it falls back to a goroutine rather than dropping the upload —
// a dropped upload would leave the QSO stuck at "R".
func (m *Manager) enqueueJob(job uploadJob) {
select {
case m.uploadCh <- job:
default:
go m.upload(job.svc, job.id, job.cfg)
}
}()
}
func (m *Manager) logf(format string, args ...any) {
@@ -234,17 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
m.scheduleUpload(svc, id, cfg)
}
// scheduleUpload either queues the upload now (immediate) or arms a timer that
// queues it later (delayed). Both go through the serialised worker so uploads are
// never fired concurrently in a burst.
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
// one never holds up the rest.
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeDelayed {
d := m.delaySeconds()
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
time.AfterFunc(d, func() { m.enqueueUpload(svc, id, cfg) })
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
return
}
m.enqueueUpload(svc, id, cfg)
m.attemptUpload(svc, id, cfg, 0)
}
// onCloseServices returns the services configured for on-close auto-upload,
+49 -10
View File
@@ -781,6 +781,38 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil
}
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
// QSL card, saving a recording) must not do a full-row Update: the row it read may
// be seconds stale, and writing it all back silently reverts any column another
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
// the key.
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
if id == 0 || strings.TrimSpace(key) == "" {
return fmt.Errorf("missing id or key")
}
var extrasJSON sql.NullString
if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
return fmt.Errorf("load extras: %w", err)
}
m := decodeExtras(extrasJSON.String)
if m == nil {
m = map[string]string{}
}
if value == "" {
delete(m, key)
} else {
m[key] = value
}
if _, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`,
encodeExtras(m), db.NowISO(), id); err != nil {
return fmt.Errorf("set extra %s: %w", key, err)
}
return nil
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 {
@@ -1864,25 +1896,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
}
// 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) {
// `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))
// 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`)
// 2000 rows covers a full hour for one operator even in a busy multi-op run
// (other operators' rows are discarded before counting).
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil {
return counts, err
}
defer rows.Close()
now = now.UTC()
opFilter := strings.ToUpper(strings.TrimSpace(operator))
for rows.Next() {
var dateStr sql.NullString
if err := rows.Scan(&dateStr); err != nil {
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
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) {
continue