chore: release v0.26.0
This commit is contained in:
+187
@@ -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) != ""
|
||||
}
|
||||
Reference in New Issue
Block a user