Compare commits

...
19 Commits
Author SHA1 Message Date
rouggy ae8e5b1bc6 chore: release v0.27.8 2026-09-02 00:16:42 +02:00
rouggy ad69371f6a fix(icom): sidebands by name, model-aware controls, address 0x00
From an IC-7300 report, four faults and one addition.

USB and LSB could not be commanded at all: modeCode knew 'SSB' — which
resolves the sideband from the band — and answered 'unsupported mode' to
the sideband names themselves. So an operator wanting USB on 40 m had no
way to say it, from the console or from anywhere else. They are separate
buttons now, and a rig reporting the folded ADIF 'SSB' still lights the
side its frequency implies.

The console offered controls the radio does not have: ANT1/ANT2 on a rig
with one socket, and a PSK button every non-7610-class Icom NAKs. Both
now follow the model, as the band buttons and attenuator steps already
did. Mic gain stops being phone-only — on USB-D it still sets what the
radio transmits at, so an operator who lives in FT8 had none.

CI-V address 0x00 was refused by a 'n > 0' test and silently replaced by
the IC-7610 default; an EMPTY setting is what means unconfigured, so the
parse error decides now, not the value. Plus the 60 m band button that
was missing.
2026-09-02 00:00:21 +02:00
rouggy 3f97084246 style(matrix): pin the label column so RTTY cannot shift it
The width was set for three characters, so the column grew when the
rotation came round to RTTY and every band beneath it moved — which the
eye reads as the matrix sliding rather than the row changing. Sized once
for the longest label it can show and pinned there, header spacer
included; past four characters (PSK31, MSK144) the type gives way
instead of the column.
2026-09-01 23:50:14 +02:00
rouggy 74dfc3a725 feat(matrix): the DIG row cycles through your own digital modes
One row per digital mode would be the honest layout, and there is no
height for it: the matrix sits in a fixed panel beside a dozen widgets.
So the row keeps its place and changes what it answers — DIG, then each
digital mode the operator's own list holds, in the order they put them
in, then back to DIG.

It costs no round trip. The query behind the matrix already grouped by
band AND mode; only the collapse to a class threw that away, so the same
cell is now published under the raw mode name too. Digital only: PH and
CW have nothing to cycle through.

On a specific mode the you-are-here mark follows THAT mode, or every FT4
entry would light whichever digital row the rotation happened to rest
on. A four-letter mode drops to 9px rather than widen a column sized for
three characters and push the whole matrix sideways. Opens 0.27.8.
2026-09-01 23:47:03 +02:00
rouggy 6442325926 chore: release v0.27.7 2026-09-01 23:40:08 +02:00
rouggy 4c4b3b6c2d feat(flex): send SmartSDR's spot priority; fix(chase): the same station on the same slot has nothing left to give
The panadapter has finite room: spots close in frequency are stacked
behind a '+' and only one is drawn, chosen by PRIORITY — a parameter
'spot add' accepts and OpsLog never sent. So a new entity sat invisible
behind three stations already in the log. DXHunter has sent it for
years; the tiers here are the same idea in the operator's own words:
the entity never worked (with my own callsign, for a multi-op), then
band/mode/slot, then the reference hunts, then everything else.

And a callsign already worked on this exact band and mode stops
advertising a need. Working it again cannot turn a missing QSL into a
confirmation — the QSO is already there — so shouting NEW DXCC over a
station worked an hour ago only teaches an operator to distrust the
colour. The need is real and stays on every OTHER station of the entity,
which is where it can be answered. Applied on both paths out of the
verdict, including the early one that leaves the loop first.
2026-09-01 23:33:56 +02:00
rouggy 4d8cb58550 fix(ftmap): tell Leaflet when its box changes size; fix(decodes): all seven continents
The FT map drew a strip of dead space along the bottom after a zoom.
Leaflet measures its container ONCE, at creation — which for this panel
is the instant the tab is selected, before the flex layout has settled —
and then asks for tiles to fit that stale size for ever. A ResizeObserver
hands it the real size, plus one pass after the first paint for the
layout that settles without ever firing a resize.

And the decode list's continent chips are the seven, fixed. Building
them from the feed meant a row that reshuffled itself every period — a
chip appeared when the first Asian station decoded and moved everything
sideways under the pointer — and it could not say what the filter was
capable of until something had been heard.
2026-09-01 23:19:18 +02:00
rouggy a6172c4323 feat(dxhunter): the chase gets a page of its own, and US states a switch
The chase switches were born on the DX Cluster page because the cluster
was the only thing that drew their badges. They now decide what the FT
decode list and Chase new say as well, and settings that govern three
screens should not live under the name of one of them. They move to a
DXHunter page — named for what it is about, and for what is meant to
land beside it as more of DXHunter's ideas are ported over.

US states join the switchable markers, gated the same way as POTA,
counties, prefixes and squares: badge, filter chip and display marker,
all from the one chaseAllows question.
2026-09-01 16:26:31 +02:00
rouggy b70a679c64 feat(chase): the switches follow the operator, not the panel
Chase POTA, US counties, prefixes and grids were written for the cluster
and stayed there, so an operator who does not chase parks still met NEW
POTA in the FT decode list and in Chase new: the same badge withdrawn on
one screen and shouting on the next. The setting is about what is hunted.

One helper answers the question for every panel, keyed by both the status
field names and the shorter category names so a single call serves badges
and filter chips alike. Chase new also stops CATEGORISING a row by a
marker that is switched off, which had left rows listed with no visible
reason for being there. A new US state has no switch of its own, so it is
always allowed.
2026-09-01 16:20:28 +02:00
rouggy f39bda110a fix(chase): the unconfirmed flag survives the next QSO
Reported as 'South Africa shows NEW BAND although I have worked 15m'.
The verdict was right — his hunt counts only LoTW and paper QSL, and no
15m contact is confirmed by either — but the badge drew SOLID, saying
'never worked here' about a band with four contacts in the log.

Proved with his own data: the confirmed ledger holds 10/12/17/20/30/40/80
and not 15, the all-QSO ledger holds 15m FT8 four times, and the backend
sets UnconfStatus correctly for exactly that case (new test, end to end
through ClusterSpotStatuses).

The flag died in the frontend. FOUR hand-written copies of the same
backend-result mapping, and they had drifted: the two that RE-fetch —
after a QSO is logged, and when a pane becomes visible — rebuilt each
entry without the unconf_* flags, so the first refresh after any contact
turned every dimmed badge solid for good. None of the four ever copied
grid_state either, so a known-but-unconfirmed square never dimmed at all.
One mapper now, used by all four.
2026-09-01 11:56:20 +02:00
rouggy ab8ecd65fe test(adif): assert FREQ_RX reaches the forwarded record too
The fix filled both halves of the receive side, but the test only proved
BAND_RX came out the other end. FREQ_RX is the half the question was
really about.
2026-08-31 22:30:32 +02:00
rouggy de0771d797 feat(cat): USB for digital modes, on every backend
A soundcard mode is USB with audio in the mic path, and that is what
most rigs need. Asking for the mode by NAME is the better answer on a
modern transceiver with a DATA position and the wrong one on an older
set, where the CAT layer resolves 'digital' to RTTY/FSK — OmniRig does
exactly that, per rig file, and there is no arguing with it from here.
The operator clicking an FT8 spot landed in FSK, which keys from a
mark/space generator and cannot pass FT8 at all.

Applied in SetCATMode, the one funnel every backend and every caller
goes through, so it holds for a spot click, the band map and the mode
selector alike. The QSO is still logged as FT8: this is what the radio
is put in, not what the contact was.
2026-08-31 22:26:12 +02:00
rouggy 22e4266d38 fix(editor): a corrected frequency corrects its band
Band and frequency have to agree — the log, every award and every upload
are read on the BAND — and an operator fixing a wrong frequency is not
also expecting to fix the band by hand. That is exactly how a QSO ends
up filed on 20m at 7 MHz. Both sides follow, TX and RX.

Only when the number lands in a known allocation, because half a
frequency is typed on the way to all of it and a band must never be
blanked by that. bandForMHz moves out of App.tsx into lib/bandplan,
where the two callers can share one answer instead of drifting.
2026-08-31 22:21:22 +02:00
rouggy e3332d1e27 fix(hamqth): the whole-log upload reports itself
It ran into a panel nobody was showing: the button never set showLog, so
the tab sat on 'Pick a service' while a 25000-QSO upload came and went.
Now it opens the console like every other action here, and the console
has something to say: the callsign it is scoped to and how many of the
logbook's QSOs that leaves (a two-callsign database sends one of them,
which is the whole explanation for a count that looks short), the
exported record count and file size, and HamQTH's own reply.

