feat(grids): the square map filters by mode, band and satellite

The FTx button is gone. It was the wrong grain in both directions: the
Digital class already sat a contest RTTY square next to an FT8 one, and
FTx then sat FT8 next to FT4, when what this map answers is where ONE
mode has been heard. The four classes stay as buttons and a dropdown
beside them offers a single mode.

Its contents come from the log, not from a list in the code. That is
what settles FT2: it is not a registered ADIF mode yet, so a hardcoded
list meant either leaving out the operators already using it or shipping
a mode that does not officially exist. A query does neither, and needs
no change here the day it is registered. The mode offered is the SUBMODE
where there is one, because ADIF files PSK63 under PSK and "PSK" is not
the name anybody is looking for.

The band list is the station's own, unioned with anything worked outside
it so nothing in the log is unreachable, ordered by frequency through
the band plan's index — sorting the names puts 10m between 1.25m and
12m. The satellite list is drawn from SAT_NAME on the squares
themselves and the control is absent altogether on a terrestrial log: a
VHF square worked through AO-91 and one worked line-of-sight are not the
same achievement, and until now nothing separated them.

The mode dropdown shares the scope with the class buttons rather than
narrowing on top of them — mode is one question, and two controls both
answering it is how a map ends up showing PHONE ∩ FT8 and nothing else.
A stored FTX preference is read as Digital, so it cannot leave the map
filtered by something no control shows as selected.
This commit is contained in:
2026-09-10 11:07:25 +02:00
parent 2615365684
commit 052fc4cb80
9 changed files with 289 additions and 39 deletions
+60 -5
View File
@@ -1860,9 +1860,19 @@ type GridSquare struct {
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
// in SQL would differ between SQLite and MySQL for no gain.
func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) {
// GridSquareRow is one contact as the grid map decides whether to keep it.
//
// Submode is here beside Mode because the mode an operator filters by is not
// always the one in MODE: ADIF puts PSK63 in SUBMODE with PSK above it, so a
// filter that read MODE alone offered "PSK" for a log full of PSK63.
type GridSquareRow struct {
Mode, Submode, Band, SatName string
}
func (r *Repo) GridSquares(ctx context.Context, keep func(GridSquareRow) bool) ([]GridSquare, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,'')),
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
FROM qso
WHERE grid IS NOT NULL AND grid != ''
@@ -1873,11 +1883,11 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
defer rows.Close()
out := map[string]*GridSquare{}
for rows.Next() {
var grid, mode, band, lotw, card, eqsl string
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
var grid, mode, submode, band, satName, lotw, card, eqsl string
if err := rows.Scan(&grid, &mode, &submode, &band, &satName, &lotw, &card, &eqsl); err != nil {
return nil, err
}
if keep != nil && !keep(mode) {
if keep != nil && !keep(GridSquareRow{Mode: mode, Submode: submode, Band: band, SatName: satName}) {
continue
}
g := strings.ToUpper(strings.TrimSpace(grid))
@@ -1914,6 +1924,51 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
return list, nil
}
// GridSquareChoices is every mode, band and satellite that the squares on the
// map were actually worked on.
//
// Taken from the log rather than from a list in the code, so a filter can only
// ever offer something there is something to see behind — and so a mode that
// does not exist yet needs no change here the day an operator starts using it.
// The mode is the SUBMODE when there is one: PSK63 is the answer, not PSK.
func (r *Repo) GridSquareChoices(ctx context.Context) (modes, bands, sats []string, err error) {
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,''))
FROM qso
WHERE grid IS NOT NULL AND grid != ''`)
if err != nil {
return nil, nil, nil, fmt.Errorf("query grid choices: %w", err)
}
defer rows.Close()
seenM, seenB, seenS := map[string]bool{}, map[string]bool{}, map[string]bool{}
for rows.Next() {
var mode, submode, band, sat string
if err := rows.Scan(&mode, &submode, &band, &sat); err != nil {
return nil, nil, nil, err
}
if m := strings.TrimSpace(submode); m != "" {
mode = m
}
if mode = strings.TrimSpace(mode); mode != "" && !seenM[mode] {
seenM[mode] = true
modes = append(modes, mode)
}
if band = strings.TrimSpace(band); band != "" && !seenB[band] {
seenB[band] = true
bands = append(bands, band)
}
if sat = strings.TrimSpace(sat); sat != "" && !seenS[sat] {
seenS[sat] = true
sats = append(sats, sat)
}
}
if err := rows.Err(); err != nil {
return nil, nil, nil, err
}
return modes, bands, sats, nil
}
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived