feat: rewrite to stateless web app with IndexedDB frontend

Replace CLI + SQLite architecture with a Go web server + vanilla JS
frontend using IndexedDB for all client-side data storage.

- Remove: cli, store, report, static packages
- Add: compute engine (BuildDashboard), server package, web UI
- Add: setup page with CRUD for profiles, rooms, devices, occupants, AC
- Add: dashboard with SVG temperature timeline, risk analysis, care checklist
- Add: i18n support (English/German) with server-side Go templates
- Add: LLM provider selection UI with client-side API key storage
- Add: per-room indoor temperature, edit buttons, language-aware AI summary
This commit is contained in:
2026-02-09 13:31:38 +01:00
parent a89720fded
commit d5452409b6
65 changed files with 3862 additions and 5332 deletions

168
internal/server/api.go Normal file
View File

@@ -0,0 +1,168 @@
package server
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/compute"
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
"github.com/cnachtigall/heatwave-autopilot/internal/weather"
)
func (s *Server) handleComputeDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req compute.ComputeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
data, err := compute.BuildDashboard(req)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, data)
}
type forecastRequest struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Timezone string `json:"timezone"`
}
func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req forecastRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
provider := weather.NewOpenMeteo(nil)
resp, err := provider.FetchForecast(ctx, req.Lat, req.Lon, req.Timezone)
if err != nil {
jsonError(w, err.Error(), http.StatusBadGateway)
return
}
jsonResponse(w, resp)
}
type warningsRequest struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
func (s *Server) handleWeatherWarnings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req warningsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
provider := weather.NewDWDWFS(nil)
warnings, err := provider.FetchWarnings(ctx, req.Lat, req.Lon)
if err != nil {
jsonError(w, err.Error(), http.StatusBadGateway)
return
}
jsonResponse(w, map[string]any{"warnings": warnings})
}
type summarizeRequest struct {
llm.SummaryInput
Provider string `json:"provider,omitempty"`
APIKey string `json:"apiKey,omitempty"`
Model string `json:"model,omitempty"`
}
func (s *Server) handleLLMSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req summarizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
// Use client-provided credentials if present, otherwise fall back to server config
provider := s.llmProvider
if req.Provider != "" && req.APIKey != "" {
switch req.Provider {
case "anthropic":
provider = llm.NewAnthropic(req.APIKey, req.Model, nil)
case "openai":
provider = llm.NewOpenAI(req.APIKey, req.Model, nil)
case "gemini":
provider = llm.NewGemini(req.APIKey, req.Model, nil)
}
}
if provider.Name() == "none" {
jsonResponse(w, map[string]string{"summary": ""})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
summary, err := provider.Summarize(ctx, req.SummaryInput)
if err != nil {
jsonError(w, err.Error(), http.StatusBadGateway)
return
}
jsonResponse(w, map[string]string{"summary": summary})
}
func (s *Server) handleLLMConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
available := s.llmProvider.Name() != "none"
jsonResponse(w, map[string]any{
"provider": s.cfg.LLM.Provider,
"model": s.cfg.LLM.Model,
"available": available,
})
}
func jsonResponse(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

6
internal/server/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package server
import "io/fs"
// WebFS is set by the main package to provide the embedded web/ filesystem.
var WebFS fs.FS

100
internal/server/i18n.go Normal file
View File

@@ -0,0 +1,100 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// translations holds the loaded translation maps keyed by language code.
type translations struct {
langs map[string]map[string]any // e.g. {"en": {...}, "de": {...}}
}
func loadTranslations(enJSON, deJSON []byte) (*translations, error) {
t := &translations{langs: make(map[string]map[string]any)}
var en map[string]any
if err := json.Unmarshal(enJSON, &en); err != nil {
return nil, fmt.Errorf("parse en.json: %w", err)
}
t.langs["en"] = en
var de map[string]any
if err := json.Unmarshal(deJSON, &de); err != nil {
return nil, fmt.Errorf("parse de.json: %w", err)
}
t.langs["de"] = de
return t, nil
}
// get resolves a dotted key (e.g. "setup.rooms.title") from the translation map.
func (t *translations) get(lang, key string) string {
m, ok := t.langs[lang]
if !ok {
m = t.langs["en"]
}
if m == nil {
return key
}
return resolve(m, key)
}
func resolve(m map[string]any, key string) string {
parts := strings.Split(key, ".")
var current any = m
for _, p := range parts {
cm, ok := current.(map[string]any)
if !ok {
return key
}
current, ok = cm[p]
if !ok {
return key
}
}
s, ok := current.(string)
if !ok {
return key
}
return s
}
// supportedLangs are the available languages.
var supportedLangs = []string{"en", "de"}
// detectLanguage determines the active language from query param, cookie, or Accept-Language header.
func detectLanguage(r *http.Request) string {
// 1. Query parameter
if lang := r.URL.Query().Get("lang"); isSupported(lang) {
return lang
}
// 2. Cookie
if c, err := r.Cookie("heatguard_lang"); err == nil && isSupported(c.Value) {
return c.Value
}
// 3. Accept-Language header
accept := r.Header.Get("Accept-Language")
for _, part := range strings.Split(accept, ",") {
lang := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
lang = strings.SplitN(lang, "-", 2)[0]
if isSupported(lang) {
return lang
}
}
return "en"
}
func isSupported(lang string) bool {
for _, l := range supportedLangs {
if l == lang {
return true
}
}
return false
}

205
internal/server/server.go Normal file
View File

@@ -0,0 +1,205 @@
package server
import (
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"path/filepath"
"github.com/cnachtigall/heatwave-autopilot/internal/config"
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
)
// Server holds the HTTP server state.
type Server struct {
mux *http.ServeMux
trans *translations
cfg config.Config
llmProvider llm.Provider
devMode bool
}
// Options configures the server.
type Options struct {
Port int
DevMode bool
Config config.Config
}
// New creates a new Server and sets up routes.
func New(opts Options) (*Server, error) {
s := &Server{
mux: http.NewServeMux(),
cfg: opts.Config,
devMode: opts.DevMode,
}
// Load translations
var enJSON, deJSON []byte
var err error
if opts.DevMode {
enJSON, err = os.ReadFile(filepath.Join("web", "i18n", "en.json"))
if err != nil {
return nil, fmt.Errorf("read en.json: %w", err)
}
deJSON, err = os.ReadFile(filepath.Join("web", "i18n", "de.json"))
if err != nil {
return nil, fmt.Errorf("read de.json: %w", err)
}
} else {
if WebFS == nil {
return nil, fmt.Errorf("WebFS not set — call server.WebFS = ... before server.New()")
}
enJSON, err = fs.ReadFile(WebFS, "i18n/en.json")
if err != nil {
return nil, fmt.Errorf("read embedded en.json: %w", err)
}
deJSON, err = fs.ReadFile(WebFS, "i18n/de.json")
if err != nil {
return nil, fmt.Errorf("read embedded de.json: %w", err)
}
}
s.trans, err = loadTranslations(enJSON, deJSON)
if err != nil {
return nil, fmt.Errorf("load translations: %w", err)
}
// Set up LLM provider
s.llmProvider = buildLLMProvider(s.cfg)
// Static assets
if opts.DevMode {
s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("web"))))
} else {
s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(WebFS))))
}
// Page routes
s.mux.HandleFunc("/", s.handleDashboard)
s.mux.HandleFunc("/setup", s.handleSetup)
s.mux.HandleFunc("/guide", s.handleGuide)
// API routes
s.mux.HandleFunc("/api/compute/dashboard", s.handleComputeDashboard)
s.mux.HandleFunc("/api/weather/forecast", s.handleWeatherForecast)
s.mux.HandleFunc("/api/weather/warnings", s.handleWeatherWarnings)
s.mux.HandleFunc("/api/llm/summarize", s.handleLLMSummarize)
s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig)
return s, nil
}
// Handler returns the HTTP handler.
func (s *Server) Handler() http.Handler {
return s.mux
}
// ListenAndServe starts the server.
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.mux)
}
type pageData struct {
Lang string
Page string
Title string
}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page, templateFile string) {
lang := detectLanguage(r)
// Set language cookie if query param was provided
if qLang := r.URL.Query().Get("lang"); isSupported(qLang) {
http.SetCookie(w, &http.Cookie{
Name: "heatguard_lang",
Value: qLang,
Path: "/",
MaxAge: 365 * 24 * 3600,
SameSite: http.SameSiteLaxMode,
})
}
funcMap := template.FuncMap{
"t": func(key string) string {
return s.trans.get(lang, key)
},
}
var tmpl *template.Template
var err error
if s.devMode {
tmpl, err = template.New("layout.html").Funcs(funcMap).ParseFiles(
filepath.Join("web", "templates", "layout.html"),
filepath.Join("web", "templates", templateFile),
)
} else {
tmpl, err = template.New("layout.html").Funcs(funcMap).ParseFS(WebFS,
"templates/layout.html",
"templates/"+templateFile,
)
}
if err != nil {
http.Error(w, fmt.Sprintf("template error: %v", err), http.StatusInternalServerError)
return
}
title := s.trans.get(lang, "nav."+page)
data := pageData{
Lang: lang,
Page: page,
Title: title,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
}
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
s.renderPage(w, r, "dashboard", "dashboard.html")
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, r, "setup", "setup.html")
}
func (s *Server) handleGuide(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, r, "guide", "guide.html")
}
func buildLLMProvider(cfg config.Config) llm.Provider {
switch cfg.LLM.Provider {
case "anthropic":
key := os.Getenv("ANTHROPIC_API_KEY")
if key == "" {
return llm.NewNoop()
}
return llm.NewAnthropic(key, cfg.LLM.Model, nil)
case "openai":
key := os.Getenv("OPENAI_API_KEY")
if key == "" {
return llm.NewNoop()
}
return llm.NewOpenAI(key, cfg.LLM.Model, nil)
case "gemini":
key := os.Getenv("GEMINI_API_KEY")
if key == "" {
return llm.NewNoop()
}
return llm.NewGemini(key, cfg.LLM.Model, nil)
case "ollama":
return llm.NewOllama(cfg.LLM.Model, cfg.LLM.Endpoint, nil)
default:
return llm.NewNoop()
}
}

