Compare commits

...
8 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
rouggy 0a9a09bec2 chore: release v0.20.1 2026-07-19 03:12:35 +02:00
rouggy 34ec91684e fix: restrict RST fields to valid report values
The RST comboboxes allowed free text, so a report typed by hand that isn't in
the mode's list (e.g. "600") was committed. Drop allowFreeText on the four RST
fields (QSO entry + edit modal) and make commit-on-type push a value only when
it's actually in the list; a leftover invalid entry reverts to the last valid
value on blur. Programmatically filled values (FT8 SNR over UDP, presets) are
unaffected — only manual typing is constrained.
2026-07-19 03:12:12 +02:00
rouggy 11f1e332f7 feat: modernise the Statistics panel with colour
Headline KPI tiles are now tinted cards: a soft colour wash, a corner glow and
a left accent bar, each tile its own hue (QSOs blue, calls teal, DXCC violet,
continents orange, confirmed green) — the number itself stays in foreground ink
for full contrast on every theme. Chart cards gain a soft shadow and a coloured
dot in the header matching the section.

Charts are no longer all-blue: BY MODE colours each bar by the categorical
palette (modes are real categories, like the continents donut), and the other
horizontal-bar charts take their card's accent (top entities green, operators
violet, stations orange) instead of a single blue. Single-hue-per-series
discipline is kept; only genuine categories get per-bar colour.
2026-07-19 03:12:00 +02:00
rouggy bd9e091e65 fix: serialise auto-uploads so a burst doesn't 403 and leave QSOs at R
A QSO logged via UDP / ADIF-import fired `go m.upload` per QSO, so a pileup or
an ADIF-import burst hit a service with dozens of concurrent requests at once.
Club Log's nginx answers that burst with 403; the upload counts as failed and
the QSO stays at "R" while a lucky few go through — the real cause behind the
"it's uploaded but shows R" report (not the earlier display race).

