From 20425b336092ff8b660dfb3cdd23050e9daae224 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:09:25 +0100 Subject: [PATCH 01/17] spec: P6-03 repo size trend (sparkline + chart) design --- ...2026-05-07-p6-03-repo-size-trend-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md diff --git a/docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md b/docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md new file mode 100644 index 0000000..d140679 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md @@ -0,0 +1,223 @@ +# P6-03 — Repo size trend graphs + +Sparkline on the dashboard host row + full chart on the host repo +page, both showing repo growth over time. Closes the last +operator-visibility gap in Phase 6 alongside Prometheus metrics +(P6-04). + +## Goals + +- Operators can see at a glance whether a host's repo is growing, + stable, or shrinking, without leaving the dashboard. +- A second screen on the repo page exposes the same data over a + longer window with a snapshot-count overlay so retention + behaviour can be eyeballed against size. +- Zero new client-side dependencies; matches the existing + HTMX + server-rendered idiom used everywhere else in the UI. + +## Non-goals + +- No backfill of historical data. Trend lights up with whatever + the agents report from the day this ships. +- No per-source-group breakdown — repo-level only. +- No alerting on growth rate (dedicated to a future ticket if a + user asks). +- No JSON API surface. Prometheus exposure is P6-04, separate. + +## Decisions taken in brainstorming + +- **Metrics:** `total_size_bytes` (sparkline + chart) and + `snapshot_count` (chart only). Raw size dropped as redundant. +- **Cadence:** one row per `(host_id, UTC date)`, last-write-wins + per column. Bounded at ~365 rows/host/year regardless of job + frequency. +- **Backfill:** none. Pure forward-fill from launch day. +- **Rendering:** server-rendered inline SVG, no JS library. +- **Spans:** sparkline fixed at 30 days; chart has `30d | 90d | 1y` + range selector, server-rendered swap. + +## Schema + +New migration `internal/store/migrations/0023_host_repo_stats_history.sql`: + +```sql +CREATE TABLE host_repo_stats_history ( + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + day TEXT NOT NULL, -- 'YYYY-MM-DD' UTC + total_size_bytes INTEGER, -- nullable; partial patches don't overwrite + snapshot_count INTEGER, -- nullable + recorded_at TEXT NOT NULL, -- RFC3339Nano of last write touching this row + PRIMARY KEY (host_id, day) +); +CREATE INDEX host_repo_stats_history_host_day + ON host_repo_stats_history(host_id, day DESC); +``` + +FK cascade matches every other host-scoped table; deleting a host +through `Store.DeleteHost` (NS-01) wipes its history automatically. + +## Write path + +Hook the existing `MsgRepoStats` handler in +`internal/server/ws/handler.go` (around line 319). After the +existing `UpsertHostRepoStats(ctx, hostID, patch)` call, append: + +```go +day := time.Now().UTC().Format("2006-01-02") +if err := deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day, patch); err != nil { + slog.Warn("ws: upsert host repo stats history", "host_id", hostID, "err", err) +} +``` + +A history-write failure is logged and dropped — never blocks the +main upsert. The partial-update contract that +`UpsertHostRepoStats` already implements is preserved at the +history layer: + +```sql +INSERT INTO host_repo_stats_history (host_id, day, total_size_bytes, snapshot_count, recorded_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT(host_id, day) DO UPDATE SET + total_size_bytes = COALESCE(excluded.total_size_bytes, host_repo_stats_history.total_size_bytes), + snapshot_count = COALESCE(excluded.snapshot_count, host_repo_stats_history.snapshot_count), + recorded_at = excluded.recorded_at; +``` + +This is critical: the agent's prune handler in +`internal/agent/runner/runner.go:318` emits a stats patch that +only carries `LastPruneAt`. Without `COALESCE`, that prune ack +would null out a `total_size_bytes` we'd already captured from a +backup earlier the same day. + +## Read path + +Two new helpers in `internal/store/host_repo_stats_history.go`: + +```go +type RepoStatsHistoryPoint struct { + Day time.Time // 00:00:00 UTC + TotalSizeBytes *int64 + SnapshotCount *int64 +} + +func (s *Store) ListHostRepoStatsHistory( + ctx context.Context, hostID string, since time.Time, +) ([]RepoStatsHistoryPoint, error) +``` + +Returns rows ordered by `day` ascending where at least one metric +is non-null. The renderer connects available points with a +straight line — there is no explicit gap representation. A host +that was offline for a week shows a single segment spanning the +gap, which is the right visual: the repo state didn't change. + +## Rendering + +New package `internal/web/sparkline`. Pure Go, no template +dependency: + +```go +type Series struct { + Name string + Points []float64 // nil-points represented as math.NaN + Stroke string // CSS color +} + +func RenderSparkline(points []float64, width, height int) template.HTML +func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML +``` + +`RenderChart` produces a 600×220 SVG with: + +- Light horizontal gridlines (4 bands). +- Two y-axes: bytes (left, blue) and count (right, amber). Each + series is normalised against its own axis. +- X-axis labels at start, midpoint, and end of the window. +- Per-point `` with a `` for hover tooltips — + accessible by default, no JS. +- Empty state: faint dashed baseline + centered "no data yet" + text. + +Sparkline is 80×20, single blue polyline, single `<title>` on the +group element showing `"current → 30d ago"`. + +Two new partials: + +- `web/templates/partials/repo_size_sparkline.html` +- `web/templates/partials/repo_size_chart.html` + +Both call into the renderer with the appropriate opts. No +inline `<style>` — colours come from existing Tailwind palette +classes already used elsewhere (`text-blue-500`, `text-amber-500`). + +## UI placement + +### Dashboard host row + +`web/templates/partials/host_row.html` gains one `<td>` between +the existing "Repo size" cell and "Snapshots" cell. Width ≈ 88px. +Cell renders the sparkline partial; if `len(points) < 2` the cell +shows "—" centred (matches the existing no-data idiom for +last-backup time in the same partial). + +The dashboard's existing 5-second htmx live-refresh +(`hx-trigger="every 5s ..."` from NS-04) re-renders this cell +along with the rest of the row. No extra polling. + +### Host repo page + +`web/templates/pages/host_repo.html` gains a "Trend" panel +inserted between the existing summary panel and the maintenance +panel. Panel contains: + +- Range pills `30d | 90d | 1y` (anchor links with + `hx-get="/hosts/{id}/repo/trend?range=…"` and + `hx-target="#repo-trend-chart" hx-swap="outerHTML"`). +- The chart partial wrapped in `<div id="repo-trend-chart">`. +- A small legend strip below the chart. + +## Endpoints + +- `GET /hosts/{id}/repo/trend?range=30d|90d|1y` — admin/operator, + htmx fragment, returns the chart partial. Auth reuses the + existing host-scoped middleware on the `/hosts/{id}` family. + Invalid `range` falls back to 30d. + +No new admin-only surface — anyone with read access to the host +can see the trend. + +## Testing + +- `internal/store/host_repo_stats_history_test.go` — upsert + merges partial patches without nulling; ordering; since-day + filter; cascade on host delete. +- `internal/web/sparkline/sparkline_test.go` — golden SVG files + for: empty input, single point, full 30-day series, mixed + null points. Goldens live under `testdata/`. +- `internal/server/http/ui_repo_test.go` — trend panel renders + with seeded history; range selector swaps server-side; empty + state. +- `internal/server/http/ui_dashboard_test.go` — host row sparkline + cell present and renders SVG when points exist, "—" when not. +- Smoke after build: dashboard row shows sparkline once two days + of data exist; repo page chart toggles cleanly between ranges. + +## Migration / rollout + +- Schema migration is additive — no risk to existing tables. +- Write path is best-effort; on schema issue the main repo-stats + upsert is unaffected. +- No agent change required, so no fleet update needed. + +## Acceptance + +- After two days of operation, the dashboard sparkline shows a + visible line for any host that has run a backup or + maintenance op on both days. +- Host repo page renders the trend panel with the snapshot-count + overlay; range selector switches view without a full page + reload. +- `go test ./...` and `go vet ./...` clean. +- Smoke env exercise: backup → sparkline updates; range pills + swap; FK cascade verified by deleting a host and checking the + history table. From 363bdff85bc52cb147eb61d3eed3161b52e126a8 Mon Sep 17 00:00:00 2001 From: Steve Cliff <steve@devcloud.guru> Date: Thu, 7 May 2026 18:15:06 +0100 Subject: [PATCH 02/17] plan: P6-03 repo size trend implementation --- .../plans/2026-05-07-p6-03-repo-size-trend.md | 1556 +++++++++++++++++ 1 file changed, 1556 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md diff --git a/docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md b/docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md new file mode 100644 index 0000000..03f0533 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md @@ -0,0 +1,1556 @@ +# P6-03 Repo size trend — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Per-host repo growth trend — sparkline on every dashboard +row plus a `30d|90d|1y` chart with snapshot-count overlay on the +host repo page. + +**Architecture:** New `host_repo_stats_history` table holds one +row per `(host_id, UTC date)`, written from the existing +`MsgRepoStats` WebSocket handler alongside the current-stats +upsert. A pure-Go `internal/web/sparkline` package emits inline +SVG; the dashboard handler attaches a per-row sparkline as +`template.HTML` and the repo page renders the chart in a new +"Trend" panel. Range switching is an htmx server fragment. + +**Tech Stack:** Go (`net/http` + chi), SQLite (modernc), Go +`html/template`, HTMX, server-rendered inline SVG, no client +library. + +**Spec:** `docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md` + +--- + +## File Structure + +**Create:** +- `internal/store/migrations/0023_host_repo_stats_history.sql` — schema +- `internal/store/host_repo_stats_history.go` — store helpers +- `internal/store/host_repo_stats_history_test.go` — store tests +- `internal/web/sparkline/sparkline.go` — SVG renderer +- `internal/web/sparkline/sparkline_test.go` — golden tests +- `internal/web/sparkline/testdata/*.svg` — golden files +- `web/templates/partials/repo_size_sparkline.html` — dashboard cell +- `web/templates/partials/repo_size_chart.html` — trend panel inner +- `internal/server/http/ui_repo_trend.go` — `GET /hosts/{id}/repo/trend` + +**Modify:** +- `internal/server/ws/handler.go` — write to history table after + the existing upsert (around line 337) +- `internal/server/http/ui_handlers.go` — attach per-row + `RepoSparklineSVG template.HTML` to `dashboardHostRow` +- `internal/server/http/ui_repo.go` — load 30d series + initial + chart SVG into `hostRepoPage` +- `internal/server/http/server.go` — mount the new trend route +- `web/templates/partials/host_row.html` — sparkline cell +- `web/templates/pages/host_repo.html` — trend panel +- `web/templates/pages/dashboard.html` — column header (if hosts + table is grid-based) +- `tasks.md` — flip P6-03 to `[x]` with as-shipped block + +--- + +## Task 1: Migration — `host_repo_stats_history` table + +**Files:** +- Create: `internal/store/migrations/0023_host_repo_stats_history.sql` +- Test: `internal/store/migrate_test.go` (existing — runs every migration end-to-end) + +- [ ] **Step 1: Write the migration** + +Create `internal/store/migrations/0023_host_repo_stats_history.sql`: + +```sql +-- 0023_host_repo_stats_history.sql +-- +-- Daily time-series of per-host repo metrics, used by the P6-03 +-- trend sparkline + chart. One row per (host_id, UTC date), +-- last-write-wins per column. Population is best-effort and +-- piggy-backs on the existing repo.stats WS message — nothing +-- else writes here. + +CREATE TABLE host_repo_stats_history ( + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + day TEXT NOT NULL, -- 'YYYY-MM-DD' UTC + total_size_bytes INTEGER, -- nullable: partial patches preserve existing value + snapshot_count INTEGER, -- nullable + recorded_at TEXT NOT NULL, -- RFC3339Nano of latest write + PRIMARY KEY (host_id, day) +); + +CREATE INDEX host_repo_stats_history_host_day + ON host_repo_stats_history(host_id, day DESC); +``` + +- [ ] **Step 2: Run the existing migration test** + +Run: `go test ./internal/store -run TestMigrate -v` +Expected: PASS — the migration runner walks every `.sql` in +`migrations/` in order; a syntactically valid file is enough at +this stage. + +- [ ] **Step 3: Run vet** + +Run: `go vet ./...` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add internal/store/migrations/0023_host_repo_stats_history.sql +git commit -m "store: migration 0023 host_repo_stats_history" +``` + +--- + +## Task 2: Store helpers — Upsert + List + +**Files:** +- Create: `internal/store/host_repo_stats_history.go` +- Test: `internal/store/host_repo_stats_history_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/store/host_repo_stats_history_test.go`: + +```go +package store + +import ( + "context" + "testing" + "time" +) + +func TestHostRepoStatsHistory_PartialUpsertPreservesPriorValues(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-1" + seedHost(t, s, hostID) + + day := "2026-05-07" + now := time.Now().UTC() + + // 1. First write of the day: total_size only. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{TotalSizeBytes: int64ptr(100)}, now); err != nil { + t.Fatalf("upsert 1: %v", err) + } + + // 2. Second write: snapshot_count only — total_size MUST survive. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{SnapshotCount: int64ptr(7)}, now.Add(time.Minute)); err != nil { + t.Fatalf("upsert 2: %v", err) + } + + // 3. Third write: a prune-only patch (no size, no count). Both + // prior values must survive. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{}, now.Add(2*time.Minute)); err != nil { + t.Fatalf("upsert 3: %v", err) + } + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(pts) != 1 { + t.Fatalf("want 1 point, got %d", len(pts)) + } + p := pts[0] + if p.TotalSizeBytes == nil || *p.TotalSizeBytes != 100 { + t.Errorf("TotalSizeBytes: want 100, got %v", p.TotalSizeBytes) + } + if p.SnapshotCount == nil || *p.SnapshotCount != 7 { + t.Errorf("SnapshotCount: want 7, got %v", p.SnapshotCount) + } +} + +func TestHostRepoStatsHistory_OrderingAndSinceFilter(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-2" + seedHost(t, s, hostID) + + now := time.Now().UTC() + for i, day := range []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} { + v := int64(100 + i*10) + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{TotalSizeBytes: &v}, now); err != nil { + t.Fatalf("upsert %s: %v", day, err) + } + } + + all, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all) != 4 { + t.Fatalf("want 4 points, got %d", len(all)) + } + // Ordering ascending by day. + wantDays := []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} + for i, p := range all { + got := p.Day.Format("2006-01-02") + if got != wantDays[i] { + t.Errorf("point %d: want day %s, got %s", i, wantDays[i], got) + } + } + + since, _ := time.Parse("2006-01-02", "2026-05-03") + recent, err := s.ListHostRepoStatsHistory(ctx, hostID, since) + if err != nil { + t.Fatalf("list since: %v", err) + } + if len(recent) != 2 { + t.Fatalf("since 2026-05-03: want 2 points, got %d", len(recent)) + } +} + +func TestHostRepoStatsHistory_CascadeOnHostDelete(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-3" + seedHost(t, s, hostID) + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, "2026-05-07", + HostRepoStats{TotalSizeBytes: int64ptr(42)}, time.Now().UTC()); err != nil { + t.Fatalf("upsert: %v", err) + } + + if err := s.DeleteHost(ctx, hostID); err != nil { + t.Fatalf("delete host: %v", err) + } + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(pts) != 0 { + t.Fatalf("want 0 points after host delete, got %d", len(pts)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/store -run TestHostRepoStatsHistory -v` +Expected: FAIL — `UpsertHostRepoStatsHistory` and +`ListHostRepoStatsHistory` undefined. + +- [ ] **Step 3: Implement the store helpers** + +Create `internal/store/host_repo_stats_history.go`: + +```go +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// RepoStatsHistoryPoint is one (day, host) point for the trend chart. +// Both metric pointers may be nil — a row exists as soon as either +// metric was reported on that day. +type RepoStatsHistoryPoint struct { + Day time.Time // 00:00:00 UTC + TotalSizeBytes *int64 + SnapshotCount *int64 +} + +// UpsertHostRepoStatsHistory records the metrics carried by a +// repo.stats patch into the daily history table. Only the non-nil +// fields of patch.TotalSizeBytes / patch.SnapshotCount are written; +// existing values in the row are preserved via COALESCE so a +// prune-only or check-only patch does not null out a backup-time +// size we already captured earlier the same day. +func (s *Store) UpsertHostRepoStatsHistory( + ctx context.Context, hostID, day string, patch HostRepoStats, recordedAt time.Time, +) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO host_repo_stats_history + (host_id, day, total_size_bytes, snapshot_count, recorded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(host_id, day) DO UPDATE SET + total_size_bytes = COALESCE(excluded.total_size_bytes, host_repo_stats_history.total_size_bytes), + snapshot_count = COALESCE(excluded.snapshot_count, host_repo_stats_history.snapshot_count), + recorded_at = excluded.recorded_at`, + hostID, day, + nullableInt64(patch.TotalSizeBytes), + nullableInt64(patch.SnapshotCount), + recordedAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("store: upsert host_repo_stats_history: %w", err) + } + return nil +} + +// ListHostRepoStatsHistory returns all points for hostID with day +// >= since (UTC), ordered ascending. Pass time.Time{} to fetch the +// full history. +func (s *Store) ListHostRepoStatsHistory( + ctx context.Context, hostID string, since time.Time, +) ([]RepoStatsHistoryPoint, error) { + sinceStr := "" + if !since.IsZero() { + sinceStr = since.UTC().Format("2006-01-02") + } + rows, err := s.db.QueryContext(ctx, ` + SELECT day, total_size_bytes, snapshot_count + FROM host_repo_stats_history + WHERE host_id = ? AND day >= ? + ORDER BY day ASC`, + hostID, sinceStr) + if err != nil { + return nil, fmt.Errorf("store: list host_repo_stats_history: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []RepoStatsHistoryPoint + for rows.Next() { + var ( + dayStr string + total sql.NullInt64 + snapCnt sql.NullInt64 + ) + if err := rows.Scan(&dayStr, &total, &snapCnt); err != nil { + return nil, fmt.Errorf("store: scan history row: %w", err) + } + d, perr := time.Parse("2006-01-02", dayStr) + if perr != nil { + return nil, fmt.Errorf("store: parse history day %q: %w", dayStr, perr) + } + p := RepoStatsHistoryPoint{Day: d} + if total.Valid { + v := total.Int64 + p.TotalSizeBytes = &v + } + if snapCnt.Valid { + v := snapCnt.Int64 + p.SnapshotCount = &v + } + out = append(out, p) + } + if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("store: iterate history rows: %w", err) + } + return out, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/store -run TestHostRepoStatsHistory -v` +Expected: PASS for all three subtests. + +- [ ] **Step 5: Run full store package + vet** + +Run: `go test ./internal/store/... && go vet ./...` +Expected: PASS, no vet output. + +- [ ] **Step 6: Commit** + +```bash +git add internal/store/host_repo_stats_history.go \ + internal/store/host_repo_stats_history_test.go +git commit -m "store: history table helpers (upsert/list, COALESCE preserves prior values)" +``` + +--- + +## Task 3: WS handler writes history alongside current stats + +**Files:** +- Modify: `internal/server/ws/handler.go:337` (after the existing + `UpsertHostRepoStats` call) +- Test: `internal/server/ws/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/server/ws/handler_test.go` (use the existing +test harness in that file — `runHandler`, `seededDeps`, etc.; +read the file before adding to confirm helper names): + +```go +func TestHandlerRepoStats_WritesHistoryRow(t *testing.T) { + t.Parallel() + deps, hostID := seededDeps(t) + ctx := context.Background() + + total := int64(12345) + count := int64(7) + env := api.Envelope{ + Type: api.MsgRepoStats, + HostID: hostID, + AgentTS: time.Now().UTC(), + } + payload := api.RepoStatsPayload{ + TotalSizeBytes: &total, + SnapshotCount: &count, + } + envWithPayload := mustEnvWithPayload(t, env, payload) // existing helper + + if err := dispatchEnvelope(ctx, deps, hostID, envWithPayload); err != nil { + t.Fatalf("dispatch: %v", err) + } + + day := time.Now().UTC().Format("2006-01-02") + pts, err := deps.Store.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(pts) != 1 { + t.Fatalf("want 1 history row, got %d", len(pts)) + } + if got := pts[0].Day.Format("2006-01-02"); got != day { + t.Errorf("day: want %s, got %s", day, got) + } + if pts[0].TotalSizeBytes == nil || *pts[0].TotalSizeBytes != 12345 { + t.Errorf("TotalSizeBytes: want 12345, got %v", pts[0].TotalSizeBytes) + } + if pts[0].SnapshotCount == nil || *pts[0].SnapshotCount != 7 { + t.Errorf("SnapshotCount: want 7, got %v", pts[0].SnapshotCount) + } +} +``` + +If `dispatchEnvelope` / `seededDeps` / `mustEnvWithPayload` are +named differently in the existing test file, adopt the existing +naming — the assertions above are what matters. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/ws -run TestHandlerRepoStats_WritesHistoryRow -v` +Expected: FAIL — `len(pts) == 0`. + +- [ ] **Step 3: Wire the history write** + +In `internal/server/ws/handler.go`, in the `case api.MsgRepoStats:` +block (around line 319), after the existing `UpsertHostRepoStats` +call (line 337), add: + +```go +day := time.Now().UTC().Format("2006-01-02") +if err := deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day, patch, time.Now().UTC()); err != nil { + slog.Warn("ws: upsert host repo stats history", "host_id", hostID, "err", err) +} +``` + +Place it after the existing `else { slog.Info(...) }` block — a +history-write failure must not block the main upsert log line. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/server/ws -run TestHandlerRepoStats -v` +Expected: PASS, including any pre-existing repo-stats tests. + +- [ ] **Step 5: Run vet** + +Run: `go vet ./...` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/ws/handler.go internal/server/ws/handler_test.go +git commit -m "ws: record daily repo stats history alongside current upsert" +``` + +--- + +## Task 4: Sparkline package — empty + single + multi-point + +**Files:** +- Create: `internal/web/sparkline/sparkline.go` +- Create: `internal/web/sparkline/sparkline_test.go` +- Create: `internal/web/sparkline/testdata/empty.svg` +- Create: `internal/web/sparkline/testdata/single_point.svg` +- Create: `internal/web/sparkline/testdata/three_points.svg` + +This task introduces the renderer with one of three behaviours +exercised. The chart-rendering function is added in Task 5. + +- [ ] **Step 1: Write the failing test** + +Create `internal/web/sparkline/sparkline_test.go`: + +```go +package sparkline + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func loadGolden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + return strings.TrimRight(string(b), "\n") +} + +func TestSparkline_Empty(t *testing.T) { + got := strings.TrimRight(string(RenderSparkline(nil, 80, 20)), "\n") + want := loadGolden(t, "empty.svg") + if got != want { + t.Errorf("empty sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestSparkline_SinglePoint(t *testing.T) { + got := strings.TrimRight(string(RenderSparkline([]float64{100}, 80, 20)), "\n") + want := loadGolden(t, "single_point.svg") + if got != want { + t.Errorf("single_point sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestSparkline_ThreePoints(t *testing.T) { + got := strings.TrimRight(string(RenderSparkline([]float64{10, 30, 20}, 80, 20)), "\n") + want := loadGolden(t, "three_points.svg") + if got != want { + t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/web/sparkline -v` +Expected: FAIL — package does not exist yet. + +- [ ] **Step 3: Implement the sparkline renderer** + +Create `internal/web/sparkline/sparkline.go`: + +```go +// Package sparkline renders inline SVG sparklines and trend +// charts for the dashboard and host repo page. All output is +// pure server-rendered SVG with no JavaScript, no external +// stylesheet, and no client library. +package sparkline + +import ( + "fmt" + "html/template" + "math" + "strings" + "time" +) + +// RenderSparkline returns an inline SVG <svg> element of the +// given size containing a single polyline normalised across the +// full y-range of points. NaN entries break the polyline. With +// fewer than two real points the SVG still renders but contains +// only a faint baseline + an em-dash placeholder. +func RenderSparkline(points []float64, width, height int) template.HTML { + const pad = 2 + w := width + h := height + innerW := w - 2*pad + innerH := h - 2*pad + + var real []float64 + for _, p := range points { + if !math.IsNaN(p) { + real = append(real, p) + } + } + + var b strings.Builder + fmt.Fprintf(&b, + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-sparkline" role="img" aria-label="repo size trend">`, + w, h) + + if len(real) < 2 { + fmt.Fprintf(&b, + `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/>`, + pad, h/2, w-pad, h/2) + fmt.Fprintf(&b, + `<text x="%d" y="%d" text-anchor="middle" font-size="%d" fill="currentColor" fill-opacity="0.4">—</text>`, + w/2, h/2+4, h-6) + b.WriteString(`</svg>`) + return template.HTML(b.String()) + } + + min, max := real[0], real[0] + for _, v := range real { + if v < min { + min = v + } + if v > max { + max = v + } + } + span := max - min + if span == 0 { + span = 1 // avoid divide-by-zero; line will be flat at mid-height + } + + // Build the polyline string. Skip NaN entries by ending the + // current segment and starting a new <polyline>. + stepX := 0.0 + if len(points) > 1 { + stepX = float64(innerW) / float64(len(points)-1) + } + + var seg strings.Builder + flush := func() { + if seg.Len() == 0 { + return + } + fmt.Fprintf(&b, + `<polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`, + strings.TrimSpace(seg.String())) + seg.Reset() + } + for i, v := range points { + if math.IsNaN(v) { + flush() + continue + } + x := float64(pad) + stepX*float64(i) + y := float64(pad) + float64(innerH) - (v-min)/span*float64(innerH) + fmt.Fprintf(&seg, "%.2f,%.2f ", x, y) + } + flush() + + // Title element for native hover (current → first delta). + cur := real[len(real)-1] + first := real[0] + delta := cur - first + sign := "+" + if delta < 0 { + sign = "-" + delta = -delta + } + fmt.Fprintf(&b, `<title>current %.0f, %s%.0f over window`, cur, sign, delta) + + b.WriteString(``) + return template.HTML(b.String()) +} + +// Series and ChartOpts are added in Task 5. They live in the same +// package so the chart renderer can share the normalisation +// helpers. +var _ = time.Time{} // placeholder import; removed in Task 5 +``` + +- [ ] **Step 4: Run test once with `-update` first to capture goldens** + +Capture the actual output as goldens. Easiest: run a small +program once, or have the test write goldens when missing — but +given how tight this skill expects steps to be, just run the +tests, observe the diffs, and create the goldens by hand from +the failing-test output. + +Run: `go test ./internal/web/sparkline -v` +Expected: FAIL — three goldens missing. + +Read the failure output for each test (`got:` block) and write +those exact strings to: +- `internal/web/sparkline/testdata/empty.svg` +- `internal/web/sparkline/testdata/single_point.svg` +- `internal/web/sparkline/testdata/three_points.svg` + +End each file with no trailing newline (or trim in test, which we +already do). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/web/sparkline -v` +Expected: PASS for all three subtests. + +- [ ] **Step 6: Run vet** + +Run: `go vet ./...` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/web/sparkline/ +git commit -m "web/sparkline: inline-SVG sparkline renderer (empty / single / multi)" +``` + +--- + +## Task 5: Chart renderer — two-axis line chart with hover dots + +**Files:** +- Modify: `internal/web/sparkline/sparkline.go` — add `RenderChart` +- Modify: `internal/web/sparkline/sparkline_test.go` — golden tests +- Create: `internal/web/sparkline/testdata/chart_empty.svg` +- Create: `internal/web/sparkline/testdata/chart_two_series.svg` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/web/sparkline/sparkline_test.go`: + +```go +func TestChart_Empty(t *testing.T) { + got := strings.TrimRight(string(RenderChart(nil, nil, ChartOpts{Width: 600, Height: 220})), "\n") + want := loadGolden(t, "chart_empty.svg") + if got != want { + t.Errorf("empty chart mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestChart_TwoSeries(t *testing.T) { + days := []time.Time{ + time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), + } + series := []Series{ + {Name: "size", Stroke: "#3b82f6", Axis: AxisLeft, Format: FormatBytes, + Points: []float64{1024, 2048, 4096, 8192}}, + {Name: "snapshots", Stroke: "#f59e0b", Axis: AxisRight, Format: FormatCount, + Points: []float64{1, 2, 3, 4}}, + } + got := strings.TrimRight(string(RenderChart(series, days, ChartOpts{Width: 600, Height: 220})), "\n") + want := loadGolden(t, "chart_two_series.svg") + if got != want { + t.Errorf("two_series chart mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/web/sparkline -run TestChart -v` +Expected: FAIL — `RenderChart`, `Series`, `ChartOpts`, +`AxisLeft`, `AxisRight`, `FormatBytes`, `FormatCount` undefined. + +- [ ] **Step 3: Implement the chart renderer** + +Replace the placeholder import block in +`internal/web/sparkline/sparkline.go` with these declarations and +the `RenderChart` function (keep `RenderSparkline` as-is): + +```go +// Axis selects which y-axis a Series is normalised against. +type Axis int + +const ( + AxisLeft Axis = iota + AxisRight +) + +// Format selects how a Series' values appear in hover tooltips. +type Format int + +const ( + FormatBytes Format = iota + FormatCount +) + +// Series is one labelled trace on a chart. +type Series struct { + Name string + Points []float64 // NaN breaks the polyline + Stroke string // hex color + Axis Axis + Format Format +} + +// ChartOpts controls rendering of the full trend chart. +type ChartOpts struct { + Width int + Height int + GridBands int // default 4 + EmptyLabel string // default "no data yet" +} + +// RenderChart returns an inline SVG with up to two y-axes, +// one polyline per series, hover-dot per data point, and X-axis +// labels at start / midpoint / end. With no series or empty +// series, renders a faint baseline + EmptyLabel centred. +func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML { + if opts.Width <= 0 { + opts.Width = 600 + } + if opts.Height <= 0 { + opts.Height = 220 + } + if opts.GridBands <= 0 { + opts.GridBands = 4 + } + if opts.EmptyLabel == "" { + opts.EmptyLabel = "no data yet" + } + const padL, padR, padT, padB = 56, 56, 16, 28 + w, h := opts.Width, opts.Height + innerW := w - padL - padR + innerH := h - padT - padB + + var b strings.Builder + fmt.Fprintf(&b, + ``, + w, h) + + hasData := false + for _, s := range series { + for _, p := range s.Points { + if !math.IsNaN(p) { + hasData = true + break + } + } + if hasData { + break + } + } + if !hasData || len(days) == 0 { + fmt.Fprintf(&b, + ``, + padL, h/2, w-padR, h/2) + fmt.Fprintf(&b, + `%s`, + w/2, h/2+4, opts.EmptyLabel) + b.WriteString(``) + return template.HTML(b.String()) + } + + // Gridlines. + for i := 0; i <= opts.GridBands; i++ { + y := padT + innerH*i/opts.GridBands + fmt.Fprintf(&b, + ``, + padL, y, w-padR, y) + } + + // Per-axis min/max. + type ax struct{ min, max float64; has bool } + axes := map[Axis]*ax{AxisLeft: {}, AxisRight: {}} + for _, s := range series { + a := axes[s.Axis] + for _, p := range s.Points { + if math.IsNaN(p) { + continue + } + if !a.has { + a.min, a.max, a.has = p, p, true + continue + } + if p < a.min { + a.min = p + } + if p > a.max { + a.max = p + } + } + } + for _, a := range axes { + if a.has && a.max == a.min { + a.max = a.min + 1 + } + } + + stepX := 0.0 + if len(days) > 1 { + stepX = float64(innerW) / float64(len(days)-1) + } + + // Polylines + hover dots. + for _, s := range series { + a := axes[s.Axis] + if !a.has { + continue + } + var seg strings.Builder + flush := func() { + if seg.Len() == 0 { + return + } + fmt.Fprintf(&b, + ``, + s.Stroke, strings.TrimSpace(seg.String())) + seg.Reset() + } + for i, v := range s.Points { + if math.IsNaN(v) { + flush() + continue + } + x := float64(padL) + stepX*float64(i) + y := float64(padT) + float64(innerH) - (v-a.min)/(a.max-a.min)*float64(innerH) + fmt.Fprintf(&seg, "%.2f,%.2f ", x, y) + d := days[i] + fmt.Fprintf(&b, + `%s · %s: %s`, + x, y, s.Stroke, d.Format("2006-01-02"), s.Name, formatValue(v, s.Format)) + } + flush() + } + + // Y-axis labels. + if axes[AxisLeft].has { + writeAxisLabels(&b, padL-6, padT, innerH, axes[AxisLeft].min, axes[AxisLeft].max, FormatBytes, "end") + } + if axes[AxisRight].has { + writeAxisLabels(&b, w-padR+6, padT, innerH, axes[AxisRight].min, axes[AxisRight].max, FormatCount, "start") + } + + // X-axis labels (start / mid / end). + xLabels := []int{0, len(days) / 2, len(days) - 1} + anchors := []string{"start", "middle", "end"} + for i, idx := range xLabels { + x := float64(padL) + stepX*float64(idx) + fmt.Fprintf(&b, + `%s`, + x, h-padB+16, anchors[i], days[idx].Format("Jan 2")) + } + + b.WriteString(``) + return template.HTML(b.String()) +} + +func writeAxisLabels(b *strings.Builder, x, padT, innerH int, min, max float64, f Format, anchor string) { + const bands = 4 + for i := 0; i <= bands; i++ { + y := padT + innerH*i/bands + v := max - (max-min)*float64(i)/float64(bands) + fmt.Fprintf(b, + `%s`, + x, y+3, anchor, formatValue(v, f)) + } +} + +func formatValue(v float64, f Format) string { + switch f { + case FormatBytes: + return humanBytes(v) + default: + return fmt.Sprintf("%.0f", v) + } +} + +func humanBytes(v float64) string { + const k = 1024.0 + units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + i := 0 + for v >= k && i < len(units)-1 { + v /= k + i++ + } + if v >= 100 { + return fmt.Sprintf("%.0f %s", v, units[i]) + } + return fmt.Sprintf("%.1f %s", v, units[i]) +} +``` + +Remove the `var _ = time.Time{}` placeholder line. + +- [ ] **Step 4: Run tests to capture goldens** + +Run: `go test ./internal/web/sparkline -run TestChart -v` +Expected: FAIL — goldens missing. Copy the `got:` blocks into: +- `internal/web/sparkline/testdata/chart_empty.svg` +- `internal/web/sparkline/testdata/chart_two_series.svg` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/web/sparkline -v` +Expected: PASS for all sparkline + chart tests. + +- [ ] **Step 6: Run vet** + +Run: `go vet ./...` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/web/sparkline/ +git commit -m "web/sparkline: two-axis trend chart with hover dots" +``` + +--- + +## Task 6: Dashboard host_row sparkline cell + +**Files:** +- Modify: `internal/server/http/ui_handlers.go` — attach `RepoSparklineSVG` +- Modify: `web/templates/partials/host_row.html` — render the cell +- Modify: `web/templates/pages/dashboard.html` — add column header (if header row is grid-defined) +- Test: `internal/server/http/ui_dashboard_test.go` (existing — extend; if absent, create) + +- [ ] **Step 1: Write the failing test** + +Open `internal/server/http/ui_dashboard_test.go` (or create with +the standard test scaffolding used elsewhere — `httptest.NewServer` ++ a logged-in cookie helper; copy the pattern from +`ui_repo_test.go`). Add: + +```go +func TestDashboard_HostRowSparklineRendersWithHistory(t *testing.T) { + t.Parallel() + srv := newTestServer(t) // existing helper + hostID := srv.seedHost(t, "h-spark") + ctx := context.Background() + + // Two history points → polyline must render. + for i, day := range []string{"2026-05-05", "2026-05-06"} { + v := int64(100 + i*50) + if err := srv.deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day, + store.HostRepoStats{TotalSizeBytes: &v}, time.Now().UTC()); err != nil { + t.Fatalf("upsert %s: %v", day, err) + } + } + + body := srv.getHTML(t, "/") // existing helper returns string + if !strings.Contains(body, `class="repo-sparkline"`) { + t.Errorf("expected sparkline SVG in dashboard body, missing") + } + if !strings.Contains(body, `—<`) { + t.Errorf("expected em-dash placeholder for empty history") + } +} +``` + +If the helper names differ, mirror what's already used by the +existing dashboard / repo tests. Inspect `dashboard_filter_test.go` +or `ui_repo_test.go` for the canonical setup. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/http -run TestDashboard_HostRowSparkline -v` +Expected: FAIL — no `repo-sparkline` class in output. + +- [ ] **Step 3: Attach sparkline to dashboard rows** + +In `internal/server/http/ui_handlers.go`, extend `dashboardHostRow`: + +```go +type dashboardHostRow struct { + Host store.Host + RunAllScheduleID string + NextRun *time.Time + UpdateAvailable bool + TargetVersion string + // RepoSparklineSVG is a 30-day total_size_bytes sparkline, + // pre-rendered server-side. Empty when no history rows exist + // for the host (rendered as the "—" placeholder). + RepoSparklineSVG template.HTML +} +``` + +Add the `html/template` import if not present (it likely is — +verify before adding). + +In `handleUIDashboard`, inside the per-host loop after computing +`row`, fetch and render the sparkline. Add the import for the +sparkline package (`"gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline"`): + +```go +since := time.Now().UTC().AddDate(0, 0, -30) +pts, perr := s.deps.Store.ListHostRepoStatsHistory(r.Context(), h.ID, since) +if perr != nil { + slog.Warn("ui dashboard: list repo history", "host_id", h.ID, "err", perr) +} +sparkPoints := make([]float64, len(pts)) +for i, p := range pts { + if p.TotalSizeBytes == nil { + sparkPoints[i] = math.NaN() + } else { + sparkPoints[i] = float64(*p.TotalSizeBytes) + } +} +row.RepoSparklineSVG = sparkline.RenderSparkline(sparkPoints, 88, 20) +``` + +Add `"math"` to the imports if missing. + +- [ ] **Step 4: Render the cell in the partial** + +Modify `web/templates/partials/host_row.html`. Insert a new cell +between the existing "Repo size" cell (line 37) and the +"Snapshots" cell (line 38): + +```html +
{{bytes $h.RepoSizeBytes}}
+
{{.RepoSparklineSVG}}
+
+``` + +- [ ] **Step 5: Update the dashboard column header** + +Open `web/templates/pages/dashboard.html`. Find the table's +column-header row (search for the cell labelled "Repo size" or +"Snapshots"). Add a new header cell with text "30d trend" +between them, and update the grid-template-columns rule (or +whatever column layout is used) so the sparkline cell has a fixed +~96px width. Keep the change minimal — match the existing +class-utility style. + +If the dashboard uses CSS grid like `grid-template-columns` +defined in a `