diff --git a/cmd/server/db.go b/cmd/server/db.go index debea55f..daa8542d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -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(®ion, &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 } diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 7c3dd04b..9afe7fe1 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -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 diff --git a/cmd/server/types.go b/cmd/server/types.go index 5f0743e2..7694be3d 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -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 { diff --git a/public/analytics.js b/public/analytics.js index d16e75d6..7455c1a4 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4491,6 +4491,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = '' + '' + '
' + + '
' + '
' + '
' + '
' + @@ -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 += '
' + hl + '
'; + } + 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 '
'; + }).join(''); + return '
' + + '
' + esc(ha.region) + '
' + + '
' + cells + '
' + + '
'; + }).join(''); + hourlyEl.innerHTML = + '

Activity by Hour of Day

' + + '

' + + '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.' + + '

' + + '
' + + '
' + + '
' + hourLabels + '
' + + '
' + + hourlyRows; + } else { + hourlyEl.innerHTML = + '

Activity by Hour of Day

' + + '

No scoped messages in this window to chart by hour of day.

'; + } + } + // 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