From b2382a6135b35c646cdb5b2f181d8639159cb495 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Thu, 13 Aug 2026 10:38:08 +0200 Subject: [PATCH] feat(cluster): configurable spot lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spots were bounded by COUNT alone, so on a quiet band a two-hour-old spot sat on the band map looking like something to chase. Settings → DX Cluster now takes a lifetime: presets at 5/10/15/30/60 minutes, or any value up to twelve hours, and 0 keeps the old behaviour. Spots are REMOVED from the shared list rather than hidden at render. The cluster list, every band map and the counts all read that one array, so pruning it once is what makes the setting mean the same thing in every view — the lesson already paid for when the band map ignored the cluster's filters. It also gives the memory back. A spot whose timestamp will not parse is never dropped: an unreadable stamp is a reason to distrust the clock, not to throw away the spot. Swept every 30 seconds — close enough that a 5-minute setting is honoured, and invisible next to the spot stream. Clamped in the backend as well as the UI. --- app.go | 33 ++++++++++++++++++++++ changelog.json | 6 ++-- frontend/src/App.tsx | 27 +++++++++++++++++- frontend/src/components/SettingsModal.tsx | 34 ++++++++++++++++++++++- frontend/src/lib/i18n.tsx | 2 +- frontend/wailsjs/go/main/App.d.ts | 4 +++ frontend/wailsjs/go/main/App.js | 8 ++++++ 7 files changed, 109 insertions(+), 5 deletions(-) diff --git a/app.go b/app.go index 64e3b39..9d2789a 100644 --- a/app.go +++ b/app.go @@ -298,6 +298,7 @@ const ( keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start keyClusterSelfSpot = "cluster.self_spot" // "1" → announce ourselves on the cluster as we log keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots + keyClusterSpotTTL = "cluster.spot_ttl_min" // drop spots older than this; 0 = keep them keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on @@ -17728,3 +17729,35 @@ func (a *App) SetKenwoodKeySpeed(wpm int) error { } return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SetKeySpeed(wpm) }) } + +// spotTTLMax bounds the setting. Twelve hours is far past any operational use +// and exists only so a mistyped value cannot mean "never". +const spotTTLMax = 720 + +// GetSpotTTLMinutes returns how long a spot stays in the list, in minutes. +// 0 means spots are kept until the count cap pushes them out, which is what +// OpsLog always did. +func (a *App) GetSpotTTLMinutes() int { + n, _ := strconv.Atoi(a.settingOr(keyClusterSpotTTL, "")) + if n < 0 { + return 0 + } + if n > spotTTLMax { + return spotTTLMax + } + return n +} + +// SetSpotTTLMinutes sets it. Clamped here as well as in the UI: the value +// shapes what an operator sees on the band map, and a stale or bypassed +// frontend must not be able to widen it past the ceiling. +func (a *App) SetSpotTTLMinutes(min int) error { + if min < 0 { + min = 0 + } + if min > spotTTLMax { + min = spotTTLMax + } + a.setSetting(keyClusterSpotTTL, strconv.Itoa(min)) + return nil +} diff --git a/changelog.json b/changelog.json index e4ae2d5..8a3a33d 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,12 @@ "version": "0.25.0", "date": "", "en": [ - "Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked." + "Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked.", + "DX Cluster: a spot lifetime can be set — 5, 10, 15 minutes or your own value — after which spots leave the list and the band maps." ], "fr": [ - "Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite." + "Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.", + "Cluster DX : on peut fixer une durée de vie des spots — 5, 10, 15 minutes ou une valeur libre — au-delà de laquelle ils quittent la liste et les band maps." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e228e2a..a857d06 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -91,7 +91,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; -import { GetRowColors } from '../wailsjs/go/main/App'; +import { GetRowColors, GetSpotTTLMinutes } from '../wailsjs/go/main/App'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel'; @@ -1928,6 +1928,31 @@ export default function App() { // settings dialog closes, which is the only place it changes. const [rowColors, setRowColors] = useState(null); useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]); + // Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather + // than filtered at render: the cluster list, every band map and the counts all + // read the same array, so pruning it once is what makes the setting mean the + // same thing everywhere — and it gives the memory back. + const [spotTTLMin, setSpotTTLMin] = useState(0); + useEffect(() => { GetSpotTTLMinutes().then(setSpotTTLMin).catch(() => {}); }, [showSettings]); + useEffect(() => { + if (spotTTLMin <= 0) return; // 0 = keep until the count cap pushes them out + const sweep = () => { + const cutoff = Date.now() - spotTTLMin * 60_000; + setSpots((arr) => { + const kept = arr.filter((sp) => { + const t = Date.parse((sp as any).received_at ?? ''); + return isNaN(t) || t >= cutoff; // an unparseable stamp is never a reason to drop a spot + }); + return kept.length === arr.length ? arr : kept; + }); + }; + sweep(); + // Half a minute: fine enough that a 5-minute setting is honoured closely, + // coarse enough to be invisible next to the spot stream itself. + const id = window.setInterval(sweep, 30_000); + return () => window.clearInterval(id); + }, [spotTTLMin]); + const qsosWithAwards = useMemo( () => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })), [qsos], diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index ff2c994..b5e338f 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -52,7 +52,7 @@ import { GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, - GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, + GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -1556,6 +1556,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // feed up or down — so the write has to go where those live. const [bandOpen, setBandOpen] = useState({ enabled: false, bands: [], available: [] }); const [chaseGrids, setChaseGrids] = useState(false); + const [spotTTL, setSpotTTL] = useState(0); + const [spotTTLText, setSpotTTLText] = useState('0'); const [gridStat, setGridStat] = useState(null); const [pskrStatus, setPskrStatus] = useState(null); const saveBandOpen = async (next: any) => { @@ -1566,6 +1568,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan (async () => { try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ } try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ } + try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } })(); // Poll the feed while the panel is open: a live count is the only thing that // distinguishes "connected" from "connected and receiving nothing". @@ -4237,6 +4240,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {/* Grid chasing. Here rather than in the filter panel because it is set up once: it decides whether locators learnt from decodes are KEPT across restarts, not what the list shows right now. */} + {/* Spot lifetime. Applies to the cluster list AND every band map: the + spots are removed from the shared list rather than hidden, so the + setting cannot mean one thing in one view and another elsewhere. */} +
+
+ {t('clu.spotTtl')} +
+ {[0, 5, 10, 15, 30, 60].map((v) => ( + + ))} +
+ {/* Raw text in local state: binding the input straight to the + clamped number makes an empty field impossible to type into. */} + { + const raw = e.target.value.replace(/[^0-9]/g, ''); + setSpotTTLText(raw); + const n = Math.min(720, parseInt(raw, 10) || 0); + setSpotTTL(n); + SetSpotTTLMinutes(n).catch(() => {}); + }} /> + {t('clu.spotTtlHint')} +
+
+