feat(ui): the FT decodes panel, two DXHunter themes, and the maps

Decodes:
- one click SELECTS, two transmit. A single click handed the decode
  straight to WSJT-X as a reply, so brushing a row while reading the
  band started calling a station.
- the list empties for a receiver that changes band, and a receiver
  column appears when more than one is feeding one merged list.
- the period clock turns red while transmitting: it is the one thing on
  the screen that moves, so it is where the eye already is.
- a WL badge, after the LoTW "L" — one letter, always in the same
  place, so the column does not shift from row to row.
- the auto-call switch, its target and its count, and the chase list:
  naming the station you are waiting for is done while watching the
  band, not in a settings tree.

Themes: DXHunter's slate with its own blue, and the same slate with
OpsLog's orange. Counted across its sources rather than guessed from
one panel — blue is 132 uses to violet's 25, and the violet is the PSK
Reporter panel alone.

Watchlist: drawn as DXHunter draws it — the callsign in the interface
font rather than monospaced, which is the difference that shows with
the two windows side by side.

FT Map: arcs no longer run off the side of the map. The map shows one
world, and a path crossing the antimeridian was drawn past 180° into
the blank space beside it — from VK that is most of them.

Cluster: "superfox", "fox/hound" and "F/H" in a comment are read as
FT8. They are WSJT-X's DXpedition transmit modes, and the comment fell
through to the band plan and came out DATA — which then decided the
band+mode verdict.

