import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected, ContestDupe, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, GetStartupStatus, CheckForUpdate, WorkedBefore, SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, GetSecretStatus, UnlockSecrets, RefreshCtyDat, DownloadAllReferenceLists, RotatorGoTo, RotatorStop, GetRotatorHeading, GetDBConnectionInfo, GetLogbookRevision, GetUltrabeamStatus, SetUltrabeamDirection, GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, GetCATSettings, OperatingDefaultForBand, LogUDPLoggedADIF, ListCountries, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, GetAwardDefs, GetUIPref, ReportLiveActivity, AwardRefsForQSOs, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs } from '@/lib/awardRefs'; import { EventsOn, BrowserOpenURL } 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 { APP_VERSION, APP_AUTHOR } from '@/version'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { FirstRunModal } from '@/components/FirstRunModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; import { WorldMap, LocatorMap } from '@/components/MainMap'; import { FlexPanel } from '@/components/FlexPanel'; import { IcomPanel } from '@/components/IcomPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { AwardsPanel } from '@/components/AwardsPanel'; 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 { NetControlPanel } from '@/components/NetControlPanel'; import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel'; import { DuplicatesModal } from '@/components/DuplicatesModal'; import { useI18n } from '@/lib/i18n'; import { AlertsModal } from '@/components/AlertsModal'; import { BulkEditModal } from '@/components/BulkEditModal'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { RotorCompass } from '@/components/RotorCompass'; import { writeUiPref } from '@/lib/uiPref'; import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel'; 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, pathBetweenLatLon, gridToLatLon, latLonToGrid } 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']; // Modes the QSO recorder captures (phone only). Mirrors recordableMode() in // app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex, // so neither is recorded (no REC badge / timer for them). const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']); 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_string: '', stx_string: '', email: '', award_refs: '', }; 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'; } // estimateCwMs roughly estimates how long the resolved text takes to send in CW // at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm // ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can // lag by tens of seconds over a remote/serial-over-IP link. function estimateCwMs(resolved: string, wpm: number): number { const w = Math.max(5, wpm || 25); const chars = resolved.replace(/\s/g, '').length; const spaces = (resolved.match(/\s/g) || []).length; return ((chars * 10 + spaces * 4) * 1200) / w; } // exchangeFields splits a contest exchange the operator typed as free text into // the ADIF numeric field (SRX/STX, when it's all digits) or the string field // (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone). function exchangeFields(raw?: string): { num?: number; str: string } { const t = (raw || '').trim(); if (t === '') return { str: '' }; return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t }; } // 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() { const { t } = useI18n(); // === 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 }; if (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 (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); } } else { // Locking (manual / deferred entry) → pre-fill with today's date + the // current UTC time so the fields aren't empty; the operator just adjusts. const now = new Date(); if (k === 'start') setQsoStartedAt((d) => d ?? now); else if (k === 'end') setQsoEndedAt((d) => d ?? now); } 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(''); // Contest mode: a picked contest + exchange config; persisted so a running // contest (serial counter included) survives a restart. `rcv` is the received // exchange for the current QSO. const [contest, setContest] = useState(CONTEST_DEFAULT); const [rcv, setRcv] = useState(''); const [contestDupe, setContestDupe] = useState(false); 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 }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false }); const [agStatus, setAgStatus] = useState({ connected: false, port_a: 0, port_b: 0, antennas: [] }); const [agEnabled, setAgEnabled] = useState(false); // Per-port optimistic selection that the status poll must not revert until the // device confirms it (or it expires) — otherwise a stale poll right after a // click reverts the UI and the click looks like it did nothing. const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({}); const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); // 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. // When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do // NOT drive the radio — the lock means "this value is decoupled from the rig". function onBandUserChange(v: string) { setBand(v); noteManualEdit(); if (catState.enabled && catState.connected && !locks.band && !locks.freq) { 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 && !locks.mode) { SetCATMode(v).catch(() => {}); } } const userEditedRef = useRef>(new Set()); const lastLookedUpRef = useRef(''); // Tracks the call we last auto-switched to the Worked-before tab for, so we // don't keep yanking the tab on every wb refresh of the same callsign. const lastWbFocusRef = 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]); // === Logbook list === const [qsos, setQsos] = useState([]); const [total, setTotal] = useState(0); const [error, setError] = useState(''); // Secret vault (encrypted passwords): prompt to unlock at launch when a // passphrase is configured but not yet entered this session. const [unlockOpen, setUnlockOpen] = useState(false); const [unlockPass, setUnlockPass] = useState(''); const [unlockErr, setUnlockErr] = useState(''); const [unlockBusy, setUnlockBusy] = useState(false); const doUnlock = async () => { setUnlockBusy(true); setUnlockErr(''); try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); } catch (e: any) { setUnlockErr(String(e?.message ?? e)); } finally { setUnlockBusy(false); } }; // Transient success toast (bottom-right, auto-dismiss). Used for things // like "spot sent" where a blocking error banner would be overkill. // Toasts are QUEUED so two firing at once don't clobber each other: each shows // for 3s, then the next takes over (no gap). `toast` is the one on screen now. const [toast, setToast] = useState(''); const toastQueue = useRef([]); const toastTimer = useRef(undefined); const advanceToast = useCallback(() => { const next = toastQueue.current.shift(); if (next === undefined) { toastTimer.current = undefined; setToast(''); return; } setToast(next); toastTimer.current = window.setTimeout(advanceToast, 3000); }, []); const showToast = useCallback((msg: string) => { toastQueue.current.push(msg); if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now }, [advanceToast]); const dismissToast = useCallback(() => { if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; } advanceToast(); // skip to the next queued toast (or clear if none) }, [advanceToast]); // Error banners auto-dismiss after a few seconds (longer than toasts since // they may be multi-line). The X button still closes them immediately. useEffect(() => { if (!error) return; const t = window.setTimeout(() => setError(''), 6000); return () => window.clearTimeout(t); }, [error]); // True while the QSO recorder is capturing the current contact (set when we // leave the callsign field, cleared on log/cancel). Drives the REC badge. const [recording, setRecording] = useState(false); // Elapsed recording time (seconds) shown next to the red dot, ticking once a // second while a recording is in progress. const [recSeconds, setRecSeconds] = useState(0); // Bumped on every (re)start so the timer resets even when `recording` was // already true (jumping spot→spot keeps recording=true but starts a fresh take). const [recTick, setRecTick] = useState(0); useEffect(() => { if (!recording) { setRecSeconds(0); return; } const start = Date.now(); setRecSeconds(0); const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000); return () => window.clearInterval(id); }, [recording, recTick]); // The callsign the in-progress recording belongs to (uppercased; '' = none). // Lets us restart from zero when the operator edits the call to a different // station mid-recording, instead of continuing the old take. const recordingCallRef = useRef(''); // restartRecordingForNewTarget (re)starts the take for a new target (clicked // spot / external app / edited callsign) and resets the elapsed timer. const restartRecordingForNewTarget = (forCall?: string) => { if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase(); QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {}); }; // Reset the recording to zero (drop everything so far, pre-roll included) — // bound to clicking the REC timer. Use when the station was already in a long // QSO and you only want your own exchange in the file. const resetRecordingClock = () => { QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {}); }; const [saving, setSaving] = useState(false); const [filterCallsign, setFilterCallsign] = useState(''); // Advanced filter builder (replaces the old band/mode dropdowns). const [filterOpen, setFilterOpen] = useState(false); const [activeFilter, setActiveFilter] = useState({ conditions: [], match: 'AND' }); const [matchCount, setMatchCount] = useState(null); const [activeTab, setActiveTab] = useState('recent'); // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. const [qslTabOpen, setQslTabOpen] = useState(false); const [qslDesignerOpen, setQslDesignerOpen] = useState(false); const [eqslQsoId, setEqslQsoId] = useState(null); // QSO being sent as eQSL 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(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]); // Contest session: load once, then persist on every change (merge + save). useEffect(() => { GetUIPref('opslog.contest').then((s) => { if (s) { try { const c = { ...CONTEST_DEFAULT, ...JSON.parse(s) }; setContest(c); if (c.active) setContestTabEnabled(true); } catch {} } }).catch(() => {}); }, []); const updateContest = (patch: Partial) => { setContest((c) => { const next = { ...c, ...patch }; writeUiPref('opslog.contest', JSON.stringify(next)); return next; }); }; // Contest dupe check: is the typed callsign already worked in this contest on // the current band + mode? Debounced so it doesn't fire on every keystroke. useEffect(() => { if (!contest.active || !contest.code || !callsign.trim()) { setContestDupe(false); return; } const call = callsign.trim().toUpperCase(); let alive = true; const t = window.setTimeout(() => { ContestDupe(call, contest.code, band, mode, contest.startISO).then((d) => { if (alive) setContestDupe(!!d); }).catch(() => {}); }, 250); return () => { alive = false; window.clearTimeout(t); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [callsign, contest.active, contest.code, band, mode, contest.startISO]); // === 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); // Holds our station callsign for the (one-shot) cluster spot listener, so a // self-spot can be surfaced in the shared header toast. const myCallRef = useRef(''); // === WinKeyer CW keyer === const [wkEnabled, setWkEnabled] = useState(false); const [wkPort, setWkPort] = useState(''); const [wkWpm, setWkWpm] = useState(25); const [wkMacros, setWkMacros] = useState([]); const [wkPorts, setWkPorts] = useState([]); const [wkStatus, setWkStatus] = useState({ connected: false, busy: false, wpm: 25, version: 0, port: '' }); const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed // CW keyer output engine (persisted in the WinKeyer settings, chosen in // Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros, // auto-call and are shared; only the transport differs. const [wkEngine, setWkEngine] = useState('winkeyer'); const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer'; const cwSourceRef = useRef(cwSource); useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]); // CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to // actually transmit. Exposed in the CW keyer panel when the engine is Icom. const [icomBreakIn, setIcomBreakIn] = useState(0); const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []); // Seed the current break-in from the rig when the CW panel becomes active in // Icom mode (so the control reflects the radio's real state). useEffect(() => { if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return; GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {}); }, [cwSource, wkEnabled, catState.backend, catState.connected]); // Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds) // until a reply is entered or it's stopped. Persisted as UI prefs. const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1'); const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3); const wkAutoCallRef = useRef(wkAutoCall); const wkAutoCallSecsRef = useRef(wkAutoCallSecs); const autoCallGenRef = useRef(0); // bump to cancel the running loop const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none) useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]); useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]); // F1-F12 macro shortcuts active only when the keyer is enabled + connected. const wkActiveRef = useRef(false); const wkEscClearsRef = useRef(true); const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the wait-then-log useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]); useEffect(() => { const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected; wkActiveRef.current = wkEnabled && connected; }, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]); useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); // === Digital Voice Keyer (DVK) === // CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the // mode is CW. The decoded text appears in a strip above the tabs. const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1'); const [cwText, setCwText] = useState(''); const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false }); const cwOn = cwEnabled && mode === 'CW'; // Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar). const cwScrollRef = useRef(null); useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]); // Manual pitch override ('' = Auto: follow the radio's CW pitch / search). const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || ''); useEffect(() => { const hz = parseInt(cwPitch, 10); SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {}); localStorage.setItem('opslog.cwPitch', cwPitch); }, [cwPitch, cwOn]); useEffect(() => { const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200))); const offS = EventsOn('cw:status', (st: any) => setCwStatus(st)); const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); }); return () => { offT?.(); offS?.(); offE?.(); }; }, []); // Start/stop the backend decoder as the (enabled, mode) combination changes. useEffect(() => { if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); } else { StopCWDecoder().catch(() => {}); } }, [cwOn]); function toggleCwDecoder() { setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; }); } // === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK === const [chatAvailable, setChatAvailable] = useState(false); const [chatOpen, setChatOpen] = useState(false); const [chatMsgs, setChatMsgs] = useState([]); const [chatOnline, setChatOnline] = useState([]); const [chatUnread, setChatUnread] = useState(0); const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen; const chatSeen = useRef>(new Set()); // Availability (only on a shared MySQL logbook; re-checked as profiles switch). useEffect(() => { let alive = true; const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {}); chk(); const id = window.setInterval(chk, 10000); return () => { alive = false; window.clearInterval(id); }; }, []); // Incoming messages — append + bump unread when the panel is closed. useEffect(() => { const off = EventsOn('chat:message', (m: ChatMsg) => { if (!m || chatSeen.current.has(m.id)) return; chatSeen.current.add(m.id); setChatMsgs((p) => [...p, m].slice(-300)); if (!chatOpenRef.current) setChatUnread((u) => u + 1); }); return () => { off?.(); }; }, []); // On open: clear unread, load history + the online list (refreshed). useEffect(() => { if (!chatOpen) return; setChatUnread(0); GetChatHistory(80).then((h: any) => { const list = (h ?? []) as ChatMsg[]; list.forEach((m) => chatSeen.current.add(m.id)); setChatMsgs((prev) => { const byId = new Map(); [...list, ...prev].forEach((m) => byId.set(m.id, m)); return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300); }); }).catch(() => {}); const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {}); lo(); const id = window.setInterval(lo, 15000); return () => window.clearInterval(id); }, [chatOpen, chatEpoch]); async function chatSend(t: string) { try { const m = (await SendChatMessage(t)) as any as ChatMsg; if (m && m.id && !chatSeen.current.has(m.id)) { chatSeen.current.add(m.id); setChatMsgs((p) => [...p, m].slice(-300)); } } catch (e: any) { setError(String(e?.message ?? e)); } } const chatShown = chatOpen && chatAvailable; const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal // Persistent spot-alert tray: keep the last few fired alerts visible (they used // to vanish with the 3 s toast, lost if you were on another window). Each stays // ~10 min and is clickable to tune the rig to its freq/mode. type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number }; const [recentAlerts, setRecentAlerts] = useState([]); const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown const ALERT_TTL_MS = 10 * 60 * 1000; useEffect(() => { const id = window.setInterval(() => { setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; }); }, 20000); return () => window.clearInterval(id); }, []); // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster). const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1'); useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]); // Contest tab is hidden until enabled from Tools → Contest mode. const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1'); useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]); const [dvkEnabled, setDvkEnabled] = useState(false); const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); const dvkActiveRef = useRef(false); const dvkPlayingRef = useRef(false); const dvkPlayRef = useRef<(slot: number) => void>(() => {}); useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]); useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]); useEffect(() => { const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat)); return () => { off?.(); }; }, []); const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []); // Load messages + status whenever the keyer is switched on. useEffect(() => { if (!dvkEnabled) return; reloadDvk(); GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {}); }, [dvkEnabled, reloadDvk]); const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []); useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]); // Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch // it from the keyboard without clashing with the F1-F12 keyer macros). type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended'; const [detailTab, setDetailTab] = useState('stats'); 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-mode' | '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(''); // Hide spots already worked (exact call worked, or this band+mode slot done). const [clusterHideWorked, setClusterHideWorked] = useState(false); // Bands shown side-by-side in the Band Map tab (portable). const [bandMapBands, setBandMapBands] = useState(() => { try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }); const toggleBandMapBand = useCallback((b: string) => { setBandMapBands((cur) => { const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b]; writeUiPref('opslog.bandMapBands', JSON.stringify(next)); return next; }); }, []); // Global band-map-tab options (apply to every map in the tab): hide FTx/digital // spots, and fit each map to its full band span. const [bandMapHideFt, setBandMapHideFt] = useState(() => localStorage.getItem('opslog.bandMapHideFt') === '1'); const [bandMapFit, setBandMapFit] = useState(() => localStorage.getItem('opslog.bandMapFit') === '1'); const toggleBandMapHideFt = useCallback(() => setBandMapHideFt((v) => { const n = !v; writeUiPref('opslog.bandMapHideFt', n ? '1' : '0'); return n; }), []); const toggleBandMapFit = useCallback(() => setBandMapFit((v) => { const n = !v; writeUiPref('opslog.bandMapFit', n ? '1' : '0'); return n; }), []); // Single band map docked beside the table (toggled by the toolbar button, // visible across tabs). Independent of the multi-band "Band Map" tab. const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1'); // Persist the Band Map open/closed state (portable) so it survives a restart. const setBandMapShown = useCallback((v: boolean) => { setShowBandMap(v); writeUiPref('bandmap.show', v ? '1' : '0'); }, []); const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>( () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'), ); const toggleBandMapSide = useCallback(() => { setBandMapSide((s) => { const next = s === 'right' ? 'left' : 'right'; writeUiPref('bandmap.side', next); return next; }); }, []); // Main tab is two configurable panes; each side shows one of the great-circle // map ("map1"), the locator street map ("map2"), the cluster grid or the // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // so it's loaded async on mount and re-read on profile:changed below. type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); const loadMainPanes = useCallback(async () => { const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol'; const [l, r] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), ]); setMainPaneLeft(valid(l) ? l : 'map1'); setMainPaneRight(valid(r) ? r : 'map2'); }, []); useEffect(() => { loadMainPanes(); }, [loadMainPanes]); // Report the current entry-strip band/mode/freq to the backend so the live // operator status (multi-op) has band/mode/freq even when the CAT is off. useEffect(() => { const hz = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0; ReportLiveActivity(hz || 0, band || '', mode || '').catch(() => {}); }, [band, mode, freqMhz]); // Cluster filter sidebar visibility — shared by the Cluster tab and the // Main-view cluster pane (portable UI pref). Hiding it keeps the filters // active, it just reclaims the width. const [clusterShowFilters, setClusterShowFilters] = useState(() => localStorage.getItem('opslog.clusterShowFilters') !== '0'); const toggleClusterFilters = useCallback(() => { setClusterShowFilters((v) => { const n = !v; writeUiPref('opslog.clusterShowFilters', n ? '1' : '0'); return n; }); }, []); 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); // QSOs queued for the delete confirm (1 or many — multi-row selection). const [deletingIds, setDeletingIds] = useState([]); const [selectedId, setSelectedId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [bulkEditIds, setBulkEditIds] = useState([]); const [bulkEditOpen, setBulkEditOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); // Re-read the "beam on map" toggle when Preferences closes (it's edited there). useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); // 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 [showAbout, setShowAbout] = useState(false); const [showDuplicates, setShowDuplicates] = useState(false); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null); // Check GitHub for a newer release once at startup (unless disabled in // General); surface a toast if one exists. Best effort — silent on failure. useEffect(() => { if (localStorage.getItem('opslog.checkUpdates') === '0') return; CheckForUpdate().then((u: any) => { if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') }); }).catch(() => {}); }, []); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); const [refsDownloading, setRefsDownloading] = useState(false); // === ADIF === const [importing, setImporting] = useState(false); const [importProgress, setImportProgress] = useState<{ processed: number; total: number } | null>(null); const [exporting, setExporting] = useState(false); // Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip). const [showExportChoice, setShowExportChoice] = 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 [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); const [importApplyCty, setImportApplyCty] = useState(true); const [importApplyStation, setImportApplyStation] = useState(false); // QRZ profile photo lightbox (full-size, in-app — not the browser). const [photoModal, setPhotoModal] = useState(null); // Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the // global ESC handler (which resets the entry) doesn't also fire. useEffect(() => { if (!photoModal) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); setPhotoModal(null); } }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [photoModal]); // === 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); // Per-award columns for the Recent QSOs / Worked-before grids: load the award // list once, then compute each shown QSO's reference per award and attach it // to the rows (the grids render one hideable column per award). const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]); // Bumped whenever award definitions are saved so the grid columns AND the // per-QSO refs re-fetch — the ref/name display choice is computed live, so // changing it updates ALL contacts (old included) with no restart. const [awardsVersion, setAwardsVersion] = useState(0); useEffect(() => { GetAwardDefs().then((defs: any[]) => setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))), ).catch(() => {}); }, [awardsVersion]); const [qsoAwardRefs, setQsoAwardRefs] = useState>>({}); useEffect(() => { const ids = (qsos as any[]).map((q) => q.id).filter(Boolean); if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; } let alive = true; AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {}); return () => { alive = false; }; }, [qsos, awardCols.length, awardsVersion]); const qsosWithAwards = useMemo( () => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })), [qsos, qsoAwardRefs], ); const [wbAwardRefs, setWbAwardRefs] = useState>>({}); useEffect(() => { const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean); if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; } let alive = true; AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {}); return () => { alive = false; }; }, [wb, awardCols.length, awardsVersion]); const wbWithAwards = useMemo( () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null), [wb, wbAwardRefs], ); // Always-current copy of the entry callsign, so the UDP event handlers // (which live in a []-deps effect with a stale `callsign` closure) can // tell whether an incoming DX call actually changed anything. const callsignValRef = useRef(''); useEffect(() => { callsignValRef.current = callsign; }, [callsign]); // Last callsign broadcast over UDP (WSJT-X/JTDX/MSHV/DXHunter). Lets us tell // "the field still shows the previous broadcast" (safe to update) from "the // user has typed a different call" (must not clobber). const lastUdpCallRef = useRef(''); // When the entered callsign turns out to be worked-before, jump to the // Worked-before tab so the history is front-and-centre. Only once per call, // and we don't yank the user out of the Cluster / QSL-manager tabs. useEffect(() => { // Opt-out: General settings can disable this auto-jump (read live so the // toggle takes effect without a reload). if (localStorage.getItem('opslog.autofocusWB') === '0') return; const c = callsign.trim().toUpperCase(); if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return; if (lastWbFocusRef.current === c) return; lastWbFocusRef.current = c; setActiveTab((t) => (t === 'cluster' || t === 'qsl' ? t : 'worked')); }, [wb, callsign]); // === Station === const [station, setStation] = useState({ callsign: '', operator: '', my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', }); const [showFirstRun, setShowFirstRun] = useState(false); myCallRef.current = (station.callsign || '').toUpperCase(); // Bearing/distance from operator's grid to the DX — used by the entry-strip // azimuth, the Info tab, and the rotor compass. Grid-to-grid when both known; // else fall back to the DX lat/lon (cty.dat-only entities carry no grid). const dxPath = useMemo(() => { const byGrid = pathBetween(station.my_grid, grid); if (byGrid) return byGrid; const myLL = gridToLatLon(station.my_grid); if (myLL && details.lat != null && details.lon != null) { return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon }); } return null; }, [station.my_grid, grid, details.lat, details.lon]); // Effective antenna heading(s): the rotor azimuth, transformed by the // Ultrabeam pattern when one is active — reversed (180°) points opposite, // bidirectional radiates both ways, normal is the heading itself. const beamHeadings = useMemo(() => { if (!(rotatorHeading.enabled && rotatorHeading.ok)) return []; const base = ((rotatorHeading.azimuth % 360) + 360) % 360; if (ubStatus.enabled && ubStatus.connected) { if (ubStatus.direction === 1) return [(base + 180) % 360]; if (ubStatus.direction === 2) return [base, (base + 180) % 360]; } return [base]; }, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]); // Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can // show where the antenna physically points (boom) vs where it radiates when // the Ultrabeam is reversed/bidirectional. const boomHeading = useMemo(() => ( rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null ), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]); const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => { if (!(ubStatus.enabled && ubStatus.connected)) return null; return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal'; }, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]); // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs). const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route // picked award references to the QSO field/extras each award actually reads. const awardFieldRef = useRef>({}); useEffect(() => { GetAwardDefs() .then((defs) => { const m: Record = {}; for (const d of (defs ?? []) as any[]) m[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase(); awardFieldRef.current = m; }) .catch(() => {}); }, []); // === 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); }, []); // The full filter sent to the backend = the builder conditions + the quick // callsign search box (always ANDed) + the on-screen row threshold. const buildActiveFilter = useCallback((): QueryFilter => ({ quick_callsign: filterCallsign, conditions: activeFilter.conditions ?? [], match: activeFilter.match ?? 'AND', limit: qsoLimit, offset: 0, }), [filterCallsign, activeFilter, qsoLimit]); // refresh reloads the grid. Returns false on failure. When silent, it doesn't // surface the error — used by the startup retry, since the logbook DB (a remote // MySQL especially) can take a few seconds to connect while the UI is already // mounted, and we don't want to flash "db not available" during that window. const refresh = useCallback(async (silent = false): Promise => { try { const f = buildActiveFilter(); const list = await ListQSOFiltered(f as any); const n = await CountQSO(); const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length)); const matched = hasFilter ? await CountQSOFiltered(f as any) : n; setQsos(list); setTotal(n); setMatchCount(matched); setError(''); return true; } catch (e: any) { if (!silent) setError(String(e?.message ?? e)); return false; } }, [buildActiveFilter]); // 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); const offEqsl = EventsOn('qsl:sent', ping); return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; }, [refresh]); // Backend-emitted toast messages (e.g. recording auto-send result/skip). useEffect(() => { const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); }); return () => { off(); }; }, [showToast]); // DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no // asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions. useEffect(() => { const off = EventsOn('alert:fired', (p: any) => { if (p?.sound) { try { const AC = (window as any).AudioContext || (window as any).webkitAudioContext; const ctx = new AC(); const beep = (freq: number, at: number, dur: number) => { const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = 'sine'; o.frequency.value = freq; o.connect(g); g.connect(ctx.destination); g.gain.setValueAtTime(0.0001, ctx.currentTime + at); g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur); o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02); }; beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp window.setTimeout(() => ctx.close().catch(() => {}), 600); } catch { /* audio blocked — ignore */ } } if (p?.visual) { const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert'); showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`); // Also keep it in the persistent tray (clickable, ~10 min). const a: RecentAlert = { id: Date.now() + Math.random(), rule, call, band, mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0), country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(), }; setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3)); } }); return () => { off(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [showToast]); // 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); }; }, []); // Poll the Ultrabeam antenna for its connection + pattern direction. useEffect(() => { let alive = true; const tick = async () => { try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {} }; tick(); const id = window.setInterval(tick, 3000); return () => { alive = false; window.clearInterval(id); }; }, []); // Poll the Antenna Genius switch for active antenna per port + the list. // Re-read the enabled flag each tick so toggling it in Settings makes the // top-bar icon appear/disappear without an app restart. useEffect(() => { let alive = true; const tick = async () => { try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {} try { const s = (await GetAntGeniusStatus()) as AGStatus; if (!alive || !s) return; const now = Date.now(); const pend = agPending.current; // Keep an optimistic selection until the device confirms it or it ages out. if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; } if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; } // Only update when something actually changed — avoids re-rendering the // widget every 1.5s (which made buttons flicker on hover). setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s)); } catch {} }; tick(); const id = window.setInterval(tick, 1500); return () => { alive = false; window.clearInterval(id); }; }, []); const agActivate = (port: number, antenna: number) => { // Optimistic: reflect the change immediately and pin it for ~3s so the next // poll (which may still carry the old cached value) can't revert it. agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 }; setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) })); AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e))); }; // RX band auto-follows the TX band (only differs for cross-band work). useEffect(() => { setBandRx(band); }, [band]); // Logbook connection label for the status bar (MySQL host:port/db, or the // local SQLite file path). useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []); // The logbook can switch at runtime when the active profile changes (each // profile can target its own SQLite/MySQL database). Refresh the grid and the // status-bar label when that happens. useEffect(() => { const off = EventsOn('logbook:changed', () => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); refresh(); }); return () => { off(); }; }, [refresh]); // Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so // QSOs another operator's instance adds/removes show up here within a few // seconds, without a manual refresh. Pointless on local SQLite (single writer). useEffect(() => { if (dbConn?.backend !== 'mysql') return; let alive = true; let last = ''; const tick = async () => { try { const rev = await GetLogbookRevision(); if (alive && last && rev !== last) await refresh(); if (alive) last = rev; } catch { /* logbook briefly unavailable — try again next tick */ } }; tick(); const id = window.setInterval(tick, 2000); return () => { alive = false; window.clearInterval(id); }; }, [dbConn, refresh]); // 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 }, []); // tuneRigCAT sends freq + mode to the rig, sequenced with a short settle // delay so an older transceiver — busy after a band/freq change — doesn't drop // the second command (which forced a second click to apply the mode). The // "mode before frequency" order (à la Log4OM) is an option for rigs that need // the mode set first; default is frequency-then-mode. async function tuneRigCAT(freqHz: number, mode: string) { const modeFirst = localStorage.getItem('opslog.catModeBeforeFreq') === '1'; const doFreq = () => SetCATFrequency(freqHz).catch(() => {}); const doMode = () => (mode ? SetCATMode(mode).catch(() => {}) : Promise.resolve()); const settle = () => new Promise((r) => window.setTimeout(r, 150)); if (modeFirst) { await doMode(); await settle(); await doFreq(); } else { await doFreq(); if (mode) { await settle(); await doMode(); } } } // applyModeFromSpot updates the mode AND its RST default for a fresh target // (clicked spot / rig-driven mode change). Unlike a manual mode tweak, this // is a new contact, so we clear the "user edited RST" flag first — otherwise // a 599 left from a CW QSO would stick when jumping to an SSB spot. function applyModeFromSpot(m: string) { if (!m) return; setMode(m); rstUserEditedRef.current = false; applyModePreset(m); } function applyModePreset(m: string) { if (rstUserEditedRef.current) return; // Prefer the user's configured preset RST; otherwise fall back to the mode // category default (CW/RTTY/PSK → 599, phone → 59, digital → first option) // so switching SSB→CW flips 59→599 even without a configured preset. const p = modePresets.find((x) => x.name === m); const fallback = rstOptions(m, rstLists)[0] || ''; setRstSent(p?.default_rst_sent || fallback); setRstRcvd(p?.default_rst_rcvd || fallback); } // Clicking a spot (cluster grid or any band map): tune the rig, set the mode, // fill the call, pre-fill POTA, (re)start the recording. Shared so every spot // source behaves identically. function handleSpotClick(s: any) { const m = inferSpotMode(s.comment ?? '', s.freq_hz); // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic), // then tune the rig if CAT is connected. We must not wait for the CAT status // echo to update the display: on a remote Flex link that echo lags by seconds, // so the frequency used to only catch up once you nudged the VFO. noteManualEdit() // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't // revert the optimistic value before the new freq is reported back. if (s.freq_hz && s.freq_hz > 0) { const mhz = (s.freq_hz / 1_000_000).toFixed(5); setFreqMhz(mhz); setRxFreqMhz(mhz); noteManualEdit(); } if (s.band) setBand(s.band); if (catState.connected) tuneRigCAT(s.freq_hz, m); if (m) applyModeFromSpot(m); onCallsignInput(s.dx_call, { force: true }); applySpotPOTA((s as any).pota_ref); if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); } // Initial grid load. The logbook (remote MySQL) may still be connecting while // the UI is mounted, so retry quietly until the first load succeeds rather // than leaving the grid empty until the next manual refresh. useEffect(() => { let alive = true; let tries = 0; let timer = 0; const attempt = async () => { if (!alive) return; const ok = await refresh(true); if (ok && alive) { // The logbook is now connected — refresh the status-bar label too, in // case its one-shot fetch ran during the startup race (before the // backend was determined) and grabbed the wrong/stale value. GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {}); } else if (!ok && alive && tries++ < 360) { // Quick retries at first (normal startup connects in ~2 s); then keep // trying for several minutes, because the very first migration against a // slow remote MySQL can legitimately take that long before the logbook // is ready. Stays silent so no "db not available" flashes meanwhile. timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000); } else if (!ok && alive) { refresh(); // give up quietly retrying; surface the error now } }; attempt(); return () => { alive = false; if (timer) window.clearTimeout(timer); }; }, [refresh]); useEffect(() => { (async () => { try { const st = await GetStartupStatus(); if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; } // First launch (or a never-configured profile): collect the mandatory // station identity before anything else. try { const ss = await GetStationSettings(); if (!ss.callsign?.trim()) setShowFirstRun(true); } catch {} } catch {} // Prompt to unlock encrypted passwords if a passphrase is configured. try { const ss: any = await GetSecretStatus(); if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true); } catch {} loadStation(); loadLists(); loadCATCfg(); })(); // Poll the CAT state at launch until the rig reports a frequency: the // backend connects asynchronously and only PUSHES cat:state on change, so // the first (freq-carrying) event can fire before this listener mounts and // be missed — leaving the display blank until the operator next moves the // VFO. Stops once a freq arrives, CAT is off, or after ~13s. (async () => { for (let i = 0; i < 16; i++) { try { const s = await GetCATState(); applyCatState(s); if (!s?.enabled) break; if (s?.connected && s.freq_hz && s.freq_hz > 0) break; } catch { /* not ready yet */ } await new Promise((r) => window.setTimeout(r, 800)); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user // just typed something (freeze window) or locked a field. Shared by the live // cat:state event and the startup poll below. function applyCatState(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. 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. // FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it. // But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so // when it's digital we still pick FT8/FT4/RTTY from the frequency's // watering hole (e.g. 14.080 → FT4), else the operator's default. // OmniRig & other rigs often can't tell digital from SSB on a digital // freq, so for them we infer from the frequency regardless of reported mode. if (!lk.mode) { let nextMode = ''; if (s.backend === 'flex') { if (s.mode === 'DATA') { nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8'; } else if (s.mode) { nextMode = s.mode; } } else { const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; if (inferred) nextMode = inferred; else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8'; else if (s.mode) nextMode = s.mode; } if (nextMode) { setMode(nextMode); applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits) } } } // 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) => applyCatState(s)); return () => { unsub?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig // QSY, spot click, manual band) — so the right antennas follow the frequency. // Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex // backends. The backend no-ops if the band has no configured mapping. const lastAntBandRef = useRef(''); useEffect(() => { if (catState.backend !== 'flex' || locks.band) return; const b = band.trim(); if (!b || b === lastAntBandRef.current) return; lastAntBandRef.current = b; FlexApplyBandAntenna(b).catch(() => {}); }, [band, catState.backend, locks.band]); // 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; }); // Self-spot: someone spotted OUR callsign — show it in the shared header // toast (same place as the other notifications), not a separate banner. const mine = myCallRef.current; if (mine && (sp.dx_call ?? '').toUpperCase() === mine) { const by = cleanSpotter(sp.spotter ?? '') || '?'; const c = (sp.comment ?? '').trim(); showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`); } }); 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(() => { // Apply a UDP-broadcast callsign, but never clobber what the operator is // typing: only update when the field is empty, already shows this call, or // still shows the previous broadcast (i.e. the field content is ours, not // a different call the user typed). Returns true if it actually changed. // force = true means an EXPLICIT action (a panadapter/spot click, a remote // "set call" command) that should always win, even over a call the operator // already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber // guard (it re-broadcasts constantly and must not fight what you typed). const applyUdpCall = (raw: string, force = false): boolean => { const call = String(raw ?? '').trim(); if (!call) return false; const upper = call.toUpperCase(); const current = callsignValRef.current.trim().toUpperCase(); const prev = lastUdpCallRef.current; lastUdpCallRef.current = upper; // remember this broadcast either way if (current === upper) return false; // already shown → no-op if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it onCallsignInput(call, { force: true }); // programmatic → always look up return true; }; const unsubDX = EventsOn('udp:dx_call', (p: any) => { // Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed // over UDP…) is an explicit pick → force it over an existing call. const force = String(p?.service ?? '').toLowerCase() !== 'wsjt'; // External app moved to a new station → fresh recording for the new target. if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? '')); }); const unsubRC = EventsOn('udp:remote_call', (raw: string) => { if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick }); // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // (the radio already tuned via trigger_action=Tune, and CAT reads the freq). // An explicit click always wins over whatever call is currently in the field. const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const call = String(p?.call ?? ''); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call); }); const unsubProg = EventsOn('import:progress', (p: any) => { setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); }); 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) { const msg = String(e?.message ?? e); // A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign — // show a quiet toast, not a red error. if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped'); else setError('UDP auto-log: ' + msg); } }); return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── WinKeyer wiring ─────────────────────────────────────────────────── const reloadWkPorts = useCallback(() => { ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {}); }, []); const reloadWk = useCallback(async () => { try { const s: any = await GetWinkeyerSettings(); setWkEnabled(!!s.enabled); setWkPort(s.port ?? ''); setWkWpm(s.wpm ?? 25); setWkMacros((s.macros ?? []) as WKMacro[]); setWkEscClears(s.esc_clears_call !== false); setWkSendOnType(!!s.send_on_type); setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer'); } catch { /* keyer not configured */ } }, []); // Every setting is per-profile, so when the active profile changes the whole // main UI re-reads its config (station identity, lists, CAT, keyer). The Go // side reloads its managers; this keeps the React state in sync. useEffect(() => { const off = EventsOn('profile:changed', () => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); // The chat is per shared logbook — clear the previous profile's messages // and reload for the new logbook (or hide if it isn't a MySQL log). setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0); ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false)); setChatEpoch((e) => e + 1); }); return () => { off(); }; }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]); useEffect(() => { (async () => { await reloadWk(); const st: any = await GetWinkeyerStatus().catch(() => null); if (st) setWkStatus(st as WKStatus); reloadWkPorts(); })(); const unsub = EventsOn('winkeyer:status', (st: WKStatus) => setWkStatus(st)); // Append each echoed char as the keyer transmits it; keep a rolling tail. const unsubEcho = EventsOn('winkeyer:echo', (ch: string) => setWkSent((s) => (s + ch).slice(-160))); return () => { unsub?.(); unsubEcho?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Keep a live ref to wkSendMacro so the global key handler calls the latest. const wkSendMacroRef = useRef<(i: number) => void>(() => {}); // Persist a single WinKeyer field then save (used by the panel's port etc.). async function saveWk(patch: Record) { try { const cur: any = await GetWinkeyerSettings(); await SaveWinkeyerSettings({ ...cur, ...patch }); } catch (e: any) { setError(String(e?.message ?? e)); } } function wkSetEnabled(on: boolean) { setWkEnabled(on); saveWk({ enabled: on }); if (!on) WinkeyerDisconnect().catch(() => {}); } function wkSelectPort(p: string) { setWkPort(p); saveWk({ port: p }); } // Resolve macro / CW-text variables from the current entry + active profile. function resolveCW(text: string): string { const myCall = (station.callsign || '').toUpperCase(); const his = callsign.trim().toUpperCase(); const cut = (s: string) => (s || '').replace(/0/g, 'T').replace(/9/g, 'N'); // cut numbers const vars: Record = { MY_CALL: myCall, CALL: his, STX: cut(rstSent), STRX: cut(rstRcvd), RST_R: cut(rstRcvd), STXF: rstSent, STRXF: rstRcvd, NAME: station.operator || '', MY_NAME: station.operator || '', MY_OPCALL: station.operator || myCall, HIS_NAME: name || '', GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '', }; let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); out = out.replace(/\*/g, myCall).replace(/!/g, his); // (2..9): repeat the whole resolved string n times. const rep = out.match(/<([2-9])>/); if (rep) { const n = parseInt(rep[1], 10); const base = out.replace(/<[2-9]>/g, '').replace(/\s+/g, ' ').trim(); out = Array(n).fill(base).join(' '); } return out.replace(/\s+/g, ' ').trim(); } async function wkSend(rawText: string) { setWkSent(''); const resolved = resolveCW(rawText); const doLog = //i.test(rawText); // resolveCW strips the token (unknown var → "") const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); if (cwSourceRef.current === 'icom') { // The rig's keyer gives no busy echo back, so show the text we sent and, // for , wait the estimated send duration before logging. setWkSent(resolved); await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e))); if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); } return; } await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e))); // (e.g. "BK 73 TU ") logs the contact AFTER the keyer has // finished sending — so the QSO isn't logged (and the form cleared) while CW // is still going out. We'd like to wait for the busy flag to rise then fall, // but over a remote/serial-over-IP link that status echo can lag by tens of // seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED // send duration (text length × WPM) plus slack: log when busy clears OR the // estimate elapses, whichever comes first. if (!doLog) return; const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start const deadline = Date.now() + capMs; while (wkBusyRef.current && Date.now() < deadline) await sleep(50); void save(); } // stopAutoCall cancels any running auto-call loop. function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; } // runAutoCall sends macro i, waits for the keyer to finish, waits the chosen // gap, then resends — looping until cancelled (reply entered, Stop, unchecked). async function runAutoCall(i: number) { const gen = ++autoCallGenRef.current; autoCallMacroRef.current = i; const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) { const m = wkMacros[i]; if (!m) break; await wkSend(m.text); // Wait for the message to finish before the gap+resend. The Icom keyer has // no busy echo, so just wait the estimated send time. For the WinKeyer, cap // the wait at the ESTIMATED send time (not the busy flag alone): over a // remote/serial-over-IP link the "busy" status lags badly and stays stuck // true for tens of seconds, which made the next CQ fire ~120s late. if (cwSourceRef.current === 'icom') { await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300); } else { const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start const deadline = Date.now() + capMs; while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50); } if (gen !== autoCallGenRef.current) break; await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call } } function wkSendMacro(i: number) { const m = wkMacros[i]; if (!m) return; // Auto-call only loops CQ-type macros. Sending any other macro (e.g. a // report once someone answers) sends ONCE and cancels a running loop — // otherwise a report would keep repeating. const isCQ = (m.text || '').toUpperCase().includes('CQ'); if (wkAutoCallRef.current && isCQ) { runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC } else { stopAutoCall(); wkSend(m.text); } } wkSendMacroRef.current = wkSendMacro; function wkToggleAutoCall(on: boolean) { setWkAutoCall(on); writeUiPref('opslog.wkAutoCall', on ? '1' : '0'); if (!on) stopAutoCall(); } function wkSetAutoCallSecs(n: number) { const v = Math.max(0, Math.min(120, n || 0)); setWkAutoCallSecs(v); writeUiPref('opslog.wkAutoCallSecs', String(v)); } // send-on-type: key the typed chars verbatim (no variable substitution). function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); } function wkBackspace() { WinkeyerBackspace().catch(() => {}); } function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); } // 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 baseStart = qsoStartedAt ?? now; // End: explicit when locked; for a deferred back-entry (start locked) it // defaults to the back-dated start so the QSO doesn't span to today; // otherwise it's now. const end = (locks.end && qsoEndedAt) ? qsoEndedAt : (locks.start ? baseStart : now); // Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful // when you call a station for a long time — otherwise TIME_ON is frozen at // when you first entered the call (minutes early) and won't match LoTW. const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1'; const start = (startEqualsEnd && !locks.start) ? end : baseStart; // Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an // alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING. const srxE = exchangeFields(details.srx_string); const stxE = exchangeFields(details.stx_string); 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: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str, email: details.email, }; applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); // Contest mode overrides the exchange fields: CONTEST_ID, the sent // exchange (running serial or fixed value) and the received exchange. if (contest.active && contest.code) { const sent = contest.exchange === 'serial' ? String(contest.nextSerial) : contest.sentFixed; const sE = exchangeFields(sent); const rE = exchangeFields(rcv.trim()); Object.assign(payload, { contest_id: contest.code, stx: sE.num, stx_string: sE.str, srx: rE.num, srx_string: rE.str, }); } await AddQSO(payload); // Advance the sent serial only after a successful log. if (contest.active && contest.code && contest.exchange === 'serial') { updateContest({ nextSerial: contest.nextSerial + 1 }); } resetEntry(); // clears the call AND the Worked-before matrix callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name) 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() { // Discard any in-progress QSO recording (no-op if it was already saved on // log, or if the recorder is off). QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; setCallsign(''); setComment(''); setNote(''); setRcv(''); if (!locks.start) setQsoStartedAt(null); if (!locks.end) setQsoEndedAt(null); resetAutoFill(); setWb(null); // clear the Worked-before grid for the just-cleared callsign 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: '', award_refs: '', })); } function resetAutoFill() { setName(''); setQth(''); setCountry(''); setGrid(''); // NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms // pass) and the short-callsign guard in scheduleLookup. Clearing it inside // runLookup blanked the Worked-before table for the whole (possibly slow, // QRZ-then-cty.dat) lookup → entries flashed in and immediately vanished. 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 = ''; lastWbFocusRef.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(); // The Worked-before grid is loaded separately (per callsign) and isn't // touched by refresh(), so an edit made from it would leave stale data // on screen. Reload it when one is shown. const wbCall = callsign.trim(); if (wb && wbCall.length >= 3) runWorkedBefore(wbCall); } catch (err: any) { setError(String(err?.message ?? err)); } } function onModalDelete(id: number) { setEditingQSO(null); setDeletingIds([id]); } // Bulk grid actions (right-click menu). Recompute country/zones from // cty.dat (instant, offline) or re-query QRZ.com, then refresh the views. async function afterBulkUpdate(n: number, label: string) { await refresh(); const wbCall = callsign.trim(); if (wb && wbCall.length >= 3) runWorkedBefore(wbCall); showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`); } function openBulkEdit(ids: number[]) { if (ids.length === 0) return; setBulkEditIds(ids); setBulkEditOpen(true); } async function bulkUpdateFromCty(ids: number[]) { if (ids.length === 0) return; try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); } catch (e: any) { setError(String(e?.message ?? e)); } } async function bulkUpdateFromQRZ(ids: number[]) { if (ids.length === 0) return; showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`); try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); } catch (e: any) { setError(String(e?.message ?? e)); } } async function bulkUpdateFromClublog(ids: number[]) { if (ids.length === 0) return; try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); } catch (e: any) { setError(String(e?.message ?? e)); } } async function bulkSendRecording(ids: number[]) { if (ids.length === 0) return; showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`); let ok = 0; const errs: string[] = []; for (const id of ids) { try { await SendQSORecordingEmail(id as any); ok++; } catch (e: any) { errs.push(String(e?.message ?? e)); } } if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`); else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`); } // Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs // on demand (regardless of their current upload status). Runs in the // background; qslmgr:done refreshes the grid when finished. async function bulkSendTo(service: string, ids: number[]) { if (ids.length === 0) return; const label = service === 'qrz' ? 'QRZ.com' : service === 'clublog' ? 'Club Log' : service === 'lotw' ? 'LoTW' : service; showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`); try { await UploadQSOsManual(service, ids as any); } catch (e: any) { setError(String(e?.message ?? e)); } } // Right-click "Export filtered to ADIF (no limit)": exports every QSO that // matches the current filter, bypassing the on-screen row threshold. async function exportFilteredADIF() { try { const path = await SaveADIFFile(); if (!path) return; const f = buildActiveFilter(); const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any); showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } // Right-click "Export selected to ADIF": only the highlighted rows. async function exportSelectedADIF(ids: number[]) { if (ids.length === 0) return; try { const path = await SaveADIFFile(); if (!path) return; const r = await ExportADIFSelected(path, false, ids as any); showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } // Cabrillo (contest log) export → a .log file. The contest name comes from the // QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones. async function exportCabrillo() { try { const path = await SaveCabrilloFile(); if (!path) return; const r = await ExportCabrillo(path); showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } async function exportFilteredCabrillo() { try { const path = await SaveCabrilloFile(); if (!path) return; const f = buildActiveFilter(); const r = await ExportCabrilloFiltered(path, { ...f, limit: 0, offset: 0 } as any); showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } async function exportSelectedCabrillo(ids: number[]) { if (ids.length === 0) return; try { const path = await SaveCabrilloFile(); if (!path) return; const r = await ExportCabrilloSelected(path, ids as any); showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } function askDelete(id: number) { setDeletingIds([id]); } // Delete the whole multi-row selection (Edit menu / Delete key). function askDeleteSelected() { if (selectedIds.length > 0) setDeletingIds(selectedIds); else if (selectedId != null) setDeletingIds([selectedId]); } async function confirmDelete() { if (deletingIds.length === 0) return; const ids = deletingIds; try { if (ids.length === 1) await DeleteQSO(ids[0]); else await DeleteQSOs(ids as any); setDeletingIds([]); setSelectedId(null); setSelectedIds([]); await refresh(); } catch (err: any) { setError(String(err?.message ?? err)); setDeletingIds([]); } } 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); // Discard a STALE result: the operator already moved to another call // (clicked a new spot / typed) while this lookup was in flight. Applying it // would clobber the current call's fields and zoom the map to the wrong // station — the bug where replacing a call didn't re-zoom the map. if (call !== callsignValRef.current.trim().toUpperCase()) return; lastLookedUpRef.current = call; // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent). // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the // result is cty.dat-only, it must NEVER overwrite the richer data already // shown for the same call — so don't downgrade the result badge/image, and // below we only fill text fields that actually carry a value. const ctyOnly = r.source === 'cty.dat'; setLookupResult((prev) => (ctyOnly && prev && prev.callsign === r.callsign ? prev : r)); const ue = userEditedRef.current; if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? ''); if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? ''); if (!ue.has('grid')) { if ((r.grid ?? '') !== '') setGrid(r.grid ?? ''); // No provider grid (cty.dat-only / portable): derive a 4-char grid from // the entity centroid (e.g. Svalbard → JQ88) so the field — and the // bearing/map — aren't empty. 4 chars signals it's entity-level, not a // precise QTH (matches how Log4OM shows it). else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4)); } // Country/zones are exactly what cty.dat IS authoritative for — set them // (only skipped if empty, so we never blank a known country). if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? ''); 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); // The DX location is now known (grid set above) — force the world map to // auto-zoom right away, so it doesn't lag behind the resolved QSO. setMapZoomSignal((n) => n + 1); // Recording: tie it to the resolved callsign. Start once a real (≥3-char) // call resolves — covers the fast CW workflow (type → Enter, no blur). If // we're already recording a DIFFERENT call (the operator edited the // callsign), restart from zero instead of continuing the old take. const recCall = call.toUpperCase(); if (recordingCallRef.current && recordingCallRef.current !== recCall) { restartRecordingForNewTarget(recCall); } else { recordingCallRef.current = recCall; QSOAudioBegin().then(setRecording).catch(() => {}); } } catch (e: any) { setLookupResult(null); setLookupError(String(e?.message ?? e)); } finally { setLookupBusy(false); } } function scheduleLookup(value: string, force?: boolean) { 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; } // Option: defer the (network) callsign lookup until the operator leaves the // Call field instead of firing it as they type. `force` (programmatic set — // clicked spot / external app) always looks up, since there's no blur to // wait for. Worked-before stays live (local, feeds the band matrix). if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') { lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400); } wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); } // applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's // park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh // target). Routed to the pota_ref column at save via applyAwardRefs. function applySpotPOTA(potaRef?: string) { const refs = String(potaRef || '') .split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); } function onCallsignInput(v: string, opts?: { force?: boolean }) { // Programmatic call-sets (force: spot click, UDP, external app) count as // "not manually typed", so a later UDP DX call (DXHunter remote control) can // still replace it. Without this, clicking a cluster spot froze the call: // applyUdpCall saw current != lastUdpCall and refused every later UDP call. if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); // A callsign appeared (someone answered the CQ, or a spot was clicked) → // stop auto-calling so we don't key over the contact. If a CQ was actually // in flight, abort the current transmission too so it stops IMMEDIATELY // rather than finishing the buffered call. (autoCallMacroRef flips to -1 on // the first keystroke, so we only abort once.) if (v.trim() !== '') { if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {}); stopAutoCall(); } // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call // on every status packet. If it matches what's already in the entry, // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and // reload worked-before + the band matrix, making them flicker. Compared // via the ref so it's correct even from the stale UDP closure. if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return; // QSO recorder: a non-empty callsign marks the QSO start (the recorder // keeps the pre-roll from before this); clearing it discards the take. // Recording START happens on blur (leaving the callsign field), NOT here — // you may type a call and work it minutes later. Clearing it cancels. if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; } const isEmpty = v.trim() === ''; if (!isEmpty && !locks.start) { // Restart the start time on every callsign change (each keystroke, a // clicked spot, a new call): "Start UTC" should mark when you actually // began this contact, not a stale time frozen at the first keystroke. // Skip when start is locked (back-entering a past QSO at a chosen time). setQsoStartedAt(new Date()); } else if (isEmpty && !locks.start) { // Callsign wiped → user abandoned this QSO; reset the timer. setQsoStartedAt(null); } setCallsign(v); // opts.force = the call was set programmatically (clicked spot / external // app): there's no "leaving the field", so look it up now regardless of the // lookup-on-blur option. scheduleLookup(v, opts?.force); } 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)); } } function exportAdif() { if (exporting) return; setShowExportChoice(true); // pick standard vs full first } async function runExport(includeAppFields: boolean) { setShowExportChoice(false); if (exporting) return; setError(''); try { const path = await SaveADIFFile(); if (!path) return; setExporting(true); const res = await ExportADIF(path, includeAppFields); // Green success toast (auto-dismiss) — not the red error banner. showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`); } 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); setImportProgress({ processed: 0, total: 0 }); setImportResult(null); setImportErrorsOpen(false); setImportDupsOpen(false); try { const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation); setImportResult(res); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setImporting(false); setImportProgress(null); } } const menus: Menu[] = useMemo(() => [ { name: 'file', label: t('menu.file'), items: [ { type: 'item', label: t('file.import'), action: 'file.import', shortcut: 'Ctrl+O' }, { type: 'item', label: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 }, { type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 }, { type: 'separator' }, { type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 }, { type: 'separator' }, { type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true }, ]}, { name: 'edit', label: t('menu.edit'), items: [ { type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null }, { type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 }, { type: 'separator' }, { type: 'item', label: t('edit.prefs'), action: 'edit.prefs' }, ]}, { name: 'view', label: t('menu.view'), items: [ { type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' }, { type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' }, ]}, { name: 'tools', label: t('menu.tools'), items: [ { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, { type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' }, { type: 'separator' }, { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' }, { type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' }, { type: 'item', label: t('tools.alerts'), action: 'tools.alerts' }, { type: 'separator' }, { type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 }, { 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 }, { type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading }, ]}, { name: 'help', label: t('menu.help'), items: [ { type: 'item', label: t('help.about'), action: 'help.about' }, ]}, ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]); function handleMenu(action: string) { switch (action) { case 'file.import': importAdif(); break; case 'file.export': exportAdif(); break; case 'file.exportCabrillo': exportCabrillo(); break; case 'file.deleteall': setShowDeleteAll(true); break; case 'view.refresh': refresh(); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.delete': askDeleteSelected(); break; case 'edit.bulkedit': openBulkEdit(selectedIds); break; case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.cwdecoder': toggleCwDecoder(); break; case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break; case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break; case 'tools.alerts': setAlertsOpen(true); break; case 'tools.duplicates': setShowDuplicates(true); break; case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.downloadRefs': downloadRefs(); break; case 'help.about': setShowAbout(true); break; } } async function downloadRefs() { if (refsDownloading) return; setRefsDownloading(true); setError(''); try { const summary = await DownloadAllReferenceLists(); showToast(`Reference lists updated — ${summary}`); } catch (e: any) { setError(`Reference download failed: ${String(e?.message ?? e)}`); } finally { setRefsDownloading(false); } } 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'; // ESC: abort CW when the keyer is live. Whether it ALSO clears the // callsign depends on the "ESC clears callsign" option; with the keyer // off it always resets the entry (the classic behaviour). if (e.key === 'Escape') { // If a voice message is transmitting, ESC just stops it (keeps entry). if (dvkActiveRef.current && dvkPlayingRef.current) { DVKStop(); e.preventDefault(); return; } const keyerLive = wkActiveRef.current; // ESC aborts the current CW transmission AND the auto-call loop, so it // won't resend after the gap — you must click a CQ macro to restart it. if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); } if (!keyerLive || wkEscClearsRef.current) { resetEntry(); callsignRef.current?.focus(); } e.preventDefault(); return; } // Function keys (work even while typing — they're not text input): const fn = /^F([1-9]|1[0-2])$/.exec(e.key); if (fn) { const n = parseInt(fn[1], 10); // 1..12 const TABS = ['stats', 'info', 'awards', 'my', 'extended'] as const; const mod = e.ctrlKey || e.metaKey; const plain = !mod && !e.altKey; if (wkActiveRef.current) { // CW keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the // detail tab (so the two don't clash). Labels read "Ctrl+F1…". if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; } return; } if (dvkActiveRef.current) { // Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs. if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; } return; } // No keyer: plain F1..F5 switch the detail tab (labels read "F1…"). if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } 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(); askDeleteSelected(); 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, selectedIds, refresh]); // ── Entry-field blocks ───────────────────────────────────────────────── // Each field is defined once here, then composed into either the compact // single-row strip or the full Log4OM-style columnar layout below. Keeping // them as shared consts avoids duplicating the (large) per-field JSX + // handlers across the two layouts. const callsignBlock = (
{contest.active && contestDupe && ( DUPE )} {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && ( )} onCallsignInput(e.target.value)} onBlur={() => { const c = callsign.trim(); if (!c) return; // Lookup-on-blur mode: run the deferred lookup now (it also starts the // recording). Otherwise just start the recording (lookup already ran). if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase()); else QSOAudioBegin().then(setRecording).catch(() => {}); }} />
); const rstTxBlock = (
{ setRstSent(v); rstUserEditedRef.current = true; }} />
); const rstRxBlock = (
{ setRstRcvd(v); rstUserEditedRef.current = true; }} />
); // DX country flag, shown large next to RST (moved here from the Country field). const flagBlock = flagURL(details.dxcc) ? (
{country} { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
) : null; // Deferred-entry date: only shown when the start time is locked (back-entering // a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time. const dateBlock = locks.start ? (
{ const d = e.target.value; // YYYY-MM-DD if (!d) return; const [y, mo, da] = d.split('-').map(Number); const next = new Date(qsoStartedAt ?? new Date()); next.setUTCFullYear(y, mo - 1, da); setQsoStartedAt(next); }} className="font-mono" />
) : null; const startBlock = (
{ 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')} />
); const endBlock = (
{ 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')} />
); const nameBlock = (
{ setName(e.target.value); markEdited('name'); }} />
); // In contest mode QTH is replaced by the sent (read-only, auto) and received // exchange fields — the exchange is what matters, not the town. const contestSnt = contest.exchange === 'serial' ? String(contest.nextSerial).padStart(3, '0') : (contest.sentFixed || '—'); const qthBlock = contest.active ? ( <>
setRcv(e.target.value.toUpperCase())} />
) : (
{ setQth(e.target.value); markEdited('qth'); }} />
); const gridBlock = (
{ setGrid(e.target.value); markEdited('grid'); }} />
); // A discreet spot-alert LED (bell) that fills the gap at the right of the Grid // row instead of the intrusive floating cards. It lights + shows a count when // alerts are pending; click it to see the last 3 (each clickable to tune). const hasAlerts = recentAlerts.length > 0; const alertLedBlock = (
{alertsPanelOpen && ( <>
setAlertsPanelOpen(false)} />
{t('alert.recent')} {hasAlerts && }
{!hasAlerts ? (
{t('alert.noneShort')}
) : recentAlerts.map((a) => ( ))}
)}
); // Compact-strip Country (stacked label) + a narrow Comment. const countryBlockSm = (
{ setCountry(v); markEdited('country'); }} />
); const commentSm = (
setComment(e.target.value)} />
); // Inline-label variants (label to the LEFT of the control, Log4OM-style) — // used in the full layout to save vertical height. const bandRow = (
); const modeRow = (
); const countryRow = (
{ setCountry(v); markEdited('country'); }} />
); // CQ/ITU zones moved to the Info (F2) tab (DetailsPanel). const freqBlock = (
setFreqFocused(true)} onBlur={() => setFreqFocused(false)} onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }} />
); const rxFreqBlock = (
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-danger-muted/40 border-danger-border focus:bg-card')} />
); const bandRxBlock = (
); // Single-line Comment/Note for the full layout (stacked in the right // column). No flex-1 so they stay one row tall. const commentLine = (
setComment(e.target.value)} />
); const noteLine = (
setNote(e.target.value)} />
); const logButtons = (
{clusterServerStatuses.some((s) => s.state === 'connected') && ( )}
); // Compact strip: Log only — the Spot dialog would be clipped by the tiny // always-on-top window, so it's reachable only in normal mode. const logButtonCompact = (
); // Cluster spots after every active filter (band / mode / status / search / // hide-worked / group). Shared by the Cluster tab and the Main-view cluster // pane so both show exactly the same list. const clusterRenderedRows = useMemo(() => { const bandsActive = clusterLockBand ? new Set([band]) : clusterBands; const search = clusterSearch.trim().toUpperCase(); const 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 e = spotStatus[k]; const st = (e?.status || '') as SpotStatusKey; // A previously-worked call counts as WORKED for filtering even when its // entity status is still new-band/new-slot (the grid flags it WKD CALL), // matching the "Hide worked" toggle. Additive: it still matches its own // entity status too, so it stays visible under NEW BAND / NEW SLOT. const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked')); if (!matches) return false; } if (clusterHideWorked) { const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const e = spotStatus[k]; if (!e) return false; if (e.worked_call || e.status === 'worked') 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()); } return rendered; }, [spots, clusterLockBand, band, clusterBands, clusterSearch, clusterFilterSource, clusterLockMode, mode, clusterModeFilter, clusterStatusFilter, spotStatus, clusterHideWorked, clusterGroup]); // The Log4OM-style cluster filter sidebar (callsign search, hide-worked, // group, band/mode/status/source). Rendered both in the Cluster tab and the // Main-view cluster pane; toggled by clusterShowFilters. const renderClusterFilters = () => (
Filters
{/* Callsign search */} setClusterSearch(e.target.value.toUpperCase())} /> {/* Toggles */}
{/* Band filter — multi-select listbox */}
Bands
{clusterBands.size > 0 && ( )}
{bands.map((b) => { const on = clusterBands.has(b); return ( ); })}
{/* Mode lock */} {/* Status filter */}
Status
{([ { k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' }, { k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' }, { k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' }, { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' }, // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.) ]).map((s) => { const on = clusterStatusFilter.has(s.k); return ( ); })}
{/* Mode filter */}
Mode
{([ { k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' }, ]).map((s) => { const on = clusterModeFilter.has(s.k); return ( ); })}
{/* Source */}
Source
); // A small "show filters" button shown when the sidebar is collapsed. const clusterFiltersToggleBtn = ( ); // Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from // the same four choices, configured per-profile in Settings → Main view. const renderMainPane = (kind: MainPaneKind) => { switch (kind) { case 'map1': return ( ); case 'map2': return ; case 'cluster': return (
Cluster {clusterFiltersToggleBtn}
{clusterShowFilters && renderClusterFilters()}
); case 'worked': return (
openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
); case 'flex': return (
{ setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
); case 'icom': return (
{ setRstSent(r); rstUserEditedRef.current = true; }} />
); case 'netcontrol': return (
); case 'recent': return (
openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} />
); } }; 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 setShowAbout(true)} title="About OpsLog">v{APP_VERSION}
{/* Transient toast / error, in the empty band between the menu and the frequency (left of centre). Single-line + truncated. */} {(error || toast) && (
{error ? (
{error}
) : (
{toast}
)}
)}
{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 = dxPath; const disabled = !p; const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err))); return (
); })()} {/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */} {ubStatus.enabled && (
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => ( ))}
)} {/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
{agEnabled && ( )} {chatAvailable && ( )}
{utcNow}UTC
{station.callsign ? ( ) : ( )}
)} {/* QRZ profile photo lightbox — full size, in-app. Click anywhere or press Esc to close; click the image itself doesn't close. */} {photoModal && (
setPhotoModal(null)} > profile full size e.stopPropagation()} />
)} {/* First launch: mandatory station identity. Blocks until filled. */} {showFirstRun && ( { setShowFirstRun(false); loadStation(); refresh(); }} /> )} {updateInfo && (

OpsLog v{updateInfo.latest} available

You're on v{APP_VERSION}.

)} {showAbout && (
setShowAbout(false)}>
e.stopPropagation()}>

