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.
This commit is contained in:
dborup
2026-07-28 14:13:04 +02:00
parent 52caa42f57
commit f479e75cc6
5 changed files with 129 additions and 9 deletions
+1
View File
@@ -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{}{
+34 -7
View File
@@ -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
}
+63
View File
@@ -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.
+7
View File
@@ -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')
: '') +
'</div>' +
'</div>';
+24 -2
View File
@@ -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 () => {