Plus the two facts that make the site's own number readable: HamQTH
accepts the file and imports it in the BACKGROUND, and it reports what
it made of each record by e-mail — never in the reply we get. An
operator comparing counts a minute later is not looking at a failure.
2026-08-31 22:11:51 +02:00
rouggy 5cebf163c5 feat(hamqth): the sent stamp reaches the columns, the filter and bulk edit
HAMLOG.online was wired into all three when it arrived; HamQTH shipped
with only its uploader and its editor row, so the log could not be asked
'which contacts have never gone there' — the question that precedes
every backlog upload — and a log uploaded to HamQTH by hand could not be
marked, leaving OpsLog offering to send every one of those contacts
again. Sent side only, both whitelists, both panels, two hidden-by-
default columns.
2026-08-31 22:04:42 +02:00
rouggy cfd85ff9c3 fix(adif): a logged QSO carries its receive side
The importer has always read an absent BAND_RX/FREQ_RX as 'not split, so
RX = TX'. The logging paths never did, so what a contact carried
depended on which door it came in through — and it shows outside
OpsLog: the record forwarded over UDP is written from the QSO as logged,
so a receiver that reads BAND_RX (Log4OM does) found nothing there.

Filled at AddQSO, the one funnel every path goes through, so the
database, the export and the forwarded copy agree. A genuine split
contact keeps what it was given — table-tested both ways.
2026-08-31 22:02:24 +02:00
rouggy c6f479750f chore(changelog): open 0.27.7 — two changes landed after the .6 tag
v0.27.6 was tagged at 88f35e2, and the disconnected-pill fix and the
HamQTH whole-log upload came after it: their entries were promising a
released build features it does not carry. They open 0.27.7, and the
.6 pill entry goes back to describing what actually shipped.
2026-08-31 21:57:41 +02:00
rouggy 386a8ad531 feat(hamqth): upload the whole log in one file
The per-QSO API is the only correct way to send a SELECTION, and at the
pace it must be driven a 14k backlog costs the better part of an hour.
HamQTH's other endpoint takes a whole log as one file — and REPLACES
what is on the site with it: its documentation says plainly that partial
uploads do not exist. So it is offered as its own deliberate act behind
a confirmation, never as the batch path behind 'send these', where it
would delete every QSO the operator had not selected.

