diff --git a/app_gate.go b/app_gate.go
index dfa2cdf..525eea7 100644
--- a/app_gate.go
+++ b/app_gate.go
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
+ "94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
}
// callDenied reports whether a callsign is on deniedCallHashes. The call is
diff --git a/autocall.go b/autocall.go
index 90538cc..aeed512 100644
--- a/autocall.go
+++ b/autocall.go
@@ -416,16 +416,18 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
}
+ chase := a.autoCallChase()
cands := make([]autocall.Candidate, 0, len(uniq))
for _, dd := range uniq {
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
- c := candidateOf(dd.d, st)
+ c := candidateOf(dd.d, st, chase)
c.Watched = a.autoCallWatched(dd.d.Call)
c.Hidden = a.autoCallHidden(dd.d.Call)
cands = append(cands, c)
}
tx := a.autoCallTX()
+
act := a.autoCallEngine().OnPeriod(autocall.Period{
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
MyCall: a.opCall,
@@ -437,8 +439,14 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
//
// Split out and kept pure because ONE line of it was wrong for weeks and
// nothing could catch it: the entity's verdict was read as the station's.
-func candidateOf(d autocall.Decode, st SpotStatus) autocall.Candidate {
- return autocall.Candidate{
+// chaseExtras is what the operator hunts BESIDES entities — the cluster's
+// orthogonal markers, from the same switches that decide whether the badges are
+// shown at all (Settings → DX Cluster). One answer for the eye and the
+// transmitter: a marker withdrawn from the screen is not one to call for.
+type chaseExtras struct{ pota, grid, pfx, county, state bool }
+
+func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
+ c := autocall.Candidate{
Decode: d,
// The ENTITY's verdict decides what is still needed…
Need: autoCallNeedOf(st.Status),
@@ -455,6 +463,51 @@ func candidateOf(d autocall.Decode, st SpotStatus) autocall.Candidate {
// less than the same need never worked at all.
Unconfirmed: st.UnconfStatus,
}
+ // NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
+ //
+ // A prefix, a square, a county, a state, a park: never worked, orthogonal to
+ // the entity's verdict, and exactly what the operator ticked in the chase
+ // settings. They ranked at nothing-needed, so auto-call sat through a
+ // never-worked WPX prefix calling CQ and did not answer it.
+ //
+ // Only when the entity has nothing to add — a new band that is ALSO a new
+ // prefix is a new band, and says so.
+ if c.Need == autocall.NeedNone {
+ switch {
+ case ch.pfx && st.NewPfx:
+ c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
+ case ch.county && st.NewCounty:
+ c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
+ case ch.state && st.NewState:
+ c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
+ case ch.grid && st.NewGrid:
+ c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
+ case ch.pota && st.NewPOTA:
+ c.Need, c.Extra = autocall.NeedExtra, "park"
+ }
+ }
+ return c
+}
+
+// autoCallChase reads the chase switches the cluster and the decode list use.
+//
+// Read once per period rather than per decode: they are settings-store reads,
+// and the period loop runs over every station on the band.
+func (a *App) autoCallChase() chaseExtras {
+ on := func(key string) bool {
+ if a.settings == nil {
+ return true
+ }
+ v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
+ return v != "0" // unset means on, as it does on the screen
+ }
+ return chaseExtras{
+ pota: on("chasePota"),
+ grid: on("chaseGrids"),
+ pfx: on("chasePfx"),
+ county: on("chaseCounty"),
+ state: on("chaseState"),
+ }
}
// autoCallDo carries out a decision and records it.
diff --git a/autocall_wiring_test.go b/autocall_wiring_test.go
index 7da6a98..50e5bf1 100644
--- a/autocall_wiring_test.go
+++ b/autocall_wiring_test.go
@@ -12,12 +12,15 @@ import (
// engine refused twenty decodes out of twenty-one as "worked" — a watched
// DXpedition calling CQ among them — because the ENTITY's status was read as
// the station's.
+// allChased is the default: every orthogonal marker ticked.
+var allChased = chaseExtras{pota: true, grid: true, pfx: true, county: true, state: true}
+
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
// from it, and calling it is NOT a duplicate.
- c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false})
+ c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false}, allChased)
if c.Worked {
t.Error("a station never worked was refused because its entity was")
}
@@ -26,13 +29,53 @@ func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
}
// The same callsign already in the log on this band and mode IS a duplicate.
- if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}); !c.Worked {
+ if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}, allChased); !c.Worked {
t.Error("a callsign already worked on this band and mode was not flagged")
}
// And a real need still carries through, with the unconfirmed distinction.
- c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true})
+ c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true}, allChased)
if c.Need != autocall.NeedBand || !c.Unconfirmed {
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
}
}
+
+// A station whose ENTITY has nothing left to give can still be the reason the
+// operator is on the band: a WPX prefix, a county, a square, a park that has
+// never been worked. Reported from the air — auto-call sat through a
+// never-worked prefix calling CQ and did nothing.
+func TestOrthogonalMarkersAreWorthACall(t *testing.T) {
+ d := autocall.Decode{Call: "BH2SWB", Band: "10m", Mode: "FT8", IsNew: true}
+ worked := SpotStatus{Status: "worked"}
+
+ for _, tc := range []struct {
+ what string
+ st SpotStatus
+ want string
+ }{
+ {"prefix", SpotStatus{Status: "worked", NewPfx: true}, "prefix"},
+ {"county", SpotStatus{Status: "worked", NewCounty: true}, "county"},
+ {"state", SpotStatus{Status: "worked", NewState: true}, "state"},
+ {"square", SpotStatus{Status: "worked", NewGrid: true}, "square"},
+ {"park", SpotStatus{Status: "worked", NewPOTA: true}, "park"},
+ } {
+ c := candidateOf(d, tc.st, allChased)
+ if c.Need != autocall.NeedExtra || c.Extra != tc.want {
+ t.Errorf("%s: need=%v extra=%q, want extra %q", tc.what, c.Need, c.Extra, tc.want)
+ }
+ // And not when the operator does not chase that kind of thing: the
+ // switch that withdraws the badge withdraws the call with it.
+ if c := candidateOf(d, tc.st, chaseExtras{}); c.Need != autocall.NeedNone {
+ t.Errorf("%s: called for a marker the operator does not chase", tc.what)
+ }
+ }
+
+ // It is the LOWEST rung: a real need on the entity still says what it is.
+ if c := candidateOf(d, SpotStatus{Status: "new-band", NewPfx: true}, allChased); c.Need != autocall.NeedBand {
+ t.Errorf("need = %v — a new band that is also a new prefix is a new band", c.Need)
+ }
+ // Nothing anywhere is still nothing.
+ if c := candidateOf(d, worked, allChased); c.Need != autocall.NeedNone {
+ t.Errorf("need = %v on a station with nothing to gain", c.Need)
+ }
+}
diff --git a/changelog.json b/changelog.json
index 23d2130..31cfa36 100644
--- a/changelog.json
+++ b/changelog.json
@@ -3,50 +3,48 @@
"version": "0.27.13",
"date": "",
"en": [
- "Chase new: a band selection of its own, under the option (160 m to 70 cm). The station’s band list still applies underneath — this one says what is worth WATCHING tonight.",
+ "[NEW] Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
+ "[NEW] Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
+ "Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
+ "[NEW] A docked watch-list panel, showing only what is ON THE AIR and still needed — one row per band and mode, freshest first, click to tune, with the cluster’s own NEW DXCC / NEW BAND / NEW SLOT badge on each row. The Watchlist tab is a tab, and an operator working FT8 lives on the decodes one: a station you asked to be told about was appearing on a screen you were not looking at. Turn it on with the bell in the toolbar.",
+ "[NEW] FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
+ "[NEW] The PSK Reporter panel switches to the station auto-call is WAITING for (the hourglass), while nothing is being called. Its analysis takes a history query and a period or two of live reports to fill, so starting it when the DX finally comes free is starting it too late — the wait is exactly what it should be spent on.",
+ "[NEW] Chase new: a band selection of its own, under the option (160 m to 70 cm). The station’s band list still applies underneath — this one says what is worth WATCHING tonight.",
"Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
- "Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
- "PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
- "Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-X’s DXpedition transmit modes, not modes of their own, and the comment fell through to the band’s default.",
"FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
"FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
- "Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, keeps calling a station that has begun working somebody else, ignores a station calling you, misreads MSHV’s two-answers-in-one-line, or refuses nearly everything on the air because it read the entity’s “worked” as the station’s.",
- "Auto-call: the priority order is stricter. A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — but never from a QSO in progress: once the station has come back to you, or you have sent it a report, nothing takes its place.",
- "Auto-call calls only what the decodes list is SHOWING: the filters above it — CQ only, LoTW only, the category chips, continents, minimum report, search — steer the transmitter as well as the eye. The separate “LoTW users only” option is gone.",
- "Auto-call: Halt is now a verdict on the station being called — set aside for the session, never called again until auto-call is switched off and on. It can also log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
- "Auto-call says what it is waiting for. A wanted station that is working somebody else now shows next to the Auto button instead of leaving the panel looking idle.",
"FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
- "FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
+ "Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-X’s DXpedition transmit modes, not modes of their own, and the comment fell through to the band’s default.",
+ "Auto-call: what it calls, and in what order. It answers only what the decodes list is SHOWING — the filters above it (CQ only, LoTW only, the category chips, continents, minimum report, search) now steer the transmitter as well as the eye, and the separate “LoTW users only” option is gone. A new prefix, county, state, square or park is worth a call too, at the foot of the ladder and only for the kinds you chase (Settings → DX Cluster). A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — never from a QSO in progress.",
+ "Auto-call: it calls THROUGH a pileup. It used to give up the moment the station it was calling answered somebody else — which is precisely how a DX with a queue behaves, and the only way to be the next one is to keep calling while it works the others. Still bounded by the call and miss counters, and a station in mid-exchange is still never CHOSEN as a new target.",
+ "Auto-call: safety and control. It is always OFF at launch and after a profile switch, never armed from a stored setting — it is the one feature that puts the station on the air by itself, and OpsLog starts with Windows. Halt is now a verdict: the station is set aside for the session and never called again until auto-call is switched off and on. It says what it is waiting for, showing a wanted station that is working somebody else next to the Auto button. And it can log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
+ "Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, ignores a station calling you, misreads MSHV’s two-answers-in-one-line, refuses nearly everything on the air because it read the entity’s “worked” as the station’s, or opens with two misses already counted against a station it has only just picked.",
"Switching profile no longer leaves the previous logbook’s verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
- "Auto-call is always OFF at launch, and off again after a profile switch — never armed from a stored setting. It is the one thing in OpsLog that puts the station on the air by itself, and OpsLog starts with Windows: arming it is a click somebody has to make.",
- "Auto-call takes the freed slot at once when the station it was calling turns out to be working somebody else. It used to stop and wait a period before choosing again, so a CQ two rows down went unanswered for fifteen seconds — unless we are mid-transmission, where it still waits for the over to finish.",
- "Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
- "Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
- "Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
- "Auto-call: a station just picked no longer starts with misses against it. The counter did not know which slot the station transmits in until it had been decoded a second time, so every period counted — including the one spent transmitting to it, where it cannot be heard at all."
+ "Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
+ "PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
+ "Callbook lookup: a compound callsign with a page of its OWN keeps that page’s location. HP/WE9G is filed on QRZ under exactly that form, with the Panama address and square the station is operating from, and OpsLog was throwing it away — the rule that drops a home address from a portable call was applying to pages that describe the operation itself. The record’s own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
],
"fr": [
- "Chase new : sélection de bandes propre au panneau, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on veut SURVEILLER ce soir.",
+ "[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle d’azimut carrée, aiguille orange qui suit la souris pour qu’un clic parte où on vise, repère jaune sur l’azimut demandé jusqu’à l’arrivée de l’antenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
+ "[NEW] Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — l’un se lit de loin, l’autre est le cadran compact d’avant — et le choix s’applique aussitôt au widget docké comme à Station Control.",
+ "Widget rotor : le bouton Stop ne clignote plus sur un rotor à l’arrêt. Le mouvement était déduit d’un changement d’un degré, soit moins que le tremblement de lecture d’un contrôleur au repos.",
+ "[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN L’AIR et manque encore — une ligne par bande et mode, le plus frais en haut, clic pour s’y caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. L’onglet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station qu’on avait demandé à surveiller apparaissait sur un écran qu’on ne regardait pas. À activer avec la cloche dans la barre d’outils.",
+ "[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à l’unité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
+ "[NEW] Le panneau PSK Reporter bascule sur la station que l’auto-call ATTEND (le sablier), tant que rien n’est appelé. Son analyse met une requête d’historique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, c’est la lancer trop tard — l’attente sert précisément à ça.",
+ "[NEW] Chase new : sélection de bandes propre au panneau, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on veut SURVEILLER ce soir.",
"Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
- "Chase new, corrections : l’en-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser d’anciens verdicts à l’écran.",
- "Panneau PSK Reporter, corrections : l’historique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage n’affiche plus une page vide ; l’offset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
- "Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
"FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire qu’un écran vide.",
"FT decodes : un badge WL rose marque une station de votre watchlist, et l’horloge de période passe au rouge en émission. C’est le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
- "Auto-call : série de corrections issues de l’utilisation en trafic. Il ne coupe plus votre 73, n’appelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, n’insiste plus sur une station qui s’est mise à travailler quelqu’un d’autre, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, et ne refuse plus presque tout ce qui passe parce qu’il prenait le « worked » de l’entité pour celui de la station.",
- "Auto-call : ordre de priorité plus strict. Un indicatif en watchlist passe devant tout ce qui n’y est pas, un vrai besoin passe devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place d’une autre qui n’a pas encore répondu — mais jamais celle d’un QSO en cours : dès que la station vous a répondu, ou que vous lui avez envoyé un report, rien ne la remplace.",
- "Auto-call n’appelle que ce que la liste des décodes AFFICHE : les filtres du dessus — CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche — pilotent l’émission autant que l’œil. L’option distincte « utilisateurs LoTW uniquement » disparaît.",
- "Auto-call : Halt devient un verdict sur la station appelée — mise de côté pour la session, plus jamais appelée jusqu’à ce que l’auto-call soit désactivé puis réactivé. Il peut aussi journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur l’air et pourquoi chaque station a été refusée.",
- "Auto-call dit ce qu’il attend. Une station voulue mais en QSO avec quelqu’un d’autre s’affiche désormais à côté du bouton Auto, au lieu de laisser croire qu’il n’a rien à faire.",
"FT decodes : un message qui VOUS est adressé s’affiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de l’autre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : l’essentiel de ce qu’elle émet s’adresse à d’autres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
- "FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à l’unité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
+ "Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
+ "Auto-call : ce qu’il appelle, et dans quel ordre. Il ne répond qu’à ce que la liste des décodes AFFICHE — les filtres du dessus (CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche) pilotent désormais l’émission autant que l’œil, et l’option distincte « utilisateurs LoTW uniquement » disparaît. Un nouveau préfixe, comté, état, locator ou parc vaut aussi un appel, au dernier barreau de l’échelle et seulement pour ce que vous chassez (Réglages → DX Cluster). Un indicatif en watchlist passe devant tout ce qui n’y est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place d’une autre qui n’a pas encore répondu — jamais celle d’un QSO en cours.",
+ "Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelqu’un d’autre — or c’est exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant qu’il travaille les autres. Toujours borné par les compteurs d’appels et de ratés, et une station en plein échange n’est toujours jamais CHOISIE comme nouvelle cible.",
+ "Auto-call : sécurité et contrôle. Il est toujours ARRÊTÉ au lancement et après un changement de profil, jamais armé depuis un réglage enregistré — c’est la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows. Halt devient un verdict : la station est mise de côté pour la session et n’est plus appelée jusqu’à ce que l’auto-call soit désactivé puis réactivé. Il dit ce qu’il attend, en affichant à côté du bouton Auto la station voulue qui travaille quelqu’un d’autre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur l’air et pourquoi chaque station a été refusée.",
+ "Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, n’appelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, ne refuse plus presque tout ce qui passe parce qu’il prenait le « worked » de l’entité pour celui de la station, et ne démarre plus avec deux ratés au compteur sur une station tout juste choisie.",
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant d’un profil au log vide, tous les décodes et spots restaient marqués NEW jusqu’au redémarrage : l’index des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
- "L’auto-call est toujours ARRÊTÉ au lancement, et de nouveau après un changement de profil — jamais armé depuis un réglage enregistré. C’est la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows : l’armer est un clic que quelqu’un doit faire.",
- "L’auto-call reprend immédiatement le créneau libéré quand la station appelée s’avère être en QSO avec quelqu’un d’autre. Il s’arrêtait et attendait une période avant de rechoisir, laissant un CQ deux lignes plus bas sans réponse pendant quinze secondes — sauf en pleine émission, où il attend toujours la fin de la séquence.",
- "Widget rotor redessiné : carte du monde nocturne derrière une échelle d’azimut carrée, aiguille orange qui suit la souris pour qu’un clic parte où on vise, repère jaune sur l’azimut demandé jusqu’à l’arrivée de l’antenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
- "Widget rotor : le bouton Stop ne clignote plus sur un rotor à l’arrêt. Le mouvement était déduit d’un changement d’un degré, soit moins que le tremblement de lecture d’un contrôleur au repos.",
- "Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — l’un se lit de loin, l’autre est le cadran compact d’avant — et le choix s’applique aussitôt au widget docké comme à Station Control.",
- "Auto-call : une station tout juste choisie ne démarre plus avec des ratés à son compte. Le compteur ignorait sur quelle séquence elle émet tant qu’elle n’avait pas été décodée une seconde fois, donc toutes les périodes comptaient — y compris celle passée à lui émettre dessus, où elle est inaudible par définition."
+ "Chase new, corrections : l’en-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser d’anciens verdicts à l’écran.",
+ "Panneau PSK Reporter, corrections : l’historique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage n’affiche plus une page vide ; l’offset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
+ "Recherche callbook : un indicatif composé qui a sa PROPRE fiche garde la position de cette fiche. HP/WE9G est déposé sur QRZ sous cette forme exacte, avec l’adresse et le locator du Panama d’où la station émet, et OpsLog les jetait — la règle qui écarte l’adresse personnelle d’un indicatif portable s’appliquait aussi aux fiches qui décrivent l’opération elle-même. C’est le pays de la fiche qui les distingue. Les fiches en cache se corrigent à la lecture suivante ; les QSO déjà enregistrés sans locator le restent."
]
},
{
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0820f26..ff36340 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -120,6 +120,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
+import { WatchlistWidget } from '@/components/WatchlistWidget';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
@@ -2774,6 +2775,9 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
+ // Off by default: it is an alerting panel, and one that appears uninvited on
+ // an operator who does not keep a watch list is just a box saying "empty".
+ const [showWatchWidget, setShowWatchWidget] = useState(() => localStorage.getItem('opslog.showWatchWidget') === '1');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
@@ -6357,13 +6361,41 @@ export default function App() {
// Follow the station the digital application says it is calling. A decode
// clicked here sets the target directly (see onCall); this covers the QSO
// started from the other side — WSJT-X's own double-click, or auto-call.
+ //
+ // On the EDGE — when the decoder's DX call actually changes — and not by
+ // comparing it with the panel's current target: the auto-call effect below
+ // sets that target too, and two effects each restoring "their" value from the
+ // other's write is a loop, not a preference.
+ const pskDxRef = useRef('');
useEffect(() => {
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
- if (dx && dx !== pskTarget) {
- setPskTarget(dx);
- setPskTargetMode(txState?.mode ?? '');
- }
- }, [txState?.dx_call, txState?.mode, pskTarget]);
+ if (!dx || dx === pskDxRef.current) return;
+ pskDxRef.current = dx;
+ setPskTarget(dx);
+ setPskTargetMode(txState?.mode ?? '');
+ }, [txState?.dx_call, txState?.mode]);
+
+ // WAITING FOR A STATION IS ALREADY A REASON TO ANALYSE IT.
+ //
+ // Auto-call shows an hourglass for a station it wants and cannot call yet
+ // because that station is working somebody else. The analysis takes a moment
+ // to fill — the history query, then a period or two of live reports — so
+ // starting it at the instant the DX becomes free is starting it too late.
+ // The wait is dead time, and this is exactly what it is worth spending on.
+ //
+ // Only while nothing is actually being called: a real target is the panel's
+ // subject, and it arrives here through the decoder's DX call above.
+ const pskWaitRef = useRef('');
+ useEffect(() => {
+ const w = String(autoCallStatus?.waiting ?? '').toUpperCase().trim();
+ if (!w) { pskWaitRef.current = ''; return; }
+ if (autoCallStatus?.target) return;
+ if (w === pskWaitRef.current) return;
+ pskWaitRef.current = w;
+ setPskTarget(w);
+ setPskTargetMode(txState?.mode ?? mode ?? '');
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [autoCallStatus?.waiting, autoCallStatus?.target]);
const renderDecodesPanel = () => (
- ))}
+ {(clLang === 'fr' ? e.fr : e.en).map((line, i) => {
+ // "[NEW] " at the head of an entry marks a new FEATURE, as
+ // opposed to a fix to one. The marker is written in the
+ // changelog file itself and is the same in both languages,
+ // so one convention covers EN and FR; it is stripped here
+ // and drawn as a pill. A release is mostly fixes, and the
+ // two or three things that are actually new should not have
+ // to be found by reading all of it.
+ const isNew = line.startsWith('[NEW] ');
+ const text = isNew ? line.slice(6) : line;
+ return (
+
))}
@@ -7527,7 +7589,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
- {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && (
+ {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || showWatchWidget || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside.
@@ -7592,6 +7654,20 @@ export default function App() {
controls column, so the widget is just the dial and needs only its
width. The classic dial sizes itself from the inside and expects a fixed
column; the current one asks for the width it needs. */}
+ {showWatchWidget && (
+
+ {/* Same handler as a cluster row, for the same reason as Chase
+ new: a row here IS a spot, and half the reflex — the call
+ without the frequency, or the frequency without the mode — is
+ what makes a shortcut not worth using. */}
+ handleSpotClick(s as any)}
+ onClose={() => { setShowWatchWidget(false); writeUiPref('opslog.showWatchWidget', '0'); }}
+ />
+
= {
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
- tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew',
+ tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
};
diff --git a/frontend/src/components/WatchlistTab.tsx b/frontend/src/components/WatchlistTab.tsx
index cd545cd..215efaf 100644
--- a/frontend/src/components/WatchlistTab.tsx
+++ b/frontend/src/components/WatchlistTab.tsx
@@ -25,13 +25,7 @@ import {
import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
-
-interface WLEntry {
- callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
- isContest: boolean; notify: boolean;
- isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
- clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
-}
+import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
interface Props {
spots: ClusterSpot[];
@@ -40,21 +34,6 @@ interface Props {
onSpotClick?: (s: ClusterSpot) => void;
}
-// A spot is ON AIR for the badge while its last sighting is this fresh.
-const ON_AIR_MS = 10 * 60 * 1000;
-
-// Exact unless the entry carries a trailing * — the same rule the backend's
-// Match applies to the live stream, mirrored so the tab and the alerts can
-// never disagree about what an entry covers.
-function matchesEntry(call: string, pattern: string): boolean {
- const c = call.toUpperCase();
- if (pattern.endsWith('*')) {
- const p = pattern.slice(0, -1);
- return p !== '' && c.startsWith(p);
- }
- return c === pattern;
-}
-
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState([]);
@@ -95,8 +74,6 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
};
- // worked answer per "call|band|modeclass|contest" key.
- const [worked, setWorked] = useState>({});
const refresh = useCallback(async () => {
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
@@ -117,63 +94,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
// (one line per slot; the freshest spot represents it).
- const spotsFor = useMemo(() => {
- const map = new Map();
- for (const e of entries) map.set(e.callsign, []);
- for (const s of spots) {
- for (const e of entries) {
- if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
- }
- }
- for (const [k, list] of map) {
- const seen = new Set();
- map.set(k, list.filter((s) => {
- const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- }));
- }
- return map;
- }, [spots, entries]);
-
- // The worked answers, refreshed when the visible slots change. Debounced: a
- // spot burst must cost one round trip, not one per spot.
- const queryTimer = useRef(undefined);
- useEffect(() => {
- if (queryTimer.current) window.clearTimeout(queryTimer.current);
- queryTimer.current = window.setTimeout(async () => {
- const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
- const keys: string[] = [];
- for (const e of entries) {
- for (const s of spotsFor.get(e.callsign) ?? []) {
- const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
- queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
- keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
- }
- }
- if (queries.length === 0) { setWorked({}); return; }
- try {
- const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
- // MERGED, not replaced: replacing made every already-answered key
- // momentarily unknown on each refresh, which re-hid settled lines.
- setWorked((prev) => {
- const next = { ...prev };
- keys.forEach((k, i) => { next[k] = !!res[i]; });
- return next;
- });
- } catch { /* the badges just stay conservative */ }
- }, 150) as unknown as number;
- return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
- }, [spotsFor, entries]);
-
- const wkey = (e: WLEntry, s: ClusterSpot) =>
- `${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
- const workedFor = (e: WLEntry, s: ClusterSpot): boolean => worked[wkey(e, s)] ?? false;
- // A spot whose verdict has not come back yet is NOT drawn. Showing it as
- // Needed and withdrawing it half a second later made the list twitch on
- // every burst — and nobody needs a spot 400 ms early, they need it settled.
- const settled = (e: WLEntry, s: ClusterSpot): boolean => wkey(e, s) in worked;
+ // Which entries are on the air, and which of their slots are still needed.
+ // One definition, shared with the docked watch-list widget — the answer
+ // involves a debounced query per visible slot, and two copies of it would be
+ // two bursts of the same question and two ideas of what "needed" means.
+ const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
const add = async () => {
const c = addCall.trim().toUpperCase();
@@ -195,11 +120,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
catch (e: any) { flash(String(e?.message ?? e), true); }
};
- const isOnAir = (e: WLEntry): boolean =>
- (spotsFor.get(e.callsign) ?? []).some((s) => {
- const ts = Date.parse(String((s as any).received_at ?? ''));
- return ts > 0 && Date.now() - ts < ON_AIR_MS;
- });
+ const isOnAir = onAir;
const shown = entries.filter((e) => {
if (family === 'normal' && e.isContest) return false;
@@ -225,14 +146,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
- switch (st?.status) {
- case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' };
- case 'new-band-mode': return { label: t('clg2.newBandMode'), color: 'var(--danger)' };
- case 'new-band': return { label: t('clg2.newBand'), color: 'var(--warning)' };
- case 'new-mode': return { label: t('clg2.newMode'), color: 'var(--caution)' };
- case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' };
- default: return null;
- }
+ const b = newBadge(st?.status);
+ return b ? { label: t(b.key), color: b.colour } : null;
};
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
diff --git a/frontend/src/components/WatchlistWidget.tsx b/frontend/src/components/WatchlistWidget.tsx
new file mode 100644
index 0000000..5a0927e
--- /dev/null
+++ b/frontend/src/components/WatchlistWidget.tsx
@@ -0,0 +1,152 @@
+// WatchlistWidget — the watch list reduced to what is worth acting on RIGHT
+// NOW: entries that are on the air and still needed.
+//
+// The Watchlist tab is a tab, and an operator working FT8 lives on the decodes
+// one. A station they asked to be told about would appear on a screen they are
+// not looking at — so the same answer is docked in the widget strip, which sits
+// above the tabs and is therefore always in view. Only active AND needed: a
+// list of everything watched is the tab's job, and it would not fit here.
+import { useEffect, useMemo, useState } from 'react';
+import { Bell, X } from 'lucide-react';
+import { useI18n } from '@/lib/i18n';
+import { cn } from '@/lib/utils';
+import { inferSpotMode, spotStatusKey } from '@/lib/spot';
+import { useWatchlistSpots, newBadge, type WLEntry } from '@/lib/watchlistSpots';
+import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
+import { WatchlistEntries } from '../../wailsjs/go/main/App';
+import { EventsOn } from '../../wailsjs/runtime/runtime';
+
+interface Props {
+ spots: ClusterSpot[];
+ // The cluster's own verdicts. WHAT a station is worth is the reason to leave
+ // what you are doing for it — "on the air and not worked" says nothing about
+ // whether it is a new entity or a fifth band on one worked in 1998.
+ spotStatus: Record;
+ // A row is a spot: clicking it does what clicking one in the cluster does.
+ onPick?: (s: ClusterSpot) => void;
+ onClose?: () => void;
+}
+
+function age(s: ClusterSpot): string {
+ const ts = Date.parse(String((s as any).received_at ?? ''));
+ if (!(ts > 0)) return '';
+ const m = Math.floor((Date.now() - ts) / 60000);
+ if (m < 1) return 'now';
+ return `${m}m`;
+}
+
+export function WatchlistWidget({ spots, spotStatus, onPick, onClose }: Props) {
+ const { t } = useI18n();
+ const [entries, setEntries] = useState([]);
+
+ const load = () => { WatchlistEntries().then((e: any) => setEntries((e ?? []) as WLEntry[])).catch(() => {}); };
+ useEffect(() => {
+ load();
+ // The list changes from four places (the tab, the cluster's star, the
+ // contest auto-add, an import), and a widget that only reads it at mount
+ // would quietly watch the wrong set for the rest of the session.
+ const off = EventsOn('watchlist:changed', load);
+ return () => { off?.(); };
+ }, []);
+
+ const { spotsFor, workedFor, settled } = useWatchlistSpots(entries, spots);
+
+ // Ticking, because every row carries an age and the freshest thing here is
+ // the reason to look at it at all.
+ const [, tick] = useState(0);
+ useEffect(() => {
+ const id = window.setInterval(() => tick((n) => n + 1), 30000);
+ return () => window.clearInterval(id);
+ }, []);
+
+ // One row per (entry, needed slot): the same station on two bands is two
+ // chances to work it, and collapsing them would hide the one that is open.
+ const rows = useMemo(() => {
+ const out: { e: WLEntry; s: ClusterSpot }[] = [];
+ for (const e of entries) {
+ for (const s of spotsFor.get(e.callsign) ?? []) {
+ if (!settled(e, s) || workedFor(e, s)) continue;
+ out.push({ e, s });
+ }
+ }
+ // Freshest first: a spot from twenty minutes ago is history, and this
+ // panel is short.
+ return out.sort((a, b) =>
+ Date.parse(String((b.s as any).received_at ?? '')) - Date.parse(String((a.s as any).received_at ?? '')));
+ }, [entries, spotsFor, workedFor, settled]);
+
+ return (
+
+
+ {rows.length === 0 ? (
+ // Empty is the normal state, and it means something precise — say it,
+ // rather than leaving a blank box that reads as broken.
+
+
+ );
+}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index fc9a9b5..4e2e5eb 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -42,7 +42,7 @@ const en: Dict = {
'wl.addPh': 'Callsign or VK9*', 'wl.added': '{call} added to the watchlist.', 'wl.addedContest': '{call} added as a contest entry (judged per UTC day).', 'wl.add': 'Add', 'wl.contest': 'Contest', 'wl.addAsContest': 'as contest', 'wl.cTotal': 'Watchlist:', 'wl.cActive': 'Active:', 'wl.cNeeded': 'Needed:', 'wl.allModes': 'All modes', 'wl.modeFilter': 'Only show spots in this mode', 'wl.patternPh': 'Auto contest (e.g. WWA)', 'wl.patternHint': 'Auto-add as contest: any spotted callsign CONTAINING this text joins the watchlist as a contest entry by itself. Empty = off; collected entries stay.',
'wl.contestHint': 'Contest station: worked/needed is judged against the current UTC day — at 00:00 UTC every slot can be worked again.',
'wl.searchPh': 'Search…', 'wl.famAll': 'All', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
- 'wl.activeOnly': 'Active', 'wl.neededOnly': 'Needed',
+ 'wlw.title': 'Watch list', 'wlw.show': 'Watch list — on the air and needed', 'wlw.hide': 'Hide the watch-list panel', 'wlw.emptyList': 'Nothing on the watch list yet.', 'wlw.emptyNone': 'Nothing on the air that you still need.', 'wlw.pick': 'Tune to {call}', 'wl.activeOnly': 'Active', 'wl.neededOnly': 'Needed',
'wl.empty': 'Add the callsigns you are hunting. A bare entry matches exactly that call; end it with * for a family — VK9* catches every VK9…, RI0SP* the expedition\u2019s portable forms. Spots from the cluster appear under each entry with what they are worth.',
'wl.noneMatch': 'Nothing matches the current filters.',
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpedition',
@@ -76,7 +76,7 @@ const en: Dict = {
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
- 'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
+ 'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.newTag': 'New', 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
@@ -131,7 +131,7 @@ const en: Dict = {
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
- 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
+ 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.watchlist': 'Watch list', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
'appr.matrixEnable': 'Choose the band/mode matrix colours', 'appr.matrixHint': '(the PH/CW/DIG grid in Stats — off, each theme uses its own)',
'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the theme’s colours',
// Matrix legend + colour names. One set of labels for the grid's legend, its
@@ -603,7 +603,7 @@ const fr: Dict = {
'wl.addPh': 'Indicatif ou VK9*', 'wl.added': '{call} ajouté à la watchlist.', 'wl.addedContest': '{call} ajouté en entrée contest (jugée par jour UTC).', 'wl.add': 'Ajouter', 'wl.contest': 'Contest', 'wl.addAsContest': 'comme contest', 'wl.cTotal': 'Watchlist :', 'wl.cActive': 'Actives :', 'wl.cNeeded': 'Manquantes :', 'wl.allModes': 'Tous les modes', 'wl.modeFilter': 'Ne montrer que les spots de ce mode', 'wl.patternPh': 'Auto contest (ex. WWA)', 'wl.patternHint': "Ajout auto comme contest : tout indicatif spotté CONTENANT ce texte rejoint la watchlist en entrée contest tout seul. Vide = désactivé ; les entrées déjà collectées restent.",
'wl.contestHint': "Station contest : contacté/manquant est jugé sur la journée UTC courante — à 00:00 UTC chaque créneau redevient à faire.",
'wl.searchPh': 'Chercher…', 'wl.famAll': 'Tous', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
- 'wl.activeOnly': 'Actifs', 'wl.neededOnly': 'Manquants',
+ 'wlw.title': 'Watchlist', 'wlw.show': 'Watchlist — en l’air et à faire', 'wlw.hide': 'Masquer le panneau watchlist', 'wlw.emptyList': 'Aucune station dans la watchlist.', 'wlw.emptyNone': 'Rien en l’air qui vous manque encore.', 'wlw.pick': 'Se caler sur {call}', 'wl.activeOnly': 'Actifs', 'wl.neededOnly': 'Manquants',
'wl.empty': "Ajoutez les indicatifs que vous chassez. Une entrée nue matche exactement ce call ; terminez par * pour une famille — VK9* attrape tous les VK9…, RI0SP* les formes portables de l'expédition. Les spots du cluster apparaissent sous chaque entrée avec ce qu'ils valent.",
'wl.noneMatch': 'Rien ne correspond aux filtres actuels.',
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpédition',
@@ -633,7 +633,7 @@ const fr: Dict = {
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
- 'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
+ 'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.newTag': 'Nouveau', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
@@ -688,7 +688,7 @@ const fr: Dict = {
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
- 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
+ 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.watchlist': 'Watchlist', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
'appr.matrixEnable': 'Choisir les couleurs de la matrice bandes/modes', 'appr.matrixHint': '(la grille PH/CW/DIG des Stats — décoché, chaque thème garde les siennes)',
'appr.matrixSample': 'Aperçu', 'appr.matrixReset': 'Revenir aux couleurs du thème',
// Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la
diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts
index 2d3ef7e..b8a91cb 100644
--- a/frontend/src/lib/uiPref.ts
+++ b/frontend/src/lib/uiPref.ts
@@ -44,6 +44,10 @@ const PORTABLE_KEYS = [
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
'opslog.chaseSota', // show SOTA references on spots
+ // The other chase switches. In the DB as well as locally because AUTO-CALL
+ // reads them: what the operator does not hunt is not something to transmit
+ // for, and the backend cannot see localStorage.
+ 'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
diff --git a/frontend/src/lib/watchlistSpots.ts b/frontend/src/lib/watchlistSpots.ts
new file mode 100644
index 0000000..f43585b
--- /dev/null
+++ b/frontend/src/lib/watchlistSpots.ts
@@ -0,0 +1,133 @@
+// What the watch list is HEARING, and what of it is still needed.
+//
+// Two places ask the same question — the Watchlist tab and the docked widget —
+// and the answer involves a debounced round trip to the logbook per visible
+// slot. Written twice it would be two definitions of "needed" drifting apart,
+// and two bursts of the same query on every spot; written here it is one.
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { ClusterSpot } from '@/components/ClusterGrid';
+import { inferSpotMode } from '@/lib/spot';
+import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
+
+export interface WLEntry {
+ callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
+ isContest: boolean; notify: boolean;
+ isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
+ clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
+}
+
+// A spot is ON AIR while its last sighting is this fresh.
+export const ON_AIR_MS = 10 * 60 * 1000;
+
+// Exact unless the entry carries a trailing * — the same rule the backend's
+// Match applies to the live stream, mirrored so the list and the alerts can
+// never disagree about what an entry covers.
+export function matchesEntry(call: string, pattern: string): boolean {
+ const c = call.toUpperCase();
+ if (pattern.endsWith('*')) {
+ const p = pattern.slice(0, -1);
+ return p !== '' && c.startsWith(p);
+ }
+ return c === pattern;
+}
+
+// What the cluster's verdict is worth saying, and in what colour.
+//
+// The order of severity is the one the band map and Chase new use: a new entity
+// first, then the band and mode inside it. Shared because the tab and the docked
+// widget must not label the same spot differently — an operator reads one of
+// them to decide whether to leave what they are doing.
+export const NEW_BADGES: Record = {
+ 'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
+ 'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
+ 'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
+ 'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
+ 'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
+};
+
+export function newBadge(status?: string): { key: string; colour: string } | null {
+ return (status && NEW_BADGES[status]) || null;
+}
+
+export interface WatchlistSpots {
+ // The raw verdicts, exposed for dependency arrays: it changes only when an
+ // answer arrives, where the closures below are new on every render.
+ worked: Record;
+ // The spots each entry covers, deduplicated by band+mode.
+ spotsFor: Map;
+ // Worked on this exact slot (and, for a contest entry, today).
+ workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
+ // Whether the logbook has actually answered for this pair yet. A spot whose
+ // verdict has not come back is NOT drawn: showing it as needed and
+ // withdrawing it half a second later made the list twitch on every burst,
+ // and nobody needs a spot 400 ms early — they need it settled.
+ settled: (e: WLEntry, s: ClusterSpot) => boolean;
+ onAir: (e: WLEntry) => boolean;
+}
+
+export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
+ const [worked, setWorked] = useState>({});
+
+ const spotsFor = useMemo(() => {
+ const map = new Map();
+ for (const e of entries) map.set(e.callsign, []);
+ for (const s of spots) {
+ for (const e of entries) {
+ if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
+ }
+ }
+ for (const [k, list] of map) {
+ const seen = new Set();
+ map.set(k, list.filter((s) => {
+ const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ }));
+ }
+ return map;
+ }, [spots, entries]);
+
+ // Debounced: a spot burst must cost one round trip, not one per spot.
+ const queryTimer = useRef(undefined);
+ useEffect(() => {
+ if (queryTimer.current) window.clearTimeout(queryTimer.current);
+ queryTimer.current = window.setTimeout(async () => {
+ const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
+ const keys: string[] = [];
+ for (const e of entries) {
+ for (const s of spotsFor.get(e.callsign) ?? []) {
+ const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
+ queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
+ keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
+ }
+ }
+ if (queries.length === 0) { setWorked({}); return; }
+ try {
+ const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
+ // MERGED, not replaced: replacing made every already-answered key
+ // momentarily unknown on each refresh, which re-hid settled lines.
+ setWorked((prev) => {
+ const next = { ...prev };
+ keys.forEach((k, i) => { next[k] = !!res[i]; });
+ return next;
+ });
+ } catch { /* the badges just stay conservative */ }
+ }, 150) as unknown as number;
+ return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
+ }, [spotsFor, entries]);
+
+ const wkey = (e: WLEntry, s: ClusterSpot) =>
+ `${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
+
+ return {
+ worked,
+ spotsFor,
+ workedFor: (e, s) => worked[wkey(e, s)] ?? false,
+ settled: (e, s) => wkey(e, s) in worked,
+ onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
+ const ts = Date.parse(String((s as any).received_at ?? ''));
+ return ts > 0 && Date.now() - ts < ON_AIR_MS;
+ }),
+ };
+}
diff --git a/internal/autocall/autocall.go b/internal/autocall/autocall.go
index 4f2fc3c..93e8949 100644
--- a/internal/autocall/autocall.go
+++ b/internal/autocall/autocall.go
@@ -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
diff --git a/internal/autocall/autocall_test.go b/internal/autocall/autocall_test.go
index c664d38..710501c 100644
--- a/internal/autocall/autocall_test.go
+++ b/internal/autocall/autocall_test.go
@@ -65,6 +65,9 @@ func TestLadderOrder(t *testing.T) {
cq("I", NeedNone, 0, watched),
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
+ // The orthogonal markers sit at the foot of the ladder: worth a call
+ // when nothing better is on the air, never worth leaving a band for.
+ cq("J", NeedExtra, 0),
}
for i := 1; i < len(order); i++ {
if rank(order[i-1]) <= rank(order[i]) {
@@ -640,7 +643,7 @@ func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
// And when it DOES call, the line names the candidates it ranked.
lines = nil
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
- if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r114)") {
+ if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
t.Errorf("trace %v does not describe the station it called", lines)
}
}
@@ -678,34 +681,26 @@ func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
}
}
-func TestATargetThatStartsWorkingSomebodyElseIsDropped(t *testing.T) {
+// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
+// already being called; this is about picking one. A reply to a decode in
+// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
+// starts a call there.
+func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
e := on()
- // Picked on its CQ.
- if a := e.OnPeriod(period(0, cq("ON7GB", NeedSlot, +5))); a.Decode.Call != "ON7GB" {
- t.Fatalf("not called: %+v", a)
- }
- // Next period it is answering somebody else. Calling it is pointless: it is
- // committed, and the seven attempts would be spent transmitting at a
- // station that cannot hear them.
busyNow := busy("ON7GB", NeedSlot, +5)
busyNow.Msg = "PY2SAD ON7GB JO21"
- a := e.OnPeriod(period(2, busyNow))
- if a.Kind != DoHalt {
- t.Fatalf("kept calling a station in a QSO with someone else: %+v", a)
- }
- if !strings.Contains(a.Reason, "PY2SAD") {
- t.Errorf("reason %q does not name the station it is working", a.Reason)
+ if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
+ t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
}
if e.Target() != "" {
- t.Error("the target was not released")
+ t.Errorf("target is %q", e.Target())
}
- // No verdict attached: the moment it CQs again it is fair game, with a full
- // allowance — it was never given up on.
- if a := e.OnPeriod(period(4, cq("ON7GB", NeedSlot, +5))); a.Kind != DoReply {
- t.Errorf("not called again once free: %+v", a)
- }
- if e.Status().Attempts != 0 {
- t.Errorf("attempts = %d on a fresh series, want 0", e.Status().Attempts)
+ // Its final frame is different: one period from free is the best moment
+ // there is to be calling it.
+ last := busy("ON7GB", NeedSlot, +5)
+ last.Msg = "PY2SAD ON7GB RR73"
+ if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
+ t.Errorf("%+v — a station on its last frame was not called", a)
}
}
@@ -864,29 +859,39 @@ func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
}
}
-func TestTheSlotIsNotWastedWhenTheTargetTurnsOutToBeBusy(t *testing.T) {
+// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
+// contact, the call started, the DX coming back to somebody else — and the
+// engine giving up on a station that was one period from being free.
+func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
e := on()
- if a := e.OnPeriod(period(0, cq("DX", NeedBand, -7))); a.Kind != DoReply {
+ if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
- // It answers somebody else, and a station of the same value is calling CQ
- // in the very same period. Waiting for the next one throws away a slot.
- a := e.OnPeriod(period(2, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8)))
- if a.Kind != DoReply || a.Decode.Call != "ER1CW" {
- t.Fatalf("%+v — the freed slot was not used", a)
+ // It answers three other callers in a row — which is what a DX with a queue
+ // does, and calling through it is how the queue is joined.
+ does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
+ for _, n := range []int{2, 4, 6} {
+ if a := does(n); a.Kind != DoNothing {
+ t.Fatalf("%+v — stopped calling a station working the pileup", a)
+ }
+ if e.Target() != "D44TWO" {
+ t.Fatalf("target is %q — the station was released while it worked others", e.Target())
+ }
}
- if !strings.Contains(a.Reason, "cannot answer") || !strings.Contains(a.Reason, "calling ER1CW") {
- t.Errorf("reason %q says neither what was left nor what was taken", a.Reason)
+ // And when it comes to us, the exchange starts as usual.
+ if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
+ t.Errorf("%+v", a)
+ }
+ if !e.answered {
+ t.Error("the reply was not taken as the start of the exchange")
}
- // Mid-over, it still waits: cutting our own transmission in half is worse
- // than losing the slot.
+ // A BETTER station may still take the slot while it is busy: nothing has
+ // been answered, so nothing is lost by moving.
e = on()
- e.OnPeriod(period(4, cq("DX", NeedBand, -7)))
- pp := period(6, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8))
- pp.TX = TXState{Transmitting: true}
- if a := e.OnPeriod(pp); a.Kind != DoHalt || !a.Soft {
- t.Errorf("%+v — replied over our own transmission", a)
+ e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
+ if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
+ t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
}
}
diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go
index 5df4fe0..20a81e4 100644
--- a/internal/lookup/lookup.go
+++ b/internal/lookup/lookup.go
@@ -395,6 +395,47 @@ func titleCase(s string) string {
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
+// sameEntityName reports whether two country names denote the same entity.
+//
+// The two come from different vocabularies — the callbook writes "Germany"
+// where cty.dat writes "Fed. Rep. of Germany", "United States" where the ADIF
+// list says "United States of America" — so they are compared on their
+// significant words with the boilerplate of officialdom removed, and one
+// containing the other counts as a match.
+//
+// Deliberately generous. Getting it wrong in the strict direction discards a
+// correct grid, which is the fault this exists to fix; getting it wrong in the
+// generous direction keeps a location from a neighbouring entity, which is the
+// state of every callbook record that has no page for the portable call anyway.
+func sameEntityName(a, b string) bool {
+ na, nb := entityKey(a), entityKey(b)
+ if na == "" || nb == "" {
+ return false
+ }
+ return na == nb || strings.Contains(na, nb) || strings.Contains(nb, na)
+}
+
+var entityNoise = map[string]bool{
+ "THE": true, "OF": true, "FED": true, "REP": true, "REPUBLIC": true,
+ "FEDERAL": true, "FEDERATION": true, "DEM": true, "DEMOCRATIC": true,
+ "STATE": true, "KINGDOM": true, "AMERICA": true,
+}
+
+// entityKey reduces a country name to its significant letters.
+func entityKey(s string) string {
+ s = strings.ToUpper(strings.TrimSpace(s))
+ var b strings.Builder
+ for _, tok := range strings.FieldsFunc(s, func(r rune) bool {
+ return r == ' ' || r == '.' || r == ',' || r == '-'
+ }) {
+ if entityNoise[tok] {
+ continue
+ }
+ b.WriteString(tok)
+ }
+ return b.String()
+}
+
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
@@ -430,11 +471,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
+ // UNLESS THE RECORD IS ABOUT THE OPERATION ITSELF.
+ //
+ // Some compound calls have a callbook page of their own, filed under the
+ // slashed form and describing where the station actually is: QRZ's HP/WE9G
+ // carries Altos del Maria, Panama, square EJ98xq. Clearing that threw away
+ // the one field the lookup existed to find, and the QSO was logged with no
+ // grid at all while the page plainly showed one.
+ //
+ // The record's OWN country is what tells the two apart: a page for the
+ // operation names the entity being operated from, a home page names home.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
- clearHomeLocation(r)
- filled = true
+ if !sameEntityName(r.Country, country) {
+ clearHomeLocation(r)
+ filled = true
+ }
}
}
}
diff --git a/internal/lookup/portable_location_test.go b/internal/lookup/portable_location_test.go
index f016623..f71acc3 100644
--- a/internal/lookup/portable_location_test.go
+++ b/internal/lookup/portable_location_test.go
@@ -91,3 +91,60 @@ func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
+
+// The other half again, and the one an operator reported: a compound call with
+// a callbook page OF ITS OWN. QRZ files HP/WE9G under that exact form, with the
+// Panama address and square the station is actually operating from — and the
+// QSO was being logged with no grid at all while the page plainly showed one.
+func TestACompoundCallWithItsOwnPageKeepsThatPagesLocation(t *testing.T) {
+ dxcc := testDXCC()
+ dxcc["HP/WE9G"] = struct {
+ num int
+ country string
+ cont string
+ cqz, ituz int
+ lat, lon float64
+ }{num: 88, country: "Panama", cont: "NA", cqz: 7, ituz: 11, lat: 8.5, lon: -80.0}
+ dxcc["WE9G"] = struct {
+ num int
+ country string
+ cont string
+ cqz, ituz int
+ lat, lon float64
+ }{num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5}
+
+ r := Result{
+ Callsign: "HP/WE9G",
+ Name: "Richard B",
+ Country: "Panama", // the RECORD's own country: this page is the operation
+ Grid: "EJ98xq",
+ Lat: 8.686667, Lon: -80.043333,
+ }
+ fillFromDXCC(&r, dxcc)
+
+ if r.Grid != "EJ98xq" {
+ t.Errorf("grid = %q, want EJ98xq — the page describes the operation, not a home address", r.Grid)
+ }
+ if r.Lat != 8.686667 || r.Lon != -80.043333 {
+ t.Errorf("lat/lon = %v/%v — the station's own position was replaced by the entity centroid", r.Lat, r.Lon)
+ }
+}
+
+func TestSameEntityNameAcrossVocabularies(t *testing.T) {
+ same := [][2]string{
+ {"Germany", "Fed. Rep. of Germany"},
+ {"United States", "United States of America"},
+ {"Panama", "Panama"},
+ {"Kosovo", "Republic of Kosovo"},
+ }
+ for _, p := range same {
+ if !sameEntityName(p[0], p[1]) {
+ t.Errorf("%q and %q read as different entities", p[0], p[1])
+ }
+ }
+ for _, p := range [][2]string{{"Costa Rica", "United States"}, {"France", "Belgium"}, {"", "Panama"}} {
+ if sameEntityName(p[0], p[1]) {
+ t.Errorf("%q and %q read as the same entity", p[0], p[1])
+ }
+ }
+}