feat(bandopen): a blinking badge that lasts as long as the opening does

The only sign of a detection was a toast. It is gone in seconds, and an operator
who was tuning at that moment had no way back to it — for an event that lasts
hours and happens a handful of times a season, that is the wrong shape entirely.

The badge sits at the right-hand end of the status bar, before the clock: an
opening is a state of the WORLD, not of this station, so it belongs with the
time and the logbook rather than among the rig and amplifier chips.

Knowing when to go out was the real work. Nothing announces that an opening
ended, and the detector deliberately says nothing more about a band for 45
minutes after announcing it — right for a message, useless for a badge. So it is
inferred: every qualifying spot on that band pushes a deadline out, and when
they stop arriving the badge fades by itself. Fifteen minutes, comfortably more
than the detector's own twelve-minute window, so a quiet couple of minutes
mid-opening does not blink it off and on again.

Gated on the same distance floor the detector uses. Without that, a band busy
with short-range tropo would hold an Es badge lit indefinitely on spots the
detector itself had refused.
This commit is contained in:
2026-08-11 13:48:26 +02:00
parent 0550ecdac3
commit 96c99f0ae6
7 changed files with 123 additions and 13 deletions
+37 -1
View File
@@ -51,7 +51,7 @@ import {
ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus,
GetPSKReporterStatus, GetLiveOpenings,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -1710,6 +1710,17 @@ export default function App() {
const [showSettings, setShowSettings] = useState(false);
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
// Openings under way, for the blinking status-bar badge. Polled rather than
// event-driven: the badge also has to go OUT when a band goes quiet, and
// nothing emits an event for something that stopped happening.
const [liveOpenings, setLiveOpenings] = useState<any[]>([]);
useEffect(() => {
const load = () => { GetLiveOpenings().then((v: any) => setLiveOpenings(v ?? [])).catch(() => {}); };
load();
const t = window.setInterval(load, 20000);
return () => window.clearInterval(t);
}, []);
// PSK Reporter feed, for the status-bar chip. Polled slowly: the chip only
// says up or down, and the count behind it is a tooltip.
const [pskr, setPskr] = useState<any>(null);
@@ -6942,6 +6953,31 @@ export default function App() {
{/* UTC clock moved out of the header, where it competed with the
frequency for the eye. It belongs with the other passive
indicators. */}
{/* Openings under way. Right-hand end, before the clock, because it is
a state of the world rather than a state of this station the
same side as the time and the logbook.
It BLINKS and it stays. The toast that announces an opening is
gone in seconds, and an operator who was tuning at that moment
had no way back to it; a band opening lasts hours and deserves
something that lasts with it. It goes out by itself when the
spots stop arriving. */}
{liveOpenings.map((o: any) => (
<button
key={o.band}
type="button"
onClick={() => { setActiveTab('bandmap'); }}
title={t('bo.liveTip', {
band: String(o.band).toUpperCase(), n: o.calls, km: o.median_km,
sector: o.sector ?? '', season: o.in_season ? '' : ' — ' + t('bmp.openUnusual'),
})}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0
border-warning-border bg-warning-muted text-warning-muted-foreground hover:bg-warning-muted/70 animate-pulse"
>
<Radio className="size-3" />
{String(o.band).toUpperCase()} {t('bo.open')}
</button>
))}
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground shrink-0" title="UTC">
<Clock className="size-3" />
{utcNow}<span className="text-[9px]">Z</span>
+2 -2
View File
@@ -270,7 +270,7 @@ const en: Dict = {
'clu.muteWorkedHint': '(they stay in the list, just quiet — leaves the colour for what is left to do)',
'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.workedSameSlot': 'Already worked only on the same slot',
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.workedSameSlot': 'Already worked only on the same slot',
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
// Backup panel
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
@@ -690,7 +690,7 @@ const fr: Dict = {
'clu.muteWorkedHint': '(elles restent dans la liste, simplement discrètes — la couleur reste pour ce qui est à faire)',
'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode',
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.',
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",