fix(cluster): NEW CALL is not NEW SLOT

The slot-highlight option reused the new-slot status, which already meant
something else and something narrower: the ENTITY was worked on this band and
on this mode, but never on the two together. What the option surfaces is a fact
about the CALLSIGN - never worked on this band and mode - and the entity may be
long since confirmed there.

Overloading it cost exactly what it always costs. Forty consecutive rows read
NEW SLOT, the genuine new-slot rows drowned in them, and the Status column
stopped carrying information at all.

new-call is its own status now, with its own badge, its own tooltip spelling out
that it is the callsign and not the entity, and its own filter chip. It keeps
the caution colour for the moment: it sits at the same "worth a look, not a new
one" level as new-slot, and the colour rework will separate them.

The status filter had to move onto the displayed status too. It read the raw
backend entry, where new-call by construction never appears, so the chip would
have selected nothing at all.
This commit is contained in:
2026-08-10 12:21:01 +02:00
parent ca42fd90f9
commit 6825af135a
5 changed files with 24 additions and 9 deletions
+11 -2
View File
@@ -89,6 +89,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
@@ -1484,7 +1485,9 @@ export default function App() {
// county or a new park is not a DXCC state, it is another dimension of the
// same spot — which is why the grid shows them as separate badges. Filtering
// is an OR across all of them, as it already was for a worked callsign.
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
// 'new-call' is a DISPLAY status, produced by the slot-highlight option rather
// than by the backend, so it is named here and not in SpotStatusKey.
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx' | 'new-call';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
// Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
@@ -4681,7 +4684,10 @@ export default function App() {
}
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
// Filter on what is DISPLAYED, not on the raw backend status: NEW CALL is
// produced by the slot-highlight option, so a filter reading the raw
// entry would offer a chip that never matches a single row.
const e = applySpotDisplay(spotStatus[k], readSpotDisplayOptions());
const st = (e?.status || '') as SpotStatusKey;
// WORKED means "I've worked THIS callsign" — the blue WKD-CALL flag —
// NOT the entity status 'worked' (entity/band/mode already worked, which
@@ -4810,6 +4816,9 @@ export default function App() {
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// NEW CALL is about the CALLSIGN, not the entity: never worked on this
// band and mode. Only appears when the slot-highlight option is on.
{ k: 'new-call' as SpotFilterKey, label: 'NEW CALL', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// Same colours as the badges in the grid — a filter that does not
// look like what it selects has to be learned twice.
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
+3
View File
@@ -158,6 +158,7 @@ function statusLabel(s: string, t: (k: string) => string, muted = false): string
case 'new-band': return t('bmp.statusNewBand');
case 'new-mode': return t('bmp.statusNewMode');
case 'new-slot': return t('bmp.statusNewSlot');
case 'new-call': return t('bmp.statusNewCall');
case 'worked': return t('bmp.statusWorked');
// An empty status means the entity could not be resolved — EXCEPT when the
// "no colour on worked stations" option emptied it on purpose. Saying
@@ -201,6 +202,7 @@ function statusStyle(s: string, muted = false): { pill: string; bar: string; lin
line: 'stroke-warning',
dot: 'fill-warning',
};
case 'new-call':
case 'new-slot': return {
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
bar: 'bg-caution',
@@ -320,6 +322,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
case 'new': return 0;
case 'new-band': return 1;
case 'new-slot': return 2;
case 'new-call': return 2;
case 'worked': return 4;
default: return 3;
}
+4 -1
View File
@@ -158,7 +158,7 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
function isDull(s: SpotStatusEntry | undefined): boolean {
if (!s || !s.status) return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
}
@@ -212,6 +212,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
else if (s?.status === 'new-call') parts.push(t('clg2.newCall'));
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
@@ -227,6 +228,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: s?.status === 'new-band' ? t('clg2.newBand')
: s?.status === 'new-mode' ? t('clg2.newMode')
: s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
}
@@ -251,6 +253,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
if (s?.status === 'new-band') return t('clg2.tipNewBand');
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
if (s?.status === 'new-call') return t('clg2.tipNewCall');
if (s?.worked_call) return t('clg2.tipWorkedCall');
return undefined;
},
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -48,7 +48,7 @@ type Entry = {
// in front of you.
function bringsNothingNew(s: Entry): boolean {
if (!s || !s.status) return false; // unresolved — never hide, it would flicker
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.new_pota || s.new_county || s.new_pfx);
}
@@ -68,7 +68,7 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
// entity is precisely the spot the second option exists to surface. Promoting
// first protects it for free: bringsNothingNew() returns false on new-slot.
if (o.slotHighlight && e.worked_slot === false && (!e.status || e.status === 'worked')) {
e = { ...e, status: 'new-slot' } as NonNullable<T>;
e = { ...e, status: 'new-call' } as NonNullable<T>;
}
if (o.muteWorked && bringsNothingNew(e)) {