diff --git a/main.go b/main.go index 6330ba7..a0aa3ab 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "embed" "encoding/json" "errors" + "fmt" "html/template" "log" "net/http" @@ -18,9 +19,15 @@ import ( var indexHTML embed.FS type store struct { - mu sync.RWMutex - value int + mu sync.RWMutex + value int updatedAt time.Time + history []sample +} + +type sample struct { + at time.Time + value int } func (s *store) get() int { @@ -32,14 +39,25 @@ func (s *store) get() int { func (s *store) set(v int) { s.mu.Lock() s.value = v - s.updatedAt = time.Now() + now := time.Now() + s.updatedAt = now + s.history = append(s.history, sample{at: now, value: v}) + cutoff := now.Add(-12 * time.Hour) + keep := s.history[:0] + for _, point := range s.history { + if point.at.After(cutoff) { + keep = append(keep, point) + } + } + s.history = keep s.mu.Unlock() } -func (s *store) snapshot() (int, time.Time) { +func (s *store) snapshot() (int, time.Time, []sample) { s.mu.RLock() defer s.mu.RUnlock() - return s.value, s.updatedAt + history := append([]sample(nil), s.history...) + return s.value, s.updatedAt, history } func main() { @@ -117,16 +135,101 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html; charset=utf-8") - watts, updatedAt := s.store.snapshot() + 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": watts, "UpdatedAt": updatedLabel}); err != nil { + if err := page.Execute(w, map[string]any{ + "Watts": watts, + "UpdatedAt": updatedLabel, + "HistoryChart": buildHistoryChart(history), + }); err != nil { http.Error(w, "render error", http.StatusInternalServerError) } } +func buildHistoryChart(history []sample) template.HTML { + const ( + width = 640 + height = 220 + padL = 52 + padR = 18 + padT = 18 + padB = 42 + ) + + if len(history) == 0 { + return template.HTML(`No power history yet`) + } + + minV, maxV := 0, history[0].value + start := history[0].at + end := history[len(history)-1].at + for _, point := range history[1:] { + if 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)) + 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(``) + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, ``, x0, yTop, x1) + fmt.Fprintf(&b, ``, x0, yMid, x1) + fmt.Fprintf(&b, ``, x0, yBottom, x1) + fmt.Fprintf(&b, ``, x0, yTop, yBottom) + fmt.Fprintf(&b, ``, x0+(plotW/2), yTop, yBottom) + fmt.Fprintf(&b, ``, x1, yTop, yBottom) + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, `%d W`, x0-10, int(yTop)+14, maxV) + fmt.Fprintf(&b, `%d W`, x0-10, int(yMid)+14, midV) + fmt.Fprintf(&b, `0 W`, x0-10, int(yBottom)+14) + fmt.Fprintf(&b, `%s`, x0, int(height)-10, start.Format("15:04")) + fmt.Fprintf(&b, `%s`, x0+(plotW/2), int(height)-10, midT.Format("15:04")) + fmt.Fprintf(&b, `%s`, x1, int(height)-10, end.Format("15:04")) + b.WriteString(``) + fmt.Fprintf(&b, ``, strings.Join(points, " ")) + fmt.Fprintf(&b, ``, lastX, lastY) + b.WriteString(``) + return template.HTML(b.String()) +} + const faviconSVG = ` diff --git a/main_test.go b/main_test.go index e38c083..7eaddb1 100644 --- a/main_test.go +++ b/main_test.go @@ -81,3 +81,31 @@ func TestLoadConfig(t *testing.T) { t.Fatalf("addr = %q, want %q", cfg.Addr, ":9090") } } + +func TestBuildHistoryChartEmpty(t *testing.T) { + chart := buildHistoryChart(nil) + 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 := []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) + if !strings.Contains(string(chart), " @@ -19,6 +21,7 @@
{{.Watts}}
watts
Last updated: {{.UpdatedAt}}
+
{{.HistoryChart}}