feat(cluster): configurable spot lifetime

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.
This commit is contained in:
2026-08-13 10:38:08 +02:00
parent f8fb57210f
commit b2382a6135
7 changed files with 109 additions and 5 deletions
+33 -1
View File
@@ -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<any>({ enabled: false, bands: [], available: [] });
const [chaseGrids, setChaseGrids] = useState(false);
const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0');
const [gridStat, setGridStat] = useState<any>(null);
const [pskrStatus, setPskrStatus] = useState<any>(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. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm">{t('clu.spotTtl')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
{[0, 5, 10, 15, 30, 60].map((v) => (
<button key={v} type="button"
onClick={() => { setSpotTTL(v); setSpotTTLText(String(v)); SetSpotTTLMinutes(v).catch(() => {}); }}
className={cn('px-2.5 py-1.5 font-medium', spotTTL === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{v === 0 ? t('clu.spotTtlNever') : `${v}`}
</button>
))}
</div>
{/* Raw text in local state: binding the input straight to the
clamped number makes an empty field impossible to type into. */}
<Input className="h-8 w-20" value={spotTTLText}
onChange={(e) => {
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(() => {});
}} />
<span className="text-xs text-muted-foreground">{t('clu.spotTtlHint')}</span>
</div>
</div>
<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={chaseGrids} className="mt-0.5"