feat: docked watch-list panel, and auto-call learns the orthogonal markers

The watch list was a tab, and an operator working FT8 lives on the decodes
one: a station they had asked to be told about turned up on a screen they
were not looking at. The same answer is now docked in the widget strip,
above the tabs, reduced to what is worth acting on — on the air and still
needed, one row per band and mode, with the cluster's own NEW DXCC /
NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The
"active and needed" answer costs a debounced query per visible slot, so it
is written once (lib/watchlistSpots) and the tab uses it too.

Auto-call:

- It answers a new prefix, county, state, square or park. Those markers
  are orthogonal to the entity, they ranked as nothing-needed, and the
  engine sat through a never-worked WPX prefix calling CQ. New rung at
  the foot of the ladder, gated by the chase switches the badges use —
  which meant making those switches portable, since the backend cannot
  read localStorage.
- It calls THROUGH a pileup. Giving up the moment the DX answered
  somebody else is precisely how a queue is not worked; the call and miss
  counters already bound the effort, and a station in mid-exchange is
  still never chosen as a new target.

The PSK Reporter panel now follows the station auto-call is waiting for:
the analysis takes a history query and a period or two to fill, so
starting it when the DX comes free is starting it too late.

Callbook lookup: a compound callsign with a page of its OWN keeps that
page's location. QRZ files HP/WE9G under exactly that form, with the
Panama square the station is operating from, and the rule that drops a
home address from a portable call was throwing it away. The record's own
country tells an operation's page from a home page.

Changelog: entries may open with [NEW], drawn as a pill in the What's new
dialog — a release is mostly fixes and the two or three genuinely new
things should not have to be found by reading all of it.
This commit is contained in:
2026-09-06 00:08:55 +02:00
parent 23323c91e0
commit 03e71bfdf2
15 changed files with 733 additions and 228 deletions
+50 -35
View File
@@ -58,10 +58,20 @@ type Need int
const (
NeedNone Need = iota
NeedSlot // entity worked on this band and in this mode, never together
NeedMode // entity never worked in this mode
NeedBand // entity never worked on this band
NeedDXCC // entity never worked at all
// NeedExtra: the ENTITY has nothing left to give on this band and mode, but
// the station carries something else that has never been worked — a WPX
// prefix, a square, a US county or state, a park. Those are what the cluster
// calls the orthogonal markers, and an operator who ticked them in the chase
// settings is hunting them: leaving them at nothing-needed meant auto-call
// watched a never-worked prefix call CQ and did nothing.
//
// The lowest rung, deliberately. It is worth calling when nothing better is
// on the air, and never worth leaving a new band for.
NeedExtra
NeedSlot // entity worked on this band and in this mode, never together
NeedMode // entity never worked in this mode
NeedBand // entity never worked on this band
NeedDXCC // entity never worked at all
)
func (n Need) String() string {
@@ -74,6 +84,8 @@ func (n Need) String() string {
return "mode"
case NeedSlot:
return "slot"
case NeedExtra:
return "extra"
}
return "-"
}
@@ -110,6 +122,9 @@ type Candidate struct {
Decode
Need Need
Watched bool
// Extra names what NeedExtra is about — "prefix", "county", "square" — so the
// decision line says why the station was worth a call. Empty otherwise.
Extra string
// Unconfirmed says the need above exists ONLY because a contact was never
// confirmed — the operator hunts "new + unconfirmed", the band is in the log
// and the QSL is not. A real need outranks it; see rank.
@@ -427,6 +442,16 @@ func (e *Engine) maxAttempts(c Candidate) int {
// 10 mode 8 mode unconf
// 6 slot 4 slot unconf
// 0 nothing to call for
//
// needLabel is what the candidate is worth, in words: the rung, or the name of
// the orthogonal marker when that is the whole reason for the call.
func needLabel(c Candidate) string {
if c.Need == NeedExtra && c.Extra != "" {
return "new " + c.Extra
}
return c.Need.String()
}
func rank(c Candidate) int {
r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
if c.Need != NeedNone && !c.Unconfirmed {
@@ -726,36 +751,26 @@ func (e *Engine) OnPeriod(p Period) Action {
e.heldSince = p.At
return Action{}
}
// IT IS WORKING SOMEBODY ELSE. Stop calling it.
// IT IS WORKING SOMEBODY ELSE, AND WE GO ON CALLING.
//
// Callability was tested when the station was CHOSEN and never again
// while it was held — so a station picked on its CQ, which then
// answered another caller, went on being called for the whole seven
// attempts. Two minutes of transmitting at a station that is in a
// QSO and cannot hear the call, while the band moves on.
// This used to stop, on the reasoning that a station in a QSO cannot
// hear the call. That is how a pileup is NOT worked: a DX with a
// queue answers one caller per period, and the only way to be the
// next one is to keep calling while it works the others. An operator
// watched exactly that — D44TWO finishing a contact, the call
// started, the DX coming back to somebody else, and the engine
// giving up on a station that was about to be free.
//
// Its final frame does not count: a station sending RR73 to somebody
// else is one period from being free, which is the best moment there
// is to be calling it. Only a report or a grid to another station
// means the exchange is under way.
if !callable(*seen, p.MyCall) {
why := fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))
e.drop()
// AND THE SLOT IS STILL FREE. This period's decodes are in hand,
// so the next station is picked from them now instead of fifteen
// seconds from now — the operator watching the screen sees a CQ
// two rows down go unanswered for a whole period, and reads that
// as the engine having missed it.
//
// Never while transmitting: a reply then switches the decoder's
// call mid-over and cuts our own transmission in half, which is
// the whole reason the halt below is Soft.
if !p.TX.Transmitting {
if a := e.pick(p, why); a.Kind == DoReply {
return a
}
}
return Action{Kind: DoHalt, Soft: true, Reason: why}
// The effort is already bounded: seven calls, fifteen for a watched
// station, and the miss counter for one that goes off the air. And
// nothing is wasted while it is busy — a better station may still
// take the slot, because it has not answered us (see preempt).
//
// The callable test still governs the CHOICE of a target, where it
// belongs: a reply to a decode in mid-exchange is one WSJT-X and
// JTDX may refuse outright.
if e.trace != nil && !callable(*seen, p.MyCall) {
e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
}
return Action{}
}
@@ -857,7 +872,7 @@ func (e *Engine) pick(p Period, why string) Action {
e.txSlot = slotOf(p.At, periodSecs(p, *best))
e.answered = false
e.waiting = ""
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need)
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), needLabel(*best))
if why != "" {
reason = why + " — " + reason
}
@@ -893,7 +908,7 @@ func (e *Engine) tracePick(p Period) {
best := make([]string, 0, 3)
for i := 0; i < len(ok) && i < 3; i++ {
best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)",
ok[i].c.Call, watchedTag(ok[i].c), ok[i].c.Need, ok[i].c.SNR, ok[i].r))
ok[i].c.Call, watchedTag(ok[i].c), needLabel(ok[i].c), ok[i].c.SNR, ok[i].r))
}
why := make([]string, 0, len(refused))
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
@@ -1059,7 +1074,7 @@ func (e *Engine) preempt(p Period) (Action, bool) {
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
return Action{Kind: DoReply, Decode: best.Decode,
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
best.Call, watchedTag(*best), best.Need, was)}, true
best.Call, watchedTag(*best), needLabel(*best), was)}, true
}
// drop lets the target go with no verdict attached: not worked, not given up