up
This commit is contained in:
@@ -520,6 +520,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.settings = settings.NewStore(conn)
|
a.settings = settings.NewStore(conn)
|
||||||
a.profiles = profile.NewRepo(conn)
|
a.profiles = profile.NewRepo(conn)
|
||||||
a.awardRefs = awardref.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.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
||||||
a.operating = operating.NewRepo(conn)
|
a.operating = operating.NewRepo(conn)
|
||||||
a.udpRepo = udp.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) != "" {
|
if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" {
|
||||||
var defs []award.Def
|
var defs []award.Def
|
||||||
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
|
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.
|
// AwardFields lists the scannable QSO fields for the award editor.
|
||||||
func (a *App) AwardFields() []string { return award.Fields() }
|
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.
|
// SaveAwardDefs persists edited award definitions.
|
||||||
func (a *App) SaveAwardDefs(defs []award.Def) error {
|
func (a *App) SaveAwardDefs(defs []award.Def) error {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -1356,13 +1384,41 @@ func (a *App) ResetAwardDefs() ([]award.Def, error) {
|
|||||||
return d, nil
|
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) {
|
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 {
|
if a.qso == nil {
|
||||||
return nil, fmt.Errorf("db not initialized")
|
return nil, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
var all []qso.QSO
|
var all []qso.QSO
|
||||||
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
all = append(all, q)
|
all = append(all, q)
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -1379,7 +1435,6 @@ func (a *App) GetAwards() ([]award.Result, error) {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
defs := a.awardDefs()
|
|
||||||
refMetas := a.awardRefMetas(defs)
|
refMetas := a.awardRefMetas(defs)
|
||||||
results := award.Compute(defs, all, refMetas, nameOf)
|
results := award.Compute(defs, all, refMetas, nameOf)
|
||||||
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
|
// 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
|
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)
|
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
|
||||||
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
|
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
|
||||||
// are large and not needed for matching; their names are filled afterwards.
|
// are large and not needed for matching; their names are filled afterwards.
|
||||||
@@ -1459,6 +1674,85 @@ type QSOAwardRef struct {
|
|||||||
Pickable bool `json:"pickable"`
|
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
|
// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
|
||||||
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
|
// — 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.
|
// 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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
defs := a.awardDefs()
|
defs := a.awardDefs()
|
||||||
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
|
fieldByCode := map[string]string{}
|
||||||
var counts map[string]int
|
for _, d := range defs {
|
||||||
if a.awardRefs != nil {
|
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||||
counts, _ = a.awardRefs.Counts(a.ctx)
|
|
||||||
}
|
}
|
||||||
|
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
|
||||||
var out []QSOAwardRef
|
var out []QSOAwardRef
|
||||||
for i := range results {
|
for i := range results {
|
||||||
r := &results[i]
|
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 {
|
for _, rf := range r.Refs {
|
||||||
if !rf.Worked {
|
if !rf.Worked {
|
||||||
continue // a single QSO only contributes worked references
|
continue // a single QSO only contributes worked references
|
||||||
|
|||||||
@@ -793,10 +793,13 @@ export default function App() {
|
|||||||
|
|
||||||
function applyModePreset(m: string) {
|
function applyModePreset(m: string) {
|
||||||
if (rstUserEditedRef.current) return;
|
if (rstUserEditedRef.current) return;
|
||||||
|
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
||||||
|
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
||||||
|
// so switching SSB→CW flips 59→599 even without a configured preset.
|
||||||
const p = modePresets.find((x) => x.name === m);
|
const p = modePresets.find((x) => x.name === m);
|
||||||
if (!p) return;
|
const fallback = rstOptions(m, rstLists)[0] || '';
|
||||||
if (p.default_rst_sent) setRstSent(p.default_rst_sent);
|
setRstSent(p?.default_rst_sent || fallback);
|
||||||
if (p.default_rst_rcvd) setRstRcvd(p.default_rst_rcvd);
|
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ import { cn } from '@/lib/utils';
|
|||||||
import {
|
import {
|
||||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
ListAwardReferences, SaveAwardReference, DeleteAwardReference,
|
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// Above this many references the editor stops loading the whole list and
|
||||||
|
// switches to search-only (mirrors Log4OM's "Too many items" behaviour).
|
||||||
|
const REF_LIST_CAP = 1000;
|
||||||
|
|
||||||
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
|
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
|
||||||
|
|
||||||
export type AwardDef = {
|
export type AwardDef = {
|
||||||
@@ -358,24 +362,44 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
const [showBulk, setShowBulk] = useState(false);
|
const [showBulk, setShowBulk] = useState(false);
|
||||||
const [hasBuiltin, setHasBuiltin] = useState(false);
|
const [hasBuiltin, setHasBuiltin] = useState(false);
|
||||||
|
|
||||||
|
const total = meta?.count ?? 0;
|
||||||
|
const large = total > REF_LIST_CAP;
|
||||||
|
|
||||||
|
// Small lists are loaded whole and filtered client-side; large lists (POTA,
|
||||||
|
// 85k parks) are search-only to stay responsive.
|
||||||
const load = () => {
|
const load = () => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
|
if (total > REF_LIST_CAP) { setRefs([]); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
|
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
|
||||||
};
|
};
|
||||||
useEffect(load, [code]);
|
useEffect(load, [code, total]);
|
||||||
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
|
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
|
||||||
|
|
||||||
|
// Server-side search for large lists (debounced, min 2 chars).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!large) return;
|
||||||
|
const s = q.trim();
|
||||||
|
if (s.length < 2) { setRefs([]); return; }
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setBusy(true);
|
||||||
|
SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false));
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [code, q, large]);
|
||||||
|
|
||||||
async function populateBuiltin() {
|
async function populateBuiltin() {
|
||||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sel = refs.find((r) => r.code === selCode) || null;
|
const sel = refs.find((r) => r.code === selCode) || null;
|
||||||
|
// Large lists are already filtered by the server; small lists filter locally.
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
|
if (large) return refs;
|
||||||
const s = q.trim().toUpperCase();
|
const s = q.trim().toUpperCase();
|
||||||
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
|
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
|
||||||
}, [refs, q]);
|
}, [refs, q, large]);
|
||||||
|
|
||||||
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
|
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
|
||||||
|
|
||||||
@@ -407,7 +431,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{refs.length}</span></span>
|
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Select value="" onValueChange={applyPreset}>
|
<Select value="" onValueChange={applyPreset}>
|
||||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
|
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
|
||||||
@@ -440,8 +464,13 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
||||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Loading…</div>}
|
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
||||||
{!busy && filtered.length === 0 && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
|
{!busy && large && q.trim().length < 2 && (
|
||||||
|
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||||
|
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
|
||||||
{filtered.map((r) => (
|
{filtered.map((r) => (
|
||||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
<button key={r.code} onClick={() => setSelCode(r.code)}
|
||||||
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||||
type AwardDef = { code: string; name: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||||
type Meta = { code: string; count: number; can_update: boolean };
|
type Meta = { code: string; count: number; can_update: boolean };
|
||||||
|
|
||||||
|
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||||
|
// are computed, never manually picked, so they don't belong in this picker.
|
||||||
|
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'state', 'cont', 'country', 'grid']);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dxcc?: number;
|
dxcc?: number;
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
|
||||||
@@ -44,6 +48,8 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
// for a French call but not for others.
|
// for a French call but not for others.
|
||||||
const awards = useMemo(() => {
|
const awards = useMemo(() => {
|
||||||
return defs.filter((d) => {
|
return defs.filter((d) => {
|
||||||
|
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
|
||||||
|
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||||
const m = metas[String(d.code).toUpperCase()];
|
const m = metas[String(d.code).toUpperCase()];
|
||||||
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
|
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
|
||||||
if (!hasRefs) return false;
|
if (!hasRefs) return false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, CheckCircle2, Search, Pencil } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3 } from 'lucide-react';
|
||||||
import { GetAwards } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -10,7 +10,7 @@ type BandCount = { band: string; worked: number; confirmed: number };
|
|||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
ref: string; name?: string; group?: string; subgrp?: string;
|
ref: string; name?: string; group?: string; subgrp?: string;
|
||||||
worked: boolean; confirmed: boolean; validated: boolean;
|
worked: boolean; confirmed: boolean; validated: boolean;
|
||||||
bands: string[]; confirmed_bands: string[];
|
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||||
};
|
};
|
||||||
type AwardResult = {
|
type AwardResult = {
|
||||||
code: string; name: string; dimension: string;
|
code: string; name: string; dimension: string;
|
||||||
@@ -18,6 +18,28 @@ type AwardResult = {
|
|||||||
bands: BandCount[]; refs: AwardRef[];
|
bands: BandCount[]; refs: AwardRef[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||||
|
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
||||||
|
|
||||||
|
// Fixed band columns for the matrix view (Log4OM-style).
|
||||||
|
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||||
|
|
||||||
|
// Per-band status for a reference, highest first.
|
||||||
|
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none';
|
||||||
|
function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||||
|
if (r.validated_bands?.includes(band)) return 'validated';
|
||||||
|
if (r.confirmed_bands?.includes(band)) return 'confirmed';
|
||||||
|
if (r.bands?.includes(band)) return 'worked';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
|
validated: 'bg-emerald-500 text-white',
|
||||||
|
confirmed: 'bg-amber-400 text-amber-950',
|
||||||
|
worked: 'bg-stone-400 text-white',
|
||||||
|
none: '',
|
||||||
|
};
|
||||||
|
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
||||||
|
|
||||||
function pct(n: number, total: number): number {
|
function pct(n: number, total: number): number {
|
||||||
if (total <= 0) return 0;
|
if (total <= 0) return 0;
|
||||||
return Math.min(100, Math.round((n / total) * 100));
|
return Math.min(100, Math.round((n / total) * 100));
|
||||||
@@ -34,35 +56,78 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AwardListItem = { code: string; name: string; valid?: boolean };
|
||||||
|
|
||||||
export function AwardsPanel() {
|
export function AwardsPanel() {
|
||||||
const [results, setResults] = useState<AwardResult[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
|
// first time it's selected (or when explicitly rescanned).
|
||||||
|
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [selected, setSelected] = useState<string>('DXCC');
|
const [selected, setSelected] = useState<string>('');
|
||||||
const [refSearch, setRefSearch] = useState('');
|
const [refSearch, setRefSearch] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||||
|
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||||
|
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
// Lazily fetch the statistics matrix when the Stats view is shown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== 'stats' || !selected) return;
|
||||||
|
setStatsLoading(true);
|
||||||
|
GetAwardStats(selected)
|
||||||
|
.then((s) => setStats(s as any))
|
||||||
|
.catch(() => setStats(null))
|
||||||
|
.finally(() => setStatsLoading(false));
|
||||||
|
}, [view, selected]);
|
||||||
|
|
||||||
|
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||||
|
async function compute(code: string, force = false) {
|
||||||
|
if (!code) return;
|
||||||
|
if (!force && byCode[code]) { setSelected(code); return; }
|
||||||
|
setSelected(code);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
setResults((await GetAwards()) as any);
|
const r = (await GetAward(code)) as any as AwardResult;
|
||||||
|
setByCode((m) => ({ ...m, [code]: r }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const current = results.find((r) => r.code === selected) ?? results[0];
|
// Load the award list (no QSO scan), then compute only the first award.
|
||||||
|
async function loadList() {
|
||||||
|
try {
|
||||||
|
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||||
|
const list: AwardListItem[] = defs.map((d) => ({ code: d.code, name: d.name, valid: d.valid }));
|
||||||
|
setAwardList(list);
|
||||||
|
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||||
|
if (first) compute(first.code);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { loadList(); }, []);
|
||||||
|
|
||||||
|
const current = byCode[selected];
|
||||||
|
|
||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
if (!q) return current.refs;
|
return current.refs.filter((r) => {
|
||||||
return current.refs.filter((r) => r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q));
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
}, [current, refSearch]);
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [current, refSearch, refFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
@@ -75,34 +140,44 @@ export function AwardsPanel() {
|
|||||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={load} disabled={loading} title="Recalculate">
|
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
|
||||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
title="Rescan all QSOs and recompute this award">
|
||||||
|
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
|
Rescan
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={load} />
|
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||||
{results.map((r) => (
|
{awardList.map((a) => {
|
||||||
<button
|
const r = byCode[a.code];
|
||||||
key={r.code}
|
return (
|
||||||
onClick={() => { setSelected(r.code); setRefSearch(''); }}
|
<button
|
||||||
className={cn(
|
key={a.code}
|
||||||
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
|
onClick={() => { setRefSearch(''); compute(a.code); }}
|
||||||
current?.code === r.code && 'bg-accent/60',
|
className={cn(
|
||||||
)}
|
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
|
||||||
>
|
selected === a.code && 'bg-accent/60',
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
a.valid === false && 'opacity-50',
|
||||||
<span className="font-semibold text-sm">{r.code}</span>
|
)}
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">
|
>
|
||||||
<span className="text-emerald-600">{r.confirmed}</span>
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
/<span className="text-foreground">{r.worked}</span>
|
<span className="font-semibold text-sm">{a.code}</span>
|
||||||
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
|
{r ? (
|
||||||
</span>
|
<span className="text-[11px] font-mono text-muted-foreground">
|
||||||
</div>
|
<span className="text-emerald-600">{r.confirmed}</span>
|
||||||
<div className="text-[11px] text-muted-foreground truncate mb-1">{r.name}</div>
|
/<span className="text-foreground">{r.worked}</span>
|
||||||
<ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />
|
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
|
||||||
</button>
|
</span>
|
||||||
))}
|
) : (
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
|
||||||
|
{r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,55 +221,202 @@ export function AwardsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* References table */}
|
{/* References toolbar */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2">
|
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} reference{filteredRefs.length > 1 ? 's' : ''}</span>
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
|
||||||
|
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||||
|
<button key={k} onClick={() => setRefFilter(k)}
|
||||||
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
|
||||||
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
||||||
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||||
|
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||||
|
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||||
|
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
|
||||||
<table className="w-full text-xs">
|
{/* Statistics matrix: status × band, by mode category */}
|
||||||
<thead className="sticky top-0 bg-card">
|
{view === 'stats' ? (
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
{statsLoading || !stats ? (
|
||||||
<th className="py-1 pr-2 font-medium">Name</th>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
||||||
<th className="py-1 pr-2 font-medium w-40">Group</th>
|
) : (
|
||||||
<th className="py-1 pr-2 font-medium w-24">Status</th>
|
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<th className="py-1 font-medium">Bands</th>
|
<thead className="sticky top-0 z-10">
|
||||||
</tr>
|
<tr className="bg-card">
|
||||||
</thead>
|
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
|
||||||
<tbody>
|
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
|
||||||
{filteredRefs.map((r) => (
|
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
|
||||||
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
</tr>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
|
</thead>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
|
<tbody>
|
||||||
<td className="py-1 pr-2">
|
{stats.rows.map((row, i) => {
|
||||||
{!r.worked ? (
|
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
||||||
<span className="text-muted-foreground/70">— missing</span>
|
return (
|
||||||
) : r.validated ? (
|
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
||||||
<span className="inline-flex items-center gap-1 text-sky-600"><CheckCircle2 className="size-3" /> valid.</span>
|
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||||
) : r.confirmed ? (
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||||
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
|
{row.cells.map((c, j) => (
|
||||||
) : (
|
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||||
<span className="text-amber-600">worked</span>
|
))}
|
||||||
)}
|
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||||
</td>
|
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||||
<td className="py-1 font-mono text-muted-foreground">
|
</tr>
|
||||||
{r.bands.map((b) => (
|
);
|
||||||
<span key={b} className={cn('mr-1', r.confirmed_bands.includes(b) && 'text-emerald-600 font-semibold')}>{b}</span>
|
})}
|
||||||
))}
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : view === 'grid' ? (
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
|
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
||||||
|
<thead className="sticky top-0 z-10">
|
||||||
|
<tr className="bg-card">
|
||||||
|
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||||
|
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
|
||||||
|
{GRID_BANDS.map((b) => (
|
||||||
|
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{filteredRefs.map((r) => (
|
||||||
</div>
|
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||||
|
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||||
|
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
|
||||||
|
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||||
|
</td>
|
||||||
|
{GRID_BANDS.map((b) => {
|
||||||
|
const s = cellStatus(r, b);
|
||||||
|
return (
|
||||||
|
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||||
|
{s === 'none' ? <span className="block w-9 h-5" /> : (
|
||||||
|
<button
|
||||||
|
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||||
|
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||||
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
|
>{CELL_LABEL[s]}</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-card">
|
||||||
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
|
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
||||||
|
<th className="py-1 pr-2 font-medium">Name</th>
|
||||||
|
<th className="py-1 pr-2 font-medium w-40">Group</th>
|
||||||
|
<th className="py-1 pr-2 font-medium w-24">Status</th>
|
||||||
|
<th className="py-1 font-medium">Bands</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRefs.map((r) => (
|
||||||
|
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
||||||
|
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||||
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
|
||||||
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
|
||||||
|
<td className="py-1 pr-2">
|
||||||
|
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
||||||
|
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||||
|
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
||||||
|
: <span className="text-stone-500">worked</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 font-mono text-muted-foreground">
|
||||||
|
{r.bands.map((b) => (
|
||||||
|
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{cell && current && (
|
||||||
|
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||||
|
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||||
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
AwardCellQSOs(code, cell.ref, cell.band)
|
||||||
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
|
.catch(() => setQsos([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [code, cell.ref, cell.band]);
|
||||||
|
|
||||||
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
|
<span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
|
||||||
|
{cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading…</div>
|
||||||
|
) : qsos.length === 0 ? (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||||||
|
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{qsos.map((q, i) => (
|
||||||
|
<tr key={q.id ?? i} className="border-b border-border/30">
|
||||||
|
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
||||||
|
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
||||||
|
<td className="py-1 pr-2">{q.band}</td>
|
||||||
|
<td className="py-1 pr-2">{q.mode}</td>
|
||||||
|
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
@@ -21,6 +21,8 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
|
|||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
@@ -97,12 +99,16 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
|
export function GetAward(arg1:string):Promise<award.Result>;
|
||||||
|
|
||||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
|
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
|
||||||
|
|
||||||
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
||||||
|
|
||||||
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
export function GetAwards():Promise<Array<award.Result>>;
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export function ApplyAwardPreset(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function AwardFields() {
|
export function AwardFields() {
|
||||||
return window['go']['main']['App']['AwardFields']();
|
return window['go']['main']['App']['AwardFields']();
|
||||||
}
|
}
|
||||||
@@ -166,6 +170,10 @@ export function GetAudioSettings() {
|
|||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAward(arg1) {
|
||||||
|
return window['go']['main']['App']['GetAward'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAwardDefs() {
|
export function GetAwardDefs() {
|
||||||
return window['go']['main']['App']['GetAwardDefs']();
|
return window['go']['main']['App']['GetAwardDefs']();
|
||||||
}
|
}
|
||||||
@@ -178,6 +186,10 @@ export function GetAwardReferenceMeta() {
|
|||||||
return window['go']['main']['App']['GetAwardReferenceMeta']();
|
return window['go']['main']['App']['GetAwardReferenceMeta']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAwardStats(arg1) {
|
||||||
|
return window['go']['main']['App']['GetAwardStats'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAwards() {
|
export function GetAwards() {
|
||||||
return window['go']['main']['App']['GetAwards']();
|
return window['go']['main']['App']['GetAwards']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export namespace award {
|
|||||||
validated: boolean;
|
validated: boolean;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
confirmed_bands: string[];
|
confirmed_bands: string[];
|
||||||
|
validated_bands: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Ref(source);
|
return new Ref(source);
|
||||||
@@ -180,6 +181,7 @@ export namespace award {
|
|||||||
this.validated = source["validated"];
|
this.validated = source["validated"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
this.confirmed_bands = source["confirmed_bands"];
|
this.confirmed_bands = source["confirmed_bands"];
|
||||||
|
this.validated_bands = source["validated_bands"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Result {
|
export class Result {
|
||||||
@@ -676,6 +678,58 @@ export namespace main {
|
|||||||
this.can_update = source["can_update"];
|
this.can_update = source["can_update"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardStatRow {
|
||||||
|
label: string;
|
||||||
|
cells: number[];
|
||||||
|
total: number;
|
||||||
|
grand_total: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardStatRow(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.label = source["label"];
|
||||||
|
this.cells = source["cells"];
|
||||||
|
this.total = source["total"];
|
||||||
|
this.grand_total = source["grand_total"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AwardStatsResult {
|
||||||
|
code: string;
|
||||||
|
bands: string[];
|
||||||
|
rows: AwardStatRow[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardStatsResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.rows = this.convertValues(source["rows"], AwardStatRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BackupSettings {
|
export class BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
folder: string;
|
folder: string;
|
||||||
|
|||||||
+153
-37
@@ -101,6 +101,48 @@ func Defaults() []Def {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate upgrades award definitions saved before the richer model existed.
|
||||||
|
// Such defs have Type=="" and the zero value for the new fields (notably
|
||||||
|
// Valid==false, which would otherwise hide every legacy award). For each legacy
|
||||||
|
// def it enables the award, fills the matching/confirmation fields from the
|
||||||
|
// matching built-in default (preserving the user's field/filters/confirm), and
|
||||||
|
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
|
||||||
|
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
|
||||||
|
func Migrate(defs []Def) ([]Def, bool) {
|
||||||
|
defaults := map[string]Def{}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
defaults[strings.ToUpper(d.Code)] = d
|
||||||
|
}
|
||||||
|
const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
|
||||||
|
changed := false
|
||||||
|
out := make([]Def, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
if d.Type != "" {
|
||||||
|
out[i] = d // already on the new model
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
d.Valid = true // legacy defs predate the Valid flag → enable them
|
||||||
|
if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
|
||||||
|
d.Type = def.Type
|
||||||
|
d.ExactMatch = def.ExactMatch
|
||||||
|
d.Dynamic = def.Dynamic
|
||||||
|
d.Protected = def.Protected
|
||||||
|
if len(d.Validate) == 0 {
|
||||||
|
d.Validate = def.Validate
|
||||||
|
}
|
||||||
|
// Fix DDFM's capture group ("06" → "D06") so refs match the list.
|
||||||
|
if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
|
||||||
|
d.Pattern = def.Pattern
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.Type = TypeQSOFields // sensible default for custom legacy awards
|
||||||
|
}
|
||||||
|
out[i] = d
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
// Fields lists the scannable QSO fields for the award editor.
|
// Fields lists the scannable QSO fields for the award editor.
|
||||||
func Fields() []string {
|
func Fields() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -129,6 +171,7 @@ type Ref struct {
|
|||||||
Validated bool `json:"validated"`
|
Validated bool `json:"validated"`
|
||||||
Bands []string `json:"bands"`
|
Bands []string `json:"bands"`
|
||||||
ConfirmedBands []string `json:"confirmed_bands"`
|
ConfirmedBands []string `json:"confirmed_bands"`
|
||||||
|
ValidatedBands []string `json:"validated_bands"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is an award's computed progress.
|
// Result is an award's computed progress.
|
||||||
@@ -151,6 +194,7 @@ type NameResolver func(field, ref string) string
|
|||||||
type refAgg struct {
|
type refAgg struct {
|
||||||
bands map[string]struct{}
|
bands map[string]struct{}
|
||||||
confirmedBands map[string]struct{}
|
confirmedBands map[string]struct{}
|
||||||
|
validatedBands map[string]struct{}
|
||||||
anyConfirmed bool
|
anyConfirmed bool
|
||||||
anyValidated bool
|
anyValidated bool
|
||||||
}
|
}
|
||||||
@@ -158,8 +202,9 @@ type refAgg struct {
|
|||||||
// refList is the per-award reference data Compute needs (a thin view of
|
// refList is the per-award reference data Compute needs (a thin view of
|
||||||
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
||||||
type refList struct {
|
type refList struct {
|
||||||
byCode map[string]RefMeta // uppercased code → metadata
|
byCode map[string]RefMeta // uppercased code → metadata
|
||||||
codes []string // codes in input order (for stable unworked listing)
|
codes []string // codes in input order (for stable unworked listing)
|
||||||
|
withPattern []string // codes whose reference declares a regex (usually none)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
||||||
@@ -187,6 +232,7 @@ func NewRefList(metas []RefMeta) refList {
|
|||||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||||
if re, err := regexp.Compile(p); err == nil {
|
if re, err := regexp.Compile(p); err == nil {
|
||||||
m.re = re
|
m.re = re
|
||||||
|
rl.withPattern = append(rl.withPattern, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.Code = code
|
m.Code = code
|
||||||
@@ -245,7 +291,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
a := agg[i][ref]
|
a := agg[i][ref]
|
||||||
if a == nil {
|
if a == nil {
|
||||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}}
|
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
||||||
agg[i][ref] = a
|
agg[i][ref] = a
|
||||||
}
|
}
|
||||||
if band != "" {
|
if band != "" {
|
||||||
@@ -259,6 +305,9 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
}
|
}
|
||||||
if isVal {
|
if isVal {
|
||||||
a.anyValidated = true
|
a.anyValidated = true
|
||||||
|
if band != "" {
|
||||||
|
a.validatedBands[band] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +330,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
r.Validated++
|
r.Validated++
|
||||||
}
|
}
|
||||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||||
r.Refs = append(r.Refs, rf)
|
r.Refs = append(r.Refs, rf)
|
||||||
for b := range a.bands {
|
for b := range a.bands {
|
||||||
@@ -303,7 +352,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
if !m.Valid {
|
if !m.Valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}}
|
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}, ValidatedBands: []string{}}
|
||||||
if rf.Name == "" && nameOf != nil {
|
if rf.Name == "" && nameOf != nil {
|
||||||
rf.Name = nameOf(d.Field, code)
|
rf.Name = nameOf(d.Field, code)
|
||||||
}
|
}
|
||||||
@@ -317,7 +366,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||||
return r.Refs[a].Confirmed
|
return r.Refs[a].Confirmed
|
||||||
}
|
}
|
||||||
return r.Refs[a].Ref < r.Refs[b].Ref
|
return natLess(r.Refs[a].Ref, r.Refs[b].Ref)
|
||||||
})
|
})
|
||||||
for _, b := range sortedBands(bandWorked) {
|
for _, b := range sortedBands(bandWorked) {
|
||||||
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
|
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
|
||||||
@@ -327,6 +376,32 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchQSO returns the reference codes a single QSO contributes to for one
|
||||||
|
// award (respecting scope + predefined enforcement). metas is the award's
|
||||||
|
// reference list (empty/nil for dynamic awards). Used for cell drill-down.
|
||||||
|
func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
||||||
|
if !inScope(&d, q) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
|
if c, err := regexp.Compile(p); err == nil {
|
||||||
|
re = c
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl := NewRefList(metas)
|
||||||
|
return candidates(&d, re, q, rl, len(metas) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
|
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||||
|
|
||||||
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||||
|
|
||||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||||
// name resolver as a fallback).
|
// name resolver as a fallback).
|
||||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||||
@@ -355,12 +430,16 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// Award-level regex: capture group 1 (or whole match) for each hit.
|
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||||
found = regexTokens(re, raw)
|
found = regexTokens(re, raw)
|
||||||
case predefined && !d.ExactMatch:
|
case predefined && !d.ExactMatch:
|
||||||
// "Search reference inside the field": keep any reference code that
|
// "Search reference inside the field": look up each token of the field in
|
||||||
// appears as a token (or whose per-reference pattern matches).
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
up := strings.ToUpper(raw)
|
// references that declare a regex.
|
||||||
for _, code := range rl.codes {
|
for _, tok := range tokenize(raw) {
|
||||||
m := rl.byCode[code]
|
if _, ok := rl.byCode[tok]; ok {
|
||||||
if (m.re != nil && m.re.MatchString(raw)) || containsToken(up, code) {
|
found = append(found, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range rl.withPattern {
|
||||||
|
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||||
found = append(found, code)
|
found = append(found, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,8 +451,11 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
if !predefined {
|
if !predefined {
|
||||||
return dedupe(found)
|
return dedupe(found)
|
||||||
}
|
}
|
||||||
// Enforce the predefined list: keep only listed, valid references whose
|
// Enforce the predefined list: keep only listed, valid references. The
|
||||||
// per-reference DXCC scope (if any) includes the QSO's entity.
|
// award-level DXCCFilter already scopes which QSOs are considered (see
|
||||||
|
// inScope), so we do NOT additionally require the QSO's entity to match the
|
||||||
|
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
||||||
|
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
||||||
var out []string
|
var out []string
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
@@ -382,9 +464,6 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
if !ok || !m.Valid {
|
if !ok || !m.Valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, dup := seen[c]; dup {
|
if _, dup := seen[c]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -425,27 +504,28 @@ func dedupe(in []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// containsToken reports whether code appears in up (already uppercased) as a
|
// tokenize splits a field into uppercased tokens on any non-alphanumeric run,
|
||||||
// whole token (delimited by non-alphanumerics), so "D6" doesn't match "D60".
|
// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553").
|
||||||
func containsToken(up, code string) bool {
|
// The whole trimmed value is also returned so single-token fields match.
|
||||||
for from := 0; ; {
|
func tokenize(raw string) []string {
|
||||||
idx := strings.Index(up[from:], code)
|
up := strings.ToUpper(strings.TrimSpace(raw))
|
||||||
if idx < 0 {
|
if up == "" {
|
||||||
return false
|
return nil
|
||||||
}
|
|
||||||
i := from + idx
|
|
||||||
j := i + len(code)
|
|
||||||
leftOK := i == 0 || !isAlnum(up[i-1])
|
|
||||||
rightOK := j == len(up) || !isAlnum(up[j])
|
|
||||||
if leftOK && rightOK {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
from = i + 1
|
|
||||||
}
|
}
|
||||||
}
|
out := []string{up}
|
||||||
|
cur := strings.Builder{}
|
||||||
func isAlnum(b byte) bool {
|
for _, r := range up {
|
||||||
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
|
if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
} else if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripAffix removes a leading and/or trailing literal string before matching.
|
// stripAffix removes a leading and/or trailing literal string before matching.
|
||||||
@@ -462,6 +542,42 @@ func stripAffix(s, lead, trail string) string {
|
|||||||
|
|
||||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||||
|
|
||||||
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||||
|
|
||||||
|
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
|
||||||
|
// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10".
|
||||||
|
func natLess(a, b string) bool {
|
||||||
|
ia, ib := 0, 0
|
||||||
|
for ia < len(a) && ib < len(b) {
|
||||||
|
ca, cb := a[ia], b[ib]
|
||||||
|
if isDigit(ca) && isDigit(cb) {
|
||||||
|
ja, jb := ia, ib
|
||||||
|
for ja < len(a) && isDigit(a[ja]) {
|
||||||
|
ja++
|
||||||
|
}
|
||||||
|
for jb < len(b) && isDigit(b[jb]) {
|
||||||
|
jb++
|
||||||
|
}
|
||||||
|
na := strings.TrimLeft(a[ia:ja], "0")
|
||||||
|
nb := strings.TrimLeft(b[ib:jb], "0")
|
||||||
|
if len(na) != len(nb) {
|
||||||
|
return len(na) < len(nb) // fewer digits = smaller number
|
||||||
|
}
|
||||||
|
if na != nb {
|
||||||
|
return na < nb
|
||||||
|
}
|
||||||
|
ia, ib = ja, jb
|
||||||
|
} else {
|
||||||
|
if ca != cb {
|
||||||
|
return ca < cb
|
||||||
|
}
|
||||||
|
ia++
|
||||||
|
ib++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(a)-ia < len(b)-ib
|
||||||
|
}
|
||||||
|
|
||||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
// bands, modes, emission category, validity dates).
|
// bands, modes, emission category, validity dates).
|
||||||
func inScope(d *Def, q *qso.QSO) bool {
|
func inScope(d *Def, q *qso.QSO) bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
@@ -66,6 +67,17 @@ func TestComputeDXCCAndConfirm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNatLess(t *testing.T) {
|
||||||
|
in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"}
|
||||||
|
want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"}
|
||||||
|
sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) })
|
||||||
|
for i := range want {
|
||||||
|
if in[i] != want[i] {
|
||||||
|
t.Fatalf("natLess order = %v, want %v", in, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refCodes(r Result) []string {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range r.Refs {
|
for _, rf := range r.Refs {
|
||||||
@@ -74,6 +86,57 @@ func refCodes(r Result) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy defs (Type=="", Valid==false, old DDFM pattern) get upgraded.
|
||||||
|
func TestMigrate(t *testing.T) {
|
||||||
|
legacy := []Def{
|
||||||
|
{Code: "DXCC", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340}, // Valid=false, Type=""
|
||||||
|
{Code: "DDFM", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, Total: 96},
|
||||||
|
{Code: "MYAWARD", Field: "note"}, // custom legacy
|
||||||
|
}
|
||||||
|
out, changed := Migrate(legacy)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected migration to change legacy defs")
|
||||||
|
}
|
||||||
|
for _, d := range out {
|
||||||
|
if !d.Valid {
|
||||||
|
t.Errorf("%s should be enabled after migration", d.Code)
|
||||||
|
}
|
||||||
|
if d.Type == "" {
|
||||||
|
t.Errorf("%s should have a type after migration", d.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out[1].Pattern != `(?i)\b(D\d{1,2}[AB]?)\b` {
|
||||||
|
t.Errorf("DDFM pattern not fixed: %q", out[1].Pattern)
|
||||||
|
}
|
||||||
|
if out[2].Type != TypeQSOFields {
|
||||||
|
t.Errorf("custom legacy award type = %q, want QSOFIELDS", out[2].Type)
|
||||||
|
}
|
||||||
|
// Idempotent: a second pass changes nothing.
|
||||||
|
if _, changed2 := Migrate(out); changed2 {
|
||||||
|
t.Error("migration should be idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: a predefined reference whose own DXCC differs from the QSO's
|
||||||
|
// entity must still count when the field code matches and the award-level
|
||||||
|
// DXCC filter allows it (WAS Alaska: state AK, but DXCC entity 6, not 291).
|
||||||
|
func TestComputePredefinedCrossDXCC(t *testing.T) {
|
||||||
|
def := Def{Code: "WAS", Type: TypeQSOFields, Field: "state", ExactMatch: true,
|
||||||
|
DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "KL5DX", Band: "20m", DXCC: ip(6), State: "AK", LOTWRcvd: "Y"}, // Alaska
|
||||||
|
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // continental
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAS": {
|
||||||
|
{Code: "AK", Name: "Alaska", DXCCList: []int{291}, Valid: true}, // wrong DXCC on purpose
|
||||||
|
{Code: "MA", Name: "Massachusetts", DXCCList: []int{291}, Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 {
|
||||||
|
t.Errorf("WAS worked = %d, want 2 (Alaska must count despite DXCC 6) %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A predefined award only counts references present in its list, lists the
|
// A predefined award only counts references present in its list, lists the
|
||||||
// unworked ones too, and uses the list size as the denominator.
|
// unworked ones too, and uses the list size as the denominator.
|
||||||
func TestComputePredefinedList(t *testing.T) {
|
func TestComputePredefinedList(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user