OpsLog

Ham-radio logbook

version {APP_VERSION}

{updateInfo ? ( ) : (

You're up to date

)}

Developed by {APP_AUTHOR}

73 & good DX

)} {/* Transient toasts (bottom-right). Errors stack on top of the green success toast; both auto-dismiss. */} {/* Unlock encrypted passwords (set via Settings → Security). Dismissable: skipping leaves lookups/uploads without their passwords until unlocked. */} {unlockOpen && (

Unlock saved passwords

Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.

{ setUnlockPass(e.target.value); setUnlockErr(''); }} onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }} className="mb-2" /> {unlockErr &&
{unlockErr}
}
)} {/* ===== ENTRY STRIP ===== Enter from any inside the strip logs the QSO. Radix Selects render as
)}
)}
{/* /entry + aside row */} {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */} {cwOn && (
{/* Input-level meter — if this stays flat with a strong signal, the RX audio device is wrong/silent rather than a decode problem. */}
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'} {/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */} setCwPitch(e.target.value)} placeholder="auto" title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search." className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none" /> {/* Left-aligned single line, no scrollbar; auto-scrolled to the newest text (see cwScrollRef effect) so the latest stays in view. */}
{cwText.trim() === '' ? ( listening… ) : (
{cwText.trim().split(/\s+/).map((tok, i) => ( ))}
)}
)} {/* ===== LOWER: tabbed table / cluster / band map ===== */} {compact ? null : <>
{t('tab.main')} {t('tab.recent')} {t('tab.cluster')} {t('tab.worked')} {wb && wb.count > 0 && ( {wb.count} )} {t('tab.awards')} {t('tab.bandmap')} {contestTabEnabled && ( {t('tab.contest')} {contest.active && } { e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); setContestTabEnabled(false); setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); }} > )} {netEnabled && ( {t('tab.net')} { e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }} > )} {catState.backend === 'flex' && FlexRadio} {catState.backend === 'icom' && Icom} {qslTabOpen && ( QSL Manager { e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); closeQslTab(); }} > )}
setFilterCallsign(e.target.value)} />
{importResult && (
0 ? 'bg-warning-muted border-warning-border text-warning-muted-foreground' : 'bg-success-muted border-success-border text-success-muted-foreground', )}>
Import complete. {importResult.imported} imported {importResult.updated > 0 && ( {importResult.updated} updated )} {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)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} onExportCabrilloSelected={exportSelectedCabrillo} onExportCabrilloFiltered={exportFilteredCabrillo} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} />
{(activeFilter.conditions?.length || filterCallsign) ? ( ) : null} Showing {qsos.length} of{' '} {(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total} {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
{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 {clusterFiltersToggleBtn}
{/* Filters moved to the right-side panel (see below). */} {(() => { // Filtered + grouped spots (shared with the Main-view cluster // pane). All the filter state lives in the right-side panel. const rendered = clusterRenderedRows; 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 ( ); })()} {/* 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 */} {/* Right-side filter panel (Log4OM style) — shared with the Main-view cluster pane; toggle hides it in both places. */} {clusterShowFilters && renderClusterFilters()} openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} /> {/* 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 && ( )} {/* Two configurable panes (per-profile, Settings → Main view). Each side shows one of: great-circle map, locator map, cluster or worked-before. */}
{renderMainPane(mainPaneLeft)}
{renderMainPane(mainPaneRight)}
setAwardsVersion((v) => v + 1)} /> {contestTabEnabled && ( )} {/* FlexRadio SmartSDR-style control panel — only present when the CAT backend is a FlexRadio. */} {catState.backend === 'flex' && ( { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> )} {/* Icom CI-V receive-DSP control panel — only when the CAT backend is an Icom. */} {catState.backend === 'icom' && ( { setRstSent(r); rstUserEditedRef.current = true; }} /> )} {/* Band Map: several bands shown side-by-side (panadapter-style strips). Pick bands with the chips; each strip is clickable to tune the rig. */} {netEnabled && ( )}
{t('bmp.bandsLabel')} {bands.map((b) => { const on = bandMapBands.includes(b); return ( ); })}
{bandMapBands.length === 0 ? (
Pick one or more bands above to show their band maps side by side.
) : bandMapBands.map((b) => (
s.band === b)} spotStatus={spotStatus} currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} onSpotClick={handleSpotClick} onClose={() => toggleBandMapBand(b)} hideDigital={bandMapHideFt} fitToBand={bandMapFit} />
))}
{showBandMap && (
s.band === band)} spotStatus={spotStatus} currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} onSpotClick={handleSpotClick} onClose={() => setBandMapShown(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); }} />
{dbConn && ( )}
); })()} {editingQSO && ( setEditingQSO(null)} countries={countries} bands={bands} modes={modes} /> )} setBulkEditOpen(false)} onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }} /> 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(); reloadWk(); }} onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }} flexAvailable={catState.backend === 'flex'} icomAvailable={catState.backend === 'icom'} /> )} {alertsOpen && ( setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} /> )} {showDuplicates && ( setShowDuplicates(false)} onDeleted={() => refresh()} /> )} showToast(`OpsLog QSL sent to ${call}`)} onError={(msg) => showToast(msg)} /> setQslDesignerOpen(false)} /> setEqslQsoId(null)} onOpenDesigner={() => setQslDesignerOpen(true)} /> {deletingIds.length > 0 && (() => { const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null; return ( 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'} message={single ? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.` : `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`} confirmLabel="Delete" danger onConfirm={confirmDelete} onCancel={() => setDeletingIds([])} /> ); })()} {showDeleteAll && ( { if (!deletingAll) setShowDeleteAll(false); }} /> )} { setActiveFilter(f); setFilterOpen(false); }} onClose={() => setFilterOpen(false)} /> {showExportChoice && ( { if (!o) setShowExportChoice(false); }}> Export ADIF Choose which fields to include. OpsLog writes ADIF 3.1.7.
)} {pendingImportPath && ( { if (!o) setPendingImportPath(null); }}> Import ADIF {pendingImportPath}
Duplicate = same callsign + UTC minute + band + mode as a QSO already in the log.
{([ { id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' }, { id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file — merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' }, { id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' }, ] as const).map((o) => ( ))}
)} {importing && ( Importing ADIF…
{(() => { const done = importProgress?.processed ?? 0; const tot = importProgress?.total ?? 0; const pct = tot > 0 ? Math.min(100, Math.round((done / tot) * 100)) : 0; return ( <>
0 ? { width: `${pct}%` } : undefined} />
{tot > 0 ? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%` : `${done.toLocaleString()} records…`}
); })()}
)}
); }