From c95a1137fc0b161de0f68fdca83a098ea10809d4 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Mon, 10 Aug 2026 19:00:09 +0200 Subject: [PATCH] refactor(cluster): one rule for the filter panel The panel had grown two shapes for the same kind of choice. Some filters were checkboxes, some were chips, and New counties only was a checkbox duplicating a chip - which I added, and which was the worst of it: a control that exists twice is not more discoverable, it is one control the operator has to recognise twice. The rule now: SWITCH a behaviour that is on or off, and narrows nothing by a property of the station: hide worked, group duplicates, the two display options, LoTW users only. CHIPS pick any number from a set; none picked means all. Status, mode, spotter continent. Selected is solid, unselected is the same chip faded, so the palette keeps teaching the colour code while switched off. Nothing appears in both shapes. The duplicate county checkbox is gone. Spotter continent became a chip row: seven two-letter codes fit on two lines, they read as a set the way Status and Mode do, and several can be picked at once - which "EU or NA" needs and a dropdown cannot express. Both Lock buttons now sit in the heading of the section they lock, instead of floating between sections, and every multi-select section clears the same way through the same heading slot. One section helper and one chip helper, so the next filter cannot drift. The panel was also entirely hardcoded English - Search call, Hide worked, Bands, Status - against the project's own bilingual rule. All of it goes through t() now, both locales. --- changelog.json | 4 +- frontend/src/App.tsx | 244 ++++++++++++++++++-------------------- frontend/src/lib/i18n.tsx | 4 +- 3 files changed, 121 insertions(+), 131 deletions(-) diff --git a/changelog.json b/changelog.json index b734311..d737cfc 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,10 @@ "version": "0.24.4", "date": "", "en": [ - "DX cluster: an L badge next to a callsign marks a station that uploads to LoTW, with a matching LoTW users only filter. A Spotter continent dropdown narrows the list by where the spot came FROM — a JA report on 20 m tells a European little about their own path. New counties only joins the filter panel as a second way to the NEW COUNTY chip. All of them apply to the band map too." + "DX cluster: an L badge next to a callsign marks a station that uploads to LoTW, with a matching LoTW users only filter, and a Spotter continent filter narrows the list by where the spot came FROM — a JA report on 20 m tells a European little about their own path. The filter panel has been tidied along one rule: a switch is a behaviour you turn on or off, chips pick any number from a set. Nothing appears in both shapes any more, the Lock buttons sit in the heading of the section they lock, every section can be cleared the same way, and the whole panel is finally translated." ], "fr": [ - "Cluster DX : un badge L à côté de l indicatif signale une station qui utilise LoTW, avec le filtre Utilisateurs LoTW seulement qui va avec. Une liste déroulante Continent du spotter restreint selon l origine du spot — un report JA sur 20 m dit peu de chose à un Européen sur son propre chemin. Nouveaux comtés seulement rejoint le panneau de filtres, en second accès à la puce NOUV CTY. Le tout s applique aussi au bandmap." + "Cluster DX : un badge L à côté de l indicatif signale une station qui utilise LoTW, avec le filtre Utilisateurs LoTW seulement qui va avec, et un filtre Continent du spotter restreint selon l origine du spot — un report JA sur 20 m dit peu de chose à un Européen sur son propre chemin. Le panneau de filtres a été remis d aplomb selon une seule règle : un interrupteur est un comportement qu on active ou non, les pastilles choisissent dans un ensemble. Plus rien n existe sous les deux formes, les boutons de verrouillage sont dans le titre de la section qu ils verrouillent, chaque section s efface de la même façon, et le panneau est enfin traduit." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7154728..521c05f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1495,7 +1495,7 @@ export default function App() { // 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 [clusterSpotterConts, setClusterSpotterConts] = useState>(() => lsSet('opslog.clusterSpotterCont')); const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1'); const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1'); const [clusterStatusFilter, setClusterStatusFilter] = useState>(() => lsSet('opslog.clusterStatusFilter')); @@ -1516,11 +1516,13 @@ export default function App() { writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0'); writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0'); writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter])); + writeUiPref('opslog.clusterSpotterCont', JSON.stringify([...clusterSpotterConts])); writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter])); writeUiPref('opslog.clusterSearch', clusterSearch); writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0'); }, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode, - clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]); + clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked, + clusterLotwOnly, clusterSpotterConts]); // Bands shown side-by-side in the Band Map tab (portable). const [bandMapBands, setBandMapBands] = useState(() => { try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; } @@ -4717,14 +4719,14 @@ export default function App() { // 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) { + if (clusterLotwOnly || clusterSpotterConts.size > 0) { 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 (clusterSpotterConts.size > 0 && e.spotter_continent && !clusterSpotterConts.has(e.spotter_continent)) return false; } } if (clusterHideWorked) { @@ -4753,11 +4755,46 @@ export default function App() { // The Log4OM-style cluster filter sidebar (callsign search, hide-worked, // group, band/mode/status/source). Rendered both in the Cluster tab and the // Main-view cluster pane; toggled by clusterShowFilters. + // One rule for the whole panel, because two shapes for the same kind of choice + // is what made it unreadable: + // + // SWITCH — a behaviour that is on or off (hide worked, group duplicates). + // CHIPS — pick any number from a set; none picked means all (status, mode, + // continent). Selected is solid, unselected is the same chip faded, + // so the palette teaches the colour code even while switched off. + // + // Nothing appears in both shapes. A control that exists twice is not more + // discoverable, it is one control the operator has to recognise twice. + const fSection = (label: string, children: any, right?: any) => ( +
+
+ {label} + {right} +
+ {children} +
+ ); + + const fSwitch = (label: string, on: boolean, set: (v: boolean) => void) => ( + + ); + + const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity'; + const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void) => ( + + ); + const renderClusterFilters = () => (
- Filters - @@ -4766,89 +4803,48 @@ export default function App() { {/* Callsign search */} setClusterSearch(e.target.value.toUpperCase())} /> - {/* Toggles */} -
- - - {/* Two ways to cut through a busy cluster, and they compose: mute what - is done, light up what is not. They sit HERE, with the other things - an operator changes mid-run, and not in the preferences dialog. - Both drive the band map as well, through lib/spotDisplay. */} - - - - {/* 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. */} - + {/* Behaviours: each is on or off, and none of them narrows by a property + of the station. The last two also drive the band map, via + lib/spotDisplay — they live here rather than in Preferences because + they are changed while working a run. */} +
+ {fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)} + {fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)} + {fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })} + {fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })} + {fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
- {/* Spotter's continent. Not the DX's: this asks whether anyone near you - is hearing the band at all, which is a different question. */} -
-
{t('clu.spotterCont')}
- -
+
, + clusterSpotterConts.size > 0 ? ( + + ) : undefined, + )} {/* Band filter — multi-select listbox */} -
-
- Bands -
- - {clusterBands.size > 0 && ( - - )} -
-
+ {fSection(t('clu.bands'),
{bands.map((b) => { const on = clusterBands.has(b); @@ -4864,23 +4860,25 @@ export default function App() { ); })} -
-
- - {/* Mode lock */} - +
, +
+ + {clusterBands.size > 0 && ( + + )} +
, + )} {/* Status filter */} -
-
Status
+ {fSection(t('clu.status'),
{([ { k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' }, @@ -4899,51 +4897,43 @@ export default function App() { // worked spots; the separate "Hide worked" checkbox drops them — they // are opposite controls, so don't use both at once. { k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, - ]).map((s) => { - const on = clusterStatusFilter.has(s.k); - return ( - - ); - })} -
-
+ ]).map((s) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k), + () => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))} +
, + clusterStatusFilter.size > 0 ? ( + + ) : undefined, + )} {/* Mode filter */} -
-
Mode
+ {fSection(t('clu.mode'),
{([ { k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' }, - ]).map((s) => { - const on = clusterModeFilter.has(s.k); - return ( - - ); - })} -
-
+ ]).map((s) => fChip(s.k, s.label, s.cls, clusterModeFilter.has(s.k), + () => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))} + , + , + )} {/* Source */} -
-
Source
+ {fSection(t('clu.source'), -
+ , + )} ); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 1402edc..e44c51b 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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.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.filters': 'Filters', 'clu.hideFilters': 'Hide filters', 'clu.searchCall': 'Search call…', 'clu.hideWorked': 'Hide worked', 'clu.groupDup': 'Group duplicates', 'clu.mode': 'Mode', 'clu.bands': 'Bands', 'clu.source': 'Source', 'clu.clear': 'clear', 'clu.allSources': 'All sources', 'clu.lockModeTitle': 'Only show spots whose mode matches the entry strip', 'clu.lockBandTitle': "Lock to the entry strip's current band", 'clu.lotwOnly': 'LoTW users only', 'clu.spotterCont': 'Spotter continent', '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.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.filters': 'Filtres', 'clu.hideFilters': 'Masquer les filtres', 'clu.searchCall': 'Chercher un indicatif…', 'clu.hideWorked': 'Masquer les contactés', 'clu.groupDup': 'Grouper les doublons', 'clu.mode': 'Mode', 'clu.bands': 'Bandes', 'clu.source': 'Source', 'clu.clear': 'effacer', 'clu.allSources': 'Toutes les sources', 'clu.lockModeTitle': 'N afficher que les spots du mode de la barre de saisie', 'clu.lockBandTitle': 'Verrouiller sur la bande de la barre de saisie', 'clu.lotwOnly': 'Utilisateurs LoTW seulement', 'clu.spotterCont': 'Continent du spotter', '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é)",