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:
2026-10-13 16:18:56 +11:00
parent 9af543c394
commit b58993d994
20 changed files with 1077 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
IMAGE ?= webrestrict/control:0.4.0-phase6
BUILD_VERSION ?= 0.4.0-phase6
IMAGE ?= webrestrict/control:0.5.0-phase7
BUILD_VERSION ?= 0.5.0-phase7
# Bounded docker helper for the Go toolchain. The container runs as the current
# user, so the repository and the module cache stay owned by you.

View File

@@ -5,8 +5,8 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists,
per-device state and usage, and serves the web dashboard. Only it publishes
device config.
This repository currently contains **Phase 6 — Schedules, blocklists,
allowlists** (AGENTS.md §11), on top of Phases 1–3:
This repository currently contains **Phase 7 — Telemetry and usage**
(AGENTS.md §11), on top of Phases 1–6:
- Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned
@@ -34,10 +34,14 @@ allowlists** (AGENTS.md §11), on top of Phases 1–3:
process) and `site` (hostname) entries. While a device is blocked, the device
blocks every blocklist value **except** the allowlist values (Spotify at
night). That allow decision is in the published config.
- **Usage**: telemetry reports are stored per device, day, app and domain and
rolled up into a daily summary. The dashboard shows a week at a time — the
daily total plus the top apps and top sites — with dark mode and phone-sized
touch targets.
- Every edit is written to the append-only audit log (FR-15).
Enforcement itself (the kids' clients), the admin API, usage screens and the Hub
arrive in later phases. Nothing here touches the router, Pi-hole, or DNS.
Enforcement itself (the kids' clients), the admin API and the Hub arrive in
later phases. Nothing here touches the router, Pi-hole, or DNS.
## Quick start (on `.13`)
@@ -53,7 +57,7 @@ docker compose up -d --build
# 3. Prove it.
curl -s http://127.0.0.1:8791/health
# {"status":"ok","service":"control-service","version":"0.4.0-phase6",...}
# {"status":"ok","service":"control-service","version":"0.5.0-phase7",...}
```
Open <http://127.0.0.1:8791/> in a browser. It redirects to `/login`. Sign in
@@ -90,7 +94,7 @@ reads only the process environment.
Generate a bcrypt hash without host Go:
```bash
docker run --rm --entrypoint /app/control webrestrict/control:0.4.0-phase6 \
docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 \
hash-password 'your-password'
```
@@ -172,7 +176,7 @@ List the MQTT identity of every device (tokens, no secrets):
docker exec webrestrict-control /app/control mqtt-clients
```
## Endpoints (Phase 2–3)
## Endpoints (Phase 2–7)
| Method | Path | Notes |
|---|---|---|
@@ -193,11 +197,46 @@ docker exec webrestrict-control /app/control mqtt-clients
| `GET` | `/devices/{id}/lists` | Blocklist / allowlist editor. |
| `POST` | `/devices/{id}/lists` | Adds one entry (`table=block|allow`, `kind=app|site`, `value`). |
| `POST` | `/devices/{id}/lists/{entryID}/delete` | Removes one entry. |
| `GET` | `/devices/{id}/usage` | Usage for one device: a week of daily totals, top apps and top sites. `?week=YYYY-MM-DD` selects the week (any day in it; default today). |
Every form POST needs the `csrf` field from the session cookie. State changes
append to the `audit` table and republish the device's retained config. Devices
are never hard-deleted (§10 rule 10).
## Telemetry and usage (Phase 7)
Usage arrives on `restrict/<device>/telemetry` and is stored in `telemetry`:
one row per (device, day, app, domain), where a repeat report for the same key
replaces the earlier total (a rolling daily total, not a delta).
`telemetry_daily` is the **daily rollup** — one row per (device, day) — written
together with the detail row and rebuildable from it with `control rollup`.
Apps and domains are summed into separate columns on purpose: a report may
carry both for the same session, so a single total would double-count the time.
The rollup is derived data, never a second source of truth.
`GET /devices/{id}/usage` shows one Monday-to-Sunday week: the app total and the
site total per day, then the top 10 apps and top 10 sites for the week. The
screen uses the Family Home Lab tokens, follows the OS light/dark preference
(there is a toggle in the nav bar), and works on a phone browser.
Retention:
- The daily rollup is kept long; the detail table is small and kept too.
Nothing is hard-deleted (AGENTS.md §10 rule 10).
- The append-only `audit` log is **size-capped** at 20 000 rows by the service's
maintenance loop, oldest first (§7). It is never edited in place.
Rebuild the rollup from the detail table at any time (idempotent, safe while the
service is up; the service also does it at start-up and every 15 minutes):
```bash
# inside the container
docker exec webrestrict-control /app/control rollup
# or against a database file on the host
/app/control rollup --db /data/control.db
```
## Schedules, blocklists and allowlists (Phase 6)
A **schedule** is a set of weekly blocking windows per device: a weekday
@@ -239,14 +278,14 @@ broker on `127.0.0.1:18883` and removes it afterwards.
## Layout
```
cmd/control/ service entry point (+ hash-password, mqtt-clients)
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup)
internal/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock expiry
schedules.go weekly windows and the effective state
reports.go presence and telemetry written from broker reports
reports.go presence, telemetry, the daily rollup, top lists, audit cap
lists.go allow/block lists and device lookup by topic
migrations/001_init.sql, 002_telemetry_unique.sql
migrations/001_init.sql, 002_telemetry_unique.sql, 003_usage_rollup.sql
internal/auth/ signed-cookie session login + CSRF
internal/mqtt/ the broker link: retained config out, reports in
payload.go topic and payload contract for the kids' clients
@@ -254,8 +293,9 @@ internal/mqtt/ the broker link: retained config out, reports in
internal/web/ HTTP handlers, templates, static assets
devices.go device registry + manual lock/unlock handlers
schedules.go weekly schedule and block/allow editors
templates/ login, dashboard, device form, editors, HTMX partials
static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts
usage.go usage screen: week grid, top apps, top sites
templates/ login, dashboard, device form, editors, usage, partials
static/ tokens.css (Family Home Lab), app.css, theme.js, htmx, fonts
Dockerfile own image (multi-stage, CGO-free)
docker-compose.yml own container, port 8791
environment.d/ control-service.env.example (real file gitignored)
@@ -269,12 +309,14 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
`webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35`
(sam-Ubuntu1); see that file for the two proxy patterns and the apply commands.
## Non-goals (Phase 6)
## Non-goals (Phase 7)
- No group/global list editing from the UI. The data model supports a NULL
`device_id` that applies to every device, and the effective state merges it,
but the editor manages per-device rows only.
- No usage screens (Phase 7). Telemetry is stored, not yet shown.
- No HTTP telemetry ingest. Devices report over MQTT; an HTTP path arrives if a
client needs it.
- No usage charts or export beyond the week grid and the top lists shown.
- No admin API (Phase 8).
- No kids' clients (Phases 4–5). The topic and payload contract above is what
they implement. The clients apply the allowlist exception; the Control Service

View File

@@ -41,6 +41,14 @@ const republishInterval = 15 * time.Minute
// the loop last looked.
const republishPoll = 30 * time.Second
// Maintenance (Phase 7): the daily usage rollup is rebuilt periodically so it
// is repaired from the detail table, and the append-only audit log is capped so
// it cannot grow without bound (AGENTS.md §7 retention).
const (
maintenanceInterval = 15 * time.Minute
auditMaxRows = 20000
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
@@ -50,6 +58,9 @@ func main() {
case "mqtt-clients":
mqttClients(os.Args[2:])
return
case "rollup":
rollup(os.Args[2:])
return
case "version":
fmt.Println(version)
return
@@ -83,6 +94,19 @@ func run() error {
return err
}
// Backfill the daily rollup from any telemetry written before Phase 7, then
// cap the audit log. Both are idempotent.
if n, err := st.RebuildRollups(context.Background()); err != nil {
return err
} else if n > 0 {
log.Info("usage rollups rebuilt", "rows", n)
}
if n, err := st.CapAudit(context.Background(), auditMaxRows); err != nil {
log.Error("audit cap", "err", err)
} else if n > 0 {
log.Info("audit log capped", "removed", n, "max", auditMaxRows)
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
if err != nil {
@@ -95,6 +119,8 @@ func run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go maintenanceLoop(ctx, log, st)
// The broker link (Phase 3). It is optional on purpose: the dashboard must
// keep working while the broker is down, and the device keeps its last state
// (AGENTS.md §10 rule 2).
@@ -234,6 +260,66 @@ func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *s
}
}
// maintenanceLoop keeps derived data healthy without blocking the service: it
// rebuilds the daily rollups and caps the audit log on a fixed interval.
func maintenanceLoop(ctx context.Context, log *slog.Logger, st *store.Store) {
ticker := time.NewTicker(maintenanceInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runMaintenance(ctx, log, st)
}
}
}
func runMaintenance(ctx context.Context, log *slog.Logger, st *store.Store) {
mctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if n, err := st.RebuildRollups(mctx); err != nil {
log.Error("usage rollups", "err", err)
} else if n > 0 {
log.Debug("usage rollups rebuilt", "rows", n)
}
if n, err := st.CapAudit(mctx, auditMaxRows); err != nil {
log.Error("audit cap", "err", err)
} else if n > 0 {
log.Info("audit log capped", "removed", n, "max", auditMaxRows)
}
}
// rollup rebuilds the daily usage rollup from the telemetry detail table. It is
// idempotent and safe to run while the service is up; it exits non-zero on a
// failure so a script can tell.
func rollup(args []string) {
dbPath := env("CONTROL_DB_PATH", "/data/control.db")
for i := 0; i < len(args); i++ {
if (args[i] == "--db" || args[i] == "-db") && i+1 < len(args) {
dbPath = args[i+1]
i++
}
}
st, err := store.Open(dbPath)
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
n, err := st.RebuildRollups(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
fmt.Printf("rebuilt %d daily rollup rows from %s\n", n, dbPath)
}
// mqttClients prints the MQTT identity of every registered device, one device
// per line, as tab-separated fields:
//

View File

@@ -12,8 +12,8 @@ services:
build:
context: .
args:
VERSION: "0.4.0-phase6"
image: webrestrict/control:0.4.0-phase6
VERSION: "0.5.0-phase7"
image: webrestrict/control:0.5.0-phase7
container_name: webrestrict-control
restart: unless-stopped
env_file:

View File

@@ -38,7 +38,7 @@ CONTROL_ADMIN_USERNAME=sam
CONTROL_ADMIN_PASSWORD=change-me
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty.
# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.3.0-phase3 hash-password 'your-password'
# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 hash-password 'your-password'
# CONTROL_ADMIN_PASSWORD_HASH=$2a$10$...
# --- MQTT (Phase 3) --------------------------------------------------------

View File

@@ -0,0 +1,24 @@
-- Migration 003 — Phase 7. Daily usage rollup (AGENTS.md §7 retention).
--
-- telemetry holds the per-day detail: one row per (device, day, app, domain).
-- This table is the daily rollup the dashboard reads for a week and that is
-- kept long. Apps and domains are summed into separate columns on purpose: a
-- report can carry both for the same session, so adding them together would
-- double-count the time.
--
-- The rollup is written whenever a telemetry report lands and can be rebuilt
-- from telemetry at any time (store.RebuildRollups), so it is derived data,
-- never a second source of truth.
CREATE TABLE IF NOT EXISTS telemetry_daily (
device_id INTEGER NOT NULL REFERENCES devices(id),
day TEXT NOT NULL, -- YYYY-MM-DD, local day
app_seconds INTEGER NOT NULL DEFAULT 0,
domain_seconds INTEGER NOT NULL DEFAULT 0,
app_count INTEGER NOT NULL DEFAULT 0,
domain_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (device_id, day)
);
CREATE INDEX IF NOT EXISTS idx_telemetry_daily_day ON telemetry_daily(day);

View File

@@ -9,6 +9,7 @@ package store
import (
"context"
"database/sql"
"fmt"
"time"
)
@@ -70,7 +71,8 @@ type TelemetryEntry struct {
// InsertTelemetry upserts a rolling daily usage total per (device, day, app,
// domain). Reports are repeats, not deltas: a later report for the same key
// replaces the earlier total (migration 002). It returns the number of rows
// replaces the earlier total (migration 002). The affected days are rolled up
// in the same transaction (migration 003). It returns the number of rows
// written.
func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entries []TelemetryEntry) (int, error) {
if len(entries) == 0 {
@@ -84,6 +86,7 @@ func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entri
defer func() { _ = tx.Rollback() }()
n := 0
days := make(map[string]struct{}, 1)
for _, e := range entries {
if e.Seconds < 0 {
continue
@@ -97,14 +100,187 @@ func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entri
id, e.App, e.Domain, e.Seconds, day); err != nil {
return n, fmt.Errorf("insert telemetry: %w", err)
}
days[day] = struct{}{}
n++
}
for d := range days {
if err := rollupDay(ctx, tx, id, d); err != nil {
return n, err
}
}
if err := tx.Commit(); err != nil {
return n, fmt.Errorf("commit telemetry: %w", err)
}
return n, nil
}
// rollupDay recomputes one device/day row of telemetry_daily from the detail
// table. Apps and domains are summed separately so a report that carries both
// is not double-counted.
func rollupDay(ctx context.Context, ex interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, id int64, day string) error {
if _, err := ex.ExecContext(ctx, `
INSERT INTO telemetry_daily
(device_id, day, app_seconds, domain_seconds, app_count, domain_count, updated_at)
SELECT ?, ?,
COALESCE(SUM(CASE WHEN app <> '' THEN seconds END), 0),
COALESCE(SUM(CASE WHEN domain <> '' THEN seconds END), 0),
COALESCE(SUM(CASE WHEN app <> '' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN domain <> '' THEN 1 END), 0),
datetime('now')
FROM telemetry WHERE device_id = ? AND day = ?
ON CONFLICT(device_id, day) DO UPDATE SET
app_seconds = excluded.app_seconds,
domain_seconds = excluded.domain_seconds,
app_count = excluded.app_count,
domain_count = excluded.domain_count,
updated_at = datetime('now')`,
id, day, id, day); err != nil {
return fmt.Errorf("rollup day: %w", err)
}
return nil
}
// RebuildRollups recomputes every telemetry_daily row from the detail table. It
// is idempotent and is how the rollup is backfilled after an upgrade or
// repaired after an import. It returns the number of rows inserted or updated.
func (s *Store) RebuildRollups(ctx context.Context) (int, error) {
res, err := s.db.ExecContext(ctx, `
INSERT INTO telemetry_daily
(device_id, day, app_seconds, domain_seconds, app_count, domain_count, updated_at)
SELECT device_id, day,
COALESCE(SUM(CASE WHEN app <> '' THEN seconds END), 0),
COALESCE(SUM(CASE WHEN domain <> '' THEN seconds END), 0),
COALESCE(SUM(CASE WHEN app <> '' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN domain <> '' THEN 1 END), 0),
datetime('now')
FROM telemetry
GROUP BY device_id, day
ON CONFLICT(device_id, day) DO UPDATE SET
app_seconds = excluded.app_seconds,
domain_seconds = excluded.domain_seconds,
app_count = excluded.app_count,
domain_count = excluded.domain_count,
updated_at = datetime('now')`)
if err != nil {
return 0, fmt.Errorf("rebuild rollups: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("rebuild rollups: %w", err)
}
return int(n), nil
}
// DayUsage is one day of rolled-up usage for a device. AppSeconds and
// DomainSeconds are kept apart because a report may carry both.
type DayUsage struct {
Day string
AppSeconds int
DomainSeconds int
AppCount int
DomainCount int
}
// UsageRange returns the daily rollup for a device in [from, to] inclusive,
// oldest first. Days without a report are absent; the caller fills the gaps.
func (s *Store) UsageRange(ctx context.Context, id int64, from, to string) ([]DayUsage, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT day, app_seconds, domain_seconds, app_count, domain_count
FROM telemetry_daily
WHERE device_id = ? AND day BETWEEN ? AND ?
ORDER BY day`, id, from, to)
if err != nil {
return nil, fmt.Errorf("usage range: %w", err)
}
defer rows.Close()
out := make([]DayUsage, 0, 8)
for rows.Next() {
var d DayUsage
if err := rows.Scan(&d.Day, &d.AppSeconds, &d.DomainSeconds, &d.AppCount, &d.DomainCount); err != nil {
return nil, fmt.Errorf("scan usage day: %w", err)
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("usage range: %w", err)
}
return out, nil
}
// UsageTotal is one app or domain total over a range.
type UsageTotal struct {
Name string
Seconds int
}
// TopUsage returns the biggest apps or domains for a device in [from, to]
// inclusive. kind is "app" or "domain"; anything else returns no rows. The
// column is chosen by a switch, never interpolated, so the argument can never
// reach SQL as text.
func (s *Store) TopUsage(ctx context.Context, id int64, from, to, kind string, limit int) ([]UsageTotal, error) {
column := ""
switch kind {
case "app":
column = "app"
case "domain":
column = "domain"
default:
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 10
}
rows, err := s.db.QueryContext(ctx, `
SELECT `+column+`, SUM(seconds)
FROM telemetry
WHERE device_id = ? AND day BETWEEN ? AND ? AND `+column+` <> ''
GROUP BY `+column+`
ORDER BY SUM(seconds) DESC, `+column+` ASC
LIMIT ?`, id, from, to, limit)
if err != nil {
return nil, fmt.Errorf("top %s: %w", column, err)
}
defer rows.Close()
out := make([]UsageTotal, 0, limit)
for rows.Next() {
var t UsageTotal
if err := rows.Scan(&t.Name, &t.Seconds); err != nil {
return nil, fmt.Errorf("scan top %s: %w", column, err)
}
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("top %s: %w", column, err)
}
return out, nil
}
// CapAudit keeps the newest max audit rows and deletes the oldest beyond that,
// so the append-only log cannot grow without bound (AGENTS.md §7 retention). It
// returns how many rows it removed. Nothing in the audit log is ever edited.
func (s *Store) CapAudit(ctx context.Context, max int) (int, error) {
if max <= 0 {
return 0, nil
}
res, err := s.db.ExecContext(ctx, `
DELETE FROM audit WHERE id NOT IN (
SELECT id FROM audit ORDER BY id DESC LIMIT ?
)`, max)
if err != nil {
return 0, fmt.Errorf("cap audit: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("cap audit: %w", err)
}
return int(n), nil
}
// TelemetryForDay returns one device's usage rows for a day, biggest first.
func (s *Store) TelemetryForDay(ctx context.Context, id int64, day string) ([]TelemetryEntry, error) {
rows, err := s.db.QueryContext(ctx, `

View File

@@ -185,3 +185,137 @@ func TestDeviceListsAndTopicLookup(t *testing.T) {
t.Errorf("unknown topic error = %v, want ErrNotFound", err)
}
}
func TestTelemetryRollupAndWeekQueries(t *testing.T) {
st, dev := newTestDevice(t)
ctx := context.Background()
// Three days of usage. Apps and domains are reported for the same day on
// purpose, to prove the rollup keeps them apart instead of double-counting.
days := []struct {
day string
app string
appSecs int
domain string
webSecs int
}{
{"2026-10-12", "com.spotify.music", 600, "youtube.com", 120},
{"2026-10-13", "com.spotify.music", 900, "youtube.com", 300},
{"2026-10-13", "com.roblox.client", 1800, "roblox.com", 1800},
{"2026-10-14", "com.roblox.client", 3600, "roblox.com", 3600},
}
for _, d := range days {
if _, err := st.InsertTelemetry(ctx, dev.ID, d.day, []TelemetryEntry{
{App: d.app, Seconds: d.appSecs},
{Domain: d.domain, Seconds: d.webSecs},
}); err != nil {
t.Fatalf("InsertTelemetry %s: %v", d.day, err)
}
}
usage, err := st.UsageRange(ctx, dev.ID, "2026-10-12", "2026-10-14")
if err != nil {
t.Fatalf("UsageRange: %v", err)
}
if len(usage) != 3 {
t.Fatalf("usage rows = %d, want 3", len(usage))
}
if usage[0].Day != "2026-10-12" || usage[0].AppSeconds != 600 || usage[0].DomainSeconds != 120 {
t.Errorf("day 1 = %+v", usage[0])
}
if usage[1].AppSeconds != 2700 || usage[1].DomainSeconds != 2100 {
t.Errorf("day 2 = %+v, want apps 2700 and domains 2100 (no cross-counting)", usage[1])
}
if usage[2].AppSeconds != 3600 || usage[2].DomainSeconds != 3600 {
t.Errorf("day 3 = %+v", usage[2])
}
// A repeated report replaces the day's total, and the rollup follows.
if _, err := st.InsertTelemetry(ctx, dev.ID, "2026-10-13", []TelemetryEntry{
{App: "com.roblox.client", Seconds: 200},
}); err != nil {
t.Fatalf("InsertTelemetry replace: %v", err)
}
usage, _ = st.UsageRange(ctx, dev.ID, "2026-10-13", "2026-10-13")
if len(usage) != 1 || usage[0].AppSeconds != 1100 {
t.Errorf("day 2 after replace = %+v, want apps 1100 (spotify 900 + roblox 200)", usage)
}
// Top lists are ordered by total seconds.
topApps, err := st.TopUsage(ctx, dev.ID, "2026-10-12", "2026-10-14", "app", 10)
if err != nil {
t.Fatalf("TopUsage app: %v", err)
}
if len(topApps) != 2 || topApps[0].Name != "com.roblox.client" || topApps[0].Seconds != 3800 {
t.Errorf("top apps = %+v, want roblox first with 3800", topApps)
}
topDomains, err := st.TopUsage(ctx, dev.ID, "2026-10-12", "2026-10-14", "domain", 10)
if err != nil {
t.Fatalf("TopUsage domain: %v", err)
}
if len(topDomains) != 2 || topDomains[0].Name != "roblox.com" || topDomains[0].Seconds != 5400 {
t.Errorf("top domains = %+v", topDomains)
}
// An unknown kind and a range with no data are both empty, not errors.
if got, err := st.TopUsage(ctx, dev.ID, "2026-10-12", "2026-10-14", "nonsense", 10); err != nil || len(got) != 0 {
t.Errorf("TopUsage nonsense = (%+v, %v), want empty", got, err)
}
if got, err := st.UsageRange(ctx, dev.ID, "2020-01-01", "2020-01-07"); err != nil || len(got) != 0 {
t.Errorf("UsageRange empty = (%+v, %v), want empty", got, err)
}
}
func TestRebuildRollupsAndCapAudit(t *testing.T) {
st, dev := newTestDevice(t)
ctx := context.Background()
// Simulate pre-Phase-7 data: telemetry written before the rollup existed.
if _, err := st.DB().ExecContext(ctx,
`INSERT INTO telemetry (device_id, app, domain, seconds, day) VALUES (?, '', 'example.com', 300, '2026-10-13')`,
dev.ID); err != nil {
t.Fatalf("seed telemetry: %v", err)
}
var count int
if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM telemetry_daily`).Scan(&count); err != nil {
t.Fatalf("count rollups: %v", err)
}
if count != 0 {
t.Fatalf("telemetry_daily has %d rows before rebuild, want 0", count)
}
n, err := st.RebuildRollups(ctx)
if err != nil {
t.Fatalf("RebuildRollups: %v", err)
}
if n != 1 {
t.Errorf("RebuildRollups = %d rows, want 1", n)
}
usage, err := st.UsageRange(ctx, dev.ID, "2026-10-13", "2026-10-13")
if err != nil || len(usage) != 1 || usage[0].DomainSeconds != 300 {
t.Fatalf("usage after rebuild = (%+v, %v)", usage, err)
}
// The audit cap keeps the newest rows and never edits the rest.
for i := 0; i < 25; i++ {
if err := st.Audit(ctx, "sam", "test", nil, "row"); err != nil {
t.Fatalf("Audit: %v", err)
}
}
removed, err := st.CapAudit(ctx, 10)
if err != nil {
t.Fatalf("CapAudit: %v", err)
}
if removed == 0 {
t.Error("CapAudit removed nothing, want the oldest rows gone")
}
if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&count); err != nil {
t.Fatalf("count audit: %v", err)
}
if count != 10 {
t.Errorf("audit rows = %d, want 10", count)
}
// A no-op cap leaves the log alone.
if n, err := st.CapAudit(ctx, 100); err != nil || n != 0 {
t.Errorf("CapAudit(100) = (%d, %v), want (0, nil)", n, err)
}
}

View File

@@ -101,6 +101,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /devices/{id}/lists", s.requireAuth(s.handleListsEditor))
mux.HandleFunc("POST /devices/{id}/lists", s.requireAuth(s.handleListAdd))
mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {

View File

@@ -546,6 +546,65 @@ a.button-utility { text-decoration: none; }
.list-entry__value { flex: 1 1 auto; word-break: break-all; }
.list-entry__remove { margin: 0; }
/* ---------- Usage (Phase 7) ---------- */
.usage-table {
width: 100%;
border-collapse: collapse;
min-width: 520px;
}
.usage-table th,
.usage-table td {
text-align: left;
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--hairline);
vertical-align: middle;
}
.usage-table thead th {
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.usage-table tbody th { font: var(--type-body-md); color: var(--ink); white-space: nowrap; }
.usage-table__date { font: var(--type-caption); color: var(--ink-muted); }
.usage-table__num { font: var(--type-body-md); color: var(--ink-secondary); white-space: nowrap; }
.usage-table__bar { width: 40%; min-width: 120px; }
.usage-table__today th { color: var(--primary); }
/* Native <progress> bars: no inline styles, so the strict
Content-Security-Policy (style-src 'self') keeps holding. */
.usage-bar {
display: block;
width: 100%;
height: 10px;
border: 0;
border-radius: var(--rounded-full);
overflow: hidden;
background: var(--surface-2);
appearance: none;
-webkit-appearance: none;
}
.usage-bar::-webkit-progress-bar { background: var(--surface-2); border-radius: var(--rounded-full); }
.usage-bar::-webkit-progress-value { background: var(--primary); border-radius: var(--rounded-full); }
.usage-bar::-moz-progress-bar { background: var(--primary); border-radius: var(--rounded-full); }
.usage-bar--full { margin-top: var(--space-xxs); }
.usage-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-sm); }
.usage-item {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-xxs) var(--space-sm);
align-items: center;
padding: var(--space-xs) 0;
border-top: 1px solid var(--hairline);
}
.usage-item:first-child { border-top: 0; }
.usage-item--empty { color: var(--ink-muted); font-style: italic; }
.usage-item__name { font: var(--type-body-md); color: var(--ink); overflow-wrap: anywhere; }
.usage-item__time { font: var(--type-body-sm); color: var(--ink-muted); white-space: nowrap; }
.usage-item .usage-bar { grid-column: 1 / -1; }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {

View File

@@ -0,0 +1,55 @@
// Theme controller. The design tokens ship both palettes; nothing switches
// between them unless this runs. It follows the OS preference by default and
// remembers an explicit choice. Loaded with defer from /static/, so it is
// allowed by the page's `script-src 'self'` policy (no inline script).
(function () {
"use strict";
var KEY = "webrestrict.theme";
var root = document.documentElement;
var media = window.matchMedia("(prefers-color-scheme: dark)");
var stored = null;
try {
stored = window.localStorage.getItem(KEY);
} catch (e) {
stored = null;
}
function apply() {
var theme = stored || (media.matches ? "dark" : "light");
root.setAttribute("data-theme", theme);
var buttons = document.querySelectorAll("[data-theme-toggle]");
for (var i = 0; i < buttons.length; i++) {
buttons[i].textContent = theme === "dark" ? "Light" : "Dark";
buttons[i].setAttribute(
"aria-label",
"Switch to " + (theme === "dark" ? "light" : "dark") + " theme"
);
}
}
document.addEventListener("click", function (event) {
var target = event.target && event.target.closest
? event.target.closest("[data-theme-toggle]")
: null;
if (!target) {
return;
}
stored = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
try {
window.localStorage.setItem(KEY, stored);
} catch (e) {
/* private mode: the choice just does not persist */
}
apply();
});
if (media.addEventListener) {
media.addEventListener("change", function () {
if (!stored) {
apply();
}
});
}
apply();
})();

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
@@ -16,6 +17,7 @@
Internet Use Control
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">

View File

@@ -7,6 +7,7 @@
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
@@ -15,6 +16,7 @@
Internet Use Control
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
</div>
</nav>

View File

@@ -7,6 +7,7 @@
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
@@ -15,6 +16,7 @@
Internet Use Control
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
</div>
</nav>

View File

@@ -7,6 +7,7 @@
<title>Sign in — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="login-page">
<main class="login-band">

View File

@@ -36,6 +36,7 @@
<a class="button-utility" href="/devices/{{.ID}}/edit">Edit</a>
<a class="button-utility" href="/devices/{{.ID}}/schedule">Schedule</a>
<a class="button-utility" href="/devices/{{.ID}}/lists">Lists</a>
<a class="button-utility" href="/devices/{{.ID}}/usage">Usage</a>
</div>
<p class="device-card__foot">

View File

@@ -7,6 +7,7 @@
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
@@ -15,6 +16,7 @@
Internet Use Control
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
</div>
</nav>

View File

@@ -0,0 +1,148 @@
{{define "usage.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Internet Use Control
</a>
<div class="nav-bar__right">
<span class="nav-bar__user">{{.Username}}</span>
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
</div>
</nav>
<main class="container">
<header class="page-head">
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
<h1 class="heading-1">Usage</h1>
<p class="page-head__sub">{{.Sub}}</p>
</header>
{{if .Flash}}<p class="flash flash--ok" role="status">{{.Flash}}</p>{{end}}
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<section class="section" aria-labelledby="week-title">
<div class="section__head">
<h2 id="week-title" class="heading-2">Week of {{.WeekLabel}}</h2>
<div class="section__actions">
<a class="button-utility" href="/devices/{{.DeviceID}}/usage?week={{.PrevWeek}}">← Previous</a>
{{if not .IsThisWeek}}<a class="button-utility" href="/devices/{{.DeviceID}}/usage">This week</a>{{end}}
<a class="button-utility" href="/devices/{{.DeviceID}}/usage?week={{.NextWeek}}">Next →</a>
</div>
</div>
<div class="card-grid">
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--sky" aria-hidden="true">▤</div>
<h3 class="tool-card__title">App time</h3>
<p class="tool-card__desc">{{.WeekAppsTxt}} this week</p>
<span class="status-pill status-pill--muted">screen time</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--purple" aria-hidden="true">⇄</div>
<h3 class="tool-card__title">Site time</h3>
<p class="tool-card__desc">{{.WeekSiteTxt}} this week</p>
<span class="status-pill status-pill--muted">by hostname</span>
</article>
</div>
{{if .HasUsage}}
<div class="schedule-scroll">
<table class="usage-table">
<caption class="visually-hidden">Daily usage for the selected week</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Apps</th>
<th scope="col">Sites</th>
<th scope="col">Screen time</th>
</tr>
</thead>
<tbody>
{{range .Days}}
<tr{{if .IsToday}} class="usage-table__today"{{end}}>
<th scope="row">{{.Weekday}} <span class="usage-table__date">{{.Date}}{{if .IsToday}} · today{{end}}</span></th>
<td class="usage-table__num">{{.AppLabel}}</td>
<td class="usage-table__num">{{.DomainLabel}}</td>
<td class="usage-table__bar">
<progress class="usage-bar" value="{{.AppSeconds}}" max="{{$.MaxApp}}"
aria-label="App time {{.AppLabel}} on {{.Date}}"></progress>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p class="empty-state__title">No usage for this week</p>
<p class="empty-state__body">No device has reported telemetry for this week. Reports arrive on
<code>restrict/&lt;device&gt;/telemetry</code> and are rolled up per day.</p>
</div>
{{end}}
</section>
{{if .HasUsage}}
<div class="list-grid">
<section class="card-panel list-panel" aria-labelledby="top-apps-title">
<h2 id="top-apps-title" class="heading-2">Top apps</h2>
<p class="list-panel__sub">Total time this week, biggest first.</p>
<ul class="usage-list">
{{range .TopApps}}
<li class="usage-item">
<span class="usage-item__name" title="{{.Name}}">{{.Name}}</span>
<span class="usage-item__time">{{.Label}}</span>
<progress class="usage-bar usage-bar--full" value="{{.Percent}}" max="100"
aria-label="{{.Name}}: {{.Label}}"></progress>
</li>
{{else}}
<li class="usage-item usage-item--empty">No apps reported.</li>
{{end}}
</ul>
</section>
<section class="card-panel list-panel" aria-labelledby="top-domains-title">
<h2 id="top-domains-title" class="heading-2">Top sites</h2>
<p class="list-panel__sub">Total time this week, biggest first.</p>
<ul class="usage-list">
{{range .TopDomains}}
<li class="usage-item">
<span class="usage-item__name" title="{{.Name}}">{{.Name}}</span>
<span class="usage-item__time">{{.Label}}</span>
<progress class="usage-bar usage-bar--full" value="{{.Percent}}" max="100"
aria-label="{{.Name}}: {{.Label}}"></progress>
</li>
{{else}}
<li class="usage-item usage-item--empty">No sites reported.</li>
{{end}}
</ul>
</section>
</div>
{{end}}
<p class="schedule-note__hint">{{.Retention}}</p>
<div class="form-actions">
<a class="button-utility" href="/devices/{{.DeviceID}}/schedule">Weekly schedule</a>
<a class="button-utility" href="/devices/{{.DeviceID}}/lists">Blocklist and allowlist</a>
<a class="button-utility" href="/">Done</a>
</div>
</main>
<footer class="app-foot">
<span>Control Service · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}

228
internal/web/usage.go Normal file
View File

@@ -0,0 +1,228 @@
// Usage screen (AGENTS.md §5 screen 5, §11 Phase 7). It reads the daily rollup
// and the per-app/domain detail. Read-only: telemetry only ever arrives from a
// device, and the Control Service only stores and displays it.
package web
import (
"context"
"fmt"
"net/http"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// usageDayView is one row of the week grid.
type usageDayView struct {
Day string // YYYY-MM-DD
Weekday string // Mon
Date string // 13 Oct
IsToday bool
AppSeconds int
DomainSeconds int
AppLabel string
DomainLabel string
// Progress is a percentage of the busiest day in the week.
AppPercent int
DomainPercent int
}
// usageTotalView is one row of the top apps or top domains list.
type usageTotalView struct {
Name string
Seconds int
Label string
Percent int
}
type usageData struct {
editorCommon
WeekLabel string
PrevWeek string
NextWeek string
IsThisWeek bool
Days []usageDayView
WeekApps int
WeekDomains int
WeekAppsTxt string
WeekSiteTxt string
MaxApp int
MaxDomain int
TopApps []usageTotalView
TopDomains []usageTotalView
HasUsage bool
Retention string
}
// formatSeconds renders a duration compactly: "9h 05m", "42m", "under a minute".
func formatSeconds(seconds int) string {
if seconds <= 0 {
return "0m"
}
if seconds < 60 {
return "under a minute"
}
m := seconds / 60
h := m / 60
m %= 60
if h == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh %02dm", h, m)
}
// weekStart returns the Monday 00:00 of the week that contains t, in t's
// location. The family's week is Monday to Sunday.
func weekStart(t time.Time) time.Time {
offset := (int(t.Weekday()) + 6) % 7 // Monday = 0
d := t.AddDate(0, 0, -offset)
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
}
// parseWeekAnchor reads the ?week=YYYY-MM-DD query, falling back to now. An
// unparseable value is ignored, never an error: the screen must always render.
func parseWeekAnchor(raw string, now time.Time) time.Time {
if raw != "" {
if t, err := time.ParseInLocation("2006-01-02", raw, now.Location()); err == nil {
return t
}
}
return now
}
func (s *Server) handleDeviceUsage(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
now := s.now()
anchor := parseWeekAnchor(r.URL.Query().Get("week"), now)
start := weekStart(anchor)
end := start.AddDate(0, 0, 6)
from, to := start.Format("2006-01-02"), end.Format("2006-01-02")
rangeRows, err := s.store.UsageRange(ctx, id, from, to)
if err != nil {
s.log.Error("usage range", "device", dev.Name, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
byDay := make(map[string]store.DayUsage, len(rangeRows))
for _, d := range rangeRows {
byDay[d.Day] = d
}
topApps, err := s.store.TopUsage(ctx, id, from, to, "app", 10)
if err != nil {
s.log.Error("top apps", "device", dev.Name, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
topDomains, err := s.store.TopUsage(ctx, id, from, to, "domain", 10)
if err != nil {
s.log.Error("top domains", "device", dev.Name, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
data := usageData{
editorCommon: editorCommon{
Title: "Usage — " + dev.Name,
Heading: "Usage",
Sub: fmt.Sprintf("%s · %s", dev.Name, platformLabel(dev.Platform)),
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
DeviceID: dev.ID,
DeviceName: dev.Name,
PlatformLabel: platformLabel(dev.Platform),
},
WeekLabel: fmt.Sprintf("%s – %s", start.Format("Mon 2 Jan"), end.Format("Mon 2 Jan 2006")),
PrevWeek: start.AddDate(0, 0, -7).Format("2006-01-02"),
NextWeek: start.AddDate(0, 0, 7).Format("2006-01-02"),
IsThisWeek: start.Equal(weekStart(now)),
Retention: "Daily rollups are kept long; the raw detail is what the dashboard sums for the top lists.",
}
for i := 0; i < 7; i++ {
day := start.AddDate(0, 0, i)
key := day.Format("2006-01-02")
row := byDay[key]
v := usageDayView{
Day: key,
Weekday: day.Format("Mon"),
Date: day.Format("2 Jan"),
IsToday: key == now.Format("2006-01-02"),
AppSeconds: row.AppSeconds,
DomainSeconds: row.DomainSeconds,
AppLabel: formatSeconds(row.AppSeconds),
DomainLabel: formatSeconds(row.DomainSeconds),
}
data.Days = append(data.Days, v)
data.WeekApps += row.AppSeconds
data.WeekDomains += row.DomainSeconds
if row.AppSeconds > data.MaxApp {
data.MaxApp = row.AppSeconds
}
if row.DomainSeconds > data.MaxDomain {
data.MaxDomain = row.DomainSeconds
}
}
data.HasUsage = data.WeekApps > 0 || data.WeekDomains > 0
data.WeekAppsTxt = formatSeconds(data.WeekApps)
data.WeekSiteTxt = formatSeconds(data.WeekDomains)
// Percentages for the day bars, relative to the busiest day of the week.
for i := range data.Days {
data.Days[i].AppPercent = percent(data.Days[i].AppSeconds, data.MaxApp)
data.Days[i].DomainPercent = percent(data.Days[i].DomainSeconds, data.MaxDomain)
}
data.TopApps = totalViews(topApps)
data.TopDomains = totalViews(topDomains)
s.render(w, r, "usage.html", data)
}
// percent returns value as a 0–100 integer of max. A non-positive max yields 0,
// so an empty week renders empty bars rather than a division by zero.
func percent(value, max int) int {
if max <= 0 || value <= 0 {
return 0
}
p := value * 100 / max
if p > 100 {
p = 100
}
return p
}
// totalViews turns store totals into view rows, with each bar relative to the
// biggest entry.
func totalViews(totals []store.UsageTotal) []usageTotalView {
max := 0
for _, t := range totals {
if t.Seconds > max {
max = t.Seconds
}
}
out := make([]usageTotalView, 0, len(totals))
for _, t := range totals {
out = append(out, usageTotalView{
Name: t.Name,
Seconds: t.Seconds,
Label: formatSeconds(t.Seconds),
Percent: percent(t.Seconds, max),
})
}
return out
}

View 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&#39;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)
}
}