diff --git a/app.go b/app.go index 71551e1..2083e09 100644 --- a/app.go +++ b/app.go @@ -228,11 +228,18 @@ func (a *App) startup(ctx context.Context) { }) a.reloadCAT() - // DX Cluster (multi-server): spot callback pushes individual spots, - // status callback signals "something changed" so the frontend can - // fetch the aggregate via GetClusterStatus. + // DX Cluster (multi-server): the spot callback enriches each spot + // with country + continent via cty.dat BEFORE emitting it, so the UI + // renders the row with all metadata already filled (no flicker of + // empty Country / Cont columns while the batch status fetch runs). a.cluster = cluster.NewManager( func(s cluster.Spot) { + if a.dxcc != nil { + if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil { + s.Country = m.Entity.Name + s.Continent = m.Continent + } + } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:spot", s) } @@ -1383,11 +1390,12 @@ type SpotQuery struct { // "worked" — exact band+mode already in the log // "" — couldn't resolve the entity (no cty.dat match) type SpotStatus struct { - Call string `json:"call"` - Band string `json:"band"` - Mode string `json:"mode"` - Country string `json:"country,omitempty"` - Status string `json:"status"` + Call string `json:"call"` + Band string `json:"band"` + Mode string `json:"mode"` + Country string `json:"country,omitempty"` + Continent string `json:"continent,omitempty"` + Status string `json:"status"` } // ClusterSpotStatuses takes a batch of spots and returns slot status for @@ -1403,7 +1411,20 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { if a.qso == nil { return out } - entities, err := a.qso.EntitySlotMap(a.ctx) + // Pass a cty.dat-backed resolver so the past-QSO map uses the SAME + // entity name we'll compare each spot against. Without it QRZ-stored + // "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW. + resolveEntity := func(callsign string) string { + if a.dxcc == nil { + return "" + } + m, ok := a.dxcc.Lookup(callsign) + if !ok || m.Entity == nil { + return "" + } + return m.Entity.Name + } + entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity) if err != nil { return out } @@ -1422,6 +1443,7 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { } country := strings.ToLower(m.Entity.Name) out[i].Country = m.Entity.Name + out[i].Continent = m.Continent e, worked := entities[country] if !worked { out[i].Status = "new" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a366cc1..e9ec8a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -353,6 +353,8 @@ export default function App() { comment?: string; locator?: string; time_utc?: string; + country?: string; + continent?: string; received_at: string; raw: string; }; @@ -392,7 +394,7 @@ export default function App() { // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". // Keyed by `${call}|${band}|${mode}` so two spots of the same call on // different slots don't share the same colour. - const [spotStatus, setSpotStatus] = useState>({}); + const [spotStatus, setSpotStatus] = useState>({}); // === Modals === const [editingQSO, setEditingQSO] = useState(null); @@ -569,7 +571,24 @@ export default function App() { } useEffect(() => { reloadClusterMeta(); - EventsOn('cluster:state', (sts: ServerStatus[]) => setClusterServerStatuses(sts ?? [])); + // cluster:state fires on connect/disconnect/save/delete — refresh + // the saved-server list too so the source dropdown stays in sync + // when the user adds, deletes or toggles a row in Settings. + EventsOn('cluster:state', async (sts: ServerStatus[]) => { + setClusterServerStatuses(sts ?? []); + try { + const list = await ListClusterServers(); + setClusterServers(((list ?? []) as any[]).map((s) => ({ + id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0, + }))); + } catch {} + // Drop any buffered spots whose source server is no longer in the + // status list (it was disconnected / deleted). Without this the + // table keeps showing stale rows from a server the user just + // turned off. + const activeIds = new Set((sts ?? []).map((s) => s.server_id)); + setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id))); + }); EventsOn('cluster:spot', (sp: ClusterSpot) => { setSpots((arr) => { const next = [sp, ...arr]; @@ -604,7 +623,7 @@ export default function App() { const next = { ...prev }; for (const r of res) { const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; - next[k] = { status: r.status ?? '', country: r.country }; + next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent }; } return next; }); @@ -1791,6 +1810,8 @@ export default function App() { { key: 'freq', label: 'Freq', align: 'right' }, { key: 'band', label: 'Band' }, { key: 'mode', label: 'Mode' }, + { key: null, label: 'Country' }, + { key: null, label: 'Cont' }, { key: 'spotter', label: 'Spotter' }, { key: 'source', label: 'Source' }, { key: null, label: 'Loc' }, @@ -1800,19 +1821,14 @@ export default function App() { s.key === k ? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' }); - const rowColor = (s: ClusterSpot): string => { - // The cache key includes the inferred mode (from - // comment / band-plan) so CW vs FT8 on the same - // band get distinct statuses. + // Log4OM-style per-cell highlight: the badge that matches + // the "what's new" gets coloured instead of the whole row. + // CALL = new entity, BAND = new band for entity, MODE = new + // mode for that band (NEW SLOT — Log4OM doesn't show this + // but the user wants it). + const cellHL = (s: ClusterSpot) => { const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); - const st = spotStatus[k]; - if (!st) return ''; - switch (st.status) { - case 'new': return 'bg-rose-50 hover:bg-rose-100'; - case 'new-band': return 'bg-amber-50 hover:bg-amber-100'; - case 'new-slot': return 'bg-yellow-50 hover:bg-yellow-100'; - default: return ''; - } + return spotStatus[k]?.status ?? ''; }; return ( @@ -1844,10 +1860,22 @@ export default function App() { - {rendered.map((s, i) => ( + {rendered.map((s, i) => { + const hl = cellHL(s); + const callCls = hl === 'new' + ? 'bg-rose-100 text-rose-800 hover:bg-rose-200 border border-rose-300' + : 'text-primary'; + const bandCls = hl === 'new-band' + ? 'bg-amber-200 text-amber-900 border-amber-500 hover:bg-amber-200' + : ''; + const modeMode = inferSpotMode(s.comment ?? '', s.freq_hz); + const modeCls = hl === 'new-slot' + ? 'bg-yellow-200 text-yellow-900 border-yellow-500 hover:bg-yellow-200' + : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100'; + return ( { // Mode comes from the spot itself (comment text // first, band plan fallback). Sending it to CAT @@ -1871,20 +1899,38 @@ export default function App() { className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40" title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined} >{s.time_utc || ''} - + - - + + + + - ))} + ); + })}
{s.dx_call} + + {s.dx_call} + + {s.freq_khz.toFixed(1)}{s.band || '—'}{(() => { - const m = inferSpotMode(s.comment ?? '', s.freq_hz); - if (!m) return ; - return {m}; - })()} + {bandCls + ? {s.band || '—'} + : {s.band || '—'}} + + {!modeMode + ? + : {modeMode}} + + {s.country ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''} + + {s.continent ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.continent ?? ''} + {cleanSpotter(s.spotter)} {s.source_name} {s.locator || ''} {s.comment}
); diff --git a/frontend/src/components/BandMap.tsx b/frontend/src/components/BandMap.tsx index c2075ce..497f0f8 100644 --- a/frontend/src/components/BandMap.tsx +++ b/frontend/src/components/BandMap.tsx @@ -1,13 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Minus, Plus, Crosshair, X } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { spotStatusKey } from '@/lib/spot'; +import { spotStatusKey, inferSpotMode } from '@/lib/spot'; -// BandMap — vertical spectrum panel. Layout follows Log4OM's well-loved -// design: a kHz scale on the left, callsign labels stacked vertically on -// the right (one per line, no overlap), connected to their actual -// frequency on the scale by diagonal "leader" lines. Wheel-scroll for -// long spot lists, Ctrl+wheel to zoom. +// BandMap — vertical spectrum panel inspired by Log4OM. +// - Full band is always visible; zoom changes pixels-per-kHz, scroll +// navigates the band (so 16× lets you read each spot, not crops the +// band to a 22 kHz slice). +// - Labels sit at their actual frequency by default (straight horizontal +// leader line). Only when two labels would visually overlap do we +// bump the lower one down — its leader then becomes diagonal back to +// the true frequency. interface Spot { source_id?: number; @@ -31,8 +34,6 @@ interface Props { onClose?: () => void; } -// Visible kHz range per band — covers IARU R1 plus a small pad so spots -// right at the edge are still drawn. const BAND_RANGES: Record = { '160m': [1800, 2000], '80m': [3500, 3800], @@ -64,33 +65,63 @@ const SEGMENT_COLORS: Record = { '6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']], }; -function statusColor(s: string): { fg: string; line: string } { - // fg is the label text colour; line is the SVG stroke. Both follow the - // same NEW / NEW BAND / NEW SLOT / WORKED palette as the spot table. +function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } { + // pill = full pill background+text+border + // bar = thick left accent inside the pill + // line = SVG leader stroke (visible on hover) + // dot = small marker on the freq scale switch (s) { - case 'new': return { fg: 'text-rose-700', line: 'stroke-rose-500' }; - case 'new-band': return { fg: 'text-amber-700', line: 'stroke-amber-500' }; - case 'new-slot': return { fg: 'text-yellow-700', line: 'stroke-yellow-600' }; - case 'worked': return { fg: 'text-muted-foreground', line: 'stroke-border' }; - default: return { fg: 'text-emerald-700', line: 'stroke-emerald-600' }; + case 'new': return { + pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100', + bar: 'bg-rose-500', + line: 'stroke-rose-400', + dot: 'fill-rose-500', + }; + case 'new-band': return { + pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100', + bar: 'bg-amber-500', + line: 'stroke-amber-400', + dot: 'fill-amber-500', + }; + case 'new-slot': return { + pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100', + bar: 'bg-yellow-500', + line: 'stroke-yellow-500', + dot: 'fill-yellow-500', + }; + case 'worked': return { + pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50', + bar: 'bg-muted-foreground/30', + line: 'stroke-border', + dot: 'fill-border', + }; + default: return { + pill: 'bg-card text-foreground border-border hover:bg-accent/40', + bar: 'bg-primary/60', + line: 'stroke-primary/50', + dot: 'fill-primary/60', + }; } } -const ZOOMS = [1, 2, 4, 8, 16]; -const SCALE_W = 56; // px — left freq scale column -const LINE_H = 18; // px — per-callsign row height -const LABEL_PAD_LEFT = 24; // px — diagonal line lands here, before the text +// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot +// every 2.75 kHz fits without anti-overlap kicking in — comfortable for +// most bands. Higher levels are for fine inspection of crowded sub-bands. +const PX_PER_KHZ = [8, 16, 32, 64, 128, 256]; +const SCALE_W = 56; +const PILL_H = 22; // px — height of each callsign pill +const PILL_GAP = 32; // px between scale border and first pill (room for leader) +const LABEL_W = 200; +const TOP_PAD = 14; // px of breathing room above/below the band edges so +const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0 export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) { const range = BAND_RANGES[band]; const segments = SEGMENT_COLORS[band] ?? []; const [zoomIdx, setZoomIdx] = useState(0); - const [center, setCenter] = useState(null); const scrollerRef = useRef(null); - const innerRef = useRef(null); const [containerH, setContainerH] = useState(400); - // Track the visible container height so we can stretch the scale. useEffect(() => { const el = scrollerRef.current; if (!el) return; @@ -100,31 +131,55 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o return () => ro.disconnect(); }, []); - // Window geometry. - const zoom = ZOOMS[zoomIdx]; const fallback: [number, number] = range ?? [0, 1]; - const [bandLo, bandHi] = fallback; - const visSpan = (bandHi - bandLo) / zoom; - const c0 = center ?? (currentFreqHz > 0 ? currentFreqHz / 1000 : (bandLo + (bandHi - bandLo) / 2)); - const c = clampCenter(c0, fallback, zoom); - const lo = c - visSpan / 2; - const hi = c + visSpan / 2; + const [lo, hi] = fallback; const span = hi - lo; + const pxPerKHz = PX_PER_KHZ[zoomIdx]; - // Filtered + sorted spots (highest freq first → top of the column). - const visible = useMemo(() => { - if (!range) return []; - return spots - .filter((s) => s.freq_khz >= lo && s.freq_khz <= hi) - .sort((a, b) => b.freq_khz - a.freq_khz); - }, [spots, lo, hi, range]); + // Anti-overlap layout: each label wants to sit at its true freq, but + // never closer than PILL_H from the previous one. Sorted top-to-bottom + // (highest freq first). Dedup by callsign (latest wins) so multi-cluster + // duplicates don't stack identical pills. + // + // Returns placed labels + the required content height (the natural + // band height OR the bottom of the last bumped label, whichever is + // larger). When more labels stack than fit in the band's natural pixel + // span, totalH grows so scrolling reveals them. + type Placed = { spot: Spot; freqY: number; labelY: number }; + const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => { + // innerH is the band's stretched pixel span; total adds top+bottom + // padding so the edge freq labels aren't clipped at y=0 / y=H. + const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); + if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD }; + const seen = new Set(); + const filtered: Spot[] = []; + for (const s of spots) { + if (s.freq_khz < lo || s.freq_khz > hi) continue; + if (seen.has(s.dx_call)) continue; + seen.add(s.dx_call); + filtered.push(s); + } + filtered.sort((a, b) => b.freq_khz - a.freq_khz); + const out: Placed[] = []; + let prevY = -Infinity; + for (const s of filtered) { + const fy = TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH; + const ly = Math.max(fy, prevY + PILL_H); + out.push({ spot: s, freqY: fy, labelY: ly }); + prevY = ly; + } + const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0; + return { + placed: out, + totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD), + }; + }, [spots, range, lo, hi, span, pxPerKHz, containerH]); - // Total content height: stretch so every label has its own row, but - // never shrink below the visible container so the scale fills the box - // when there are few spots. - const totalH = Math.max(containerH, visible.length * LINE_H + 16); + // freqToY for elements rendered outside the memo (ticks, rig pointer). + // Must mirror the same offset so the rig triangle sits on the right kHz. + const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); + const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH; - // Ctrl+wheel = zoom, regular wheel = native scroll (default browser). useEffect(() => { const el = scrollerRef.current; if (!el) return; @@ -132,7 +187,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o if (!range) return; if (e.ctrlKey || e.metaKey) { e.preventDefault(); - setZoomIdx((z) => Math.max(0, Math.min(ZOOMS.length - 1, z + (e.deltaY > 0 ? -1 : 1)))); + setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1)))); } }; el.addEventListener('wheel', onWheel, { passive: false }); @@ -148,29 +203,25 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o ); } - // Tick step adapts to visible kHz span so labels stay legible. - let step = 100; - if (span <= 1500) step = 50; - if (span <= 800) step = 25; - if (span <= 300) step = 10; - if (span <= 100) step = 5; - if (span <= 40) step = 2; - if (span <= 20) step = 1; + // Tick step (where small marks land) and label step (where the kHz + // number is printed) are decoupled so the scale shows ~10-20 numeric + // labels per viewport regardless of zoom — at base 8 px/kHz we want + // labels every 25 kHz, not every 250. + let tickStep = 50; + let labelStep = 100; + if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; } + if (pxPerKHz >= 8) { tickStep = 5; labelStep = 25; } + if (pxPerKHz >= 16) { tickStep = 2; labelStep = 10; } + if (pxPerKHz >= 32) { tickStep = 1; labelStep = 5; } + if (pxPerKHz >= 64) { tickStep = 1; labelStep = 2; } + if (pxPerKHz >= 128) { tickStep = 1; labelStep = 1; } const ticks: number[] = []; - for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t); - - // Y-axis convention: HIGH frequency at top, LOW at bottom (matches a - // physical receiver dial). freqToY maps a kHz to pixel-Y in totalH. - const freqToY = (kHz: number) => (1 - (kHz - lo) / span) * totalH; + for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t); function recenterOnRig() { - if (currentFreqHz > 0) setCenter(clampCenter(currentFreqHz / 1000, range, zoom)); - else setCenter(null); - // Also scroll to keep the rig pointer in view. - if (scrollerRef.current && currentFreqHz > 0) { - const y = freqToY(currentFreqHz / 1000); - scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2); - } + if (!scrollerRef.current || currentFreqHz <= 0) return; + const y = freqToY(currentFreqHz / 1000); + scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2); } const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0; @@ -186,15 +237,15 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o title="Zoom out"> - {zoom}× - {onClose && ( @@ -207,8 +258,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
-
- {/* Scale column background — full height, segments stretched */} +
+ {/* Scale background segments — stretched to total height */} {segments.map(([s, e, cls], i) => { - if (e < lo || s > hi) return null; const y1 = freqToY(Math.min(e, hi)); const y2 = freqToY(Math.max(s, lo)); return ; })} - {/* Scale border */} - {/* Tick marks + labels on scale */} + {/* Tick marks + freq labels */} {ticks.map((t) => { const y = freqToY(t); - const major = t % (step * 5) === 0; + const major = t % labelStep === 0; return (
@@ -241,83 +290,91 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o ); })} - {/* SVG layer for leader lines + rig pointer */} - - {visible.map((s, i) => { - const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); + {/* Dots on the scale + leader lines (always-on, subtle) + rig pointer */} + + {placed.map((p, i) => { + const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz); const st = spotStatus[k]?.status ?? ''; - const color = statusColor(st); - const fy = freqToY(s.freq_khz); - const ly = i * LINE_H + LINE_H / 2 + 8; + const style = statusStyle(st); + const labelMidY = p.labelY + PILL_H / 2; + const bumped = Math.abs(p.freqY - labelMidY) > 0.5; return ( - + + {/* Small dot on the scale where the spot actually is */} + + {/* Leader line — solid+full opacity when straight, + dashed+lower opacity when diagonal (bumped) so the + eye distinguishes "this is the real freq" from + "this label was nudged to fit". */} + + ); })} {showRigPointer && ( <> + {/* Triangle pointer + soft horizontal target line */} )} - {/* Callsign label stack — one per line, sorted by freq desc */} -
- {visible.map((s, i) => { - const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); - const st = spotStatus[k]?.status ?? ''; - const color = statusColor(st); - return ( - - ); - })} -
+ + + ); + })}
- scroll · ctrl+wheel = zoom · ◎ = recenter + scroll · ctrl+wheel = zoom · ◎ = jump to rig
); } - -function clampCenter(c: number, [lo, hi]: [number, number], zoom: number): number { - const halfSpan = (hi - lo) / zoom / 2; - return Math.max(lo + halfSpan, Math.min(hi - halfSpan, c)); -} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 7ec9da7..da91d42 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -19,6 +19,7 @@ import { import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models'; +import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, @@ -275,6 +276,20 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setClusterStatuses((st ?? []) as ClusterServerStatus[]); } catch (e: any) { setErr(String(e?.message ?? e)); } } + + // Live cluster status updates while Preferences is open — the user can + // click Connect/Disconnect inside the modal and see the pills change + // without saving + reopening. + useEffect(() => { + EventsOn('cluster:state', async (st: any) => { + setClusterStatuses((st ?? []) as ClusterServerStatus[]); + try { + const list = await ListClusterServers(); + setClusterServers((list ?? []) as ClusterServer[]); + } catch {} + }); + return () => { EventsOff('cluster:state'); }; + }, []); const [profiles, setProfiles] = useState([]); // State for ProfilesPanel — lifted here because PANELS[selected]() calls // the panel as a plain function, not as a JSX element, so any useState diff --git a/frontend/src/style.css b/frontend/src/style.css index 76efb35..bd3454d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -3,44 +3,43 @@ @import "tailwindcss"; @import "tw-animate-css"; -/* ===== Cream & warm-orange palette ===== - Background : warm cream (subtle beige, less cold than stone) - Accent : burnt orange (signal-lamp vibe, classic radio amateur) - Status : muted emerald / amber / rose - The BandSlotGrid cells keep their emerald+indigo independently — that - colour code conveys QSO status semantics, not app branding. +/* ===== Warm taupe & burnt-orange palette ===== + A step darker than pure cream: the body is a soft taupe so white-ish + cards lift off the surface, giving real depth without going dark. + Borders are more defined; muted surfaces sit between bg and card so + secondary chrome (toolbars, table headers) reads as quietly recessed. */ @theme { - --color-background: #faf6ed; /* warm cream */ + --color-background: #e8dfc9; /* warm taupe — deeper than cream */ --color-foreground: #1c1917; /* stone-900 */ - --color-card: #ffffff; + --color-card: #faf6ea; /* lifted off-white cream */ --color-card-foreground: #1c1917; - --color-popover: #ffffff; + --color-popover: #faf6ea; --color-popover-foreground: #1c1917; - --color-primary: #c2410c; /* orange-700 — burnt orange */ + --color-primary: #b8410c; /* burnt orange, slightly deeper */ --color-primary-foreground: #fff7ed; - --color-secondary: #f5efe0; /* warmer secondary surface */ + --color-secondary: #ddd2b8; /* a touch under the bg */ --color-secondary-foreground: #1c1917; - --color-muted: #f5efe0; - --color-muted-foreground: #57534e; /* stone-600 */ + --color-muted: #ddd2b8; /* toolbars / table headers */ + --color-muted-foreground: #44403c; /* stone-700 — readable on muted */ - --color-accent: #ffedd5; /* orange-100 — light cream-orange */ - --color-accent-foreground: #9a3412; /* orange-800 */ + --color-accent: #f8d6a9; /* warm amber-cream */ + --color-accent-foreground: #7c2d12; /* orange-900 */ - --color-destructive: #b91c1c; /* red-700, classic brick */ + --color-destructive: #a51c1c; --color-destructive-foreground: #ffffff; - --color-border: #e7dfd0; /* warm beige border */ - --color-input: #e7dfd0; - --color-ring: #ea580c; /* orange-600 — focus ring */ + --color-border: #c8b994; /* deeper warm beige border */ + --color-input: #c8b994; + --color-ring: #d97706; /* amber-600 focus ring */ - --color-ok: #15803d; /* emerald-700 — slightly deeper */ - --color-warn: #b45309; /* amber-700 */ + --color-ok: #15803d; + --color-warn: #b45309; --radius: 0.5rem; @@ -61,12 +60,19 @@ } } -/* Warm scrollbar */ +/* Subtle elevation on every Card-styled surface so cards visibly sit on + top of the taupe background — paper-on-paper feel. */ +.bg-card { + box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), + 0 0 0 1px rgba(28, 25, 23, 0.02); +} + +/* Warm scrollbar tuned to the deeper bg */ ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: #d6cbb1; + background: #b8a880; border-radius: 5px; border: 2px solid var(--color-background); } -::-webkit-scrollbar-thumb:hover { background: #b3a47d; } +::-webkit-scrollbar-thumb:hover { background: #968455; } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 0180896..0607322 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -411,6 +411,7 @@ export namespace main { band: string; mode: string; country?: string; + continent?: string; status: string; static createFrom(source: any = {}) { @@ -423,6 +424,7 @@ export namespace main { this.band = source["band"]; this.mode = source["mode"]; this.country = source["country"]; + this.continent = source["continent"]; this.status = source["status"]; } } diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 298624f..9f00761 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -36,6 +36,9 @@ type ServerConfig struct { } // Spot is a single DX spot as parsed from the cluster stream. +// Country/Continent are filled by the caller (app.go) before the spot +// is emitted to the UI, so the table never has empty country cells +// flickering in for a few hundred ms. type Spot struct { SourceID int64 `json:"source_id"` // ID of the cluster server this came from SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers) @@ -47,6 +50,8 @@ type Spot struct { Comment string `json:"comment,omitempty"` Locator string `json:"locator,omitempty"` // spotter grid (optional) TimeUTC string `json:"time_utc,omitempty"` + Country string `json:"country,omitempty"` // DXCC entity name (cty.dat) + Continent string `json:"continent,omitempty"` // 2-letter continent ReceivedAt time.Time `json:"received_at"` Raw string `json:"raw"` } @@ -92,6 +97,7 @@ type session struct { conn net.Conn stopCh chan struct{} doneCh chan struct{} + stopped bool // guards against double-stop on the same session spotsCnt int } @@ -162,11 +168,14 @@ func (m *Manager) StopServer(id int64) { if ok { delete(m.sessions, id) } + remaining := len(m.sessions) m.mu.Unlock() + fmt.Printf("cluster.StopServer id=%d found=%v remaining=%d\n", id, ok, remaining) if !ok { return } s.stop() + fmt.Printf("cluster.StopServer id=%d stopped successfully\n", id) m.emitStatus() } @@ -230,10 +239,19 @@ func (s *session) send(cmd string) error { } func (s *session) stop() { + // Critical: do NOT nil out s.stopCh — the supervisor goroutine reads + // `<-s.stopCh` in its select. Setting the field to nil would make + // `<-nil` block forever, leaving the supervisor stuck on its backoff + // timer and then re-dialing → a "deleted" cluster keeps spotting. + // We just close() the channel and let the goroutine see the broadcast. s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.stopped = true stop, done := s.stopCh, s.doneCh conn := s.conn - s.stopCh, s.doneCh, s.conn = nil, nil, nil s.mu.Unlock() if conn != nil { _ = conn.Close() diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 68b3221..0b76789 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -832,34 +832,48 @@ type EntitySlot struct { Slots map[string]map[string]struct{} // band → modes worked } -// EntitySlotMap returns slot data for every QSO grouped by lowercase -// country name (cty.dat-style key). Cheap on a 25k-row table: one -// scan, no joins. Callers can compare a spot's entity to this map to -// decide if it's NEW / NEW SLOT / WORKED. -func (r *Repo) EntitySlotMap(ctx context.Context) (map[string]*EntitySlot, error) { +// EntitySlotMap returns slot data for every QSO, grouping by entity. +// +// `resolveEntity` maps a callsign to its canonical entity name (we use +// cty.dat for this). When non-nil, the resolved name wins over the +// stored `country` column — that's important because QRZ's "Turkey" +// disagrees with cty.dat's "Asiatic Turkey" and the cluster status +// comparison would otherwise miss past QSOs. When nil, we fall back to +// the stored country (useful for tests). +// +// One DB scan regardless of input size. Cheap to call per cluster batch. +func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign string) string) (map[string]*EntitySlot, error) { rows, err := r.db.QueryContext(ctx, - `SELECT lower(country), lower(band), upper(mode) FROM qso - WHERE country IS NOT NULL AND country != '' - AND band IS NOT NULL AND band != '' - AND mode IS NOT NULL AND mode != ''`) + `SELECT callsign, lower(coalesce(country,'')), lower(band), upper(mode) FROM qso + WHERE band IS NOT NULL AND band != '' + AND mode IS NOT NULL AND mode != ''`) if err != nil { return nil, err } defer rows.Close() out := make(map[string]*EntitySlot, 256) for rows.Next() { - var country, band, mode string - if err := rows.Scan(&country, &band, &mode); err != nil { + var call, country, band, mode string + if err := rows.Scan(&call, &country, &band, &mode); err != nil { return nil, err } - e, ok := out[country] + key := country + if resolveEntity != nil { + if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" { + key = name + } + } + if key == "" { + continue + } + e, ok := out[key] if !ok { e = &EntitySlot{ - Country: country, + Country: key, Bands: make(map[string]struct{}), Slots: make(map[string]map[string]struct{}), } - out[country] = e + out[key] = e } e.Bands[band] = struct{}{} bandSlots, ok := e.Slots[band]