diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index cf89ca16..cbe61806 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -426,6 +426,17 @@ func componentSchemas() map[string]interface{} { "kmPerSecondAirtime": map[string]interface{}{"type": "number", "nullable": true, "description": "farthestKm / (airtimeMs/1000) -- geographic distance covered per second of estimated RF airtime spent relaying this ping. Only set when both farthestKm and airtimeMs (with relayCount>0) are available."}, }, }, + "WeeklyPingRecords": map[string]interface{}{ + "type": "object", + "description": "Mirrors PingScoresResponse's 5 all-time record slots, scoped to the trailing 7 days -- an achievable target that resets on its own, instead of a slot that locks in forever once someone sets a big all-time record.", + "properties": map[string]interface{}{ + "farthestPing": schemaRef("PingScore"), + "mostHopsPing": schemaRef("PingScore"), + "widestSpreadPing": schemaRef("PingScore"), + "fastestSpreadPing": map[string]interface{}{"allOf": []interface{}{schemaRef("PingScore")}, "description": "Same >=2-station rule as the all-time fastestSpreadPing, applied within the 7-day window."}, + "mostEfficientPing": schemaRef("PingScore"), + }, + }, "PingLeaderboardEntry": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -445,6 +456,7 @@ func componentSchemas() map[string]interface{} { "widestSpreadPing": schemaRef("PingScore"), "fastestSpreadPing": map[string]interface{}{"allOf": []interface{}{schemaRef("PingScore")}, "description": "The fastest full spread among pings heard by at least 2 stations -- a lone station is trivially \"instant\" and is excluded so it can't win this record for nothing."}, "mostEfficientPing": schemaRef("PingScore"), + "thisWeek": map[string]interface{}{"allOf": []interface{}{schemaRef("WeeklyPingRecords")}, "description": "The same 5 records as above, scoped to the trailing 7 days instead of all-time. Omitted when no ping in the last 7 days resolved to a usable score."}, "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."}, diff --git a/cmd/server/ping_scores.go b/cmd/server/ping_scores.go index 831cc1bd..68eeee33 100644 --- a/cmd/server/ping_scores.go +++ b/cmd/server/ping_scores.go @@ -86,6 +86,13 @@ type PingScoresSnapshot struct { FastestSpreadPing *PingScore `json:"fastestSpreadPing,omitempty"` MostEfficientPing *PingScore `json:"mostEfficientPing,omitempty"` + // ThisWeek mirrors the 5 all-time records above but scoped to the + // trailing 7 days -- an all-time record set once (e.g. a 364km + // farthest ping) locks that slot forever, so this gives people an + // achievable target that resets on its own (dborup, 2026-07-28). + // nil when no ping in the last 7 days resolved to a usable score. + ThisWeek *WeeklyPingRecords `json:"thisWeek,omitempty"` + // RelayLeaderboard ranks nodes by how many 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 that appears in @@ -105,6 +112,16 @@ type PingScoresSnapshot struct { SenderLeaderboard []PingLeaderboardEntry `json:"senderLeaderboard,omitempty"` } +// WeeklyPingRecords mirrors PingScoresSnapshot's 5 all-time record slots, +// scoped to the trailing 7 days. +type WeeklyPingRecords struct { + FarthestPing *PingScore `json:"farthestPing,omitempty"` + MostHopsPing *PingScore `json:"mostHopsPing,omitempty"` + WidestSpreadPing *PingScore `json:"widestSpreadPing,omitempty"` + FastestSpreadPing *PingScore `json:"fastestSpreadPing,omitempty"` + MostEfficientPing *PingScore `json:"mostEfficientPing,omitempty"` +} + type pingTriggerRow struct { txID int64 hash string @@ -211,6 +228,40 @@ func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { return score } +// pingRecordSet accumulates the same 5 "best of" record slots used by +// both the all-time and this-week views, so the selection rules only +// need to be written once. +type pingRecordSet struct { + Farthest *PingScore + MostHops *PingScore + WidestSpread *PingScore + FastestSpread *PingScore + MostEfficient *PingScore +} + +func (rs *pingRecordSet) consider(score *PingScore) { + if score.FarthestKm != nil && (rs.Farthest == nil || rs.Farthest.FarthestKm == nil || *score.FarthestKm > *rs.Farthest.FarthestKm) { + rs.Farthest = score + } + if rs.MostHops == nil || score.DeepestHops > rs.MostHops.DeepestHops { + rs.MostHops = score + } + if rs.WidestSpread == nil || score.StationCount > rs.WidestSpread.StationCount { + rs.WidestSpread = score + } + // Fastest full spread only makes sense with a real multi-station + // spread to measure -- a lone station is trivially "instant" and + // would otherwise always win this record for nothing. + if score.SpreadSeconds != nil && score.StationCount >= 2 && + (rs.FastestSpread == nil || rs.FastestSpread.SpreadSeconds == nil || *score.SpreadSeconds < *rs.FastestSpread.SpreadSeconds) { + rs.FastestSpread = score + } + if score.KmPerSecondAirtime != nil && + (rs.MostEfficient == nil || rs.MostEfficient.KmPerSecondAirtime == nil || *score.KmPerSecondAirtime > *rs.MostEfficient.KmPerSecondAirtime) { + rs.MostEfficient = score + } +} + // computeAllPingScores computes the full snapshot: records + leaderboards. func (s *Server) computeAllPingScores() *PingScoresSnapshot { triggers, err := s.db.fetchPingTriggers() @@ -236,31 +287,23 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { senderCutoff := time.Now().AddDate(0, 0, -30) senderCounts := map[string]*PingLeaderboardEntry{} + // weekCutoff drives ThisWeek -- same fail-toward-stale rule as + // senderCutoff above (an unparseable timestamp is excluded, not + // defaulted to included). + weekCutoff := time.Now().AddDate(0, 0, -7) + allTime := &pingRecordSet{} + week := &pingRecordSet{} + for _, trigger := range triggers { score := s.computePingScore(trigger) if score == nil { continue } - if score.FarthestKm != nil && (snap.FarthestPing == nil || snap.FarthestPing.FarthestKm == nil || *score.FarthestKm > *snap.FarthestPing.FarthestKm) { - snap.FarthestPing = score - } - if snap.MostHopsPing == nil || score.DeepestHops > snap.MostHopsPing.DeepestHops { - snap.MostHopsPing = score - } - if snap.WidestSpreadPing == nil || score.StationCount > snap.WidestSpreadPing.StationCount { - snap.WidestSpreadPing = score - } - // Fastest full spread only makes sense with a real multi-station - // spread to measure -- a lone station is trivially "instant" and - // would otherwise always win this record for nothing. - if score.SpreadSeconds != nil && score.StationCount >= 2 && - (snap.FastestSpreadPing == nil || snap.FastestSpreadPing.SpreadSeconds == nil || *score.SpreadSeconds < *snap.FastestSpreadPing.SpreadSeconds) { - snap.FastestSpreadPing = score - } - if score.KmPerSecondAirtime != nil && - (snap.MostEfficientPing == nil || snap.MostEfficientPing.KmPerSecondAirtime == nil || *score.KmPerSecondAirtime > *snap.MostEfficientPing.KmPerSecondAirtime) { - snap.MostEfficientPing = score + allTime.consider(score) + ts, tsErr := time.Parse(time.RFC3339, score.Timestamp) + if tsErr == nil && ts.After(weekCutoff) { + week.consider(score) } for _, pk := range score.relayPubkeys { @@ -279,15 +322,28 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { } e.Count++ } - if score.Sender != "" { - if ts, err := time.Parse(time.RFC3339, score.Timestamp); err == nil && ts.After(senderCutoff) { - e := senderCounts[score.Sender] - if e == nil { - e = &PingLeaderboardEntry{Name: score.Sender} - senderCounts[score.Sender] = e - } - e.Count++ + if score.Sender != "" && tsErr == nil && ts.After(senderCutoff) { + e := senderCounts[score.Sender] + if e == nil { + e = &PingLeaderboardEntry{Name: score.Sender} + senderCounts[score.Sender] = e } + e.Count++ + } + } + + snap.FarthestPing = allTime.Farthest + snap.MostHopsPing = allTime.MostHops + snap.WidestSpreadPing = allTime.WidestSpread + snap.FastestSpreadPing = allTime.FastestSpread + snap.MostEfficientPing = allTime.MostEfficient + if week.Farthest != nil || week.MostHops != nil || week.WidestSpread != nil || week.FastestSpread != nil || week.MostEfficient != nil { + snap.ThisWeek = &WeeklyPingRecords{ + FarthestPing: week.Farthest, + MostHopsPing: week.MostHops, + WidestSpreadPing: week.WidestSpread, + FastestSpreadPing: week.FastestSpread, + MostEfficientPing: week.MostEfficient, } } diff --git a/cmd/server/ping_scores_test.go b/cmd/server/ping_scores_test.go index 26900a61..23a38c0a 100644 --- a/cmd/server/ping_scores_test.go +++ b/cmd/server/ping_scores_test.go @@ -318,6 +318,63 @@ func TestComputeAllPingScores_SenderLeaderboard30DayCutoff(t *testing.T) { } } +// TestComputeAllPingScores_ThisWeek confirms ThisWeek is an independently +// windowed record set: an older, BIGGER ping still wins the all-time slot, +// while a smaller but recent ping wins the equivalent ThisWeek slot since +// the older one falls outside the 7-day window. +func TestComputeAllPingScores_ThisWeek(t *testing.T) { + srv, _ := setupPingScoresFixture(t) + + // Old: 10 days ago, farther (pingobsb, ~230km from pingobsa) -- must + // win the all-time FarthestPing but be excluded from ThisWeek. + oldTs := time.Now().AddDate(0, 0, -10) + txOld := seedPingTrigger(t, srv, "weekold0000001", "#test", "Alice", oldTs.Format(time.RFC3339)) + seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix()) + seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10) + + // Recent: 2 days ago, closer (pingobsc, ~6km from pingobsa) -- the + // only ping inside the 7-day window, so it must win ThisWeek's + // FarthestPing even though it's smaller than the old one. + recentTs := time.Now().AddDate(0, 0, -2) + txRecent := seedPingTrigger(t, srv, "weeknew0000001", "#test", "Bob", recentTs.Format(time.RFC3339)) + seedPingObservation(t, srv, txRecent, "pingobsa", 9.0, `[]`, `[]`, recentTs.Unix()) + seedPingObservation(t, srv, txRecent, "pingobsc", 6.0, `["aa"]`, `["pkrelay2"]`, recentTs.Unix()+10) + + snap := srv.computeAllPingScores() + if snap == nil { + t.Fatal("computeAllPingScores returned nil") + } + if snap.FarthestPing == nil || snap.FarthestPing.Hash != "weekold0000001" { + t.Errorf("all-time FarthestPing = %+v, want weekold0000001 (the farther, older ping)", snap.FarthestPing) + } + if snap.ThisWeek == nil { + t.Fatal("ThisWeek is nil, want a populated record set from the recent ping") + } + if snap.ThisWeek.FarthestPing == nil || snap.ThisWeek.FarthestPing.Hash != "weeknew0000001" { + t.Errorf("ThisWeek.FarthestPing = %+v, want weeknew0000001 -- the old ping is outside the 7-day window", snap.ThisWeek.FarthestPing) + } +} + +// TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings confirms ThisWeek +// stays nil (not a zero-valued struct) when every ping is outside the +// 7-day window, matching the frontend's null-safe "no record yet" +// rendering rather than an empty-but-present object. +func TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings(t *testing.T) { + srv, _ := setupPingScoresFixture(t) + oldTs := time.Now().AddDate(0, 0, -30) + txOld := seedPingTrigger(t, srv, "weeknone000001", "#test", "Alice", oldTs.Format(time.RFC3339)) + seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix()) + seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10) + + snap := srv.computeAllPingScores() + if snap == nil { + t.Fatal("computeAllPingScores returned nil") + } + if snap.ThisWeek != nil { + t.Errorf("ThisWeek = %+v, want nil when no ping in the last 7 days resolved to a usable score", snap.ThisWeek) + } +} + // 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 ef777ada..838d3586 100644 --- a/public/ping-scores.js +++ b/public/ping-scores.js @@ -119,10 +119,25 @@ return recordCardHtml(def, data[def.key]); }).join(''); + // ThisWeek mirrors the same 5 slots, scoped to the trailing 7 days -- + // an all-time record set once (e.g. a 364km farthest ping) locks that + // card in forever, so this gives people an achievable target that + // resets on its own. May be entirely absent (no ping in 7 days), in + // which case every card falls through to recordCardHtml's "No record + // yet" placeholder via the `|| {}` fallback. + var week = data.thisWeek || {}; + var weekHtml = recordDefs.map(function (def) { + return recordCardHtml(def, week[def.key]); + }).join(''); + container.innerHTML = '
' + '

' + phIcon('trophy') + ' Ping Scores

' + '

Global records and leaderboards from every "ping" sent in any channel (' + data.totalPings + ' total). Not scoped by region. Updated ' + escapeHtml(formatAgo(data.generatedAt)) + '.

' + + '

' + phIcon('arrow-clockwise') + ' This Week\'s Best

' + + '

Resets on its own after 7 days.

' + + '
' + weekHtml + '
' + + '

All-Time Records

' + '
' + recordsHtml + '
' + '
' + leaderboardTableHtml('Top Senders (30 days)', phIcon('megaphone'), data.senderLeaderboard, 'Sender') + diff --git a/public/style.css b/public/style.css index 54315ca8..5732c67f 100644 --- a/public/style.css +++ b/public/style.css @@ -4517,9 +4517,12 @@ th.sort-active { color: var(--accent, #60a5fa); } /* ── Ping Scores page (cmd/server/ping_scores.go, GET /api/ping-scores) ── */ .ping-scores-page { padding: 16px; max-width: 1100px; margin: 0 auto; } +.ping-scores-page h3 { margin: 20px 0 4px 0; font-size: 16px; } +.ping-scores-page h3:first-of-type { margin-top: 12px; } .ps-records-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } .ps-record-card { position: relative; } .ps-record-card.ps-empty { opacity: 0.6; } +.ps-week-grid .ps-record-card { border-left: 3px solid var(--accent); padding-left: 13px; } .ps-record-meta { font-size: 12px; color: var(--text-muted); margin-top: 4px; } .ps-record-desc { font-size: 11px; color: var(--text-muted); margin-top: 6px; line-height: 1.4; } .ps-leaderboards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin-top: 8px; } diff --git a/test-ping-scores.js b/test-ping-scores.js index 096f29b9..bfc2d826 100644 --- a/test-ping-scores.js +++ b/test-ping-scores.js @@ -124,24 +124,58 @@ function makeSandbox(apiImpl) { widestSpreadPing: { hash: 'wide0001', sender: 'Carol', timestamp: new Date().toISOString(), stationCount: 6, deepestHops: 3 }, fastestSpreadPing: { hash: 'fast0001', sender: 'Dave', timestamp: new Date().toISOString(), spreadSeconds: 2.5, stationCount: 2, deepestHops: 1 }, mostEfficientPing: { hash: 'eff0001', sender: 'Eve', timestamp: new Date().toISOString(), kmPerSecondAirtime: 50.2, farthestKm: 100, stationCount: 2, deepestHops: 1 }, + thisWeek: { + farthestPing: { hash: 'wkfar0001', sender: 'Frank', timestamp: new Date().toISOString(), farthestKm: 12.3, farthestNodeName: 'WeeklyRepeater', stationCount: 2, deepestHops: 1 }, + }, relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], observerLeaderboard: [{ pubkey: 'pkobs1', name: 'ObsOne', count: 3 }], senderLeaderboard: [{ name: 'PingMaster', count: 12 }], }; const { container, getPage } = makeSandbox(() => Promise.resolve(data)); await getPage().init(container); - assert.ok(container.innerHTML.includes('123.4'), 'should show the farthest record km, got: ' + container.innerHTML); + assert.ok(container.innerHTML.includes('123.4'), 'should show the all-time farthest record km, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('4 hops'), 'should show the most-hops record, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('6 stations'), 'should show the widest-spread record, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('RelayOne'), 'should show the relay leaderboard entry, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('ObsOne'), 'should show the observer leaderboard entry, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('PingMaster'), 'should show the sender leaderboard entry, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('Top Senders (30 days)'), 'should show the Top Senders leaderboard heading with the 30-day window noted, got: ' + container.innerHTML); + assert.ok(container.innerHTML.includes("This Week's Best"), 'should show the This Week\'s Best section heading, got: ' + container.innerHTML); + 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); 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); } })(); + await (async () => { + try { + // thisWeek is entirely absent when no ping in the last 7 days + // resolved to a usable score (cmd/server/ping_scores.go leaves it + // nil rather than sending an empty object) -- the section must + // still render its 5 "No record yet" placeholders, not throw. All 5 + // all-time slots are populated here so the only "No record yet" + // cards left are the 5 thisWeek ones -- isolates what's under test. + const data = { + totalPings: 1, + generatedAt: new Date().toISOString(), + farthestPing: { hash: 'old0001', sender: 'Grace', timestamp: new Date().toISOString(), farthestKm: 300, stationCount: 2, deepestHops: 1 }, + mostHopsPing: { hash: 'old0002', sender: 'Grace', timestamp: new Date().toISOString(), deepestHops: 3, stationCount: 2 }, + widestSpreadPing: { hash: 'old0003', sender: 'Grace', timestamp: new Date().toISOString(), stationCount: 4, deepestHops: 1 }, + fastestSpreadPing: { hash: 'old0004', sender: 'Grace', timestamp: new Date().toISOString(), spreadSeconds: 3, stationCount: 2, deepestHops: 1 }, + mostEfficientPing: { hash: 'old0005', sender: 'Grace', timestamp: new Date().toISOString(), kmPerSecondAirtime: 10, farthestKm: 20, stationCount: 2, deepestHops: 1 }, + }; + const { container, getPage } = makeSandbox(() => Promise.resolve(data)); + await getPage().init(container); + assert.ok(container.innerHTML.includes("This Week's Best"), 'should still show the This Week\'s Best heading, got: ' + container.innerHTML); + const weekSectionCount = (container.innerHTML.match(/No record yet/g) || []).length; + assert.strictEqual(weekSectionCount, 5, 'expected all 5 thisWeek cards to fall back to "No record yet", got ' + weekSectionCount); + passed++; + console.log(' ✅ missing thisWeek renders 5 "No record yet" placeholders instead of throwing'); + } catch (e) { failed++; console.log(' ❌ missing thisWeek renders 5 "No record yet" placeholders instead of throwing: ' + e.message); } + })(); + await (async () => { try { // Sender entries never carry a pubkey (see cmd/server/ping_scores.go