# 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 `