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 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, + ``, + w, h) + + if len(real) < 2 { + fmt.Fprintf(&b, + ``, + pad, h/2, w-pad, h/2) + fmt.Fprintf(&b, + ``, + w/2, h/2+4, h-6) + b.WriteString(``) + 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 . + 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, + ``, + 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, `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 `