A decoder is named by what it IS: Nexus sends its packets as "Tempo",
the engine inside it, and OpsLog showed a program nobody has heard of.
This commit is contained in:
2026-09-05 21:46:06 +02:00
parent e4a5d42b85
commit 8dbc4b7e62
7 changed files with 321 additions and 41 deletions
+62
View File
@@ -1,4 +1,66 @@
[ [
{
"version": "0.27.13",
"date": "",
"en": [
"Chase new: the panel now says what the FEED is doing — up or down, how many reports it has taken and how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all, which is the first question when it looks dead.",
"Chase new: the count in the header counted what SURVIVED the category filters, so a panel holding twelve stations with the filters off read “0 heard”. It now shows both — “3 of 12 heard” — and the footer takes the radius from the feed instead of claiming ~300 km whatever the setting says.",
"Chase new: category filters that hide everything are no longer remembered as a preference. A stored set holding none of the categories on offer — what a preference written by an older build becomes once a category is renamed — hid the whole panel at every launch with nothing to say why.",
"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, which is a different question: a station equipped for 2 m and 70 cm that is chasing HF no longer has them in the list. A band switched off is dropped as it arrives, so it stops taking a place.",
"Auto-call cut your own 73 in half. When the DXs RRR was decoded the QSO read as finished and the next station was picked in the same instant — but a reply is not queued: WSJT-X acts on it at once, drops the exchange and starts calling the new station. Nothing is called over a transmission in progress now, and the four-minute backstop restarts when the station begins answering, so a QSO that started after a long wait cannot be halted mid-exchange.",
"Auto-call: a real need now outranks an unconfirmed one at the same level. Hunting “new + unconfirmed”, a band already worked and awaiting a QSL is flagged as needed too — rightly — but it is not the same catch as a band never worked, and the two ranked identically: with one of each in a period the engine took whichever was louder. An unconfirmed need drops below the real one at its own level and no further: an unconfirmed new band still outranks a real new slot.",
"PSK Reporter panel: the history query now runs in BOTH feed scopes and is asked again every five minutes while a station is watched. It ran once, and only on the narrow feed — so a target picked shortly after opening the panel showed one decode where another program, subscribed for an hour, showed four. Uploaders batch their reports about every five minutes, which is the cadence this now matches.",
"PSK Reporter panel: the texts said “in the last 5 min” while the window had become ten. The window is what changed; the sentences had not.",
"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 band plan and came out DATA — which then decided the band+mode verdict, so a ZD8 on 21.071 read as a new DATA slot rather than the new FT8 one it is.",
"FT decodes: a receiver column appears when more than one decoder feeds one merged list. Two decoders on the same band send the same stations twice with their own SNR, and nothing on the row said which was which.",
"Auto-call answered four to six seconds late, so the decoder started its call well inside the slot. A period was judged a whole slot plus four seconds after the DECODES OWN TIMESTAMP — which is the start of that slot, thirteen seconds before its decodes exist. It is now judged 0.8 s after the last decode ARRIVES, and the sweeper ticks four times a second instead of once every two: the answer leaves in the same second the decodes landed, which is what the next slot boundary requires.",
"FT decodes: the period clock turns red while transmitting. It is the one thing on the screen that moves, so it is where the eye already is, and “am I on the air” is worth reading from across the room.",
"Auto-call started a call and cut it a second later, then went again. Two faults, both visible in the log: the attempts cap trips WHILE the seventh call is going out — that is what counts it — and it halted the transmitter where it stood, sending half a call; and the station was then picked again four seconds later, because the rest after a series was skipped whenever nothing better was on the air, which for the station everybody is chasing is every period. The cap now lets the over finish before stopping, and a rest is a rest.",
"Chase new now follows a change of hunt (“new” ↔ “new + unconfirmed”). It judges each decode as it arrives and keeps only what was new at that moment, so its list was a set of verdicts reached under the old rule and nothing in it was ever re-judged: switching to “new only” left rows that are merely unconfirmed, and switching the other way could not bring back what had already been refused. The list is emptied on the change and refills from the live feed within a period or two.",
"Halt is now a verdict on the station being auto-called: it is set aside for the session and never called again until auto-call is switched off and on. Halt used to clear the engines whole state — rests and rounds along with it — so the station the operator had just stopped was eligible again in the same second and the next period called it straight back. Naming it in the chase list does not override that either.",
"Auto-call can log every decision (Settings → DXHunter, under auto-call): one line per period saying what was on the air, how many stations were callable, why each of the others was refused — worked, nothing needed, mid-QSO, resting, halted, parked, no LoTW — and which ones were ranked. It answers the question the log could not: why nothing was called while the panel showed a screen full of wanted stations. A line every fifteen seconds, so it is off by default.",
"FT decodes: the list empties for a receiver that changes band. What is on 20 m says nothing about 40 m, and half a screen of stations no longer reachable is worse than an empty one — the period headings still march on and the badges still read as verdicts. Per receiver: in a split view the other one has not moved.",
"FT decodes: a pink WL badge marks a station on your watch list, first in the status column. It is your own answer to “is this one worth my time”, and it outranks whatever the log makes of the entity. Matched by the watch lists own rule — a trailing * is a prefix — so a badge and an alert cannot disagree about the same station.",
"Auto-call: a station CALLING YOU is answered whether or not the log has anything to gain. It was refused when nothing was needed from it — so a QSO would end, two stations would call on the next sequence, and the machine ignored both. Already worked on this band and mode, or stopped by the operator, still means no.",
"Auto-call: a watched callsign now outranks every station that is not on the list, instead of being lifted one rung at a time. A watched DXpedition with nothing left to gain from it sat at the bottom of the ladder and was never reached on a band full of stations that were needed — which is the opposite of what putting it on the list meant. Among watched stations the old order still applies: entity, band, mode, slot.",
"FT decodes: the watch-list badge comes after the LoTW “L”, which is one letter and always in the same place — a badge in front of it shifted the whole status column from row to row.",
"Auto-call stops calling a station that starts working somebody else. Callability was tested when the station was CHOSEN and never again while it was held, so one picked on its CQ that then answered another caller went on being called for the whole seven attempts — two minutes of transmitting at a station in a QSO that cannot hear it. Its final frame to another station does not count: that one is a period from being free, which is the best moment there is to be calling it. Released with no verdict attached, so it is fair game with a full allowance the moment it CQs again.",
"Auto-call now calls only what the decodes list is SHOWING. The filters above it — CQ only, LoTW only, the new-category chips, continents, minimum report, the search box — steer the transmitter as well as the eye: a station filtered off the screen is not called. The panel sends the callsigns it is showing rather than its filter settings, so there is one definition of “shown” and the engine cannot disagree with what is in front of the operator. With the list closed, nothing is filtered.",
"PSK Reporter panel: the suggested call offset now always has an answer when there is data. It looked for a clear run of empty slots and said nothing when it found none — which is exactly the case it was built for: a hundred decodes across a 2800 Hz passband leave no gap at all, and the panel drew the whole histogram with no advice under it. Failing a real gap it now names the quietest slot, ties going to the higher offset, and never above the ceiling.",
"Auto-call refused nearly every station on the air as “already worked”. It was reading the ENTITYs verdict as the stations: “worked” there means the COUNTRY is in the log on this band and mode, so on a band where the operator has most of them, everything decoding was turned away — a trace of one evening shows twenty decodes out of twenty-one refused that way, the watched DXpedition among them. The entitys verdict now decides what is NEEDED, and the callsigns own slot decides whether calling it would be a duplicate.",
"Auto-call reads a multi-answer line whole. MSHV answers two stations in one transmission and the decoder prints them as one line — “YV5ALI RR73; F4BPO <HK0/PY8WW> -08” — where the second half is a report to us. Reading only the start of it, the engine saw a station working somebody else and dropped the target at the exact moment the DX was answering. The decodes panel already read every segment; the engine now does too, for “is it calling us”, “can it be called” and “is the QSO finished”.",
"Auto-call leaves the slot after a QSO to your own 73. The DXs RR73 is decoded while you are between overs, so the decoder is about to send that 73 in the slot just opening — answering somebody else there switched the DX call mid-sequence, and the frame that closes the contact never went out whole. The next station is picked on the following period instead, fifteen seconds later, and it is still there. It used to hand straight on because the slot is the resource; the slot is worth less than the QSO it spoils."
],
"fr": [
"Chasse au nouveau : le panneau dit désormais ce que fait le FLUX — actif ou coupé, combien de reports il a pris, sur combien de carrés récepteurs il est abonné. Une liste vide ne disait rien sur le fait quil arrive quoi que ce soit, ce qui est la première question quand elle semble morte.",
"Chasse au nouveau : le compteur de len-tête comptait ce qui SURVIVAIT aux filtres de catégorie — un panneau contenant douze stations affichait « 0 entendus » filtres éteints. Il affiche maintenant les deux (« 3 sur 12 entendus »), et le pied de panneau prend le rayon dans le flux au lieu dannoncer ~300 km quel que soit le réglage.",
"Chasse au nouveau : un filtrage qui masque tout nest plus retenu comme une préférence. Un ensemble enregistré ne contenant aucune des catégories proposées — ce que devient une préférence écrite par une version antérieure après le renommage dune catégorie — masquait tout le panneau à chaque lancement, sans rien dire.",
"Chasse au nouveau : une sélection de bandes qui lui est propre, 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, ce qui est une autre question : une station équipée 2 m et 70 cm qui chasse en HF ne les a plus dans la liste. Une bande décochée est écartée à larrivée, elle cesse de prendre une place.",
"Lappel automatique coupait votre propre 73. Au décodage du RRR du DX, le QSO était considéré terminé et la station suivante choisie dans le même instant — or un reply nest pas mis en file : WSJT-X lexécute aussitôt, abandonne l’échange et se met à appeler la nouvelle station. Plus rien nest appelé par-dessus une émission en cours, et le butoir de quatre minutes repart quand la station commence à répondre : un QSO entamé après une longue attente ne peut plus être interrompu en plein échange.",
"Appel automatique : un vrai besoin passe désormais devant un besoin non confirmé de même niveau. En chasse « nouveau + non confirmé », une bande déjà travaillée en attente de QSL est signalée comme besoin — à juste titre — mais ce nest pas la même prise quune bande jamais travaillée, et les deux avaient le même rang : avec une de chaque dans la période, le moteur prenait la plus forte. Un besoin non confirmé descend sous le vrai besoin de son propre niveau, et pas plus bas : une nouvelle bande non confirmée passe toujours devant un vrai nouveau slot.",
"Panneau PSK Reporter : la requête dhistorique sexécute désormais dans LES DEUX portées de flux, et elle est refaite toutes les cinq minutes tant quune station est surveillée. Elle navait lieu quune fois, et seulement sur le flux étroit — une cible choisie peu après louverture du panneau affichait un décodage là où un autre programme, abonné depuis une heure, en montrait quatre. Les stations qui alimentent PSK Reporter envoient par paquets toutes les cinq minutes environ : cest cette cadence qui est désormais suivie.",
"Panneau PSK Reporter : les textes annonçaient « depuis 5 min » alors que la fenêtre était passée à dix. Cest la fenêtre qui avait changé, pas les phrases.",
"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 DXpedition de WSJT-X, pas des modes à part : le commentaire retombait sur le plan de bande et ressortait en DATA — ce qui décidait ensuite du verdict bande+mode, si bien quun ZD8 sur 21.071 se lisait comme un nouveau slot DATA au lieu du nouveau FT8 quil est.",
"Décodages FT : une colonne récepteur apparaît quand plusieurs décodeurs alimentent une même liste fusionnée. Deux décodeurs sur la même bande envoient les mêmes stations deux fois avec leur propre SNR, et rien sur la ligne ne disait laquelle était laquelle.",
"Lappel automatique répondait avec quatre à six secondes de retard, si bien que le décodeur commençait son appel bien après le début du créneau. Une période était jugée un créneau entier plus quatre secondes après LHORODATAGE DU DÉCODAGE — cest-à-dire le début de ce créneau, treize secondes avant que ses décodages existent. Elle est désormais jugée 0,8 s après lARRIVÉE du dernier décodage, et le balayeur tourne quatre fois par seconde au lieu dune fois toutes les deux : la réponse part dans la seconde où les décodages sont tombés, ce quexige la frontière du créneau suivant.",
"Décodages FT : lhorloge de période passe au rouge pendant l’émission. Cest le seul élément qui bouge à l’écran, donc là où l’œil se trouve déjà, et « suis-je en émission » mérite d’être lisible de lautre bout de la pièce.",
"Lappel automatique lançait un appel puis le coupait une seconde plus tard, avant de repartir. Deux fautes, toutes deux lisibles dans le journal : le plafond dappels tombe PENDANT l’émission du septième appel — cest elle qui le compte — et il coupait la porteuse sur place, envoyant un demi-appel ; puis la station était reprise quatre secondes après, parce que le repos suivant une série était ignoré dès que rien de mieux n’était sur lair, ce qui, pour la station que tout le monde chasse, est le cas à chaque période. Le plafond laisse désormais l’émission en cours se terminer, et un repos est un repos.",
"La chasse au nouveau suit désormais un changement de chasse (« nouveau » ↔ « nouveau + non confirmé »). Elle juge chaque décodage à son arrivée et ne garde que ce qui était nouveau à cet instant : sa liste était donc un ensemble de verdicts rendus sous lancienne règle, que rien ne rejugeait — passer en « nouveau seulement » y laissait des lignes simplement non confirmées, et linverse ne pouvait pas ramener ce qui avait déjà été refusé. La liste est vidée au changement et se remplit à nouveau depuis le flux en une période ou deux.",
"Le Stop devient un verdict sur la station appelée automatiquement : elle est mise de côté pour la session et nest plus appelée jusqu’à ce que lappel automatique soit éteint puis rallumé. Le Stop remettait tout le moteur à zéro — repos et séries compris — si bien que la station quon venait darrêter redevenait éligible dans la seconde et que la période suivante la rappelait. La nommer dans la liste de chasse ne passe pas outre non plus.",
"Lappel automatique peut journaliser chaque décision (Réglages → DXHunter, sous lappel automatique) : une ligne par période disant ce quil y avait sur lair, combien de stations étaient appelables, pourquoi chacune des autres a été écartée — travaillée, rien à y gagner, en QSO, au repos, arrêtée par lopérateur, épuisée, sans LoTW — et lesquelles ont été classées. Elle répond à la question à laquelle le journal ne pouvait pas répondre : pourquoi rien na été appelé alors que le panneau était plein de stations voulues. Une ligne toutes les quinze secondes : désactivé par défaut.",
"Décodages FT : la liste se vide pour un récepteur qui change de bande. Ce quil y a sur 20 m ne dit rien du 40 m, et un demi-écran de stations devenues inatteignables est pire quun écran vide — les en-têtes de période continuent de défiler et les pastilles se lisent toujours comme des verdicts. Par récepteur : en vue scindée, lautre na pas bougé.",
"Décodages FT : une pastille rose WL signale une station de votre liste de surveillance, en tête de la colonne Statut. Cest votre propre réponse à « celle-là vaut-elle mon temps », et elle passe devant ce que le log pense de lentité. Reconnue par la règle de la liste elle-même — un * final est un préfixe — pour quune pastille et une alerte ne puissent pas se contredire.",
"Appel automatique : une station qui VOUS appelle est prise, que le log ait quelque chose à y gagner ou non. Elle était écartée quand il ny avait rien à en tirer : un QSO se terminait, deux stations appelaient à la séquence suivante, et la machine les ignorait toutes les deux. Déjà travaillée sur cette bande et ce mode, ou arrêtée par lopérateur, veut toujours dire non.",
"Appel automatique : un indicatif surveillé passe désormais devant toute station absente de la liste, au lieu d’être remonté dun cran à la fois. Une DXpédition surveillée dont on navait plus besoin restait au bas de l’échelle et n’était jamais atteinte sur une bande pleine de stations utiles — linverse de ce que veut dire lavoir mise sur la liste. Entre stations surveillées, lordre davant sapplique toujours : entité, bande, mode, slot.",
"Décodages FT : la pastille de liste de surveillance passe après le « L » de LoTW, qui tient en une lettre et reste toujours au même endroit — une pastille devant lui décalait toute la colonne Statut dune ligne à lautre.",
"Lappel automatique cesse dappeler une station qui se met à travailler quelquun dautre. Le fait d’être appelable était vérifié au moment de la CHOISIR et jamais ensuite pendant quon la tenait : une station prise sur son CQ qui répondait à un autre appelant continuait d’être appelée pendant les sept essais — deux minutes d’émission vers une station en QSO qui ne peut pas lentendre. Sa trame finale vers un autre ne compte pas : celle-là est à une période d’être libre, cest le meilleur moment pour lappeler. Relâchée sans verdict, elle redevient appelable avec un quota complet dès quelle repasse CQ.",
"Lappel automatique nappelle plus que ce que la liste des décodages AFFICHE. Les filtres au-dessus delle — CQ seuls, LoTW, les pastilles de catégorie, continents, report minimal, la recherche — pilotent l’émetteur autant que l’œil : une station filtrée hors écran nest pas appelée. Le panneau envoie les indicatifs quil montre et non ses réglages de filtre, si bien quil nexiste quune définition de « affiché » et que le moteur ne peut pas contredire ce que lopérateur a sous les yeux. La liste fermée, rien nest filtré.",
"Panneau PSK Reporter : le créneau dappel suggéré donne désormais toujours une réponse quand il y a des données. Il cherchait une suite de créneaux vides et se taisait quand il nen trouvait pas — cest-à-dire précisément dans le cas pour lequel il existe : cent décodages sur un passe-bande de 2800 Hz ne laissent aucun trou, et le panneau dessinait tout lhistogramme sans un mot de conseil dessous. À défaut de vrai trou, il nomme le créneau le moins encombré, les égalités allant vers le haut, et jamais au-dessus du plafond.",
"Lappel automatique écartait presque toutes les stations sur lair comme « déjà travaillées ». Il lisait le verdict de lENTITÉ comme celui de la station : « travaillé » y signifie que le PAYS est au log sur cette bande et ce mode, donc sur une bande où lopérateur les a presque tous, tout ce qui décodait était refusé — une trace dune soirée montre vingt décodages sur vingt et un écartés ainsi, la DXpédition surveillée parmi eux. Le verdict de lentité décide désormais de ce qui est NÉCESSAIRE, et le créneau propre à lindicatif décide si lappeler ferait un doublon.",
"Lappel automatique lit une trame multi-réponses en entier. MSHV répond à deux stations dans une même émission et le décodeur limprime sur une seule ligne — « YV5ALI RR73; F4BPO <HK0/PY8WW> -08 » — dont la seconde moitié est un report pour nous. En nen lisant que le début, le moteur voyait une station occupée avec quelquun dautre et lâchait la cible au moment précis où le DX répondait. Le panneau des décodages lisait déjà chaque segment ; le moteur le fait désormais aussi, pour « est-ce quil nous appelle », « peut-on lappeler » et « le QSO est-il fini ».",
"Lappel automatique laisse à votre 73 le créneau qui suit un QSO. Le RR73 du DX est décodé pendant que vous êtes entre deux émissions : le décodeur sapprête à envoyer ce 73 dans le créneau qui souvre, et y répondre à quelquun dautre changeait lindicatif en pleine séquence — la trame qui clôt le contact ne partait jamais entière. La station suivante est désormais choisie à la période daprès, quinze secondes plus tard, et elle est toujours là. Il enchaînait aussitôt parce que le créneau est la ressource ; le créneau vaut moins que le QSO quil gâche."
]
},
{ {
"version": "0.27.12", "version": "0.27.12",
"date": "", "date": "",
+55 -6
View File
@@ -54,7 +54,7 @@ import {
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew, GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall, GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
@@ -123,6 +123,7 @@ import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros'; import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { PSKReporterPanel } from '@/components/PSKReporterPanel'; import { PSKReporterPanel } from '@/components/PSKReporterPanel';
import { decoderName } from '@/lib/decoderName';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat'; import { formatDateTimeUTC } from '@/lib/dateFormat';
@@ -2406,6 +2407,17 @@ export default function App() {
// the watchlist panel: a call can be added from the cluster or the // the watchlist panel: a call can be added from the cluster or the
// DXpeditions tab, and the old inline message lived on a page nobody was // DXpeditions tab, and the old inline message lived on a page nobody was
// looking at. // looking at.
// The watch list as patterns, for the decodes badge. Loaded once and kept in
// step with the same event the notice below listens to — a call added from
// the cluster has to light up in the decodes list too.
const [watchPatterns, setWatchPatterns] = useState<string[]>([]);
const loadWatchPatterns = useCallback(() => {
WatchlistEntries()
.then((es: any[]) => setWatchPatterns((es ?? []).map((e: any) => String(e?.callsign ?? '')).filter(Boolean)))
.catch(() => {});
}, []);
useEffect(() => { loadWatchPatterns(); }, [loadWatchPatterns]);
useEffect(() => EventsOn('watchlist:changed', () => loadWatchPatterns()), [loadWatchPatterns]);
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null); const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
const wlNoticeTimer = useRef<number | undefined>(undefined); const wlNoticeTimer = useRef<number | undefined>(undefined);
useEffect(() => EventsOn('watchlist:changed', (e: any) => { useEffect(() => EventsOn('watchlist:changed', (e: any) => {
@@ -2597,6 +2609,9 @@ export default function App() {
// Staged like the cluster's, so a period arriving as one burst of fifty // Staged like the cluster's, so a period arriving as one burst of fifty
// packets costs one status lookup and one render, not fifty of each. // packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]); const pendingDecodesRef = useRef<DecodeRow[]>([]);
// The band each receiver was last decoding on. A band change empties that
// receiver's list — see the flush below.
const decoderBandRef = useRef<Map<string, string>>(new Map());
const pendingDecodeTimer = useRef<number | undefined>(undefined); const pendingDecodeTimer = useRef<number | undefined>(undefined);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the // Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
@@ -3718,9 +3733,42 @@ export default function App() {
}); });
} }
} catch { /* status unresolved — the decode still shows, just unflagged */ } } catch { /* status unresolved — the decode still shows, just unflagged */ }
// A BAND CHANGE empties that receiver's list.
//
// What is on 20 m says nothing about 40 m, and half a screen of stations
// that are no longer reachable is worse than an empty one: the period
// headings still march on, the rows still carry status badges, and the
// operator reads a band they have left. Per receiver — in a split view
// the other one has not moved — and only when the new band is known.
const moved = new Map<string, string>();
for (const d of batch) {
const inst = d.instance ?? '';
const band = (d.band ?? '').toLowerCase();
if (!band) continue;
const was = decoderBandRef.current.get(inst);
decoderBandRef.current.set(inst, band);
if (was && was !== band) moved.set(inst, band);
}
if (moved.size > 0) {
for (const [inst, band] of moved) {
LogUIError('decodes', `${decoderName(inst) || 'decoder'} moved to ${band.toUpperCase()} — its earlier decodes cleared`, '');
}
setTxMsgs((arr) => arr.filter((m) => !moved.has(m.instance ?? '')));
}
setDecodes((arr) => { setDecodes((arr) => {
const cutoff = Date.now() - DECODE_KEEP_MS; const cutoff = Date.now() - DECODE_KEEP_MS;
const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff); // Rows from a receiver that has just changed band go with it — the
// batch itself is already on the new band.
const kept = moved.size === 0 ? arr : arr.filter((d) => !moved.has(d.instance ?? ''));
// The batch can straddle the change — a 300 ms flush can hold the last
// decodes of the old band and the first of the new. Only the new band
// survives for a receiver that moved.
const fresh = moved.size === 0 ? batch
: batch.filter((d) => {
const b2 = moved.get(d.instance ?? '');
return !b2 || (d.band ?? '').toLowerCase() === b2;
});
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
return next; return next;
}); });
}; };
@@ -6385,15 +6433,16 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application // An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case. // last reported its status — the normal single-receiver case.
onHalt={(instance) => { onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started — and it // Halt is a verdict on the station being called: it is set aside for
// clears the engine's state, so a target it had given up on is not // the session rather than released, or the next period would call it
// still sitting there when the operator switches it back on. // straight back — which is exactly what the operator just stopped.
ResetAutoCall().catch(() => {}); HaltAutoCall().catch(() => {});
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e))); HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}} }}
autoCallOn={!!autoCallStatus?.enabled} autoCallOn={!!autoCallStatus?.enabled}
onToggleAutoCall={toggleAutoCall} onToggleAutoCall={toggleAutoCall}
autoCall={autoCallStatus} autoCall={autoCallStatus}
watchlist={watchPatterns}
autoCallOnly={autoCallStatus?.only ?? ''} autoCallOnly={autoCallStatus?.only ?? ''}
onSetAutoCallOnly={(list) => { onSetAutoCallOnly={(list) => {
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() })); setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
+35 -5
View File
@@ -15,7 +15,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay'; import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers'; import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App'; import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
export interface ChaseNewSpot { export interface ChaseNewSpot {
call: string; call: string;
@@ -80,15 +80,25 @@ function categoryOf(s: ChaseNewSpot): Category | null {
const FILTER_KEY = 'opslog.chaseNewFilters'; const FILTER_KEY = 'opslog.chaseNewFilters';
function allowedCategories(): Category[] {
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
}
function loadFilters(): Set<Category> { function loadFilters(): Set<Category> {
const allowed = allowedCategories();
try { try {
const raw = localStorage.getItem(FILTER_KEY); const raw = localStorage.getItem(FILTER_KEY);
if (raw) { if (raw) {
const list = JSON.parse(raw) as Category[]; const list = JSON.parse(raw) as Category[];
if (Array.isArray(list)) return new Set(list); // A stored set holding NONE of the categories on offer hides the whole
// panel, for ever, with nothing to say why — and that is exactly what a
// preference written by an older build does once a category is renamed.
// Treated as "no preference": a panel that shows nothing at every launch
// is never what was meant, and the chips are one click away.
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
} }
} catch { /* a corrupt preference is not worth a broken panel */ } } catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key)); return new Set(allowed);
} }
export function ChaseNewPanel({ onPick, onClose }: Props) { export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -96,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
const [spots, setSpots] = useState<ChaseNewSpot[]>([]); const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [on, setOn] = useState<Set<Category>>(loadFilters); const [on, setOn] = useState<Set<Category>>(loadFilters);
// The FEED, not the list: connected or not, how many reports it has taken,
// and what it is filtered on. Without it an empty panel says nothing about
// whether anything is arriving at all — which is the first question.
const [feed, setFeed] = useState<any>(null);
// Polled rather than pushed: the feed can deliver several a second under an // Polled rather than pushed: the feed can deliver several a second under an
// opening, and an event per row would be a redraw per row for a list nobody // opening, and an event per row would be a redraw per row for a list nobody
@@ -106,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
try { try {
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[]; const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
if (alive) { setSpots(r); setLoaded(true); } if (alive) { setSpots(r); setLoaded(true); }
const st = await GetPSKReporterStatus();
if (alive) setFeed(st);
} catch { /* the feed may not be up yet */ } } catch { /* the feed may not be up yet */ }
}; };
tick(); tick();
@@ -154,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
))} ))}
</div> </div>
{/* Both numbers: what is on screen, and what was heard. They differ
exactly when a category is switched off, which is the one case an
operator reads this panel as broken. */}
<span className="shrink-0 text-[10px] text-muted-foreground"> <span className="shrink-0 text-[10px] text-muted-foreground">
{loaded ? t('chn.count', { n: shown.length }) : ''} {loaded ? (shown.length === spots.length
? t('chn.count', { n: spots.length })
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
</span> </span>
{onClose && ( {onClose && (
<button <button
@@ -218,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
)} )}
</div> </div>
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p> {/* The radius comes from the FEED, not from a sentence: it was written
into this line as "~300 km" and stayed 300 while the setting said
1000, which is the panel telling the operator their change did not
take when it had. */}
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
{feed && (feed.running
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
</p>
</div> </div>
); );
} }
+84 -11
View File
@@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay'; import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
import { decoderName } from '@/lib/decoderName'; import { decoderName } from '@/lib/decoderName';
export type Decode = { export type Decode = {
@@ -125,6 +126,9 @@ interface Props {
// waiting for is done WHILE watching the band, not in a settings tree. // waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string; autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void; onSetAutoCallOnly?: (list: string) => void;
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
// so where the operator is reading the band, not only in the watchlist tab.
watchlist?: string[];
} }
// The "new" categories, as toggle badges — the same idea and the same colours as // The "new" categories, as toggle badges — the same idea and the same colours as
@@ -171,6 +175,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
return out; return out;
} }
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
// anything else is the whole callsign — so a decode is judged here exactly as
// the backend judges a spot. Two rules for one list is how a badge and an alert
// start disagreeing about the same station.
function isWatched(call: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0 || !call) return false;
const c = call.toUpperCase();
for (const raw of patterns) {
const p = (raw ?? '').toUpperCase().trim();
if (!p) continue;
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
}
return false;
}
const CAT_KEY = 'opslog.decodeCats'; const CAT_KEY = 'opslog.decodeCats';
const SPLIT_KEY = 'opslog.decodeSplit'; const SPLIT_KEY = 'opslog.decodeSplit';
const FILTER_KEY = 'opslog.decodeFilters'; const FILTER_KEY = 'opslog.decodeFilters';
@@ -252,9 +271,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the // One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header // resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body. // and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status'; type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 }, { key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
// that is the case it exists for: two decoders on one band send the same
// stations twice, each with its own SNR and DT, and a merged list gave no way
// at all to tell a second receiver from a duplicate.
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 }, { key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 }, { key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 }, { key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
@@ -450,7 +474,7 @@ function renderMsg(msg: string, me: string, calling: string) {
// //
// It is the one moving thing on the panel, and it answers the question an // It is the one moving thing on the panel, and it answers the question an
// operator actually has between overs: how long until the next batch. // operator actually has between overs: how long until the next batch.
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) { function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
// 100 ms: smooth enough for a bar that fills in three and three quarter // 100 ms: smooth enough for a bar that fills in three and three quarter
@@ -466,19 +490,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
// The last fifth of a slot is when a decode is imminent and an operator // The last fifth of a slot is when a decode is imminent and an operator
// deciding whether to answer has run out of time to think. // deciding whether to answer has run out of time to think.
const closing = left <= trSec / 5; const closing = left <= trSec / 5;
// TRANSMITTING outranks both. The bar is the one thing on this screen that
// moves continuously, so it is what the eye is already on — and "am I on the
// air" is the state worth reading from across the room. Red, and it stays red
// for the whole over rather than turning amber near the end of it.
const tone = tx ? 'danger' : closing ? 'warning' : '';
return ( return (
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}> <span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} /> <Timer className={cn('size-4',
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden"> <span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<span <span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear', className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
closing ? 'bg-warning' : 'bg-primary')} tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</span> </span>
<span className={cn('font-mono text-sm tabular-nums w-10 text-right', <span className={cn('font-mono text-sm tabular-nums w-10 text-right',
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}> tone === 'danger' ? 'text-danger font-semibold'
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
{left.toFixed(1)} {left.toFixed(1)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -552,7 +583,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) { export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted // Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/ // through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -567,8 +598,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return; if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? ''); setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]); }, [autoCallOnly, t]);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
const setColWidth = (key: ColKey, px: number) => { const setColWidth = (key: ColKey, px: number) => {
const col = COLS.find((c) => c.key === key)!; const col = COLS.find((c) => c.key === key)!;
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px))); const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
@@ -681,6 +710,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]); }, [decodes, spotStatus]);
// The receiver column is dead weight with one decoder — which is nearly
// everybody — so it is not there at all until a second one starts feeding.
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
[instances.length, splitByInstance]);
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
// All seven, always, in their usual order. // All seven, always, in their usual order.
// //
// The chips used to be built from the continents ON the feed, which read as a // The chips used to be built from the continents ON the feed, which read as a
@@ -714,6 +749,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]); }, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
// What this panel is SHOWING, published to the auto-call engine.
//
// The filters are the operator's control over the transmitter as well as
// over the list: a station filtered off the screen is not called. The panel
// sends the callsigns rather than the filter settings, so there is one
// definition of "shown" and not two — the engine cannot disagree with what
// is in front of the operator.
useEffect(() => {
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
SetAutoCallVisible(calls, true).catch(() => {});
}, [filtered]);
// Closed, it publishes nothing: filters that are not on the screen cannot
// silence the engine behind the operator's back.
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
// Group into periods, newest first, and drop the operator's transmissions into // Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in. // the slot they went out in.
@@ -770,7 +821,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{/* The slot clock. Taken from the newest decode's mode, falling back to {/* The slot clock. Taken from the newest decode's mode, falling back to
what the transmit state reports, so it is right the moment anything what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */} is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} /> {/* Any receiver on the air colours it: with two decoders the shared
txState is whichever reported last, and "somebody here is
transmitting" is what the bar has to say. */}
<PeriodClock trSec={liveTr} mode={liveMode}
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
{bandDrift && ( {bandDrift && (
<span <span
title={t('dec.bandDriftTip')} title={t('dec.bandDriftTip')}
@@ -1082,12 +1137,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<div className="shrink-0 border-b border-border bg-background overflow-hidden"> <div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')} <div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}> style={{ gridTemplateColumns: template, width: tableW }}>
{COLS.map((c, i) => ( {cols.map((c, i) => (
<span key={c.key} <span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would // Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle. // clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2', className={cn('relative flex items-center min-w-0 px-2',
i < COLS.length - 1 && 'border-r border-border/30', i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the // The three numeric columns label their own right edge, where the
// figures are. // figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')} (c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
@@ -1185,6 +1240,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{hhmmssCompact(d.at)} {hhmmssCompact(d.at)}
</span> </span>
{/* Which receiver heard it — present only while more than one
is feeding a merged list. */}
{cols.some((c) => c.key === 'rx') && (
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
title={d.instance ?? ''}>
{decoderName(d.instance)}
</span>
)}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}> <span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr} {d.snr > 0 ? `+${d.snr}` : d.snr}
</span> </span>
@@ -1251,6 +1315,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{e?.lotw && ( {e?.lotw && (
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span> <span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
)} )}
{/* After the L, which is one letter and always in the same
place: a badge in front of it moved the whole column
sideways from row to row. */}
{isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')}
</span>
)}
{e?.worked_call && ( {e?.worked_call && (
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0"> <span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
{t('dec.wkd')} {t('dec.wkd')}
+65 -5
View File
@@ -60,7 +60,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetChaseNewBands, SetChaseNewBands, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -405,16 +405,21 @@ interface TreeProps {
flexAvailable?: boolean; flexAvailable?: boolean;
} }
function Tree({ selected, onSelect, flexAvailable }: TreeProps) { // The sidebar. Memoised and its tree built once per (language, radio): it is
// sixty-odd items that depend on nothing an operator types, and it was rebuilt
// and re-rendered on every keystroke in every field of every panel — the whole
// dialog holds its state in one component, so one character redraws all of it.
const Tree = memo(function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
const { t } = useI18n(); const { t } = useI18n();
const nodes = useMemo(() => buildTree(!!flexAvailable, t), [flexAvailable, t]);
return ( return (
<nav className="text-sm"> <nav className="text-sm">
{buildTree(!!flexAvailable, t).map((node, i) => ( {nodes.map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} /> <TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))} ))}
</nav> </nav>
); );
} });
function TreeNodeView({ function TreeNodeView({
node, depth, selected, onSelect, node, depth, selected, onSelect,
@@ -472,6 +477,11 @@ function TreeNodeView({
// identity is STABLE across SettingsModal re-renders; defining them inside the // identity is STABLE across SettingsModal re-renders; defining them inside the
// component would give each render a fresh function, remounting the Radix // component would give each render a fresh function, remounting the Radix
// Select and slamming the open dropdown shut on any ambient re-render. // Select and slamming the open dropdown shut on any ambient re-render.
// The bands the Chase new filter offers, in band-plan order. Mirrors
// ChaseNewBands in pskchase.go, which is what actually filters the feed —
// a band added there and not here is simply one nobody can switch off.
const CHASE_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = { const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' }, 'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
@@ -2089,11 +2099,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' }); const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 }); const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2, on_screen_only: true });
// The named contest callsigns. Raw text in state, written on blur: it is a // The named contest callsigns. Raw text in state, written on blur: it is a
// multi-line list, and normalising it on every keystroke would fight the // multi-line list, and normalising it on every keystroke would fight the
// Return key — the one key this box is built around. // Return key — the one key this box is built around.
const [contestCalls, setContestCalls] = useState(''); const [contestCalls, setContestCalls] = useState('');
// Which bands the Chase new panel shows. The station's own band list still
// applies underneath — this one is "what am I watching tonight".
const [chaseBands, setChaseBands] = useState<string[]>([]);
const saveChaseBands = (next: string[]) => {
setChaseBands(next);
void SetChaseNewBands(next);
};
const [contestPattern, setContestPattern] = useState(''); const [contestPattern, setContestPattern] = useState('');
const saveAC = async (next: any) => { const saveAC = async (next: any) => {
setAc(next); setAc(next);
@@ -2132,6 +2149,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ } try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ } try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ } try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
try { setChaseBands((await GetChaseNewBands()) ?? []); } catch { /* defaults stand */ }
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ } try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ } try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
@@ -5402,6 +5420,34 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span> <span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
</div> </div>
)} )}
{/* Which bands the panel lists. Not the station's band list that
one says what this station can work at all and still applies
underneath. This says what is worth watching tonight, which is
a different question and changes far more often. */}
{chaseNew && (
<div className="pl-6 space-y-1">
<span className="text-xs text-muted-foreground">{t('chn.bands')}</span>
<div className="flex flex-wrap items-center gap-1">
{CHASE_BANDS.map((b) => {
const on = chaseBands.includes(b);
return (
<button key={b} type="button"
onClick={() => saveChaseBands(on ? chaseBands.filter((x) => x !== b) : [...chaseBands, b])}
className={cn('h-6 px-2 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
<button type="button" onClick={() => saveChaseBands([...CHASE_BANDS])}
className="h-6 px-2 rounded-full border border-border text-[11px] text-muted-foreground hover:bg-muted">
{t('chn.bandsAll')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('chn.bandsHint')}</p>
</div>
)}
</div> </div>
{/* PSK Reporter analysis of the station being called. Same service as {/* PSK Reporter analysis of the station being called. Same service as
@@ -5505,6 +5551,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</span> </span>
))} ))}
</div> </div>
{/* A rule about what the transmitter may call, stated once. The
decodes panel has a LoTW chip too, but that one is a way of
READING the band it is flicked on and off while looking
around, and the panel can be closed. */}
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={ac.on_screen_only !== false}
onCheckedChange={(c) => saveAC({ ...ac, on_screen_only: !!c })} />
<span>{t('ac.onScreen')} <span className="text-muted-foreground">{t('ac.onScreenHint')}</span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!ac.trace}
onCheckedChange={(c) => saveAC({ ...ac, trace: !!c })} />
<span>{t('ac.trace')} <span className="text-muted-foreground">{t('ac.traceHint')}</span></span>
</label>
</div> </div>
)} )}
</div> </div>
File diff suppressed because one or more lines are too long
+6
View File
@@ -15,6 +15,12 @@ export function cleanSpotter(s: string): string {
// alone instead of guessing wrong. // alone instead of guessing wrong.
export function inferSpotMode(comment: string, freqHz: number): string { export function inferSpotMode(comment: string, freqHz: number): string {
const c = (comment || '').toUpperCase(); const c = (comment || '').toUpperCase();
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit
// modes, not modes of their own. A spot commented "super fox" fell through to
// the band plan and came out DATA, and that verdict is not cosmetic: the
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
// new DATA slot rather than the new FT8 one it is.
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
if (/\bFT8\b/.test(c)) return 'FT8'; if (/\bFT8\b/.test(c)) return 'FT8';
if (/\bFT4\b/.test(c)) return 'FT4'; if (/\bFT4\b/.test(c)) return 'FT4';
if (/\bJS8\b/.test(c)) return 'JS8'; if (/\bJS8\b/.test(c)) return 'JS8';