feat(cluster): Chase New — a panel for what is new and audible here
PSK Reporter tells you what is actually being decoded in your region, which is a larger set than what somebody chose to spot: nobody spots the FT8 caller running ten watts from a rare square. Almost all of it existed. The MQTT payload already carries frequency, mode, transmitter and both grids; the watcher already drops any report collected further than NearKm from the operator, which is exactly the question worth asking — the station is being heard HERE, not in Japan; and with grid chasing on the subscription is already every band, filtered at the broker by receiver square, measured at 0.2 to 1.2 messages a second. This reads messages that were arriving and being discarded. "New" is not decided here. Every spot goes through ClusterSpotStatuses, the same function the DX cluster grid uses and the same cached index, so the two panels cannot drift apart the way the county columns did. Cost per message is map lookups behind an option cached in an atomic, because the MQTT goroutine must never wait on the settings store. The option is its own, not nested under grid chasing: chasing squares and chasing entities are different wants, and the feed now has three consumers, any one of which brings it up and none of which cuts the others loose when it goes down. The panel says "digital modes only" in its footer. An empty list has to mean "nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never show a new entity on CW.
This commit is contained in:
@@ -650,6 +650,12 @@ type App struct {
|
|||||||
// bounds itself by age instead, so memory follows how many distinct stations
|
// bounds itself by age instead, so memory follows how many distinct stations
|
||||||
// have actually been heard in two years rather than a made-up ceiling.
|
// have actually been heard in two years rather than a made-up ceiling.
|
||||||
gridStore *gridcache.Store
|
gridStore *gridcache.Store
|
||||||
|
|
||||||
|
// chaseNew holds the "new against the log" stations PSK Reporter is hearing
|
||||||
|
// near here; chaseNewOn is the option cached for the MQTT goroutine, which
|
||||||
|
// consults it once per message and must not reach the settings store.
|
||||||
|
chaseNew *chaseNewStore
|
||||||
|
chaseNewOn atomic.Bool
|
||||||
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
||||||
// It is the source that makes VHF detection work at all: the cluster and RBN
|
// It is the source that makes VHF detection work at all: the cluster and RBN
|
||||||
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
||||||
@@ -1415,8 +1421,11 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
|
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
|
||||||
go a.hrdlogOnAirLoop() // publish frequency/mode/rig on hrdlog.net when enabled
|
go a.hrdlogOnAirLoop() // publish frequency/mode/rig on hrdlog.net when enabled
|
||||||
// Locator store BEFORE the feed: the feed asks whether it exists to decide
|
// Locator store BEFORE the feed: the feed asks whether it exists to decide
|
||||||
// which bands to subscribe to.
|
// which bands to subscribe to. Same for the chase-new option, read into its
|
||||||
|
// atomic here so the feed and the MQTT goroutine agree from the first message.
|
||||||
a.startGridCache()
|
a.startGridCache()
|
||||||
|
a.chaseNew = newChaseNewStore()
|
||||||
|
a.refreshChaseNew()
|
||||||
// PSK Reporter. After the operator's grid is known: without it there is no
|
// PSK Reporter. After the operator's grid is known: without it there is no
|
||||||
// distance to measure and no receiver squares to filter on, so it stays down.
|
// distance to measure and no receiver squares to filter on, so it stays down.
|
||||||
a.startBandOpenFeed()
|
a.startBandOpenFeed()
|
||||||
|
|||||||
+16
-7
@@ -138,7 +138,10 @@ func (a *App) startBandOpenFeed() {
|
|||||||
a.clearBandOpenings()
|
a.clearBandOpenings()
|
||||||
}
|
}
|
||||||
chaseGrids := a.gridStore != nil
|
chaseGrids := a.gridStore != nil
|
||||||
if !s.Enabled && !chaseGrids {
|
chaseNew := a.chaseNewEnabled()
|
||||||
|
// Three consumers, one feed. Any one of them is reason enough to bring it up,
|
||||||
|
// and turning one off must not cut the others loose.
|
||||||
|
if !s.Enabled && !chaseGrids && !chaseNew {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Every spot is measured from the operator's position. Without one there is
|
// Every spot is measured from the operator's position. Without one there is
|
||||||
@@ -149,10 +152,11 @@ func (a *App) startBandOpenFeed() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid chasing wants every band; the opening watch wants its four. "+" is the
|
// Grid chasing and new-chasing want every band; the opening watch wants its
|
||||||
// MQTT single-level wildcard, so one subscription per square covers the lot.
|
// four. "+" is the MQTT single-level wildcard, so one subscription per square
|
||||||
|
// covers the lot.
|
||||||
bands := s.Bands
|
bands := s.Bands
|
||||||
if chaseGrids {
|
if chaseGrids || chaseNew {
|
||||||
bands = []string{"+"}
|
bands = []string{"+"}
|
||||||
}
|
}
|
||||||
// Filter at the BROKER on the receiver's square rather than receiving the
|
// Filter at the BROKER on the receiver's square rather than receiving the
|
||||||
@@ -168,8 +172,13 @@ func (a *App) startBandOpenFeed() {
|
|||||||
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
||||||
}
|
}
|
||||||
var onSpot func(pskr.Spot)
|
var onSpot func(pskr.Spot)
|
||||||
if s.Enabled {
|
switch {
|
||||||
|
case s.Enabled && chaseNew:
|
||||||
|
onSpot = func(sp pskr.Spot) { a.feedBandOpen(sp); a.feedChaseNew(sp) }
|
||||||
|
case s.Enabled:
|
||||||
onSpot = a.feedBandOpen
|
onSpot = a.feedBandOpen
|
||||||
|
case chaseNew:
|
||||||
|
onSpot = a.feedChaseNew
|
||||||
}
|
}
|
||||||
|
|
||||||
a.pskr = pskr.New(pskr.Config{
|
a.pskr = pskr.New(pskr.Config{
|
||||||
@@ -195,8 +204,8 @@ func (a *App) startBandOpenFeed() {
|
|||||||
applog.Printf("pskr: feed did not start: %v", err)
|
applog.Printf("pskr: feed did not start: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v)",
|
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v, chase-new=%v)",
|
||||||
bands, len(rxGrids), s.Enabled, chaseGrids)
|
bands, len(rxGrids), s.Enabled, chaseGrids, chaseNew)
|
||||||
}
|
}
|
||||||
|
|
||||||
// feedBandOpen hands one PSK Reporter decode to the detector.
|
// feedBandOpen hands one PSK Reporter decode to the detector.
|
||||||
|
|||||||
+4
-2
@@ -4,11 +4,13 @@
|
|||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
"Send Spot: the comment now carries the award references after the mode — the ones you assigned (POTA, SOTA, IOTA…), not the DXCC, zone and prefix every reader works out from the callsign. A self-spot carries your OWN activation references instead.",
|
"Send Spot: the comment now carries the award references after the mode — the ones you assigned (POTA, SOTA, IOTA…), not the DXCC, zone and prefix every reader works out from the callsign. A self-spot carries your OWN activation references instead.",
|
||||||
"Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default."
|
"Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default.",
|
||||||
|
"Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log — entity, band, mode, slot, prefix or square. Click one to put it in the entry and tune the rig. Digital modes only, and it shares the feed the band-opening watch and the locator store already use."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.",
|
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.",
|
||||||
"Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut."
|
"Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut.",
|
||||||
|
"Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Un clic la met en saisie et accorde la radio. Modes numériques uniquement, et le flux est partagé avec la veille d’ouvertures et la base des locators."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+19
-2
@@ -51,7 +51,7 @@ import {
|
|||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
GetAmpStatuses, AmpOperate,
|
GetAmpStatuses, AmpOperate,
|
||||||
GetFlexState, FlexAmpOperate,
|
GetFlexState, FlexAmpOperate,
|
||||||
GetPSKReporterStatus, GetLiveOpenings,
|
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
|
||||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
@@ -82,6 +82,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
|||||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import { AmpWidget } from '@/components/AmpWidget';
|
import { AmpWidget } from '@/components/AmpWidget';
|
||||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||||
|
import { ChaseNewPanel } from '@/components/ChaseNewPanel';
|
||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
import { StatsPanel } from '@/components/StatsPanel';
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
@@ -2086,6 +2087,12 @@ export default function App() {
|
|||||||
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');
|
||||||
|
// The Chase New widget follows its own setting rather than a local toggle: the
|
||||||
|
// panel is only meaningful while the PSK Reporter feed is up, and that is what
|
||||||
|
// the setting decides.
|
||||||
|
const [chaseNewOn, setChaseNewOn] = useState(false);
|
||||||
|
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
|
||||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
|
|
||||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||||
@@ -6217,6 +6224,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{chaseNewOn && (
|
||||||
|
<div className="w-[360px] shrink-0 min-h-0">
|
||||||
|
{/* Same reflex as clicking a cluster spot: the callsign into the
|
||||||
|
entry, and the rig onto the frequency it was decoded on. */}
|
||||||
|
<ChaseNewPanel onPick={(sp) => {
|
||||||
|
onCallsignInput(sp.call, { force: true });
|
||||||
|
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode);
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dvkEnabled && (
|
{dvkEnabled && (
|
||||||
<div className="w-[320px] shrink-0 min-h-0">
|
<div className="w-[320px] shrink-0 min-h-0">
|
||||||
<DvkPanel
|
<DvkPanel
|
||||||
@@ -7264,7 +7281,7 @@ export default function App() {
|
|||||||
{showSettings && (
|
{showSettings && (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
initialSection={settingsSection}
|
initialSection={settingsSection}
|
||||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
onClose={() => { setShowSettings(false); setSettingsSection(undefined); refreshChaseNew(); }}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||||
// Drop the cached spot statuses. They are computed once per
|
// Drop the cached spot statuses. They are computed once per
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// ChaseNewPanel — the stations PSK Reporter is hearing NEAR HERE that are new
|
||||||
|
// against the log.
|
||||||
|
//
|
||||||
|
// A DX cluster tells you what somebody chose to spot. This tells you what is
|
||||||
|
// actually being decoded in your own region, which is a different and often
|
||||||
|
// larger set: nobody spots the FT8 caller running 10 watts from a rare square.
|
||||||
|
//
|
||||||
|
// The one thing an operator has to know, and the reason for the line at the
|
||||||
|
// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
|
||||||
|
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Radar, Loader2 } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
|
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export interface ChaseNewSpot {
|
||||||
|
call: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
freq_hz?: number;
|
||||||
|
grid?: string;
|
||||||
|
country?: string;
|
||||||
|
cont?: string;
|
||||||
|
dist_km?: number;
|
||||||
|
bearing?: number;
|
||||||
|
status?: string;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
lotw?: boolean;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Tuning the rig to a row is the whole point — a station heard on 14.074 is
|
||||||
|
// only useful if you can get there in one click.
|
||||||
|
onPick?: (s: ChaseNewSpot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusLabel maps the backend's vocabulary — the cluster's own — to a short
|
||||||
|
// badge. Kept to the same words the DX cluster list uses: an operator should not
|
||||||
|
// have to learn two names for one idea.
|
||||||
|
function statusKey(s: ChaseNewSpot): string | null {
|
||||||
|
switch (s.status) {
|
||||||
|
case 'new': return 'clg2.newDxcc';
|
||||||
|
case 'new-band': return 'clg2.newBand';
|
||||||
|
case 'new-mode': return 'clg2.newMode';
|
||||||
|
case 'new-slot': return 'clg2.newSlot';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChaseNewPanel({ onPick }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// reads that fast.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
||||||
|
if (alive) { setSpots(r); setLoaded(true); }
|
||||||
|
} catch { /* the feed may not be up yet */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
|
<Radar className="size-3.5 text-primary" />
|
||||||
|
<span className="text-xs font-semibold">{t('chn.title')}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||||
|
{spots.length > 0 ? t('chn.count', { n: spots.length }) : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!loaded ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
|
||||||
|
</div>
|
||||||
|
) : spots.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">{t('chn.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{spots.map((s, i) => {
|
||||||
|
const sk = statusKey(s);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick?.(s)}
|
||||||
|
className="flex w-full items-center gap-2 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
||||||
|
title={[s.country, s.grid, s.dist_km ? `${s.dist_km} km` : ''].filter(Boolean).join(' · ')}
|
||||||
|
>
|
||||||
|
<span className="font-mono font-bold w-24 truncate">{s.call}</span>
|
||||||
|
<span className="font-mono text-muted-foreground w-12">{s.band}</span>
|
||||||
|
<span className="text-muted-foreground w-10 truncate">{s.mode}</span>
|
||||||
|
<span className="font-mono text-muted-foreground w-16 text-right">
|
||||||
|
{s.freq_hz ? (s.freq_hz / 1000).toFixed(1) : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{sk && <span className="font-bold" style={{ color: 'var(--danger)' }}>{t(sk)}</span>}
|
||||||
|
{s.new_pfx && <span style={{ color: markerColour('new_pfx') }}>{t('clg2.newPfx')}</span>}
|
||||||
|
{s.new_grid && <span style={{ color: markerColour('new_grid') }}>{t('clg2.newGrid')}</span>}
|
||||||
|
{s.lotw && <span className="text-[9px] text-info" title="LoTW">L</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||||
} 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';
|
||||||
@@ -1562,6 +1562,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// feed up or down — so the write has to go where those live.
|
// feed up or down — so the write has to go where those live.
|
||||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||||
const [chaseGrids, setChaseGrids] = useState(false);
|
const [chaseGrids, setChaseGrids] = useState(false);
|
||||||
|
const [chaseNew, setChaseNew] = useState(false);
|
||||||
const [spotTTL, setSpotTTL] = useState(0);
|
const [spotTTL, setSpotTTL] = useState(0);
|
||||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||||
const [gridStat, setGridStat] = useState<any>(null);
|
const [gridStat, setGridStat] = useState<any>(null);
|
||||||
@@ -1574,6 +1575,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
(async () => {
|
(async () => {
|
||||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||||
|
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||||||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||||
})();
|
})();
|
||||||
@@ -4339,6 +4341,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||||
|
squares and chasing entities are different wants; they only share
|
||||||
|
the PSK Reporter feed, which either one brings up. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={chaseNew} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||||||
|
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||||
switching it on adds two of them — the operator should see that
|
switching it on adds two of them — the operator should see that
|
||||||
happen where it happens rather than find nodes they did not add. */}
|
happen where it happens rather than find nodes they did not add. */}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -406,8 +406,12 @@ export function GetCatalogCodes():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
|
export function GetChaseNew():Promise<boolean>;
|
||||||
|
|
||||||
export function GetChaseNewGrids():Promise<boolean>;
|
export function GetChaseNewGrids():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -992,6 +996,8 @@ export function SetCIVTrace(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetChaseNew(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -754,10 +754,18 @@ export function GetChangelog() {
|
|||||||
return window['go']['main']['App']['GetChangelog']();
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseNew() {
|
||||||
|
return window['go']['main']['App']['GetChaseNew']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChaseNewGrids() {
|
export function GetChaseNewGrids() {
|
||||||
return window['go']['main']['App']['GetChaseNewGrids']();
|
return window['go']['main']['App']['GetChaseNewGrids']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseNewSpots() {
|
||||||
|
return window['go']['main']['App']['GetChaseNewSpots']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1926,6 +1934,10 @@ export function SetCWDecoderPitch(arg1) {
|
|||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetChaseNew(arg1) {
|
||||||
|
return window['go']['main']['App']['SetChaseNew'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetChaseNewGrids(arg1) {
|
export function SetChaseNewGrids(arg1) {
|
||||||
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2131,6 +2131,44 @@ export namespace main {
|
|||||||
this.fr = source["fr"];
|
this.fr = source["fr"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChaseNewSpot {
|
||||||
|
call: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
freq_hz: number;
|
||||||
|
grid: string;
|
||||||
|
country?: string;
|
||||||
|
cont?: string;
|
||||||
|
dist_km: number;
|
||||||
|
bearing: number;
|
||||||
|
status?: string;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
lotw?: boolean;
|
||||||
|
at: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChaseNewSpot(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.call = source["call"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.country = source["country"];
|
||||||
|
this.cont = source["cont"];
|
||||||
|
this.dist_km = source["dist_km"];
|
||||||
|
this.bearing = source["bearing"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.new_pfx = source["new_pfx"];
|
||||||
|
this.new_grid = source["new_grid"];
|
||||||
|
this.lotw = source["lotw"];
|
||||||
|
this.at = source["at"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type Spot struct {
|
|||||||
Band string
|
Band string
|
||||||
Mode string
|
Mode string
|
||||||
Grid string // transmitter's grid, 4 characters
|
Grid string // transmitter's grid, 4 characters
|
||||||
|
// FreqHz is where the decode happened. The band alone is enough for an
|
||||||
|
// opening, but a station worth chasing has to be tuned to.
|
||||||
|
FreqHz int64
|
||||||
DistKm int // from the operator
|
DistKm int // from the operator
|
||||||
Bearing int // degrees from the operator, short path
|
Bearing int // degrees from the operator, short path
|
||||||
At time.Time
|
At time.Time
|
||||||
|
|||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Chase New — a widget listing the stations PSK Reporter is hearing NEAR HERE
|
||||||
|
// that are new against the log.
|
||||||
|
//
|
||||||
|
// The feed is the one the band-opening watch and the grid store already use, so
|
||||||
|
// this costs no extra subscription when either is on: it reads messages that
|
||||||
|
// were arriving and being discarded. Measured on the live broker, one ring of
|
||||||
|
// neighbour squares is 0.2 to 1.2 messages a second.
|
||||||
|
//
|
||||||
|
// Two things about the data decide the shape of everything below:
|
||||||
|
//
|
||||||
|
// - PSK Reporter is DIGITAL ONLY. This can never show a new entity on CW or
|
||||||
|
// SSB, and the panel says so rather than letting an operator conclude the
|
||||||
|
// band is dead when it is full of CW.
|
||||||
|
// - A report says "X was heard BY Y". The watcher already drops anything
|
||||||
|
// collected further than NearKm from the operator (internal/pskr), so what
|
||||||
|
// arrives here is a station being heard in this region — not a world map.
|
||||||
|
//
|
||||||
|
// "New" is NOT decided here. It goes through ClusterSpotStatuses, the same
|
||||||
|
// function the DX cluster grid uses, because two definitions of new is how the
|
||||||
|
// two panels quietly start disagreeing about the same callsign.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/pskr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyChaseNew turns the widget on. Deliberately NOT nested under "chase grids":
|
||||||
|
// chasing squares and chasing entities are different wants, and an operator may
|
||||||
|
// have one without the other. They only share the feed, which either one starts.
|
||||||
|
const keyChaseNew = "cluster.chase_new"
|
||||||
|
|
||||||
|
// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
|
||||||
|
// information, and the oldest rows are the least likely to still be on the air.
|
||||||
|
const chaseNewMax = 200
|
||||||
|
|
||||||
|
// chaseSeenTTL is how long the same station stays de-duplicated on one band and
|
||||||
|
// mode. PSK Reporter re-reports a calling station every cycle — without this the
|
||||||
|
// panel would be one operator repeated fifty times.
|
||||||
|
const chaseSeenTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// ChaseNewSpot is one station worth looking at, as the widget shows it.
|
||||||
|
type ChaseNewSpot struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
FreqHz int64 `json:"freq_hz"`
|
||||||
|
Grid string `json:"grid"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Cont string `json:"cont,omitempty"`
|
||||||
|
DistKm int `json:"dist_km"`
|
||||||
|
Bearing int `json:"bearing"`
|
||||||
|
// Status is the entity-level verdict from the cluster's own vocabulary:
|
||||||
|
// new | new-band | new-mode | new-slot. Empty when the row is here for a
|
||||||
|
// prefix or a square instead.
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
NewPfx bool `json:"new_pfx,omitempty"`
|
||||||
|
NewGrid bool `json:"new_grid,omitempty"`
|
||||||
|
LoTW bool `json:"lotw,omitempty"`
|
||||||
|
At string `json:"at"` // RFC3339, stamped on receipt
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseNewStore holds what the widget shows. Written from the MQTT goroutine,
|
||||||
|
// read by the UI poll, so everything is behind one mutex — the work per message
|
||||||
|
// is a handful of map lookups and this must never become the reason the broker's
|
||||||
|
// buffer backs up.
|
||||||
|
type chaseNewStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
spots []ChaseNewSpot // newest last
|
||||||
|
seen map[string]time.Time // "CALL|BAND|MODE" → when it was last shown
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChaseNewStore() *chaseNewStore {
|
||||||
|
return &chaseNewStore{seen: make(map[string]time.Time, 512)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// put adds a spot unless the same station on the same band and mode is already
|
||||||
|
// on the list. Returns false when it was a duplicate.
|
||||||
|
func (s *chaseNewStore) put(sp ChaseNewSpot, now time.Time) bool {
|
||||||
|
key := sp.Call + "|" + sp.Band + "|" + sp.Mode
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if last, ok := s.seen[key]; ok && now.Sub(last) < chaseSeenTTL {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.seen[key] = now
|
||||||
|
s.spots = append(s.spots, sp)
|
||||||
|
if len(s.spots) > chaseNewMax {
|
||||||
|
s.spots = s.spots[len(s.spots)-chaseNewMax:]
|
||||||
|
}
|
||||||
|
// The de-duplication map is the only thing here that grows without a natural
|
||||||
|
// bound, so it is swept when it gets large rather than on every message.
|
||||||
|
if len(s.seen) > 4*chaseNewMax {
|
||||||
|
for k, t := range s.seen {
|
||||||
|
if now.Sub(t) >= chaseSeenTTL {
|
||||||
|
delete(s.seen, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// list returns the spots newer than ttl, newest first.
|
||||||
|
func (s *chaseNewStore) list(ttl time.Duration, now time.Time) []ChaseNewSpot {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]ChaseNewSpot, 0, len(s.spots))
|
||||||
|
for _, sp := range s.spots {
|
||||||
|
at, err := time.Parse(time.RFC3339, sp.At)
|
||||||
|
if err == nil && ttl > 0 && now.Sub(at) > ttl {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].At > out[j].At })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *chaseNewStore) clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.spots = nil
|
||||||
|
s.seen = make(map[string]time.Time, 512)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseNewEnabled reads the option. Called per message, so it reads the cached
|
||||||
|
// atomic rather than the settings store.
|
||||||
|
func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }
|
||||||
|
|
||||||
|
// refreshChaseNew re-reads the option into the atomic the feed consults.
|
||||||
|
func (a *App) refreshChaseNew() {
|
||||||
|
on := a.settingOr(keyChaseNew, "") == "1"
|
||||||
|
a.chaseNewOn.Store(on)
|
||||||
|
if !on && a.chaseNew != nil {
|
||||||
|
// Drop the list rather than leave it on screen: it would go stale with no
|
||||||
|
// feed behind it, and a frozen list of "new" stations is worse than none.
|
||||||
|
a.chaseNew.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedChaseNew turns one PSK Reporter decode into a widget row, or drops it.
|
||||||
|
//
|
||||||
|
// Runs on the MQTT goroutine. The cheap tests come first — the option, then the
|
||||||
|
// de-duplication — so a station already listed costs one map lookup and nothing
|
||||||
|
// else.
|
||||||
|
func (a *App) feedChaseNew(sp pskr.Spot) {
|
||||||
|
if !a.chaseNewEnabled() || a.chaseNew == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(sp.Call))
|
||||||
|
if call == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
band := strings.ToLower(strings.TrimSpace(sp.Band))
|
||||||
|
mode := strings.ToUpper(strings.TrimSpace(sp.Mode))
|
||||||
|
|
||||||
|
// The same verdict the cluster grid computes, from the same cached index:
|
||||||
|
// map lookups per spot, no query.
|
||||||
|
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
|
||||||
|
if len(st) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := st[0]
|
||||||
|
isNew := s.Status == "new" || s.Status == "new-band" ||
|
||||||
|
s.Status == "new-mode" || s.Status == "new-slot" || s.NewPfx || s.NewGrid
|
||||||
|
if !isNew {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
row := ChaseNewSpot{
|
||||||
|
Call: call, Band: band, Mode: mode, FreqHz: sp.FreqHz,
|
||||||
|
Grid: sp.Grid, Country: s.Country, Cont: s.Continent,
|
||||||
|
DistKm: sp.DistKm, Bearing: sp.Bearing,
|
||||||
|
Status: s.Status, NewPfx: s.NewPfx, NewGrid: s.NewGrid, LoTW: s.LoTW,
|
||||||
|
At: now.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
// The grid the watcher gives is the transmitter's own, straight off the air —
|
||||||
|
// better than anything we could look up, so it is kept even when the status
|
||||||
|
// index had one.
|
||||||
|
if row.Grid == "" {
|
||||||
|
row.Grid = s.Grid
|
||||||
|
}
|
||||||
|
a.chaseNew.put(row, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChaseNewSpots returns what the widget should show, newest first, aged out
|
||||||
|
// with the same spot lifetime the cluster and band maps use — one setting for
|
||||||
|
// "how long is a spot worth looking at", not three.
|
||||||
|
func (a *App) GetChaseNewSpots() []ChaseNewSpot {
|
||||||
|
if a.chaseNew == nil || !a.chaseNewEnabled() {
|
||||||
|
return []ChaseNewSpot{}
|
||||||
|
}
|
||||||
|
ttl := time.Duration(a.GetSpotTTLMinutes()) * time.Minute
|
||||||
|
return a.chaseNew.list(ttl, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChaseNew reports whether the widget is on.
|
||||||
|
func (a *App) GetChaseNew() bool { return a.chaseNewEnabled() }
|
||||||
|
|
||||||
|
// SetChaseNew turns the widget on or off and brings the feed up or down with it.
|
||||||
|
func (a *App) SetChaseNew(on bool) error {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyChaseNew, v)
|
||||||
|
a.refreshChaseNew()
|
||||||
|
// The feed is shared: startBandOpenSources decides whether it is still needed
|
||||||
|
// by anything else, so turning this off does not cut the grid store loose.
|
||||||
|
a.startBandOpenFeed()
|
||||||
|
applog.Printf("chase new: %v", on)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The panel must not become one station repeated. PSK Reporter re-reports a
|
||||||
|
// calling operator every cycle, and dozens of receivers report the same
|
||||||
|
// transmission, so a station arrives many times a minute.
|
||||||
|
func TestChaseNewStoreDeduplicates(t *testing.T) {
|
||||||
|
s := newChaseNewStore()
|
||||||
|
t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
row := ChaseNewSpot{Call: "VK9XX", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}
|
||||||
|
|
||||||
|
if !s.put(row, t0) {
|
||||||
|
t.Fatal("the first sighting was rejected")
|
||||||
|
}
|
||||||
|
if s.put(row, t0.Add(2*time.Minute)) {
|
||||||
|
t.Error("the same station on the same band and mode was listed twice")
|
||||||
|
}
|
||||||
|
// A different band is a different opportunity — a new-band slot is exactly
|
||||||
|
// what an operator is watching for.
|
||||||
|
other := row
|
||||||
|
other.Band = "15m"
|
||||||
|
if !s.put(other, t0.Add(2*time.Minute)) {
|
||||||
|
t.Error("the same station on another band was suppressed")
|
||||||
|
}
|
||||||
|
// Once the window has passed it is worth showing again: the station is still
|
||||||
|
// there, and the row it had has aged out of the list.
|
||||||
|
if !s.put(row, t0.Add(chaseSeenTTL+time.Minute)) {
|
||||||
|
t.Error("the station never came back after the de-duplication window")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list is aged with the operator's own spot lifetime, so a station heard an
|
||||||
|
// hour ago is not offered as something to chase now.
|
||||||
|
func TestChaseNewStoreAgesOut(t *testing.T) {
|
||||||
|
s := newChaseNewStore()
|
||||||
|
t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
s.put(ChaseNewSpot{Call: "OLD", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}, t0)
|
||||||
|
s.put(ChaseNewSpot{Call: "NEW", Band: "20m", Mode: "FT8",
|
||||||
|
At: t0.Add(20 * time.Minute).Format(time.RFC3339)}, t0.Add(20*time.Minute))
|
||||||
|
|
||||||
|
got := s.list(15*time.Minute, t0.Add(21*time.Minute))
|
||||||
|
if len(got) != 1 || got[0].Call != "NEW" {
|
||||||
|
t.Fatalf("got %+v, want only NEW", got)
|
||||||
|
}
|
||||||
|
// Newest first: an operator reads the top of this list and nothing else.
|
||||||
|
s.put(ChaseNewSpot{Call: "NEWEST", Band: "20m", Mode: "FT8",
|
||||||
|
At: t0.Add(25 * time.Minute).Format(time.RFC3339)}, t0.Add(25*time.Minute))
|
||||||
|
got = s.list(15*time.Minute, t0.Add(26*time.Minute))
|
||||||
|
if len(got) != 2 || got[0].Call != "NEWEST" {
|
||||||
|
t.Fatalf("got %+v, want NEWEST first", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list is bounded. A widget that grows without limit under a 6 m opening
|
||||||
|
// costs memory for rows nobody will ever scroll to.
|
||||||
|
func TestChaseNewStoreIsBounded(t *testing.T) {
|
||||||
|
s := newChaseNewStore()
|
||||||
|
t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
for i := 0; i < chaseNewMax+50; i++ {
|
||||||
|
at := t0.Add(time.Duration(i) * time.Second)
|
||||||
|
s.put(ChaseNewSpot{
|
||||||
|
Call: "S" + time.Duration(i).String(), Band: "20m", Mode: "FT8",
|
||||||
|
At: at.Format(time.RFC3339),
|
||||||
|
}, at)
|
||||||
|
}
|
||||||
|
if got := len(s.list(0, t0.Add(time.Hour))); got != chaseNewMax {
|
||||||
|
t.Errorf("kept %d rows, want the %d cap", got, chaseNewMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user