import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock, Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X, } from 'lucide-react'; import { AddQSO, ListQSO, CountQSO, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, LookupCallsign, GetStationSettings, GetListsSettings, GetStartupStatus, WorkedBefore, SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, RefreshCtyDat, RotatorGoTo, RotatorStop, GetRotatorHeading, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, GetCATSettings, OperatingDefaultForBand, LogUDPLoggedADIF, ListCountries, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { EventsOn } from '../wailsjs/runtime/runtime'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import { Menubar, type Menu } from '@/components/Menubar'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { pathBetween } from '@/lib/maidenhead'; import { flagURL } from '@/lib/flags'; type QSO = QSOForm; type ImportResult = adifModels.ImportResult; type LookupResult = lookupModels.Result; type StationSettings = StationSettingsForm; type ListsSettings = ListsSettingsForm; type ModePreset = ModePresetForm; type WB = WorkedBeforeView; type CATState = Omit; const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm']; const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE']; const emptyDetails: DetailsState = { state: '', cnty: '', address: '', lat: undefined, lon: undefined, dxcc: undefined, cqz: undefined, ituz: undefined, cont: '', qsl_msg: '', qsl_via: '', ant_az: undefined, ant_el: undefined, ant_path: '', prop_mode: '', my_rig: '', my_antenna: '', tx_pwr: undefined, sat_name: '', sat_mode: '', contest_id: '', srx: undefined, stx: undefined, email: '', }; function fmtDateUTC(s: any): string { if (!s) return ''; const d = new Date(s); if (isNaN(d.getTime())) return s; const p = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; } function fmtFreq(hz?: number): string { if (!hz) return ''; return (hz / 1_000_000).toFixed(4); } // cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so // the BandMap component reads from the same canonical source — keeps // "CW spot looks like CW everywhere" honest. function fmtHMSUTC(d: Date): string { const p = (n: number) => String(n).padStart(2, '0'); return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`; } // parseHMSUTC parses "HH:MM" or "HH:MM:SS" and returns a Date with that // UTC time on the same UTC day as base. Tolerates "HHMM" / "HHMMSS" too // (no separators) so the user can type fast. Falls back to base on bad input. function parseHMSUTC(s: string, base: Date): Date { const clean = s.replace(/[^0-9:]/g, ''); let h = 0, m = 0, sec = 0; if (clean.includes(':')) { const parts = clean.split(':'); h = parseInt(parts[0] ?? '0', 10) || 0; m = parseInt(parts[1] ?? '0', 10) || 0; sec = parseInt(parts[2] ?? '0', 10) || 0; } else if (clean.length >= 4) { h = parseInt(clean.slice(0, 2), 10) || 0; m = parseInt(clean.slice(2, 4), 10) || 0; sec = clean.length >= 6 ? (parseInt(clean.slice(4, 6), 10) || 0) : 0; } else { return base; } if (h > 23 || m > 59 || sec > 59) return base; const d = new Date(base); d.setUTCHours(h, m, sec, 0); return d; } // fmtFreqDots formats a MHz string for display as MHz.kHz.Hz with dot // separators (14.266500 → "14.266.500"). Pads the fractional part to 6 // digits so partial inputs ("14.21") still render as "14.210.000". function fmtFreqDots(mhzStr: string): string { if (!mhzStr) return ''; const [intPart, fracRaw = ''] = mhzStr.split('.'); const frac = (fracRaw + '000000').slice(0, 6); return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`; } // shortCatError condenses a backend error into a few words for the topbar // pill. The full message stays in the tooltip. Recognises the common cases // (OmniRig not installed, not registered) and otherwise truncates. function shortCatError(err?: string): string { if (!err) return ''; const e = err.toLowerCase(); if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found'; if (e.includes('not connected')) return 'not connected'; if (e.includes('coinitialize')) return 'COM error'; return err.length > 24 ? err.slice(0, 22) + '…' : err; } // bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside // every known allocation (used to auto-fill the band when the freq changes). function bandForMHz(mhz: number): string { if (!mhz || isNaN(mhz)) return ''; const plan: [number, number, string][] = [ [1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'], [10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'], [24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'], [144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'], ]; for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b; return ''; } // rstCategory buckets a mode into the report family used for its RST list. type RSTLists = { phone: string[]; cw: string[]; digital: string[] }; function rstCategory(mode: string): keyof RSTLists { const m = (mode || '').toUpperCase(); const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR']; if (digital.includes(m)) return 'digital'; if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; return 'phone'; } // rstOptions returns the valid report choices for a mode from the user's // editable lists (Settings → Modes), with a tiny fallback before they load. function rstOptions(mode: string, lists: RSTLists): string[] { const cat = rstCategory(mode); const l = lists[cat]; if (l && l.length) return l; return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20']; } function computePrefix(call: string): string { if (!call) return ''; const c = call.trim().toUpperCase().split('/')[0]; let lastDigit = -1; for (let i = 0; i < c.length; i++) { if (c[i] >= '0' && c[i] <= '9') lastDigit = i; } return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c; } export default function App() { // === Lists from settings (fallback for first paint) === const [bands, setBands] = useState(DEFAULT_BANDS); const [modes, setModes] = useState(DEFAULT_MODES); const [modePresets, setModePresets] = useState([]); // === Entry === const [callsign, setCallsign] = useState(''); // Ref to the callsign input so ESC can snap focus back to it. const callsignRef = useRef(null); // QSO start time — frozen when the operator starts typing the callsign, // logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF). const [qsoStartedAt, setQsoStartedAt] = useState(null); // Frozen end time used when the End-UTC field is locked (backdated QSO); // null means the field tracks the live clock. const [qsoEndedAt, setQsoEndedAt] = useState(null); // Local string buffers for the time inputs while the user is editing. // Parsing only on blur — otherwise every keystroke would re-format the // value and React would move the cursor back to the end of the field. const [startInputStr, setStartInputStr] = useState(''); const [endInputStr, setEndInputStr] = useState(''); const [startFocused, setStartFocused] = useState(false); const [endFocused, setEndFocused] = useState(false); const [freqFocused, setFreqFocused] = useState(false); // Per-field locks — like Log4OM's padlocks. When locked, CAT updates skip // that field and the time fields become editable so the user can log a // QSO from the past while the radio is parked elsewhere. type LockKey = 'band' | 'mode' | 'freq' | 'start' | 'end'; const [locks, setLocks] = useState>({ band: false, mode: false, freq: false, start: false, end: false, }); const locksRef = useRef(locks); useEffect(() => { locksRef.current = locks; }, [locks]); const toggleLock = (k: LockKey) => { setLocks((s) => { const wasLocked = s[k]; const next = { ...s, [k]: !wasLocked }; // Unlocking → restore automatic behavior. Without this the locked // value would linger forever: a stale Start time would never refresh // even after a new callsign is entered. if (wasLocked) { if (k === 'start') { // If a QSO is currently in progress (callsign typed), snap start // to now since we missed the auto-start moment. Otherwise clear. setQsoStartedAt(callsign.trim() ? new Date() : null); } else if (k === 'end') { // Drop the frozen end so the field tracks the live UTC clock. setQsoEndedAt(null); } } return next; }); }; // Small padlock toggle rendered inside each lockable field's label. Match // the icon to the current state so the user can tell at a glance which // fields are immune to CAT updates / live clock. function LockBtn({ k, title }: { k: LockKey; title: string }) { const on = locks[k]; const Icon = on ? Lock : Unlock; return ( ); } const [band, setBand] = useState('20m'); const [mode, setMode] = useState('SSB'); const [freqMhz, setFreqMhz] = useState(''); // RX freq for split — only set/shown when the rig is in split mode. const [rxFreqMhz, setRxFreqMhz] = useState(''); // RX band — follows the TX band by default; only differs for cross-band work. const [bandRx, setBandRx] = useState('20m'); const [countries, setCountries] = useState([]); const [rstLists, setRstLists] = useState({ phone: [], cw: [], digital: [] }); const [rstSent, setRstSent] = useState('59'); const [rstRcvd, setRstRcvd] = useState('59'); const [grid, setGrid] = useState(''); const [name, setName] = useState(''); const [qth, setQth] = useState(''); const [country, setCountry] = useState(''); const [comment, setComment] = useState(''); const [note, setNote] = useState(''); // Compact (popout) mode: shrinks the window + always-on-top so the entry // strip can sit on top of WSJT-X / JT-Alert / the cluster. const [compact, setCompact] = useState(false); function toggleCompact() { const next = !compact; setCompact(next); SetCompactMode(next); } // CAT — receives live rig state via Wails events. const [catState, setCatState] = useState({ enabled: false, connected: false } as any); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // in Preferences > Hardware > CAT interface. const digitalDefaultRef = useRef('FT8'); // Don't override freq/band/mode the user JUST typed — track a small grace // window after manual edits and skip CAT updates during it. const catFreezeUntilRef = useRef(0); function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; } // Suggested QSY frequency (Hz) for a given band + mode. Common phone / // CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq. // TODO: make this a user preference per band/mode later. const QSY_TABLE: Record> = { '160m': { SSB: 1842000, CW: 1820000, FT8: 1840000, FT4: 1840000, RTTY: 1838000, default: 1840000 }, '80m': { SSB: 3750000, CW: 3520000, FT8: 3573000, FT4: 3575000, RTTY: 3580000, default: 3750000 }, '60m': { default: 5357000 }, '40m': { SSB: 7150000, CW: 7030000, FT8: 7074000, FT4: 7047500, RTTY: 7040000, default: 7150000 }, '30m': { CW: 10116000, FT8: 10136000, FT4: 10140000, RTTY: 10142000, default: 10136000 }, '20m': { SSB: 14250000, CW: 14030000, FT8: 14074000, FT4: 14080000, RTTY: 14080000, default: 14250000 }, '17m': { SSB: 18140000, CW: 18080000, FT8: 18100000, FT4: 18104000, RTTY: 18105000, default: 18140000 }, '15m': { SSB: 21300000, CW: 21030000, FT8: 21074000, FT4: 21140000, RTTY: 21080000, default: 21300000 }, '12m': { SSB: 24950000, CW: 24910000, FT8: 24915000, FT4: 24919000, RTTY: 24920000, default: 24950000 }, '10m': { SSB: 28500000, CW: 28030000, FT8: 28074000, FT4: 28180000, RTTY: 28080000, default: 28500000 }, '6m': { SSB: 50150000, CW: 50080000, FT8: 50313000, FT4: 50318000, default: 50150000 }, '2m': { SSB: 144300000, CW: 144050000, FT8: 144174000, default: 144300000 }, '70cm': { SSB: 432300000, CW: 432050000, FT8: 432174000, default: 432300000 }, }; function qsyFreqHz(band: string, mode: string): number { const t = QSY_TABLE[band]; if (!t) return 0; return t[mode] ?? t.default ?? 0; } // Digital watering holes (±3 kHz tolerance). When the rig is parked here // CAT typically reports SSB (USB-D under the hood) which is misleading — // auto-promote to the specific digital mode for the entry strip. const DIGITAL_FREQS: { freq: number; mode: string }[] = [ { freq: 1.840, mode: 'FT8' }, { freq: 3.573, mode: 'FT8' }, { freq: 5.357, mode: 'FT8' }, { freq: 7.074, mode: 'FT8' }, { freq: 7.0475, mode: 'FT4' }, { freq: 10.136, mode: 'FT8' }, { freq: 10.140, mode: 'FT4' }, { freq: 14.074, mode: 'FT8' }, { freq: 14.080, mode: 'FT4' }, { freq: 18.100, mode: 'FT8' }, { freq: 18.104, mode: 'FT4' }, { freq: 21.074, mode: 'FT8' }, { freq: 21.140, mode: 'FT4' }, { freq: 24.915, mode: 'FT8' }, { freq: 24.919, mode: 'FT4' }, { freq: 28.074, mode: 'FT8' }, { freq: 28.180, mode: 'FT4' }, { freq: 50.313, mode: 'FT8' }, { freq: 50.318, mode: 'FT4' }, { freq: 144.174, mode: 'FT8' }, ]; function inferDigitalMode(freqHz: number): string { const mhz = freqHz / 1_000_000; for (const d of DIGITAL_FREQS) { if (Math.abs(mhz - d.freq) <= 0.003) return d.mode; } return ''; } // User changed band/mode in the entry strip → push to the rig if CAT is up. // Both calls are fire-and-forget; CAT will reflect back via cat:state. function onBandUserChange(v: string) { setBand(v); noteManualEdit(); if (catState.enabled && catState.connected) { const hz = qsyFreqHz(v, mode); if (hz > 0) SetCATFrequency(hz).catch(() => {}); } } function onModeUserChange(v: string) { setMode(v); applyModePreset(v); noteManualEdit(); if (catState.enabled && catState.connected) { SetCATMode(v).catch(() => {}); } } const userEditedRef = useRef>(new Set()); const lastLookedUpRef = useRef(''); const rstUserEditedRef = useRef(false); const [details, setDetails] = useState(emptyDetails); const updateDetails = useCallback((patch: Partial) => { setDetails((d) => ({ ...d, ...patch })); }, []); // Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree // whenever the band changes. The backend resolves the "default antenna // for this band" within the active profile and returns the (rig, // antenna) tuple. Empty result → we DO clear the fields so leftover // values from a previous band don't get logged against the wrong gear. useEffect(() => { if (!band) return; let cancelled = false; OperatingDefaultForBand(band).then((d) => { if (cancelled) return; setDetails((cur) => ({ ...cur, my_rig: d?.station_name || '', my_antenna: d?.antenna_name || '', tx_pwr: d?.tx_pwr ?? cur.tx_pwr, })); }).catch(() => {}); return () => { cancelled = true; }; }, [band]); const prefix = useMemo(() => computePrefix(callsign), [callsign]); // Bearing/distance from operator's home grid to the remote station — // shown live in the entry strip (SP azimuth) and Info tab (LP + dist). // === Logbook list === const [qsos, setQsos] = useState([]); const [total, setTotal] = useState(0); const [error, setError] = useState(''); // Transient success toast (bottom-right, auto-dismiss). Used for things // like "spot sent" where a blocking error banner would be overkill. const [toast, setToast] = useState(''); const showToast = useCallback((msg: string) => { setToast(msg); window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); }, []); const [saving, setSaving] = useState(false); const [filterCallsign, setFilterCallsign] = useState(''); const [filterBand, setFilterBand] = useState(''); const [filterMode, setFilterMode] = useState(''); const [activeTab, setActiveTab] = useState('recent'); // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. const [qslTabOpen, setQslTabOpen] = useState(false); function closeQslTab() { setQslTabOpen(false); setActiveTab((t) => (t === 'qsl' ? 'recent' : t)); } // Recent QSOs row cap, persisted. With AG Grid's virtual scroller // huge logs render OK once loaded, but a 25k+ logbook still takes a // couple of seconds to round-trip from SQLite at launch. Defaulting // to 500 keeps the first paint instant; the user can bump to "All" // when they actually want to search history. const [qsoLimit, setQsoLimit] = useState(() => { const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500'); return Number.isFinite(raw) && raw > 0 ? raw : 500; }); useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]); // === DX Cluster live state === type ClusterSpot = { source_id: number; source_name: string; spotter: string; dx_call: string; freq_khz: number; freq_hz: number; band?: string; comment?: string; locator?: string; time_utc?: string; country?: string; continent?: string; cqz?: number; ituz?: number; distance_km?: number; sp_deg?: number; lp_deg?: number; received_at: string; raw: string; }; type ServerStatus = { server_id: number; name: string; host: string; port: number; state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'; login?: string; error?: string; spots_count?: number; retries?: number; }; const [clusterServerStatuses, setClusterServerStatuses] = useState([]); // "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up. const [showSpotModal, setShowSpotModal] = useState(false); const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]); // Ring buffer — only keep the last N spots; cluster firehose can be heavy. const [spots, setSpots] = useState([]); const SPOTS_CAP = 1000; const [clusterFilterSource, setClusterFilterSource] = useState(''); const [clusterGroup, setClusterGroup] = useState(true); const [clusterCmd, setClusterCmd] = useState(''); // Multi-band filter: empty set = all bands. The user toggles chips. const [clusterBands, setClusterBands] = useState>(new Set()); // Lock-to-entry: when on, the band filter follows the entry's current // band and the mode filter follows the entry's current mode. const [clusterLockBand, setClusterLockBand] = useState(false); const [clusterLockMode, setClusterLockMode] = useState(false); // Status filter chips. Empty set = show every status (including // already-worked). Otherwise only matching spots pass. type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked'; const [clusterStatusFilter, setClusterStatusFilter] = useState>(new Set()); // Mode filter chips. Empty set = show every mode. Categories map the // inferred per-spot mode onto SSB (phone) / CW / DATA (digital). type SpotModeCat = 'SSB' | 'CW' | 'DATA'; const [clusterModeFilter, setClusterModeFilter] = useState>(new Set()); const [clusterSearch, setClusterSearch] = useState(''); const [showBandMap, setShowBandMap] = useState(false); type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source'; const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' }); // 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>({}); // === Modals === const [editingQSO, setEditingQSO] = useState(null); const [deletingQSO, setDeletingQSO] = useState(null); const [selectedId, setSelectedId] = useState(null); const [showSettings, setShowSettings] = useState(false); // Optional deep-link: which Preferences section to open. Cleared on // close so the next plain "Preferences" launch reverts to default. const [settingsSection, setSettingsSection] = useState(undefined); const [showDeleteAll, setShowDeleteAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); // === ADIF === const [importing, setImporting] = useState(false); const [exporting, setExporting] = useState(false); const [importResult, setImportResult] = useState(null); const [importErrorsOpen, setImportErrorsOpen] = useState(false); const [importDupsOpen, setImportDupsOpen] = useState(false); // ADIF import confirmation: after the user picks a file, hold the path // until they confirm the options (skip duplicates etc.). const [pendingImportPath, setPendingImportPath] = useState(null); const [importSkipDups, setImportSkipDups] = useState(true); // === Lookup + WB === const [lookupResult, setLookupResult] = useState(null); const [lookupBusy, setLookupBusy] = useState(false); const [lookupError, setLookupError] = useState(''); const lookupTimerRef = useRef(null); const wbTimerRef = useRef(null); const [wb, setWb] = useState(null); const [wbBusy, setWbBusy] = useState(false); // === Station === const [station, setStation] = useState({ callsign: '', operator: '', my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', }); // === Clock === const [utcNow, setUtcNow] = useState(''); useEffect(() => { function tick() { const d = new Date(); const p = (n: number) => String(n).padStart(2, '0'); setUtcNow(`${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`); } tick(); const id = window.setInterval(tick, 1000); return () => window.clearInterval(id); }, []); const refresh = useCallback(async () => { try { const list = await ListQSO({ callsign: filterCallsign, band: filterBand, mode: filterMode, limit: qsoLimit, offset: 0, } as any); const n = await CountQSO(); setQsos(list); setTotal(n); setError(''); } catch (e: any) { setError(String(e?.message ?? e)); } }, [filterCallsign, filterBand, filterMode, qsoLimit]); // Refresh the Recent QSOs grid after external-service uploads stamp the // sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via // qslmgr:done). Debounced so a batch of per-QSO events triggers one reload. useEffect(() => { let t: number | undefined; const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); }; const offUploaded = EventsOn('extsvc:uploaded', ping); const offDone = EventsOn('qslmgr:done', ping); return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); }; }, [refresh]); // Poll PstRotator for the live antenna heading (status bar). Cheap when the // rotator is disabled (the backend just reads settings and returns). useEffect(() => { let alive = true; const tick = async () => { try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {} }; tick(); const id = window.setInterval(tick, 3000); return () => { alive = false; window.clearInterval(id); }; }, []); // RX band auto-follows the TX band (only differs for cross-band work). useEffect(() => { setBandRx(band); }, [band]); // RX freq mirrors TX freq on every TX change (unless the rig is in split, // where the RX freq is genuinely different). It stays editable by hand: // a manual RX edit sticks until the next TX-freq change re-syncs it. useEffect(() => { if (!catState.split) setRxFreqMhz(freqMhz); }, [freqMhz, catState.split]); // Load the DXCC country list for the Country picker. cty.dat loads a few // seconds after startup, so retry until it's available. useEffect(() => { let tries = 0; let timer = 0; const load = async () => { try { const c = await ListCountries(); if (c && c.length) { setCountries(c); return; } } catch {} if (tries++ < 15) timer = window.setTimeout(load, 2000); }; load(); return () => { if (timer) window.clearTimeout(timer); }; }, []); const loadStation = useCallback(async () => { try { setStation(await GetStationSettings()); } catch {} }, []); const loadCATCfg = useCallback(async () => { try { const c = await GetCATSettings(); if (c.digital_default) digitalDefaultRef.current = c.digital_default; } catch {} }, []); const loadLists = useCallback(async () => { try { const l: ListsSettings = await GetListsSettings(); setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] }); if (l.bands && l.bands.length) setBands(l.bands); if (l.modes && l.modes.length) { setModePresets(l.modes); const names = l.modes.map((m) => m.name); setModes(names); setMode((cur) => names.includes(cur) ? cur : names[0]); const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0]; if (preset && !rstUserEditedRef.current) { if (preset.default_rst_sent) setRstSent(preset.default_rst_sent); if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd); } } } catch {} // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function applyModePreset(m: string) { if (rstUserEditedRef.current) return; const p = modePresets.find((x) => x.name === m); if (!p) return; if (p.default_rst_sent) setRstSent(p.default_rst_sent); if (p.default_rst_rcvd) setRstRcvd(p.default_rst_rcvd); } useEffect(() => { refresh(); }, [refresh]); useEffect(() => { (async () => { try { const st = await GetStartupStatus(); if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; } } catch {} loadStation(); loadLists(); loadCATCfg(); // Hydrate CAT state on mount (the backend may already be polling). try { setCatState(await GetCATState()); } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // CAT live updates. Push freq/band/mode into the entry strip when the rig // moves, unless the user just typed something (1.5s grace window). useEffect(() => { const unsub = EventsOn('cat:state', (s: CATState) => { setCatState(s); if (!s?.connected) return; if (Date.now() < catFreezeUntilRef.current) return; const lk = locksRef.current; if (!lk.freq && s.freq_hz && s.freq_hz > 0) { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); } // RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX. // In split we take the rig's real RX freq; otherwise RX mirrors TX // (the user can still override it by hand). The freq lock covers both. if (!lk.freq) { if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) { setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5)); } else if (s.freq_hz && s.freq_hz > 0) { setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); } } if (!lk.band && s.band) setBand(s.band); // Mode resolution priority: // 1. If freq matches a known digital watering hole, pick the specific // mode for that hole (FT8 / FT4) — beats whatever CAT reports. // 2. Else if CAT reports DATA (generic), use the user's configured // default digital mode (FT8 by default). // 3. Else trust CAT (SSB, CW, AM, FM…). if (!lk.mode) { const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; if (inferred) { setMode(inferred); } else if (s.mode === 'DATA') { setMode(digitalDefaultRef.current || 'FT8'); } else if (s.mode) { setMode(s.mode); } } }); return () => { unsub?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Cluster live wiring: hydrate per-server status + saved server list, // then subscribe to push events. async function reloadClusterMeta() { try { const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]); setClusterServerStatuses((st ?? []) as ServerStatus[]); setClusterServers(((list ?? []) as any[]).map((s) => ({ id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0, }))); } catch {} } useEffect(() => { reloadClusterMeta(); // 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. const unsubState = 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))); }); const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { setSpots((arr) => { const next = [sp, ...arr]; return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next; }); }); return () => { unsubState?.(); unsubSpot?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── UDP integration events ─────────────────────────────────────────── // Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…). // We push the broadcast DX call into the entry field and auto-log any // ADIF record that arrives. useEffect(() => { const unsubDX = EventsOn('udp:dx_call', (p: any) => { const call = String(p?.call ?? '').trim(); if (!call) return; // Don't clobber what the user is currently typing — only update // when the entry field is empty or matches a previous broadcast. onCallsignInput(call); }); const unsubRC = EventsOn('udp:remote_call', (call: string) => { if (call) onCallsignInput(String(call).trim()); }); const unsubLog = EventsOn('udp:logged_qso', async (p: any) => { const text = String(p?.adif ?? '').trim(); if (!text) return; try { await LogUDPLoggedADIF(text); await refresh(); } catch (e: any) { setError('UDP auto-log: ' + String(e?.message ?? e)); } }); return () => { unsubDX?.(); unsubRC?.(); unsubLog?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Resolve slot status for any spot we haven't seen yet — debounced so we // don't hammer the backend at firehose rate. The mode passed to the // backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the // band-plan fallback, NOT just digital watering-hole detection — that's // how CW spots get correctly classified instead of being labelled // "new-slot" because the lookup key carried mode="". useEffect(() => { const t = window.setTimeout(async () => { const unknown: { call: string; band: string; mode: string }[] = []; const seen = new Set(); for (const s of spots) { const mode = inferSpotMode(s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); if (seen.has(k) || spotStatus[k]) continue; seen.add(k); unknown.push({ call: s.dx_call, band: s.band ?? '', mode }); } if (unknown.length === 0) return; try { const res = await ClusterSpotStatuses(unknown as any); setSpotStatus((prev) => { 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, continent: (r as any).continent, worked_call: !!(r as any).worked_call, }; } return next; }); } catch {} }, 400); return () => window.clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [spots]); async function save() { if (!callsign.trim()) { setError('Callsign required'); return; } setSaving(true); setError(''); try { const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined; const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined; const now = new Date(); const start = qsoStartedAt ?? now; const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now; const payload: any = { callsign: callsign.trim().toUpperCase(), qso_date: start.toISOString(), qso_date_off: end.toISOString(), band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz, rst_sent: rstSent, rst_rcvd: rstRcvd, grid: grid.trim().toUpperCase(), name, qth, country, comment, notes: note, state: details.state, cnty: details.cnty, address: details.address, lat: details.lat, lon: details.lon, dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, cont: details.cont || undefined, qsl_msg: details.qsl_msg, qsl_via: details.qsl_via, ant_az: details.ant_az, ant_el: details.ant_el, ant_path: details.ant_path, prop_mode: details.prop_mode, my_rig: details.my_rig, my_antenna: details.my_antenna, tx_pwr: details.tx_pwr, sat_name: details.sat_name, sat_mode: details.sat_mode, contest_id: details.contest_id, srx: details.srx, stx: details.stx, email: details.email, }; await AddQSO(payload); resetEntry(); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setSaving(false); } } // resetEntry clears the form for the next QSO. Triggered after a // successful log AND by ESC. Locked values (band/mode/freq/start/end) // are preserved so backdated batches stay productive. function resetEntry() { setCallsign(''); setComment(''); setNote(''); if (!locks.start) setQsoStartedAt(null); if (!locks.end) setQsoEndedAt(null); resetAutoFill(); setLookupError(''); rstUserEditedRef.current = false; applyModePreset(mode); setDetails((d) => ({ ...d, state: '', cnty: '', address: '', lat: undefined, lon: undefined, dxcc: undefined, cqz: undefined, ituz: undefined, cont: '', qsl_msg: '', qsl_via: '', contest_id: '', srx: undefined, stx: undefined, email: '', })); } function resetAutoFill() { setName(''); setQth(''); setCountry(''); setGrid(''); setWb(null); setLookupResult(null); setDetails((d) => ({ ...d, state: '', cnty: '', address: '', lat: undefined, lon: undefined, dxcc: undefined, cqz: undefined, ituz: undefined, cont: '', qsl_via: '', email: '', })); userEditedRef.current.clear(); lastLookedUpRef.current = ''; setLookupResult(null); } async function openEdit(id: number) { try { setEditingQSO(await GetQSO(id)); } catch (e: any) { setError(String(e?.message ?? e)); } } async function onModalSave(q: QSO) { try { await UpdateQSO(q as any); setEditingQSO(null); await refresh(); } catch (err: any) { setError(String(err?.message ?? err)); } } function onModalDelete(id: number) { const q = editingQSO; setEditingQSO(null); if (q) setDeletingQSO(q); else askDelete(id); } function askDelete(id: number) { const q = qsos.find((x) => x.id === id); if (q) setDeletingQSO(q); } async function confirmDelete() { if (!deletingQSO) return; try { await DeleteQSO(deletingQSO.id); if (selectedId === deletingQSO.id) setSelectedId(null); setDeletingQSO(null); await refresh(); } catch (err: any) { setError(String(err?.message ?? err)); setDeletingQSO(null); } } async function confirmDeleteAll() { if (deletingAll) return; setDeletingAll(true); try { await DeleteAllQSO(); setSelectedId(null); setShowDeleteAll(false); await refresh(); } catch (err: any) { setError(String(err?.message ?? err)); } finally { setDeletingAll(false); } } async function runWorkedBefore(call: string, dxccHint: number = 0) { setWbBusy(true); try { setWb(await WorkedBefore(call, dxccHint)); } catch { setWb(null); } finally { setWbBusy(false); } } async function runLookup(call: string) { if (call !== lastLookedUpRef.current) resetAutoFill(); setLookupBusy(true); try { const r = await LookupCallsign(call); setLookupResult(r); lastLookedUpRef.current = call; const ue = userEditedRef.current; if (!ue.has('name')) setName(r.name ?? ''); if (!ue.has('qth')) setQth(r.qth ?? ''); if (!ue.has('country')) setCountry(r.country ?? ''); if (!ue.has('grid')) setGrid(r.grid ?? ''); setDetails((d) => ({ ...d, address: d.address || (r.address ?? ''), state: d.state || (r.state ?? ''), cnty: d.cnty || (r.cnty ?? ''), lat: d.lat ?? (r.lat || undefined), lon: d.lon ?? (r.lon || undefined), dxcc: d.dxcc ?? (r.dxcc || undefined), cqz: d.cqz ?? (r.cqz || undefined), ituz: d.ituz ?? (r.ituz || undefined), cont: d.cont || (r.cont ?? ''), email: d.email || (r.email ?? ''), qsl_via: d.qsl_via || (r.qsl_via ?? ''), })); if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc); } catch (e: any) { setLookupResult(null); setLookupError(String(e?.message ?? e)); } finally { setLookupBusy(false); } } function scheduleLookup(value: string) { setLookupError(''); if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current); if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current); const call = value.trim().toUpperCase(); if (call.length < 3) { setLookupResult(null); setWb(null); if (lastLookedUpRef.current !== '') resetAutoFill(); return; } lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400); wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); } function onCallsignInput(v: string) { const wasEmpty = callsign.trim() === ''; const isEmpty = v.trim() === ''; if (wasEmpty && !isEmpty && !locks.start) { // First keystroke of a new QSO — freeze the start time so it doesn't // drift even if the lookup or typing takes 30 seconds. Skip when // start is locked: the user is back-entering a past QSO and set a // specific time manually. setQsoStartedAt(new Date()); } else if (isEmpty && !locks.start) { // Callsign wiped → user abandoned this QSO; reset the timer. setQsoStartedAt(null); } setCallsign(v); scheduleLookup(v); } function markEdited(field: string) { userEditedRef.current.add(field); } async function importAdif() { if (importing) return; setError(''); try { const path = await OpenADIFFile(); if (!path) return; // Stash the path and open the options dialog. The actual import // is fired from runImport() when the user clicks "Import". setPendingImportPath(path); } catch (e: any) { setError(String(e?.message ?? e)); } } async function exportAdif() { if (exporting) return; setError(''); try { const path = await SaveADIFFile(); if (!path) return; setExporting(true); const res = await ExportADIF(path); // Reuse the error banner area for a brief success note (4s auto-dismiss). const msg = `ADIF exported: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`; setError(msg); setTimeout(() => setError((e) => e === msg ? '' : e), 4000); } catch (e: any) { setError(`ADIF export failed: ${String(e?.message ?? e)}`); } finally { setExporting(false); } } async function runImport() { const path = pendingImportPath; if (!path || importing) return; setPendingImportPath(null); setImporting(true); setImportResult(null); setImportErrorsOpen(false); setImportDupsOpen(false); try { const res = await ImportADIF(path, importSkipDups); setImportResult(res); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setImporting(false); } } const menus: Menu[] = useMemo(() => [ { name: 'file', label: 'File', items: [ { type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' }, { type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 }, { type: 'separator' }, { type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 }, { type: 'separator' }, { type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true }, ]}, { name: 'edit', label: 'Edit', items: [ { type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null }, { type: 'separator' }, { type: 'item', label: 'Preferences…', action: 'edit.prefs' }, ]}, { name: 'view', label: 'View', items: [ { type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' }, { type: 'item', label: 'Clear filters', action: 'view.clearfilters' }, ]}, { name: 'tools', label: 'Tools', items: [ { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' }, { type: 'separator' }, { type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' }, { type: 'item', label: 'CAT interface…', action: 'tools.cat' }, { type: 'item', label: 'Rotator…', action: 'tools.rotator' }, { type: 'separator' }, // Maintenance — bumped here while we only have one entry. Will move // to a Tools → Maintenance submenu once Clublog + LoTW refresh land. { type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing }, ]}, { name: 'help', label: 'Help', items: [ { type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true }, ]}, ], [total, selectedId, ctyRefreshing, exporting]); function handleMenu(action: string) { switch (action) { case 'file.import': importAdif(); break; case 'file.export': exportAdif(); break; case 'file.deleteall': setShowDeleteAll(true); break; case 'view.refresh': refresh(); break; case 'view.clearfilters': setFilterCallsign(''); setFilterBand(''); setFilterMode(''); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break; case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break; case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break; case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break; case 'tools.refreshCty': refreshCtyDat(); break; } } async function refreshCtyDat() { if (ctyRefreshing) return; setCtyRefreshing(true); setError(''); try { const info = await RefreshCtyDat(); // Use the regular error banner area for a brief success note — keeps // us from pulling in a toast system just for one maintenance action. setError(`cty.dat refreshed — ${info.entities} entities loaded`); setTimeout(() => setError((e) => e.startsWith('cty.dat refreshed') ? '' : e), 4000); } catch (e: any) { setError(`cty.dat refresh failed: ${String(e?.message ?? e)}`); } finally { setCtyRefreshing(false); } } useEffect(() => { function onKey(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; const typing = tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'; if (e.key === 'F5') { e.preventDefault(); refresh(); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') { e.preventDefault(); importAdif(); return; } if (typing) return; if (selectedId !== null) { if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; } if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; } } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, refresh]); return (
{/* ===== TOPBAR ===== */} {compact ? ( // Minimal compact topbar — brand + freq + toggle. Saves vertical space // so the single-row entry strip fits in a ~140px tall window.
OpsLog
{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'} MHz {band} {mode}
{utcNow}Z
{station.callsign && ( {station.callsign} )}
) : (
OpsLog v0.1
{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'} {catState.split && rxFreqMhz && ( RX {fmtFreqDots(rxFreqMhz)} )}
MHz {catState.split && ( SPLIT )} {/* Band & mode removed here — shown in the QSO entry strip below. */} {catState.enabled && catState.backend === 'omnirig' && (
{[1, 2].map((n) => { const active = (catState.rig_num || 1) === n; return ( ); })}
)}
{/* Bearing controls — three separate buttons so SP and LP are both directly clickable, plus an always-visible Stop. The old Shift/Ctrl shortcuts were not discoverable enough. */} {(() => { const p = pathBetween(station.my_grid, grid); const disabled = !p; const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err))); return (
); })()}
{utcNow}UTC
{station.callsign ? ( ) : ( )}
)} {error && (
{error}
)} {/* Transient success toast (bottom-right). */} {toast && (
{toast}
)} {/* ===== ENTRY STRIP ===== Enter from any inside the strip logs the QSO. Radix Selects render as )} {lookupBusy && Looking up…} {!lookupBusy && lookupResult && ( {lookupResult.source} )} {!lookupBusy && !lookupResult && lookupError && ( {lookupError} )} {/* Contacted entity flag (from its DXCC number). */} {flagURL(details.dxcc) && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )} onCallsignInput(e.target.value)} />
{ setRstSent(v); rstUserEditedRef.current = true; }} />
{ setRstRcvd(v); rstUserEditedRef.current = true; }} />
{ setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); setStartFocused(true); }} onBlur={() => { setStartFocused(false); if (startInputStr.trim() === '') { if (locks.start) setQsoStartedAt(null); return; } setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date())); }} onChange={(e) => setStartInputStr(e.target.value)} placeholder="HH:MM:SS" className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')} />
{ setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }} onBlur={() => { setEndFocused(false); if (endInputStr.trim() === '') { if (locks.end) setQsoEndedAt(null); return; } setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date())); }} onChange={(e) => setEndInputStr(e.target.value)} placeholder="HH:MM:SS" className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')} />
{/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */} {!compact && <>
{ setName(e.target.value); markEdited('name'); }} />
{ setQth(e.target.value); markEdited('qth'); }} />
{ setGrid(e.target.value); markEdited('grid'); }} />
{ setCountry(v); markEdited('country'); }} />
{/* DXCC # and Continent are derived from the callsign — read-only. CQ/ITU stay editable but as plain text (no number spinners). Kept compact (Log4OM-style) — just wide enough for their digits. */}
{ const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
{ const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
} {/* ── Row 3: Freq + Band + Mode + Band RX + RX Freq ── */}
setFreqFocused(true)} onBlur={() => setFreqFocused(false)} onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }} />
setFreqFocused(true)} onBlur={() => setFreqFocused(false)} onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }} className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')} />
{/* ── Row 4: Comment + Note ── */}
setComment(e.target.value)} />
{!compact && (
setNote(e.target.value)} />
)}
{/* Send DX spot — only when a cluster is connected. Pre-fills the dialog from the current entry (or the last logged QSO). */} {clusterServerStatuses.some((s) => s.state === 'connected') && ( )}
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail tabs, then reserved free space. Hidden in compact mode. */} {!compact && (
)} {/* Reserved free space to the right — shows the QRZ profile photo large so it's actually legible. Click opens the full-size image on QRZ. */} {!compact && lookupResult?.image_url && (
)}
{/* /entry + aside row */} {/* ===== LOWER: tabs+table | call history | (optional) band map ===== */} {compact ? null : <>
Main Recent QSOs {qsos.length} Cluster Worked before {wb && wb.count > 0 && ( {wb.count} )} Awards Propagation {qslTabOpen && ( QSL Manager { e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); closeQslTab(); }} > )}
setFilterCallsign(e.target.value)} />
{importResult && (
0 ? 'bg-amber-50 border-amber-300 text-amber-800' : 'bg-emerald-50 border-emerald-300 text-emerald-800', )}>
Import complete. {importResult.imported} imported {importResult.duplicates > 0 && ( {importResult.duplicates} duplicates )} {importResult.skipped} skipped {importResult.total} total {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && ( )} {importResult.errors && importResult.errors.length > 0 && ( )}
{importDupsOpen && importResult.duplicate_samples && (
    {importResult.duplicate_samples.map((d, i) =>
  • {d}
  • )}
)} {importErrorsOpen && importResult.errors && (
    {importResult.errors.map((e, i) =>
  • {e}
  • )}
)}
)} openEdit(q.id as number)} onRowSelected={(id) => setSelectedId(id)} />
Showing {qsos.length} of{' '} {total} {filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
{qsos.length >= qsoLimit && qsos.length < total && ( Limit reached — raise Max to see more. )} { const n = Number(e.target.value); if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n)); }} />
{/* Row 1: actions + per-server pills */}
{clusterServerStatuses.length === 0 && ( No active sessions — configure clusters in Settings → DX Cluster. )} {clusterServerStatuses.map((s) => { const isMaster = clusterServers .filter((x) => x.enabled) .sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id; return ( {isMaster && } {s.name} {s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''} ); })}
{spots.length} live
{/* Row 2: filters */}
setClusterSearch(e.target.value.toUpperCase())} /> Bands: {bands.map((b) => { const on = clusterBands.has(b); return ( ); })} {clusterBands.size > 0 && ( )}
Status: {([ { k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' }, { k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' }, { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, { k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' }, ]).map((s) => { const on = clusterStatusFilter.has(s.k); return ( ); })}
Mode: {([ { k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' }, { k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' }, { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' }, ]).map((s) => { const on = clusterModeFilter.has(s.k); return ( ); })}
{(() => { // Apply every filter. `bandsActive` is the band set the // user clicked, OR the entry's locked band when Lock band // is on. Mode lock compares the spot's inferred mode to // the entry's current one. const bandsActive = clusterLockBand ? new Set([band]) : clusterBands; const search = clusterSearch.trim().toUpperCase(); let list = spots.filter((s) => { if (clusterFilterSource && s.source_id !== clusterFilterSource) return false; if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false; if (search && !s.dx_call.includes(search)) return false; if (clusterLockMode) { const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz); if (spotMode && mode && spotMode !== mode) return false; } if (clusterModeFilter.size > 0) { const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)); if (!cat || !clusterModeFilter.has(cat)) return false; } if (clusterStatusFilter.size > 0) { const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const st = spotStatus[k]?.status || ''; if (!clusterStatusFilter.has(st as SpotStatusKey)) return false; } return true; }); let rendered = list as (ClusterSpot & { repeats?: number })[]; if (clusterGroup) { const seen = new Map(); for (const s of list) { const e = seen.get(s.dx_call); if (e) { e.repeats++; } else seen.set(s.dx_call, { ...s, repeats: 1 }); } rendered = Array.from(seen.values()); } if (rendered.length === 0) { return (
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Spots will appear as the cluster sends them.' : 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
); } return ( { const m = inferSpotMode(s.comment ?? '', s.freq_hz); if (catState.connected) { SetCATFrequency(s.freq_hz).catch(() => {}); if (m) SetCATMode(m).catch(() => {}); } else { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); if (s.band) setBand(s.band); if (m) setMode(m); } onCallsignInput(s.dx_call); }} /> ); })()} {/* Command input — sends to the master server. */}
→ master setClusterCmd(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && clusterCmd.trim()) { SendClusterCommand(clusterCmd.trim()) .then(() => setClusterCmd('')) .catch((err) => setError(String(err?.message ?? err))); } }} />
{/* /left column */} {/* BandMap moved to a global side panel below — toggle is now in the topbar, visible on every tab. */} {/* Opened on demand from Tools → QSL Manager; closable via the tab's × . forceMount keeps its state (and a running download updating) while you work on other tabs. */} {qslTabOpen && ( )} {(['main','awards','propagation'] as const).map((t) => (
{t[0].toUpperCase() + t.slice(1)}
Module coming soon.
))}
{showBandMap && (
s.band === band)} spotStatus={spotStatus} currentFreqHz={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} onSpotClick={(s) => { const m = inferSpotMode(s.comment ?? '', s.freq_hz); if (catState.connected) { SetCATFrequency(s.freq_hz).catch(() => {}); if (m) SetCATMode(m).catch(() => {}); } else { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); if (s.band) setBand(s.band); if (m) setMode(m); } onCallsignInput(s.dx_call); }} onClose={() => setShowBandMap(false)} />
)}
} {/* ===== STATUS BAR ===== */} {!compact && (() => { const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected'); const catUp = catState.enabled && catState.connected; const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => ( ); return (
QSO count {total.toLocaleString('en-US')}
setActiveTab('cluster')} /> {catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}} title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')} onClick={() => { setSettingsSection('cat'); setShowSettings(true); }} /> { setSettingsSection('rotator'); setShowSettings(true); }} />
); })()} {editingQSO && ( setEditingQSO(null)} /> )} setShowSpotModal(false)} // Callsign: the QSO-entry call, else the last logged QSO. defaultCall={callsign.trim() || qsos[0]?.callsign || ''} // Freq: the entry TX freq (kHz), else the last logged QSO's. defaultFreqKHz={ parseFloat(freqMhz) > 0 ? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10 : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0) } defaultMode={mode || qsos[0]?.mode || ''} targetName={ clusterServers .filter((s) => s.enabled) .sort((a, b) => a.sort_order - b.sort_order)[0]?.name } recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({ callsign: q.callsign, freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0, mode: q.mode ?? '', band: q.band, }))} onSend={async (call, freqKHz, comment) => { await SendClusterSpot(call, freqKHz, comment); const target = clusterServers .filter((s) => s.enabled) .sort((a, b) => a.sort_order - b.sort_order)[0]?.name; showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`); }} /> {showSettings && ( { setShowSettings(false); setSettingsSection(undefined); }} onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }} /> )} {deletingQSO && ( setDeletingQSO(null)} /> )} {showDeleteAll && ( { if (!deletingAll) setShowDeleteAll(false); }} /> )} {pendingImportPath && ( { if (!o) setPendingImportPath(null); }}> Import ADIF {pendingImportPath}
)}
); }