Files
restic-manager/docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md
T

45 KiB

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.goGET /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:

-- 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
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:

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:

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
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):

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:

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
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:

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:

// 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</title>`, cur, sign, delta)

	b.WriteString(`</svg>`)
	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
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:

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):

// 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 <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,
		`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time">`,
		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,
			`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/>`,
			padL, h/2, w-padR, h/2)
		fmt.Fprintf(&b,
			`<text x="%d" y="%d" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">%s</text>`,
			w/2, h/2+4, opts.EmptyLabel)
		b.WriteString(`</svg>`)
		return template.HTML(b.String())
	}

	// Gridlines.
	for i := 0; i <= opts.GridBands; i++ {
		y := padT + innerH*i/opts.GridBands
		fmt.Fprintf(&b,
			`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.08"/>`,
			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,
				`<polyline fill="none" stroke="%s" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
				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,
				`<circle cx="%.2f" cy="%.2f" r="2.5" fill="%s"><title>%s · %s: %s</title></circle>`,
				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,
			`<text x="%.2f" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
			x, h-padB+16, anchors[i], days[idx].Format("Jan 2"))
	}

	b.WriteString(`</svg>`)
	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,
			`<text x="%d" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
			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
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:
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, `<polyline`) {
		t.Errorf("expected polyline element in dashboard body")
	}
}

func TestDashboard_HostRowSparklineEmptyState(t *testing.T) {
	t.Parallel()
	srv := newTestServer(t)
	srv.seedHost(t, "h-empty")

	body := srv.getHTML(t, "/")
	if !strings.Contains(body, `class="repo-sparkline"`) {
		t.Errorf("expected sparkline SVG element")
	}
	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:

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"):

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):

  <div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes $h.RepoSizeBytes}}</div>
  <div class="repo-sparkline-cell {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">{{.RepoSparklineSVG}}</div>
  <div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
  • 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 <style> block, add an extra column with width 96px.

  • Step 6: Run tests to verify they pass

Run: go test ./internal/server/http -run TestDashboard_HostRowSparkline -v Expected: PASS for both subtests.

  • Step 7: Run full http package + vet

Run: go test ./internal/server/http/... && go vet ./... Expected: PASS, no vet output. Fix any test that broke because of the new column (existing dashboard tests may need a column- count update).

  • Step 8: Commit
git add internal/server/http/ui_handlers.go \
        internal/server/http/ui_dashboard_test.go \
        web/templates/partials/host_row.html \
        web/templates/pages/dashboard.html
git commit -m "ui: 30d repo-size sparkline on every dashboard host row"

Task 7: Host repo page Trend panel + range selector

Files:

  • Create: web/templates/partials/repo_size_chart.html

  • Create: internal/server/http/ui_repo_trend.go

  • Modify: internal/server/http/ui_repo.go — pre-render initial chart

  • Modify: internal/server/http/server.go — mount route

  • Modify: web/templates/pages/host_repo.html — Trend panel

  • Test: internal/server/http/ui_repo_test.go — extend

  • Test: internal/server/http/ui_repo_trend_test.go — new

  • Step 1: Write the failing test for the trend endpoint

Create internal/server/http/ui_repo_trend_test.go:

package http

import (
	"context"
	"strings"
	"testing"
	"time"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

func TestUIRepoTrend_30dRange(t *testing.T) {
	t.Parallel()
	srv := newTestServer(t)
	hostID := srv.seedHost(t, "h-trend")
	ctx := context.Background()

	now := time.Now().UTC()
	for i := 0; i < 5; i++ {
		day := now.AddDate(0, 0, -i).Format("2006-01-02")
		v := int64(1000 + i*100)
		c := int64(10 + i)
		if err := srv.deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day,
			store.HostRepoStats{TotalSizeBytes: &v, SnapshotCount: &c}, now); err != nil {
			t.Fatalf("seed %s: %v", day, err)
		}
	}

	body := srv.getHTML(t, "/hosts/"+hostID+"/repo/trend?range=30d")
	if !strings.Contains(body, `class="repo-trend-chart"`) {
		t.Errorf("expected repo-trend-chart SVG in fragment, missing")
	}
	if !strings.Contains(body, `id="repo-trend-chart"`) {
		t.Errorf("expected outer wrapper with id repo-trend-chart")
	}
}

func TestUIRepoTrend_InvalidRangeFallsBackTo30d(t *testing.T) {
	t.Parallel()
	srv := newTestServer(t)
	hostID := srv.seedHost(t, "h-trend2")

	body := srv.getHTML(t, "/hosts/"+hostID+"/repo/trend?range=banana")
	if !strings.Contains(body, `data-range="30d"`) {
		t.Errorf("expected data-range=30d on fallback, body: %s", body)
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/server/http -run TestUIRepoTrend -v Expected: FAIL — 404 (route not mounted).

  • Step 3: Implement the trend handler

Create internal/server/http/ui_repo_trend.go:

package http

import (
	"math"
	stdhttp "net/http"
	"time"

	"github.com/go-chi/chi/v5"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline"
)

// handleUIRepoTrend renders the trend chart fragment for the repo
// page. Returns the chart partial wrapped in
// <div id="repo-trend-chart"> so htmx can outerHTML-swap it.
//
//   GET /hosts/{id}/repo/trend?range=30d|90d|1y
func (s *Server) handleUIRepoTrend(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")

	rangeKey := r.URL.Query().Get("range")
	days := 30
	switch rangeKey {
	case "90d":
		days = 90
	case "1y":
		days = 365
	default:
		rangeKey = "30d"
	}

	since := time.Now().UTC().AddDate(0, 0, -days)
	pts, err := s.deps.Store.ListHostRepoStatsHistory(r.Context(), hostID, since)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	sizes := make([]float64, len(pts))
	counts := make([]float64, len(pts))
	dayList := make([]time.Time, len(pts))
	for i, p := range pts {
		dayList[i] = p.Day
		if p.TotalSizeBytes == nil {
			sizes[i] = math.NaN()
		} else {
			sizes[i] = float64(*p.TotalSizeBytes)
		}
		if p.SnapshotCount == nil {
			counts[i] = math.NaN()
		} else {
			counts[i] = float64(*p.SnapshotCount)
		}
	}

	chartSVG := sparkline.RenderChart([]sparkline.Series{
		{Name: "size", Stroke: "#3b82f6", Axis: sparkline.AxisLeft, Format: sparkline.FormatBytes, Points: sizes},
		{Name: "snapshots", Stroke: "#f59e0b", Axis: sparkline.AxisRight, Format: sparkline.FormatCount, Points: counts},
	}, dayList, sparkline.ChartOpts{Width: 600, Height: 220})

	ui.Render(w, r, "repo_size_chart", map[string]any{
		"HostID":   hostID,
		"Range":    rangeKey,
		"ChartSVG": chartSVG,
	})
}

If ui.Render is not the project's render helper, replace with the actual rendering call used elsewhere — check internal/server/http/ui_repo.go to see the canonical pattern (likely something like s.render(w, r, "...", data)). Adopt whatever is already used.

  • Step 4: Mount the route

In internal/server/http/server.go, in the auth-gated UI block (near the existing r.Get("/hosts/{id}/repo", s.handleUIHostRepo) on line 198), add:

r.Get("/hosts/{id}/repo/trend", s.handleUIRepoTrend)
  • Step 5: Create the chart partial

Create web/templates/partials/repo_size_chart.html:

{{define "repo_size_chart"}}
<div id="repo-trend-chart" data-range="{{.Range}}">
  <div class="flex items-center gap-2 mb-2">
    <span class="text-xs text-ink-mid">Range:</span>
    {{range $r := slice "30d" "90d" "1y"}}
      <a class="btn btn-ghost-xs {{if eq $r $.Range}}is-active{{end}}"
         hx-get="/hosts/{{$.HostID}}/repo/trend?range={{$r}}"
         hx-target="#repo-trend-chart"
         hx-swap="outerHTML">{{$r}}</a>
    {{end}}
  </div>
  <div class="text-ink">{{.ChartSVG}}</div>
  <div class="flex gap-4 mt-2 text-xs text-ink-mid">
    <span><span class="inline-block w-3 h-[2px] align-middle" style="background:#3b82f6"></span> repo size</span>
    <span><span class="inline-block w-3 h-[2px] align-middle" style="background:#f59e0b"></span> snapshot count</span>
  </div>
</div>
{{end}}

If the project's templates don't use slice, hard-code three anchors. Confirm template-func availability before keeping the loop.

  • Step 6: Insert the Trend panel on the repo page

Open web/templates/pages/host_repo.html. Find the existing summary panel — likely the first <section> or <div class="panel"> that renders StatsView. Immediately after it (and before the maintenance panel), insert:

<section class="panel">
  <h3 class="panel-title">Trend</h3>
  {{template "repo_size_chart" .Trend}}
</section>
  • Step 7: Pre-render the initial chart in loadHostRepoPage

In internal/server/http/ui_repo.go, extend hostRepoPage:

type hostRepoPage struct {
	hostChromeData
	// ... existing fields ...
	Trend repoTrendView
}

type repoTrendView struct {
	HostID   string
	Range    string
	ChartSVG template.HTML
}

Add the html/template import if missing.

In loadHostRepoPage (or wherever the page struct is populated for handleUIHostRepo), build the initial 30d chart by calling the same helper as the trend endpoint. Factor the build out into a small private helper:

func (s *Server) buildRepoTrendView(ctx context.Context, hostID, rangeKey string) repoTrendView {
	days := 30
	switch rangeKey {
	case "90d":
		days = 90
	case "1y":
		days = 365
	default:
		rangeKey = "30d"
	}
	since := time.Now().UTC().AddDate(0, 0, -days)
	pts, err := s.deps.Store.ListHostRepoStatsHistory(ctx, hostID, since)
	if err != nil {
		slog.Warn("ui repo trend: list history", "host_id", hostID, "err", err)
	}
	sizes := make([]float64, len(pts))
	counts := make([]float64, len(pts))
	dayList := make([]time.Time, len(pts))
	for i, p := range pts {
		dayList[i] = p.Day
		if p.TotalSizeBytes == nil {
			sizes[i] = math.NaN()
		} else {
			sizes[i] = float64(*p.TotalSizeBytes)
		}
		if p.SnapshotCount == nil {
			counts[i] = math.NaN()
		} else {
			counts[i] = float64(*p.SnapshotCount)
		}
	}
	chartSVG := sparkline.RenderChart([]sparkline.Series{
		{Name: "size", Stroke: "#3b82f6", Axis: sparkline.AxisLeft, Format: sparkline.FormatBytes, Points: sizes},
		{Name: "snapshots", Stroke: "#f59e0b", Axis: sparkline.AxisRight, Format: sparkline.FormatCount, Points: counts},
	}, dayList, sparkline.ChartOpts{Width: 600, Height: 220})
	return repoTrendView{HostID: hostID, Range: rangeKey, ChartSVG: chartSVG}
}

Replace the duplicate body inside handleUIRepoTrend with a call to this helper too — DRY.

In loadHostRepoPage, set:

page.Trend = s.buildRepoTrendView(r.Context(), hostID, "30d")
  • Step 8: Run tests to verify they pass

Run: go test ./internal/server/http -run TestUIRepoTrend -v Expected: PASS for both subtests.

  • Step 9: Run full http package + vet

Run: go test ./internal/server/http/... && go vet ./... Expected: PASS, no vet output.

  • Step 10: Commit
git add internal/server/http/ui_repo_trend.go \
        internal/server/http/ui_repo_trend_test.go \
        internal/server/http/ui_repo.go \
        internal/server/http/server.go \
        web/templates/partials/repo_size_chart.html \
        web/templates/pages/host_repo.html
git commit -m "ui: trend panel + range selector on host repo page"

Task 8: Build, restage smoke env, smoke check

Files:

  • Modify: tasks.md — flip P6-03 to [x] with as-shipped block

  • Step 1: Build all binaries

Run: make build Expected: server + agent binaries land in bin/.

  • Step 2: Restage smoke env

Per CLAUDE.md, run the full restage block (server-side change only, no agent or unit-file edit, so the agent restage is optional but harmless):

cp bin/restic-manager-agent \
   $HOME/smoke/data/agent-binaries/restic-manager-agent-linux-amd64
cp deploy/install/install.sh \
   $HOME/smoke/data/install/install.sh
cp deploy/install/install.ps1 \
   $HOME/smoke/data/install/install.ps1
cp deploy/install/restic-manager-agent.service \
   $HOME/smoke/data/install/restic-manager-agent.service

pkill -f restic-manager-server
RM_LISTEN=:8080 RM_DATA_DIR=$HOME/smoke/data \
RM_BASE_URL=http://127.0.0.1:8080 \
RM_SECRET_KEY_FILE=$HOME/smoke/data/secret.key \
RM_COOKIE_SECURE=false \
./bin/restic-manager-server >> $HOME/smoke/server.log 2>&1 &
  • Step 3: Smoke verify the dashboard

In a logged-in browser session at http://127.0.0.1:8080/:

  • Confirm a new 30d trend column appears between Repo size and Snapshots.

  • Hosts with no history rows show the em-dash placeholder.

  • Trigger a backup on a connected host. After it lands and the dashboard's 5s live-poll fires, that host's sparkline cell now shows a single point — placeholder still visible (need ≥2 points). Run a second backup the next day (or insert a manual history row via SQLite if testing same-day) to confirm the polyline draws.

  • Step 4: Smoke verify the repo page

Navigate to /hosts/<id>/repo:

  • Confirm the new "Trend" panel renders between summary and maintenance.

  • Click each of the 30d | 90d | 1y pills — the chart fragment swaps in place without a full reload (network tab will show a single htmx GET per click).

  • Hover a data dot — the native tooltip shows <date · series: value>.

  • Step 5: Update tasks.md

In tasks.md, change P6-03 line to [x] and add an as-shipped note immediately after the bullet, mirroring the P6-01/P6-02 block above it. Suggested text:

> **As shipped (2026-05-07, branch `<this-branch>`):**
> Spec `docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md`,
> plan `docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md`.
> Migration 0023 introduces `host_repo_stats_history`
> (one row per host per UTC day, last-write-wins per column).
> WS handler in `internal/server/ws/handler.go` writes a history
> row alongside the existing `UpsertHostRepoStats` call. New
> `internal/web/sparkline` package emits inline SVG (sparkline +
> two-axis chart). Dashboard host row gains a 30d sparkline cell
> next to repo size; host repo page gains a Trend panel with
> `30d | 90d | 1y` server-rendered range selector. No new
> dependencies, no client JS, no agent change.
  • Step 6: Commit and verify clean state
git add tasks.md
git commit -m "tasks: P6-03 done, repo size trend graphs"
git status

Expected: working tree clean.


Self-review notes (resolved inline)

  • All spec sections (schema, write path, read path, rendering, placement, endpoints, testing) map to a task.
  • No placeholders.
  • Type names consistent across tasks: RepoStatsHistoryPoint, Series, Axis, Format, ChartOpts, dashboardHostRow, repoTrendView.
  • The DRY helper buildRepoTrendView is reused by both the page-load handler and the htmx fragment endpoint.
  • COALESCE behaviour in the upsert is exercised by Task 2's first test (the prune-only third write must preserve prior size + count).