From 806b39970bbf25082ef323c9e4f4b3a95b1e7212 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 30 May 2026 01:35:50 +0200 Subject: [PATCH] feat: status bar added --- .claude/scheduled_tasks.lock | 1 + app.go | 440 ++++++++++++- frontend/src/App.tsx | 581 ++++++++++++------ frontend/src/components/BandMap.tsx | 104 +++- frontend/src/components/DetailsPanel.tsx | 40 +- frontend/src/components/QSLManagerModal.tsx | 377 ++++++------ frontend/src/components/QSOEditModal.tsx | 53 +- frontend/src/components/RecentQSOsGrid.tsx | 1 + frontend/src/components/SendSpotModal.tsx | 167 +++++ frontend/src/components/SettingsModal.tsx | 116 +++- frontend/src/components/ui/combobox.tsx | 86 +++ frontend/src/components/ui/dialog.tsx | 6 +- frontend/src/lib/flags.ts | 78 +++ frontend/wailsjs/go/main/App.d.ts | 20 + frontend/wailsjs/go/main/App.js | 40 ++ frontend/wailsjs/go/models.ts | 44 ++ internal/adif/export.go | 2 + internal/adif/import.go | 3 + .../db/migrations/0016_qrzcom_download.sql | 5 + internal/dxcc/manager.go | 26 + internal/extsvc/qrz.go | 75 +++ internal/lookup/lookup.go | 15 +- internal/qso/qso.go | 50 +- internal/rotator/pst/pst.go | 54 ++ 24 files changed, 1933 insertions(+), 451 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 frontend/src/components/SendSpotModal.tsx create mode 100644 frontend/src/components/ui/combobox.tsx create mode 100644 frontend/src/lib/flags.ts create mode 100644 internal/db/migrations/0016_qrzcom_download.sql diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..39532bf --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"9e3eadf5-7e35-4848-8cf9-515589d63e73","pid":5360,"acquiredAt":1780094656528} \ No newline at end of file diff --git a/app.go b/app.go index d7a02c5..011cc68 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "math" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -53,8 +54,11 @@ const ( keyStationSOTA = "station.my_sota_ref" keyStationPOTA = "station.my_pota_ref" - keyListsBands = "lists.bands" - keyListsModes = "lists.modes" + keyListsBands = "lists.bands" + keyListsModes = "lists.modes" + keyListsRSTPhone = "lists.rst_phone" + keyListsRSTCW = "lists.rst_cw" + keyListsRSTDigital = "lists.rst_digital" keyCATEnabled = "cat.enabled" keyCATBackend = "cat.backend" // "omnirig" (only one for now) @@ -151,8 +155,11 @@ type ModePreset struct { // ListsSettings holds the user-customisable dropdown lists used by the // entry form. Default values match common HF/VHF practice. type ListsSettings struct { - Bands []string `json:"bands"` - Modes []ModePreset `json:"modes"` + Bands []string `json:"bands"` + Modes []ModePreset `json:"modes"` + RSTPhone []string `json:"rst_phone"` // RS reports for phone modes + RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK + RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT… } var defaultBands = []string{ @@ -171,6 +178,49 @@ var defaultModes = []ModePreset{ {Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, } +// Default RST report lists, editable in Settings → Modes. Phone carries the +// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid; +// digital the dB reports +30…-30. +var defaultRSTPhone = buildPhoneRST() +var defaultRSTCW = buildCWRST() +var defaultRSTDigital = buildDigitalRST() + +func buildPhoneRST() []string { + out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"} + for r := 5; r >= 1; r-- { + for s := 9; s >= 1; s-- { + out = append(out, fmt.Sprintf("%d%d", r, s)) + } + } + return out +} +func buildCWRST() []string { + var out []string + for r := 5; r >= 1; r-- { + for s := 9; s >= 1; s-- { + for t := 9; t >= 1; t-- { + out = append(out, fmt.Sprintf("%d%d%d", r, s, t)) + } + } + } + return out +} +func buildDigitalRST() []string { + var out []string + for db := 30; db >= -30; db-- { + sign := "+" + if db < 0 { + sign = "-" + } + n := db + if n < 0 { + n = -n + } + out = append(out, fmt.Sprintf("%s%02d", sign, n)) + } + return out +} + // StationSettings holds the active operator profile. Used to stamp every // new QSO so we don't ask the user to retype it for each contact. // Multi-profile support (portable / SOTA …) will layer on top of this. @@ -214,7 +264,8 @@ type App struct { udpRepo *udp.Repo extsvc *extsvc.Manager startupErr string // captured for surfacing to the frontend - dbPath string + dbPath string // active database file (may be a user-chosen location) + dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while @@ -316,7 +367,7 @@ func (a *App) refreshOperatorGrid() { // without making the lookup package import dxcc. type dxccAdapter struct{ m *dxcc.Manager } -func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) { +func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) { if a.m == nil { return } @@ -324,7 +375,7 @@ func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz if !found || mm.Entity == nil { return } - return mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true + return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true } func NewApp() *App { return &App{} } @@ -343,12 +394,29 @@ func (a *App) startup(ctx context.Context) { fmt.Println("OpsLog:", a.startupErr) return } + a.dataDir = dataDir a.dbPath = filepath.Join(dataDir, "opslog.db") - // One-shot rename for users coming from the HamLog era. - if _, err := os.Stat(a.dbPath); os.IsNotExist(err) { - oldDB := filepath.Join(dataDir, "hamlog.db") - if _, err := os.Stat(oldDB); err == nil { - _ = os.Rename(oldDB, a.dbPath) + usingDefault := true + // config.json (in the data dir) may point the database to a user-chosen + // location — e.g. another drive or a synced folder, so it survives a + // Windows reinstall. It lives OUTSIDE the DB since we must know the path + // before opening it. + if custom := readDBPointer(dataDir); custom != "" { + a.dbPath = custom + usingDefault = false + } + if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil { + a.startupErr = "cannot create db folder: " + err.Error() + fmt.Println("OpsLog:", a.startupErr) + return + } + // One-shot rename for users coming from the HamLog era (default location only). + if usingDefault { + if _, err := os.Stat(a.dbPath); os.IsNotExist(err) { + oldDB := filepath.Join(dataDir, "hamlog.db") + if _, err := os.Stat(oldDB); err == nil { + _ = os.Rename(oldDB, a.dbPath) + } } } if _, err := applog.Init(dataDir); err != nil { @@ -632,6 +700,122 @@ func userDataDir() (string, error) { return newDir, nil } +// ── Database location (config.json pointer) ──────────────────────────── + +// dbPointer is the tiny bootstrap config stored in the data dir. It must +// live outside the database because we read it to decide which DB to open. +type dbPointer struct { + DBPath string `json:"db_path"` +} + +func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } + +// readDBPointer returns the user-chosen DB path, or "" for the default. +func readDBPointer(dataDir string) string { + b, err := os.ReadFile(dbPointerPath(dataDir)) + if err != nil { + return "" + } + var c dbPointer + if json.Unmarshal(b, &c) != nil { + return "" + } + return strings.TrimSpace(c.DBPath) +} + +// writeDBPointer persists the chosen DB path ("" resets to default). +func writeDBPointer(dataDir, path string) error { + b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ") + return os.WriteFile(dbPointerPath(dataDir), b, 0o644) +} + +// DatabaseSettings describes the active database file for the Settings UI. +type DatabaseSettings struct { + Path string `json:"path"` + DefaultPath string `json:"default_path"` + IsCustom bool `json:"is_custom"` +} + +// GetDatabaseSettings returns where the active database lives. +func (a *App) GetDatabaseSettings() DatabaseSettings { + def := filepath.Join(a.dataDir, "opslog.db") + return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def} +} + +// PickOpenDatabase opens a file dialog to choose an existing .db file. +func (a *App) PickOpenDatabase() (string, error) { + if a.ctx == nil { + return "", fmt.Errorf("no app context") + } + return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Open an OpsLog database", + DefaultDirectory: filepath.Dir(a.dbPath), + Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, + }) +} + +// PickSaveDatabase opens a save dialog to choose where to put a copy. +func (a *App) PickSaveDatabase() (string, error) { + if a.ctx == nil { + return "", fmt.Errorf("no app context") + } + return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + Title: "Save the OpsLog database to…", + DefaultFilename: "opslog.db", + Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, + }) +} + +// OpenDatabase points OpsLog at an existing database file. Takes effect on +// the next launch. +func (a *App) OpenDatabase(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("no path given") + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("database file not found: %w", err) + } + return writeDBPointer(a.dataDir, path) +} + +// MoveDatabase writes a clean copy of the current database to dest (which +// must not exist yet) and switches OpsLog to it on the next launch. Uses +// VACUUM INTO so the copy is consistent even with an open WAL. +func (a *App) MoveDatabase(dest string) error { + dest = strings.TrimSpace(dest) + if dest == "" { + return fmt.Errorf("no destination given") + } + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("a file already exists at %s — pick a new name", dest) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create folder: %w", err) + } + if a.db == nil { + return fmt.Errorf("database not open") + } + // VACUUM INTO takes a string literal; escape single quotes in the path. + safe := strings.ReplaceAll(dest, "'", "''") + if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil { + return fmt.Errorf("copy database: %w", err) + } + return writeDBPointer(a.dataDir, dest) +} + +// ResetDatabaseToDefault clears the custom location (back to the data dir). +func (a *App) ResetDatabaseToDefault() error { + return writeDBPointer(a.dataDir, "") +} + +// QuitApp closes OpsLog (used to apply a database change on next launch). +func (a *App) QuitApp() { + if a.ctx != nil { + wruntime.Quit(a.ctx) + } +} + // reloadLookupProviders rebuilds the lookup chain from current settings. // Called at startup and after the user saves new credentials. // @@ -721,6 +905,16 @@ type StationInfoComputed struct { Lon float64 `json:"lon"` } +// ListCountries returns the DXCC entity names for the Country picker, so the +// user selects from a fixed list instead of typing (avoids typos). Empty +// until cty.dat has loaded. +func (a *App) ListCountries() []string { + if a.dxcc == nil { + return nil + } + return a.dxcc.EntityNames() +} + // ComputeStationInfo resolves a station's structured metadata from the // callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The // frontend calls this whenever Callsign or Grid changes in the Station @@ -1646,9 +1840,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe done(matched, total) return } - // Snapshot what's already confirmed so we can flag each incoming - // confirmation as a NEW DXCC / band / slot. - sets, _ := a.qso.ConfirmedSlots(ctx) + // Snapshot award-valid confirmations (LoTW + paper QSL — the only two + // that count for ARRL awards) so each incoming one is flagged NEW. + sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) var items []ConfirmationItem perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { q, ok := adif.RecordToQSO(rec) @@ -1715,12 +1909,148 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe if a.settings != nil { _ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02")) } + + case extsvc.ServiceQRZ: + emit("Fetching QRZ.com logbook…") + fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL") + if err != nil { + emit("Fetch failed: " + err.Error()) + done(matched, total) + return + } + adifText := fr.ADIF + emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText))) + if snip := strings.TrimSpace(adifText); snip != "" { + if len(snip) > 300 { + snip = snip[:300] + } + emit("ADIF head: " + snip) + } + keyIDs, _ := a.qso.DedupeKeyIDs(ctx) + // QRZ confirmations are QRZ-specific (not award-valid), so NEW is + // judged only against other QRZ confirmations. + sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"}) + // Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED", + // without a per-record DB read. + alreadyQrz := map[int64]bool{} + if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil { + for rs.Next() { + var id int64 + if rs.Scan(&id) == nil { + alreadyQrz[id] = true + } + } + rs.Close() + } + var items []ConfirmationItem + parsed := 0 + allKeys := map[string]bool{} // union of field names seen, for diagnostics + // QRZ FETCH returns headerless ADIF (no ); prepend one so the + // parser treats the stream as records. + perr := adif.Parse(strings.NewReader("\n"+adifText), func(rec adif.Record) error { + parsed++ + for k := range rec { + allKeys[k] = true + } + if !qrzRecordConfirmed(rec) { + return nil + } + q, ok := adif.RecordToQSO(rec) + if !ok { + return nil + } + total++ + date := rec["qrzcom_qso_download_date"] + if date == "" { + date = time.Now().UTC().Format("20060102") + } + a.enrichContactedFromCty(&q) + line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s", + q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode) + key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) + id, found := keyIDs[key] + switch { + case found: + if alreadyQrz[id] { + emit(line + " ### ALREADY CONFIRMED ###") + } else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil { + alreadyQrz[id] = true + matched++ + emit(line + " ### UPDATED ###") + } + case addNotFound: + q.QRZComUploadStatus = "Y" + q.QRZComDownloadStatus = "Y" + q.QRZComDownloadDate = date + if newID, e := a.qso.Add(ctx, q); e == nil { + keyIDs[key] = newID + added++ + emit(line + " ### ADDED ###") + } + default: + emit(line + " ### NOT IN LOG ###") + } + // Result row + NEW flags. + dxccNum := 0 + if q.DXCC != nil { + dxccNum = *q.DXCC + } + it := ConfirmationItem{ + Callsign: q.Callsign, + QSODate: q.QSODate.UTC().Format(time.RFC3339), + Band: q.Band, Mode: q.Mode, Country: q.Country, + } + if dxccNum != 0 { + it.NewDXCC = !sets.DXCC[dxccNum] + it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] + it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] + sets.DXCC[dxccNum] = true + sets.Band[qso.BandKey(dxccNum, q.Band)] = true + sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true + } + items = append(items, it) + return nil + }) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items) + } + if perr != nil { + emit("Parse error: " + perr.Error()) + } + // Diagnostic: the union of every field name QRZ returned, so we can + // pin the confirmation marker against real data. + keys := make([]string, 0, len(allKeys)) + for k := range allKeys { + keys = append(keys, k) + } + sort.Strings(keys) + emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", "))) + emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) + default: emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) } done(matched+added, total) } +// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a +// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we +// accept the likely candidates; the download's one-time field dump lets us +// pin the exact field against real data and tighten this if needed. +func qrzRecordConfirmed(rec adif.Record) bool { + if strings.EqualFold(rec["qsl_rcvd"], "Y") { + return true + } + if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") { + return true + } + switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) { + case "C", "Y": + return true + } + return false +} + // enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones // from cty.dat (offline) — used when adding a not-found confirmation that // only carries call/band/mode/date. @@ -2444,12 +2774,30 @@ func (a *App) GetListsSettings() (ListsSettings, error) { if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" { _ = json.Unmarshal([]byte(raw), &out.Modes) } + if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" { + _ = json.Unmarshal([]byte(raw), &out.RSTPhone) + } + if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" { + _ = json.Unmarshal([]byte(raw), &out.RSTCW) + } + if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" { + _ = json.Unmarshal([]byte(raw), &out.RSTDigital) + } if len(out.Bands) == 0 { out.Bands = append([]string(nil), defaultBands...) } if len(out.Modes) == 0 { out.Modes = append([]ModePreset(nil), defaultModes...) } + if len(out.RSTPhone) == 0 { + out.RSTPhone = append([]string(nil), defaultRSTPhone...) + } + if len(out.RSTCW) == 0 { + out.RSTCW = append([]string(nil), defaultRSTCW...) + } + if len(out.RSTDigital) == 0 { + out.RSTDigital = append([]string(nil), defaultRSTDigital...) + } return out, nil } @@ -2469,7 +2817,23 @@ func (a *App) SaveListsSettings(l ListsSettings) error { if err != nil { return err } - return a.settings.Set(a.ctx, keyListsModes, string(m)) + if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil { + return err + } + for k, v := range map[string][]string{ + keyListsRSTPhone: l.RSTPhone, + keyListsRSTCW: l.RSTCW, + keyListsRSTDigital: l.RSTDigital, + } { + b, err := json.Marshal(v) + if err != nil { + return err + } + if err := a.settings.Set(a.ctx, k, string(b)); err != nil { + return err + } + } + return nil } // SaveStationSettings updates only the six "basic" fields on the active @@ -2624,6 +2988,28 @@ func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) { return pst.New(s.Host, s.Port), s, nil } +// RotatorHeading is the live antenna heading for the status bar. +type RotatorHeading struct { + Enabled bool `json:"enabled"` + OK bool `json:"ok"` + Azimuth int `json:"azimuth"` + Raw string `json:"raw"` +} + +// GetRotatorHeading queries PstRotator for the current azimuth. Returns +// Enabled=false when the rotator isn't configured. Polled by the status bar. +func (a *App) GetRotatorHeading() RotatorHeading { + s, err := a.GetRotatorSettings() + if err != nil || !s.Enabled { + return RotatorHeading{Enabled: false} + } + az, raw, herr := pst.New(s.Host, s.Port).Heading() + if herr != nil { + return RotatorHeading{Enabled: true, OK: false, Raw: raw} + } + return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw} +} + // RotatorGoTo points the antenna at the given azimuth (and optional // elevation if the rotator is configured for it). func (a *App) RotatorGoTo(az int, el int) error { @@ -2891,6 +3277,28 @@ func (a *App) SendClusterCommand(cmd string) error { return fmt.Errorf("no enabled cluster server to send to") } +// SendClusterSpot announces a DX spot on the **master** cluster (first +// enabled server). Format is the universal DXSpider/AR-Cluster command +// `DX `. The frequency is taken in kHz; call is +// upper-cased; comment is optional (commonly the mode, e.g. "CW"). +func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error { + call = strings.ToUpper(strings.TrimSpace(call)) + if call == "" { + return fmt.Errorf("callsign required") + } + if freqKHz <= 0 { + return fmt.Errorf("invalid frequency") + } + // Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0), + // but keep sub-kHz precision when present (e.g. 10138.7). + freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64) + cmd := fmt.Sprintf("DX %s %s", freqStr, call) + if c := strings.TrimSpace(comment); c != "" { + cmd += " " + c + } + return a.SendClusterCommand(cmd) +} + // GetClusterStatus returns a snapshot of every active session. Used by // the UI on mount and to hydrate after a `cluster:state` event. func (a *App) GetClusterStatus() []cluster.ServerStatus { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 373988b..02cee49 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock, - Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X, + Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X, } from 'lucide-react'; import { @@ -14,24 +14,25 @@ import { SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, RefreshCtyDat, - RotatorGoTo, RotatorStop, + RotatorGoTo, RotatorStop, GetRotatorHeading, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, - ListClusterServers, ClusterSpotStatuses, + ListClusterServers, ClusterSpotStatuses, SendClusterSpot, GetCATSettings, OperatingDefaultForBand, LogUDPLoggedADIF, + ListCountries, } from '../wailsjs/go/main/App'; +import { Combobox } from '@/components/ui/combobox'; import { EventsOn } from '../wailsjs/runtime/runtime'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import { Menubar, type Menu } from '@/components/Menubar'; -import { QSLManagerModal } from '@/components/QSLManagerModal'; +import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { QSOEditModal } from '@/components/QSOEditModal'; -import { BandSlotGrid } from '@/components/BandSlotGrid'; import { BandMap } from '@/components/BandMap'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { ShutdownProgress } from '@/components/ShutdownProgress'; @@ -39,6 +40,7 @@ import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; +import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -54,6 +56,7 @@ import { } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { pathBetween } from '@/lib/maidenhead'; +import { flagURL } from '@/lib/flags'; type QSO = QSOForm; type ImportResult = adifModels.ImportResult; @@ -141,6 +144,38 @@ function shortCatError(err?: string): string { if (e.includes('coinitialize')) return 'COM error'; return err.length > 24 ? err.slice(0, 22) + '…' : err; } +// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside +// every known allocation (used to auto-fill the band when the freq changes). +function bandForMHz(mhz: number): string { + if (!mhz || isNaN(mhz)) return ''; + const plan: [number, number, string][] = [ + [1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'], + [10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'], + [24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'], + [144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'], + ]; + for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b; + return ''; +} + +// rstCategory buckets a mode into the report family used for its RST list. +type RSTLists = { phone: string[]; cw: string[]; digital: string[] }; +function rstCategory(mode: string): keyof RSTLists { + const m = (mode || '').toUpperCase(); + const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR']; + if (digital.includes(m)) return 'digital'; + if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; + return 'phone'; +} +// rstOptions returns the valid report choices for a mode from the user's +// editable lists (Settings → Modes), with a tiny fallback before they load. +function rstOptions(mode: string, lists: RSTLists): string[] { + const cat = rstCategory(mode); + const l = lists[cat]; + if (l && l.length) return l; + return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20']; +} + function computePrefix(call: string): string { if (!call) return ''; const c = call.trim().toUpperCase().split('/')[0]; @@ -159,6 +194,8 @@ export default function App() { // === 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); @@ -228,6 +265,10 @@ export default function App() { 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(''); @@ -248,6 +289,7 @@ export default function App() { // CAT — receives live rig state via Wails events. const [catState, setCatState] = useState({ enabled: false, connected: false } as any); + const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // in Preferences > Hardware > CAT interface. @@ -360,11 +402,24 @@ export default function App() { const [qsos, setQsos] = useState([]); const [total, setTotal] = useState(0); const [error, setError] = useState(''); + // Transient success toast (bottom-right, auto-dismiss). Used for things + // like "spot sent" where a blocking error banner would be overkill. + const [toast, setToast] = useState(''); + const showToast = useCallback((msg: string) => { + setToast(msg); + window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); + }, []); const [saving, setSaving] = useState(false); const [filterCallsign, setFilterCallsign] = useState(''); const [filterBand, setFilterBand] = useState(''); const [filterMode, setFilterMode] = useState(''); const [activeTab, setActiveTab] = useState('recent'); + // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. + const [qslTabOpen, setQslTabOpen] = useState(false); + function closeQslTab() { + setQslTabOpen(false); + setActiveTab((t) => (t === 'qsl' ? 'recent' : t)); + } // Recent QSOs row cap, persisted. With AG Grid's virtual scroller // huge logs render OK once loaded, but a 25k+ logbook still takes a // couple of seconds to round-trip from SQLite at launch. Defaulting @@ -410,6 +465,8 @@ export default function App() { retries?: number; }; const [clusterServerStatuses, setClusterServerStatuses] = useState([]); + // "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up. + const [showSpotModal, setShowSpotModal] = useState(false); const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]); // Ring buffer — only keep the last N spots; cluster firehose can be heavy. const [spots, setSpots] = useState([]); @@ -449,7 +506,6 @@ export default function App() { // close so the next plain "Preferences" launch reverts to default. const [settingsSection, setSettingsSection] = useState(undefined); const [showDeleteAll, setShowDeleteAll] = useState(false); - const [showQSLManager, setShowQSLManager] = useState(false); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); @@ -518,6 +574,44 @@ export default function App() { return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); }; }, [refresh]); + // Poll PstRotator for the live antenna heading (status bar). Cheap when the + // rotator is disabled (the backend just reads settings and returns). + useEffect(() => { + let alive = true; + const tick = async () => { + try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {} + }; + tick(); + const id = window.setInterval(tick, 3000); + return () => { alive = false; window.clearInterval(id); }; + }, []); + + // RX band auto-follows the TX band (only differs for cross-band work). + useEffect(() => { setBandRx(band); }, [band]); + + // RX freq mirrors TX freq on every TX change (unless the rig is in split, + // where the RX freq is genuinely different). It stays editable by hand: + // a manual RX edit sticks until the next TX-freq change re-syncs it. + useEffect(() => { + if (!catState.split) setRxFreqMhz(freqMhz); + }, [freqMhz, catState.split]); + + // Load the DXCC country list for the Country picker. cty.dat loads a few + // seconds after startup, so retry until it's available. + useEffect(() => { + let tries = 0; + let timer = 0; + const load = async () => { + try { + const c = await ListCountries(); + if (c && c.length) { setCountries(c); return; } + } catch {} + if (tries++ < 15) timer = window.setTimeout(load, 2000); + }; + load(); + return () => { if (timer) window.clearTimeout(timer); }; + }, []); + const loadStation = useCallback(async () => { try { setStation(await GetStationSettings()); } catch {} }, []); @@ -530,6 +624,7 @@ export default function App() { 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); @@ -581,14 +676,14 @@ export default function App() { if (!lk.freq && s.freq_hz && s.freq_hz > 0) { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); } - // RX freq (split only): backend follows ADIF — freq_hz = TX, - // freq_rx_hz = RX. Only set when the rig is in split, otherwise the - // field would duplicate TX for no reason. The freq lock covers both. + // RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX. + // In split we take the rig's real RX freq; otherwise RX mirrors TX + // (the user can still override it by hand). The freq lock covers both. if (!lk.freq) { if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) { setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5)); - } else { - setRxFreqMhz(''); + } 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); @@ -736,7 +831,7 @@ export default function App() { callsign: callsign.trim().toUpperCase(), qso_date: start.toISOString(), qso_date_off: end.toISOString(), - band, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz, + 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, @@ -981,9 +1076,8 @@ export default function App() { { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' }, { type: 'separator' }, { type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' }, - { type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true }, - { type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true }, - { type: 'item', label: 'Rotator', action: 'tools.rotator', disabled: true }, + { type: 'item', label: 'CAT interface…', action: 'tools.cat' }, + { type: 'item', label: 'Rotator…', action: 'tools.rotator' }, { type: 'separator' }, // Maintenance — bumped here while we only have one entry. Will move // to a Tools → Maintenance submenu once Clublog + LoTW refresh land. @@ -1004,8 +1098,10 @@ export default function App() { case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break; case 'edit.prefs': setShowSettings(true); break; - case 'tools.qslmanager': setShowQSLManager(true); break; + case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break; + case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break; + case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break; case 'tools.refreshCty': refreshCtyDat(); break; } } @@ -1097,9 +1193,20 @@ export default function App() { {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 ( + + ); + })} +
+ )}
- {band} - {mode} {/* 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. */} @@ -1151,51 +1258,6 @@ export default function App() {
); })()} - {catState.enabled && ( - <> - - - {catState.connected - ? (catState.rig || 'CAT') - : (shortCatError(catState.error) || 'CAT off')} - - {catState.backend === 'omnirig' && ( -
- {[1, 2].map((n) => { - const active = (catState.rig_num || 1) === n; - return ( - - ); - })} -
- )} - - )}
@@ -1251,12 +1313,23 @@ export default function App() {
)} + {/* Transient success toast (bottom-right). */} + {toast && ( +
+ + {toast} + +
+ )} + {/* ===== ENTRY STRIP ===== Enter from any inside the strip logs the QSO. Radix Selects render as +
+ {/* Send DX spot — only when a cluster is connected. Pre-fills the + dialog from the current entry (or the last logged QSO). */} + {clusterServerStatuses.some((s) => s.state === 'connected') && ( + + )} + +
- {/* In compact mode the entry strip is the whole app — hide everything - else and let the user re-expand with the topbar toggle. */} - {compact ? null : <> - {/* ===== BAND/SLOT GRID ===== */} - {/* QRZ profile picture sits next to the matrix when the user has - opted in (Settings → Lookup → Show QRZ profile pictures). The - backend returns image_url="" when the toggle is off, so we - don't need to re-check the setting here. */} -
-
- + {/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail + tabs, then reserved free space. Hidden in compact mode. */} + {!compact && ( +
+
- {lookupResult?.image_url && ( - { - e.preventDefault(); - OpenExternalURL(lookupResult.image_url!).catch((err) => setError(String(err?.message ?? err))); - }} + )} + {/* Reserved free space to the right — shows the QRZ profile photo large + so it's actually legible. Click opens the full-size image on QRZ. */} + {!compact && lookupResult?.image_url && ( + - - {/* ===== F2-F5 DETAILS ===== */} - + +
+ )} +
{/* /entry + aside row */} {/* ===== LOWER: tabs+table | call history | (optional) band map ===== */} + {compact ? null : <>
@@ -1540,6 +1636,21 @@ export default function App() { Awards Propagation + {qslTabOpen && ( + + QSL Manager + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeQslTab(); }} + > + + + + )} @@ -1949,6 +2060,15 @@ export default function App() { + {/* Opened on demand from Tools → QSL Manager; closable via the + tab's × . forceMount keeps its state (and a running download + updating) while you work on other tabs. */} + {qslTabOpen && ( + + + + )} + {(['main','awards','propagation'] as const).map((t) => ( @@ -1985,9 +2105,87 @@ export default function App() {
} + {/* ===== STATUS BAR ===== */} + {!compact && (() => { + const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected'); + const catUp = catState.enabled && catState.connected; + const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => ( + + ); + return ( +
+ + QSO count {total.toLocaleString('en-US')} + +
+ setActiveTab('cluster')} /> + {catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}} + title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')} + onClick={() => { setSettingsSection('cat'); setShowSettings(true); }} + /> + { setSettingsSection('rotator'); setShowSettings(true); }} + /> +
+
+ ); + })()} + {editingQSO && ( setEditingQSO(null)} /> )} + + setShowSpotModal(false)} + // Callsign: the QSO-entry call, else the last logged QSO. + defaultCall={callsign.trim() || qsos[0]?.callsign || ''} + // Freq: the entry TX freq (kHz), else the last logged QSO's. + defaultFreqKHz={ + parseFloat(freqMhz) > 0 + ? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10 + : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0) + } + defaultMode={mode || qsos[0]?.mode || ''} + targetName={ + clusterServers + .filter((s) => s.enabled) + .sort((a, b) => a.sort_order - b.sort_order)[0]?.name + } + recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({ + callsign: q.callsign, + freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0, + mode: q.mode ?? '', + band: q.band, + }))} + onSend={async (call, freqKHz, comment) => { + await SendClusterSpot(call, freqKHz, comment); + const target = clusterServers + .filter((s) => s.enabled) + .sort((a, b) => a.sort_order - b.sort_order)[0]?.name; + showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`); + }} + /> + {showSettings && ( { loadStation(); loadLists(); loadCATCfg(); }} /> )} - setShowQSLManager(false)} /> {deletingQSO && ( = { '6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']], }; +// Small coloured dot + label used in the band-map legend strip. +function LegendDot({ cls, label }: { cls: string; label: string }) { + return ( + + + {label} + + ); +} + +// Human-readable label for a spot status — used in the pill hover tooltip +// so the operator can see WHY a spot is coloured the way it is. +function statusLabel(s: string): string { + switch (s) { + case 'new': return 'NEW DXCC (entity never worked)'; + case 'new-band': return 'NEW BAND (entity not worked on this band)'; + case 'new-slot': return 'NEW SLOT (mode not worked on this band)'; + case 'worked': return 'Worked (this band + mode already in log)'; + default: return 'Entity not resolved'; + } +} + function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } { // pill = full pill background+text+border // bar = thick left accent inside the pill @@ -114,6 +136,13 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade const LABEL_W = 200; const TOP_PAD = 14; // px of breathing room above/below the band edges so const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0 +// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single +// watering-hole frequency and otherwise spawn hundreds of spots that fan out +// and cover the whole map. ONLY the FT family is capped — CW, SSB and other +// digital spots are always shown in full. When more than this FT spots are in +// band we keep the most useful (new entities first, worked last; ties broken +// by closeness to the rig freq). +const MAX_VISIBLE_SPOTS = 30; export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) { const range = BAND_RANGES[band]; @@ -146,19 +175,53 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o // larger). When more labels stack than fit in the band's natural pixel // span, totalH grows so scrolling reveals them. type Placed = { spot: Spot; freqY: number; labelY: number }; - const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => { + const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => { // innerH is the band's stretched pixel span; total adds top+bottom // padding so the edge freq labels aren't clipped at y=0 / y=H. const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); - if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD }; + if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 }; const seen = new Set(); - const filtered: Spot[] = []; + const inBand: Spot[] = []; for (const s of spots) { if (s.freq_khz < lo || s.freq_khz > hi) continue; if (seen.has(s.dx_call)) continue; seen.add(s.dx_call); - filtered.push(s); + inBand.push(s); } + + // Only the FT family (FT8/FT4/…) is capped — it's what floods a single + // watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is + // always shown in full. + const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz)); + const ftSpots = inBand.filter(isFlood); + const otherSpots = inBand.filter((s) => !isFlood(s)); + + // Rank an FT spot by usefulness (new entity → unworked → worked); ties + // break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS. + const rank = (s: Spot) => { + const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); + switch (spotStatus[k]?.status ?? '') { + case 'new': return 0; + case 'new-band': return 1; + case 'new-slot': return 2; + case 'worked': return 4; + default: return 3; + } + }; + let keptFt = ftSpots; + let hiddenCount = 0; + if (ftSpots.length > MAX_VISIBLE_SPOTS) { + const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2; + keptFt = [...ftSpots] + .sort((a, b) => { + const r = rank(a) - rank(b); + if (r !== 0) return r; + return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK); + }) + .slice(0, MAX_VISIBLE_SPOTS); + hiddenCount = ftSpots.length - keptFt.length; + } + const filtered = [...otherSpots, ...keptFt]; filtered.sort((a, b) => b.freq_khz - a.freq_khz); // Desired pill-CENTRE Y for each spot = its true frequency's Y. @@ -206,14 +269,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o return { placed: out, totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD), + hidden: hiddenCount, }; - }, [spots, range, lo, hi, span, pxPerKHz, containerH]); + }, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]); // freqToY for elements rendered outside the memo (ticks, rig pointer). // Must mirror the same offset so the rig triangle sits on the right kHz. const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH; + // Auto-centre on the rig frequency when the map opens or the band changes + // (once per band, so it doesn't fight the user's manual scrolling). Waits + // for the scroller height to be measured and a valid in-band rig freq. + const centeredForRef = useRef(''); + useEffect(() => { + if (!range || containerH <= 0 || currentFreqHz <= 0) return; + const kHz = currentFreqHz / 1000; + if (kHz < lo || kHz > hi) return; + if (centeredForRef.current === band) return; + const el = scrollerRef.current; + if (!el) return; + centeredForRef.current = band; + el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2); + // freqToY is recomputed each render; intentionally excluded from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [band, containerH, currentFreqHz, range, lo, hi]); + useEffect(() => { const el = scrollerRef.current; if (!el) return; @@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o {/* Pills absolutely positioned at their (anti-overlapped) Y */} {placed.map((p, i) => { const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz); - const st = spotStatus[k]?.status ?? ''; + const entry = spotStatus[k]; + const st = entry?.status ?? ''; const style = statusStyle(st); const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz); return ( @@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o 'hover:translate-x-0.5 hover:shadow', style.pill, )} - title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`} + title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`} > {/* Status accent strip on the left */} @@ -406,8 +488,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o })} + {/* Colour legend — what each pill colour means. */} +
+ + + + +
scroll · ctrl+wheel = zoom · ◎ = jump to rig + {hidden > 0 && · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)}
); diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index a2dbb88..c04601e 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { ChevronUp, Construction } from 'lucide-react'; +import { Construction } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; @@ -8,6 +8,7 @@ import { } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { pathBetween } from '@/lib/maidenhead'; +import { BandSlotGrid } from '@/components/BandSlotGrid'; export interface DetailsState { state: string; @@ -45,9 +46,16 @@ interface Props { remoteGrid: string; // entry-strip Grid value — destination details: DetailsState; onChange: (patch: Partial) => void; + // Stats (F1) tab content: the worked-before matrix + optional QRZ image. + wb?: any; + wbBusy?: boolean; + band: string; + mode: string; + imageUrl?: string; + onOpenImage?: () => void; } -type TabName = 'info' | 'awards' | 'my' | 'extended'; +type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended'; const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR']; @@ -67,8 +75,8 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 ); } -export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) { - const [open, setOpen] = useState(null); +export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) { + const [open, setOpen] = useState('stats'); // Bearing/distance from operator's home grid to the remote station. // Recomputed only when either grid actually changes. @@ -79,7 +87,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, const fmtDeg = (n: number) => `${Math.round(n)}°`; const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`; - function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); } + function toggle(t: TabName) { setOpen(t); } const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT'; function setSatellite(on: boolean) { @@ -94,6 +102,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, } const tabs: { key: TabName; label: string }[] = [ + { key: 'stats', label: 'Stats (F1)' }, { key: 'info', label: 'Info (F2)' }, { key: 'awards', label: 'Awards (F3)' }, { key: 'my', label: 'My (F4)' }, @@ -101,8 +110,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, ]; return ( -
-