feat(cluster): LoTW badge and filter, spotter-continent filter

The L badge is a single letter, not a word: the call column is 120 px and holds
a callsign. It keeps the muted blue of a confirmation and never a status colour,
because whether a station uploads to LoTW says nothing about whether the spot is
worth chasing - those are different questions and must not share a palette.

The spotter's continent is not the DX's. It asks whether anyone near you is
hearing the band at all, which is why it earns its own control rather than
reusing the existing Continent column. The spotter callsign now travels with the
status query so the backend can resolve it against the one DXCC prefix table,
instead of a second continent rule appearing in the frontend.

Both AND with the status chips rather than joining their OR: "a new band, and
from Europe" is the question being asked. An unresolved spot is never dropped by
them - the status arrives a moment after the row, and filtering meanwhile made
the list flicker.

New counties only is the SAME state as the NEW COUNTY chip, reached a second
way, not a second filter. County chasing is a mode you switch into, and hunting
for one chip among eight is not how you switch into it.
This commit is contained in:
2026-08-10 18:51:56 +02:00
parent 9b2115be8f
commit d41352a3a5
4 changed files with 89 additions and 14 deletions
+61 -9
View File
@@ -1491,6 +1491,11 @@ export default function App() {
// Display options, shared with the band map through lib/spotDisplay. Kept in
// localStorage (mirrored to settings by writeUiPref) so both panels and the
// filter predicate read one source without prop-drilling through three levels.
// LoTW-only, and the spotter's continent. Both narrow the list by a property
// of the station rather than by what the spot is worth, which is why they sit
// beside Hide worked and not among the status chips.
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
const [clusterSpotterCont, setClusterSpotterCont] = useState(() => localStorage.getItem('opslog.clusterSpotterCont') ?? '');
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1');
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1');
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
@@ -1591,7 +1596,7 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
@@ -1624,13 +1629,13 @@ export default function App() {
const refreshSpotStatuses = useCallback(async () => {
const cur = spotsRef.current;
if (!cur.length) return;
const queries: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>();
for (const s of cur) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k)) continue;
seen.add(k);
queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '' });
queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' });
}
if (!queries.length) return;
try {
@@ -1641,7 +1646,7 @@ export default function App() {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '', country: r.country, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, county: (r as any).county, state: (r as any).state,
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
}
@@ -2717,7 +2722,7 @@ export default function App() {
// Resolve unknown statuses before the rows go in.
try {
const known = spotStatusRef.current;
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>();
for (const s of batch) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
@@ -2727,6 +2732,7 @@ export default function App() {
call: s.dx_call, band: s.band ?? '',
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
pota_ref: (s as any).pota_ref ?? '',
spotter: s.spotter ?? '',
});
}
if (unknown.length > 0) {
@@ -2741,7 +2747,7 @@ export default function App() {
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, county: (r as any).county, state: (r as any).state,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
@@ -3187,14 +3193,14 @@ export default function App() {
// "new-slot" because the lookup key carried mode="".
useEffect(() => {
const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>();
for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue;
seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' });
}
if (unknown.length === 0) return;
try {
@@ -3209,7 +3215,7 @@ export default function App() {
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, county: (r as any).county, state: (r as any).state,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
@@ -4708,6 +4714,19 @@ export default function App() {
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
if (!matches) return false;
}
// LoTW only, and the spotter's continent. Both are properties of the
// station rather than judgements about the spot, so they AND with the
// status chips instead of joining that OR: "a new band, and from Europe".
if (clusterLotwOnly || clusterSpotterCont) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
// An unresolved spot is not filtered out. The status arrives a moment
// after the row does, and dropping it meanwhile made the list flicker.
if (e) {
if (clusterLotwOnly && !e.lotw) return false;
if (clusterSpotterCont && e.spotter_continent && e.spotter_continent !== clusterSpotterCont) return false;
}
}
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
@@ -4776,6 +4795,39 @@ export default function App() {
onCheckedChange={(c) => { const v = !!c; setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); }} />
{t('clu.slotHighlightShort')}
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterLotwOnly}
onCheckedChange={(c) => { const v = !!c; setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); }} />
{t('clu.lotwOnly')}
</label>
{/* The same state as the NEW COUNTY status chip, reached a second way.
Not a second filter: county chasing is a mode you switch into, and
hunting for one chip among eight is not how you switch into it. */}
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterStatusFilter.has('new-county')}
onCheckedChange={(c) => setClusterStatusFilter((cur) => {
const n = new Set(cur);
if (c) n.add('new-county'); else n.delete('new-county');
return n;
})} />
{t('clu.newCountyOnly')}
</label>
</div>
{/* Spotter's continent. Not the DX's: this asks whether anyone near you
is hearing the band at all, which is a different question. */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t('clu.spotterCont')}</div>
<select
className="w-full h-7 rounded border border-border bg-background px-1 text-xs"
value={clusterSpotterCont}
onChange={(e) => { const v = e.target.value; setClusterSpotterCont(v); writeUiPref('opslog.clusterSpotterCont', v); }}
>
<option value="">{t('clu.allContinents')}</option>
{['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Band filter — multi-select listbox */}
+20 -1
View File
@@ -64,6 +64,11 @@ export type SpotStatusEntry = {
new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean;
spotter_continent?: string;
grid?: string;
new_grid?: boolean;
};
type Props = {
@@ -220,7 +225,21 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
cellRenderer: (p: any) => {
const s = statusFor(p);
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
return (
<span style={{ color: color ?? undefined, fontWeight: 700 }}>
{p.value ?? ''}
{/* A single letter rather than a word: the column is 120 px and holds a
callsign. It stays the muted-blue of a confirmation, never a status
colour — whether a station uploads to LoTW says nothing about
whether the spot is worth chasing. */}
{s?.lotw ? (
<span title={t('clu.lotwBadge')} style={{
marginLeft: 4, padding: '0 3px', borderRadius: 3, fontSize: 9, verticalAlign: 'middle',
background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)',
}}>L</span>
) : null}
</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
+2 -2
View File
@@ -266,7 +266,7 @@ const en: Dict = {
'clu.selfSpot': 'Self-spot while I log', 'clu.selfSpotEvery': 'at most every', 'clu.selfSpotMinutes': 'min',
'clu.selfSpotHint': 'Announces YOU on the master cluster when you log a QSO — the spot carries your station callsign and the frequency you just worked on, so callers find you without waiting for someone else to spot you. Sent on the first QSO of a frequency, then no more often than the gap below. Five minutes is the floor: a self-spot is traffic every user of the node sees.',
'clu.freeNodes': 'Free public nodes:',
'clu.muteWorkedShort': 'No colour on worked', 'clu.slotHighlightShort': 'Colour unworked here', 'clu.muteWorked': 'No colour or badge on stations already worked',
'clu.lotwOnly': 'LoTW users only', 'clu.newCountyOnly': 'New counties only', 'clu.spotterCont': 'Spotter continent', 'clu.allContinents': 'All continents', 'clu.lotwBadge': 'Uploads to LoTW', 'clu.muteWorkedShort': 'No colour on worked', 'clu.slotHighlightShort': 'Colour unworked here', 'clu.muteWorked': 'No colour or badge on stations already worked',
'clu.muteWorkedHint': '(they stay in the list, just quiet — leaves the colour for what is left to do)',
'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
@@ -686,7 +686,7 @@ const fr: Dict = {
'clu.selfSpot': "M'auto-spotter quand j'enregistre", 'clu.selfSpotEvery': 'au plus toutes les', 'clu.selfSpotMinutes': 'min',
'clu.selfSpotHint': "Annonce TON indicatif sur le cluster maître quand tu enregistres un QSO — le spot porte l'indicatif de station et la fréquence que tu viens de travailler, pour qu'on te trouve sans attendre que quelqu'un te spotte. Envoyé au premier QSO d'une fréquence, puis pas plus souvent que l'intervalle ci-dessous. Cinq minutes est le plancher : un auto-spot est du trafic que voient tous les utilisateurs du nœud.",
'clu.freeNodes': 'Nœuds publics gratuits :',
'clu.muteWorkedShort': 'Pas de couleur sur les faits', 'clu.slotHighlightShort': 'Colorer les non faits ici', 'clu.muteWorked': 'Aucune couleur ni badge sur les stations déjà contactées',
'clu.lotwOnly': 'Utilisateurs LoTW seulement', 'clu.newCountyOnly': 'Nouveaux comtés seulement', 'clu.spotterCont': 'Continent du spotter', 'clu.allContinents': 'Tous les continents', 'clu.lotwBadge': 'Utilise LoTW', 'clu.muteWorkedShort': 'Pas de couleur sur les faits', 'clu.slotHighlightShort': 'Colorer les non faits ici', 'clu.muteWorked': 'Aucune couleur ni badge sur les stations déjà contactées',
'clu.muteWorkedHint': '(elles restent dans la liste, simplement discrètes — la couleur reste pour ce qui est à faire)',
'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode',
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",