feat: hour-of-day activity heatmap on the Scopes analytics tab

Adds hourlyActivityByRegion to /api/scope-stats: each region's message
counts bucketed by hour-of-day (0-23 UTC), aggregated across every day
in the window — answers "when during a typical day is this region
active" rather than "how did volume change over the window" (that's
the existing chronological TimeSeries chart).

Frontend renders a compact heatmap: one row per region, 24 hour
columns, color intensity normalized per-row (each region's own busiest
hour) so a quiet region's daily shape stays visible next to a loud
one instead of being crushed toward zero.
This commit is contained in:
dborup
2026-07-18 13:22:57 +02:00
parent 4f0610296d
commit 03abeba00e
4 changed files with 180 additions and 0 deletions
+46
View File
@@ -2958,6 +2958,52 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) {
resp.TimeSeries = []ScopeTimePoint{}
}
// Hour-of-day activity per region: same named-region set as ByRegion,
// but bucketed by hour-of-day (0-23, UTC) instead of chronological
// time — answers "when during a typical day is this region active"
// rather than "how did volume change over the window". Aggregated
// across every day in the window, so this reads best on 7d (a single
// 1h/24h window won't show a meaningful daily shape).
hourRows, err := db.conn.Query(`
SELECT scope_name, CAST(strftime('%H', first_seen) AS INTEGER) AS hour, COUNT(*) AS cnt
FROM transmissions
WHERE ` + routeTypeTransportSQL + ` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ?
GROUP BY scope_name, hour
`, since)
if err != nil {
return nil, fmt.Errorf("scope hourly activity query: %w", err)
}
defer hourRows.Close()
hourly := make(map[string]*ScopeHourlyActivity)
var hourlyOrder []string
for hourRows.Next() {
var region string
var hour, cnt int
if hourRows.Scan(&region, &hour, &cnt) != nil {
continue
}
if hour < 0 || hour > 23 {
continue
}
ha, ok := hourly[region]
if !ok {
ha = &ScopeHourlyActivity{Region: region}
hourly[region] = ha
hourlyOrder = append(hourlyOrder, region)
}
ha.Hours[hour] = cnt
}
if err := hourRows.Err(); err != nil {
return nil, fmt.Errorf("scope hourly activity iteration: %w", err)
}
resp.HourlyActivityByRegion = make([]ScopeHourlyActivity, 0, len(hourlyOrder))
for _, region := range hourlyOrder {
resp.HourlyActivityByRegion = append(resp.HourlyActivityByRegion, *hourly[region])
}
sort.Slice(resp.HourlyActivityByRegion, func(i, j int) bool {
return resp.HourlyActivityByRegion[i].Region < resp.HourlyActivityByRegion[j].Region
})
return resp, nil
}
+79
View File
@@ -4263,6 +4263,85 @@ func TestHandleScopeStats(t *testing.T) {
}
}
// TestHandleScopeStats_HourlyActivityByRegion verifies the hour-of-day
// bucketing: counts must land in the bucket matching first_seen's actual
// hour-of-day (UTC), grouped separately per region.
func TestHandleScopeStats_HourlyActivityByRegion(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 _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
t1 := time.Now().UTC().Add(-2 * time.Hour)
t2 := time.Now().UTC().Add(-1 * time.Hour)
hour1, hour2 := t1.Hour(), t2.Hour()
rows := []struct {
hash string
scope string
ts time.Time
}{
{"h1", "#belgium", t1},
{"h2", "#belgium", t1},
{"h3", "#belgium", t2},
{"h4", "#france", t1},
}
for _, r := range rows {
if _, err := srv.db.conn.Exec(
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,scope_name) VALUES (?,?,?,0,5,?)`,
"aa", r.hash, r.ts.Format(time.RFC3339), r.scope,
); err != nil {
t.Fatalf("seed row %s: %v", r.hash, 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 resp ScopeStatsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
var belgium, france *ScopeHourlyActivity
for i := range resp.HourlyActivityByRegion {
switch resp.HourlyActivityByRegion[i].Region {
case "#belgium":
belgium = &resp.HourlyActivityByRegion[i]
case "#france":
france = &resp.HourlyActivityByRegion[i]
}
}
if belgium == nil || france == nil {
t.Fatalf("hourlyActivityByRegion missing entries: %+v", resp.HourlyActivityByRegion)
}
if hour1 == hour2 {
// Edge case: test ran within a few minutes of an hour rollover, so
// t1 and t2 collapsed into the same hour-of-day bucket.
if belgium.Hours[hour1] != 3 {
t.Errorf("belgium.Hours[%d] = %d, want 3 (hour1==hour2 collapse case)", hour1, belgium.Hours[hour1])
}
} else {
if belgium.Hours[hour1] != 2 {
t.Errorf("belgium.Hours[%d] = %d, want 2", hour1, belgium.Hours[hour1])
}
if belgium.Hours[hour2] != 1 {
t.Errorf("belgium.Hours[%d] = %d, want 1", hour2, belgium.Hours[hour2])
}
}
if france.Hours[hour1] != 1 {
t.Errorf("france.Hours[%d] = %d, want 1", hour1, france.Hours[hour1])
}
}
// TestHandleScopeStats_ChannelMessagesExcludesOtherPayloadTypes verifies the
// payload_type=5 filter: a non-channel transmission (e.g. an ADVERT) with a
// scope must not be counted in ChannelMessages even though it affects the
+12
View File
@@ -126,6 +126,15 @@ type ScopeTimePoint struct {
Unscoped int `json:"unscoped"`
}
// ScopeHourlyActivity is a region's message counts bucketed by hour-of-day
// (0-23, UTC), aggregated across every day in the requested window —
// "when during a typical day is this region active" rather than "how did
// volume change over the window" (that's ScopeTimePoint/TimeSeries).
type ScopeHourlyActivity struct {
Region string `json:"region"`
Hours [24]int `json:"hours"`
}
type ScopeStatsResponse struct {
Window string `json:"window"`
Summary ScopeStatsSummary `json:"summary"`
@@ -159,6 +168,9 @@ type ScopeStatsResponse struct {
// literal backbone nodes connecting separate regional communities.
// All-time, like RepeatersByRegion (same source data, same caveats).
BridgeRepeaters []BridgeRepeater `json:"bridgeRepeaters,omitempty"`
// HourlyActivityByRegion is window-scoped like Summary/TimeSeries
// above — see ScopeHourlyActivity doc.
HourlyActivityByRegion []ScopeHourlyActivity `json:"hourlyActivityByRegion,omitempty"`
}
type RepeaterRef struct {
+43
View File
@@ -4491,6 +4491,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
'<tbody id="scopes-tbody"></tbody>' +
'</table>' +
'<div id="scopes-chart"></div>' +
'<div id="scopes-hourly" style="margin-top:16px"></div>' +
'<div id="scopes-utilization" style="margin-top:16px"></div>' +
'<div id="scopes-repeaters" style="margin-top:16px"></div>' +
'<div id="scopes-bridges" style="margin-top:16px"></div>' +
@@ -4654,6 +4655,48 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
chartEl.innerHTML = chartHtml;
}
// Hour-of-day activity per region: when during a typical day is each
// region active — a heatmap, not a chronological chart. Color
// intensity is normalized PER ROW (each region's own busiest hour),
// not globally, so a quiet region's shape is still visible next to
// a loud one instead of being crushed to near-zero.
var hourlyEl = document.getElementById('scopes-hourly');
if (hourlyEl) {
var hourly = d.hourlyActivityByRegion || [];
if (hourly.length > 0) {
var hourLabels = '';
for (var hl = 0; hl < 24; hl += 3) {
hourLabels += '<div style="flex:3;text-align:left;font-size:9px" title="' + hl + ':00 UTC">' + hl + '</div>';
}
var hourlyRows = hourly.map(function(ha) {
var maxV = Math.max.apply(null, ha.hours.concat([1]));
var cells = ha.hours.map(function(v, h) {
var alpha = v > 0 ? (0.12 + 0.88 * (v / maxV)) : 0;
var bg = v > 0 ? 'background:var(--accent);opacity:' + alpha.toFixed(2) : 'background:var(--border)';
return '<div style="flex:1;height:16px;' + bg + '" title="' + esc(ha.region) + ' ' + h + ':00 UTC — ' + v.toLocaleString() + ' msg' + (v === 1 ? '' : 's') + '"></div>';
}).join('');
return '<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">' +
'<div style="width:110px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(ha.region) + '"><code>' + esc(ha.region) + '</code></div>' +
'<div style="flex:1;display:flex;gap:1px">' + cells + '</div>' +
'</div>';
}).join('');
hourlyEl.innerHTML =
'<h4 style="margin:0 0 4px">Activity by Hour of Day</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">' +
'When during a typical day (UTC) each region is active, aggregated across every day in the window above. Reads best on 7d — color is normalized per region, so quiet and busy regions are both visible.' +
'</p>' +
'<div style="display:flex;gap:6px;margin-bottom:4px">' +
'<div style="width:110px"></div>' +
'<div style="flex:1;display:flex">' + hourLabels + '</div>' +
'</div>' +
hourlyRows;
} else {
hourlyEl.innerHTML =
'<h4 style="margin:0 0 4px">Activity by Hour of Day</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">No scoped messages in this window to chart by hour of day.</p>';
}
}
// Region utilization: how much of the configured hashRegions list
// has never actually matched anything — all-time (not window-scoped),
// so it doesn't fluctuate with the 1h/24h/7d selector above. Only