feat(cluster): Chase New — a panel for what is new and audible here

PSK Reporter tells you what is actually being decoded in your region, which
is a larger set than what somebody chose to spot: nobody spots the FT8 caller
running ten watts from a rare square.

Almost all of it existed. The MQTT payload already carries frequency, mode,
transmitter and both grids; the watcher already drops any report collected
further than NearKm from the operator, which is exactly the question worth
asking — the station is being heard HERE, not in Japan; and with grid chasing
on the subscription is already every band, filtered at the broker by receiver
square, measured at 0.2 to 1.2 messages a second. This reads messages that
were arriving and being discarded.

"New" is not decided here. Every spot goes through ClusterSpotStatuses, the
same function the DX cluster grid uses and the same cached index, so the two
panels cannot drift apart the way the county columns did. Cost per message is
map lookups behind an option cached in an atomic, because the MQTT goroutine
must never wait on the settings store.

The option is its own, not nested under grid chasing: chasing squares and
chasing entities are different wants, and the feed now has three consumers,
any one of which brings it up and none of which cuts the others loose when it
goes down.

The panel says "digital modes only" in its footer. An empty list has to mean
"nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never
show a new entity on CW.
This commit is contained in:
2026-08-14 15:20:18 +02:00
parent 7c781bf793
commit a9e3a159d9
13 changed files with 549 additions and 19 deletions
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"testing"
"time"
)
// The panel must not become one station repeated. PSK Reporter re-reports a
// calling operator every cycle, and dozens of receivers report the same
// transmission, so a station arrives many times a minute.
func TestChaseNewStoreDeduplicates(t *testing.T) {
s := newChaseNewStore()
t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
row := ChaseNewSpot{Call: "VK9XX", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}
if !s.put(row, t0) {
t.Fatal("the first sighting was rejected")
}
if s.put(row, t0.Add(2*time.Minute)) {
t.Error("the same station on the same band and mode was listed twice")
}
// A different band is a different opportunity — a new-band slot is exactly
// what an operator is watching for.
other := row
other.Band = "15m"
if !s.put(other, t0.Add(2*time.Minute)) {
t.Error("the same station on another band was suppressed")
}
// Once the window has passed it is worth showing again: the station is still
// there, and the row it had has aged out of the list.
if !s.put(row, t0.Add(chaseSeenTTL+time.Minute)) {
t.Error("the station never came back after the de-duplication window")
}
}
// The list is aged with the operator's own spot lifetime, so a station heard an
// hour ago is not offered as something to chase now.
func TestChaseNewStoreAgesOut(t *testing.T) {
s := newChaseNewStore()
t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
s.put(ChaseNewSpot{Call: "OLD", Band: "20m", Mode: "FT8", At: t0.Format(time.RFC3339)}, t0)
s.put(ChaseNewSpot{Call: "NEW", Band: "20m", Mode: "FT8",
At: t0.Add(20 * time.Minute).Format(time.RFC3339)}, t0.Add(20*time.Minute))
got := s.list(15*time.Minute, t0.Add(21*time.Minute))
if len(got) != 1 || got[0].Call != "NEW" {
t.Fatalf("got %+v, want only NEW", got)
}
// Newest first: an operator reads the top of this list and nothing else.
s.put(ChaseNewSpot{Call: "NEWEST", Band: "20m", Mode: "FT8",
At: t0.Add(25 * time.Minute).Format(time.RFC3339)}, t0.Add(25*time.Minute))
got = s.list(15*time.Minute, t0.Add(26*time.Minute))
if len(got) != 2 || got[0].Call != "NEWEST" {
t.Fatalf("got %+v, want NEWEST first", got)
}
}
// The list is bounded. A widget that grows without limit under a 6 m opening
// costs memory for rows nobody will ever scroll to.
func TestChaseNewStoreIsBounded(t *testing.T) {
s := newChaseNewStore()
t0 := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
for i := 0; i < chaseNewMax+50; i++ {
at := t0.Add(time.Duration(i) * time.Second)
s.put(ChaseNewSpot{
Call: "S" + time.Duration(i).String(), Band: "20m", Mode: "FT8",
At: at.Format(time.RFC3339),
}, at)
}
if got := len(s.list(0, t0.Add(time.Hour))); got != chaseNewMax {
t.Errorf("kept %d rows, want the %d cap", got, chaseNewMax)
}
}