diff --git a/frontend/src/components/BandMap.tsx b/frontend/src/components/BandMap.tsx index 65d0aa5..607d429 100644 --- a/frontend/src/components/BandMap.tsx +++ b/frontend/src/components/BandMap.tsx @@ -38,6 +38,9 @@ type SpotStatusEntry = { new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; + // muted: the status was emptied on purpose by the "no colour on worked + // stations" option, so an empty status here is a choice, not an unknown. + muted?: boolean; }; // The extra markers are ORTHOGONAL to the entity status: a spot can be a worked @@ -149,13 +152,18 @@ function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; labe // Human-readable label for a spot status — used in the pill hover tooltip // so the operator can see WHY a spot is coloured the way it is. -function statusLabel(s: string, t: (k: string) => string): string { +function statusLabel(s: string, t: (k: string) => string, muted = false): string { switch (s) { case 'new': return t('bmp.statusNew'); case 'new-band': return t('bmp.statusNewBand'); + case 'new-mode': return t('bmp.statusNewMode'); case 'new-slot': return t('bmp.statusNewSlot'); case 'worked': return t('bmp.statusWorked'); - default: return t('bmp.statusUnresolved'); + // An empty status means the entity could not be resolved — EXCEPT when the + // "no colour on worked stations" option emptied it on purpose. Saying + // "entity not resolved" there was a flat contradiction of the country + // printed two words earlier in the same tooltip. + default: return muted ? t('bmp.statusMuted') : t('bmp.statusUnresolved'); } } @@ -617,7 +625,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, 'hover:translate-x-0.5 hover:shadow', style.pill, )} - title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`} + title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t, entry?.muted)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`} > {/* Left accent strip. With no extra marker it repeats the status colour, exactly as before; otherwise it splits into one diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 024f006..18848a3 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -343,7 +343,7 @@ const en: Dict = { 'chp.workedBefore': 'Worked before', 'chp.typeCallsign': 'Type a callsign to see prior contacts.', 'chp.checking': 'checking…', 'chp.new': 'NEW', 'chp.noPriorQso': 'No prior QSO with this callsign.', 'chp.first': 'First', 'chp.last': 'Last', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Band', 'chp.mode': 'Mode', 'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs', - 'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)', + 'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)', 'bmp.statusNewMode': 'NEW MODE (mode never worked for this entity)', 'bmp.statusMuted': 'Nothing new here — entity already worked (muted by your display option)', 'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.', 'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map', 'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge', @@ -755,7 +755,7 @@ const fr: Dict = { 'chp.workedBefore': 'Déjà contacté', 'chp.typeCallsign': 'Saisis un indicatif pour voir les contacts précédents.', 'chp.checking': 'vérification…', 'chp.new': 'NOUVEAU', 'chp.noPriorQso': 'Aucun QSO antérieur avec cet indicatif.', 'chp.first': 'Premier', 'chp.last': 'Dernier', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Bande', 'chp.mode': 'Mode', 'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens', - 'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)', + 'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)', 'bmp.statusNewMode': 'NOUVEAU MODE (mode jamais contacté pour cette entité)', 'bmp.statusMuted': 'Rien de nouveau ici — entité déjà contactée (masqué par ton option d affichage)', 'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.', 'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande', 'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande', diff --git a/frontend/src/lib/spotDisplay.ts b/frontend/src/lib/spotDisplay.ts index e713093..53058c9 100644 --- a/frontend/src/lib/spotDisplay.ts +++ b/frontend/src/lib/spotDisplay.ts @@ -31,12 +31,18 @@ type Entry = { new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; + muted?: boolean; } | undefined; -// bringsNothingNew: the entity is resolved and worked, and no other dimension +// bringsNothingNew: the ENTITY is resolved and worked, and no other dimension // (county, park, prefix) is new. Same test the cluster list already used to dim // a row — muting reuses it rather than inventing a second notion of "done". // +// Entity-level, NOT callsign-level: an operator can be on their 421st Bulgarian +// and still have never worked that particular station. That spot is muted here +// by design — it brings nothing to an award — and turning on the slot option is +// what brings it back, which is why the promotion above runs first. +// // Note what is NOT muted: a status of new-band / new-slot survives, because // having worked that callsign once on another band says nothing about the band // in front of you. @@ -51,15 +57,29 @@ function bringsNothingNew(s: Entry): boolean { // re-deriving it. export function applySpotDisplay(s: T, o: SpotDisplayOptions): T { if (!s) return s; - if (o.muteWorked && bringsNothingNew(s)) { + let e = s; + + // ORDER MATTERS, and it is the whole difference between the two options + // composing and one cancelling the other. + // + // Slot promotion runs FIRST. A callsign not yet worked on this band and mode + // is not "done", so it must never be swallowed by the mute below — yet the + // mute test only looks at the entity, and an unworked callsign inside a worked + // 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; + } + + if (o.muteWorked && bringsNothingNew(e)) { // Strip everything that paints: the row keeps its data, loses its emphasis. - return { ...s, status: '', worked_call: false } as T; + // + // muted says WHY the status went empty. An empty status already meant + // "entity not resolved" in the band map, so without this flag every muted + // spot claimed its entity was unknown — with the country printed right next + // to it. Blanking is still what drives the colour, the badges and the + // ranking; muted only lets the tooltip stay honest. + return { ...e, status: '', worked_call: false, muted: true } as T; } - if (o.slotHighlight && s.worked_slot === false) { - // Not worked on this band+mode. If the entity check found nothing new, say - // so with the slot status rather than leaving the spot colourless — that is - // exactly the row this option exists to surface. - if (!s.status || s.status === 'worked') return { ...s, status: 'new-slot' } as T; - } - return s; + return e; }