import { useEffect, useMemo, useState } from 'react'; import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { sunTimes } from '@/lib/sun'; import { BandSlotQSOs } from '../../wailsjs/go/main/App'; import type { WorkedBeforeView } from '@/types'; type WorkedBefore = WorkedBeforeView; interface Props { wb: WorkedBefore | null; busy: boolean; currentBand: string; currentMode: string; bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell // DX station coordinates, for its sunrise/sunset. Optional: many spots resolve // to an entity with no position at all, and the block simply does not appear. lat?: number; lon?: number; // Set when the matrix is showing a QSO picked in the log grid rather than the // entry form — labelled so the two can never be confused. forCall?: string; } // Compact column label for a band tag: keep the classic V/U for 2m/70cm, // strip the trailing "m" for meter bands (160m→160), and shorten cm bands // (13cm→13c) so the column stays narrow. function bandColLabel(tag: string): string { if (tag === '2m') return 'V'; if (tag === '70cm') return 'U'; if (tag.endsWith('cm')) return tag.replace('cm', 'c'); return tag.replace(/m$/, ''); } // Default 13-column band layout, used when the operator hasn't configured bands. const DEFAULT_BANDS: { tag: string; label: string }[] = [ { tag: '160m', label: '160' }, { tag: '80m', label: '80' }, { tag: '60m', label: '60' }, { tag: '40m', label: '40' }, { tag: '30m', label: '30' }, { tag: '20m', label: '20' }, { tag: '17m', label: '17' }, { tag: '15m', label: '15' }, { tag: '12m', label: '12' }, { tag: '10m', label: '10' }, { tag: '6m', label: '6' }, { tag: '2m', label: 'V' }, { tag: '70cm', label: 'U' }, ]; const CLASSES = ['PH', 'CW', 'DIG'] as const; const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']); function classMatchesMode(cls: string, mode: string): boolean { const u = (mode || '').toUpperCase(); if (cls === 'PH') return PHONE_MODES.has(u); if (cls === 'CW') return u === 'CW'; return u !== '' && u !== 'CW' && !PHONE_MODES.has(u); } // Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level // ramp per hue so confirmed / worked / not-worked stay distinguishable on both // light and dark surfaces (the generic status -muted fills were too dark on the // dark themes to tell "worked" from "not worked" apart). Light-warm reproduces // the original emerald/indigo/stone colours exactly. const STATUS_CLASSES: Record = { call_c: 'bg-mx-call-conf', call_w: 'bg-mx-call-work', dxcc_c: 'bg-mx-dx-conf', dxcc_w: 'bg-mx-dx-work', }; // Legend entries, in the same colour order as the cells. swatch = the // background class (or a special ring marker for the current-entry cell). const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [ { swatch: 'bg-mx-call-conf', label: 'Call confirmed' }, { swatch: 'bg-mx-call-work', label: 'Call worked' }, { swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' }, { swatch: 'bg-mx-dx-work', label: 'Entity worked' }, { swatch: 'bg-mx-none', label: 'Not worked' }, { swatch: 'bg-mx-none', ring: true, label: 'Current entry' }, ]; function cellTitle(band: string, cls: string, status: string, current: boolean): string { const desc = status === 'call_c' ? 'This callsign confirmed' : status === 'call_w' ? 'This callsign worked (not confirmed)' : status === 'dxcc_c' ? 'Entity confirmed (other callsign)' : status === 'dxcc_w' ? 'Entity worked (other callsign)' : 'Never worked'; return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`; } export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall }: Props) { // Cell drill-down: which band+class the operator clicked, or null. const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null); // Columns from the operator's configured bands (so the matrix shows only the // bands they actually use), falling back to the built-in default set. const cols = useMemo( () => (bands && bands.length ? bands.map((tag) => ({ tag, label: bandColLabel(tag) })) : DEFAULT_BANDS), [bands], ); const dxcc = wb?.dxcc ?? 0; const dxccName = wb?.dxcc_name ?? ''; const dxccCount = wb?.dxcc_count ?? 0; const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown) const callCount = wb?.count ?? 0; // QSOs with this exact callsign const hasDxcc = dxcc > 0; const newOne = hasDxcc && dxccCount === 0; const statusMap = useMemo(() => { const m = new Map(); for (const s of wb?.band_status ?? []) { m.set(`${s.band}|${s.class}`, s.status); } return m; }, [wb]); // "Newness" of the current band+mode entry, for the award/DX-chase badges. // Derived straight from the entity's real band_status (all bands it was // worked on — not just the operator's configured column list). // By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a // group, so FT4 after FT8 is genuinely a new mode. The operator can opt into // DXCC-style grouping instead (Settings → General), where all digital modes // count as ONE — then FT4 after FT8 is just "worked". const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1'; const normMode = (m: string): string => { const u = (m || '').toUpperCase().trim(); if (!groupDigital) return u; return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG'; }; const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[]; const curMode = normMode(currentMode); const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode) const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band) const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode); // Mutually-exclusive badges, shown only when the entity is worked but this // exact band+mode is NOT yet: // New Band & Mode = both the band AND the mode are new for this entity. // New Band = the band is new (the mode was worked on another band). // New Mode = the mode is new (the band was worked in another mode). // New Slot = both band and mode already worked — just not together. const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked; const newBandMode = slotNew && !bandWorked && !modeWorked; const newBand = slotNew && !bandWorked && modeWorked; const newMode = slotNew && bandWorked && !modeWorked; const newSlot = slotNew && bandWorked && modeWorked; // ClubLog Most Wanted rank pill (shown next to the entity name when the feature // is on). Hotter colour the more wanted the entity is. const mwBadge = mwRank > 0 ? ( MW #{mwRank} ) : null; // Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is // about to open or close on their side, which is the reason to look at a DX // station's grey line at all. Recomputed only when the position changes. const sun = useMemo( () => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)), [lat, lon], ); const sunBlock = sun ? (
{sun.polarDay ? ( midnight sun ) : sun.polarNight ? ( polar night ) : ( <> {sun.rise || '—'} {sun.set || '—'} UTC )}
) : null; return (
{newOne ? ( <> NEW ONE {dxccName || `DXCC #${dxcc}`} {' '}· never worked this entity {mwBadge} ) : hasDxcc ? ( <> {/* Says WHOSE stats these are when they come from a row picked in the log rather than from what's being typed. */} {forCall && ( {forCall} )} {dxccName || `DXCC #${dxcc}`} {mwBadge} {dxccCount}{' '} QSO{dxccCount > 1 ? 's' : ''} with this entity {callCount > 0 && ( <> {' · '} {callCount}{' '} with this call )} {(newBand || newMode || newBandMode || newSlot) && (
{newBandMode && New Band & Mode} {newBand && New Band} {newMode && New Mode} {newSlot && New Slot}
)} ) : busy ? ( looking up… ) : ( Type a callsign to see entity stats )} {sunBlock}
))} {CLASSES.map((cls) => { const classCurrent = classMatchesMode(cls, currentMode); return ( {cols.map((b) => { const st = statusMap.get(`${b.tag}|${cls}`) ?? ''; const isCurrent = hasCall && b.tag === currentBand && classCurrent; return ( ); })}
{cols.map((b) => ( {b.label}
{cls} setSlot({ band: b.tag, cls }) : undefined} className={cn( 'w-[28px] h-[24px] rounded transition-colors p-0', st ? STATUS_CLASSES[st] : 'bg-mx-none', // Only a filled cell has anything to show — an empty one // stays inert rather than opening a "no QSOs" dialog. st && 'cursor-pointer hover:brightness-110', isCurrent && 'ring-2 ring-warning ring-inset', )} /> ); })}
{/* Colour legend — sits in the spare room under the matrix. */}
{LEGEND.map((l) => ( {l.label} ))}
{slot && ( setSlot(null)} /> )}
); } // ── Cell drill-down ────────────────────────────────────────────────────────── // The contacts behind one band+class cell: this exact callsign AND anyone else // in the entity, because that is the pair of facts the cell's colour encodes. // The call's own QSOs are marked so the two never blur together. function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: { call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; }) { const [rows, setRows] = useState(null); const [err, setErr] = useState(''); useEffect(() => { let dead = false; BandSlotQSOs(call, dxcc, band, cls) .then((r: any) => { if (!dead) setRows((r ?? []) as any[]); }) .catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } }); return () => { dead = true; }; }, [call, dxcc, band, cls]); useEffect(() => { // Capture phase: the app's global ESC handler resets the entry form, and // closing a dialog must not also wipe what the operator was typing. function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); } } window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [onClose]); return (
e.stopPropagation()}>
{band} · {cls} {entity && — {entity}} {rows ? `${rows.length} QSO` : ''}
{err &&

{err}

} {!rows ? (

Loading…

) : rows.length === 0 ? (

No QSOs.

) : ( {rows.map((q, i) => { const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y'; return ( ); })}
Date UTC Callsign Mode Freq Name Cfm
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')} {q.callsign} {q.mode} {q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''} {q.name} {cfm ? : ''}
)}
); }