From f479e75cc64bfdbf01ef143c6a01d30e4f8e87b4 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 14:13:04 +0200 Subject: [PATCH] feat: add Area Activity leaderboard to Ping Scores Ranks configured areas by number of distinct pings with a relay hop or hearing station inside them -- "which area is most active" -- reusing the same touched-areas config/matching View Path already draws from (annotatePacketPathTouchedAreas), rather than reimplementing area matching. Omitted entirely on deployments with no areas configured. --- cmd/server/openapi.go | 1 + cmd/server/ping_scores.go | 41 ++++++++++++++++++---- cmd/server/ping_scores_test.go | 63 ++++++++++++++++++++++++++++++++++ public/ping-scores.js | 7 ++++ test-ping-scores.js | 26 ++++++++++++-- 5 files changed, 129 insertions(+), 9 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index cbe61806..5f726b16 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -460,6 +460,7 @@ func componentSchemas() map[string]interface{} { "relayLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top nodes ranked by number of distinct pings they appeared as a relay hop in (deduped per ping first, so one busy ping's many branches can't over-credit a relay)."}, "observerLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top observers ranked by number of pings they were the first station to hear."}, "senderLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top senders ranked by number of pings sent in the last 30 days (unlike the other leaderboards and records, which are all-time). Keyed by the sender display name from the channel message itself -- no resolved pubkey, so entries never carry one."}, + "areaLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Configured areas ranked by number of distinct pings with at least one relay hop or hearing station inside them -- \"which area is most active,\" all-time. Same area-matching config as View Path's touchedAreas. Keyed by area label -- no resolved pubkey, so entries never carry one. Omitted entirely when no areas are configured."}, }, }, "AreaDensity": map[string]interface{}{ diff --git a/cmd/server/ping_scores.go b/cmd/server/ping_scores.go index 68eeee33..07751142 100644 --- a/cmd/server/ping_scores.go +++ b/cmd/server/ping_scores.go @@ -60,11 +60,13 @@ type PingScore struct { // AirtimeMs (with RelayCount>0) are available. KmPerSecondAirtime *float64 `json:"kmPerSecondAirtime,omitempty"` - // relayPubkeys/firstPubkey/firstName feed the leaderboards during - // computeAllPingScores -- never serialized on an individual record. - relayPubkeys []string - firstPubkey string - firstName string + // relayPubkeys/firstPubkey/firstName/touchedAreaLabels feed the + // leaderboards during computeAllPingScores -- never serialized on an + // individual record. + relayPubkeys []string + firstPubkey string + firstName string + touchedAreaLabels []string } // PingLeaderboardEntry is one row of a leaderboard ranking. @@ -110,6 +112,16 @@ type PingScoresSnapshot struct { // pubkey to link back to a node, so entries never carry one (matches // PingLeaderboardEntry.Pubkey's existing omitempty). SenderLeaderboard []PingLeaderboardEntry `json:"senderLeaderboard,omitempty"` + + // AreaLeaderboard ranks configured areas (cmd/server/config.go's + // AreaEntry, same set View Path's touchedAreas draws from) by how + // many DISTINCT pings had at least one relay hop or hearing station + // inside them -- "which area is most active." Keyed by area Label + // (matching SenderLeaderboard's no-pubkey precedent -- an area isn't a + // resolvable node either). Omitted entirely when no areas are + // configured (per-deployment optional feature, like the rest of the + // Areas tooling). + AreaLeaderboard []PingLeaderboardEntry `json:"areaLeaderboard,omitempty"` } // WeeklyPingRecords mirrors PingScoresSnapshot's 5 all-time record slots, @@ -157,14 +169,16 @@ func (db *DB) fetchPingTriggers() ([]pingTriggerRow, error) { } // computePingScore builds one ping's full stats via the same GetPacketPath -// + airtime-annotation path View Path uses, so the numbers on the -// highscore board always match what "View path" shows for that packet. +// + airtime/touched-areas annotation path View Path uses, so the numbers +// on the highscore board always match what "View path" shows for that +// packet. func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { resp, err := s.db.GetPacketPath(trigger.hash) if err != nil || resp == nil || len(resp.Branches) == 0 { return nil } s.annotatePacketPathAirtime(resp) + s.annotatePacketPathTouchedAreas(resp) score := &PingScore{ Hash: trigger.hash, @@ -225,6 +239,9 @@ func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { score.firstPubkey = resp.First.Observer.PublicKey score.firstName = resp.First.Observer.Name } + for _, area := range resp.TouchedAreas { + score.touchedAreaLabels = append(score.touchedAreaLabels, area.Label) + } return score } @@ -286,6 +303,7 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { // elsewhere in this file's package. senderCutoff := time.Now().AddDate(0, 0, -30) senderCounts := map[string]*PingLeaderboardEntry{} + areaCounts := map[string]*PingLeaderboardEntry{} // weekCutoff drives ThisWeek -- same fail-toward-stale rule as // senderCutoff above (an unparseable timestamp is excluded, not @@ -330,6 +348,14 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { } e.Count++ } + for _, label := range score.touchedAreaLabels { + e := areaCounts[label] + if e == nil { + e = &PingLeaderboardEntry{Name: label} + areaCounts[label] = e + } + e.Count++ + } } snap.FarthestPing = allTime.Farthest @@ -366,6 +392,7 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { snap.RelayLeaderboard = topPingLeaderboard(relayCounts, 10) snap.ObserverLeaderboard = topPingLeaderboard(observerCounts, 10) snap.SenderLeaderboard = topPingLeaderboard(senderCounts, 10) + snap.AreaLeaderboard = topPingLeaderboard(areaCounts, 10) return snap } diff --git a/cmd/server/ping_scores_test.go b/cmd/server/ping_scores_test.go index 23a38c0a..af718855 100644 --- a/cmd/server/ping_scores_test.go +++ b/cmd/server/ping_scores_test.go @@ -375,6 +375,69 @@ func TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings(t *testing.T) { } } +// TestComputeAllPingScores_AreaLeaderboard confirms AreaLeaderboard tallies +// DISTINCT pings per configured area (a ping with two hearing stations in +// the SAME area still counts once for it, mirroring RelayLeaderboard's +// per-ping dedup), correctly attributes a ping that touches two areas to +// BOTH, and reuses the exact same area-matching config View Path's +// touchedAreas draws from. +func TestComputeAllPingScores_AreaLeaderboard(t *testing.T) { + srv, _ := setupPingScoresFixture(t) + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "AREAA": {Label: "Area A", LatMin: f(55.9), LatMax: f(56.2), LonMin: f(9.9), LonMax: f(10.2)}, + "AREAB": {Label: "Area B", LatMin: f(57.7), LatMax: f(57.9), LonMin: f(12.5), LonMax: f(12.7)}, + } + + // Ping 1: pingobsa AND pingobsc both fall in Area A (must count once + // for Area A, not twice), pingobsb falls in Area B -- touches both. + tx1 := seedPingTrigger(t, srv, "arealb0000001", "#test", "Alice", "2026-01-15T10:00:00Z") + seedPingObservation(t, srv, tx1, "pingobsa", 9.0, `[]`, `[]`, 1736935200) + seedPingObservation(t, srv, tx1, "pingobsc", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) + seedPingObservation(t, srv, tx1, "pingobsb", 4.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935260) + + // Ping 2: only pingobsa (Area A) -- Area B must NOT get credit for this one. + tx2 := seedPingTrigger(t, srv, "arealb0000002", "#test", "Bob", "2026-01-15T11:00:00Z") + seedPingObservation(t, srv, tx2, "pingobsa", 9.0, `[]`, `[]`, 1736938800) + + snap := srv.computeAllPingScores() + if snap == nil { + t.Fatal("computeAllPingScores returned nil") + } + counts := map[string]int{} + for _, e := range snap.AreaLeaderboard { + counts[e.Name] = e.Count + } + if counts["Area A"] != 2 { + t.Errorf("Area A count = %d, want 2 (both pings touch it, dedup within ping 1's two same-area stations)", counts["Area A"]) + } + if counts["Area B"] != 1 { + t.Errorf("Area B count = %d, want 1 (only ping 1 touches it)", counts["Area B"]) + } +} + +// TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured confirms +// AreaLeaderboard stays nil (not present) rather than an empty-but-present +// array when the deployment has no areas configured -- matches +// annotatePacketPathTouchedAreas's own early-return for the same case. +func TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured(t *testing.T) { + srv, _ := setupPingScoresFixture(t) + srv.cfg.Areas = nil + + txID := seedPingTrigger(t, srv, "arealbnone0001", "#test", "Alice", "2026-01-15T10:00:00Z") + seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, 1736935200) + seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) + + snap := srv.computeAllPingScores() + if snap == nil { + t.Fatal("computeAllPingScores returned nil") + } + if snap.AreaLeaderboard != nil { + t.Errorf("AreaLeaderboard = %+v, want nil when no areas are configured", snap.AreaLeaderboard) + } +} + // TestHandlePingScores_EmptyState confirms the endpoint returns a // well-formed 200 with zero-valued/omitted fields rather than an error // when no ping has ever been recorded -- an ordinary state, not a failure. diff --git a/public/ping-scores.js b/public/ping-scores.js index 838d3586..1533a60e 100644 --- a/public/ping-scores.js +++ b/public/ping-scores.js @@ -143,6 +143,13 @@ leaderboardTableHtml('Top Senders (30 days)', phIcon('megaphone'), data.senderLeaderboard, 'Sender') + leaderboardTableHtml('Top Relays', phIcon('repeat'), data.relayLeaderboard) + leaderboardTableHtml('Top First-Hearers', phIcon('eye'), data.observerLeaderboard) + + // areaLeaderboard is omitted entirely (not an empty array) when the + // deployment has no areas configured (cmd/server/ping_scores.go) -- + // skip the whole card rather than show an always-empty "Area + // Activity" section on every deployment without areas set up. + (data.areaLeaderboard && data.areaLeaderboard.length + ? leaderboardTableHtml('Area Activity', phIcon('map-pin'), data.areaLeaderboard, 'Area') + : '') + '' + ''; diff --git a/test-ping-scores.js b/test-ping-scores.js index bfc2d826..17eb83bb 100644 --- a/test-ping-scores.js +++ b/test-ping-scores.js @@ -130,6 +130,7 @@ function makeSandbox(apiImpl) { relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], observerLeaderboard: [{ pubkey: 'pkobs1', name: 'ObsOne', count: 3 }], senderLeaderboard: [{ name: 'PingMaster', count: 12 }], + areaLeaderboard: [{ name: 'Area A', count: 9 }], }; const { container, getPage } = makeSandbox(() => Promise.resolve(data)); await getPage().init(container); @@ -144,9 +145,30 @@ function makeSandbox(apiImpl) { assert.ok(container.innerHTML.includes('All-Time Records'), 'should show the All-Time Records section heading, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('12.3'), 'should show the thisWeek farthest record km distinct from the all-time one, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('WeeklyRepeater'), 'should show the thisWeek record\'s node name, got: ' + container.innerHTML); + assert.ok(container.innerHTML.includes('Area Activity'), 'should show the Area Activity leaderboard heading, got: ' + container.innerHTML); + assert.ok(container.innerHTML.includes('Area A'), 'should show the area leaderboard entry, got: ' + container.innerHTML); passed++; - console.log(' ✅ renders all 5 records and all three leaderboards (including Top Senders) from a populated response'); - } catch (e) { failed++; console.log(' ❌ renders all 5 records and all three leaderboards (including Top Senders) from a populated response: ' + e.message); } + console.log(' ✅ renders all 5 records and all four leaderboards (including Top Senders and Area Activity) from a populated response'); + } catch (e) { failed++; console.log(' ❌ renders all 5 records and all four leaderboards (including Top Senders and Area Activity) from a populated response: ' + e.message); } + })(); + + await (async () => { + try { + // areaLeaderboard is omitted entirely (not an empty array) when the + // deployment has no areas configured -- the whole "Area Activity" + // card must be skipped, not shown empty/broken, on deployments that + // never set up areas. + const data = { + totalPings: 1, + generatedAt: new Date().toISOString(), + relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], + }; + const { container, getPage } = makeSandbox(() => Promise.resolve(data)); + await getPage().init(container); + assert.ok(!container.innerHTML.includes('Area Activity'), 'should NOT show an Area Activity card when areaLeaderboard is absent, got: ' + container.innerHTML); + passed++; + console.log(' ✅ Area Activity leaderboard card is skipped entirely when no areas are configured'); + } catch (e) { failed++; console.log(' ❌ Area Activity leaderboard card is skipped entirely when no areas are configured: ' + e.message); } })(); await (async () => {