fix(stats): confirmed outranks worked in the band/mode matrix

Reported by VK4DX with the case that names itself: YB confirmed on 20m
digital, painted blue, because one unconfirmed YB station had also been
worked on that slot.

The ladder ran call_c > call_w > dxcc_c > dxcc_w, so a callsign worked and
not confirmed beat an entity CONFIRMED on the same band and mode. The grid
answers 'what do I still need here', and a confirmed entity needs nothing
whoever was worked afterwards — so the order is now call_c > dxcc_c >
call_w > dxcc_w.

Two things made it easy to get wrong, and both are fixed rather than
merely corrected. The code was a run of assignments where the later test
silently overwrote the earlier, so 'call worked' erased 'entity
confirmed'; it now takes the maximum. And the rule lived inside a scan
loop where nothing could reach it, so it is lifted into bandStatusCode
with a table test — including this exact case, and one that fails if
anybody reorders the constants.
This commit is contained in:
2026-08-26 10:53:36 +02:00
parent 08905a1680
commit dc8f9c5099
3 changed files with 120 additions and 21 deletions
+58 -19
View File
@@ -1895,7 +1895,8 @@ type WorkedBefore struct {
// Status grid driving the band×class matrix in the UI. One entry per
// (band, class) where ANY QSO exists in this DXCC. Only the highest
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
// status for that cell is kept (call_c > dxcc_c > call_w > dxcc_w —
// confirmed outranks worked).
BandStatus []BandStatus `json:"band_status"`
}
@@ -1906,6 +1907,48 @@ type BandStatus struct {
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
}
// Band-status codes, lowest first. The ORDER is the rule: a cell shows the
// highest that applies.
const (
stDxccW = iota // the entity was worked on this slot
stCallW // …and this callsign was one of them
stDxccC // the entity is CONFIRMED here
stCallC // …and by this callsign
)
// bandStatusNames maps those codes to what the UI colours by.
var bandStatusNames = [...]string{"dxcc_w", "call_w", "dxcc_c", "call_c"}
// bandStatusCode picks the status of one cell of the band × mode matrix.
//
// CONFIRMED OUTRANKS WORKED, and that is the whole of it. The ladder used to
// run call_c > call_w > dxcc_c > dxcc_w, so a callsign worked and not confirmed
// beat an entity confirmed on the same slot: an operator with YB confirmed on
// 20m digital saw that cell as "worked, not confirmed" because he had also
// worked one unconfirmed YB station there. The grid answers "what do I still
// need", and a confirmed entity needs nothing, whoever was worked afterwards.
//
// Taken as a MAXIMUM rather than as a run of assignments, which is how the
// later test came to overwrite the earlier one in the first place.
func bandStatusCode(callWorked, callConfirmed, entityConfirmed bool) int {
code := stDxccW // there is a row at all ⇒ the entity was worked here
raise := func(c int) {
if c > code {
code = c
}
}
if callWorked {
raise(stCallW)
}
if entityConfirmed {
raise(stDxccC)
}
if callConfirmed {
raise(stCallC)
}
return code
}
// modeClass collapses ADIF modes into the three buckets DXers care about.
// Anything not voice and not CW is treated as digital.
func modeClass(mode string) string {
@@ -2167,7 +2210,18 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
// ---- Per-(band, class) status grid ----
// One pass over every distinct (band, mode) in the DXCC, aggregating
// "did this call work it?" and "was anything confirmed?" via MAX.
// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
// Status precedence: CONFIRMED OUTRANKS WORKED — call_c > dxcc_c > call_w >
// dxcc_w.
//
// It used to run call_c > call_w > dxcc_c > dxcc_w, which made a call worked
// and not confirmed outrank an entity confirmed on the same slot. Reported
// from a real log: YB confirmed on 20m digital showed BLUE, because that
// operator had also worked one unconfirmed YB station there. The cell said
// "not confirmed" about a slot that is confirmed.
//
// The grid answers "what do I still need on this band and mode", and for
// that question confirmation is the axis that matters: a confirmed entity
// needs nothing, whoever else was worked afterwards.
// Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI.
@@ -2188,12 +2242,6 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
return wb, fmt.Errorf("band status: %w", err)
}
type cellKey struct{ band, class string }
const (
stDxccW = 0
stDxccC = 1
stCallW = 2
stCallC = 3
)
best := map[cellKey]int{}
for statusRows.Next() {
var band, mode string
@@ -2202,23 +2250,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
statusRows.Close()
return wb, fmt.Errorf("scan band status: %w", err)
}
code := stDxccW // row exists ⇒ entity worked at minimum
if dxccConfirmed == 1 {
code = stDxccC
}
if callW == 1 {
code = stCallW
}
if callC == 1 {
code = stCallC
}
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
k := cellKey{band: band, class: modeClass(mode)}
if cur, ok := best[k]; !ok || code > cur {
best[k] = code
}
}
statusRows.Close()
codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"}
codeStr := bandStatusNames
for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code],