feat(cluster): a SOTA column, and a spot click that credits the summit
The SOTA feeds put the summit in the spot's own text, so there is nothing to poll: a regex on the comment gives the reference, and clicking the spot writes SOTA@<ref> into the award references the way a park already wrote POTA@<ref>. The regex is narrow on purpose — association/region-NNN, anchored — because POTA (US-4475), WWFF (DLFF-0001) and portable callsigns (DL/SP9DPM/P) share that field and none of them is a summit. Pinned by a table test. Off by default: the reference is only there on the summit feeds, and an empty column for every operator who does not chase them is worse than a checkbox.
This commit is contained in:
+24
-20
@@ -44,30 +44,30 @@ type ServerConfig struct {
|
||||
// is emitted to the UI, so the table never has empty country cells
|
||||
// flickering in for a few hundred ms.
|
||||
type Spot struct {
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
// SpotterContinent belongs to the SPOT, not to the DX station: one call is
|
||||
// spotted by dozens of skimmers on every continent within a minute. It is
|
||||
// resolved per spot at ingest for exactly that reason — see the note on the
|
||||
// spotter-continent filter in App.tsx.
|
||||
SpotterContinent string `json:"spotter_continent,omitempty"`
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
FreqKHz float64 `json:"freq_khz"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
SpotterContinent string `json:"spotter_continent,omitempty"`
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
FreqKHz float64 `json:"freq_khz"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||
@@ -75,6 +75,10 @@ type Spot struct {
|
||||
Historical bool `json:"historical,omitempty"`
|
||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||
POTAName string `json:"pota_name,omitempty"` // park name
|
||||
// SOTARef comes from the COMMENT, not from an API: the SOTA clusters put the
|
||||
// summit in the text of the spot they send ("W9/WI-001"), and there is no
|
||||
// per-callsign endpoint to ask the way POTA has one.
|
||||
SOTARef string `json:"sota_ref,omitempty"`
|
||||
}
|
||||
|
||||
// State enumerates the per-server lifecycle.
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestParseShowDX(t *testing.T) {
|
||||
// chatter turned into fake spots would be worse than no parser at all.
|
||||
func TestParseShowDXRejectsNoise(t *testing.T) {
|
||||
noise := []string{
|
||||
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||
"Hello and welcome to the DXSpider cluster",
|
||||
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
|
||||
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cluster
|
||||
|
||||
import "regexp"
|
||||
|
||||
// sotaRefRe matches a SOTA summit reference inside a spot comment.
|
||||
//
|
||||
// The shape is association/region-NNN — "W9/WI-001", "DM/BM-063", "VK3/VC-014",
|
||||
// "F/AM-123" — and the association may carry digits. Anchored on both sides so
|
||||
// a callsign like DL/SP9DPM/P can never be read as one, and deliberately
|
||||
// narrower than "anything with a slash and a dash": POTA (US-4475) and WWFF
|
||||
// (DLFF-0001) refs share the comment field and must not be caught here.
|
||||
var sotaRefRe = regexp.MustCompile(`\b([A-Z0-9]{1,4}(?:/[A-Z0-9]{1,4})?/[A-Z]{2}-[0-9]{3})\b`)
|
||||
|
||||
// SOTARefFrom returns the first SOTA reference in a spot comment, or "".
|
||||
func SOTARefFrom(comment string) string {
|
||||
m := sotaRefRe.FindStringSubmatch(comment)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cluster
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSOTARefFrom(t *testing.T) {
|
||||
// Left column: real comments seen on the SOTA cluster feed.
|
||||
cases := []struct{ in, want string }{
|
||||
{"W9/WI-001", "W9/WI-001"},
|
||||
{"DM/BM-063", "DM/BM-063"},
|
||||
{"W7Y/TT-122", "W7Y/TT-122"},
|
||||
{"VK3/VC-014 s2s", "VK3/VC-014"},
|
||||
{"[SOTA] F/AM-123 cq", "F/AM-123"},
|
||||
{"", ""},
|
||||
// The other reference schemes that share this field.
|
||||
{"POTA US-4475", ""},
|
||||
{"WWFF DLFF-0001", ""},
|
||||
// A portable callsign is not a summit.
|
||||
{"DL/SP9DPM/P calling", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := SOTARefFrom(c.in); got != c.want {
|
||||
t.Errorf("SOTARefFrom(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user