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

1557 lines
45 KiB
Markdown

# P6-03 Repo size trend — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Per-host repo growth trend — sparkline on every dashboard
row plus a `30d|90d|1y` chart with snapshot-count overlay on the
host repo page.
**Architecture:** New `host_repo_stats_history` table holds one
row per `(host_id, UTC date)`, written from the existing
`MsgRepoStats` WebSocket handler alongside the current-stats
upsert. A pure-Go `internal/web/sparkline` package emits inline
SVG; the dashboard handler attaches a per-row sparkline as
`template.HTML` and the repo page renders the chart in a new
"Trend" panel. Range switching is an htmx server fragment.
**Tech Stack:** Go (`net/http` + chi), SQLite (modernc), Go
`html/template`, HTMX, server-rendered inline SVG, no client
library.
**Spec:** `docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md`
---
## File Structure
**Create:**
- `internal/store/migrations/0023_host_repo_stats_history.sql` — schema
- `internal/store/host_repo_stats_history.go` — store helpers
- `internal/store/host_repo_stats_history_test.go` — store tests
- `internal/web/sparkline/sparkline.go` — SVG renderer
- `internal/web/sparkline/sparkline_test.go` — golden tests
- `internal/web/sparkline/testdata/*.svg` — golden files
- `web/templates/partials/repo_size_sparkline.html` — dashboard cell
- `web/templates/partials/repo_size_chart.html` — trend panel inner
- `internal/server/http/ui_repo_trend.go``GET /hosts/{id}/repo/trend`
**Modify:**
- `internal/server/ws/handler.go` — write to history table after
the existing upsert (around line 337)
- `internal/server/http/ui_handlers.go` — attach per-row
`RepoSparklineSVG template.HTML` to `dashboardHostRow`
- `internal/server/http/ui_repo.go` — load 30d series + initial
chart SVG into `hostRepoPage`
- `internal/server/http/server.go` — mount the new trend route
- `web/templates/partials/host_row.html` — sparkline cell
- `web/templates/pages/host_repo.html` — trend panel
- `web/templates/pages/dashboard.html` — column header (if hosts
table is grid-based)
- `tasks.md` — flip P6-03 to `[x]` with as-shipped block
---
## Task 1: Migration — `host_repo_stats_history` table
**Files:**
- Create: `internal/store/migrations/0023_host_repo_stats_history.sql`
- Test: `internal/store/migrate_test.go` (existing — runs every migration end-to-end)
- [ ] **Step 1: Write the migration**
Create `internal/store/migrations/0023_host_repo_stats_history.sql`:
```sql
-- 0023_host_repo_stats_history.sql
--
-- Daily time-series of per-host repo metrics, used by the P6-03
-- trend sparkline + chart. One row per (host_id, UTC date),
-- last-write-wins per column. Population is best-effort and
-- piggy-backs on the existing repo.stats WS message — nothing
-- else writes here.
CREATE TABLE host_repo_stats_history (
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
day TEXT NOT NULL, -- 'YYYY-MM-DD' UTC
total_size_bytes INTEGER, -- nullable: partial patches preserve existing value
snapshot_count INTEGER, -- nullable
recorded_at TEXT NOT NULL, -- RFC3339Nano of latest write
PRIMARY KEY (host_id, day)
);
CREATE INDEX host_repo_stats_history_host_day
ON host_repo_stats_history(host_id, day DESC);
```
- [ ] **Step 2: Run the existing migration test**
Run: `go test ./internal/store -run TestMigrate -v`
Expected: PASS — the migration runner walks every `.sql` in
`migrations/` in order; a syntactically valid file is enough at
this stage.
- [ ] **Step 3: Run vet**
Run: `go vet ./...`
Expected: no output.
- [ ] **Step 4: Commit**
```bash
git add internal/store/migrations/0023_host_repo_stats_history.sql
git commit -m "store: migration 0023 host_repo_stats_history"
```
---
## Task 2: Store helpers — Upsert + List
**Files:**
- Create: `internal/store/host_repo_stats_history.go`
- Test: `internal/store/host_repo_stats_history_test.go`
- [ ] **Step 1: Write the failing test**
Create `internal/store/host_repo_stats_history_test.go`:
```go
package store
import (
"context"
"testing"
"time"
)
func TestHostRepoStatsHistory_PartialUpsertPreservesPriorValues(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const hostID = "h-history-1"
seedHost(t, s, hostID)
day := "2026-05-07"
now := time.Now().UTC()
// 1. First write of the day: total_size only.
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
HostRepoStats{TotalSizeBytes: int64ptr(100)}, now); err != nil {
t.Fatalf("upsert 1: %v", err)
}
// 2. Second write: snapshot_count only — total_size MUST survive.
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
HostRepoStats{SnapshotCount: int64ptr(7)}, now.Add(time.Minute)); err != nil {
t.Fatalf("upsert 2: %v", err)
}
// 3. Third write: a prune-only patch (no size, no count). Both
// prior values must survive.
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
HostRepoStats{}, now.Add(2*time.Minute)); err != nil {
t.Fatalf("upsert 3: %v", err)
}
pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(pts) != 1 {
t.Fatalf("want 1 point, got %d", len(pts))
}
p := pts[0]
if p.TotalSizeBytes == nil || *p.TotalSizeBytes != 100 {
t.Errorf("TotalSizeBytes: want 100, got %v", p.TotalSizeBytes)
}
if p.SnapshotCount == nil || *p.SnapshotCount != 7 {
t.Errorf("SnapshotCount: want 7, got %v", p.SnapshotCount)
}
}
func TestHostRepoStatsHistory_OrderingAndSinceFilter(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const hostID = "h-history-2"
seedHost(t, s, hostID)
now := time.Now().UTC()
for i, day := range []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} {
v := int64(100 + i*10)
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day,
HostRepoStats{TotalSizeBytes: &v}, now); err != nil {
t.Fatalf("upsert %s: %v", day, err)
}
}
all, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
if err != nil {
t.Fatalf("list all: %v", err)
}
if len(all) != 4 {
t.Fatalf("want 4 points, got %d", len(all))
}
// Ordering ascending by day.
wantDays := []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"}
for i, p := range all {
got := p.Day.Format("2006-01-02")
if got != wantDays[i] {
t.Errorf("point %d: want day %s, got %s", i, wantDays[i], got)
}
}
since, _ := time.Parse("2006-01-02", "2026-05-03")
recent, err := s.ListHostRepoStatsHistory(ctx, hostID, since)
if err != nil {
t.Fatalf("list since: %v", err)
}
if len(recent) != 2 {
t.Fatalf("since 2026-05-03: want 2 points, got %d", len(recent))
}
}
func TestHostRepoStatsHistory_CascadeOnHostDelete(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const hostID = "h-history-3"
seedHost(t, s, hostID)
if err := s.UpsertHostRepoStatsHistory(ctx, hostID, "2026-05-07",
HostRepoStats{TotalSizeBytes: int64ptr(42)}, time.Now().UTC()); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := s.DeleteHost(ctx, hostID); err != nil {
t.Fatalf("delete host: %v", err)
}
pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
if err != nil {
t.Fatalf("list after delete: %v", err)
}
if len(pts) != 0 {
t.Fatalf("want 0 points after host delete, got %d", len(pts))
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/store -run TestHostRepoStatsHistory -v`
Expected: FAIL — `UpsertHostRepoStatsHistory` and
`ListHostRepoStatsHistory` undefined.
- [ ] **Step 3: Implement the store helpers**
Create `internal/store/host_repo_stats_history.go`:
```go
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// RepoStatsHistoryPoint is one (day, host) point for the trend chart.
// Both metric pointers may be nil — a row exists as soon as either
// metric was reported on that day.
type RepoStatsHistoryPoint struct {
Day time.Time // 00:00:00 UTC
TotalSizeBytes *int64
SnapshotCount *int64
}
// UpsertHostRepoStatsHistory records the metrics carried by a
// repo.stats patch into the daily history table. Only the non-nil
// fields of patch.TotalSizeBytes / patch.SnapshotCount are written;
// existing values in the row are preserved via COALESCE so a
// prune-only or check-only patch does not null out a backup-time
// size we already captured earlier the same day.
func (s *Store) UpsertHostRepoStatsHistory(
ctx context.Context, hostID, day string, patch HostRepoStats, recordedAt time.Time,
) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO host_repo_stats_history
(host_id, day, total_size_bytes, snapshot_count, recorded_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(host_id, day) DO UPDATE SET
total_size_bytes = COALESCE(excluded.total_size_bytes, host_repo_stats_history.total_size_bytes),
snapshot_count = COALESCE(excluded.snapshot_count, host_repo_stats_history.snapshot_count),
recorded_at = excluded.recorded_at`,
hostID, day,
nullableInt64(patch.TotalSizeBytes),
nullableInt64(patch.SnapshotCount),
recordedAt.UTC().Format(time.RFC3339Nano),
)
if err != nil {
return fmt.Errorf("store: upsert host_repo_stats_history: %w", err)
}
return nil
}
// ListHostRepoStatsHistory returns all points for hostID with day
// >= since (UTC), ordered ascending. Pass time.Time{} to fetch the
// full history.
func (s *Store) ListHostRepoStatsHistory(
ctx context.Context, hostID string, since time.Time,
) ([]RepoStatsHistoryPoint, error) {
sinceStr := ""
if !since.IsZero() {
sinceStr = since.UTC().Format("2006-01-02")
}
rows, err := s.db.QueryContext(ctx, `
SELECT day, total_size_bytes, snapshot_count
FROM host_repo_stats_history
WHERE host_id = ? AND day >= ?
ORDER BY day ASC`,
hostID, sinceStr)
if err != nil {
return nil, fmt.Errorf("store: list host_repo_stats_history: %w", err)
}
defer func() { _ = rows.Close() }()
var out []RepoStatsHistoryPoint
for rows.Next() {
var (
dayStr string
total sql.NullInt64
snapCnt sql.NullInt64
)
if err := rows.Scan(&dayStr, &total, &snapCnt); err != nil {
return nil, fmt.Errorf("store: scan history row: %w", err)
}
d, perr := time.Parse("2006-01-02", dayStr)
if perr != nil {
return nil, fmt.Errorf("store: parse history day %q: %w", dayStr, perr)
}
p := RepoStatsHistoryPoint{Day: d}
if total.Valid {
v := total.Int64
p.TotalSizeBytes = &v
}
if snapCnt.Valid {
v := snapCnt.Int64
p.SnapshotCount = &v
}
out = append(out, p)
}
if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("store: iterate history rows: %w", err)
}
return out, nil
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/store -run TestHostRepoStatsHistory -v`
Expected: PASS for all three subtests.
- [ ] **Step 5: Run full store package + vet**
Run: `go test ./internal/store/... && go vet ./...`
Expected: PASS, no vet output.
- [ ] **Step 6: Commit**
```bash
git add internal/store/host_repo_stats_history.go \
internal/store/host_repo_stats_history_test.go
git commit -m "store: history table helpers (upsert/list, COALESCE preserves prior values)"
```
---
## Task 3: WS handler writes history alongside current stats
**Files:**
- Modify: `internal/server/ws/handler.go:337` (after the existing
`UpsertHostRepoStats` call)
- Test: `internal/server/ws/handler_test.go`
- [ ] **Step 1: Write the failing test**
Append to `internal/server/ws/handler_test.go` (use the existing
test harness in that file — `runHandler`, `seededDeps`, etc.;
read the file before adding to confirm helper names):
```go
func TestHandlerRepoStats_WritesHistoryRow(t *testing.T) {
t.Parallel()
deps, hostID := seededDeps(t)
ctx := context.Background()
total := int64(12345)
count := int64(7)
env := api.Envelope{
Type: api.MsgRepoStats,
HostID: hostID,
AgentTS: time.Now().UTC(),
}
payload := api.RepoStatsPayload{
TotalSizeBytes: &total,
SnapshotCount: &count,
}
envWithPayload := mustEnvWithPayload(t, env, payload) // existing helper
if err := dispatchEnvelope(ctx, deps, hostID, envWithPayload); err != nil {
t.Fatalf("dispatch: %v", err)
}
day := time.Now().UTC().Format("2006-01-02")
pts, err := deps.Store.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(pts) != 1 {
t.Fatalf("want 1 history row, got %d", len(pts))
}
if got := pts[0].Day.Format("2006-01-02"); got != day {
t.Errorf("day: want %s, got %s", day, got)
}
if pts[0].TotalSizeBytes == nil || *pts[0].TotalSizeBytes != 12345 {
t.Errorf("TotalSizeBytes: want 12345, got %v", pts[0].TotalSizeBytes)
}
if pts[0].SnapshotCount == nil || *pts[0].SnapshotCount != 7 {
t.Errorf("SnapshotCount: want 7, got %v", pts[0].SnapshotCount)
}
}
```
If `dispatchEnvelope` / `seededDeps` / `mustEnvWithPayload` are
named differently in the existing test file, adopt the existing
naming — the assertions above are what matters.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/server/ws -run TestHandlerRepoStats_WritesHistoryRow -v`
Expected: FAIL — `len(pts) == 0`.
- [ ] **Step 3: Wire the history write**
In `internal/server/ws/handler.go`, in the `case api.MsgRepoStats:`
block (around line 319), after the existing `UpsertHostRepoStats`
call (line 337), add:
```go
day := time.Now().UTC().Format("2006-01-02")
if err := deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day, patch, time.Now().UTC()); err != nil {
slog.Warn("ws: upsert host repo stats history", "host_id", hostID, "err", err)
}
```
Place it after the existing `else { slog.Info(...) }` block — a
history-write failure must not block the main upsert log line.
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/server/ws -run TestHandlerRepoStats -v`
Expected: PASS, including any pre-existing repo-stats tests.
- [ ] **Step 5: Run vet**
Run: `go vet ./...`
Expected: no output.
- [ ] **Step 6: Commit**
```bash
git add internal/server/ws/handler.go internal/server/ws/handler_test.go
git commit -m "ws: record daily repo stats history alongside current upsert"
```
---
## Task 4: Sparkline package — empty + single + multi-point
**Files:**
- Create: `internal/web/sparkline/sparkline.go`
- Create: `internal/web/sparkline/sparkline_test.go`
- Create: `internal/web/sparkline/testdata/empty.svg`
- Create: `internal/web/sparkline/testdata/single_point.svg`
- Create: `internal/web/sparkline/testdata/three_points.svg`
This task introduces the renderer with one of three behaviours
exercised. The chart-rendering function is added in Task 5.
- [ ] **Step 1: Write the failing test**
Create `internal/web/sparkline/sparkline_test.go`:
```go
package sparkline
import (
"os"
"path/filepath"
"strings"
"testing"
)
func loadGolden(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read golden %s: %v", name, err)
}
return strings.TrimRight(string(b), "\n")
}
func TestSparkline_Empty(t *testing.T) {
got := strings.TrimRight(string(RenderSparkline(nil, 80, 20)), "\n")
want := loadGolden(t, "empty.svg")
if got != want {
t.Errorf("empty sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
func TestSparkline_SinglePoint(t *testing.T) {
got := strings.TrimRight(string(RenderSparkline([]float64{100}, 80, 20)), "\n")
want := loadGolden(t, "single_point.svg")
if got != want {
t.Errorf("single_point sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
func TestSparkline_ThreePoints(t *testing.T) {
got := strings.TrimRight(string(RenderSparkline([]float64{10, 30, 20}, 80, 20)), "\n")
want := loadGolden(t, "three_points.svg")
if got != want {
t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/web/sparkline -v`
Expected: FAIL — package does not exist yet.
- [ ] **Step 3: Implement the sparkline renderer**
Create `internal/web/sparkline/sparkline.go`:
```go
// Package sparkline renders inline SVG sparklines and trend
// charts for the dashboard and host repo page. All output is
// pure server-rendered SVG with no JavaScript, no external
// stylesheet, and no client library.
package sparkline
import (
"fmt"
"html/template"
"math"
"strings"
"time"
)
// RenderSparkline returns an inline SVG <svg> element of the
// given size containing a single polyline normalised across the
// full y-range of points. NaN entries break the polyline. With
// fewer than two real points the SVG still renders but contains
// only a faint baseline + an em-dash placeholder.
func RenderSparkline(points []float64, width, height int) template.HTML {
const pad = 2
w := width
h := height
innerW := w - 2*pad
innerH := h - 2*pad
var real []float64
for _, p := range points {
if !math.IsNaN(p) {
real = append(real, p)
}
}
var b strings.Builder
fmt.Fprintf(&b,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-sparkline" role="img" aria-label="repo size trend">`,
w, h)
if len(real) < 2 {
fmt.Fprintf(&b,
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/>`,
pad, h/2, w-pad, h/2)
fmt.Fprintf(&b,
`<text x="%d" y="%d" text-anchor="middle" font-size="%d" fill="currentColor" fill-opacity="0.4">—</text>`,
w/2, h/2+4, h-6)
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
min, max := real[0], real[0]
for _, v := range real {
if v < min {
min = v
}
if v > max {
max = v
}
}
span := max - min
if span == 0 {
span = 1 // avoid divide-by-zero; line will be flat at mid-height
}
// Build the polyline string. Skip NaN entries by ending the
// current segment and starting a new <polyline>.
stepX := 0.0
if len(points) > 1 {
stepX = float64(innerW) / float64(len(points)-1)
}
var seg strings.Builder
flush := func() {
if seg.Len() == 0 {
return
}
fmt.Fprintf(&b,
`<polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
strings.TrimSpace(seg.String()))
seg.Reset()
}
for i, v := range points {
if math.IsNaN(v) {
flush()
continue
}
x := float64(pad) + stepX*float64(i)
y := float64(pad) + float64(innerH) - (v-min)/span*float64(innerH)
fmt.Fprintf(&seg, "%.2f,%.2f ", x, y)
}
flush()
// Title element for native hover (current → first delta).
cur := real[len(real)-1]
first := real[0]
delta := cur - first
sign := "+"
if delta < 0 {
sign = "-"
delta = -delta
}
fmt.Fprintf(&b, `<title>current %.0f, %s%.0f over window</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**
```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 <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**
```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, `<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`:
```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
<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**
```bash
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`:
```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`:
```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:
```go
r.Get("/hosts/{id}/repo/trend", s.handleUIRepoTrend)
```
- [ ] **Step 5: Create the chart partial**
Create `web/templates/partials/repo_size_chart.html`:
```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:
```html
<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`:
```go
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:
```go
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:
```go
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**
```bash
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):
```bash
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:
```markdown
> **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**
```bash
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).