From e8cac569e3532820e5483d486768caf3bfab04de Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 28 May 2026 18:35:22 +0200 Subject: [PATCH] rigs completed --- app.go | 476 +++++++++++++++- frontend/package-lock.json | 57 ++ frontend/package.json | 2 + frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 444 ++++++--------- frontend/src/components/ClusterGrid.tsx | 434 +++++++++++++++ frontend/src/components/Menubar.tsx | 27 +- frontend/src/components/OperatingPanel.tsx | 451 +++++++++++++++ frontend/src/components/RecentQSOsGrid.tsx | 373 +++++++++++++ frontend/src/components/SettingsModal.tsx | 519 +++++++++++++++--- frontend/src/components/ShutdownProgress.tsx | 66 +++ frontend/src/components/WorkedBeforeGrid.tsx | 297 ++++++++++ frontend/wailsjs/go/main/App.d.ts | 21 + frontend/wailsjs/go/main/App.js | 40 ++ frontend/wailsjs/go/models.ts | 142 +++++ go.mod | 2 +- internal/adif/import.go | 56 +- internal/adif/parser.go | 21 +- internal/backup/backup.go | 200 +++++++ internal/cluster/cluster.go | 36 +- internal/db/migrations/0008_operating.sql | 50 ++ .../db/migrations/0009_operating_fk_fix.sql | 42 ++ .../db/migrations/0010_operating_simpler.sql | 51 ++ internal/operating/operating.go | 324 +++++++++++ internal/qso/qso.go | 80 ++- main.go | 12 +- 26 files changed, 3834 insertions(+), 391 deletions(-) create mode 100644 frontend/src/components/ClusterGrid.tsx create mode 100644 frontend/src/components/OperatingPanel.tsx create mode 100644 frontend/src/components/RecentQSOsGrid.tsx create mode 100644 frontend/src/components/ShutdownProgress.tsx create mode 100644 frontend/src/components/WorkedBeforeGrid.tsx create mode 100644 internal/backup/backup.go create mode 100644 internal/db/migrations/0008_operating.sql create mode 100644 internal/db/migrations/0009_operating_fk_fix.sql create mode 100644 internal/db/migrations/0010_operating_simpler.sql create mode 100644 internal/operating/operating.go diff --git a/app.go b/app.go index 2083e09..cfb9733 100644 --- a/app.go +++ b/app.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "strconv" @@ -13,9 +14,11 @@ import ( "time" "hamlog/internal/adif" + "hamlog/internal/backup" "hamlog/internal/cat" "hamlog/internal/cluster" "hamlog/internal/db" + "hamlog/internal/operating" "hamlog/internal/dxcc" "hamlog/internal/lookup" "hamlog/internal/profile" @@ -63,6 +66,12 @@ const ( keyRotatorHasElevation = "rotator.has_elevation" keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start + + keyBackupEnabled = "backup.enabled" + keyBackupFolder = "backup.folder" + keyBackupRotation = "backup.rotation" + keyBackupZip = "backup.zip" + keyBackupLast = "backup.last_at" ) // CATSettings is the user-tweakable rig-control configuration. Stored as @@ -145,8 +154,104 @@ type App struct { cat *cat.Manager dxcc *dxcc.Manager cluster *cluster.Manager + operating *operating.Repo startupErr string // captured for surfacing to the frontend dbPath string + + // shuttingDown gates beforeClose re-entry: the first user attempt to + // close fires shutdown tasks (backup, future LoTW upload, ...) while + // blocking the window close; the subsequent programmatic Quit() call + // must be allowed through. + shuttingDown bool + + // Cached operator location used to compute distance/bearing for + // cluster spots. Refreshed on profile activation; zero means + // "unknown" and we skip the per-spot computation. + opLat float64 + opLon float64 + opSet bool +} + +// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the +// centre lat/lon in degrees. Returns ok=false on malformed input. +func gridToLatLon(grid string) (lat, lon float64, ok bool) { + g := strings.ToUpper(strings.TrimSpace(grid)) + if len(g) < 4 { + return 0, 0, false + } + A := g[0] - 'A' + B := g[1] - 'A' + C := g[2] - '0' + D := g[3] - '0' + if A > 17 || B > 17 || C > 9 || D > 9 { + return 0, 0, false + } + lon = -180 + float64(A)*20 + float64(C)*2 + lat = -90 + float64(B)*10 + float64(D)*1 + if len(g) >= 6 { + E := g[4] - 'A' + F := g[5] - 'A' + if E <= 23 && F <= 23 { + lon += float64(E)*(5.0/60.0) + 2.5/60.0 + lat += float64(F)*(2.5/60.0) + 1.25/60.0 + return lat, lon, true + } + } + // 4-char locator: aim at the centre of the square. + lon += 1 + lat += 0.5 + return lat, lon, true +} + +// haversineKm returns the great-circle distance between two lat/lon pairs +// in kilometres. Standard Haversine, mean Earth radius 6371 km. +func haversineKm(lat1, lon1, lat2, lon2 float64) float64 { + const R = 6371.0 + rad := math.Pi / 180.0 + dLat := (lat2 - lat1) * rad + dLon := (lon2 - lon1) * rad + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return R * c +} + +// initialBearingDeg returns the initial great-circle bearing (azimuth) in +// degrees [0, 360) from (lat1, lon1) towards (lat2, lon2). This is the +// "short path" heading. +func initialBearingDeg(lat1, lon1, lat2, lon2 float64) float64 { + rad := math.Pi / 180.0 + dLon := (lon2 - lon1) * rad + y := math.Sin(dLon) * math.Cos(lat2*rad) + x := math.Cos(lat1*rad)*math.Sin(lat2*rad) - + math.Sin(lat1*rad)*math.Cos(lat2*rad)*math.Cos(dLon) + deg := math.Atan2(y, x) / rad + if deg < 0 { + deg += 360 + } + return deg +} + +// refreshOperatorGrid reloads the active profile and caches its grid as +// lat/lon. Called at startup and after profile activation so the cluster +// onSpot callback can compute distance/bearing without hitting the DB +// per spot. +func (a *App) refreshOperatorGrid() { + a.opSet = false + if a.profiles == nil || a.ctx == nil { + return + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return + } + lat, lon, ok := gridToLatLon(p.MyGrid) + if !ok { + return + } + a.opLat = lat + a.opLon = lon + a.opSet = true } // dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface @@ -191,6 +296,7 @@ func (a *App) startup(ctx context.Context) { a.qso = qso.NewRepo(conn) a.settings = settings.NewStore(conn) a.profiles = profile.NewRepo(conn) + a.operating = operating.NewRepo(conn) // On first run, copy the legacy single-station settings into a // "Default" profile so the user's existing config carries over without // any manual step. Subsequent runs just confirm an active profile. @@ -238,6 +344,14 @@ func (a *App) startup(ctx context.Context) { if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil { s.Country = m.Entity.Name s.Continent = m.Continent + s.CQZone = m.CQZone + s.ITUZone = m.ITUZone + if a.opSet && (m.Lat != 0 || m.Lon != 0) { + s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5) + sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon) + s.ShortPath = int(sp + 0.5) + s.LongPath = (s.ShortPath + 180) % 360 + } } } if a.ctx != nil { @@ -250,6 +364,7 @@ func (a *App) startup(ctx context.Context) { } }, ) + a.refreshOperatorGrid() if cs, _ := a.clusterAutoConnect(); cs { a.startAllEnabledClusters() } @@ -275,7 +390,118 @@ func (a *App) GetStartupStatus() StartupStatus { } } +// beforeClose intercepts the window-close event so we can run shutdown +// tasks (backup, future LoTW upload, ...) while showing a progress modal +// to the user. Returns true the first time to block the close; the +// goroutine eventually calls wruntime.Quit() which re-enters this method +// with shuttingDown=true and we let the close proceed. +func (a *App) beforeClose(ctx context.Context) bool { + if a.shuttingDown { + return false + } + a.shuttingDown = true + + steps := a.plannedShutdownSteps() + if len(steps) == 0 { + // Nothing to do — exit immediately, no need to flash a modal. + return false + } + go a.runShutdownTasks(ctx, steps) + return true +} + +// shutdownStep is emitted to the frontend so the progress modal can +// render the task list and update each row's state as work progresses. +type shutdownStep struct { + ID string `json:"id"` + Label string `json:"label"` + Status string `json:"status"` // "pending" | "running" | "done" | "error" + Detail string `json:"detail,omitempty"` +} + +// plannedShutdownSteps returns the tasks that will actually run, so the +// UI knows the full checklist up front. Right now that's just the backup +// (when enabled and not yet done today); LoTW upload, eQSL upload, etc. +// will append to this list as they land. +func (a *App) plannedShutdownSteps() []shutdownStep { + var out []shutdownStep + if s, err := a.GetBackupSettings(); err == nil && s.Enabled { + folder := s.Folder + if folder == "" { + folder = s.DefaultFolder + } + if !backup.HasBackupToday(folder) { + out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"}) + } + } + return out +} + +func (a *App) emitShutdownEvent(name string, payload any) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, name, payload) + } +} + +// runShutdownTasks executes every planned shutdown task in order, +// emitting progress events at each transition so the frontend modal +// stays in sync. Errors don't abort the sequence — we still want to +// give later steps a chance and ultimately close the app. +func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) { + a.emitShutdownEvent("shutdown:start", steps) + for i := range steps { + steps[i].Status = "running" + a.emitShutdownEvent("shutdown:update", steps) + var err error + switch steps[i].ID { + case "backup": + err = a.runBackupForShutdown() + } + if err != nil { + steps[i].Status = "error" + steps[i].Detail = err.Error() + } else { + steps[i].Status = "done" + } + a.emitShutdownEvent("shutdown:update", steps) + } + a.emitShutdownEvent("shutdown:done", steps) + // Give the UI a moment to show the "done" state before we yank the + // window away. 600ms feels purposeful without being annoying. + time.Sleep(600 * time.Millisecond) + wruntime.Quit(ctx) +} + +// runBackupForShutdown is the same logic as maybeShutdownBackup but +// returns an error so the shutdown sequence can mark the step as failed. +func (a *App) runBackupForShutdown() error { + if a.settings == nil || a.db == nil { + return fmt.Errorf("db not ready") + } + s, err := a.GetBackupSettings() + if err != nil { + return err + } + folder := s.Folder + if folder == "" { + folder = s.DefaultFolder + } + if backup.HasBackupToday(folder) { + return nil + } + if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { + return err + } + return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) +} + func (a *App) shutdown(ctx context.Context) { + // If the user managed to skip beforeClose (force kill, OS shutdown, + // crash recovery) we still try the backup here as a best-effort + // safety net. HasBackupToday makes a double-run a no-op. + if !a.shuttingDown { + a.maybeShutdownBackup() + } if a.db != nil { _ = a.db.Close() } @@ -763,6 +989,238 @@ func (a *App) SaveCATSettings(s CATSettings) error { return nil } +// ── Operating conditions ─────────────────────────────────────────────── + +// ListOperatingTree returns the stations/antennas/bands tree for the +// active profile. The UI renders the Settings tree from this. +func (a *App) ListOperatingTree() ([]operating.Station, error) { + if a.operating == nil || a.profiles == nil { + return nil, fmt.Errorf("db not initialized") + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return nil, err + } + return a.operating.ListTree(a.ctx, p.ID) +} + +// SaveOperatingStation upserts a station. profile_id is set from the +// active profile if zero so the frontend doesn't have to know about it. +func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) { + if a.operating == nil || a.profiles == nil { + return s, fmt.Errorf("db not initialized") + } + if s.ProfileID == 0 { + p, err := a.profiles.Active(a.ctx) + if err != nil { + return s, err + } + s.ProfileID = p.ID + } + if err := a.operating.SaveStation(a.ctx, &s); err != nil { + return s, err + } + return s, nil +} + +// DeleteOperatingStation cascades to antennas + bands. +func (a *App) DeleteOperatingStation(id int64) error { + if a.operating == nil { + return fmt.Errorf("db not initialized") + } + return a.operating.DeleteStation(a.ctx, id) +} + +// SaveOperatingAntenna upserts an antenna and replaces its band list. +// Setting is_default on a band clears the flag from any other antenna +// on the same band within this profile. +func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) { + if a.operating == nil { + return ant, fmt.Errorf("db not initialized") + } + if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil { + return ant, err + } + return ant, nil +} + +// DeleteOperatingAntenna cascades to bands. +func (a *App) DeleteOperatingAntenna(id int64) error { + if a.operating == nil { + return fmt.Errorf("db not initialized") + } + return a.operating.DeleteAntenna(a.ctx, id) +} + +// OperatingDefaultForBand returns the (station, antenna) flagged default +// for `band` in the active profile. Used by the entry strip to auto-fill +// MY_RIG and MY_ANTENNA when the user picks a band. +func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) { + if a.operating == nil || a.profiles == nil { + return operating.BandDefault{}, fmt.Errorf("db not initialized") + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return operating.BandDefault{}, err + } + d, _, err := a.operating.BandDefault(a.ctx, p.ID, band) + return d, err +} + +// ── Backup ────────────────────────────────────────────────────────────── + +// BackupSettings is the user-tweakable database backup configuration. +type BackupSettings struct { + Enabled bool `json:"enabled"` + Folder string `json:"folder"` + Rotation int `json:"rotation"` + Zip bool `json:"zip"` + LastBackupAt string `json:"last_backup_at"` + DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint +} + +// GetBackupSettings returns stored backup config with safe defaults. +func (a *App) GetBackupSettings() (BackupSettings, error) { + out := BackupSettings{ + Rotation: 5, + DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)), + } + if a.settings == nil { + return out, nil + } + m, err := a.settings.GetMany(a.ctx, + keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast) + if err != nil { + return out, err + } + out.Enabled = m[keyBackupEnabled] == "1" + out.Folder = m[keyBackupFolder] + if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 { + out.Rotation = n + } + out.Zip = m[keyBackupZip] == "1" + out.LastBackupAt = m[keyBackupLast] + return out, nil +} + +// SaveBackupSettings persists backup config (no immediate backup — +// trigger it explicitly with RunBackupNow). +func (a *App) SaveBackupSettings(s BackupSettings) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + if s.Rotation <= 0 { + s.Rotation = 5 + } + enabled := "0" + if s.Enabled { + enabled = "1" + } + doZip := "0" + if s.Zip { + doZip = "1" + } + for k, v := range map[string]string{ + keyBackupEnabled: enabled, + keyBackupFolder: strings.TrimSpace(s.Folder), + keyBackupRotation: strconv.Itoa(s.Rotation), + keyBackupZip: doZip, + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + return nil +} + +// RunBackupNow forces an immediate backup using the persisted settings. +// Returns the destination path of the file that was written. +func (a *App) RunBackupNow() (string, error) { + s, err := a.GetBackupSettings() + if err != nil { + return "", err + } + folder := s.Folder + if folder == "" { + folder = s.DefaultFolder + } + path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip) + if err != nil { + return path, err + } + _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) + return path, nil +} + +// maybeShutdownBackup runs a backup at shutdown if the user enabled it +// and no backup for today already exists. Running at shutdown (not at +// startup) means the snapshot includes the QSOs the user just logged +// this session — exactly what we want to protect. Errors are printed +// but never block the close. +func (a *App) maybeShutdownBackup() { + if a.settings == nil || a.db == nil { + return + } + s, err := a.GetBackupSettings() + if err != nil || !s.Enabled { + return + } + folder := s.Folder + if folder == "" { + folder = s.DefaultFolder + } + if backup.HasBackupToday(folder) { + return + } + if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { + fmt.Println("HamLog: shutdown backup failed:", err) + return + } + _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) +} + +// PickBackupFolder opens a native directory picker so the user can browse +// to a backup target rather than typing the path. Returns the absolute +// path (or empty string if the dialog was cancelled). +// +// Windows' shell dialog refuses to open when DefaultDirectory points at +// a path that doesn't exist yet (typical for our default backups folder +// on first launch). We walk up the path until we find an existing +// ancestor and use that as the dialog's starting point. +func (a *App) PickBackupFolder() (string, error) { + if a.ctx == nil { + return "", fmt.Errorf("no app context") + } + current, _ := a.GetBackupSettings() + defaultDir := current.Folder + if defaultDir == "" { + defaultDir = current.DefaultFolder + } + defaultDir = firstExistingAncestor(defaultDir) + return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Pick a folder for HamLog backups", + DefaultDirectory: defaultDir, + }) +} + +// firstExistingAncestor returns p if it exists, otherwise the closest +// parent directory that does. Returns "" if nothing valid is found (the +// dialog then opens at the OS default location). +func firstExistingAncestor(p string) string { + p = strings.TrimSpace(p) + for p != "" { + if st, err := os.Stat(p); err == nil && st.IsDir() { + return p + } + parent := filepath.Dir(p) + if parent == p { + break + } + p = parent + } + return "" +} + // GetCATState returns the current snapshot from the CAT manager. Used by the // frontend on mount before any cat:state event has been emitted. func (a *App) GetCATState() cat.RigState { @@ -998,6 +1456,7 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) { if err := a.profiles.Save(a.ctx, &p); err != nil { return profile.Profile{}, err } + a.refreshOperatorGrid() return p, nil } @@ -1016,7 +1475,11 @@ func (a *App) ActivateProfile(id int64) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } - return a.profiles.SetActive(a.ctx, id) + if err := a.profiles.SetActive(a.ctx, id); err != nil { + return err + } + a.refreshOperatorGrid() + return nil } // DuplicateProfile clones an existing profile under newName. Useful when @@ -1396,6 +1859,10 @@ type SpotStatus struct { Country string `json:"country,omitempty"` Continent string `json:"continent,omitempty"` Status string `json:"status"` + // WorkedCall is true when this exact callsign exists in the log + // (any band, any mode). Drives the per-call text highlight, in + // addition to the entity-level Status (NEW / NEW BAND / …). + WorkedCall bool `json:"worked_call"` } // ClusterSpotStatuses takes a batch of spots and returns slot status for @@ -1428,12 +1895,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { if err != nil { return out } + // Per-call worked set — separate from the entity check so we can flag + // "I've already QSO'd this exact station" even when the band/mode + // makes the entity check say "new-band" or "new-slot". + workedCalls, _ := a.qso.WorkedCallsigns(a.ctx) for i, q := range spots { out[i] = SpotStatus{ Call: q.Call, Band: strings.ToLower(q.Band), Mode: strings.ToUpper(q.Mode), } + if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok { + out[i].WorkedCall = true + } if a.dxcc == nil { continue } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 157abae..44267d8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,8 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "ag-grid-community": "^35.3.0", + "ag-grid-react": "^35.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", @@ -2641,6 +2643,35 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ag-charts-types": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.3.0.tgz", + "integrity": "sha512-UMoAn908LC4ZIJSNfUckSBEFa79Mi1vFRA8qIRx+NusEuuFgXDioCZx4MxM7O3rDXlxTWH9DvQmcDjh7vyd89w==", + "license": "MIT" + }, + "node_modules/ag-grid-community": { + "version": "35.3.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.3.0.tgz", + "integrity": "sha512-c9WQWB88J965IjBC/GPUX30aAZix10o6oYT86DWipcxgLZTIQlLSilJJEr1bno/245rPEAIMjhoU1gp9VIfURg==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "13.3.0" + } + }, + "node_modules/ag-grid-react": { + "version": "35.3.0", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.3.0.tgz", + "integrity": "sha512-3c6YEFGQGNZxEi1PdK0b+WhKkKRJ7KxuYzsG4UmISyax5/J7N93f8B1TZK1pq+AgzPhdk/++vjZe3KhFdF3tog==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "35.3.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3302,6 +3333,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3351,6 +3391,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3376,6 +3427,12 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 56c2c45..e20fe06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "ag-grid-community": "^35.3.0", + "ag-grid-react": "^35.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 32677fe..206da80 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -58f02c99f9fceb8f5aeae2c8b90fd325 \ No newline at end of file +687705a933fcf09f20bdb5083955a417 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9ec8a3..a395174 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,8 +19,9 @@ import { ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, GetCATSettings, + OperatingDefaultForBand, } from '../wailsjs/go/main/App'; -import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; +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'; @@ -30,8 +31,11 @@ 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'; +import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; -import { CallHistoryPanel } from '@/components/CallHistoryPanel'; +import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { Button } from '@/components/ui/button'; @@ -327,6 +331,25 @@ export default function App() { const updateDetails = useCallback((patch: Partial) => { setDetails((d) => ({ ...d, ...patch })); }, []); + // Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree + // whenever the band changes. The backend resolves the "default antenna + // for this band" within the active profile and returns the (rig, + // antenna) tuple. Empty result → we DO clear the fields so leftover + // values from a previous band don't get logged against the wrong gear. + useEffect(() => { + if (!band) return; + let cancelled = false; + OperatingDefaultForBand(band).then((d) => { + if (cancelled) return; + setDetails((cur) => ({ + ...cur, + my_rig: d?.station_name || '', + my_antenna: d?.antenna_name || '', + tx_pwr: d?.tx_pwr ?? cur.tx_pwr, + })); + }).catch(() => {}); + return () => { cancelled = true; }; + }, [band]); const prefix = useMemo(() => computePrefix(callsign), [callsign]); // Bearing/distance from operator's home grid to the remote station — // shown live in the entry strip (SP azimuth) and Info tab (LP + dist). @@ -340,6 +363,16 @@ export default function App() { const [filterBand, setFilterBand] = useState(''); const [filterMode, setFilterMode] = useState(''); const [activeTab, setActiveTab] = useState('recent'); + // Recent QSOs row cap, persisted. With AG Grid's virtual scroller + // huge logs render OK once loaded, but a 25k+ logbook still takes a + // couple of seconds to round-trip from SQLite at launch. Defaulting + // to 500 keeps the first paint instant; the user can bump to "All" + // when they actually want to search history. + const [qsoLimit, setQsoLimit] = useState(() => { + const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500'); + return Number.isFinite(raw) && raw > 0 ? raw : 500; + }); + useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]); // === DX Cluster live state === type ClusterSpot = { @@ -355,6 +388,11 @@ export default function App() { 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; }; @@ -394,7 +432,7 @@ export default function App() { // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". // Keyed by `${call}|${band}|${mode}` so two spots of the same call on // different slots don't share the same colour. - const [spotStatus, setSpotStatus] = useState>({}); + const [spotStatus, setSpotStatus] = useState>({}); // === Modals === const [editingQSO, setEditingQSO] = useState(null); @@ -451,7 +489,7 @@ export default function App() { try { const list = await ListQSO({ callsign: filterCallsign, band: filterBand, mode: filterMode, - limit: 500, offset: 0, + limit: qsoLimit, offset: 0, } as any); const n = await CountQSO(); setQsos(list); @@ -460,7 +498,7 @@ export default function App() { } catch (e: any) { setError(String(e?.message ?? e)); } - }, [filterCallsign, filterBand, filterMode]); + }, [filterCallsign, filterBand, filterMode, qsoLimit]); const loadStation = useCallback(async () => { try { setStation(await GetStationSettings()); } catch {} @@ -517,7 +555,7 @@ export default function App() { // 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(() => { - EventsOn('cat:state', (s: CATState) => { + const unsub = EventsOn('cat:state', (s: CATState) => { setCatState(s); if (!s?.connected) return; if (Date.now() < catFreezeUntilRef.current) return; @@ -554,7 +592,7 @@ export default function App() { } } }); - return () => { EventsOff('cat:state'); }; + return () => { unsub?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -574,7 +612,7 @@ export default function App() { // cluster:state fires on connect/disconnect/save/delete — refresh // the saved-server list too so the source dropdown stays in sync // when the user adds, deletes or toggles a row in Settings. - EventsOn('cluster:state', async (sts: ServerStatus[]) => { + const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => { setClusterServerStatuses(sts ?? []); try { const list = await ListClusterServers(); @@ -589,13 +627,13 @@ export default function App() { const activeIds = new Set((sts ?? []).map((s) => s.server_id)); setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id))); }); - EventsOn('cluster:spot', (sp: ClusterSpot) => { + const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { setSpots((arr) => { const next = [sp, ...arr]; return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next; }); }); - return () => { EventsOff('cluster:state'); EventsOff('cluster:spot'); }; + return () => { unsubState?.(); unsubSpot?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -623,7 +661,12 @@ export default function App() { const next = { ...prev }; for (const r of res) { const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; - next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent }; + next[k] = { + status: r.status ?? '', + country: r.country, + continent: (r as any).continent, + worked_call: !!(r as any).worked_call, + }; } return next; }); @@ -955,6 +998,7 @@ export default function App() { return (
+ {/* ===== TOPBAR ===== */} {compact ? ( // Minimal compact topbar — brand + freq + toggle. Saves vertical space @@ -1127,10 +1171,6 @@ export default function App() { Set station )} -
-
{total.toLocaleString('en-US')}
-
Total QSOs
-
+
@@ -1737,205 +1761,74 @@ export default function App() { -
- {(() => { - // Apply every filter. `bandsActive` is the band set the - // user clicked, OR the entry's locked band when Lock band - // is on. Mode lock compares the spot's inferred mode to - // the entry's current one. - const bandsActive = clusterLockBand - ? new Set([band]) - : clusterBands; - const search = clusterSearch.trim().toUpperCase(); - let list = spots.filter((s) => { - if (clusterFilterSource && s.source_id !== clusterFilterSource) return false; - if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false; - if (search && !s.dx_call.includes(search)) return false; - if (clusterLockMode) { - const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz); - // Treat empty inferred mode as wildcard so we don't - // hide perfectly good spots just because the comment - // was ambiguous. - if (spotMode && mode && spotMode !== mode) return false; - } - if (clusterStatusFilter.size > 0) { - const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); - const st = spotStatus[k]?.status || ''; - if (!clusterStatusFilter.has(st as SpotStatusKey)) return false; - } - return true; - }); - let rendered = list as (ClusterSpot & { repeats?: number })[]; - if (clusterGroup) { - const seen = new Map(); - for (const s of list) { - const e = seen.get(s.dx_call); - if (e) { e.repeats++; } - else seen.set(s.dx_call, { ...s, repeats: 1 }); - } - rendered = Array.from(seen.values()); + {(() => { + // Apply every filter. `bandsActive` is the band set the + // user clicked, OR the entry's locked band when Lock band + // is on. Mode lock compares the spot's inferred mode to + // the entry's current one. + const bandsActive = clusterLockBand + ? new Set([band]) + : clusterBands; + const search = clusterSearch.trim().toUpperCase(); + let list = spots.filter((s) => { + if (clusterFilterSource && s.source_id !== clusterFilterSource) return false; + if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false; + if (search && !s.dx_call.includes(search)) return false; + if (clusterLockMode) { + const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz); + if (spotMode && mode && spotMode !== mode) return false; } - // Apply sort. Time defaults to descending (newest first). - const dir = clusterSort.dir === 'asc' ? 1 : -1; - const cmp = (a: any, b: any) => (a < b ? -dir : a > b ? dir : 0); - rendered = [...rendered].sort((a, b) => { - switch (clusterSort.key) { - case 'time': return cmp(a.received_at, b.received_at); - case 'call': return cmp(a.dx_call, b.dx_call); - case 'freq': return cmp(a.freq_khz, b.freq_khz); - case 'band': return cmp(a.band ?? '', b.band ?? ''); - case 'mode': return cmp(inferSpotMode(a.comment ?? '', a.freq_hz), inferSpotMode(b.comment ?? '', b.freq_hz)); - case 'spotter': return cmp(cleanSpotter(a.spotter), cleanSpotter(b.spotter)); - case 'source': return cmp(a.source_name, b.source_name); - } - }); - 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).'} -
-
- ); - } - const headers: { key: SortKey | null; label: string; align?: 'right' }[] = [ - { key: 'time', label: 'Time' }, - { key: 'call', label: 'Call' }, - { key: 'freq', label: 'Freq', align: 'right' }, - { key: 'band', label: 'Band' }, - { key: 'mode', label: 'Mode' }, - { key: null, label: 'Country' }, - { key: null, label: 'Cont' }, - { key: 'spotter', label: 'Spotter' }, - { key: 'source', label: 'Source' }, - { key: null, label: 'Loc' }, - { key: null, label: 'Comment' }, - ]; - const toggleSort = (k: SortKey) => setClusterSort((s) => - s.key === k - ? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' } - : { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' }); - // Log4OM-style per-cell highlight: the badge that matches - // the "what's new" gets coloured instead of the whole row. - // CALL = new entity, BAND = new band for entity, MODE = new - // mode for that band (NEW SLOT — Log4OM doesn't show this - // but the user wants it). - const cellHL = (s: ClusterSpot) => { + if (clusterStatusFilter.size > 0) { const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); - return spotStatus[k]?.status ?? ''; - }; + const st = spotStatus[k]?.status || ''; + if (!clusterStatusFilter.has(st as SpotStatusKey)) return false; + } + return true; + }); + let rendered = list as (ClusterSpot & { repeats?: number })[]; + if (clusterGroup) { + const seen = new Map(); + for (const s of list) { + const e = seen.get(s.dx_call); + if (e) { e.repeats++; } + else seen.set(s.dx_call, { ...s, repeats: 1 }); + } + rendered = Array.from(seen.values()); + } + if (rendered.length === 0) { return ( - - - - {headers.map((h, i) => { - const sortable = h.key !== null; - const active = sortable && clusterSort.key === h.key; - return ( - - ); - })} - - - - {rendered.map((s, i) => { - const hl = cellHL(s); - const callCls = hl === 'new' - ? 'bg-rose-100 text-rose-800 hover:bg-rose-200 border border-rose-300' - : 'text-primary'; - const bandCls = hl === 'new-band' - ? 'bg-amber-200 text-amber-900 border-amber-500 hover:bg-amber-200' - : ''; - const modeMode = inferSpotMode(s.comment ?? '', s.freq_hz); - const modeCls = hl === 'new-slot' - ? 'bg-yellow-200 text-yellow-900 border-yellow-500 hover:bg-yellow-200' - : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100'; - return ( - { - // Mode comes from the spot itself (comment text - // first, band plan fallback). Sending it to CAT - // matters because skipping it leaves the rig - // on whatever it had — typically DIGU after a - // previous FT8 contact, which breaks a SSB click. - const m = inferSpotMode(s.comment ?? '', s.freq_hz); - if (catState.connected) { - SetCATFrequency(s.freq_hz).catch(() => {}); - if (m) SetCATMode(m).catch(() => {}); - } else { - setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); - if (s.band) setBand(s.band); - if (m) setMode(m); - } - onCallsignInput(s.dx_call); - }} - title={s.raw} - > - - - - - - - - - - - - - ); - })} - -
toggleSort(h.key as SortKey) : undefined} - className={cn( - 'px-2.5 py-1.5 font-semibold text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border sticky top-0', - h.align === 'right' ? 'text-right' : 'text-left', - sortable && 'cursor-pointer select-none hover:text-foreground', - active && 'text-primary', - )} - > - {h.label} - {active && ( - - {clusterSort.dir === 'asc' ? '▲' : '▼'} - - )} -
1 ? `Seen ${s.repeats}× across active clusters` : undefined} - >{s.time_utc || ''} - - {s.dx_call} - - {s.freq_khz.toFixed(1)} - {bandCls - ? {s.band || '—'} - : {s.band || '—'}} - - {!modeMode - ? - : {modeMode}} - - {s.country ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''} - - {s.continent ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.continent ?? ''} - {cleanSpotter(s.spotter)}{s.source_name}{s.locator || ''}{s.comment}
+
+ +
+ {clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'} +
+
+ {clusterServerStatuses.some((s) => s.state === 'connected') + ? 'Spots will appear as the cluster sends them.' + : 'Use Connect all (or configure a cluster in Settings → DX Cluster).'} +
+
); - })()} -
+ } + return ( + { + const m = inferSpotMode(s.comment ?? '', s.freq_hz); + if (catState.connected) { + SetCATFrequency(s.freq_hz).catch(() => {}); + if (m) SetCATMode(m).catch(() => {}); + } else { + setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); + if (s.band) setBand(s.band); + if (m) setMode(m); + } + onCallsignInput(s.dx_call); + }} + /> + ); + })()} {/* Command input — sends to the master server. */}
@@ -1971,6 +1864,10 @@ export default function App() { now in the topbar, visible on every tab. */} + + + + {(['main','awards','propagation'] as const).map((t) => ( @@ -1981,7 +1878,6 @@ export default function App() { - {showBandMap && (
; + onSpotClick?: (s: ClusterSpot) => void; +}; + +const COL_STATE_KEY = 'hamlog.clusterColState.v1'; + +// Extracts the prefix from a callsign — drops portable suffixes (/P, /MM +// etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing +// digits after the last letter group (DL2SBY → DL2). +function fmtPfx(call: string): string { + if (!call) return ''; + const c = call.trim().toUpperCase(); + const base = c.includes('/') ? c.split('/')[0] : c; + // If "base" is a callsign rather than a bare prefix (like DL2SBY), cut + // at the last digit to get DL2. + let lastDigit = -1; + for (let i = 0; i < base.length; i++) { + if (base[i] >= '0' && base[i] <= '9') lastDigit = i; + } + return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base; +} + +// Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC +// "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style. +function fmtDateTimeUTC(s: any): string { + if (!s) return ''; + const d = new Date(s); + if (isNaN(d.getTime())) return String(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())}:${p(d.getUTCSeconds())}`; +} + +type ColEntry = ColDef & { group: string; label: string; defaultVisible?: boolean }; + +const COL_CATALOG: ColEntry[] = [ + { + group: 'Spot', label: 'Time', colId: 'time', + headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono', + defaultVisible: true, + sort: 'desc', + cellStyle: { color: '#7a6b50' }, + }, + { + group: 'Spot', label: 'Call', colId: 'call', + headerName: 'Call', field: 'dx_call' as any, width: 120, + defaultVisible: true, + cellRenderer: (p: any) => { + if (!p.value) return ''; + const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[ + spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) + ]; + const isNew = status?.status === 'new'; + const workedCall = !!status?.worked_call; + const style: any = { + display: 'inline-block', padding: '1px 6px', borderRadius: 4, + fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12, + }; + if (isNew) { + style.backgroundColor = '#ffe4e6'; + style.color = '#9f1239'; + style.border = '1px solid #fda4af'; + } else if (workedCall) { + style.color = '#0369a1'; + } else { + style.color = '#b8410c'; + } + return {p.value}; + }, + }, + { + group: 'Spot', label: 'Freq', colId: 'freq', + headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono', + defaultVisible: true, + valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '', + comparator: (a, b) => (a ?? 0) - (b ?? 0), + }, + { + group: 'Spot', label: 'Band', colId: 'band', + headerName: 'Band', field: 'band' as any, width: 75, + defaultVisible: true, + cellClass: 'flex items-center', + cellRenderer: (p: any) => { + const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[ + spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) + ]; + const newBand = status?.status === 'new-band'; + const bg = newBand ? '#fde68a' : '#f0d9a8'; + const fg = newBand ? '#92400e' : '#7a4a14'; + return p.value + ? {p.value} + : ''; + }, + }, + { + group: 'Spot', label: 'Mode', colId: 'mode', + headerName: 'Mode', colSpan: undefined, width: 80, + defaultVisible: true, + cellClass: 'flex items-center', + valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', + cellRenderer: (p: any) => { + const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[ + spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) + ]; + const newSlot = status?.status === 'new-slot'; + const bg = newSlot ? '#fef08a' : '#d1fae5'; + const fg = newSlot ? '#854d0e' : '#047857'; + return p.value + ? {p.value} + : ; + }, + }, + { + group: 'Spot', label: 'Pfx', colId: 'pfx', + headerName: 'Pfx', width: 60, cellClass: 'font-mono', + valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''), + cellStyle: { color: '#7a6b50' }, + }, + { + group: 'Geo', label: 'CQ Zone', colId: 'cqz', + headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', + valueFormatter: (p) => p.value ? String(p.value) : '', + }, + { + group: 'Geo', label: 'ITU Zone', colId: 'ituz', + headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', + valueFormatter: (p) => p.value ? String(p.value) : '', + }, + { + group: 'Geo', label: 'Distance (km)', colId: 'distance_km', + headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono', + valueFormatter: (p) => p.value ? String(p.value) : '', + comparator: (a, b) => (a ?? 0) - (b ?? 0), + }, + { + group: 'Geo', label: 'Short path (°)', colId: 'sp_deg', + headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', + valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '', + comparator: (a, b) => (a ?? 0) - (b ?? 0), + }, + { + group: 'Geo', label: 'Long path (°)', colId: 'lp_deg', + headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', + valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '', + comparator: (a, b) => (a ?? 0) - (b ?? 0), + }, + { + group: 'Spot', label: 'Country', colId: 'country', + headerName: 'Country', width: 140, + defaultVisible: true, + valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[ + spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) + ]?.country ?? '', + cellStyle: { color: '#7a6b50' }, + }, + { + group: 'Spot', label: 'Continent', colId: 'continent', + headerName: 'Cont', width: 60, cellClass: 'font-mono', + defaultVisible: true, + valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[ + spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) + ]?.continent ?? '', + cellStyle: { color: '#7a6b50', fontSize: 10 }, + }, + { + group: 'Spot', label: 'Spotter', colId: 'spotter', + headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono', + defaultVisible: true, + valueFormatter: (p) => cleanSpotter(p.value ?? ''), + cellStyle: { color: '#7a6b50' }, + }, + { + group: 'Spot', label: 'Source', colId: 'source', + headerName: 'Source', field: 'source_name' as any, width: 100, + defaultVisible: true, + cellStyle: { color: '#9a8870', fontSize: 10 }, + }, + { + group: 'Spot', label: 'Locator', colId: 'locator', + headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono', + cellStyle: { color: '#7a6b50' }, + }, + { + group: 'Spot', label: 'Comment', colId: 'comment', + headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, + defaultVisible: true, + cellStyle: { color: '#7a6b50' }, + }, + { + group: 'Spot', label: 'Received at', colId: 'received_at', + headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono', + valueFormatter: (p) => fmtDateTimeUTC(p.value), + }, + { + group: 'Spot', label: 'Raw', colId: 'raw', + headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono', + }, +]; + +const GROUP_ORDER = ['Spot', 'Geo']; + +export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { + const gridRef = useRef(null); + const [pickerOpen, setPickerOpen] = useState(false); + + const columnDefs = useMemo[]>(() => COL_CATALOG.map((c) => { + const { group: _g, label: _l, defaultVisible, ...rest } = c; + return { ...rest, hide: !defaultVisible }; + }), []); + + const defaultColDef = useMemo(() => ({ + sortable: true, resizable: true, filter: true, suppressMovable: false, + }), []); + + // Pass spotStatus through AG Grid's context so cell renderers can look up + // per-cell highlight without re-rendering the whole grid when the map + // updates. We refresh cells whose values depend on it after each prop + // change below. + const context = useMemo(() => ({ spotStatus }), [spotStatus]); + + function onGridReady(e: GridReadyEvent) { + try { + const raw = localStorage.getItem(COL_STATE_KEY); + if (raw) { + const state = JSON.parse(raw) as ColumnState[]; + if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true }); + } + } catch {} + } + const saveColumnState = useCallback(() => { + try { + const state = gridRef.current?.api?.getColumnState(); + if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state)); + } catch {} + }, []); + + function handleRowClicked(e: RowClickedEvent) { + if (e.data && onSpotClick) onSpotClick(e.data); + } + + function isColVisible(colId: string): boolean { + const col = gridRef.current?.api?.getColumn(colId); + return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible; + } + function setColVisible(colId: string, visible: boolean) { + const api = gridRef.current?.api; + if (!api) return; + api.setColumnsVisible([colId], visible); + saveColumnState(); + } + function showAll(group?: string) { + const api = gridRef.current?.api; + if (!api) return; + const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!); + api.setColumnsVisible(ids, true); + saveColumnState(); + } + function hideAll(group?: string) { + const api = gridRef.current?.api; + if (!api) return; + const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!); + api.setColumnsVisible(ids, false); + saveColumnState(); + } + function resetDefaults() { + const api = gridRef.current?.api; + if (!api) return; + const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!); + const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!); + api.setColumnsVisible(visible, true); + api.setColumnsVisible(hidden, false); + saveColumnState(); + } + + return ( + <> +
+ +
+
+
+ + ref={gridRef} + theme={hamlogTheme} + rowData={rows} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + context={context} + onGridReady={onGridReady} + onColumnResized={saveColumnState} + onColumnMoved={saveColumnState} + onColumnPinned={saveColumnState} + onColumnVisible={saveColumnState} + onSortChanged={saveColumnState} + onRowClicked={handleRowClicked} + animateRows={false} + suppressCellFocus + getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`} + /> +
+
+ + + + + Cluster columns + + Pick the columns you want visible in the Cluster table. + + +
+ {GROUP_ORDER.map((group) => { + const cols = COL_CATALOG.filter((c) => c.group === group); + if (cols.length === 0) return null; + return ( +
+
+ {group} +
+ + +
+
+
+ {cols.map((c) => ( + + ))} +
+
+ ); + })} +
+ + + + +
+
+ + ); +} diff --git a/frontend/src/components/Menubar.tsx b/frontend/src/components/Menubar.tsx index 8667306..96069cb 100644 --- a/frontend/src/components/Menubar.tsx +++ b/frontend/src/components/Menubar.tsx @@ -31,12 +31,27 @@ export function Menubar({ menus, onAction }: Props) { key={menu.name} open={openMenu === menu.name} onOpenChange={(o) => setOpenMenu(o ? menu.name : null)} + modal={false} > { // Only switch on hover if a menu is already open. if (openMenu !== null && openMenu !== menu.name) setOpenMenu(menu.name); }} + onPointerDown={(e) => { + // Desktop-menubar behaviour: when another menu is already + // open, a click on a different trigger should switch to it + // in one click. Without this Radix consumes the click to + // close the current menu first, requiring a second click + // to open the new one. We pre-empt by setting open state + // synchronously and stopping the event from reaching the + // default Radix toggle. + if (openMenu !== null && openMenu !== menu.name) { + e.preventDefault(); + e.stopPropagation(); + setOpenMenu(menu.name); + } + }} className={cn( 'px-3 text-sm rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring', openMenu === menu.name && 'bg-muted text-primary', @@ -44,7 +59,17 @@ export function Menubar({ menus, onAction }: Props) { > {menu.label} - + { + // Radix re-focuses the trigger after close. Combined with our + // focus-visible:ring style this leaves an orange outline around + // the previously-clicked menu — looks like a stuck "selected" + // state. We swallow the auto-focus and let the next interaction + // decide where focus belongs. + e.preventDefault(); + }} + > {menu.items.map((item, i) => item.type === 'separator' ? ( diff --git a/frontend/src/components/OperatingPanel.tsx b/frontend/src/components/OperatingPanel.tsx new file mode 100644 index 0000000..dced810 --- /dev/null +++ b/frontend/src/components/OperatingPanel.tsx @@ -0,0 +1,451 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Antenna as AntennaIcon, Radio, Plus, Trash2, Star, + ChevronRight, ChevronDown, Edit2, +} from 'lucide-react'; +import { + ListOperatingTree, SaveOperatingStation, DeleteOperatingStation, + SaveOperatingAntenna, DeleteOperatingAntenna, +} from '../../wailsjs/go/main/App'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; + +type Band = { band: string; is_default: boolean }; +type Antenna = { + id: number; + station_id: number; + name: string; + sort_order: number; + bands: Band[]; +}; +type Station = { + id: number; + profile_id: number; + name: string; + tx_pwr?: number; + sort_order: number; + antennas?: Antenna[]; +}; + +type Props = { + /** ADIF bands available to toggle, in display order (from ListsSettings). */ + bands: string[]; + /** External error sink — parent shows it next to the Save button. */ + onError: (msg: string) => void; +}; + +export function OperatingPanel({ bands, onError }: Props) { + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(true); + // expanded keeps which stations show their antennas; everything open by + // default so the user sees the full setup at a glance. + const [expanded, setExpanded] = useState>(new Set()); + // editingId tracks the row currently in edit mode. Use a string namespace + // to keep station ids and antenna ids in the same Set. + const [editing, setEditing] = useState(null); + + const reload = useCallback(async () => { + try { + const t = await ListOperatingTree(); + const list = (t ?? []) as Station[]; + setTree(list); + setExpanded((prev) => { + if (prev.size > 0) return prev; + return new Set(list.map((s) => s.id)); + }); + } catch (e: any) { + onError(String(e?.message ?? e)); + } finally { + setLoading(false); + } + }, [onError]); + + useEffect(() => { void reload(); }, [reload]); + + function toggleExpanded(id: number) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + + async function addStation() { + try { + const created = await SaveOperatingStation({ + id: 0, profile_id: 0, name: 'New rig', sort_order: tree.length, + } as any); + const c = created as Station; + setTree((prev) => [...prev, { ...c, antennas: [] }]); + setExpanded((prev) => new Set(prev).add(c.id)); + setEditing(`station:${c.id}`); + } catch (e: any) { onError(String(e?.message ?? e)); } + } + async function updateStation(s: Station) { + try { + const saved = await SaveOperatingStation(s as any) as Station; + setTree((prev) => prev.map((x) => x.id === s.id ? { ...x, ...saved } : x)); + } catch (e: any) { onError(String(e?.message ?? e)); } + } + async function removeStation(id: number) { + if (!confirm('Delete this rig and all its antennas?')) return; + try { + await DeleteOperatingStation(id); + setTree((prev) => prev.filter((s) => s.id !== id)); + } catch (e: any) { onError(String(e?.message ?? e)); } + } + + async function addAntenna(stationId: number) { + try { + const created = await SaveOperatingAntenna({ + id: 0, station_id: stationId, name: 'New antenna', sort_order: 0, bands: [], + } as any) as Antenna; + setTree((prev) => prev.map((s) => + s.id === stationId + ? { ...s, antennas: [...(s.antennas ?? []), created] } + : s + )); + setEditing(`antenna:${created.id}`); + } catch (e: any) { onError(String(e?.message ?? e)); } + } + async function updateAntenna(a: Antenna) { + try { + const saved = await SaveOperatingAntenna(a as any) as Antenna; + setTree((prev) => prev.map((s) => s.id === a.station_id + ? { + ...s, + antennas: (s.antennas ?? []).map((x) => x.id === a.id ? saved : x), + } + : { + // The save may have cleared is_default on antennas of OTHER + // stations (one default per band per profile). Refresh those + // by reloading the tree wholesale. + ...s, + } + )); + // Reload to pick up cross-station default flips. + void reload(); + } catch (e: any) { onError(String(e?.message ?? e)); } + } + async function removeAntenna(stationId: number, antId: number) { + if (!confirm('Delete this antenna?')) return; + try { + await DeleteOperatingAntenna(antId); + setTree((prev) => prev.map((s) => + s.id === stationId + ? { ...s, antennas: (s.antennas ?? []).filter((a) => a.id !== antId) } + : s + )); + } catch (e: any) { onError(String(e?.message ?? e)); } + } + + if (loading) { + return
Loading…
; + } + + return ( +
+
+ Define your rigs (stations) and the antennas connected to each one. + For every antenna, tick the bands it covers. marks + the default antenna for that band — when you change the band in the + entry strip, the matching rig + antenna auto-fill the MY_RIG and + MY_ANTENNA ADIF fields. Only one antenna can be the default per + band; setting one clears the previous default. +
+ +
+ +
+ + {tree.length === 0 ? ( +
+ No rig configured yet. Click "Add rig" to get started. +
+ ) : ( +
+ {tree.map((station) => ( + toggleExpanded(station.id)} + onUpdate={updateStation} + onDelete={() => removeStation(station.id)} + onAddAntenna={() => addAntenna(station.id)} + onUpdateAntenna={updateAntenna} + onDeleteAntenna={(antId) => removeAntenna(station.id, antId)} + /> + ))} +
+ )} +
+ ); +} + +// ── Station row ──────────────────────────────────────────────────────── + +type StationRowProps = { + station: Station; + bands: string[]; + expanded: boolean; + editing: string | null; + setEditing: (id: string | null) => void; + onToggleExpanded: () => void; + onUpdate: (s: Station) => void; + onDelete: () => void; + onAddAntenna: () => void; + onUpdateAntenna: (a: Antenna) => void; + onDeleteAntenna: (antId: number) => void; +}; + +function StationRow({ + station, bands, expanded, editing, setEditing, + onToggleExpanded, onUpdate, onDelete, onAddAntenna, + onUpdateAntenna, onDeleteAntenna, +}: StationRowProps) { + const editKey = `station:${station.id}`; + const isEditing = editing === editKey; + const [draft, setDraft] = useState({ + name: station.name, + tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '', + }); + useEffect(() => { + if (!isEditing) setDraft({ + name: station.name, + tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '', + }); + }, [isEditing, station.name, station.tx_pwr]); + + function commit() { + const pwrNum = draft.tx_pwr.trim() === '' ? undefined : parseFloat(draft.tx_pwr); + onUpdate({ + ...station, + name: draft.name.trim() || station.name, + tx_pwr: Number.isFinite(pwrNum as number) ? (pwrNum as number) : undefined, + }); + setEditing(null); + } + + return ( +
+
+ + + + {isEditing ? ( +
+ setDraft((d) => ({ ...d, name: e.target.value }))} + onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }} + /> + + setDraft((d) => ({ ...d, tx_pwr: e.target.value }))} + onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }} + /> + + +
+ ) : ( + <> + {station.name} + {station.tx_pwr != null && ( + {station.tx_pwr} W + )} +
+ + + + + )} +
+ + {expanded && ( +
+ {(station.antennas ?? []).length === 0 ? ( +
+ No antenna yet — click "Antenna" above to add one. +
+ ) : ( + (station.antennas ?? []).map((a) => ( + onDeleteAntenna(a.id)} + /> + )) + )} +
+ )} +
+ ); +} + +// ── Antenna row ──────────────────────────────────────────────────────── + +type AntennaRowProps = { + antenna: Antenna; + bands: string[]; + editing: string | null; + setEditing: (id: string | null) => void; + onUpdate: (a: Antenna) => void; + onDelete: () => void; +}; + +function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) { + const editKey = `antenna:${antenna.id}`; + const isEditing = editing === editKey; + const [draft, setDraft] = useState({ name: antenna.name }); + useEffect(() => { + if (!isEditing) setDraft({ name: antenna.name }); + }, [isEditing, antenna.name]); + + const enabledBands = new Map( + (antenna.bands ?? []).map((b) => [b.band, b]) + ); + + function commitNames() { + onUpdate({ + ...antenna, + name: draft.name.trim() || antenna.name, + bands: antenna.bands ?? [], + }); + setEditing(null); + } + + function toggleBand(band: string, on: boolean) { + let next = [...(antenna.bands ?? [])]; + if (on) { + if (!next.find((b) => b.band === band)) { + next.push({ band, is_default: false }); + } + } else { + next = next.filter((b) => b.band !== band); + } + onUpdate({ ...antenna, bands: next }); + } + + function setDefault(band: string, isDefault: boolean) { + const next = (antenna.bands ?? []).map((b) => + b.band === band ? { ...b, is_default: isDefault } : b + ); + onUpdate({ ...antenna, bands: next }); + } + + return ( +
+
+ + {isEditing ? ( +
+ setDraft((d) => ({ ...d, name: e.target.value }))} + onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }} + /> + + +
+ ) : ( + <> + {antenna.name} +
+ + + + )} +
+ +
+ {bands.length === 0 ? ( + No band configured in Settings → Bands. + ) : bands.map((band) => { + const entry = enabledBands.get(band); + const enabled = !!entry; + const isDefault = !!entry?.is_default; + return ( +
+ toggleBand(band, !!c)} + className="size-3" + /> + {band} + {enabled && ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx new file mode 100644 index 0000000..42095d9 --- /dev/null +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -0,0 +1,373 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + AllCommunityModule, ModuleRegistry, themeQuartz, + type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, +} from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import { Columns3 } from 'lucide-react'; +import type { QSOForm } from '@/types'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; + +// Register every Community feature once. v32+ requires explicit registration; +// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/ +// virtual-scroll — everything we want out of the box for a logbook table. +ModuleRegistry.registerModules([AllCommunityModule]); + +// Custom Quartz theme tuned to match HamLog's warm palette. +const hamlogTheme = themeQuartz.withParams({ + fontFamily: 'inherit', + fontSize: 12.5, + backgroundColor: '#faf6ea', + foregroundColor: '#2a2419', + headerBackgroundColor: '#e8dfc9', + headerTextColor: '#5a4f3a', + headerFontWeight: 600, + oddRowBackgroundColor: '#f5efe0', + rowHoverColor: '#ecdcb4', + selectedRowBackgroundColor: '#f0d9a8', + borderColor: '#c8b994', + rowBorder: { color: '#d8c9a8', width: 1 }, + columnBorder: { color: '#d8c9a8', width: 1 }, + cellHorizontalPadding: 10, + rowHeight: 32, + headerHeight: 34, + spacing: 4, + accentColor: '#b8410c', + iconSize: 12, +}); + +const badgeCellClass = 'flex items-center'; + +type Props = { + rows: QSOForm[]; + total: number; + onRowDoubleClicked?: (q: QSOForm) => void; + onRowSelected?: (id: number | null) => void; +}; + +const COL_STATE_KEY = 'hamlog.qsoColState.v2'; + +function fmtMhzDots(hz?: number): string { + if (!hz) return ''; + const mhz = (hz / 1_000_000).toFixed(6); + const [i, f] = mhz.split('.'); + return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`; +} + +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 fmtDateOnly(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())}`; +} + +const bandPill = (p: any) => p.value + ? {p.value} + : ''; +const modePill = (p: any) => p.value + ? {p.value} + : ''; + +// Full catalog of selectable columns, grouped for the picker. `defaultVisible` +// = shown out of the box; anything else stays hidden until the user toggles +// it in the Columns dialog. +type ColEntry = ColDef & { group: string; label: string; defaultVisible?: boolean }; + +const COL_CATALOG: ColEntry[] = [ + // ── QSO basics ── + { group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true }, + { group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) }, + { group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true }, + { group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill, defaultVisible: true }, + { group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill }, + { group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: badgeCellClass, cellRenderer: modePill, defaultVisible: true }, + { group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' }, + { group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true }, + { group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) }, + { group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, + { group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, + { group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, + + // ── Contacted station ── + { group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true }, + { group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true }, + { group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 }, + { group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true }, + { group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 }, + { group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 }, + { group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 }, + { group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, + { group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' }, + { group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' }, + { group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' }, + { group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' }, + { group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' }, + { group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' }, + { group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' }, + { group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' }, + { group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' }, + { group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, + { group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, + { group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 }, + { group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 }, + + // ── QSL ── + { group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 }, + { group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 }, + { group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 }, + { group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 }, + { group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 }, + + // ── LoTW ── + { group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 }, + { group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 }, + { group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, + + // ── eQSL ── + { group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 }, + { group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 }, + { group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, + + // ── Uploads ── + { group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 }, + { group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 }, + + // ── Contest ── + { group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 }, + { group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' }, + { group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' }, + { group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 }, + { group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 }, + { group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 }, + { group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 }, + { group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 }, + + // ── Propagation / antenna ── + { group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 }, + { group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 }, + { group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 }, + { group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' }, + { group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' }, + { group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 }, + { group: 'Propagation', label: 'Rig', colId: 'rig', headerName: 'Rig', field: 'rig' as any, width: 120 }, + { group: 'Propagation', label: 'Antenna', colId: 'ant', headerName: 'Antenna', field: 'ant' as any, width: 140 }, + + // ── My station (operator side) ── + { group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true }, + { group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' }, + { group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' }, + { group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 }, + { group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 }, + { group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 }, + { group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' }, + { group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' }, + { group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' }, + { group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' }, + { group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' }, + { group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' }, + { group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' }, + { group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' }, + { group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 }, + { group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 }, + { group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 }, + { group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 }, + { group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 }, + + // ── Misc ── + { group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true }, + { group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 }, + { group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) }, + { group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) }, +]; + +const GROUP_ORDER = [ + 'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads', + 'Contest', 'Propagation', 'My station', 'Misc', +]; + +export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) { + const gridRef = useRef(null); + const [pickerOpen, setPickerOpen] = useState(false); + + // Compute initial column defs: all columns defined, but those not marked + // defaultVisible start hidden. The user's saved state (loaded onGridReady) + // overrides this so a previously toggled column wins. + const columnDefs = useMemo[]>(() => COL_CATALOG.map((c) => { + const { group: _g, label: _l, defaultVisible, ...rest } = c; + return { ...rest, hide: !defaultVisible }; + }), []); + + const defaultColDef = useMemo(() => ({ + sortable: true, + resizable: true, + filter: true, + suppressMovable: false, + }), []); + + function onGridReady(e: GridReadyEvent) { + try { + const raw = localStorage.getItem(COL_STATE_KEY); + if (raw) { + const state = JSON.parse(raw) as ColumnState[]; + if (Array.isArray(state)) { + e.api.applyColumnState({ state, applyOrder: true }); + } + } + } catch {} + } + const saveColumnState = useCallback(() => { + try { + const state = gridRef.current?.api?.getColumnState(); + if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state)); + } catch {} + }, []); + + function handleRowDoubleClicked(e: RowDoubleClickedEvent) { + if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); + } + function onSelectionChanged() { + const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined; + onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null); + } + + // ── Column picker (visibility) ── + // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React + // state for "which columns are visible" — AG Grid's column state is the + // source of truth, and saveColumnState persists it. + function isColVisible(colId: string): boolean { + const col = gridRef.current?.api?.getColumn(colId); + return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible; + } + function setColVisible(colId: string, visible: boolean) { + const api = gridRef.current?.api; + if (!api) return; + api.setColumnsVisible([colId], visible); + saveColumnState(); + } + function showAll(group?: string) { + const api = gridRef.current?.api; + if (!api) return; + const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!); + api.setColumnsVisible(ids, true); + saveColumnState(); + } + function hideAll(group?: string) { + const api = gridRef.current?.api; + if (!api) return; + const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!); + api.setColumnsVisible(ids, false); + saveColumnState(); + } + function resetDefaults() { + const api = gridRef.current?.api; + if (!api) return; + const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!); + const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!); + api.setColumnsVisible(visible, true); + api.setColumnsVisible(hidden, false); + saveColumnState(); + } + + return ( + <> +
+ +
+
+
+ + ref={gridRef} + theme={hamlogTheme} + rowData={rows} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }} + onGridReady={onGridReady} + onColumnResized={saveColumnState} + onColumnMoved={saveColumnState} + onColumnPinned={saveColumnState} + onColumnVisible={saveColumnState} + onSortChanged={saveColumnState} + onRowDoubleClicked={handleRowDoubleClicked} + onSelectionChanged={onSelectionChanged} + animateRows={false} + suppressCellFocus + getRowId={(p) => String((p.data as any).id)} + /> +
+
+ + + + + Columns + + Pick the columns you want visible in the Recent QSOs table. + Your selection is saved. + + +
+ {GROUP_ORDER.map((group) => { + const cols = COL_CATALOG.filter((c) => c.group === group); + if (cols.length === 0) return null; + return ( +
+
+ {group} +
+ + +
+
+
+ {cols.map((c) => ( + + ))} +
+
+ ); + })} +
+ + + + +
+
+ + ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index da91d42..b26c81c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { - ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2, + ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, Compass, Wifi, Construction, @@ -15,11 +15,12 @@ import { GetClusterAutoConnect, SetClusterAutoConnect, ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, + GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models'; -import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'; +import { EventsOn } from '../../wailsjs/runtime/runtime'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, @@ -33,6 +34,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; +import { OperatingPanel } from '@/components/OperatingPanel'; type LookupSettings = LookupSettingsForm; type StationSettings = StationSettingsForm; @@ -44,6 +46,55 @@ type ClusterServer = Omit; type ClusterServerStatus = Omit; type Profile = Omit; +// Catalog of all standard ADIF bands, in natural frequency order. The user +// picks a subset on the right; everything else in the UI (entry strip, +// band-slot grid, band-map switcher) iterates that subset. +const BAND_CATALOG = [ + '2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m', + '8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm', + '6mm','4mm','2.5mm','2mm','1mm', +]; + +// Catalog of common ADIF modes with sensible RST defaults. When the user +// picks one on the right, the RSTs are pre-filled but stay editable. +const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [ + { name: 'SSB', sent: '59', rcvd: '59' }, + { name: 'CW', sent: '599', rcvd: '599' }, + { name: 'AM', sent: '59', rcvd: '59' }, + { name: 'FM', sent: '59', rcvd: '59' }, + { name: 'DIGITALVOICE', sent: '59', rcvd: '59' }, + { name: 'FT8', sent: '-10', rcvd: '-10' }, + { name: 'FT4', sent: '-10', rcvd: '-10' }, + { name: 'JS8', sent: '-10', rcvd: '-10' }, + { name: 'MSK144', sent: '+00', rcvd: '+00' }, + { name: 'JT65', sent: '-15', rcvd: '-15' }, + { name: 'JT9', sent: '-15', rcvd: '-15' }, + { name: 'Q65', sent: '-15', rcvd: '-15' }, + { name: 'FST4', sent: '-15', rcvd: '-15' }, + { name: 'FST4W', sent: '-15', rcvd: '-15' }, + { name: 'WSPR', sent: '-20', rcvd: '-20' }, + { name: 'RTTY', sent: '599', rcvd: '599' }, + { name: 'PSK31', sent: '599', rcvd: '599' }, + { name: 'PSK63', sent: '599', rcvd: '599' }, + { name: 'PSK125', sent: '599', rcvd: '599' }, + { name: 'OLIVIA', sent: '599', rcvd: '599' }, + { name: 'CONTESTI', sent: '599', rcvd: '599' }, + { name: 'MFSK', sent: '599', rcvd: '599' }, + { name: 'THROB', sent: '599', rcvd: '599' }, + { name: 'HELL', sent: '599', rcvd: '599' }, + { name: 'PACKET', sent: '599', rcvd: '599' }, + { name: 'PACTOR', sent: '599', rcvd: '599' }, + { name: 'VARA', sent: '599', rcvd: '599' }, + { name: 'VARA HF', sent: '599', rcvd: '599' }, + { name: 'ARDOP', sent: '599', rcvd: '599' }, + { name: 'ATV', sent: '59', rcvd: '59' }, + { name: 'SSTV', sent: '59', rcvd: '59' }, + { name: 'C4FM', sent: '59', rcvd: '59' }, + { name: 'DSTAR', sent: '59', rcvd: '59' }, + { name: 'DMR', sent: '59', rcvd: '59' }, + { name: 'FUSION', sent: '59', rcvd: '59' }, +]; + const emptyProfile = (): Profile => ({ id: 0, name: '', @@ -72,6 +123,7 @@ interface Props { type SectionId = | 'station' | 'profiles' + | 'operating' | 'lookup' | 'lists-bands' | 'lists-modes' @@ -92,6 +144,7 @@ const TREE: TreeNode[] = [ kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [ { kind: 'item', label: 'Station Information', id: 'station' }, { kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' }, + { kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' }, ], }, { @@ -102,7 +155,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'Modes & default RST', id: 'lists-modes' }, ]}, { kind: 'item', label: 'DX Cluster', id: 'cluster' }, - { kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true }, + { kind: 'item', label: 'Database backup', id: 'backup' }, { kind: 'item', label: 'Awards', id: 'awards', disabled: true }, ], }, @@ -120,11 +173,12 @@ const TREE: TreeNode[] = [ const SECTION_LABELS: Partial> = { station: 'Station Information', profiles: 'Profiles', + operating: 'Operating conditions', lookup: 'Callsign Lookup', 'lists-bands': 'Bands', 'lists-modes': 'Modes & default RST', cluster: 'DX Cluster', - backup: 'Backup / Export', + backup: 'Database backup', awards: 'Awards', cat: 'CAT interface', rotator: 'Rotator', @@ -248,7 +302,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const updateActive = (patch: Partial) => setActiveProfile((p) => (p ? { ...p, ...patch } : p)); const [lists, setLists] = useState({ bands: [], modes: [] }); - const [bandsText, setBandsText] = useState(''); + // Custom band drafts (catalog covers ADIF spec but the user may have + // exotic or experimental bands not listed). + const [bandDraft, setBandDraft] = useState(''); + const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', @@ -259,6 +316,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [backupCfg, setBackupCfg] = useState({ + enabled: false, folder: '', rotation: 5, zip: false, + last_backup_at: '', default_folder: '', + } as any); + const [backupRunning, setBackupRunning] = useState(false); + const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null); + const [clusterServers, setClusterServers] = useState([]); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); const [clusterStatuses, setClusterStatuses] = useState([]); @@ -281,14 +345,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { // click Connect/Disconnect inside the modal and see the pills change // without saving + reopening. useEffect(() => { - EventsOn('cluster:state', async (st: any) => { + const unsub = EventsOn('cluster:state', async (st: any) => { setClusterStatuses((st ?? []) as ClusterServerStatus[]); try { const list = await ListClusterServers(); setClusterServers((list ?? []) as ClusterServer[]); } catch {} }); - return () => { EventsOff('cluster:state'); }; + return () => { unsub?.(); }; }, []); const [profiles, setProfiles] = useState([]); // State for ProfilesPanel — lifted here because PANELS[selected]() calls @@ -325,18 +389,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { useEffect(() => { (async () => { try { - const [l, ls, c, ap, r] = await Promise.all([ + const [l, ls, c, ap, r, b] = await Promise.all([ GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(), - GetRotatorSettings(), + GetRotatorSettings(), GetBackupSettings(), ]); setLookup(l); setActiveProfile(ap as Profile); setLists(ls); await reloadProfiles(); await reloadClusterServers(); - setBandsText((ls.bands ?? []).join('\n')); setCatCfg(c); setRotator(r); + setBackupCfg(b as any); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { @@ -345,12 +409,59 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { })(); }, []); + // ── Band selection helpers (dual-list shuttle) ────────────────────────── + function addBand(tag: string) { + const b = tag.trim().toLowerCase(); + if (!b) return; + setLists((l) => { + if ((l.bands ?? []).includes(b)) return l; + return { ...l, bands: [...(l.bands ?? []), b] }; + }); + } + function removeBand(i: number) { + setLists((l) => { + const next = [...(l.bands ?? [])]; + next.splice(i, 1); + return { ...l, bands: next }; + }); + } + function moveBand(i: number, dir: -1 | 1) { + setLists((l) => { + const next = [...(l.bands ?? [])]; + const j = i + dir; + if (j < 0 || j >= next.length) return l; + [next[i], next[j]] = [next[j], next[i]]; + return { ...l, bands: next }; + }); + } + + // ── Mode helpers ──────────────────────────────────────────────────────── function addMode() { setLists((l) => ({ ...l, modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }], })); } + function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) { + setLists((l) => { + if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l; + return { + ...l, + modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }], + }; + }); + } + function addCustomMode(name: string) { + const n = name.trim().toUpperCase(); + if (!n) return; + setLists((l) => { + if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l; + return { + ...l, + modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }], + }; + }); + } function removeMode(i: number) { setLists((l) => { const next = [...(l.modes ?? [])]; @@ -378,11 +489,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { async function save() { setSaving(true); setErr(''); setMsg(''); try { - // Bands: dedup, lowercase, trim. + // Bands: dedup, lowercase, trim. Order = user's drag order. const seen = new Set(); const bands: string[] = []; - for (const line of bandsText.split('\n')) { - const b = line.trim().toLowerCase(); + for (const raw of lists.bands ?? []) { + const b = (raw ?? '').trim().toLowerCase(); if (b && !seen.has(b)) { seen.add(b); bands.push(b); } } const modes = (lists.modes ?? []) @@ -407,6 +518,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { await SaveLookupSettings(lookup as any); await SaveCATSettings(catCfg as any); await SaveRotatorSettings(rotator as any); + await SaveBackupSettings(backupCfg as any); await SetClusterAutoConnect(clusterAutoConnect); setMsg('Settings saved.'); @@ -506,23 +618,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
-
- - updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" /> -
-
- - updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" /> -
-
- - updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })} - placeholder="100" - /> -
); @@ -796,57 +891,212 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { } function BandsPanel() { + const selected = lists.bands ?? []; + const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase())); + const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase())); return ( <> - -