Route all immediate/delayed auto-uploads through a single serialised worker
(one at a time, 250ms apart) so no burst ever trips a per-IP rate limiter.
upload() now reports whether a failure is transient (HTTP/rate-limit) vs
permanent (not eligible, wrong station callsign, no record); transient ones
are retried with exponential back-off (up to 4 attempts, 1s/2s/4s), so a QSO
that hit a momentary 403 ends up marked instead of stuck at R. A full queue
falls back to a goroutine rather than dropping an upload.
2026-07-19 03:05:02 +02:00
12 changed files with 255 additions and 107 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)
+36 -10
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('');
@@ -2879,12 +2905,12 @@ export default function App() {
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
// DX country flag, shown large next to RST (moved here from the Country field).
+2 -2
View File
@@ -412,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
<div className="flex flex-col w-20"><Label>S</Label>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<div className="flex flex-col w-20"><Label>R</Label>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
+41 -30
View File
@@ -52,11 +52,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
// ── Shell ────────────────────────────────────────────────────────────────────
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
return (
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
<header className="mb-3 shrink-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
{title}
</h3>
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
</header>
{children}
@@ -64,14 +67,22 @@ function Card({ title, sub, children, className }: { title: string; sub?: string
);
}
// A headline number IS the chart — a one-bar bar chart would be noise.
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
// carries an accent (a categorical chart hue or a semantic token): a soft tint
// wash + a left accent bar give it a colourful identity, while the number itself
// stays in high-contrast foreground ink so it reads on every theme.
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
<div className="relative">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
</div>
</div>
);
}
@@ -80,7 +91,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
// One series → one hue. The value is direct-labelled, so no reader ever depends
// on a tooltip to get a number.
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
// max is a display cap for long tails (top entities). Where EVERY row matters —
// the operators of a multi-op — it is deliberately not set: a capped chart would
// silently drop the 9th operator, and "who worked what" is the whole question.
@@ -90,13 +101,13 @@ function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
return (
<div className="flex flex-col gap-1.5 min-w-0">
{top.map((d) => (
{top.map((d, i) => (
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key}${nf(d.count)}`}>
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
<div className="flex-1 min-w-0 h-[14px] flex items-center">
<div
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
/>
</div>
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
@@ -629,12 +640,12 @@ export function StatsPanel() {
{/* Headline figures: stat tiles, not a grouped bar chart. */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
</div>
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
@@ -723,43 +734,43 @@ export function StatsPanel() {
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')} accent="var(--chart-3)">
<VBars data={stats.by_band} empty={empty} showValues colorful />
</Card>
<Card title={t('stats.byMode')}>
<HBars data={stats.by_mode} max={8} empty={empty} />
<Card title={t('stats.byMode')} accent="var(--chart-2)">
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
</Card>
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<AreaTrend data={stats.by_month} empty={empty} />
</Card>
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
instead of truncating. */}
<Card title={t('stats.byOperator')}>
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_operator} empty={empty} share />
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
</div>
</Card>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
<Donut data={stats.by_continent} empty={empty} />
</Card>
<Card title={t('stats.topEntities')}>
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
</Card>
<div className="flex flex-col gap-2.5 min-w-0">
<Card title={t('stats.confirmations')} className="flex-1">
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
<div className="flex flex-col gap-3 justify-center flex-1">
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
</div>
</Card>
<Card title={t('stats.byStation')}>
<Card title={t('stats.byStation')} accent="var(--chart-8)">
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_station} empty={empty} share />
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
</div>
</Card>
</div>
+11 -1
View File
@@ -64,7 +64,17 @@ export function Combobox({
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
setOpen(true);
// Commit-on-type pushes the value live to the parent (so a CW macro sent
// without leaving the field uses what was just typed). With free text that's
// any input; a restricted field (allowFreeText=false) commits ONLY a value
// that's actually in the list, so a half-typed or invalid report never
// becomes the committed value — blur then reverts the leftover text.
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
}}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.0';
export const APP_VERSION = '0.20.1';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+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
+45 -18
View File
@@ -89,6 +89,9 @@ type Manager struct {
rnd *rand.Rand
}
// 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}
@@ -101,6 +104,24 @@ func NewManager(deps Deps) *Manager {
}
}
// 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) })
}
}()
}
func (m *Manager) logf(format string, args ...any) {
if m.deps.Logf != nil {
m.deps.Logf(format, args...)
@@ -175,15 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
m.scheduleUpload(svc, id, cfg)
}
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
// 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.upload(svc, id, cfg) })
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
return
}
go m.upload(svc, id, cfg)
m.attemptUpload(svc, id, cfg, 0)
}
// onCloseServices returns the services configured for on-close auto-upload,
@@ -243,25 +266,25 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
case ServiceQRZ:
for _, id := range ids {
if m.upload(svc, id, cfg.QRZ) {
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
uploaded++
}
}
case ServiceClublog:
for _, id := range ids {
if m.upload(svc, id, cfg.Clublog) {
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
uploaded++
}
}
case ServiceHRDLog:
for _, id := range ids {
if m.upload(svc, id, cfg.HRDLog) {
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
uploaded++
}
}
case ServiceEQSL:
for _, id := range ids {
if m.upload(svc, id, cfg.EQSL) {
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
uploaded++
}
}
@@ -312,12 +335,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
// upload performs the actual push and returns true on success. It builds a
// fresh, lifecycle-independent context so a delayed upload still completes
// even if it fires close to shutdown.
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
// marked), and retryable=true when it failed in a way worth retrying later (an
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
// (not eligible, wrong station callsign, no record).
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
// match the configured Upload flag).
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
return false
return false, false
}
// Station-callsign guard. Each logbook belongs to one callsign:
@@ -345,7 +372,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return false
return false, false
}
}
@@ -360,7 +387,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
case ServiceClublog:
@@ -369,7 +396,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
case ServiceLoTW:
@@ -378,7 +405,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadLoTW(ctx, cfg, "", record)
case ServiceHRDLog:
@@ -387,7 +414,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
case ServiceEQSL:
@@ -396,11 +423,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
default:
return false
return false, false
}
if err != nil || !res.OK {
@@ -411,12 +438,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return false
return false, true // transient (rate-limit / network) → worth a retry
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
return true
return true, false
}
+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
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.0"
appVersion = "0.20.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.