package main // "Have I worked this reference before?" — the NEW badge on a QSO's award // references. // // # Where the answer comes from, and where it must NOT come from // // The obvious source is the materialised award_refs column: it is already on // every row and one query reads it. It is also the wrong answer twice over. // That column holds DISPLAY LABELS, not references — depending on the award's // RefDisplay it can read "Krasnogvardeysky" or "ST-25 — Krasnogvardeysky", and // DXCC stores a country name — and it is a derived cache that lags: a QSO whose // references were back-filled (see BackfillRDA) counts on the Awards grid long // before anything recomputes its column. The first build of this badge used it // and marked a district worked on five bands as NEW. // // So the answer comes from the same place the Awards grid's answer comes from: // award.MatchQSO over the logbook. What is engineered here is the COST of that, // because the question is asked while the operator is logging: // // - the set is per award CODE, and built only for codes actually asked about; // - it reuses the award snapshot when one is already in memory, which it // usually is (the Awards tab, the cluster status index); // - otherwise it STREAMS the logbook rather than materialising a second copy // of it — the references of one award are a few thousand short strings; // - and it is built in the BACKGROUND. A caller that asks before the set is // ready is told so and gets no badge, never a wait and never a wrong badge. // // A freshly logged QSO's references are folded into the sets, so a normal // evening of logging never rebuilds anything. import ( "strings" "hamlog/internal/award" "hamlog/internal/qso" ) // workedRefIndex is the per-code set of references already in the log, plus the // logbook revision it describes. type workedRefIndex struct { rev string byCode map[string]map[string]struct{} building map[string]bool } // AwardRefNewResult answers one badge request. type AwardRefNewResult struct { // New maps "CODE@REF" to "never worked". An entry whose award set is still // building is ABSENT rather than false: "not worked" and "not known yet" // must not look the same to the badge. New map[string]bool `json:"new"` // Pending is true when at least one award set is still being built, and the // caller should ask again shortly. Pending bool `json:"pending"` } // AwardRefsNew reports which of the given "CODE@REF" entries have never been // worked. Returns immediately, always. func (a *App) AwardRefsNew(entries []string) AwardRefNewResult { res := AwardRefNewResult{New: map[string]bool{}} if a.qso == nil || len(entries) == 0 { return res } rev, err := a.qso.Revision(a.ctx) if err != nil { return res // logbook unreachable: no badge is the honest answer } // Group the request by award code, so one code is looked up once however // many of its references are on the QSO. want := map[string][]string{} for _, e := range entries { code, _, ok := splitRefEntry(e) if !ok { continue } want[code] = append(want[code], e) } a.workedRefMu.Lock() if a.workedRef.rev != rev || a.workedRef.byCode == nil { // The logbook moved on: every set describes a log that no longer exists. a.workedRef = workedRefIndex{rev: rev, byCode: map[string]map[string]struct{}{}, building: map[string]bool{}} } var build []string for code := range want { set, ready := a.workedRef.byCode[code] if !ready { if !a.workedRef.building[code] { a.workedRef.building[code] = true build = append(build, code) } res.Pending = true continue } for _, e := range want[code] { _, ref, _ := splitRefEntry(e) _, worked := set[ref] res.New[e] = !worked } } a.workedRefMu.Unlock() for _, code := range build { go a.buildWorkedRefs(code, rev) } return res } // splitRefEntry splits "RDA@ST-25" into its uppercased code and reference. func splitRefEntry(e string) (code, ref string, ok bool) { at := strings.Index(e, "@") if at <= 0 { return "", "", false } code = strings.ToUpper(strings.TrimSpace(e[:at])) ref = strings.ToUpper(strings.TrimSpace(e[at+1:])) return code, ref, code != "" && ref != "" } // buildWorkedRefs collects every reference of one award already in the log. func (a *App) buildWorkedRefs(code string, rev string) { set := map[string]struct{}{} defer func() { a.workedRefMu.Lock() // Publish only against the revision we were asked for: a QSO logged while // this ran has already reset the index, and a set built from the older log // would claim to describe the newer one. if a.workedRef.rev == rev && a.workedRef.byCode != nil { a.workedRef.byCode[code] = set } delete(a.workedRef.building, code) a.workedRefMu.Unlock() }() var def *award.Def for _, d := range a.awardDefs() { if strings.EqualFold(d.Code, code) { dd := d def = &dd break } } if def == nil { return // unknown award: an empty set, so nothing is ever badged NEW } metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] collect := func(q *qso.QSO) { for _, r := range award.MatchQSO(*def, metas, q) { set[strings.ToUpper(strings.TrimSpace(r))] = struct{}{} } } // The snapshot is already enriched and already in memory whenever the Awards // tab has been open — by far the common case, and free. if snap := a.awardSnapshotIfLoaded(); snap != nil { for i := range snap { collect(&snap[i]) } return } // Otherwise stream. One pass, nothing retained but the reference strings. _ = a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error { a.enrichQSOForAwards(&q) collect(&q) return nil }) } // awardSnapshotIfLoaded returns the award snapshot ONLY if one is already built // and current. Never builds one: the caller is a background convenience, and // reading the whole logbook for it is the cost this file exists to avoid. func (a *App) awardSnapshotIfLoaded() []qso.QSO { rev, err := a.qso.Revision(a.ctx) if err != nil { return nil } a.awardSnapMu.Lock() defer a.awardSnapMu.Unlock() if a.awardSnap != nil && a.awardSnapRev == rev { return a.awardSnap } return nil } // noteWorkedQSO folds a just-logged contact's references into the built sets, so // logging does not throw away work already done. func (a *App) noteWorkedQSO(q qso.QSO) { a.workedRefMu.Lock() codes := make(map[string]bool, len(a.workedRef.byCode)) for c := range a.workedRef.byCode { codes[c] = true } a.workedRefMu.Unlock() if len(codes) == 0 { return } rev, err := a.qso.Revision(a.ctx) if err != nil { return } a.enrichQSOForAwards(&q) found := map[string][]string{} for _, d := range a.awardDefs() { code := strings.ToUpper(d.Code) if !codes[code] { continue } metas := a.awardRefMetas([]award.Def{d})[code] for _, r := range award.MatchQSO(d, metas, &q) { found[code] = append(found[code], strings.ToUpper(strings.TrimSpace(r))) } } a.workedRefMu.Lock() defer a.workedRefMu.Unlock() // Carry the sets forward to the new revision rather than dropping them: they // were correct one contact ago, and that contact is what is being added. a.workedRef.rev = rev for code, refs := range found { if set := a.workedRef.byCode[code]; set != nil { for _, r := range refs { set[r] = struct{}{} } } } } // invalidateWorkedRefs drops every set. For the changes a revision cannot see — // award definitions edited, reference lists updated, QSLs confirmed. func (a *App) invalidateWorkedRefs() { a.workedRefMu.Lock() a.workedRef = workedRefIndex{} a.workedRefMu.Unlock() }