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
+1
View File
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {}, "0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {}, "ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {}, "9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
} }
// callDenied reports whether a callsign is on deniedCallHashes. The call is // callDenied reports whether a callsign is on deniedCallHashes. The call is
+56 -3
View File
@@ -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 status[st.Call+"|"+st.Band+"|"+st.Mode] = st
} }
chase := a.autoCallChase()
cands := make([]autocall.Candidate, 0, len(uniq)) cands := make([]autocall.Candidate, 0, len(uniq))
for _, dd := range uniq { for _, dd := range uniq {
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode] 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.Watched = a.autoCallWatched(dd.d.Call)
c.Hidden = a.autoCallHidden(dd.d.Call) c.Hidden = a.autoCallHidden(dd.d.Call)
cands = append(cands, c) cands = append(cands, c)
} }
tx := a.autoCallTX() tx := a.autoCallTX()
act := a.autoCallEngine().OnPeriod(autocall.Period{ act := a.autoCallEngine().OnPeriod(autocall.Period{
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx, Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
MyCall: a.opCall, 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 // 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. // nothing could catch it: the entity's verdict was read as the station's.
func candidateOf(d autocall.Decode, st SpotStatus) autocall.Candidate { // chaseExtras is what the operator hunts BESIDES entities — the cluster's
return autocall.Candidate{ // 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, Decode: d,
// The ENTITY's verdict decides what is still needed… // The ENTITY's verdict decides what is still needed…
Need: autoCallNeedOf(st.Status), 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. // less than the same need never worked at all.
Unconfirmed: st.UnconfStatus, 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. // autoCallDo carries out a decision and records it.
+46 -3
View File
@@ -12,12 +12,15 @@ import (
// engine refused twenty decodes out of twenty-one as "worked" — a watched // 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 // DXpedition calling CQ among them — because the ENTITY's status was read as
// the station's. // 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) { func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true} d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed // Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
// from it, and calling it is NOT a duplicate. // 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 { if c.Worked {
t.Error("a station never worked was refused because its entity was") 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. // 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") 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. // 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 { if c.Need != autocall.NeedBand || !c.Unconfirmed {
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, 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)
}
}
+30 -32
View File
@@ -3,50 +3,48 @@
"version": "0.27.13", "version": "0.27.13",
"date": "", "date": "",
"en": [ "en": [
"Chase new: a band selection of its own, under the option (160 m to 70 cm). The stations 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 clusters 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 stations 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: 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-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the bands 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 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.", "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 MSHVs two-answers-in-one-line, or refuses nearly everything on the air because it read the entitys “worked” as the stations.",
"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 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-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the bands 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 MSHVs two-answers-in-one-line, refuses nearly everything on the air because it read the entitys “worked” as the stations, or opens with two misses already counted against a station it has only just picked.",
"Switching profile no longer leaves the previous logbooks 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.", "Switching profile no longer leaves the previous logbooks 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.", "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.",
"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.", "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.",
"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.", "Callbook lookup: a compound callsign with a page of its OWN keeps that pages 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 records own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
"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."
], ],
"fr": [ "fr": [
"Chase new : sélection de bandes propre au panneau, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous celle-ci dit ce quon veut SURVEILLER ce soir.", "[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle dazimut carrée, aiguille orange qui suit la souris pour quun clic parte où on vise, repère jaune sur lazimut demandé jusqu’à larrivée de lantenne, 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 — lun se lit de loin, lautre est le cadran compact davant — et le choix sapplique aussitôt au widget docké comme à Station Control.",
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à larrêt. Le mouvement était déduit dun changement dun degré, soit moins que le tremblement de lecture dun contrôleur au repos.",
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN LAIR et manque encore — une ligne par bande et mode, le plus frais en haut, clic pour sy caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. Longlet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station quon avait demandé à surveiller apparaissait sur un écran quon ne regardait pas. À activer avec la cloche dans la barre doutils.",
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à lunité — 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 lauto-call ATTEND (le sablier), tant que rien nest appelé. Son analyse met une requête dhistorique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, cest la lancer trop tard — lattente sert précisément à ça.",
"[NEW] Chase new : sélection de bandes propre au panneau, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous — celle-ci dit ce quon 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 : 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 : len-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 danciens verdicts à l’écran.",
"Panneau PSK Reporter, corrections : lhistorique 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 naffiche plus une page vide ; loffset 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 quun écran vide.", "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 quun écran vide.",
"FT decodes : un badge WL rose marque une station de votre watchlist, et lhorloge de période passe au rouge en émission. Cest le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.", "FT decodes : un badge WL rose marque une station de votre watchlist, et lhorloge de période passe au rouge en émission. Cest 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 lutilisation en trafic. Il ne coupe plus votre 73, nappelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ninsiste plus sur une station qui sest mise à travailler quelquun dautre, 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 quil prenait le « worked » de lentité pour celui de la station.",
"Auto-call : ordre de priorité plus strict. Un indicatif en watchlist passe devant tout ce qui ny est pas, un vrai besoin passe devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place dune autre qui na pas encore répondu — mais jamais celle dun 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 nappelle 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. Loption 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 lauto-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 lair et pourquoi chaque station a été refusée.",
"Auto-call dit ce quil attend. Une station voulue mais en QSO avec quelquun dautre saffiche désormais à côté du bouton Auto, au lieu de laisser croire quil na rien à faire.",
"FT decodes : un message qui VOUS est adressé saffiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de lautre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : lessentiel de ce quelle émet sadresse à dautres, et colorer ces lignes pareil laissait croire à un QSO en cours.", "FT decodes : un message qui VOUS est adressé saffiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de lautre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : lessentiel de ce quelle émet sadresse à dautres, 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 à lunité — 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 quil 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 loption 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 ny est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place dune autre qui na pas encore répondu — jamais celle dun QSO en cours.",
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelquun dautre — or cest exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant quil travaille les autres. Toujours borné par les compteurs dappels et de ratés, et une station en plein échange nest 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é — cest 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 nest plus appelée jusqu’à ce que lauto-call soit désactivé puis réactivé. Il dit ce quil attend, en affichant à côté du bouton Auto la station voulue qui travaille quelquun dautre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur lair et pourquoi chaque station a été refusée.",
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, nappelle 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 quil prenait le « worked » de lentité 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 dun profil au log vide, tous les décodes et spots restaient marqués NEW jusquau redémarrage : lindex des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.", "Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant dun profil au log vide, tous les décodes et spots restaient marqués NEW jusquau redémarrage : lindex des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
"Lauto-call est toujours ARRÊTÉ au lancement, et de nouveau après un changement de profil — jamais armé depuis un réglage enregistré. Cest la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows : larmer est un clic que quelquun doit faire.", "Chase new, corrections : len-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 danciens verdicts à l’écran.",
"Lauto-call reprend immédiatement le créneau libéré quand la station appelée savère être en QSO avec quelquun dautre. Il sarrê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.", "Panneau PSK Reporter, corrections : lhistorique 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 naffiche plus une page vide ; loffset 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.",
"Widget rotor redessiné : carte du monde nocturne derrière une échelle dazimut carrée, aiguille orange qui suit la souris pour quun clic parte où on vise, repère jaune sur lazimut demandé jusqu’à larrivée de lantenne, 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.", "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 ladresse et le locator du Panama doù la station émet, et OpsLog les jetait — la règle qui écarte ladresse personnelle dun indicatif portable sappliquait aussi aux fiches qui décrivent lopération elle-même. Cest 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."
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à larrêt. Le mouvement était déduit dun changement dun degré, soit moins que le tremblement de lecture dun 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 — lun se lit de loin, lautre est le cadran compact davant — et le choix sapplique 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 quelle navait 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."
] ]
}, },
{ {
+84 -8
View File
@@ -120,6 +120,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic'; import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { WatchlistWidget } from '@/components/WatchlistWidget';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle'; import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { GridSquareMap } from '@/components/GridSquareMap'; import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros'; import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
@@ -2774,6 +2775,9 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs). // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); 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 [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0'); const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '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 // Follow the station the digital application says it is calling. A decode
// clicked here sets the target directly (see onCall); this covers the QSO // 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. // 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(() => { useEffect(() => {
const dx = (txState?.dx_call ?? '').toUpperCase().trim(); const dx = (txState?.dx_call ?? '').toUpperCase().trim();
if (dx && dx !== pskTarget) { if (!dx || dx === pskDxRef.current) return;
pskDxRef.current = dx;
setPskTarget(dx); setPskTarget(dx);
setPskTargetMode(txState?.mode ?? ''); setPskTargetMode(txState?.mode ?? '');
} }, [txState?.dx_call, txState?.mode]);
}, [txState?.dx_call, txState?.mode, pskTarget]);
// 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 = () => ( const renderDecodesPanel = () => (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
@@ -6818,6 +6850,18 @@ export default function App() {
<Ear className="size-4" /> <Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />} {cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
</button> </button>
<button
type="button"
onClick={() => { const v = !showWatchWidget; setShowWatchWidget(v); writeUiPref('opslog.showWatchWidget', v ? '1' : '0'); }}
title={showWatchWidget ? t('wlw.hide') : t('wlw.show')}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showWatchWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Bell className="size-4" />
</button>
<button <button
type="button" type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }} onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
@@ -7300,12 +7344,30 @@ export default function App() {
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>} {e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
</div> </div>
<ul className="space-y-1.5 text-sm"> <ul className="space-y-1.5 text-sm">
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => ( {(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 (
<li key={i} className="flex gap-2"> <li key={i} className="flex gap-2">
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" /> <span className={cn('mt-1.5 size-1.5 rounded-full shrink-0', isNew ? 'bg-success' : 'bg-primary')} />
<span>{line}</span> <span>
{isNew && (
<span className="mr-1.5 align-[1px] rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide bg-success text-success-foreground">
{t('whatsnew.newTag')}
</span>
)}
{text}
</span>
</li> </li>
))} );
})}
</ul> </ul>
</div> </div>
))} ))}
@@ -7527,7 +7589,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} 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. // 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 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. // 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 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 width. The classic dial sizes itself from the inside and expects a fixed
column; the current one asks for the width it needs. */} column; the current one asks for the width it needs. */}
{showWatchWidget && (
<div className="w-[260px] shrink-0 min-h-0" style={{ order: wOrder('watchlist') }}>
{/* 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. */}
<WatchlistWidget
spots={spots}
spotStatus={spotStatus as any}
onPick={(s) => handleSpotClick(s as any)}
onClose={() => { setShowWatchWidget(false); writeUiPref('opslog.showWatchWidget', '0'); }}
/>
</div>
)}
{showRotor && (rotatorHeading.enabled || dxPath) && ( {showRotor && (rotatorHeading.enabled || dxPath) && (
<div className={cn('shrink-0 min-h-0', <div className={cn('shrink-0 min-h-0',
rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')} rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
+2 -2
View File
@@ -304,13 +304,13 @@ export function AppearancePanel() {
// place here: it comes back where the operator left it rather than at the end. // place here: it comes back where the operator left it rather than at the end.
export const WIDGET_KEYS = [ export const WIDGET_KEYS = [
'livestations', 'chat', 'rotor', 'motorant', 'antgenius', 'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo', 'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
] as const; ] as const;
const WIDGET_LABELS: Record<string, string> = { const WIDGET_LABELS: Record<string, string> = {
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor', livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp', 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', dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
}; };
+9 -94
View File
@@ -25,13 +25,7 @@ import {
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid'; import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot'; import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
interface Props { interface Props {
spots: ClusterSpot[]; spots: ClusterSpot[];
@@ -40,21 +34,6 @@ interface Props {
onSpotClick?: (s: ClusterSpot) => void; 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) { export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]); const [entries, setEntries] = useState<WLEntry[]>([]);
@@ -95,8 +74,6 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number; noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
}; };
// worked answer per "call|band|modeclass|contest" key.
const [worked, setWorked] = useState<Record<string, boolean>>({});
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); } 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 // Live spots per entry — prefix-matched, newest first, deduped per band+mode
// (one line per slot; the freshest spot represents it). // (one line per slot; the freshest spot represents it).
const spotsFor = useMemo(() => { // Which entries are on the air, and which of their slots are still needed.
const map = new Map<string, ClusterSpot[]>(); // One definition, shared with the docked watch-list widget — the answer
for (const e of entries) map.set(e.callsign, []); // involves a debounced query per visible slot, and two copies of it would be
for (const s of spots) { // two bursts of the same question and two ideas of what "needed" means.
for (const e of entries) { const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
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<number | undefined>(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;
const add = async () => { const add = async () => {
const c = addCall.trim().toUpperCase(); 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); } catch (e: any) { flash(String(e?.message ?? e), true); }
}; };
const isOnAir = (e: WLEntry): boolean => const isOnAir = onAir;
(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 shown = entries.filter((e) => { const shown = entries.filter((e) => {
if (family === 'normal' && e.isContest) return false; 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. // The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => { const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]; const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
switch (st?.status) { const b = newBadge(st?.status);
case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' }; return b ? { label: t(b.key), color: b.colour } : null;
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;
}
}; };
// Three decimals, and no trailing zeros beyond them: 7.056 rather than // Three decimals, and no trailing zeros beyond them: 7.056 rather than
+152
View File
@@ -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<string, SpotStatusEntry>;
// 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<WLEntry[]>([]);
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 (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Bell className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('wlw.title')}
</span>
<span className={cn('rounded px-1.5 py-px text-[10px] font-bold',
rows.length > 0 ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground')}>
{rows.length}
</span>
<div className="flex-1" />
{onClose && (
<button type="button" onClick={onClose} title={t('wlw.hide')}
className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{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.
<div className="px-3 py-3 text-xs text-muted-foreground">
{entries.length === 0 ? t('wlw.emptyList') : t('wlw.emptyNone')}
</div>
) : rows.map(({ e, s }, i) => {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
const badge = newBadge(spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.status);
return (
<button
key={`${e.callsign}-${s.dx_call}-${s.band}-${mode}-${i}`}
type="button"
onClick={() => onPick?.(s)}
title={t('wlw.pick', { call: s.dx_call })}
className="w-full text-left px-2 py-1 border-b border-border/20 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] font-bold text-warning truncate">{s.dx_call}</span>
{/* The entry it matched, when it is a prefix: "DP1*" told the
operator to watch a fleet, and which member turned up is not
obvious from the callsign alone. */}
{e.callsign !== s.dx_call && (
<span className="text-[9px] text-muted-foreground shrink-0">{e.callsign}</span>
)}
<div className="flex-1" />
<span className="font-mono text-[10px] text-muted-foreground/70 tabular-nums shrink-0">
{age(s)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{s.band && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground shrink-0">
{s.band}
</span>
)}
{mode && <span className="font-mono text-[10px] text-muted-foreground shrink-0">{mode}</span>}
<div className="flex-1" />
{/* Why it is worth interrupting a QSO for — or not. */}
{badge && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide border shrink-0"
style={{ color: badge.colour, borderColor: `color-mix(in srgb, ${badge.colour} 45%, transparent)`,
background: `color-mix(in srgb, ${badge.colour} 12%, transparent)` }}>
{t(badge.key)}
</span>
)}
</div>
</button>
);
})}
</div>
</section>
);
}
+6 -6
View File
@@ -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.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.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.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.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.noneMatch': 'Nothing matches the current filters.',
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpedition', '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': '…', 'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser // Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', '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.', '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.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh', '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', '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.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
'sec.confirmations': 'Confirmations', 'sec.external': 'External services', '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.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 themes colours', 'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the themes colours',
// Matrix legend + colour names. One set of labels for the grid's legend, its // 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.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.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.searchPh': 'Chercher…', 'wl.famAll': 'Tous', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
'wl.activeOnly': 'Actifs', 'wl.neededOnly': 'Manquants', 'wlw.title': 'Watchlist', 'wlw.show': 'Watchlist — en lair et à faire', 'wlw.hide': 'Masquer le panneau watchlist', 'wlw.emptyList': 'Aucune station dans la watchlist.', 'wlw.emptyNone': 'Rien en lair 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.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.noneMatch': 'Rien ne correspond aux filtres actuels.',
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpédition', '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ç', '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': '…', '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.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', '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.", '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…', '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', '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.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', '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 daffichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous laviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir lordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à lair', '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 lopé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 daffichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous laviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir lordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à lair', '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 lopé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.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', '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 // Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la
+4
View File
@@ -44,6 +44,10 @@ const PORTABLE_KEYS = [
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps '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.chasePota', // show POTA references and the NEW POTA marker on spots
'opslog.chaseSota', // show SOTA references 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.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares '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:[..]} 'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
+133
View File
@@ -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<string, { key: string; colour: string }> = {
'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<string, boolean>;
// The spots each entry covers, deduplicated by band+mode.
spotsFor: Map<string, ClusterSpot[]>;
// 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<Record<string, boolean>>({});
const spotsFor = useMemo(() => {
const map = new Map<string, ClusterSpot[]>();
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<string>();
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<number | undefined>(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;
}),
};
}
+45 -30
View File
@@ -58,6 +58,16 @@ type Need int
const ( const (
NeedNone Need = iota NeedNone Need = iota
// 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 NeedSlot // entity worked on this band and in this mode, never together
NeedMode // entity never worked in this mode NeedMode // entity never worked in this mode
NeedBand // entity never worked on this band NeedBand // entity never worked on this band
@@ -74,6 +84,8 @@ func (n Need) String() string {
return "mode" return "mode"
case NeedSlot: case NeedSlot:
return "slot" return "slot"
case NeedExtra:
return "extra"
} }
return "-" return "-"
} }
@@ -110,6 +122,9 @@ type Candidate struct {
Decode Decode
Need Need Need Need
Watched bool 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 // Unconfirmed says the need above exists ONLY because a contact was never
// confirmed — the operator hunts "new + unconfirmed", the band is in the log // confirmed — the operator hunts "new + unconfirmed", the band is in the log
// and the QSL is not. A real need outranks it; see rank. // 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 // 10 mode 8 mode unconf
// 6 slot 4 slot unconf // 6 slot 4 slot unconf
// 0 nothing to call for // 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 { func rank(c Candidate) int {
r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16 r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
if c.Need != NeedNone && !c.Unconfirmed { if c.Need != NeedNone && !c.Unconfirmed {
@@ -726,36 +751,26 @@ func (e *Engine) OnPeriod(p Period) Action {
e.heldSince = p.At e.heldSince = p.At
return Action{} 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 // This used to stop, on the reasoning that a station in a QSO cannot
// while it was held — so a station picked on its CQ, which then // hear the call. That is how a pileup is NOT worked: a DX with a
// answered another caller, went on being called for the whole seven // queue answers one caller per period, and the only way to be the
// attempts. Two minutes of transmitting at a station that is in a // next one is to keep calling while it works the others. An operator
// QSO and cannot hear the call, while the band moves on. // 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 // The effort is already bounded: seven calls, fifteen for a watched
// else is one period from being free, which is the best moment there // station, and the miss counter for one that goes off the air. And
// is to be calling it. Only a report or a grid to another station // nothing is wasted while it is busy — a better station may still
// means the exchange is under way. // take the slot, because it has not answered us (see preempt).
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 // The callable test still governs the CHOICE of a target, where it
// call mid-over and cuts our own transmission in half, which is // belongs: a reply to a decode in mid-exchange is one WSJT-X and
// the whole reason the halt below is Soft. // JTDX may refuse outright.
if !p.TX.Transmitting { if e.trace != nil && !callable(*seen, p.MyCall) {
if a := e.pick(p, why); a.Kind == DoReply { e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
return a
}
}
return Action{Kind: DoHalt, Soft: true, Reason: why}
} }
return Action{} return Action{}
} }
@@ -857,7 +872,7 @@ func (e *Engine) pick(p Period, why string) Action {
e.txSlot = slotOf(p.At, periodSecs(p, *best)) e.txSlot = slotOf(p.At, periodSecs(p, *best))
e.answered = false e.answered = false
e.waiting = "" 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 != "" { if why != "" {
reason = why + " — " + reason reason = why + " — " + reason
} }
@@ -893,7 +908,7 @@ func (e *Engine) tracePick(p Period) {
best := make([]string, 0, 3) best := make([]string, 0, 3)
for i := 0; i < len(ok) && i < 3; i++ { for i := 0; i < len(ok) && i < 3; i++ {
best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)", 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)) why := make([]string, 0, len(refused))
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} { 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 e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
return Action{Kind: DoReply, Decode: best.Decode, return Action{Kind: DoReply, Decode: best.Decode,
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet", 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 // drop lets the target go with no verdict attached: not worked, not given up
+44 -39
View File
@@ -65,6 +65,9 @@ func TestLadderOrder(t *testing.T) {
cq("I", NeedNone, 0, watched), cq("I", NeedNone, 0, watched),
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0), cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
cq("F", NeedMode, 0), cq("H", NeedSlot, 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++ { for i := 1; i < len(order); i++ {
if rank(order[i-1]) <= rank(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. // And when it DOES call, the line names the candidates it ranked.
lines = nil lines = nil
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched))) 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) 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() 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 := busy("ON7GB", NeedSlot, +5)
busyNow.Msg = "PY2SAD ON7GB JO21" busyNow.Msg = "PY2SAD ON7GB JO21"
a := e.OnPeriod(period(2, busyNow)) if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
if a.Kind != DoHalt { t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
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 e.Target() != "" { 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 // Its final frame is different: one period from free is the best moment
// allowance — it was never given up on. // there is to be calling it.
if a := e.OnPeriod(period(4, cq("ON7GB", NeedSlot, +5))); a.Kind != DoReply { last := busy("ON7GB", NeedSlot, +5)
t.Errorf("not called again once free: %+v", a) last.Msg = "PY2SAD ON7GB RR73"
} if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
if e.Status().Attempts != 0 { t.Errorf("%+v — a station on its last frame was not called", a)
t.Errorf("attempts = %d on a fresh series, want 0", e.Status().Attempts)
} }
} }
@@ -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() 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) t.Fatalf("%+v", a)
} }
// It answers somebody else, and a station of the same value is calling CQ // It answers three other callers in a row — which is what a DX with a queue
// in the very same period. Waiting for the next one throws away a slot. // does, and calling through it is how the queue is joined.
a := e.OnPeriod(period(2, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8))) does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
if a.Kind != DoReply || a.Decode.Call != "ER1CW" { for _, n := range []int{2, 4, 6} {
t.Fatalf("%+v — the freed slot was not used", a) if a := does(n); a.Kind != DoNothing {
t.Fatalf("%+v — stopped calling a station working the pileup", a)
} }
if !strings.Contains(a.Reason, "cannot answer") || !strings.Contains(a.Reason, "calling ER1CW") { if e.Target() != "D44TWO" {
t.Errorf("reason %q says neither what was left nor what was taken", a.Reason) t.Fatalf("target is %q — the station was released while it worked others", e.Target())
}
}
// 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 // A BETTER station may still take the slot while it is busy: nothing has
// than losing the slot. // been answered, so nothing is lost by moving.
e = on() e = on()
e.OnPeriod(period(4, cq("DX", NeedBand, -7))) e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
pp := period(6, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8)) if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
pp.TX = TXState{Transmitting: true} t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
if a := e.OnPeriod(pp); a.Kind != DoHalt || !a.Soft {
t.Errorf("%+v — replied over our own transmission", a)
} }
} }
+53
View File
@@ -395,6 +395,47 @@ func titleCase(s string) string {
// lives, and keeps the ones that say WHO they are — name, address, QSL route. // 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 // A portable operator's cards still go to the home address, so that address is
// not wrong; their county 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) { func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", "" r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0 r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
@@ -430,14 +471,26 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// //
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match, // Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is. // 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 dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign { if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum { if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
if !sameEntityName(r.Country, country) {
clearHomeLocation(r) clearHomeLocation(r)
filled = true filled = true
} }
} }
} }
}
if country != "" { if country != "" {
r.Country = country r.Country = country
filled = true filled = true
+57
View File
@@ -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) 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])
}
}
}