Phase 7: telemetry and usage (daily rollup + week dashboard)
Build on Phases 1–6. Telemetry already arrived on
restrict/<device>/telemetry; this phase stores it in a daily rollup and
shows it.
- store: migration 003 adds telemetry_daily (device, day, app seconds,
site seconds). InsertTelemetry maintains the affected days in the same
transaction; RebuildRollups backfills and repairs it. UsageRange and
TopUsage serve the week grid and top lists. CapAudit enforces the
size-capped append-only audit log from AGENTS.md §7.
- web: GET /devices/{id}/usage — Monday-to-Sunday totals plus the top 10
apps and top 10 sites, using CSP-safe native <progress> bars.
- theme.js: light/dark from the OS preference, with a nav toggle, loaded
as an external script so the strict CSP still holds.
- cmd/control: `rollup` subcommand; rollup rebuild and audit cap at
start-up and every 15 minutes.
- Version 0.5.0-phase7 (compose, Makefile, docs).
Tests: go test ./... and go vet ./... pass in golang:1.25-alpine.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
94
internal/web/usage_test.go
Normal file
94
internal/web/usage_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
func TestUsageScreenShowsAWeek(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
dev := createTestDevice(t, app)
|
||||
ctx := t.Context()
|
||||
|
||||
// Seed the whole current week: apps and domains for every day.
|
||||
start := weekStart(time.Now())
|
||||
for i := 0; i < 7; i++ {
|
||||
day := start.AddDate(0, 0, i).Format("2006-01-02")
|
||||
if _, err := app.store.InsertTelemetry(ctx, dev.ID, day, []store.TelemetryEntry{
|
||||
{App: "com.roblox.client", Seconds: (i + 1) * 600},
|
||||
{App: "com.spotify.music", Seconds: 300},
|
||||
{Domain: "roblox.com", Seconds: (i + 1) * 600},
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertTelemetry %s: %v", day, err)
|
||||
}
|
||||
}
|
||||
|
||||
rec := app.do(t, http.MethodGet, fmt.Sprintf("/devices/%d/usage", dev.ID), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET usage = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Usage", "Top apps", "Top sites", "App time", "Site time",
|
||||
"com.roblox.client", "com.spotify.music", "roblox.com",
|
||||
fmt.Sprintf("%d", dev.ID), "Harry's Pixel",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("usage page missing %q", want)
|
||||
}
|
||||
}
|
||||
// Seven day rows: the weekday abbreviations must all be present.
|
||||
for _, wd := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
|
||||
if !strings.Contains(body, ">"+wd+" <") {
|
||||
t.Errorf("usage page missing the %s row", wd)
|
||||
}
|
||||
}
|
||||
|
||||
// A week with no data renders the empty state, never an error.
|
||||
rec = app.do(t, http.MethodGet,
|
||||
fmt.Sprintf("/devices/%d/usage?week=2020-01-01", dev.ID), nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "No usage for this week") {
|
||||
t.Errorf("empty week = %d, want the empty state", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekHelpers(t *testing.T) {
|
||||
loc := time.UTC
|
||||
// A Sunday belongs to the week that started the previous Monday.
|
||||
sunday := time.Date(2026, 10, 18, 12, 0, 0, 0, loc) // Sunday
|
||||
got := weekStart(sunday)
|
||||
want := time.Date(2026, 10, 12, 0, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("weekStart(Sun) = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
// A bad ?week= value falls back to now instead of failing.
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
||||
if got := parseWeekAnchor("not-a-date", now); !got.Equal(now) {
|
||||
t.Errorf("parseWeekAnchor(bad) = %s, want now", got)
|
||||
}
|
||||
if got := parseWeekAnchor("2026-10-12", now); got.Day() != 12 || got.Month() != time.October {
|
||||
t.Errorf("parseWeekAnchor(valid) = %s", got)
|
||||
}
|
||||
|
||||
if got := formatSeconds(0); got != "0m" {
|
||||
t.Errorf("formatSeconds(0) = %q", got)
|
||||
}
|
||||
if got := formatSeconds(3600); got != "1h 00m" {
|
||||
t.Errorf("formatSeconds(3600) = %q", got)
|
||||
}
|
||||
if got := formatSeconds(30); got != "under a minute" {
|
||||
t.Errorf("formatSeconds(30) = %q", got)
|
||||
}
|
||||
if got := percent(50, 100); got != 50 {
|
||||
t.Errorf("percent(50,100) = %d", got)
|
||||
}
|
||||
if got := percent(50, 0); got != 0 {
|
||||
t.Errorf("percent with max 0 = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user