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 */}