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')} +
+
+