diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go
index 6d6f0f8..8c5f433 100644
--- a/internal/web/sparkline/sparkline.go
+++ b/internal/web/sparkline/sparkline.go
@@ -9,6 +9,7 @@ import (
"html/template"
"math"
"strings"
+ "time"
)
// RenderSparkline returns an inline SVG `)
return template.HTML(b.String())
}
+
+// Axis selects which y-axis a Series is normalised against.
+type Axis int
+
+const (
+ // AxisLeft maps the series to the left y-axis.
+ AxisLeft Axis = iota
+ // AxisRight maps the series to the right y-axis.
+ AxisRight
+)
+
+// Format selects how a Series' values appear in hover tooltips.
+type Format int
+
+const (
+ // FormatBytes formats a value as a human-readable byte size.
+ FormatBytes Format = iota
+ // FormatCount formats a value as an integer count.
+ FormatCount
+)
+
+// Series is one labelled trace on a chart.
+type Series struct {
+ Name string
+ Points []float64 // NaN breaks the polyline
+ Stroke string // hex colour
+ 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 `)
+ return template.HTML(b.String())
+}
+
+func writeAxisLabels(b *strings.Builder, x, padT, innerH int, min, max float64, f Format, anchor string) {
+ const bands = 4
+ for i := 0; i <= bands; i++ {
+ y := padT + innerH*i/bands
+ v := max - (max-min)*float64(i)/float64(bands)
+ fmt.Fprintf(b,
+ `%s`,
+ x, y+3, anchor, formatValue(v, f))
+ }
+}
+
+func formatValue(v float64, f Format) string {
+ switch f {
+ case FormatBytes:
+ return humanBytes(v)
+ default:
+ return fmt.Sprintf("%.0f", v)
+ }
+}
+
+func humanBytes(v float64) string {
+ const k = 1024.0
+ units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
+ i := 0
+ for v >= k && i < len(units)-1 {
+ v /= k
+ i++
+ }
+ if v >= 100 {
+ return fmt.Sprintf("%.0f %s", v, units[i])
+ }
+ return fmt.Sprintf("%.1f %s", v, units[i])
+}
diff --git a/internal/web/sparkline/sparkline_test.go b/internal/web/sparkline/sparkline_test.go
index f0af51b..9320d1f 100644
--- a/internal/web/sparkline/sparkline_test.go
+++ b/internal/web/sparkline/sparkline_test.go
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
)
func loadGolden(t *testing.T, name string) string {
@@ -39,3 +40,35 @@ func TestSparkline_ThreePoints(t *testing.T) {
t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
}
}
+
+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)
+ }
+}
diff --git a/internal/web/sparkline/testdata/chart_empty.svg b/internal/web/sparkline/testdata/chart_empty.svg
new file mode 100644
index 0000000..52f8d64
--- /dev/null
+++ b/internal/web/sparkline/testdata/chart_empty.svg
@@ -0,0 +1 @@
+no data yet
diff --git a/internal/web/sparkline/testdata/chart_two_series.svg b/internal/web/sparkline/testdata/chart_two_series.svg
new file mode 100644
index 0000000..f84855c
--- /dev/null
+++ b/internal/web/sparkline/testdata/chart_two_series.svg
@@ -0,0 +1 @@
+2026-05-01 · size: 1.0 KiB2026-05-02 · size: 2.0 KiB2026-05-03 · size: 4.0 KiB2026-05-04 · size: 8.0 KiB2026-05-01 · snapshots: 12026-05-02 · snapshots: 22026-05-03 · snapshots: 32026-05-04 · snapshots: 48.0 KiB6.2 KiB4.5 KiB2.8 KiB1.0 KiB43221May 1May 3May 4