Scoped to the callsign this profile uploads as, so a database holding
two operators' contacts cannot push one into the other's log; tar.gz
above 12 MB because the ceiling is 20 and a six-figure log passes it as
text; and every QSO not already stamped is marked sent afterwards, in
bulk, so the backlog list agrees with reality.
2026-08-31 20:07:46 +02:00
rouggy bcd7e409ba fix(cluster): a disconnected server keeps its pill
The pills listed live SESSIONS, so disconnecting one made it vanish —
along with the only way back. They now list every enabled server, and a
server with no session shows as disconnected: the state you click to
undo. The address, known only while a session exists, drops out of the
tooltip rather than reading ':0'.
2026-08-31 19:05:56 +02:00
32 changed files with 1200 additions and 248 deletions
+218 -2
View File
@@ -117,6 +117,9 @@ const (
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file. // Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B" keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
// Put the RADIO in USB for a digital mode instead of asking for the mode by
// name. Every backend, because the reason is the radio, not the link.
keyCATDigiUSB = "cat.digi_usb"
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
@@ -474,6 +477,9 @@ type CATSettings struct {
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the // the .ini: an IC-7610 file was seen declaring VFO B permanently while the
// operator worked on the main VFO, so OpsLog wrote to A and read B. // operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B" OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
// than naming the mode. What the QSO is LOGGED as never changes.
DigiAsUSB bool `json:"digi_as_usb"`
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
@@ -2948,6 +2954,28 @@ func (a *App) reloadLookupProviders() {
// --- QSO bindings --- // --- QSO bindings ---
// fillRXDefaults stamps the receive side when the contact was not split.
//
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
// which door it came through. It shows up outside OpsLog: the record forwarded
// to another logger over UDP is written from the QSO as logged, and a receiver
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
// path that left it blank.
//
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
// the export and the forwarded copy all say the same thing.
func fillRXDefaults(q *qso.QSO) {
if strings.TrimSpace(q.BandRX) == "" {
q.BandRX = q.Band
}
if q.FreqRXHz == nil && q.FreqHz != nil {
v := *q.FreqHz
q.FreqRXHz = &v
}
}
func (a *App) AddQSO(q qso.QSO) (id int64, err error) { func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
@@ -2963,6 +2991,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
} }
}() }()
a.applyStationDefaults(&q, true) a.applyStationDefaults(&q, true)
fillRXDefaults(&q)
fillDistance(&q) fillDistance(&q)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
@@ -8212,7 +8241,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -8301,9 +8330,16 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n out.IcomBaud = n
} }
if n, _ := strconv.Atoi(m[keyCATIcomAddr]); n > 0 && n <= 0xFF { // 0x00 is a real CI-V address an operator may need (a bare interface, a rig
// left at its factory broadcast address), and "> 0" silently sent them back
// to the IC-7610 default with no way to say what they meant. An EMPTY
// setting is what means "never configured" — so the error is what decides,
// not the value.
if v := strings.TrimSpace(m[keyCATIcomAddr]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 0xFF {
out.IcomAddr = n out.IcomAddr = n
} }
}
if out.Backend == "" { if out.Backend == "" {
out.Backend = "omnirig" out.Backend = "omnirig"
} }
@@ -8313,6 +8349,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" { if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v out.OmniRigVFO = v
} }
out.DigiAsUSB = m[keyCATDigiUSB] == "1"
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 { if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
out.OmniRigNum = n out.OmniRigNum = n
} }
@@ -8421,6 +8458,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATBackend: s.Backend, keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)), keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATDigiUSB: boolStr(s.DigiAsUSB),
keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots, keyCATFlexSpots: flexSpots,
@@ -9122,6 +9160,7 @@ func (a *App) clusterEventWorker() {
FreqHz: s.FreqHz, FreqHz: s.FreqHz,
Callsign: s.DXCall, Callsign: s.DXCall,
Mode: mode, Mode: mode,
Priority: spotPriority(status),
Comment: spotComment(s.Comment, s.Spotter, s.Country, status), Comment: spotComment(s.Comment, s.Spotter, s.Country, status),
Color: col.Text, Color: col.Text,
BackgroundColor: col.Bg, BackgroundColor: col.Bg,
@@ -11460,6 +11499,135 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
} }
// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
//
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
// it has to be driven a full backlog costs the better part of an hour. This is
// the other endpoint HamQTH offers: a whole log as one file, which is why it
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
// nothing that is not in the file.
//
// Scoped to the callsign this profile uploads as: a database holding two
// operators' contacts must not push one operator's QSOs into the other's log.
func (a *App) UploadFullLogHamQTH() error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
cfg := a.loadExternalServices().HamQTH
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return fmt.Errorf("set the HamQTH username and password first")
}
go a.runFullLogHamQTH(cfg)
return nil
}
func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(n int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
}
}
ctx := a.ctx
owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)
// Written to a temp file rather than a buffer so the ordinary, tested
// exporter does the work — the same one the Export menu uses.
tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
if owner != "" {
emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
}
emit("Exporting the log…")
var res adif.ExportResult
if owner != "" {
// station_callsign empty OR the owner call — an old QSO logged before
// the field existed belongs to whoever is uploading now.
f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
{Field: "station_callsign", Op: "eq", Value: ""},
{Field: "station_callsign", Op: "eq", Value: owner},
}}
res, err = a.ExportADIFFiltered(path, false, f, nil)
} else {
res, err = a.ExportADIF(path, false, nil)
}
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
data, rerr := os.ReadFile(path)
if rerr != nil {
emit("Export failed: " + rerr.Error())
done(0)
return
}
total, _ := a.qso.Count(ctx)
if total > 0 && int64(res.Count) != total {
emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
}
emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))
up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
if uerr != nil || !up.OK {
msg := up.Message
if uerr != nil {
msg = uerr.Error()
}
emit("Upload failed: " + msg)
applog.Printf("hamqth: full-log upload failed: %s", msg)
done(0)
return
}
emit("HamQTH replied: " + up.Message)
// Said plainly, because the numbers will not agree for a while and an
// operator comparing them straight away has every reason to think the
// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
// own pace. What it makes of each record is reported by E-MAIL, never in
// this reply — so a count that stops short means the site rejected records,
// and the mail says which.
emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")
// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
// the rows that are not already stamped need writing.
pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
if lerr != nil {
applog.Printf("hamqth: marking sent: %v", lerr)
} else if len(pending) > 0 {
ids := make([]int64, 0, len(pending))
for _, q := range pending {
ids = append(ids, q.ID)
}
date := time.Now().UTC().Format("20060102")
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
applog.Printf("hamqth: marking sent: %v", e)
}
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
applog.Printf("hamqth: marking sent date: %v", e)
}
emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
}
applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
}
done(res.Count)
}
// TestHamQTHUpload checks the HamQTH credentials against the callbook login — // TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log. // authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) { func (a *App) TestHamQTHUpload() (string, error) {
@@ -14238,6 +14406,7 @@ func (a *App) consumeUDPEvents() {
BackgroundColor: bgCol, BackgroundColor: bgCol,
Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status), Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
LifetimeSec: secs, LifetimeSec: secs,
Priority: spotPriority(status),
}: }:
default: default:
// Radio not keeping up: skip the spot rather than stall the // Radio not keeping up: skip the spot rather than stall the
@@ -14676,6 +14845,7 @@ func (a *App) SetCATMode(mode string) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
} }
mode = a.catModeForRadio(mode)
err := a.cat.SetMode(mode) err := a.cat.SetMode(mode)
if err != nil { if err != nil {
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err) applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
@@ -14683,6 +14853,31 @@ func (a *App) SetCATMode(mode string) error {
return err return err
} }
// catModeForRadio translates a LOGGED mode into what the RADIO should be put
// in, for operators who have asked for USB on digital.
//
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
// the microphone path, and that is what most rigs need. Asking for the mode by
// name is the better answer on a modern transceiver with a DATA/PKT position,
// and the wrong one on an older set where the CAT layer resolves "digital" to
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
// file, and there is no arguing with it from here — so the option sends what
// the radio can actually do.
//
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
// what the contact WAS, not what the front panel says.
func (a *App) catModeForRadio(mode string) string {
if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
return mode
}
if qso.ModeClass(mode) != "DIG" {
return mode
}
applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
return "USB"
}
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ── // ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
// These are no-ops / errors unless the active CAT backend is a FlexRadio. // These are no-ops / errors unless the active CAT backend is a FlexRadio.
@@ -20857,6 +21052,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if !worked { if !worked {
out[i].Status = "new" out[i].Status = "new"
if _, ever := entities[dxccNum]; ever { if _, ever := entities[dxccNum]; ever {
// Worked, never confirmed — and if it was THIS station on THIS
// slot, the need is not its to answer (see the rule below).
if out[i].WorkedSlot {
out[i].Status = "worked"
continue
}
out[i].UnconfStatus = true out[i].UnconfStatus = true
} }
continue continue
@@ -20893,6 +21094,21 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
} }
} }
} }
// THIS station, already in the log on THIS slot, has nothing left to
// give. Working it a second time on the same band and mode cannot make
// the entity count: the QSO is already there and only the QSL is
// missing, and a duplicate does not produce one. So the need is real —
// it is simply not this station's to answer, and shouting NEW DXCC over
// a callsign worked an hour ago is how an operator learns to distrust
// the colour. Another station in the entity still carries the badge.
//
// Only for a need that IS a missing confirmation: a genuinely new
// entity cannot co-exist with a worked callsign, so nothing real is
// suppressed here.
if out[i].UnconfStatus && out[i].WorkedSlot {
out[i].Status = "worked"
out[i].UnconfStatus = false
}
} }
return out return out
} }
+56
View File
@@ -1,4 +1,60 @@
[ [
{
"version": "0.27.8",
"date": "",
"en": [
"The band matrixs DIG row is now a rotation: click it and it answers for FT8, then FT4, then each digital mode your mode list holds — in YOUR order — then back to DIG. One row per digital mode would be the honest layout and there is no height for it beside the other widgets, so the row keeps its place and changes what it says. The label column is sized once for the longest mode it can show, so the matrix never shifts as the rotation comes round to RTTY.",
"Icom console: LSB and USB are separate buttons and can finally be commanded by name — the single SSB button resolved the sideband from the band, so there was no way to ask an IC-7300 for USB on 40 m. A rig reporting the folded “SSB” still lights the side its frequency implies.",
"Icom console: a 60 m band button, and the antenna and PSK controls only appear on radios that have them. An IC-7300 has one antenna socket and no native PSK mode, so ANT1/ANT2 could only ever disagree with its front panel and the PSK button was dead furniture.",
"Icom console: the mic gain is no longer hidden outside phone modes — on USB-D it still sets what the radio transmits at, and an operator who lives in FT8 had none at all.",
"Icom CI-V: address 0x00 can be chosen. It was silently refused and replaced by the IC-7610 default, with no way to say what was meant."
],
"fr": [
"La ligne DIG de la matrice devient une rotation : un clic et elle répond pour FT8, puis FT4, puis chaque mode numérique de votre liste — dans VOTRE ordre — puis retour à DIG. Une ligne par mode numérique serait la mise en page honnête et la hauteur manque à côté des autres widgets : la ligne garde donc sa place et change ce quelle dit. La colonne des libellés est dimensionnée une fois pour le plus long mode quelle peut afficher : la matrice ne bouge donc plus quand la rotation arrive sur RTTY.",
"Console Icom : LSB et USB sont deux boutons distincts et peuvent enfin être demandés par leur nom — le bouton SSB unique déduisait la bande latérale de la fréquence, impossible donc de demander lUSB à un IC-7300 sur 40 m. Une radio qui annonce le « SSB » générique allume malgré tout le côté que sa fréquence implique.",
"Console Icom : un bouton de bande 60 m, et les commandes antenne et PSK napparaissent que sur les radios qui en disposent. Un IC-7300 na quune prise dantenne et pas de mode PSK natif : ANT1/ANT2 ne pouvait que contredire sa face avant, et le bouton PSK était un meuble mort.",
"Console Icom : le gain micro nest plus masqué hors des modes phonie — en USB-D il règle toujours le niveau d’émission, et un opérateur qui vit en FT8 nen avait aucun.",
"CI-V Icom : ladresse 0x00 peut être choisie. Elle était refusée en silence et remplacée par le défaut IC-7610, sans moyen de dire ce que lon voulait."
]
},
{
"version": "0.27.7",
"date": "",
"en": [
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbooks QSOs that leaves, the file size, and HamQTHs own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.",
"Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now.",
"The Chase switches (POTA, US counties, prefixes, grids) now hold in FT decodes and in Chase new, not only in the DX Cluster — they say what you hunt, not which panel is open. Unchecked, their badges and filter chips disappear from all three and only the entity verdicts remain: NEW DXCC, band, mode, slot.",
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunters ideas will land beside them.",
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.",
"FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.",
"FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard.",
"Panadapter spots now carry SmartSDRs priority: an entity never worked comes first, then the band / mode / slot needs, then POTA, SOTA, county and prefix, and everything else last. The radio stacks spots that sit close in frequency behind a “+” and draws only one — it picks by priority, so a new DXCC was disappearing behind stations already in the log simply because OpsLog never said which was worth the space.",
"A station already worked on the SAME band and mode no longer advertises a need. Working it a second time cannot turn a missing QSL into a confirmation, so the badge goes quiet on that callsign — and stays on every other station of the entity, which is where the need can actually be answered."
],
"fr": [
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen dy revenir.",
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu dune requête par QSO, une première synchro passe de près dune heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site na pas denvoi partiel) : il demande donc confirmation, se limite à lindicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
"ADIF sortant (transfert dun QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. Limport le faisait déjà, pas les chemins de log — ce quun QSO transportait dépendait donc de la porte par laquelle il était entré.",
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date denvoi), le filtre de QSO et l’édition groupée — cette dernière pour quun log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à lenvoi.",
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — lindicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande nest touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne lefface jamais.",
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — cest le cas dOmniRig, selon le fichier radio — faisait basculer lopérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.",
"Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien quune entité contactée sur une bande affichait NEW BAND comme si elle ne lavait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il ny en a plus quune.",
"Les cases Chasse (POTA, comtés US, préfixes, locators) sappliquent désormais aux FT decodes et à Chase new, plus seulement au DX Cluster — elles disent ce que vous chassez, pas quel panneau est ouvert. Décochées, leurs badges et leurs puces de filtre disparaissent des trois écrans et il ne reste que les verdicts dentité : NOUVEAU DXCC, bande, mode, slot.",
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom dun seul ; dautres idées de DXHunter viendront sy ajouter.",
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.",
"FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici linstant où longlet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.",
"FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même davoir entendu quoi que ce soit.",
"Les spots du panadapter portent désormais la priorité SmartSDR : une entité jamais contactée dabord, puis les besoins bande / mode / slot, puis POTA, SOTA, comté et préfixe, et le reste en dernier. La radio empile les spots proches en fréquence derrière un « + » et nen dessine quun — elle choisit par priorité, si bien quun nouveau DXCC disparaissait derrière des stations déjà au log, faute pour OpsLog davoir dit laquelle méritait la place.",
"Une station déjà contactée sur la MÊME bande et le même mode nannonce plus de besoin. La recontacter ne transformera pas une QSL manquante en confirmation : le badge se tait sur cet indicatif — et reste sur toutes les autres stations de lentité, là où le besoin peut réellement être comblé."
]
},
{ {
"version": "0.27.6", "version": "0.27.6",
"date": "", "date": "",
+58 -70
View File
@@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App'; import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { bandForMHz } from '@/lib/bandplan';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime'; import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -101,7 +102,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, chaseState, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App'; import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors'; import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
@@ -432,21 +433,6 @@ function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
return null; return null;
} }
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
// modeAccent maps a mode to a theme-aware colour for the live-stations widget: // modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted. // CW gold, phone green, digital blue, unknown muted.
@@ -2112,7 +2098,41 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is // worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply // assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped. // never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({}); // spotStatusEntry maps ONE backend verdict onto the entry the panels read.
//
// There were four of these, written by hand at each call site, and they had
// drifted apart: the two that RE-fetch — after a QSO is logged, and when a
// pane becomes visible — rebuilt every entry without the unconfirmed flags.
// So a badge drawn correctly as "worked, QSL missing" turned solid at the
// next refresh and stayed that way, which is how South Africa came to read
// NEW BAND on a band with four contacts in the log. grid_state was never
// copied by any of them, so a known-but-unconfirmed square never dimmed at
// all. One function now, so a field added to the backend cannot reach three
// callers and miss the fourth.
const spotStatusEntry = (r: any) => ({
status: r.status ?? '',
country: r.country,
continent: r.continent,
worked_call: !!r.worked_call,
worked_slot: !!r.worked_slot,
new_county: !!r.new_county,
lotw: !!r.lotw,
spotter_continent: r.spotter_continent,
grid: r.grid,
grid_state: r.grid_state,
new_grid: !!r.new_grid,
county: r.county,
state: r.state,
new_state: !!r.new_state,
new_pota: !!r.new_pota,
new_pfx: !!r.new_pfx,
pfx: r.pfx,
unconf_status: !!r.unconf_status,
unconf_pfx: !!r.unconf_pfx,
unconf_cty: !!r.unconf_cty,
unconf_state: !!r.unconf_state,
});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; grid_state?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener. // still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus); const spotStatusRef = useRef(spotStatus);
@@ -2185,11 +2205,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '', country: r.country, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -3668,22 +3684,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -3777,23 +3778,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -4291,17 +4276,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -6290,7 +6265,8 @@ export default function App() {
]).filter((s: any) => (s.k !== 'new-pota' || chasePota()) ]).filter((s: any) => (s.k !== 'new-pota' || chasePota())
&& (s.k !== 'new-county' || chaseCounty()) && (s.k !== 'new-county' || chaseCounty())
&& (s.k !== 'new-pfx' || chasePfx()) && (s.k !== 'new-pfx' || chasePfx())
&& (s.k !== 'new-grid' || chaseGrid())) && (s.k !== 'new-grid' || chaseGrid())
&& (s.k !== 'new-state' || chaseState()))
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k), .map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))} () => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
</div>, </div>,
@@ -7435,6 +7411,7 @@ export default function App() {
band={band} band={band}
mode={mode} mode={mode}
bands={bands} bands={bands}
modes={modes}
satellites={satellites} satellites={satellites}
onEditQso={openEdit} onEditQso={openEdit}
{...(!callsign.trim() && selQso ? { {...(!callsign.trim() && selQso ? {
@@ -8124,12 +8101,23 @@ export default function App() {
> >
Disconnect all Disconnect all
</Button> </Button>
{clusterServerStatuses.length === 0 && ( {clusterServers.filter((x) => x.enabled).length === 0 && (
<span className="text-xs text-muted-foreground italic"> <span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster. No active sessions configure clusters in Settings DX Cluster.
</span> </span>
)} )}
{clusterServerStatuses.map((s) => { {clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((srv) => {
// A disconnected server has no session to report, so it has no
// entry in the status list at all — the pill stands in for it
// as "disconnected", which is exactly the state you click to
// undo.
const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
const s: ServerStatus = live ?? {
server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
};
const isMaster = clusterServers const isMaster = clusterServers
.filter((x) => x.enabled) .filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id; .sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
@@ -8159,7 +8147,7 @@ export default function App() {
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' : s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border', 'bg-muted text-muted-foreground border-border',
)} )}
title={`${s.name}${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''} · ${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`} title={`${s.name}${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''}${s.host ? ` · ${s.host}:${s.port}` : ''}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
> >
{isMaster && <span className="text-warning" title="Master (commands go here)"></span>} {isMaster && <span className="text-warning" title="Master (commands go here)"></span>}
{s.name} {s.name}
+46 -6
View File
@@ -15,7 +15,10 @@ interface Props {
busy: boolean; busy: boolean;
currentBand: string; currentBand: string;
currentMode: string; currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS bands?: string[];
// The operator's configured mode list, in THEIR order: the digital row
// rotates through it.
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve // DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
// to an entity with no position at all, and the block simply does not appear. // to an entity with no position at all, and the block simply does not appear.
@@ -121,10 +124,31 @@ function cellTitle(t: (k: string) => string, band: string, cls: string, status:
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`; return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
} }
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) { export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null. // Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null); const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// The DIGITAL row is a rotation, not a fixed row.
//
// One row for every digital mode would be the honest layout and there is no
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
// So the row keeps its place and changes what it answers: DIG (all of them),
// then each digital mode the operator actually uses, in the order their mode
// list gives, then back to DIG. The backend publishes the same cells under
// both the class name and the raw mode, so a rotation costs no round trip.
const digModes = useMemo(
() => (modes ?? [])
.map((m) => (m || '').toUpperCase().trim())
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes],
);
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
// A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
// Columns from the operator's configured bands (so the matrix shows only the // Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set. // bands they actually use), falling back to the built-in default set.
const cols = useMemo( const cols = useMemo(
@@ -310,7 +334,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<table className="border-separate" style={{ borderSpacing: 3 }}> <table className="border-separate" style={{ borderSpacing: 3 }}>
<thead> <thead>
<tr> <tr>
<th className="w-[26px]" /> <th className="w-[38px] min-w-[38px] max-w-[38px]" />
{cols.map((b) => ( {cols.map((b) => (
<th <th
key={b.tag} key={b.tag}
@@ -325,13 +349,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{CLASSES.map((cls) => { {CLASSES.map((clsBase) => {
const classCurrent = classMatchesMode(cls, currentMode); const cls = clsBase === 'DIG' ? digRow : clsBase;
// On a specific digital mode the "you are here" mark has to be that
// mode, not any digital one — otherwise every FT4 entry lights the
// FT8 row it happens to be cycled to.
const classCurrent = cls === clsBase
? classMatchesMode(cls, currentMode)
: (currentMode || '').toUpperCase() === cls;
return ( return (
<tr key={cls}> <tr key={cls}>
<th <th
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
className={cn( className={cn(
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]', // Sized once for the LONGEST label the rotation can show,
// and pinned there: a column that grows when RTTY comes
// round shifts every band beneath it, and the eye reads
// that as the matrix moving rather than the row changing.
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
// Beyond four characters (PSK31, MSK144) the type gives way
// instead of the column.
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground', classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)} )}
> >
@@ -56,6 +56,8 @@ const FIELDS: FieldDef[] = [
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' }, { id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' }, { id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator // My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true }, { id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true }, { id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
+9 -4
View File
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Radar, Loader2, X } from 'lucide-react'; import { Radar, Loader2, X } from 'lucide-react';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers'; import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App'; import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
@@ -60,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
]; ];
// categoryOf is the single thing a row says about a station. // categoryOf is the single thing a row says about a station.
//
// A category the operator does not chase is not a category here either: with
// prefixes and squares switched off this panel showed rows whose only reason
// for being listed had been withdrawn everywhere else.
function categoryOf(s: ChaseNewSpot): Category | null { function categoryOf(s: ChaseNewSpot): Category | null {
switch (s.status) { switch (s.status) {
case 'new': return 'dxcc'; case 'new': return 'dxcc';
@@ -68,8 +73,8 @@ function categoryOf(s: ChaseNewSpot): Category | null {
case 'new-mode': return 'mode'; case 'new-mode': return 'mode';
case 'new-slot': return 'slot'; case 'new-slot': return 'slot';
} }
if (s.new_pfx) return 'pfx'; if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
if (s.new_grid) return 'grid'; if (s.new_grid && chaseAllows('grid')) return 'grid';
return null; return null;
} }
@@ -83,7 +88,7 @@ function loadFilters(): Set<Category> {
if (Array.isArray(list)) return new Set(list); if (Array.isArray(list)) return new Set(list);
} }
} catch { /* a corrupt preference is not worth a broken panel */ } } catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.map((c) => c.key)); return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
} }
export function ChaseNewPanel({ onPick, onClose }: Props) { export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -132,7 +137,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
{/* Filters, in the same order and colours as the badges they hide. */} {/* Filters, in the same order and colours as the badges they hide. */}
<div className="flex flex-1 flex-wrap items-center gap-1"> <div className="flex flex-1 flex-wrap items-center gap-1">
{CATEGORIES.map((c) => ( {CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
<button <button
key={c.key} key={c.key}
type="button" type="button"
+17 -15
View File
@@ -16,6 +16,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react'; import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
@@ -120,6 +121,9 @@ interface Props {
// that starts by hiding most of the band would be lying about what is on it. // that starts by hiding most of the band would be lying about what is on it.
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state'; type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
// The seven, in the order an operator reads them.
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' }, { key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
{ key: 'band', label: 'dec.stBand', colour: 'var(--warning)' }, { key: 'band', label: 'dec.stBand', colour: 'var(--warning)' },
@@ -629,27 +633,25 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// dropdowns were pure furniture for most operators; they appear the day a // dropdowns were pure furniture for most operators; they appear the day a
// second instance puts a second band on the link, which is the only day they // second instance puts a second band on the link, which is the only day they
// mean anything. // mean anything.
const { bands, modes, conts, instances } = useMemo(() => { const { bands, modes, instances } = useMemo(() => {
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>(); const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
for (const d of decodes) { for (const d of decodes) {
if (d.band) b.add(d.band); if (d.band) b.add(d.band);
if (d.mode) m.add(d.mode); if (d.mode) m.add(d.mode);
if (d.instance) i.add(d.instance); if (d.instance) i.add(d.instance);
const ct = statusOf(d)?.continent;
if (ct) c.add(ct);
} }
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() }; return { bands: [...b].sort(), modes: [...m].sort(), instances: [...i].sort() };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]); }, [decodes, spotStatus]);
// The chips are the continents on the feed — a selector with nothing to choose // All seven, always, in their usual order.
// is furniture — PLUS anything currently selected. Without that second half a //
// filter can strand itself: pick AF, the last African station stops decoding, // The chips used to be built from the continents ON the feed, which read as a
// and the list empties with no chip left to switch it back off. // list that reshuffled itself every period: a chip appeared when the first
const contChips = useMemo( // Asian station decoded and moved everything sideways under the pointer. A
() => [...new Set([...conts, ...contList])].sort(), // fixed row can be aimed at — you learn where OC is and it stays there — and
[conts, contList], // it also says what the filter can do before anything has been heard.
); const contChips = CONTINENTS;
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toUpperCase(); const q = search.trim().toUpperCase();
@@ -743,7 +745,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* Per-category badges, in the colours of the flags they select — the {/* Per-category badges, in the colours of the flags they select — the
same vocabulary as the Chase New panel. */} same vocabulary as the Chase New panel. */}
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}> <span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
{NEW_CATS.map((c) => { {NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
const on = cats.has(c.key); const on = cats.has(c.key);
return ( return (
<button <button
@@ -1051,7 +1053,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
const e = statusOf(d); const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : ''; const st = e?.status && e.status !== 'worked' ? e.status : '';
const entities = st ? entityBadgesFor(st) : []; const entities = st ? entityBadgesFor(st) : [];
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]); const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
const mine = !!me && d.call === me; const mine = !!me && d.call === me;
const hot = entities.length > 0 || extras.length > 0; const hot = entities.length > 0 || extras.length > 0;
// Someone answering us outranks everything else on the screen. // Someone answering us outranks everything else on the screen.
+3 -1
View File
@@ -71,6 +71,7 @@ interface Props {
band: string; band: string;
mode: string; mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns bands?: string[]; // configured bands for the worked-before matrix columns
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than // The station's satellites, for the SAT_NAME dropdown. Passed in rather than
// read here: the list lives in Preferences, and App already reloads it when // read here: the list lives in Preferences, and App already reloads it when
// Preferences close — a panel reading it once at mount would need a restart. // Preferences close — a panel reading it once at mount would need a restart.
@@ -155,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
); );
} }
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) { export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided const open = tab ?? internalOpen; // controlled when `tab` is provided
@@ -294,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
currentBand={slotCall ? (slotBand ?? '') : band} currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode} currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands} bands={bands}
modes={modes}
hasCall={slotCall ? true : callsign.trim() !== ''} hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall} forCall={slotCall}
onEditQso={onEditQso} onEditQso={onEditQso}
+19 -1
View File
@@ -62,7 +62,25 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
}); });
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
return () => { m.remove(); mapRef.current = null; layerRef.current = null; }; // Leaflet measures its container ONCE, when the map is created, and then
// draws tiles for that size for ever. This panel is mounted the moment its
// tab is selected — before the flex layout has settled — and the window can
// be resized under it, so the stale measurement showed as a strip of dead
// space along the bottom where tiles were never asked for. The observer
// hands it the real size whenever the box changes.
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
ro.observe(divRef.current);
// Once more after the first paint: the first observation can arrive while
// the panel is still zero-height, and no further resize follows a layout
// that settles by itself.
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
return () => {
window.clearTimeout(settle);
ro.disconnect();
m.remove();
mapRef.current = null;
layerRef.current = null;
};
}, []); }, []);
// Basemap follows the picker. // Basemap follows the picker.
@@ -96,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' }, { value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' }, { value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' }, { value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' }, { value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' }, { value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' }, { value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
+61 -10
View File
@@ -53,7 +53,13 @@ const ZERO: IcomState = {
type Band = { l: string; hz: number }; type Band = { l: string; hz: number };
const HF_BANDS: Band[] = [ const HF_BANDS: Band[] = [
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 }, { l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
// every 60 m-capable rig can display. Where the band is channelised (the US)
// the operator moves to their channel from here — the button is a way onto
// the band, not a claim about what may be transmitted on it.
{ l: '60', hz: 5_354_000 },
{ l: '40', hz: 7_100_000 },
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 }, { l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 }, { l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
]; ];
@@ -78,9 +84,36 @@ function bandsFor(model?: string): Band[] {
return [...HF_BANDS, B6]; return [...HF_BANDS, B6];
} }
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for // Mode buttons for the console (like RS-BA1's row).
// SSB by frequency and the rig's data variant for digital modes. //
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM', 'DATA']; // LSB and USB by NAME, not one "SSB" button that resolves by band: the band
// convention is right for a logged mode and useless when the operator means
// "put this radio in USB on 40 m", which the console could not express at all.
//
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
// DATA, which every rig can do.
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
function hasNativePSK(model?: string): boolean {
const m = (model ?? '').toUpperCase();
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
m.includes('7800') || m.includes('7700');
}
function modesFor(model?: string): string[] {
if (!hasNativePSK(model)) return MODES_BASE;
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
}
// Which radios actually have an antenna selector on the CI-V command (0x12).
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
// only ever disagree with the front panel.
function hasAntennaSelector(model?: string): boolean {
const m = (model ?? '').toUpperCase();
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
m.includes('7800') || m.includes('7700') || m.includes('9700');
}
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the // Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18 // same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
@@ -155,9 +188,21 @@ function icomWatts(pct: number): { w: number; defl: number } {
return { w: Math.round(w), defl }; return { w: Math.round(w), defl };
} }
function modeMatches(btn: string, cur?: string): boolean { // Which sideband a bare "SSB" means at this frequency — the same convention the
// backend applies when it resolves the mode for the radio.
function sideForHz(hz?: number): string | null {
if (!hz || hz <= 0) return null;
return hz < 10_000_000 ? 'LSB' : 'USB';
}
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
if (!cur) return false; if (!cur) return false;
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB'; // A rig that reports the folded ADIF "SSB" still lights the side its
// frequency implies, so the row is never blank on a phone contact.
if (btn === 'USB' || btn === 'LSB') {
if (cur === btn) return true;
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
}
// The backend surfaces USB-D as the operator's digital default (FT8…), or as // The backend surfaces USB-D as the operator's digital default (FT8…), or as
// plain DATA — either way it is the DATA button that should light. // plain DATA — either way it is the DATA button that should light.
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur); if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
@@ -507,9 +552,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
</div> </div>
</div> </div>
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */} {/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
<div className="grid grid-cols-7 border-t border-border/60 divide-x divide-border/60"> <div className="grid border-t border-border/60 divide-x divide-border/60"
{MODES.map((m) => { style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
const on = modeMatches(m, curMode); {modesFor(st.model).map((m) => {
const on = modeMatches(m, curMode, mainHz);
return ( return (
<button key={m} type="button" onClick={() => setMode(m)} <button key={m} type="button" onClick={() => setMode(m)}
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors', className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
@@ -561,10 +607,12 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
); );
})} })}
</div> </div>
{hasAntennaSelector(st.model) && (
<Row label={t('icmp.antenna')}> <Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]} <Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} /> onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
</Row> </Row>
)}
</Card> </Card>
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */} {/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
@@ -588,7 +636,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power} {(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
</span> </span>
</Row> </Row>
{isPhone && ( {/* Not phone-only: on USB-D the same control still sets what the radio
transmits at, and hiding it left an operator who lives in FT8 with
no mic gain at all. */}
{(
<Row label={t('icmp.mic')}> <Row label={t('icmp.mic')}>
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} /> <Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
+20 -2
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -723,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{service !== 'pota' && service !== 'paper' && ( {service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0"> <div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{service === 'hamlog' ? ( {service === 'hamqth' ? (
// HamQTH's file endpoint REPLACES the remote log — its own
// documentation is explicit that partial uploads do not exist. So
// it is offered as its own deliberate act, never as the batch path
// behind "send these": that would delete everything not selected.
<Button variant="outline" size="sm" disabled={busy}
title={t('qslm.hqFullTitle')}
onClick={async () => {
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
// Same three lines every other action here runs: without
// setShowLog the whole upload reported itself into a panel
// nobody was showing, and the tab sat on "Pick a service".
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadFullLogHamQTH(); }
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
}}>
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
</Button>
) : service === 'hamlog' ? (
<> <>
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy} <Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}> title={t('qslm.hamlogImportTitle')}>
+18 -4
View File
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { bandForMHz } from '@/lib/bandplan';
import { titleCase, sentenceCase } from '@/lib/textCase'; import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
@@ -312,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const splitHz = (hz?: number) => hz const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') } ? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' }; : { khz: '', hz: '' };
// Correcting a frequency corrects its band. The pair has to agree — the log,
// every award and every upload are read on the BAND — and an operator fixing
// a wrong frequency is not also expecting to fix the band by hand, which is
// exactly how a QSO ends up filed on 20m at 7 MHz.
//
// Only when the number lands in a known allocation: half a frequency is typed
// on the way to all of it, and a band must never be blanked by that.
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
if (!khz.trim()) return;
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
if (b) set(field, b as any);
};
const f0 = splitHz(draft.freq_hz); const f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz); const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz); const [freqKHz, setFreqKHz] = useState(f0.khz);
@@ -634,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label> <Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> <Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> <Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label> <Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> <Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> <Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div> </div>
</div> </div>
@@ -219,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false }, { group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false }, { group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false }, { group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
// HamQTH, the extras again — sent only, the site having no confirmations.
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
// App-specific: when the QSO's audio recording was e-mailed to the station. // App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
+137 -102
View File
@@ -213,6 +213,7 @@ type SectionId =
| 'lists-modes' | 'lists-modes'
| 'lists-satellites' | 'lists-satellites'
| 'cluster' | 'cluster'
| 'dxhunter'
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart' | 'autostart'
@@ -325,6 +326,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' }, { kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
]}, ]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' }, { kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' }, { kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
@@ -1612,7 +1614,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001, digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
}); });
// Brand + connection, derived from the stored backend rather than held // Brand + connection, derived from the stored backend rather than held
// separately: two sources for one fact drift apart the first time something // separately: two sources for one fact drift apart the first time something
@@ -2081,6 +2083,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0'); const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0'); const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0'); const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [spotTTL, setSpotTTL] = useState(0); const [spotTTL, setSpotTTL] = useState(0);
@@ -3840,6 +3843,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label> </> </label> </>
)} )}
</div> </div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
<Checkbox
checked={!!catCfg.digi_as_usb}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
/>
{t('cat.digiUsb')}
</label>
{catCfg.backend === 'omnirig' && ( {catCfg.backend === 'omnirig' && (
<> <>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -5229,6 +5239,131 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
setEditingServer(next); setEditingServer(next);
} }
// Chasing is not a cluster feature.
//
// These switches were born on the DX Cluster page because the cluster was the
// only thing that drew their badges. They now decide what the FT decode list
// and Chase new say as well, and settings that govern three screens do not
// belong under the name of one of them. The page is called DXHunter because
// that is what it is about — hunting DX — and because more of DXHunter's
// ideas are meant to land beside them.
function DXHunterPanel() {
return (
<>
<h3 className="text-sm font-semibold">{t('sec.dxhunter')}</h3>
<p className="text-xs text-muted-foreground -mt-1">{t('dxh.intro')}</p>
<div className="space-y-3">
<div className="border-t border-border/60 pt-3 space-y-2">
{/* Chase switches: off, the marker stops being TOLD, everywhere at
once badge, colour, filter chip, reference column. A "new band
+ new POTA" spot then reads NEW BAND alone. */}
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
<Checkbox checked={chasePotaOn}
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
{t('clu.chasePota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
<Checkbox checked={chaseSotaOn}
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
<Checkbox checked={chaseCountyOn}
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
{t('clu.chaseCounty')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
<Checkbox checked={chasePfxOn}
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
{t('clu.chasePfx')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseStateHint')}>
<Checkbox checked={chaseStateOn}
onCheckedChange={(c) => { const v = !!c; setChaseStateOn(v); writeUiPref('opslog.chaseState', v ? '1' : '0'); }} />
{t('clu.chaseState')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5"
onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label>
{chaseGrids && (
<p className="pl-6 text-xs text-muted-foreground">
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseNew} className="mt-0.5"
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
</div>
</div>
</>
);
}
function ClusterPanel() { function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of // Written on every keystroke. This panel has no Save button, and a pair of
@@ -5431,107 +5566,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-2">
{/* Chase switches: off, the marker stops being TOLD, everywhere at
once badge, colour, filter chip, reference column. A "new band
+ new POTA" spot then reads NEW BAND alone. */}
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
<Checkbox checked={chasePotaOn}
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
{t('clu.chasePota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
<Checkbox checked={chaseSotaOn}
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
<Checkbox checked={chaseCountyOn}
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
{t('clu.chaseCounty')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
<Checkbox checked={chasePfxOn}
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
{t('clu.chasePfx')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5"
onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label>
{chaseGrids && (
<p className="pl-6 text-xs text-muted-foreground">
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseNew} className="mt-0.5"
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
</div>
{/* Band-opening watch. It lives HERE, with the cluster nodes, because {/* Band-opening watch. It lives HERE, with the cluster nodes, because
switching it on adds two of them the operator should see that switching it on adds two of them the operator should see that
happen where it happens rather than find nodes they did not add. */} happen where it happens rather than find nodes they did not add. */}
@@ -8036,6 +8070,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
'lists-modes': ModesPanel, 'lists-modes': ModesPanel,
'lists-satellites': SatellitesPanel, 'lists-satellites': SatellitesPanel,
cluster: ClusterPanel, cluster: ClusterPanel,
dxhunter: DXHunterPanel,
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
// Module-scope components, wrapped so their props can be passed. The nested // Module-scope components, wrapped so their props can be passed. The nested
// panels below go through PanelHost instead — which is what now lets either // panels below go through PanelHost instead — which is what now lets either
+23
View File
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
export function bandSegments(band: string): Seg[] { export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? []; return plan().segments[band] ?? [];
} }
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
// outside every known allocation.
//
// Lives here rather than in a panel because more than one place has to answer
// the same question the same way: the entry form retunes on a typed frequency,
// and the QSO editor has to keep band and frequency agreeing when one of them
// is corrected. Kept in step with BandFromHz on the Go side.
export function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
File diff suppressed because one or more lines are too long
+33 -3
View File
@@ -20,7 +20,7 @@ export type SpotDisplayOptions = {
// keep being computed; only the telling stops, so ticking the box back on // keep being computed; only the telling stops, so ticking the box back on
// needs no rescan. // needs no rescan.
chasePota: boolean; chaseSota: boolean; chasePota: boolean; chaseSota: boolean;
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
}; };
// chasePota/chaseSota read the switches directly — for the places that show a // chasePota/chaseSota read the switches directly — for the places that show a
@@ -37,6 +37,9 @@ export function chaseCounty(): boolean {
export function chasePfx(): boolean { export function chasePfx(): boolean {
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; } try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
} }
export function chaseState(): boolean {
try { return localStorage.getItem('opslog.chaseState') !== '0'; } catch { return true; }
}
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the // chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
// mirror on load and on toggle) so the display layer can gate NEW GRID without // mirror on load and on toggle) so the display layer can gate NEW GRID without
// an async round-trip per row. // an async round-trip per row.
@@ -44,6 +47,28 @@ export function chaseGrid(): boolean {
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; } try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
} }
// chaseAllows answers "does this operator chase this kind of thing at all?"
// for the ORTHOGONAL markers — park, square, prefix, county.
//
// The switches were written for the cluster and stayed there, so an operator
// who does not chase parks still met NEW POTA in the decode list and in Chase
// new: the same badge, withdrawn on one screen and shouting on the next. The
// setting is about what the operator hunts, not about which panel is open.
//
// The keys are both the status-entry field names (new_pota…) and the shorter
// category names the panels filter with (pota…), so one call serves both.
// Anything without a switch of its own — a new US state — is always allowed.
export function chaseAllows(key: string): boolean {
switch (key) {
case 'new_pota': case 'pota': return chasePota();
case 'new_grid': case 'grid': return chaseGrid();
case 'new_pfx': case 'pfx': return chasePfx();
case 'new_county': case 'cty': return chaseCounty();
case 'new_state': case 'state': return chaseState();
default: return true;
}
}
// Both options are withdrawn from the filter panel for now. The machinery below // Both options are withdrawn from the filter panel for now. The machinery below
// is deliberately kept whole — it is correct and hard-won — so putting the two // is deliberately kept whole — it is correct and hard-won — so putting the two
// switches back is this one flag and the block they came from in App.tsx. // switches back is this one flag and the block they came from in App.tsx.
@@ -57,7 +82,7 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
// The EXPOSED flag only withdraws the two original switches; the chase // The EXPOSED flag only withdraws the two original switches; the chase
// switches are live regardless. // switches are live regardless.
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) { if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid() }; return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseState: chaseState() };
} }
try { try {
return { return {
@@ -65,9 +90,10 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1', slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
chasePota: chasePota(), chaseSota: chaseSota(), chasePota: chasePota(), chaseSota: chaseSota(),
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
chaseState: chaseState(),
}; };
} catch { } catch {
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true }; return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true, chaseState: true };
} }
} }
@@ -79,6 +105,7 @@ type Entry = {
new_pota?: boolean; new_pota?: boolean;
new_pfx?: boolean; new_pfx?: boolean;
new_grid?: boolean; new_grid?: boolean;
new_state?: boolean;
} | undefined; } | undefined;
// applySpotDisplay rewrites a status entry per the options, so every consumer — // applySpotDisplay rewrites a status entry per the options, so every consumer —
@@ -123,6 +150,9 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (!o.chaseGrid && e.new_grid) { if (!o.chaseGrid && e.new_grid) {
e = { ...e, new_grid: false } as NonNullable<T>; e = { ...e, new_grid: false } as NonNullable<T>;
} }
if (!o.chaseState && e.new_state) {
e = { ...e, new_state: false } as NonNullable<T>;
}
return e; return e;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.6'; export const APP_VERSION = '0.27.8';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -1397,6 +1397,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>; export function UploadCallsign(arg1:string):Promise<string>;
export function UploadFullLogHamQTH():Promise<void>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>; export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>; export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
+4
View File
@@ -2730,6 +2730,10 @@ export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1); return window['go']['main']['App']['UploadCallsign'](arg1);
} }
export function UploadFullLogHamQTH() {
return window['go']['main']['App']['UploadFullLogHamQTH']();
}
export function UploadQSOsManual(arg1, arg2) { export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2); return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
} }
+2
View File
@@ -2298,6 +2298,7 @@ export namespace main {
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
omnirig_vfo: string; omnirig_vfo: string;
digi_as_usb: boolean;
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
@@ -2350,6 +2351,7 @@ export namespace main {
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"]; this.omnirig_vfo = source["omnirig_vfo"];
this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
+8
View File
@@ -304,6 +304,14 @@ type SpotInfo struct {
BackgroundColor string BackgroundColor string
Comment string Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default) LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
// Priority is SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// It matters because the panadapter has finite room: spots close in
// frequency are stacked behind a "+" and only one of them is drawn. The
// radio picks that one by priority — so an entity never worked can sit
// invisible behind three stations already in the log unless we say which
// is worth the space. 0 leaves the field off the command entirely.
Priority int
} }
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio // Spotter is an OPTIONAL backend capability: show cluster spots on the radio
+5
View File
@@ -1557,6 +1557,10 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if hadOld { if hadOld {
f.send(fmt.Sprintf("spot remove %d", old)) f.send(fmt.Sprintf("spot remove %d", old))
} }
prio := ""
if s.Priority >= 1 && s.Priority <= 5 {
prio = fmt.Sprintf(" priority=%d", s.Priority)
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d", cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the // Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
@@ -1575,6 +1579,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if c := flexEncode(s.Comment); c != "" { if c := flexEncode(s.Comment); c != "" {
cmd += " comment=" + c cmd += " comment=" + c
} }
cmd += prio
seq := f.send(cmd) seq := f.send(cmd)
if seq > 0 { if seq > 0 {
// Remember which call this add was for; the R<seq> response carries the // Remember which call this add was for; the R<seq> response carries the
+8
View File
@@ -1543,6 +1543,14 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
return civ.ModeCW, false, nil return civ.ModeCW, false, nil
case "SSB": case "SSB":
return usb, false, nil return usb, false, nil
case "USB":
// The SIDEBAND, asked for by name. "SSB" resolves to whichever side the
// band convention wants, which is right for a logged mode and useless
// when the operator means "put this radio in USB" — on 40 m there was no
// way to say it at all, and the console's own button could not either.
return civ.ModeUSB, false, nil
case "LSB":
return civ.ModeLSB, false, nil
case "AM": case "AM":
return civ.ModeAM, false, nil return civ.ModeAM, false, nil
case "FM": case "FM":
+139
View File
@@ -1,9 +1,13 @@
package extsvc package extsvc
import ( import (
"archive/tar"
"bytes"
"compress/gzip"
"context" "context"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -18,6 +22,20 @@ import (
// duplicate…, reason in the body), 403 wrong credentials, 500 server error. // duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php" const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
// documentation says "you always have to upload whole log. HamQTH doesn't
// support partial upload" — the file REPLACES what is on the site. That is why
// it is not the batch path for a selection, and why the caller must have said
// so out loud before we get here.
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
// hamqthMaxUpload is the documented ceiling for one upload.
const hamqthMaxUpload = 20 << 20
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
// under the limit: the multipart envelope and the form fields ride along too.
const hamqthCompressAbove = 12 << 20
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH // hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings // endpoint that cannot change anything in the log, which is what the settings
// Test button must call. // Test button must call.
@@ -92,6 +110,127 @@ func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, c
} }
} }
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
//
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
// for this callsign that is not in this file stops existing. The caller owns
// the confirmation.
//
// The file goes in the multipart field "f" (HamQTH's own curl example:
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
// 20 MB and a six-figure log passes it as plain text.
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
case strings.TrimSpace(adifText) == "":
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
}
payload := []byte(adifText)
name := "opslog.adi"
if len(payload) > hamqthCompressAbove {
gz, err := tarGzADIF(payload)
if err != nil {
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
}
payload, name = gz, "opslog.tar.gz"
}
if len(payload) > hamqthMaxUpload {
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
len(payload)>>20, hamqthMaxUpload>>20)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("u", user)
_ = mw.WriteField("p", cfg.Password)
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
_ = mw.WriteField("c", c)
}
_ = mw.WriteField("send_log", "OK")
fw, err := mw.CreateFormFile("f", name)
if err != nil {
return UploadResult{}, err
}
if _, err := fw.Write(payload); err != nil {
return UploadResult{}, err
}
if err := mw.Close(); err != nil {
return UploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
// A whole log is a long POST on a slow uplink.
client = &http.Client{Timeout: 10 * time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(raw))
if looksLikeHTML(msg) {
msg = ""
}
if resp.StatusCode != http.StatusOK {
if msg != "" && len(msg) < 300 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
// The site answers in prose, and only its own refusals are worth reading
// back: the ADIF itself is validated later, in the background, and any
// complaint about it reaches the operator by e-mail rather than here.
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "successfully"):
return UploadResult{OK: true, Message: msg}, nil
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case strings.Contains(low, "cannot upload log for this callsign"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case msg == "":
// HTTP 200 with nothing to say: taken as accepted, and said so.
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
default:
return UploadResult{OK: false, Message: msg}, nil
}
}
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
// a .adi/.adif member for HamQTH to find the log in it.
func tarGzADIF(adif []byte) ([]byte, error) {
var out bytes.Buffer
gz := gzip.NewWriter(&out)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
}); err != nil {
return nil, err
}
if _, err := tw.Write(adif); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// TestHamQTH verifies the credentials against the callbook session login — // TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log. // authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
+27 -2
View File
@@ -995,6 +995,12 @@ var bulkEditableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE", "hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM", "hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE", "hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH, same story and SENT only — the site publishes no confirmations,
// so there is no received side to edit. Bulk-editable for the one case that
// matters: a log uploaded to HamQTH by hand, which OpsLog would otherwise
// offer to send all over again.
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
} }
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "". // BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
@@ -1403,6 +1409,10 @@ var filterableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE", "hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM", "hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE", "hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH — sent only, and filterable for the question that precedes every
// backlog upload: "which contacts have never gone there".
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
} }
// FilterableFields returns the whitelist (for the frontend to build its field // FilterableFields returns the whitelist (for the frontend to build its field
@@ -2003,7 +2013,7 @@ type WorkedBefore struct {
// at all about yesterday. // at all about yesterday.
type BandStatus struct { type BandStatus struct {
Band string `json:"band"` // ADIF lowercase band, e.g. "20m" Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
Class string `json:"class"` // "PH" | "CW" | "DIG" Class string `json:"class"` // "PH" | "CW" | "DIG", or a raw digital mode ("FT8", "RTTY"…)
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
// Call is "", "w" (worked with this callsign) or "c" (confirmed with it). // Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
Call string `json:"call,omitempty"` Call string `json:"call,omitempty"`
@@ -2392,12 +2402,26 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
return wb, fmt.Errorf("scan band status: %w", err) return wb, fmt.Errorf("scan band status: %w", err)
} }
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1) code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
k := cellKey{band: band, class: modeClass(mode)} keys := []cellKey{{band: band, class: modeClass(mode)}}
// The DIGITAL row can be cycled through the individual modes in the UI —
// FT8, then FT4, then RTTY — so the same cell is also published under the
// raw mode name. The query already grouped by mode; only the collapse to
// a class threw that away, and re-asking the database for it would be a
// second scan to learn what we had just read.
//
// Digital only: PH and CW have nothing to cycle through, and publishing
// "SSB" beside "PH" would just double the payload.
if um := strings.ToUpper(mode); modeClass(mode) == "DIG" && um != "" {
keys = append(keys, cellKey{band: band, class: um})
}
for _, k := range keys {
if cur, ok := best[k]; !ok || code > cur { if cur, ok := best[k]; !ok || code > cur {
best[k] = code best[k] = code
} }
}
// Confirmed beats worked here too, and neither is ever erased by the // Confirmed beats worked here too, and neither is ever erased by the
// entity: this is only ever about the callsign. // entity: this is only ever about the callsign.
for _, k := range keys {
switch { switch {
case callC == 1: case callC == 1:
callByCell[k] = "c" callByCell[k] = "c"
@@ -2405,6 +2429,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
callByCell[k] = "w" callByCell[k] = "w"
} }
} }
}
statusRows.Close() statusRows.Close()
codeStr := bandStatusNames codeStr := bandStatusNames
for k, code := range best { for k, code := range best {
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"strings"
"testing"
"hamlog/internal/adif"
"hamlog/internal/qso"
)
// A contact that was not split still has a receive side, and the record
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
func TestFillRXDefaults(t *testing.T) {
hz := int64(14074000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
fillRXDefaults(&q)
if q.BandRX != "20m" {
t.Errorf("BandRX = %q, want 20m", q.BandRX)
}
if q.FreqRXHz == nil || *q.FreqRXHz != hz {
t.Errorf("FreqRXHz = %v, want %d", q.FreqRXHz, hz)
}
// Assert on the RECORD another logger reads, not merely on the struct:
// both halves of the receive side have to reach it.
rec := strings.ToUpper(adif.SingleRecordADIF(q))
if !strings.Contains(rec, "<BAND_RX:3>20M") {
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
}
if !strings.Contains(rec, "<FREQ_RX:9>14.074000") {
t.Errorf("FREQ_RX missing from the forwarded record:\n%s", rec)
}
}
// A genuine split contact keeps what it was given.
func TestFillRXDefaultsKeepsSplit(t *testing.T) {
tx, rx := int64(14195000), int64(14205000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
fillRXDefaults(&q)
if q.BandRX != "17m" || q.FreqRXHz == nil || *q.FreqRXHz != rx {
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
}
}
+24
View File
@@ -314,6 +314,30 @@ func bracket(s string) string {
// //
// Read from the same cached palette as the colours — see spotColorFor — because // Read from the same cached palette as the colours — see spotColorFor — because
// it is consulted on every spot from every cluster. // it is consulted on every spot from every cluster.
// spotPriority ranks a spot for SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// The panadapter has finite room: spots close in frequency are stacked behind a
// "+" and only one of them is drawn. The radio chooses by priority — so an
// entity never worked was disappearing behind three stations already in the
// log, simply because OpsLog never said which was worth the space.
//
// The tiers are the operator's own: the entity never worked first, then the
// pieces of one already worked (band, mode, slot), then the reference hunts
// (POTA, SOTA, county, prefix), and everything else last. My own callsign rides
// at the top with the first — it is how a multi-op sees where it already is.
func spotPriority(status string) int {
switch status {
case "new", "my-call":
return 1
case "new-band-mode", "new-band", "new-mode", "new-slot":
return 2
case "new-pota", "new-sota", "new-county", "new-pfx":
return 3
default:
return 5
}
}
func (a *App) spotHidden(status string) bool { func (a *App) spotHidden(status string) bool {
a.spotColorsMu.Lock() a.spotColorsMu.Lock()
if a.spotColorsCache == nil { if a.spotColorsCache == nil {
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.6" appVersion = "0.27.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"testing"
"time"
"hamlog/internal/qso"
)
// The operator's report: South Africa worked on 15m FT8 but confirmed only by
// eQSL/Club Log/QRZ, with the chase set to "new + unconfirmed" over LoTW and
// paper QSL. The verdict must be NEW BAND — that is what the settings ask for —
// and it must be flagged UNCONFIRMED, so the badge reads "worked, QSL missing"
// rather than "never worked here".
func TestUnconfirmedBandIsFlagged(t *testing.T) {
a := syncTestApp(t)
hz := int64(21074000)
add := func(call, band, mode string, lotw, eqsl string, dxcc int) {
q := qso.QSO{
Callsign: call, Band: band, Mode: mode,
QSODate: time.Now().UTC().Add(-48 * time.Hour),
FreqHz: &hz, DXCC: &dxcc, Country: "South Africa",
LOTWRcvd: lotw, EQSLRcvd: eqsl,
}
if _, err := a.qso.Add(a.ctx, q); err != nil {
t.Fatalf("add %s: %v", call, err)
}
}
// 20m is LoTW-confirmed, so the ENTITY is confirmed…
add("ZS1AAA", "20m", "FT8", "Y", "Y", 462)
// …but every 15m contact is confirmed only by eQSL, which this operator
// does not count.
add("ZS6GAV", "15m", "FT8", "N", "Y", 462)
add("ZS4AW", "15m", "FT8", "N", "N", 462)
pred := qso.ConfirmSourcesPredicate([]string{"lotw", "card"})
all, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, "")
if err != nil {
t.Fatalf("all ledger: %v", err)
}
conf, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, pred)
if err != nil {
t.Fatalf("confirmed ledger: %v", err)
}
if _, ok := all[462]; !ok {
t.Fatal("the all-QSO ledger has no South Africa at all")
}
if _, ok := all[462].Bands["15m"]; !ok {
t.Error("all-QSO ledger: 15m missing — the dimming test can never fire")
}
if _, ok := all[462].Slots["15m"]["FT8"]; !ok {
t.Error("all-QSO ledger: 15m/FT8 slot missing")
}
c, ok := conf[462]
if !ok {
t.Fatal("confirmed ledger: South Africa missing — the 20m LoTW QSO should put it there")
}
if _, ok := c.Bands["15m"]; ok {
t.Error("confirmed ledger has 15m, but no 15m QSO is confirmed by LoTW or card")
}
// The two halves the UI shows.
status := spotEntityStatus(true, false, true, false, "FT8")
if status != "new-band" {
t.Errorf("status = %q, want new-band", status)
}
_, bAll := all[462].Bands["15m"]
_, mAll := all[462].Modes["FT8"]
_, sAll := all[462].Slots["15m"]["FT8"]
if got := spotEntityStatus(true, bAll, mAll, sAll, "FT8"); got != "worked" {
t.Errorf("all-ledger verdict = %q, want worked (→ the badge should be dimmed)", got)
}
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/qso"
"hamlog/internal/settings"
)
// The operator's exact case, end to end through the real verdict path.
//
// South Africa is confirmed (LoTW) on 20m and worked-but-unconfirmed on 15m,
// with the hunt set to "new + unconfirmed" over LoTW and paper QSL. A 15m FT8
// decode must come back NEW BAND — that is what the settings ask — and carry
// the UNCONFIRMED flag, which is what draws the badge as a missing QSL rather
// than a band never worked.
func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "log.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
a.settingsScoped.Store(true)
if err := a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed"); err != nil {
t.Fatalf("set chase mode: %v", err)
}
if err := a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card"); err != nil {
t.Fatalf("set chase sources: %v", err)
}
// The real prefix table, so ZS4AW resolves the way it does in the app.
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
if err := a.dxcc.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
za := 462
add := func(call, band, mode, lotw string) {
hz := int64(21074000)
if _, err := a.qso.Add(a.ctx, qso.QSO{
Callsign: call, Band: band, Mode: mode, FreqHz: &hz,
QSODate: time.Now().UTC().Add(-72 * time.Hour),
DXCC: &za, Country: "South Africa", LOTWRcvd: lotw,
}); err != nil {
t.Fatalf("add %s: %v", call, err)
}
}
add("ZS1AAA", "20m", "FT8", "Y") // the entity IS confirmed, elsewhere
add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed
add("ZS4AW", "15m", "FT8", "N")
// A station of the entity that is NOT the one already worked on this slot:
// that case has a rule of its own, tested below.
got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS9XX", Band: "15m", Mode: "FT8"}})
if len(got) != 1 {
t.Fatalf("got %d results", len(got))
}
if got[0].Status != "new-band" {
t.Fatalf("status = %q, want new-band", got[0].Status)
}
if !got[0].UnconfStatus {
t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked")
}
}
// The same station, already worked on the same band and mode, has nothing left
// to give: working it again cannot turn a missing QSL into a confirmation. The
// need belongs to the entity, not to that callsign, so the badge goes quiet on
// it — and stays on every other station of the entity.
func TestWorkedSameSlotDropsTheUnconfirmedBadge(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "log.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
a.settingsScoped.Store(true)
_ = a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed")
_ = a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card")
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
if err := a.dxcc.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
hz := int64(10136000)
if _, err := a.qso.Add(a.ctx, qso.QSO{
Callsign: "TN8GD", Band: "30m", Mode: "FT8", FreqHz: &hz,
QSODate: time.Now().UTC().Add(-24 * time.Hour),
}); err != nil {
t.Fatalf("add: %v", err)
}
got := a.ClusterSpotStatuses([]SpotQuery{
{Call: "TN8GD", Band: "30m", Mode: "FT8"}, // the very station worked
{Call: "TN4XY", Band: "30m", Mode: "FT8"}, // another in the same entity
})
if got[0].Status == "new" || got[0].UnconfStatus {
t.Errorf("the worked station still advertises a need: status=%q unconf=%v",
got[0].Status, got[0].UnconfStatus)
}
if got[1].Status != "new" {
t.Errorf("another station in the entity lost its badge: status=%q", got[1].Status)
}
}