fix(watchlist): read the cluster's slot index, and cap the card width

The first version asked the alerts' worked-index — keyed by RAW mode (FT8) —
with a CLASS name (DIGI). Nothing ever matched, and a DXpedition worked on five
bands showed every slot as Needed.

The verdict now reads the cluster's own WorkedCallSlotKeys set, built in the
cached status maps (now unconditionally — it used to exist only when a display
option wanted it). Same index, same mode normalisation as the grid, so the
watchlist can never contradict the cluster about the same spot. The contest
today-set is normalised through the same function.

And the cards column is capped at a reading width instead of stretching
callsign-to-badge lines across the whole window.
This commit is contained in:
2026-08-29 00:33:52 +02:00
parent f6b70184a7
commit 29d79fb5d1
3 changed files with 38 additions and 16 deletions
+3 -2
View File
@@ -19803,9 +19803,10 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// "Already worked only on the same slot" option (Settings → DX Cluster): the // "Already worked only on the same slot" option (Settings → DX Cluster): the
// WORKED-call flag then needs the SAME band and mode (digital-grouped through // WORKED-call flag then needs the SAME band and mode (digital-grouped through
// the same normMode when that option is on) rather than the call anywhere. // the same normMode when that option is on) rather than the call anywhere.
if sameSlot || slotHighlight { // Built UNCONDITIONALLY since the watchlist arrived: its per-slot verdict
// needs this set whatever the cluster's own display options say, and the
// scan already runs at cache-rebuild cadence, not per batch.
c.workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, c.normMode) c.workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, c.normMode)
}
// Orthogonal dimensions: worked US counties (for the ULS callsign→county // Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. // lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey) c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
+31 -12
View File
@@ -11,7 +11,6 @@ import (
"strings" "strings"
"time" "time"
"hamlog/internal/qso"
"hamlog/internal/watchlist" "hamlog/internal/watchlist"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime" wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -68,18 +67,38 @@ type WatchlistSlotQuery struct {
// WatchlistWorkedSlots answers a batch of slot questions for the tab. // WatchlistWorkedSlots answers a batch of slot questions for the tab.
// //
// One pass over today's contacts and the in-memory worked index rather than a // Normal entries read the CLUSTER's own slot set — the same cached
// query per spot: the tab refreshes on every spot burst, and a busy evening // WorkedCallSlotKeys index that colours the grid — so the watchlist can never
// must not turn into a query storm. Contest entries read the TODAY set — the // contradict the cluster about the same spot. That index normalises the mode
// midnight-UTC reset is the query's date bound, nothing stored, nothing to // exactly as the operator configured (digital grouping on → FT4 counts as FT8's
// reset. Mode is compared at CLASS grain (FT8 and FT4 are both Digital), // class; off → exact mode), and the first version of this ignored that: it
// matching how the cluster's own worked_slot judges a slot. // asked the alerts' raw-mode index with a CLASS name, matched nothing, and
// showed a fully-worked DXpedition as all Needed.
//
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
// the query's date bound, nothing stored, nothing to reset — normalised through
// the same function so the two answers use one grammar.
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool { func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
out := make([]bool, len(queries)) out := make([]bool, len(queries))
if a.qso == nil || len(queries) == 0 { if a.qso == nil || len(queries) == 0 {
return out return out
} }
// Today's slots, only if some entry needs them. idx := a.clusterStatusMaps()
norm := func(m string) string {
m = strings.ToUpper(strings.TrimSpace(m))
if idx.normMode != nil {
m = idx.normMode(m)
}
return m
}
slotKey := func(call, band, mode string) string {
up := strings.ToUpper(strings.TrimSpace(call))
b := strings.ToLower(strings.TrimSpace(band))
if m := norm(mode); m != "" {
return up + "|" + b + "|" + m
}
return up + "|" + b
}
needToday := false needToday := false
for _, q := range queries { for _, q := range queries {
if q.Contest { if q.Contest {
@@ -93,16 +112,16 @@ func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
rows, err := a.qso.SlotsSince(a.ctx, midnight) rows, err := a.qso.SlotsSince(a.ctx, midnight)
if err == nil { if err == nil {
for _, r := range rows { for _, r := range rows {
today[wcbmKey(r.Callsign, r.Band, qso.ModeClass(r.Mode))] = true today[slotKey(r.Callsign, r.Band, r.Mode)] = true
} }
} }
} }
for i, q := range queries { for i, q := range queries {
key := wcbmKey(q.Call, q.Band, qso.ModeClass(q.Mode)) key := slotKey(q.Call, q.Band, q.Mode)
if q.Contest { if q.Contest {
out[i] = today[key] out[i] = today[key]
} else { } else if idx.workedCallSlots != nil {
out[i] = a.isWorkedBandMode(q.Call, q.Band, qso.ModeClass(q.Mode)) _, out[i] = idx.workedCallSlots[key]
} }
} }
return out return out
+3 -1
View File
@@ -167,7 +167,9 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
); );
return ( return (
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2"> // Capped and centred: a card is a reading surface, and callsign-to-badge
// lines stretched across a 34-inch window are not readable, they are long.
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-4xl mx-auto">
{/* toolbar */} {/* toolbar */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Eye className="size-4 text-primary shrink-0" /> <Eye className="size-4 text-primary shrink-0" />