diff --git a/changelog.json b/changelog.json index d9917b1..ebf8c35 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,12 @@ "version": "0.26.17", "date": "", "en": [ - "Awards, RDA district comparison: each disagreement can now be settled on its own row. Click the district you keep — the log's or the database's — and apply. The choice becomes the contact's award reference, so it is what counts, while both sources keep saying what they said." + "Awards, RDA district comparison: each disagreement can now be settled on its own row. Click the district you keep — the log's or the database's — and apply. The choice becomes the contact's award reference, so it is what counts, while both sources keep saying what they said.", + "The band/mode matrix: a confirmed slot now shows as confirmed. A callsign worked and not confirmed was outranking an entity CONFIRMED on the same band and mode, so a slot that needs nothing was painted as if it still did." ], "fr": [ - "Diplômes, comparaison des districts RDA : chaque divergence se règle désormais sur sa propre ligne. Cliquer le district qu'on garde — celui du log ou celui de la base — puis appliquer. Le choix devient la référence de diplôme du contact, donc c'est lui qui compte, tandis que les deux sources continuent de dire ce qu'elles disaient." + "Diplômes, comparaison des districts RDA : chaque divergence se règle désormais sur sa propre ligne. Cliquer le district qu'on garde — celui du log ou celui de la base — puis appliquer. Le choix devient la référence de diplôme du contact, donc c'est lui qui compte, tandis que les deux sources continuent de dire ce qu'elles disaient.", + "Matrice bandes/modes : une case confirmée s'affiche enfin comme confirmée. Un indicatif travaillé et non confirmé l'emportait sur une entité CONFIRMÉE sur la même bande et le même mode, si bien qu'une case qui ne demandait plus rien était peinte comme s'il manquait encore quelque chose." ] }, { diff --git a/internal/qso/bandstatus_test.go b/internal/qso/bandstatus_test.go new file mode 100644 index 0000000..bdb6584 --- /dev/null +++ b/internal/qso/bandstatus_test.go @@ -0,0 +1,58 @@ +package qso + +import "testing" + +// The colour of a matrix cell is a claim about what the operator still needs, +// and it was wrong for several releases: a callsign worked and not confirmed +// outranked an entity CONFIRMED on the same band and mode, so a slot that was +// finished showed as unfinished. +// +// Reported by VK4DX with the case that names itself: YB confirmed on 20m +// digital, shown blue, because one unconfirmed YB station had also been worked +// there. +func TestBandStatusConfirmedOutranksWorked(t *testing.T) { + cases := []struct { + name string + callWorked, callConfirmed, entityConfirm bool + want string + }{ + {"entity worked only", false, false, false, "dxcc_w"}, + {"this call worked, nothing confirmed", true, false, false, "call_w"}, + // The regression, in one line. + {"entity confirmed, this call worked but not confirmed", true, false, true, "dxcc_c"}, + {"entity confirmed, this call not worked here", false, false, true, "dxcc_c"}, + {"this call confirmed", true, true, true, "call_c"}, + // A confirmed contact with this call implies it was worked, but the flags + // arrive from separate SQL aggregates and nothing guarantees the pair. + {"call confirmed without the worked flag", false, true, true, "call_c"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := bandStatusNames[bandStatusCode(c.callWorked, c.callConfirmed, c.entityConfirm)] + if got != c.want { + t.Errorf("bandStatusCode(worked=%v, callConf=%v, entityConf=%v) = %s, want %s", + c.callWorked, c.callConfirmed, c.entityConfirm, got, c.want) + } + }) + } +} + +// The ladder itself, stated once: every confirmed code must beat every worked +// code. A future edit that reorders the constants fails here rather than in a +// screenshot from an operator. +func TestBandStatusLadderPutsConfirmedAbove(t *testing.T) { + for _, worked := range []int{stDxccW, stCallW} { + for _, confirmed := range []int{stDxccC, stCallC} { + if confirmed <= worked { + t.Errorf("%s (%d) does not outrank %s (%d)", + bandStatusNames[confirmed], confirmed, bandStatusNames[worked], worked) + } + } + } + if stCallC <= stDxccC { + t.Error("call_c must outrank dxcc_c: the callsign is the more specific claim") + } + if stCallW <= stDxccW { + t.Error("call_w must outrank dxcc_w for the same reason") + } +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index d5307f6..906f042 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -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],