This commit is contained in:
2026-06-06 00:02:56 +02:00
parent 51d3a734e8
commit 922a185208
10 changed files with 941 additions and 131 deletions
+307 -8
View File
@@ -520,6 +520,7 @@ func (a *App) startup(ctx context.Context) {
a.settings = settings.NewStore(conn)
a.profiles = profile.NewRepo(conn)
a.awardRefs = awardref.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
a.operating = operating.NewRepo(conn)
a.udpRepo = udp.NewRepo(conn)
@@ -1322,7 +1323,9 @@ func (a *App) awardDefs() []award.Def {
if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" {
var defs []award.Def
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
return defs
// Upgrade legacy defs (pre-rich-model) in memory on every load.
migrated, _ := award.Migrate(defs)
return migrated
}
}
}
@@ -1335,6 +1338,31 @@ func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
// AwardFields lists the scannable QSO fields for the award editor.
func (a *App) AwardFields() []string { return award.Fields() }
// migrateAwardDefs upgrades legacy award definitions in storage once, so the
// editor and persisted state reflect the new model (enabled awards + filled
// matching/confirmation fields). No-op when there is nothing to migrate.
func (a *App) migrateAwardDefs() {
if a.settings == nil {
return
}
s, _ := a.settings.Get(a.ctx, keyAwardDefs)
if strings.TrimSpace(s) == "" {
return // nothing saved yet → Defaults() (already on the new model)
}
var defs []award.Def
if json.Unmarshal([]byte(s), &defs) != nil || len(defs) == 0 {
return
}
migrated, changed := award.Migrate(defs)
if !changed {
return
}
if b, err := json.Marshal(migrated); err == nil {
_ = a.settings.Set(a.ctx, keyAwardDefs, string(b))
applog.Printf("awards: migrated %d legacy definitions to the new model", len(migrated))
}
}
// SaveAwardDefs persists edited award definitions.
func (a *App) SaveAwardDefs(defs []award.Def) error {
if a.settings == nil {
@@ -1356,13 +1384,41 @@ func (a *App) ResetAwardDefs() ([]award.Def, error) {
return d, nil
}
// GetAwards computes award progress (worked/confirmed) across the whole log.
// GetAwards computes progress for EVERY award (whole-log scan). Kept for
// callers that need all results at once; the UI now computes one award at a
// time via GetAward to stay responsive on large logs.
func (a *App) GetAwards() ([]award.Result, error) {
return a.computeAwards(a.awardDefs())
}
// GetAward computes progress for a single award by code (one whole-log scan,
// matching only that award). This is what the awards UI calls when an award is
// selected, so opening the panel doesn't scan every award up front.
func (a *App) GetAward(code string) (award.Result, error) {
for _, d := range a.awardDefs() {
if strings.EqualFold(d.Code, code) {
results, err := a.computeAwards([]award.Def{d})
if err != nil {
return award.Result{}, err
}
if len(results) > 0 {
return results[0], nil
}
return award.Result{}, nil
}
}
return award.Result{}, fmt.Errorf("unknown award %q", code)
}
// computeAwards runs the engine for the given award definitions over the whole
// log and enriches dynamic awards (totals + worked-reference names).
func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
var all []qso.QSO
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
all = append(all, q)
return nil
}); err != nil {
@@ -1379,7 +1435,6 @@ func (a *App) GetAwards() ([]award.Result, error) {
}
return ""
}
defs := a.awardDefs()
refMetas := a.awardRefMetas(defs)
results := award.Compute(defs, all, refMetas, nameOf)
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
@@ -1416,6 +1471,166 @@ func (a *App) GetAwards() ([]award.Result, error) {
return results, nil
}
// AwardCellQSOs returns the QSOs that contribute to one award reference,
// optionally on a single band (band="" = all bands). Powers the award-grid
// cell drill-down ("show me every Canada contact on 20m").
func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return nil, fmt.Errorf("unknown award %q", code)
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
wantRef := strings.ToUpper(strings.TrimSpace(ref))
wantBand := strings.ToLower(strings.TrimSpace(band))
var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand {
return nil
}
a.enrichQSOForAwards(&q)
for _, c := range award.MatchQSO(*def, metas, &q) {
if strings.ToUpper(c) == wantRef {
out = append(out, q)
break
}
}
return nil
})
return out, err
}
// AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"):
// distinct-reference counts per band, plus Total (distinct on any band) and
// GrandTotal (sum of the per-band band-slots).
type AwardStatRow struct {
Label string `json:"label"`
Cells []int `json:"cells"`
Total int `json:"total"`
GrandTotal int `json:"grand_total"`
}
// AwardStatsResult is the statistics matrix for one award (Log4OM "Statistics").
type AwardStatsResult struct {
Code string `json:"code"`
Bands []string `json:"bands"`
Rows []AwardStatRow `json:"rows"`
}
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
// GetAwardStats computes the worked/confirmed/validated reference counts of one
// award, broken down by band and by mode category (All/CW/Digital/Phone).
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
if a.qso == nil {
return AwardStatsResult{}, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return AwardStatsResult{}, fmt.Errorf("unknown award %q", code)
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
bandIdx := make(map[string]int, len(statsBands))
for i, b := range statsBands {
bandIdx[b] = i
}
cats := []string{"ALL", "CW", "DIGITAL", "PHONE"}
stats := []string{"WORKED", "CONFIRMED", "VALIDATED"}
// acc[cat][stat]: per-band ref sets + an overall (any-band) ref set.
type acc struct {
perBand []map[string]struct{}
overall map[string]struct{}
}
accs := map[string]map[string]*acc{}
for _, c := range cats {
accs[c] = map[string]*acc{}
for _, s := range stats {
pb := make([]map[string]struct{}, len(statsBands))
for i := range pb {
pb[i] = map[string]struct{}{}
}
accs[c][s] = &acc{perBand: pb, overall: map[string]struct{}{}}
}
}
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
refs := award.MatchQSO(*def, metas, &q)
if len(refs) == 0 {
return nil
}
bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
isConf := award.Confirmed(&q, def.Confirm)
isVal := award.Confirmed(&q, def.Validate)
cat := strings.ToUpper(award.EmissionOf(q.Mode))
record := func(c string) {
put := func(stat string) {
ac := accs[c][stat]
for _, r := range refs {
ac.overall[r] = struct{}{}
if hasBand {
ac.perBand[bi][r] = struct{}{}
}
}
}
put("WORKED")
if isConf {
put("CONFIRMED")
}
if isVal {
put("VALIDATED")
}
}
record("ALL")
if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
record(cat)
}
return nil
})
if err != nil {
return AwardStatsResult{}, err
}
res := AwardStatsResult{Code: def.Code, Bands: statsBands}
for _, c := range cats {
for _, s := range stats {
ac := accs[c][s]
cells := make([]int, len(statsBands))
grand := 0
for i := range ac.perBand {
cells[i] = len(ac.perBand[i])
grand += cells[i]
}
label := s
if c != "ALL" {
label = s + " " + c
}
res.Rows = append(res.Rows, AwardStatRow{Label: label, Cells: cells, Total: len(ac.overall), GrandTotal: grand})
}
}
return res, nil
}
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
// are large and not needed for matching; their names are filled afterwards.
@@ -1459,6 +1674,85 @@ type QSOAwardRef struct {
Pickable bool `json:"pickable"`
}
// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
// cty.dat when the QSO doesn't carry them, so computed awards (WAZ/WITUZ/WAC/
// DXCC) work even on records that were never enriched. Non-destructive: it
// mutates the in-memory copy used for award matching only, never the database.
func (a *App) enrichQSOForAwards(q *qso.QSO) {
if a.dxcc == nil {
return
}
// Recover the band from the frequency when missing, so per-band award
// statistics aren't lost for QSOs that carry only a frequency.
if strings.TrimSpace(q.Band) == "" && q.FreqHz != nil && *q.FreqHz > 0 {
if b := bandForHz(*q.FreqHz); b != "" {
q.Band = b
}
}
needCQ := q.CQZ == nil || *q.CQZ == 0
needITU := q.ITUZ == nil || *q.ITUZ == 0
needCont := strings.TrimSpace(q.Continent) == ""
needDXCC := q.DXCC == nil || *q.DXCC == 0
if !needCQ && !needITU && !needCont && !needDXCC {
return
}
m, ok := a.dxcc.Lookup(q.Callsign)
if !ok {
return
}
if needCQ && m.CQZone > 0 {
z := m.CQZone
q.CQZ = &z
}
if needITU && m.ITUZone > 0 {
z := m.ITUZone
q.ITUZ = &z
}
if needCont && m.Continent != "" {
q.Continent = m.Continent
}
if needDXCC && m.Entity != nil {
if n := dxcc.EntityDXCC(m.Entity.Name); n > 0 {
q.DXCC = &n
}
}
}
// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the
// band for award statistics when a QSO has a frequency but no band field.
var awardBandPlan = []struct {
name string
lo, hi int64
}{
{"2190m", 135700, 137800}, {"630m", 472000, 479000}, {"160m", 1800000, 2000000},
{"80m", 3500000, 4000000}, {"60m", 5060000, 5450000}, {"40m", 7000000, 7300000},
{"30m", 10100000, 10150000}, {"20m", 14000000, 14350000}, {"17m", 18068000, 18168000},
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
{"13cm", 2300000000, 2450000000},
}
func bandForHz(hz int64) string {
for _, b := range awardBandPlan {
if hz >= b.lo && hz <= b.hi {
return b.name
}
}
return ""
}
// isComputedAwardField reports whether an award's field is auto-derived from
// structured QSO data (entity, zones, prefix, location) rather than a reference
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
func isComputedAwardField(field string) bool {
switch field {
case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid":
return true
}
return false
}
// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
// the per-QSO Award Refs editor. Reuses the same engine as GetAwards.
@@ -1474,16 +1768,21 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
}
return ""
}
a.enrichQSOForAwards(&q)
defs := a.awardDefs()
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
var counts map[string]int
if a.awardRefs != nil {
counts, _ = a.awardRefs.Counts(a.ctx)
fieldByCode := map[string]string{}
for _, d := range defs {
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
}
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
var out []QSOAwardRef
for i := range results {
r := &results[i]
pickable := counts[strings.ToUpper(r.Code)] > 0 || awardref.CanUpdate(r.Code)
// "Pickable" = the reference is manually assigned per QSO (POTA, notes…),
// NOT auto-derived from a structured field. DXCC/WAZ/WAS/WAC/WPX are
// computed and belong in the read-only panel even though they now have
// reference lists.
pickable := !isComputedAwardField(fieldByCode[strings.ToUpper(r.Code)])
for _, rf := range r.Refs {
if !rf.Worked {
continue // a single QSO only contributes worked references