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— schemainternal/store/host_repo_stats_history.go— store helpersinternal/store/host_repo_stats_history_test.go— store testsinternal/web/sparkline/sparkline.go— SVG rendererinternal/web/sparkline/sparkline_test.go— golden testsinternal/web/sparkline/testdata/*.svg— golden filesweb/templates/partials/repo_size_sparkline.html— dashboard cellweb/templates/partials/repo_size_chart.html— trend panel innerinternal/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-rowRepoSparklineSVG template.HTMLtodashboardHostRowinternal/server/http/ui_repo.go— load 30d series + initial chart SVG intohostRepoPageinternal/server/http/server.go— mount the new trend routeweb/templates/partials/host_row.html— sparkline cellweb/templates/pages/host_repo.html— trend panelweb/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 existingUpsertHostRepoStatscall) -
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
-updatefirst 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.svginternal/web/sparkline/testdata/single_point.svginternal/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— addRenderChart -
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— attachRepoSparklineSVG -
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 trendcolumn 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 | 1ypills — 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
buildRepoTrendViewis 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).