chore: release v0.26.0

This commit is contained in:
2026-08-20 17:53:55 +02:00
parent 47992b5f03
commit 1e507225dd
44 changed files with 3197 additions and 240 deletions
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"strings"
"hamlog/internal/award"
"hamlog/internal/qso"
)
// New-grid scoping: WHICH prior contacts count as "I already have this square".
//
// The question has no single right answer, which is why it is a setting rather
// than a rule. A VUCC chaser counts a square per band; someone filling in a
// wall map counts it once. GridTracker offers the same six combinations, and an
// operator running both alongside each other needs them to agree — a square
// "wanted" in one and not the other is a bug report every time.
const (
keyGridScope = "grid.scope" // band+mode combination — see gridScopes
keyGridHunt = "grid.hunt" // "new" | "new_unconfirmed"
)
// gridScope is one band × mode combination.
type gridScope struct {
Key string `json:"key"`
// Band is true when the square has to have been worked on THIS band. False
// is GridTracker's "Mix Bands": any band counts.
Band bool `json:"band"`
// Mode is how much of the mode matters:
// "digi" — any digital mode at all
// "ftx" — any of the FT family (FT8/FT4/FT2)
// "mode" — this exact mode and no other
Mode string `json:"mode"`
}
var gridScopes = []gridScope{
{Key: "band_digi", Band: true, Mode: "digi"},
{Key: "band_mode", Band: true, Mode: "mode"},
{Key: "band_ftx", Band: true, Mode: "ftx"},
{Key: "mix_digi", Band: false, Mode: "digi"},
{Key: "mix_mode", Band: false, Mode: "mode"},
{Key: "mix_ftx", Band: false, Mode: "ftx"},
}
// defaultGridScope matches what the panel showed before the setting existed:
// any band, any digital mode. Changing an operator's badges on upgrade is worse
// than offering them a choice they have not made yet.
const defaultGridScope = "mix_digi"
func lookupGridScope(key string) gridScope {
for _, s := range gridScopes {
if s.Key == key {
return s
}
}
for _, s := range gridScopes {
if s.Key == defaultGridScope {
return s
}
}
return gridScopes[0]
}
// ftxModes is the FT family, which is a narrower thing than "digital": an
// operator chasing squares on FT8 does not count the RTTY contest contact, and
// GridTracker draws the same line.
var ftxModes = map[string]bool{"FT8": true, "FT4": true, "FT2": true}
// gridClassesOf lists every bucket one logged mode belongs to. A single FT8
// contact counts under its own name, under FTx and under digital — the scopes
// overlap by construction, so the index stores all three and the lookup picks.
func gridClassesOf(mode string) []string {
m := strings.ToUpper(strings.TrimSpace(mode))
if m == "" {
return nil
}
out := []string{"m:" + m}
if ftxModes[m] {
out = append(out, "ftx")
}
if award.ModeClass(m) == "DIGI" {
out = append(out, "digi")
}
return out
}
// gridClassFor turns the scope plus the mode being heard into the one class to
// look up. Empty means "this scope cannot judge this mode" — a phone contact
// under an FTx scope — and the caller then flags nothing rather than guessing.
func gridClassFor(s gridScope, mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
if m == "" {
return ""
}
switch s.Mode {
case "mode":
return "m:" + m
case "ftx":
if ftxModes[m] {
return "ftx"
}
return ""
default: // digi
if award.ModeClass(m) == "DIGI" {
return "digi"
}
return ""
}
}
// GridScopeSettings is the panel's shape.
type GridScopeSettings struct {
Scope string `json:"scope"`
Hunt string `json:"hunt"`
// Scopes is the list the UI renders, so it cannot drift from the one the
// lookup honours.
Scopes []gridScope `json:"scopes"`
}
func (a *App) GetGridScopeSettings() GridScopeSettings {
return GridScopeSettings{
Scope: lookupGridScope(a.settingOr(keyGridScope, "")).Key,
Hunt: gridHunt(a.settingOr(keyGridHunt, "")),
Scopes: gridScopes,
}
}
// gridHunt normalises the hunting mode. "new" is never-worked; "new_unconfirmed"
// also chases a square worked but not yet confirmed — the same square is still
// missing from the award either way.
func gridHunt(v string) string {
if strings.TrimSpace(v) == "new_unconfirmed" {
return "new_unconfirmed"
}
return "new"
}
func (a *App) SaveGridScopeSettings(s GridScopeSettings) error {
a.setSetting(keyGridScope, lookupGridScope(s.Scope).Key)
a.setSetting(keyGridHunt, gridHunt(s.Hunt))
// The worked-grid index is built under these rules, so it is now stale.
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
return nil
}
// Grid states a heard square can be in, from the operator's point of view.
//
// "wanted" is not one state but two, and they call for different decisions: a
// square never worked is a QSO to make, one worked and not yet confirmed is a
// QSL to chase. Reporting both as plain NEW GRID — which is what this did when
// the unconfirmed hunt was added — leaves an operator staring at a badge on a
// station the log plainly says they worked an hour ago.
const (
gridStateNew = "new" // never worked under this scope
gridStateUnconf = "unconf" // worked under this scope, not yet confirmed
)
// gridStateFor classifies one heard square. "" means nothing to chase: already
// confirmed, or a mode this scope cannot judge.
//
// band is where it was heard and mode what it was heard in — the SCOPE decides
// whether either is allowed to matter.
func gridStateFor(idx map[string]bool, s gridScope, hunt, grid, band, mode string) string {
cls := gridClassFor(s, mode)
if cls == "" || len(grid) < 4 {
return ""
}
key := qso.GridKey(strings.ToUpper(grid[:4]), "", cls)
if s.Band {
key = qso.GridKey(strings.ToUpper(grid[:4]), band, cls)
}
confirmed, worked := idx[key]
switch {
case !worked:
return gridStateNew
case !confirmed && hunt == "new_unconfirmed":
return gridStateUnconf
default:
return ""
}
}
// gridIsWanted is gridStateFor reduced to the yes/no the filters need.
func gridIsWanted(idx map[string]bool, s gridScope, hunt, grid, band, mode string) bool {
return gridStateFor(idx, s, hunt, grid, band, mode) != ""
}