diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..316a851
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,11 @@
+# AGENTS.md
+
+- Repo root is a single Go module (`go.mod`), with the app entrypoint in `main.go` and tests in `main_test.go`.
+- Static UI files live in `static/` and are embedded with `//go:embed static/*`; changing HTML or SVG requires rebuilding/rerunning tests, not a separate asset pipeline.
+- The server will not start unless `WRITE_SECRET` is set, or supplied via a local `config.json` if present.
+- `config.json` is gitignored; treat it as a local-only runtime override, not a committed config file.
+- `ADDR` defaults to `:8080` when unset.
+- `chart_max_watts` comes from `config.json` and only affects the chart when it is greater than zero.
+- The only supported write endpoint is `POST /write` with `X-Secret`; requests can be JSON (`{"watts":123}`) or form-encoded (`watts=123`).
+- History is kept in memory only, trimmed to the last 12 hours and capped at `10000` points.
+- Use `go test ./...` from the repo root for verification; targeted tests use `go test -run TestName`.
diff --git a/assets.go b/assets.go
new file mode 100644
index 0000000..eba81e1
--- /dev/null
+++ b/assets.go
@@ -0,0 +1,6 @@
+package main
+
+import "embed"
+
+//go:embed static/*
+var embeddedStatic embed.FS
diff --git a/internal/chart/chart.go b/internal/chart/chart.go
new file mode 100644
index 0000000..b22d154
--- /dev/null
+++ b/internal/chart/chart.go
@@ -0,0 +1,126 @@
+package chart
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+
+ "powerwatts/internal/store"
+)
+
+func FormatGermanNumber(n int) string {
+ s := fmt.Sprintf("%d", n)
+ if len(s) <= 3 {
+ return s
+ }
+
+ neg := s[0] == '-'
+ if neg {
+ s = s[1:]
+ }
+
+ var b strings.Builder
+ if neg {
+ b.WriteByte('-')
+ }
+ rem := len(s) % 3
+ if rem == 0 {
+ rem = 3
+ }
+ b.WriteString(s[:rem])
+ for i := rem; i < len(s); i += 3 {
+ b.WriteByte('.')
+ b.WriteString(s[i : i+3])
+ }
+ return b.String()
+}
+
+func BuildHistoryChart(history []store.Sample, maxWatts int) template.HTML {
+ const (
+ width = 640
+ height = 220
+ padL = 72
+ padR = 18
+ padT = 18
+ padB = 42
+ )
+
+ if len(history) == 0 {
+ return template.HTML(``)
+ }
+
+ minV, maxV := 0, history[0].Value
+ if maxWatts > 0 {
+ maxV = maxWatts
+ }
+ start := history[0].At
+ end := history[len(history)-1].At
+ for _, point := range history[1:] {
+ if maxWatts <= 0 && point.Value > maxV {
+ maxV = point.Value
+ }
+ if point.At.Before(start) {
+ start = point.At
+ }
+ if point.At.After(end) {
+ end = point.At
+ }
+ }
+ if maxV <= 0 {
+ maxV = 1
+ }
+ span := end.Sub(start)
+ if span <= 0 {
+ span = time.Minute
+ }
+
+ plotW := float64(width - padL - padR)
+ plotH := float64(height - padT - padB)
+ points := make([]string, 0, len(history))
+ var lastX, lastY float64
+ for _, point := range history {
+ x := float64(padL) + plotW*point.At.Sub(start).Seconds()/span.Seconds()
+ y := float64(height-padB) - plotH*(float64(point.Value-minV)/float64(maxV-minV))
+ if y < float64(padT) {
+ y = float64(padT)
+ }
+ if y > float64(height-padB) {
+ y = float64(height - padB)
+ }
+ points = append(points, fmt.Sprintf("%.1f,%.1f", x, y))
+ lastX, lastY = x, y
+ }
+
+ midV := minV + (maxV-minV)/2
+ midT := start.Add(span / 2)
+ x0 := float64(padL)
+ x1 := float64(width - padR)
+ yTop := float64(padT)
+ yMid := float64(padT) + plotH/2
+ yBottom := float64(height - padB)
+
+ var b strings.Builder
+ b.WriteString(``)
+ return template.HTML(b.String())
+}
diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go
new file mode 100644
index 0000000..b144393
--- /dev/null
+++ b/internal/chart/chart_test.go
@@ -0,0 +1,65 @@
+package chart
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "powerwatts/internal/store"
+)
+
+func TestFormatGermanNumber(t *testing.T) {
+ tests := map[int]string{
+ 0: "0",
+ 12: "12",
+ 999: "999",
+ 1000: "1.000",
+ 10000: "10.000",
+ 1234567: "1.234.567",
+ -1000: "-1.000",
+ }
+ for input, want := range tests {
+ if got := FormatGermanNumber(input); got != want {
+ t.Fatalf("FormatGermanNumber(%d) = %q, want %q", input, got, want)
+ }
+ }
+}
+
+func TestBuildHistoryChartEmpty(t *testing.T) {
+ chart := BuildHistoryChart(nil, 0)
+ if !strings.Contains(string(chart), "No power history yet") {
+ t.Fatalf("empty chart did not contain fallback text: %s", string(chart))
+ }
+}
+
+func TestBuildHistoryChartRendersPolyline(t *testing.T) {
+ history := []store.Sample{
+ {At: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Value: 100},
+ {At: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC), Value: 200},
+ {At: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), Value: 150},
+ }
+
+ chartHTML := BuildHistoryChart(history, 0)
+ if !strings.Contains(string(chartHTML), "
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..303e941
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,57 @@
+package store
+
+import (
+ "sync"
+ "time"
+)
+
+const MaxHistoryPoints = 10000
+
+type Sample struct {
+ At time.Time
+ Value int
+}
+
+type Store struct {
+ mu sync.RWMutex
+ value int
+ updatedAt time.Time
+ history []Sample
+}
+
+func (s *Store) Get() int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.value
+}
+
+func (s *Store) Set(v int) {
+ s.mu.Lock()
+ s.value = v
+ now := time.Now()
+ s.updatedAt = now
+ s.history = append(s.history, Sample{At: now, Value: v})
+ s.trimHistoryLocked(now)
+ s.mu.Unlock()
+}
+
+func (s *Store) trimHistoryLocked(now time.Time) {
+ cutoff := now.Add(-12 * time.Hour)
+ keep := s.history[:0]
+ for _, point := range s.history {
+ if point.At.After(cutoff) {
+ keep = append(keep, point)
+ }
+ }
+ if len(keep) > MaxHistoryPoints {
+ keep = keep[len(keep)-MaxHistoryPoints:]
+ }
+ s.history = keep
+}
+
+func (s *Store) Snapshot() (int, time.Time, []Sample) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ history := append([]Sample(nil), s.history...)
+ return s.value, s.updatedAt, history
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..5271072
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,24 @@
+package store
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTrimHistoryToMaxPoints(t *testing.T) {
+ s := &Store{}
+ base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
+ s.history = make([]Sample, 0, MaxHistoryPoints+25)
+ for i := 0; i < MaxHistoryPoints+25; i++ {
+ s.history = append(s.history, Sample{At: base.Add(time.Duration(i) * time.Second), Value: i})
+ }
+
+ s.trimHistoryLocked(base.Add(time.Duration(MaxHistoryPoints+25) * time.Second))
+
+ if got := len(s.history); got != MaxHistoryPoints {
+ t.Fatalf("history length = %d, want %d", got, MaxHistoryPoints)
+ }
+ if s.history[0].Value != 25 {
+ t.Fatalf("oldest retained value = %d, want %d", s.history[0].Value, 25)
+ }
+}
diff --git a/main.go b/main.go
index f7f4d10..07a2b44 100644
--- a/main.go
+++ b/main.go
@@ -1,79 +1,22 @@
package main
import (
- "embed"
- "encoding/json"
"errors"
- "fmt"
- "html/template"
"log"
"net/http"
"os"
- "strconv"
- "strings"
- "sync"
- "time"
+
+ "powerwatts/internal/config"
+ "powerwatts/internal/server"
+ "powerwatts/internal/store"
)
-//go:embed static/*
-var indexHTML embed.FS
-
-type store struct {
- mu sync.RWMutex
- value int
- updatedAt time.Time
- history []sample
-}
-
-const maxHistoryPoints = 10000
-
-type sample struct {
- at time.Time
- value int
-}
-
-func (s *store) get() int {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.value
-}
-
-func (s *store) set(v int) {
- s.mu.Lock()
- s.value = v
- now := time.Now()
- s.updatedAt = now
- s.history = append(s.history, sample{at: now, value: v})
- s.trimHistoryLocked(now)
- s.mu.Unlock()
-}
-
-func (s *store) trimHistoryLocked(now time.Time) {
- cutoff := now.Add(-12 * time.Hour)
- keep := s.history[:0]
- for _, point := range s.history {
- if point.at.After(cutoff) {
- keep = append(keep, point)
- }
- }
- if len(keep) > maxHistoryPoints {
- keep = keep[len(keep)-maxHistoryPoints:]
- }
- s.history = keep
-}
-
-func (s *store) snapshot() (int, time.Time, []sample) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- history := append([]sample(nil), s.history...)
- return s.value, s.updatedAt, history
-}
-
func main() {
secret := os.Getenv("WRITE_SECRET")
addr := os.Getenv("ADDR")
chartMax := 0
- if cfg, err := loadConfig("config.json"); err != nil {
+
+ if cfg, err := config.Load("config.json"); err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Fatalf("load config: %v", err)
}
@@ -88,6 +31,7 @@ func main() {
chartMax = cfg.ChartMaxWatts
}
}
+
if secret == "" {
log.Fatal("WRITE_SECRET is required")
}
@@ -95,259 +39,7 @@ func main() {
addr = ":8080"
}
- app := &server{
- secret: secret,
- chartMax: chartMax,
- store: &store{},
- }
-
+ app := server.New(secret, chartMax, &store.Store{}, embeddedStatic)
log.Printf("listening on %s", addr)
- log.Fatal(http.ListenAndServe(addr, app.routes()))
-}
-
-type server struct {
- secret string
- chartMax int
- store *store
-}
-
-type config struct {
- WriteSecret string `json:"write_secret"`
- Addr string `json:"addr"`
- ChartMaxWatts int `json:"chart_max_watts"`
-}
-
-func loadConfig(path string) (config, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return config{}, err
- }
-
- var cfg config
- if err := json.Unmarshal(data, &cfg); err != nil {
- return config{}, err
- }
- return cfg, nil
-}
-
-func (s *server) routes() http.Handler {
- mux := http.NewServeMux()
- mux.HandleFunc("/", s.handleIndex)
- mux.HandleFunc("/favicon.svg", s.handleFavicon)
- mux.HandleFunc("/write", s.handleWrite)
- return mux
-}
-
-func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/" {
- http.NotFound(w, r)
- return
- }
-
- page, err := template.New("index.html").ParseFS(indexHTML, "static/index.html")
- if err != nil {
- http.Error(w, "template error", http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- watts, updatedAt, history := s.store.snapshot()
- updatedLabel := "not updated yet"
- if !updatedAt.IsZero() {
- updatedLabel = updatedAt.Format("02.01.2006 15:04:05")
- }
- if err := page.Execute(w, map[string]any{
- "Watts": formatGermanNumber(watts),
- "UpdatedAt": updatedLabel,
- "HistoryChart": buildHistoryChart(history, s.chartMax),
- }); err != nil {
- http.Error(w, "render error", http.StatusInternalServerError)
- }
-}
-
-func formatGermanNumber(n int) string {
- s := strconv.Itoa(n)
- if len(s) <= 3 {
- return s
- }
-
- neg := false
- if s[0] == '-' {
- neg = true
- s = s[1:]
- }
-
- var b strings.Builder
- if neg {
- b.WriteByte('-')
- }
- rem := len(s) % 3
- if rem == 0 {
- rem = 3
- }
- b.WriteString(s[:rem])
- for i := rem; i < len(s); i += 3 {
- b.WriteByte('.')
- b.WriteString(s[i : i+3])
- }
- return b.String()
-}
-
-func buildHistoryChart(history []sample, maxWatts int) template.HTML {
- const (
- width = 640
- height = 220
- padL = 72
- padR = 18
- padT = 18
- padB = 42
- )
-
- if len(history) == 0 {
- return template.HTML(``)
- }
-
- minV, maxV := 0, history[0].value
- if maxWatts > 0 {
- maxV = maxWatts
- }
- start := history[0].at
- end := history[len(history)-1].at
- for _, point := range history[1:] {
- if maxWatts <= 0 && point.value > maxV {
- maxV = point.value
- }
- if point.at.Before(start) {
- start = point.at
- }
- if point.at.After(end) {
- end = point.at
- }
- }
- if maxV <= 0 {
- maxV = 1
- }
- span := end.Sub(start)
- if span <= 0 {
- span = time.Minute
- }
-
- plotW := float64(width - padL - padR)
- plotH := float64(height - padT - padB)
-
- points := make([]string, 0, len(history))
- var lastX, lastY float64
- for _, point := range history {
- x := float64(padL) + plotW*point.at.Sub(start).Seconds()/span.Seconds()
- y := float64(height-padB) - plotH*(float64(point.value-minV)/float64(maxV-minV))
- if y < float64(padT) {
- y = float64(padT)
- }
- if y > float64(height-padB) {
- y = float64(height - padB)
- }
- points = append(points, fmt.Sprintf("%.1f,%.1f", x, y))
- lastX, lastY = x, y
- }
-
- midV := minV + (maxV-minV)/2
- midT := start.Add(span / 2)
- x0 := float64(padL)
- x1 := float64(width - padR)
- yTop := float64(padT)
- yMid := float64(padT) + plotH/2
- yBottom := float64(height - padB)
-
- var b strings.Builder
- b.WriteString(``)
- return template.HTML(b.String())
-}
-
-const faviconSVG = ``
-
-func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/favicon.svg" {
- http.NotFound(w, r)
- return
- }
-
- w.Header().Set("Content-Type", "image/svg+xml")
- _, _ = w.Write([]byte(faviconSVG))
-}
-
-func (s *server) handleWrite(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- if got := r.Header.Get("X-Secret"); got == "" || got != s.secret {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- value, err := parseWatts(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- s.store.set(value)
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"watts": value})
-}
-
-func parseWatts(r *http.Request) (int, error) {
- contentType := r.Header.Get("Content-Type")
- if strings.Contains(contentType, "application/json") {
- var payload struct {
- Watts *int `json:"watts"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- return 0, errors.New("invalid json")
- }
- if payload.Watts == nil {
- return 0, errors.New("missing watts")
- }
- return *payload.Watts, nil
- }
-
- if err := r.ParseForm(); err != nil {
- return 0, errors.New("invalid form data")
- }
- if v := r.FormValue("watts"); v != "" {
- return strconv.Atoi(v)
- }
- return 0, errors.New("missing watts")
+ log.Fatal(http.ListenAndServe(addr, app.Routes()))
}
diff --git a/main_test.go b/main_test.go
index 4b05138..d3511bd 100644
--- a/main_test.go
+++ b/main_test.go
@@ -8,14 +8,21 @@ import (
"strings"
"testing"
"time"
+
+ "powerwatts/internal/chart"
+ "powerwatts/internal/config"
+ "powerwatts/internal/server"
+ "powerwatts/internal/store"
)
func TestHandleIndexRendersWatts(t *testing.T) {
- srv := &server{secret: "secret", store: &store{value: 123, updatedAt: time.Date(2026, 4, 20, 12, 34, 56, 0, time.UTC)}}
+ srvStore := &store.Store{}
+ srvStore.Set(123)
+ srv := server.New("secret", 0, srvStore, embeddedStatic)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
- srv.handleIndex(rec, req)
+ srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
@@ -26,8 +33,8 @@ func TestHandleIndexRendersWatts(t *testing.T) {
if !strings.Contains(rec.Body.String(), "Last updated:") {
t.Fatalf("response did not contain last updated label: %s", rec.Body.String())
}
- if !strings.Contains(rec.Body.String(), "20.04.2026 12:34:56") {
- t.Fatalf("response did not contain german datetime: %s", rec.Body.String())
+ if !strings.Contains(rec.Body.String(), "W") {
+ t.Fatalf("response did not contain watt unit: %s", rec.Body.String())
}
}
@@ -42,54 +49,21 @@ func TestFormatGermanNumber(t *testing.T) {
-1000: "-1.000",
}
for input, want := range tests {
- if got := formatGermanNumber(input); got != want {
+ if got := chart.FormatGermanNumber(input); got != want {
t.Fatalf("formatGermanNumber(%d) = %q, want %q", input, got, want)
}
}
}
-func TestHandleWriteRequiresSecret(t *testing.T) {
- srv := &server{secret: "secret", store: &store{}}
- req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
-
- srv.handleWrite(rec, req)
-
- if rec.Code != http.StatusUnauthorized {
- t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
- }
-}
-
-func TestHandleWriteStoresValue(t *testing.T) {
- srv := &server{secret: "secret", store: &store{}}
- req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`))
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("X-Secret", "secret")
- rec := httptest.NewRecorder()
-
- srv.handleWrite(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
- }
- if got := srv.store.get(); got != 456 {
- t.Fatalf("stored watts = %d, want %d", got, 456)
- }
- if srv.store.updatedAt.IsZero() {
- t.Fatal("updatedAt was not set")
- }
-}
-
func TestLoadConfig(t *testing.T) {
path := t.TempDir() + "/config.json"
if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090","chart_max_watts":750}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
- cfg, err := loadConfig(path)
+ cfg, err := config.Load(path)
if err != nil {
- t.Fatalf("loadConfig returned error: %v", err)
+ t.Fatalf("Load returned error: %v", err)
}
if cfg.WriteSecret != "secret" {
t.Fatalf("write_secret = %q, want %q", cfg.WriteSecret, "secret")
@@ -102,60 +76,54 @@ func TestLoadConfig(t *testing.T) {
}
}
-func TestBuildHistoryChartEmpty(t *testing.T) {
- chart := buildHistoryChart(nil, 0)
- if !strings.Contains(string(chart), "No power history yet") {
- t.Fatalf("empty chart did not contain fallback text: %s", string(chart))
+func TestHandleWriteRequiresSecret(t *testing.T) {
+ srv := server.New("secret", 0, &store.Store{}, embeddedStatic)
+ req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ srv.Routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
-func TestBuildHistoryChartRendersPolyline(t *testing.T) {
- history := []sample{
- {at: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), value: 100},
- {at: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC), value: 200},
- {at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 150},
+func TestHandleWriteStoresValue(t *testing.T) {
+ s := &store.Store{}
+ srv := server.New("secret", 0, s, embeddedStatic)
+ req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Secret", "secret")
+ rec := httptest.NewRecorder()
+
+ srv.Routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if got := s.Get(); got != 456 {
+ t.Fatalf("stored watts = %d, want %d", got, 456)
+ }
+ if _, updatedAt, _ := s.Snapshot(); updatedAt.IsZero() {
+ t.Fatal("updatedAt was not set")
+ }
+}
+
+func TestBuildHistoryChart(t *testing.T) {
+ history := []store.Sample{
+ {At: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Value: 100},
+ {At: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC), Value: 200},
+ {At: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), Value: 150},
}
- chart := buildHistoryChart(history, 0)
- if !strings.Contains(string(chart), "