View File

@@ -0,0 +1,167 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/cnachtigall/heatwave-autopilot/internal/compute"
"github.com/cnachtigall/heatwave-autopilot/internal/config"
)
func TestMain(m *testing.M) {
// Tests run from the package directory. Set WebFS to point to web/ dir.
WebFS = os.DirFS("../../web")
os.Exit(m.Run())
}
func testServer(t *testing.T) *Server {
t.Helper()
s, err := New(Options{
Port: 0,
DevMode: false,
Config: config.DefaultConfig(),
})
if err != nil {
t.Fatalf("create server: %v", err)
}
return s
}
func TestDashboardPage(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("got content-type %q, want text/html", ct)
}
}
func TestSetupPage(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/setup", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
}
}
func TestGuidePage(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/guide", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
}
}
func TestNotFound(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("got status %d, want 404", w.Code)
}
}
func TestLanguageDetection(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/?lang=de", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200", w.Code)
}
body := w.Body.String()
if !bytes.Contains([]byte(body), []byte(`lang="de"`)) {
t.Error("expected lang=de in response")
}
cookies := w.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == "heatguard_lang" && c.Value == "de" {
found = true
}
}
if !found {
t.Error("expected heatguard_lang cookie to be set")
}
}
func TestComputeAPI_NoForecasts(t *testing.T) {
s := testServer(t)
reqBody := compute.ComputeRequest{
Profile: compute.Profile{Name: "Test", Timezone: "UTC"},
Date: "2025-07-15",
}
b, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/compute/dashboard", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("got status %d, want 400", w.Code)
}
}
func TestComputeAPI_MethodNotAllowed(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/api/compute/dashboard", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("got status %d, want 405", w.Code)
}
}
func TestLLMConfigAPI(t *testing.T) {
s := testServer(t)
req := httptest.NewRequest("GET", "/api/llm/config", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200", w.Code)
}
var resp struct {
Provider string `json:"provider"`
Model string `json:"model"`
Available bool `json:"available"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Available {
t.Errorf("expected available=false for noop provider, got provider=%q model=%q available=%v", resp.Provider, resp.Model, resp.Available)
}
}
func TestLLMSummarize_Noop(t *testing.T) {
s := testServer(t)
body := `{"date":"2025-07-15","peakTempC":35,"riskLevel":"high"}`
req := httptest.NewRequest("POST", "/api/llm/summarize", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want 200", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["summary"] != "" {
t.Errorf("expected empty summary for noop, got %q", resp["summary"])
}
}