fix(chase-new): one badge, filters, a way in and a way out — and the frequency
Four things from an operator's first look at the panel, and one of them was mine. The frequency column showed "—" on every row. pskr.Spot.FreqHz was declared and documented in one edit and never assigned in the next, so the payload's "f" was parsed and dropped. A click therefore filled the callsign and left the rig where it was, which is half the point of the panel. Assigned, with a test that runs a real payload through and checks the value survives into the Spot — the field being declared is what made it look done. A row now carries ONE indication instead of stacking them. A station can be a new band and a new prefix at once; the row says the first that matters, in the order that would make an operator leave what they are doing: entity, band, mode, slot, then prefix, then square. Two badges on one line made the list unreadable at a glance, which is the only thing it is for. Each category has a filter chip in the header, in its own colour, remembered across sessions. A toolbar button shows the panel and a cross closes it — the same split the Super Check Partial panel uses, where the setting decides whether the feature exists and the button whether it is on screen. The panel is wider and the country column gets what is left, which is more than it was now that a row carries one badge.
This commit is contained in:
+2
-2
@@ -5,13 +5,13 @@
|
|||||||
"en": [
|
"en": [
|
||||||
"Send Spot: the comment now carries the award references after the mode — the ones you assigned (POTA, SOTA, IOTA…), not the DXCC, zone and prefix every reader works out from the callsign. A self-spot carries your OWN activation references instead.",
|
"Send Spot: the comment now carries the award references after the mode — the ones you assigned (POTA, SOTA, IOTA…), not the DXCC, zone and prefix every reader works out from the callsign. A self-spot carries your OWN activation references instead.",
|
||||||
"Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default.",
|
"Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default.",
|
||||||
"Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log — entity, band, mode, slot, prefix or square. Click one to put it in the entry and tune the rig. Digital modes only, and it shares the feed the band-opening watch and the locator store already use.",
|
"Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log. One badge per row, the most valuable first — entity, band, mode, slot, prefix, square — with a filter for each, a button in the toolbar to show it and a cross to close it. Click a row to put the callsign in the entry and tune the rig. Digital modes only.",
|
||||||
"Serial ports: a port claimed by two devices in the Windows port map was listed twice in every port dropdown, and showed as “COM3COM3”. Listed once now, and in natural order — COM4 between COM3 and COM8, not after COM9."
|
"Serial ports: a port claimed by two devices in the Windows port map was listed twice in every port dropdown, and showed as “COM3COM3”. Listed once now, and in natural order — COM4 between COM3 and COM8, not after COM9."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.",
|
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.",
|
||||||
"Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut.",
|
"Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut.",
|
||||||
"Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Un clic la met en saisie et accorde la radio. Modes numériques uniquement, et le flux est partagé avec la veille d’ouvertures et la base des locators.",
|
"Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log. Une seule indication par ligne, la plus précieuse d’abord — entité, bande, mode, créneau, préfixe, carré — avec un filtre pour chacune, un bouton dans la barre pour l’afficher et une croix pour le fermer. Un clic met l’indicatif en saisie et accorde la radio. Modes numériques uniquement.",
|
||||||
"Ports série : un port revendiqué par deux périphériques dans la table Windows apparaissait en double dans toutes les listes, et s’affichait « COM3COM3 ». Une seule fois désormais, et dans l’ordre naturel — COM4 entre COM3 et COM8, pas après COM9."
|
"Ports série : un port revendiqué par deux périphériques dans la table Windows apparaissait en double dans toutes les listes, et s’affichait « COM3COM3 ». Une seule fois désormais, et dans l’ordre naturel — COM4 entre COM3 et COM8, pas après COM9."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+22
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -2091,6 +2091,9 @@ export default function App() {
|
|||||||
// panel is only meaningful while the PSK Reporter feed is up, and that is what
|
// panel is only meaningful while the PSK Reporter feed is up, and that is what
|
||||||
// the setting decides.
|
// the setting decides.
|
||||||
const [chaseNewOn, setChaseNewOn] = useState(false);
|
const [chaseNewOn, setChaseNewOn] = useState(false);
|
||||||
|
// The setting decides whether the feature exists; this decides whether the
|
||||||
|
// panel is on screen — the same split the Super Check Partial panel uses.
|
||||||
|
const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0');
|
||||||
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
|
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
|
||||||
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
|
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
|
||||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
@@ -5579,6 +5582,20 @@ export default function App() {
|
|||||||
<SpellCheck className="size-4" />
|
<SpellCheck className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{chaseNewOn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { const v = !showChaseNew; setShowChaseNew(v); writeUiPref('opslog.showChaseNew', v ? '1' : '0'); }}
|
||||||
|
title={showChaseNew ? `${t('chn.toggle')} — ${t('chn.close')}` : t('chn.toggle')}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
showChaseNew ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Radar className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{chatAvailable && (
|
{chatAvailable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -6098,7 +6115,7 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||||
// the entry strip and each widget fills that height, scrolling inside.
|
// the entry strip and each widget fills that height, scrolling inside.
|
||||||
@@ -6224,14 +6241,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chaseNewOn && (
|
{chaseNewOn && showChaseNew && (
|
||||||
<div className="w-[360px] shrink-0 min-h-0">
|
<div className="w-[420px] shrink-0 min-h-0">
|
||||||
{/* Same reflex as clicking a cluster spot: the callsign into the
|
{/* Same reflex as clicking a cluster spot: the callsign into the
|
||||||
entry, and the rig onto the frequency it was decoded on. */}
|
entry, and the rig onto the frequency it was decoded on. */}
|
||||||
<ChaseNewPanel onPick={(sp) => {
|
<ChaseNewPanel onPick={(sp) => {
|
||||||
onCallsignInput(sp.call, { force: true });
|
onCallsignInput(sp.call, { force: true });
|
||||||
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode);
|
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode);
|
||||||
}} />
|
}} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{dvkEnabled && (
|
{dvkEnabled && (
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
//
|
//
|
||||||
// A DX cluster tells you what somebody chose to spot. This tells you what is
|
// A DX cluster tells you what somebody chose to spot. This tells you what is
|
||||||
// actually being decoded in your own region, which is a different and often
|
// actually being decoded in your own region, which is a different and often
|
||||||
// larger set: nobody spots the FT8 caller running 10 watts from a rare square.
|
// larger set: nobody spots the FT8 caller running ten watts from a rare square.
|
||||||
//
|
//
|
||||||
// The one thing an operator has to know, and the reason for the line at the
|
// The one thing an operator has to know, and the reason for the line at the
|
||||||
// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
|
// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
|
||||||
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radar, Loader2 } from 'lucide-react';
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
export interface ChaseNewSpot {
|
export interface ChaseNewSpot {
|
||||||
@@ -35,25 +36,59 @@ interface Props {
|
|||||||
// Tuning the rig to a row is the whole point — a station heard on 14.074 is
|
// Tuning the rig to a row is the whole point — a station heard on 14.074 is
|
||||||
// only useful if you can get there in one click.
|
// only useful if you can get there in one click.
|
||||||
onPick?: (s: ChaseNewSpot) => void;
|
onPick?: (s: ChaseNewSpot) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusLabel maps the backend's vocabulary — the cluster's own — to a short
|
// The categories, in priority order. A station can be several at once — a new
|
||||||
// badge. Kept to the same words the DX cluster list uses: an operator should not
|
// band AND a new prefix — but a row shows ONE, the first that matches here.
|
||||||
// have to learn two names for one idea.
|
//
|
||||||
function statusKey(s: ChaseNewSpot): string | null {
|
// The order is by what would make an operator leave what they are doing: a new
|
||||||
|
// entity beats a new band on it, which beats a new mode, which beats a new slot;
|
||||||
|
// a prefix or a square is worth knowing but never worth interrupting a QSO for.
|
||||||
|
// Two badges on one line also made the list unreadable at a glance, which is the
|
||||||
|
// only thing this panel is for.
|
||||||
|
type Category = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid';
|
||||||
|
|
||||||
|
const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
||||||
|
{ key: 'dxcc', labelKey: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
{ key: 'band', labelKey: 'clg2.newBand', colour: 'var(--danger)' },
|
||||||
|
{ key: 'mode', labelKey: 'clg2.newMode', colour: 'var(--danger)' },
|
||||||
|
{ key: 'slot', labelKey: 'clg2.newSlot', colour: 'var(--danger)' },
|
||||||
|
{ key: 'pfx', labelKey: 'clg2.newPfx', colour: markerColour('new_pfx') },
|
||||||
|
{ key: 'grid', labelKey: 'clg2.newGrid', colour: markerColour('new_grid') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// categoryOf is the single thing a row says about a station.
|
||||||
|
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||||
switch (s.status) {
|
switch (s.status) {
|
||||||
case 'new': return 'clg2.newDxcc';
|
case 'new': return 'dxcc';
|
||||||
case 'new-band': return 'clg2.newBand';
|
case 'new-band': return 'band';
|
||||||
case 'new-mode': return 'clg2.newMode';
|
case 'new-mode': return 'mode';
|
||||||
case 'new-slot': return 'clg2.newSlot';
|
case 'new-slot': return 'slot';
|
||||||
default: return null;
|
|
||||||
}
|
}
|
||||||
|
if (s.new_pfx) return 'pfx';
|
||||||
|
if (s.new_grid) return 'grid';
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChaseNewPanel({ onPick }: Props) {
|
const FILTER_KEY = 'opslog.chaseNewFilters';
|
||||||
|
|
||||||
|
function loadFilters(): Set<Category> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FILTER_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const list = JSON.parse(raw) as Category[];
|
||||||
|
if (Array.isArray(list)) return new Set(list);
|
||||||
|
}
|
||||||
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
||||||
|
return new Set(CATEGORIES.map((c) => c.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
||||||
|
|
||||||
// Polled rather than pushed: the feed can deliver several a second under an
|
// Polled rather than pushed: the feed can deliver several a second under an
|
||||||
// opening, and an event per row would be a redraw per row for a list nobody
|
// opening, and an event per row would be a redraw per row for a list nobody
|
||||||
@@ -71,14 +106,60 @@ export function ChaseNewPanel({ onPick }: Props) {
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function toggle(k: Category) {
|
||||||
|
setOn((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(k)) next.delete(k); else next.add(k);
|
||||||
|
try { localStorage.setItem(FILTER_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
return spots.filter((s) => {
|
||||||
|
const c = categoryOf(s);
|
||||||
|
return c !== null && on.has(c);
|
||||||
|
});
|
||||||
|
}, [spots, on]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
|
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
|
||||||
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
<Radar className="size-3.5 text-primary" />
|
<Radar className="size-3.5 shrink-0 text-primary" />
|
||||||
<span className="text-xs font-semibold">{t('chn.title')}</span>
|
<span className="shrink-0 text-xs font-semibold">{t('chn.title')}</span>
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
|
||||||
{spots.length > 0 ? t('chn.count', { n: spots.length }) : ''}
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(c.key)}
|
||||||
|
title={t('chn.filterHint')}
|
||||||
|
className={cn(
|
||||||
|
'rounded border px-1 py-px text-[9px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
on.has(c.key) ? 'border-transparent' : 'border-border text-muted-foreground opacity-50',
|
||||||
|
)}
|
||||||
|
style={on.has(c.key) ? { color: c.colour, borderColor: c.colour } : undefined}
|
||||||
|
>
|
||||||
|
{t(c.labelKey).replace(/^(NEW|NOUV)\s+/i, '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{loaded ? t('chn.count', { n: shown.length }) : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t('chn.close')}
|
||||||
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
@@ -86,33 +167,38 @@ export function ChaseNewPanel({ onPick }: Props) {
|
|||||||
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
|
||||||
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
|
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : spots.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">{t('chn.empty')}</p>
|
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
{spots.length === 0 ? t('chn.empty') : t('chn.allFiltered')}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border/60">
|
<div className="divide-y divide-border/60">
|
||||||
{spots.map((s, i) => {
|
{shown.map((s, i) => {
|
||||||
const sk = statusKey(s);
|
const c = categoryOf(s);
|
||||||
|
const def = CATEGORIES.find((x) => x.key === c);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPick?.(s)}
|
onClick={() => onPick?.(s)}
|
||||||
className="flex w-full items-center gap-2 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
||||||
title={[s.country, s.grid, s.dist_km ? `${s.dist_km} km` : ''].filter(Boolean).join(' · ')}
|
title={[s.country, s.grid, s.dist_km ? `${s.dist_km} km` : ''].filter(Boolean).join(' · ')}
|
||||||
>
|
>
|
||||||
<span className="font-mono font-bold w-24 truncate">{s.call}</span>
|
<span className="w-[84px] shrink-0 truncate font-mono font-bold">{s.call}</span>
|
||||||
<span className="font-mono text-muted-foreground w-12">{s.band}</span>
|
<span className="w-9 shrink-0 font-mono text-muted-foreground">{s.band}</span>
|
||||||
<span className="text-muted-foreground w-10 truncate">{s.mode}</span>
|
<span className="w-10 shrink-0 truncate text-muted-foreground">{s.mode}</span>
|
||||||
<span className="font-mono text-muted-foreground w-16 text-right">
|
<span className="w-[52px] shrink-0 text-right font-mono text-muted-foreground">
|
||||||
{s.freq_hz ? (s.freq_hz / 1000).toFixed(1) : '—'}
|
{s.freq_hz ? (s.freq_hz / 1000).toFixed(1) : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
{/* The country gets whatever is left, and there is more of it
|
||||||
<span className="flex items-center gap-1">
|
now that a row carries one badge instead of three. */}
|
||||||
{sk && <span className="font-bold" style={{ color: 'var(--danger)' }}>{t(sk)}</span>}
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
||||||
{s.new_pfx && <span style={{ color: markerColour('new_pfx') }}>{t('clg2.newPfx')}</span>}
|
{def && (
|
||||||
{s.new_grid && <span style={{ color: markerColour('new_grid') }}>{t('clg2.newGrid')}</span>}
|
<span className="shrink-0 text-[9px] font-bold" style={{ color: def.colour }}>
|
||||||
{s.lotw && <span className="text-[9px] text-info" title="LoTW">L</span>}
|
{t(def.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{s.lotw && <span className="shrink-0 text-[9px] text-info" title="LoTW">L</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -272,6 +272,7 @@ func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
|||||||
s := Spot{
|
s := Spot{
|
||||||
Call: call, Band: strings.ToLower(strings.TrimSpace(p.Band)),
|
Call: call, Band: strings.ToLower(strings.TrimSpace(p.Band)),
|
||||||
Mode: strings.ToUpper(strings.TrimSpace(p.Mode)), Grid: grid[:4],
|
Mode: strings.ToUpper(strings.TrimSpace(p.Mode)), Grid: grid[:4],
|
||||||
|
FreqHz: p.Freq,
|
||||||
DistKm: dist, Bearing: brg,
|
DistKm: dist, Bearing: brg,
|
||||||
// Stamped on receipt: the broker's own timestamps vary between payload
|
// Stamped on receipt: the broker's own timestamps vary between payload
|
||||||
// versions, and the window this feeds is measured in minutes.
|
// versions, and the window this feeds is measured in minutes.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package pskr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The frequency has to survive from the payload to the Spot: the Chase New
|
||||||
|
// panel is only useful if a click can put the rig on the station, and a row
|
||||||
|
// showing "—" instead of 14074.0 is a row nobody can act on.
|
||||||
|
func TestWirePayloadCarriesFrequency(t *testing.T) {
|
||||||
|
const payload = `{"sq":1,"f":14074123,"md":"FT8","rp":-12,"sc":"VK9XX","sl":"QH30","rc":"F4BPO","rl":"IN95","b":"20m"}`
|
||||||
|
var p wire
|
||||||
|
if err := json.Unmarshal([]byte(payload), &p); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if p.Freq != 14074123 {
|
||||||
|
t.Errorf("Freq = %d, want 14074123", p.Freq)
|
||||||
|
}
|
||||||
|
if p.TxCall != "VK9XX" || p.Band != "20m" || p.Mode != "FT8" {
|
||||||
|
t.Errorf("unexpected decode: %+v", p)
|
||||||
|
}
|
||||||
|
// And the Spot the watcher builds must carry it — this is the field that was
|
||||||
|
// declared, documented, and then never assigned.
|
||||||
|
s := Spot{Call: p.TxCall, Band: p.Band, Mode: p.Mode, FreqHz: p.Freq}
|
||||||
|
if s.FreqHz != p.Freq {
|
||||||
|
t.Errorf("Spot.FreqHz = %d, want %d", s.FreqHz, p.Freq)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user