diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go
index c5dba4e0..b0d6bf1f 100644
--- a/cmd/server/routes_test.go
+++ b/cmd/server/routes_test.go
@@ -4835,6 +4835,65 @@ func TestHandleScopeStats_ScopeAdoptionByArea(t *testing.T) {
}
}
+// TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent is a
+// regression test: NodesMatchingArea must NOT have `omitempty`, or a
+// genuine 0 count (a real, meaningful value — "this area has a linked
+// region but nobody uses it") silently disappears from the raw JSON. The
+// frontend reads a.nodesMatchingArea directly and calls .toLocaleString()
+// on it — an absent key deserializes to `undefined` in JS, not 0, which
+// throws and breaks the whole Scopes tab render. Decodes into a raw map
+// (not the typed struct, which would hide this by defaulting to the zero
+// value regardless of whether the key was present).
+func TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent(t *testing.T) {
+ srv, _ := setupTestServer(t)
+ if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
+ t.Fatalf("add scope_name column: %v", err)
+ }
+ srv.db.hasScopeName = true
+ if !srv.db.hasDefaultScope {
+ if _, err := srv.db.conn.Exec(`ALTER TABLE nodes ADD COLUMN default_scope TEXT DEFAULT NULL`); err != nil {
+ t.Fatalf("add default_scope column: %v", err)
+ }
+ srv.db.hasDefaultScope = true
+ }
+ f := func(v float64) *float64 { return &v }
+ srv.cfg.Areas = map[string]AreaEntry{
+ "AAR": {Label: "Aarhus by", RegionScope: "dk-aarhus", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)},
+ }
+ // A node with a scope, but NOT the area's own region — NodesMatchingArea
+ // must come out as a real, present 0, same as the live #dk-vs-#dk-aarhus
+ // case this feature was built to surface.
+ if _, err := srv.db.conn.Exec(
+ `INSERT INTO nodes (public_key, name, role, default_scope, lat, lon) VALUES ('aar00000001', 'AarhusNode', 'repeater', '#dk', 56.15, 10.15)`,
+ ); err != nil {
+ t.Fatalf("seed node: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil)
+ w := httptest.NewRecorder()
+ srv.handleScopeStats(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
+ }
+
+ var raw map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ areas, ok := raw["scopeAdoptionByArea"].([]interface{})
+ if !ok || len(areas) != 1 {
+ t.Fatalf("scopeAdoptionByArea = %v, want 1 entry", raw["scopeAdoptionByArea"])
+ }
+ area := areas[0].(map[string]interface{})
+ val, present := area["nodesMatchingArea"]
+ if !present {
+ t.Fatal("nodesMatchingArea key is missing from the JSON entirely — omitempty regression, this crashes the frontend")
+ }
+ if val != float64(0) {
+ t.Errorf("nodesMatchingArea = %v, want 0", val)
+ }
+}
+
func TestHandleScopeStatsInvalidWindow(t *testing.T) {
srv, _ := setupTestServer(t)
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
diff --git a/cmd/server/types.go b/cmd/server/types.go
index 25e1a3ae..89c08988 100644
--- a/cmd/server/types.go
+++ b/cmd/server/types.go
@@ -216,8 +216,10 @@ type AreaScopeAdoption struct {
// NodesMatchingArea is the subset of NodesWithAnyScope whose scope
// matches this area's own RegionScope specifically. Only meaningful
// when RegionScope is set — 0 otherwise (not the same as "0 of them
- // match", there's simply nothing configured to match against).
- NodesMatchingArea int `json:"nodesMatchingArea,omitempty"`
+ // match", there's simply nothing configured to match against). No
+ // omitempty: a real 0 count must still serialize, or the frontend has
+ // no way to distinguish it from "field absent".
+ NodesMatchingArea int `json:"nodesMatchingArea"`
}
type RepeaterRef struct {
diff --git a/public/analytics.js b/public/analytics.js
index 23039e03..e5930133 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -5079,8 +5079,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
if (byArea.length > 0) {
var areaRows = byArea.map(function(a) {
var withScope = a.nodesWithAnyScope.toLocaleString() + ' (' + pct(a.nodesWithAnyScope, a.totalNodes) + ')';
+ var matchCount = a.nodesMatchingArea || 0;
var matching = a.regionScope
- ? a.nodesMatchingArea.toLocaleString() + ' (' + pct(a.nodesMatchingArea, a.totalNodes) + ')' +
+ ? matchCount.toLocaleString() + ' (' + pct(matchCount, a.totalNodes) + ')' +
' of #' + esc(a.regionScope) + ''
: 'no region linked to this area';
return '