rigs completed
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user