diff --git a/.gitignore b/.gitignore index 50b5631..d61dbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ node_modules/ +web/css/app.css diff --git a/Makefile b/Makefile index 0d88c6d..5b573fc 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,20 @@ -.PHONY: build test clean css +.PHONY: build test clean css dev -BINARY := heatwave +BINARY := heatguard BUILD_DIR := bin build: css - go build -o $(BUILD_DIR)/$(BINARY) ./cmd/heatwave + go build -o $(BUILD_DIR)/$(BINARY) ./cmd/heatguard test: - go test -race ./... + go test -race ./internal/heat/... ./internal/risk/... ./internal/action/... \ + ./internal/weather/... ./internal/llm/... ./internal/compute/... ./internal/server/... clean: rm -rf $(BUILD_DIR) css: - npx @tailwindcss/cli -i tailwind/input.css -o internal/static/tailwind.css --minify + npx @tailwindcss/cli -i tailwind/input.css -o web/css/app.css --minify + +dev: + go run ./cmd/heatguard --dev --port 8080 diff --git a/cmd/heatguard/main.go b/cmd/heatguard/main.go new file mode 100644 index 0000000..d8782c6 --- /dev/null +++ b/cmd/heatguard/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/cnachtigall/heatwave-autopilot/internal/config" + "github.com/cnachtigall/heatwave-autopilot/internal/server" + "github.com/cnachtigall/heatwave-autopilot/web" +) + +func main() { + port := flag.Int("port", 8080, "HTTP port") + dev := flag.Bool("dev", false, "development mode (serve from filesystem)") + flag.Parse() + + cfg := config.Load() + + // Set the embedded filesystem for the server package + server.WebFS = web.FS + + srv, err := server.New(server.Options{ + Port: *port, + DevMode: *dev, + Config: cfg, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + addr := fmt.Sprintf(":%d", *port) + log.Printf("HeatGuard listening on http://localhost%s", addr) + if err := srv.ListenAndServe(addr); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/heatwave/main.go b/cmd/heatwave/main.go deleted file mode 100644 index ad181be..0000000 --- a/cmd/heatwave/main.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "os" - - "github.com/cnachtigall/heatwave-autopilot/internal/cli" -) - -func main() { - if err := cli.Execute(); err != nil { - os.Exit(1) - } -} diff --git a/go.mod b/go.mod index 226d7f1..b680983 100644 --- a/go.mod +++ b/go.mod @@ -2,23 +2,4 @@ module github.com/cnachtigall/heatwave-autopilot go 1.25.7 -require ( - github.com/spf13/cobra v1.10.2 - gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.44.3 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.37.0 // indirect - modernc.org/libc v1.67.6 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect -) +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 365ea9f..a62c313 100644 --- a/go.sum +++ b/go.sum @@ -1,66 +1,4 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= -modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/cli/ac.go b/internal/cli/ac.go deleted file mode 100644 index 35c9477..0000000 --- a/internal/cli/ac.go +++ /dev/null @@ -1,110 +0,0 @@ -package cli - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" -) - -var ( - acType string - acBTU float64 - acRooms []int64 - acDehumidify bool - acEER float64 -) - -func init() { - acCmd := &cobra.Command{ - Use: "ac", - Short: "Manage AC units", - } - - addCmd := &cobra.Command{ - Use: "add ", - Short: "Add an AC unit", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - ac, err := db.CreateACUnit(p.ID, args[0], acType, acBTU, acDehumidify, acEER) - if err != nil { - return err - } - for _, roomID := range acRooms { - if err := db.AssignACToRoom(ac.ID, roomID); err != nil { - return fmt.Errorf("assign room %d: %w", roomID, err) - } - } - fmt.Printf("AC unit added: %s (ID: %d, %.0f BTU/h, type: %s)\n", ac.Name, ac.ID, ac.CapacityBTU, ac.ACType) - return nil - }, - } - addCmd.Flags().StringVar(&acType, "type", "portable", "AC type (portable, split, window, central)") - addCmd.Flags().Float64Var(&acBTU, "btu", 0, "cooling capacity in BTU/h") - addCmd.Flags().Int64SliceVar(&acRooms, "rooms", nil, "room IDs to assign") - addCmd.Flags().BoolVar(&acDehumidify, "dehumidify", false, "has dehumidify mode") - addCmd.Flags().Float64Var(&acEER, "eer", 10.0, "energy efficiency ratio") - addCmd.MarkFlagRequired("btu") - - listCmd := &cobra.Command{ - Use: "list", - Short: "List AC units", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - units, err := db.ListACUnits(p.ID) - if err != nil { - return err - } - if len(units) == 0 { - fmt.Println("No AC units found") - return nil - } - for _, u := range units { - rooms, _ := db.GetACRoomAssignments(u.ID) - dehumid := "" - if u.HasDehumidify { - dehumid = " +dehumidify" - } - fmt.Printf(" [%d] %s — %.0f BTU/h, type: %s, EER: %.1f%s, rooms: %v\n", - u.ID, u.Name, u.CapacityBTU, u.ACType, u.EfficiencyEER, dehumid, rooms) - } - return nil - }, - } - - editCmd := &cobra.Command{ - Use: "edit ", - Short: "Edit an AC unit field", - Args: cobra.ExactArgs(3), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid AC unit ID: %s", args[0]) - } - return db.UpdateACUnit(id, args[1], args[2]) - }, - } - - removeCmd := &cobra.Command{ - Use: "remove ", - Short: "Remove an AC unit", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid AC unit ID: %s", args[0]) - } - return db.DeleteACUnit(id) - }, - } - - acCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd) - rootCmd.AddCommand(acCmd) -} diff --git a/internal/cli/budget.go b/internal/cli/budget.go deleted file mode 100644 index 381046d..0000000 --- a/internal/cli/budget.go +++ /dev/null @@ -1,120 +0,0 @@ -package cli - -import ( - "github.com/cnachtigall/heatwave-autopilot/internal/heat" - "github.com/cnachtigall/heatwave-autopilot/internal/store" -) - -// hourWeather holds weather data for a single hour, used for budget computation. -type hourWeather struct { - Hour int - TempC float64 - CloudCoverPct float64 - SunshineMin float64 -} - -// roomBudgetResult holds the budget result for a single room in a single hour. -type roomBudgetResult struct { - RoomName string - RoomID int64 - Result heat.BudgetResult -} - -// computeRoomBudgets computes heat budgets for all rooms in a profile for a given hour. -// It returns per-room results and the worst-case BudgetStatus. -func computeRoomBudgets(profileID int64, w hourWeather, toggles map[string]bool, indoorTempC float64) ([]roomBudgetResult, heat.BudgetStatus) { - rooms, err := db.ListRooms(profileID) - if err != nil || len(rooms) == 0 { - return nil, heat.Comfortable - } - - var results []roomBudgetResult - worstStatus := heat.Comfortable - - for _, room := range rooms { - budget := computeSingleRoomBudget(room, w, toggles, indoorTempC) - results = append(results, roomBudgetResult{ - RoomName: room.Name, - RoomID: room.ID, - Result: budget, - }) - if budget.Status > worstStatus { - worstStatus = budget.Status - } - } - - return results, worstStatus -} - -func computeSingleRoomBudget(room store.Room, w hourWeather, toggles map[string]bool, indoorTempC float64) heat.BudgetResult { - // Devices - devices, _ := db.ListDevices(room.ID) - var heatDevices []heat.Device - for _, d := range devices { - heatDevices = append(heatDevices, heat.Device{ - WattsIdle: d.WattsIdle, - WattsTypical: d.WattsTypical, - WattsPeak: d.WattsPeak, - DutyCycle: d.DutyCycle, - }) - } - - // Determine device mode from toggles - mode := heat.ModeTypical - if toggles["gaming"] { - mode = heat.ModePeak - } - - // Occupants - occupants, _ := db.ListOccupants(room.ID) - var heatOccupants []heat.Occupant - for _, o := range occupants { - heatOccupants = append(heatOccupants, heat.Occupant{ - Count: o.Count, - Activity: heat.ParseActivityLevel(o.ActivityLevel), - }) - } - - // AC capacity - acCap, _ := db.GetRoomACCapacity(room.ID) - - // Solar params - cloudFactor := 1.0 - (w.CloudCoverPct / 100.0) - sunshineFraction := 0.0 - if w.SunshineMin > 0 { - sunshineFraction = w.SunshineMin / 60.0 - if sunshineFraction > 1.0 { - sunshineFraction = 1.0 - } - } - - solar := heat.SolarParams{ - AreaSqm: room.AreaSqm, - WindowFraction: room.WindowFraction, - SHGC: room.SHGC, - ShadingFactor: room.ShadingFactor, - OrientationFactor: heat.OrientationFactor(room.Orientation, w.Hour), - CloudFactor: cloudFactor, - SunshineFraction: sunshineFraction, - PeakIrradiance: 800, // W/m² typical clear-sky peak - } - - // Ventilation params - volume := room.AreaSqm * room.CeilingHeightM - vent := heat.VentilationParams{ - ACH: room.VentilationACH, - VolumeCubicM: volume, - OutdoorTempC: w.TempC, - IndoorTempC: indoorTempC, - RhoCp: 1.2, // kJ/(m³·K) — standard air at sea level - } - - return heat.ComputeRoomBudget(heat.BudgetInput{ - Devices: heatDevices, - DeviceMode: mode, - Occupants: heatOccupants, - Solar: solar, - Ventilation: vent, - ACCapacityBTUH: acCap, - }) -} diff --git a/internal/cli/device.go b/internal/cli/device.go deleted file mode 100644 index 6ea95ba..0000000 --- a/internal/cli/device.go +++ /dev/null @@ -1,106 +0,0 @@ -package cli - -import ( - "fmt" - "strconv" - - "github.com/cnachtigall/heatwave-autopilot/internal/store" - "github.com/spf13/cobra" -) - -var ( - deviceRoom int64 - deviceType string - deviceIdle float64 - deviceTypical float64 - devicePeak float64 - deviceDuty float64 -) - -func init() { - deviceCmd := &cobra.Command{ - Use: "device", - Short: "Manage heat-producing devices", - } - - addCmd := &cobra.Command{ - Use: "add ", - Short: "Add a device", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - d, err := db.CreateDevice(deviceRoom, args[0], deviceType, deviceIdle, deviceTypical, devicePeak, deviceDuty) - if err != nil { - return err - } - fmt.Printf("Device added: %s (ID: %d, typical: %.0fW)\n", d.Name, d.ID, d.WattsTypical) - return nil - }, - } - addCmd.Flags().Int64Var(&deviceRoom, "room", 0, "room ID") - addCmd.Flags().StringVar(&deviceType, "type", "other", "device type (pc, monitor, appliance, other)") - addCmd.Flags().Float64Var(&deviceIdle, "watts-idle", 0, "idle power draw (W)") - addCmd.Flags().Float64Var(&deviceTypical, "watts-typical", 0, "typical power draw (W)") - addCmd.Flags().Float64Var(&devicePeak, "watts-peak", 0, "peak power draw (W)") - addCmd.Flags().Float64Var(&deviceDuty, "duty-cycle", 1.0, "duty cycle (0.0-1.0)") - addCmd.MarkFlagRequired("room") - - listCmd := &cobra.Command{ - Use: "list", - Short: "List devices", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - roomFilter, _ := cmd.Flags().GetInt64("room") - var devices []store.Device - if roomFilter > 0 { - devices, err = db.ListDevices(roomFilter) - } else { - devices, err = db.ListAllDevices(p.ID) - } - if err != nil { - return err - } - if len(devices) == 0 { - fmt.Println("No devices found") - return nil - } - for _, d := range devices { - fmt.Printf(" [%d] %s — type: %s, idle: %.0fW, typical: %.0fW, peak: %.0fW, duty: %.0f%%\n", - d.ID, d.Name, d.DeviceType, d.WattsIdle, d.WattsTypical, d.WattsPeak, d.DutyCycle*100) - } - return nil - }, - } - listCmd.Flags().Int64("room", 0, "filter by room ID") - - editCmd := &cobra.Command{ - Use: "edit ", - Short: "Edit a device field", - Args: cobra.ExactArgs(3), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid device ID: %s", args[0]) - } - return db.UpdateDevice(id, args[1], args[2]) - }, - } - - removeCmd := &cobra.Command{ - Use: "remove ", - Short: "Remove a device", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid device ID: %s", args[0]) - } - return db.DeleteDevice(id) - }, - } - - deviceCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd) - rootCmd.AddCommand(deviceCmd) -} diff --git a/internal/cli/forecast.go b/internal/cli/forecast.go deleted file mode 100644 index 8d3e24e..0000000 --- a/internal/cli/forecast.go +++ /dev/null @@ -1,165 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "time" - - "github.com/cnachtigall/heatwave-autopilot/internal/store" - "github.com/cnachtigall/heatwave-autopilot/internal/weather" - "github.com/spf13/cobra" -) - -var ( - forecastForce bool - forecastHours int -) - -func init() { - forecastCmd := &cobra.Command{ - Use: "forecast", - Short: "Manage weather forecasts", - } - - fetchCmd := &cobra.Command{ - Use: "fetch", - Short: "Fetch weather forecast and warnings", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - - if !forecastForce { - lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo") - if err == nil && time.Since(lastFetch) < time.Hour { - fmt.Printf("Forecast is fresh (last fetched %s). Use --force to refetch.\n", lastFetch.Format("15:04")) - return nil - } - } - - return fetchForecastForProfile(p) - }, - } - fetchCmd.Flags().BoolVar(&forecastForce, "force", false, "force refetch even if recent data exists") - - showCmd := &cobra.Command{ - Use: "show", - Short: "Show forecast data", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - now := time.Now() - from := now - to := now.Add(time.Duration(forecastHours) * time.Hour) - forecasts, err := db.GetForecasts(p.ID, from, to, "") - if err != nil { - return err - } - if len(forecasts) == 0 { - fmt.Println("No forecast data. Run: heatwave forecast fetch") - return nil - } - for _, f := range forecasts { - temp := "n/a" - if f.TemperatureC != nil { - temp = fmt.Sprintf("%.1f°C", *f.TemperatureC) - } - hum := "n/a" - if f.HumidityPct != nil { - hum = fmt.Sprintf("%.0f%%", *f.HumidityPct) - } - fmt.Printf(" %s %s %s %s\n", f.Timestamp.Format("02 Jan 15:04"), temp, hum, f.Source) - } - return nil - }, - } - showCmd.Flags().IntVar(&forecastHours, "hours", 48, "hours to display") - - risksCmd := &cobra.Command{ - Use: "risks", - Short: "Show identified risk windows", - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Risk analysis requires forecast data. Run: heatwave forecast fetch") - fmt.Println("Then use: heatwave plan today") - return nil - }, - } - - forecastCmd.AddCommand(fetchCmd, showCmd, risksCmd) - rootCmd.AddCommand(forecastCmd) -} - -// fetchForecastForProfile fetches forecast and warnings for the given profile. -func fetchForecastForProfile(p *store.Profile) error { - ctx := context.Background() - - om := weather.NewOpenMeteo(nil) - resp, err := om.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone) - if err != nil { - fmt.Printf("Open-Meteo failed: %v, trying Bright Sky...\n", err) - bs := weather.NewBrightSky(nil) - resp, err = bs.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone) - if err != nil { - return fmt.Errorf("all forecast providers failed: %w", err) - } - } - - count := 0 - for _, h := range resp.Hourly { - f := &store.Forecast{ - ProfileID: p.ID, - Timestamp: h.Timestamp, - TemperatureC: &h.TemperatureC, - HumidityPct: &h.HumidityPct, - WindSpeedMs: &h.WindSpeedMs, - CloudCoverPct: &h.CloudCoverPct, - PrecipitationMm: &h.PrecipitationMm, - SunshineMin: &h.SunshineMin, - PressureHpa: &h.PressureHpa, - DewPointC: &h.DewPointC, - ApparentTempC: &h.ApparentTempC, - Condition: h.Condition, - Source: resp.Source, - } - if err := db.UpsertForecast(f); err != nil { - return fmt.Errorf("store forecast: %w", err) - } - count++ - } - fmt.Printf("Stored %d hourly forecasts from %s\n", count, resp.Source) - - dwd := weather.NewDWDWFS(nil) - warnings, err := dwd.FetchWarnings(ctx, p.Latitude, p.Longitude) - if err != nil { - fmt.Printf("Warning fetch failed: %v\n", err) - } else { - for _, w := range warnings { - sw := &store.Warning{ - ProfileID: p.ID, - WarningID: w.ID, - EventType: w.EventType, - Severity: w.Severity, - Headline: w.Headline, - Description: w.Description, - Instruction: w.Instruction, - Onset: w.Onset, - Expires: w.Expires, - } - if err := db.UpsertWarning(sw); err != nil { - return fmt.Errorf("store warning: %w", err) - } - } - fmt.Printf("Stored %d heat warnings\n", len(warnings)) - } - - cutoff := time.Now().Add(-7 * 24 * time.Hour) - deleted, _ := db.CleanupOldForecasts(cutoff) - if deleted > 0 && verbose { - fmt.Printf("Cleaned up %d old forecasts\n", deleted) - } - - return nil -} diff --git a/internal/cli/heatplan.go b/internal/cli/heatplan.go deleted file mode 100644 index ca7018c..0000000 --- a/internal/cli/heatplan.go +++ /dev/null @@ -1,158 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/cnachtigall/heatwave-autopilot/internal/action" - "github.com/cnachtigall/heatwave-autopilot/internal/heat" - "github.com/cnachtigall/heatwave-autopilot/internal/llm" - "github.com/cnachtigall/heatwave-autopilot/internal/risk" - "github.com/spf13/cobra" -) - -var ( - heatplanOutput string -) - -func init() { - heatplanCmd := &cobra.Command{ - Use: "heatplan", - Short: "Generate a 1-page plain-language heat plan", - } - - generateCmd := &cobra.Command{ - Use: "generate", - Short: "Generate a heat plan document", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - - provider := getLLMProvider() - if provider.Name() == "none" { - return fmt.Errorf("LLM not configured. Set llm.provider in config or use --llm flag") - } - - dateStr := time.Now().Format("2006-01-02") - date, _ := time.Parse("2006-01-02", dateStr) - loc, _ := time.LoadLocation(p.Timezone) - from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) - to := from.Add(24 * time.Hour) - - forecasts, err := db.GetForecasts(p.ID, from, to, "") - if err != nil || len(forecasts) == 0 { - return fmt.Errorf("no forecast data. Run: heatwave forecast fetch") - } - - hourlyData := buildHourlyData(forecasts, loc) - dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds()) - - devices, _ := db.ListAllDevices(p.ID) - var sources []llm.HeatSource - for _, d := range devices { - sources = append(sources, llm.HeatSource{Name: d.Name, Watts: d.WattsTypical * d.DutyCycle}) - } - - acUnits, _ := db.ListACUnits(p.ID) - var totalACBTU float64 - for _, ac := range acUnits { - totalACBTU += ac.CapacityBTU - } - var totalGainW float64 - for _, s := range sources { - totalGainW += s.Watts - } - - warnings, _ := db.GetActiveWarnings(p.ID, time.Now()) - var warningStrs []string - for _, w := range warnings { - warningStrs = append(warningStrs, w.Headline) - } - - var riskWindows []llm.RiskWindowSummary - for _, w := range dayRisk.Windows { - riskWindows = append(riskWindows, llm.RiskWindowSummary{ - StartHour: w.StartHour, EndHour: w.EndHour, PeakTempC: w.PeakTempC, Level: w.Level.String(), - }) - } - - summaryInput := llm.SummaryInput{ - Date: dateStr, - PeakTempC: dayRisk.PeakTempC, - MinNightTempC: dayRisk.MinNightTempC, - RiskLevel: dayRisk.Level.String(), - TopHeatSources: sources, - ACHeadroomBTUH: totalACBTU - heat.WattsToBTUH(totalGainW), - BudgetStatus: heat.Comfortable.String(), - ActiveWarnings: warningStrs, - RiskWindows: riskWindows, - } - - // Build timeline - actions, _ := action.LoadDefaultActions() - toggles, _ := db.GetActiveToggleNames(p.ID) - var timelineSlots []llm.TimelineSlotSummary - var actionSummaries []llm.ActionSummary - - for _, h := range hourlyData { - ctx := action.HourContext{ - Hour: h.Hour, TempC: h.TempC, HumidityPct: h.HumidityPct, - IsDay: h.IsDay, RiskLevel: dayRisk.Level, - BudgetStatus: heat.Comfortable, ActiveToggles: toggles, - } - matched := action.SelectActions(actions, ctx) - var actionNames []string - for _, a := range matched { - actionNames = append(actionNames, a.Name) - actionSummaries = append(actionSummaries, llm.ActionSummary{ - Name: a.Name, Category: string(a.Category), Impact: string(a.Impact), Hour: h.Hour, - }) - } - timelineSlots = append(timelineSlots, llm.TimelineSlotSummary{ - Hour: h.Hour, TempC: h.TempC, RiskLevel: dayRisk.Level.String(), - BudgetStatus: heat.Comfortable.String(), Actions: actionNames, - }) - } - - // Care checklist - occupants, _ := db.ListAllOccupants(p.ID) - var careList []string - for _, o := range occupants { - if o.Vulnerable { - careList = append(careList, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID)) - } - } - - input := llm.HeatPlanInput{ - Summary: summaryInput, - Timeline: timelineSlots, - Actions: actionSummaries, - CareChecklist: careList, - } - - result, err := provider.GenerateHeatPlan(context.Background(), input) - if err != nil { - return fmt.Errorf("LLM call failed: %w", err) - } - - if heatplanOutput != "" { - if err := os.WriteFile(heatplanOutput, []byte(result), 0o644); err != nil { - return fmt.Errorf("write output: %w", err) - } - fmt.Printf("Heat plan written to %s\n", heatplanOutput) - } else { - fmt.Println(result) - } - - return nil - }, - } - generateCmd.Flags().StringVar(&heatplanOutput, "output", "", "output file path") - - heatplanCmd.AddCommand(generateCmd) - rootCmd.AddCommand(heatplanCmd) -} diff --git a/internal/cli/occupant.go b/internal/cli/occupant.go deleted file mode 100644 index 771ba66..0000000 --- a/internal/cli/occupant.go +++ /dev/null @@ -1,97 +0,0 @@ -package cli - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" -) - -var ( - occRoom int64 - occCount int - occActivity string - occVuln bool -) - -func init() { - occCmd := &cobra.Command{ - Use: "occupant", - Short: "Manage room occupants", - } - - addCmd := &cobra.Command{ - Use: "add", - Short: "Add occupants to a room", - RunE: func(cmd *cobra.Command, args []string) error { - o, err := db.CreateOccupant(occRoom, occCount, occActivity, occVuln) - if err != nil { - return err - } - fmt.Printf("Occupant added (ID: %d, room: %d, count: %d, activity: %s, vulnerable: %v)\n", - o.ID, o.RoomID, o.Count, o.ActivityLevel, o.Vulnerable) - return nil - }, - } - addCmd.Flags().Int64Var(&occRoom, "room", 0, "room ID") - addCmd.Flags().IntVar(&occCount, "count", 1, "number of occupants") - addCmd.Flags().StringVar(&occActivity, "activity", "sedentary", "activity level (sleeping, sedentary, light, moderate, heavy)") - addCmd.Flags().BoolVar(&occVuln, "vulnerable", false, "vulnerable occupant (elderly, child, ill)") - addCmd.MarkFlagRequired("room") - - listCmd := &cobra.Command{ - Use: "list", - Short: "List occupants", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - occupants, err := db.ListAllOccupants(p.ID) - if err != nil { - return err - } - if len(occupants) == 0 { - fmt.Println("No occupants found") - return nil - } - for _, o := range occupants { - vuln := "" - if o.Vulnerable { - vuln = " [VULNERABLE]" - } - fmt.Printf(" [%d] room %d — %d person(s), %s%s\n", o.ID, o.RoomID, o.Count, o.ActivityLevel, vuln) - } - return nil - }, - } - - editCmd := &cobra.Command{ - Use: "edit ", - Short: "Edit an occupant field", - Args: cobra.ExactArgs(3), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid occupant ID: %s", args[0]) - } - return db.UpdateOccupant(id, args[1], args[2]) - }, - } - - removeCmd := &cobra.Command{ - Use: "remove ", - Short: "Remove an occupant entry", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid occupant ID: %s", args[0]) - } - return db.DeleteOccupant(id) - }, - } - - occCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd) - rootCmd.AddCommand(occCmd) -} diff --git a/internal/cli/plan.go b/internal/cli/plan.go deleted file mode 100644 index f29bede..0000000 --- a/internal/cli/plan.go +++ /dev/null @@ -1,165 +0,0 @@ -package cli - -import ( - "fmt" - "time" - - "github.com/cnachtigall/heatwave-autopilot/internal/action" - "github.com/cnachtigall/heatwave-autopilot/internal/risk" - "github.com/spf13/cobra" -) - -var planDate string - -func init() { - planCmd := &cobra.Command{ - Use: "plan", - Short: "Generate action plans", - } - - todayCmd := &cobra.Command{ - Use: "today", - Short: "Generate today's action plan", - RunE: func(cmd *cobra.Command, args []string) error { - return runPlan(time.Now().Format("2006-01-02")) - }, - } - - generateCmd := &cobra.Command{ - Use: "generate", - Short: "Generate plan for a specific date", - RunE: func(cmd *cobra.Command, args []string) error { - if planDate == "" { - planDate = time.Now().Format("2006-01-02") - } - return runPlan(planDate) - }, - } - generateCmd.Flags().StringVar(&planDate, "date", "", "date (YYYY-MM-DD)") - - planCmd.AddCommand(todayCmd, generateCmd) - rootCmd.AddCommand(planCmd) -} - -func runPlan(dateStr string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - - date, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return fmt.Errorf("invalid date: %s", dateStr) - } - - loc, err := time.LoadLocation(p.Timezone) - if err != nil { - loc = time.UTC - } - - from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) - to := from.Add(24 * time.Hour) - - forecasts, err := db.GetForecasts(p.ID, from, to, "") - if err != nil { - return err - } - if len(forecasts) == 0 { - return fmt.Errorf("no forecast data for %s. Run: heatwave forecast fetch", dateStr) - } - - // Build hourly risk data - hourlyData := make([]risk.HourlyData, 0, 24) - for _, f := range forecasts { - tempC := 0.0 - if f.TemperatureC != nil { - tempC = *f.TemperatureC - } - apparentC := tempC - if f.ApparentTempC != nil { - apparentC = *f.ApparentTempC - } - humPct := 50.0 - if f.HumidityPct != nil { - humPct = *f.HumidityPct - } - h := f.Timestamp.In(loc).Hour() - hourlyData = append(hourlyData, risk.HourlyData{ - Hour: h, - TempC: tempC, - ApparentC: apparentC, - HumidityPct: humPct, - IsDay: h >= 6 && h < 21, - }) - } - - // Analyze risks - th := risk.DefaultThresholds() - dayRisk := risk.AnalyzeDay(hourlyData, th) - - fmt.Printf("=== Heat Plan for %s ===\n", dateStr) - fmt.Printf("Risk Level: %s | Peak: %.1f°C | Night Min: %.1f°C\n", dayRisk.Level, dayRisk.PeakTempC, dayRisk.MinNightTempC) - if dayRisk.PoorNightCool { - fmt.Println("Warning: Poor nighttime cooling expected") - } - fmt.Println() - - if len(dayRisk.Windows) > 0 { - fmt.Println("Risk Windows:") - for _, w := range dayRisk.Windows { - fmt.Printf(" %02d:00–%02d:00 | Peak %.1f°C | %s — %s\n", w.StartHour, w.EndHour, w.PeakTempC, w.Level, w.Reason) - } - fmt.Println() - } - - // Load actions and build timeline - actions, err := action.LoadDefaultActions() - if err != nil { - return fmt.Errorf("load actions: %w", err) - } - - // Get toggles - toggles, _ := db.GetActiveToggleNames(p.ID) - - // Build hour contexts with real budget computation - contexts := make([]action.HourContext, 0, len(hourlyData)) - for i, h := range hourlyData { - cloudPct := 50.0 - sunMin := 0.0 - if i < len(forecasts) { - if forecasts[i].CloudCoverPct != nil { - cloudPct = *forecasts[i].CloudCoverPct - } - if forecasts[i].SunshineMin != nil { - sunMin = *forecasts[i].SunshineMin - } - } - w := hourWeather{Hour: h.Hour, TempC: h.TempC, CloudCoverPct: cloudPct, SunshineMin: sunMin} - _, worstStatus := computeRoomBudgets(p.ID, w, toggles, 25.0) - - contexts = append(contexts, action.HourContext{ - Hour: h.Hour, - TempC: h.TempC, - HumidityPct: h.HumidityPct, - IsDay: h.IsDay, - RiskLevel: dayRisk.Level, - BudgetStatus: worstStatus, - ActiveToggles: toggles, - }) - } - - timeline := action.BuildTimeline(contexts, actions) - - fmt.Println("Hour-by-Hour Plan:") - for _, slot := range timeline { - if len(slot.Actions) == 0 { - continue - } - fmt.Printf(" %02d:00 (%.1f°C):\n", slot.Hour, slot.TempC) - for _, a := range slot.Actions { - fmt.Printf(" - %s [%s, effort: %s]\n", a.Name, a.Impact, a.Effort) - } - } - - return nil -} diff --git a/internal/cli/profile.go b/internal/cli/profile.go deleted file mode 100644 index 399f6a1..0000000 --- a/internal/cli/profile.go +++ /dev/null @@ -1,106 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var ( - profileLat float64 - profileLon float64 - profileTZ string -) - -func init() { - profileCmd := &cobra.Command{ - Use: "profile", - Short: "Manage location profiles", - } - - createCmd := &cobra.Command{ - Use: "create ", - Short: "Create a new location profile", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - p, err := db.CreateProfile(args[0], profileLat, profileLon, profileTZ) - if err != nil { - return err - } - fmt.Printf("Profile created: %s (ID: %d, %.4f, %.4f)\n", p.Name, p.ID, p.Latitude, p.Longitude) - return nil - }, - } - createCmd.Flags().Float64Var(&profileLat, "lat", 0, "latitude") - createCmd.Flags().Float64Var(&profileLon, "lon", 0, "longitude") - createCmd.Flags().StringVar(&profileTZ, "tz", "Europe/Berlin", "timezone") - createCmd.MarkFlagRequired("lat") - createCmd.MarkFlagRequired("lon") - - showCmd := &cobra.Command{ - Use: "show", - Short: "Display current profile", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - fmt.Printf("Profile: %s (ID: %d)\n", p.Name, p.ID) - fmt.Printf(" Location: %.4f, %.4f\n", p.Latitude, p.Longitude) - fmt.Printf(" Timezone: %s\n", p.Timezone) - fmt.Printf(" Created: %s\n", p.CreatedAt.Format("2006-01-02 15:04")) - return nil - }, - } - - editCmd := &cobra.Command{ - Use: "edit ", - Short: "Edit a profile field", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - return db.UpdateProfile(p.ID, args[0], args[1]) - }, - } - - deleteCmd := &cobra.Command{ - Use: "delete", - Short: "Delete current profile", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - if err := db.DeleteProfile(p.ID); err != nil { - return err - } - fmt.Printf("Profile %q deleted\n", p.Name) - return nil - }, - } - - listCmd := &cobra.Command{ - Use: "list", - Short: "List all profiles", - RunE: func(cmd *cobra.Command, args []string) error { - profiles, err := db.ListProfiles() - if err != nil { - return err - } - if len(profiles) == 0 { - fmt.Println("No profiles found") - return nil - } - for _, p := range profiles { - fmt.Printf(" [%d] %s (%.4f, %.4f, %s)\n", p.ID, p.Name, p.Latitude, p.Longitude, p.Timezone) - } - return nil - }, - } - - profileCmd.AddCommand(createCmd, showCmd, editCmd, deleteCmd, listCmd) - rootCmd.AddCommand(profileCmd) -} diff --git a/internal/cli/report.go b/internal/cli/report.go deleted file mode 100644 index 630f8b4..0000000 --- a/internal/cli/report.go +++ /dev/null @@ -1,331 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "os" - "os/exec" - "runtime" - "time" - - "github.com/cnachtigall/heatwave-autopilot/internal/action" - "github.com/cnachtigall/heatwave-autopilot/internal/llm" - "github.com/cnachtigall/heatwave-autopilot/internal/report" - "github.com/cnachtigall/heatwave-autopilot/internal/risk" - "github.com/cnachtigall/heatwave-autopilot/internal/store" - "github.com/spf13/cobra" -) - -var ( - reportOutput string - reportDate string - servePort int - serveDate string - serveOpen bool -) - -func init() { - reportCmd := &cobra.Command{ - Use: "report", - Short: "Generate and serve HTML reports", - } - - generateCmd := &cobra.Command{ - Use: "generate", - Short: "Generate an HTML heat report", - RunE: func(cmd *cobra.Command, args []string) error { - dateStr := reportDate - if dateStr == "" { - dateStr = time.Now().Format("2006-01-02") - } - - data, err := buildReportData(dateStr) - if err != nil { - return err - } - - output := reportOutput - if output == "" { - output = fmt.Sprintf("heatwave-report-%s.html", dateStr) - } - - f, err := os.Create(output) - if err != nil { - return fmt.Errorf("create output file: %w", err) - } - defer f.Close() - - if err := report.Generate(f, data); err != nil { - return fmt.Errorf("generate report: %w", err) - } - - fmt.Printf("Report generated: %s\n", output) - return nil - }, - } - generateCmd.Flags().StringVar(&reportOutput, "output", "", "output file path (default: heatwave-report-DATE.html)") - generateCmd.Flags().StringVar(&reportDate, "date", "", "date (YYYY-MM-DD, default: today)") - - serveCmd := &cobra.Command{ - Use: "serve", - Short: "Serve the HTML report via a local HTTP server", - RunE: func(cmd *cobra.Command, args []string) error { - dateStr := serveDate - if dateStr == "" { - dateStr = time.Now().Format("2006-01-02") - } - return runWebServer(dateStr, servePort, serveOpen) - }, - } - serveCmd.Flags().IntVar(&servePort, "port", 8080, "HTTP port to serve on") - serveCmd.Flags().StringVar(&serveDate, "date", "", "date (YYYY-MM-DD, default: today)") - serveCmd.Flags().BoolVar(&serveOpen, "open", true, "open browser automatically") - - reportCmd.AddCommand(generateCmd, serveCmd) - rootCmd.AddCommand(reportCmd) -} - -// buildReportData constructs the full DashboardData from DB + forecast + budget + LLM. -func buildReportData(dateStr string) (report.DashboardData, error) { - p, err := getActiveProfile() - if err != nil { - return report.DashboardData{}, err - } - - date, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return report.DashboardData{}, fmt.Errorf("invalid date: %s", dateStr) - } - - loc, err := time.LoadLocation(p.Timezone) - if err != nil { - loc = time.UTC - } - - from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) - to := from.Add(24 * time.Hour) - - forecasts, err := db.GetForecasts(p.ID, from, to, "") - if err != nil { - return report.DashboardData{}, err - } - if len(forecasts) == 0 { - return report.DashboardData{}, fmt.Errorf("no forecast data for %s. Run: heatwave forecast fetch", dateStr) - } - - // Build risk analysis - hourlyData := buildHourlyData(forecasts, loc) - th := risk.DefaultThresholds() - dayRisk := risk.AnalyzeDay(hourlyData, th) - - // Build actions timeline - actions, _ := action.LoadDefaultActions() - toggles, _ := db.GetActiveToggleNames(p.ID) - - // Active warnings - warnings, _ := db.GetActiveWarnings(p.ID, time.Now()) - - data := report.DashboardData{ - GeneratedAt: time.Now(), - ProfileName: p.Name, - Date: dateStr, - RiskLevel: dayRisk.Level.String(), - PeakTempC: dayRisk.PeakTempC, - MinNightTempC: dayRisk.MinNightTempC, - PoorNightCool: dayRisk.PoorNightCool, - } - - // Warnings - for _, w := range warnings { - data.Warnings = append(data.Warnings, report.WarningData{ - Headline: w.Headline, - Severity: w.Severity, - Description: w.Description, - Instruction: w.Instruction, - Onset: w.Onset.Format("2006-01-02 15:04"), - Expires: w.Expires.Format("2006-01-02 15:04"), - }) - } - - // Risk windows - for _, w := range dayRisk.Windows { - data.RiskWindows = append(data.RiskWindows, report.RiskWindowData{ - StartHour: w.StartHour, - EndHour: w.EndHour, - PeakTempC: w.PeakTempC, - Level: w.Level.String(), - Reason: w.Reason, - }) - } - - // Timeline + room budget computation - var peakBudgets []roomBudgetResult - for i, h := range hourlyData { - cloudPct := 50.0 - sunMin := 0.0 - if i < len(forecasts) { - if forecasts[i].CloudCoverPct != nil { - cloudPct = *forecasts[i].CloudCoverPct - } - if forecasts[i].SunshineMin != nil { - sunMin = *forecasts[i].SunshineMin - } - } - w := hourWeather{Hour: h.Hour, TempC: h.TempC, CloudCoverPct: cloudPct, SunshineMin: sunMin} - budgets, worstStatus := computeRoomBudgets(p.ID, w, toggles, 25.0) - - ctx := action.HourContext{ - Hour: h.Hour, - TempC: h.TempC, - HumidityPct: h.HumidityPct, - IsDay: h.IsDay, - RiskLevel: dayRisk.Level, - BudgetStatus: worstStatus, - ActiveToggles: toggles, - } - matched := action.SelectActions(actions, ctx) - slot := report.TimelineSlotData{ - Hour: h.Hour, - HourStr: fmt.Sprintf("%02d:00", h.Hour), - TempC: h.TempC, - RiskLevel: dayRisk.Level.String(), - BudgetStatus: worstStatus.String(), - } - for _, a := range matched { - slot.Actions = append(slot.Actions, report.ActionData{ - Name: a.Name, - Category: string(a.Category), - Effort: string(a.Effort), - Impact: string(a.Impact), - Description: a.Description, - }) - } - data.Timeline = append(data.Timeline, slot) - - // Track peak hour budgets for room budget section - if h.TempC == dayRisk.PeakTempC && len(budgets) > 0 { - peakBudgets = budgets - } - } - - // Room budgets (computed at peak temp hour) - for _, rb := range peakBudgets { - data.RoomBudgets = append(data.RoomBudgets, report.RoomBudgetData{ - RoomName: rb.RoomName, - InternalGainsW: rb.Result.InternalGainsW, - SolarGainW: rb.Result.SolarGainW, - VentGainW: rb.Result.VentilationGainW, - TotalGainW: rb.Result.TotalGainW, - TotalGainBTUH: rb.Result.TotalGainBTUH, - ACCapacityBTUH: rb.Result.ACCapacityBTUH, - HeadroomBTUH: rb.Result.HeadroomBTUH, - Status: rb.Result.Status.String(), - }) - } - - // Care checklist - occupants, _ := db.ListAllOccupants(p.ID) - for _, o := range occupants { - if o.Vulnerable { - data.CareChecklist = append(data.CareChecklist, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID)) - } - } - - // LLM summary (optional — gracefully degrades) - provider := getLLMProvider() - if provider.Name() != "noop" { - var warningHeadlines []string - for _, w := range warnings { - warningHeadlines = append(warningHeadlines, w.Headline) - } - var riskWindowSummaries []llm.RiskWindowSummary - for _, rw := range dayRisk.Windows { - riskWindowSummaries = append(riskWindowSummaries, llm.RiskWindowSummary{ - StartHour: rw.StartHour, - EndHour: rw.EndHour, - PeakTempC: rw.PeakTempC, - Level: rw.Level.String(), - }) - } - summaryBudgetStatus := "comfortable" - var summaryHeadroom float64 - if len(peakBudgets) > 0 { - summaryBudgetStatus = peakBudgets[0].Result.Status.String() - summaryHeadroom = peakBudgets[0].Result.HeadroomBTUH - for _, rb := range peakBudgets[1:] { - if rb.Result.Status > peakBudgets[0].Result.Status { - summaryBudgetStatus = rb.Result.Status.String() - } - if rb.Result.HeadroomBTUH < summaryHeadroom { - summaryHeadroom = rb.Result.HeadroomBTUH - } - } - } - - summaryInput := llm.SummaryInput{ - Date: dateStr, - PeakTempC: dayRisk.PeakTempC, - MinNightTempC: dayRisk.MinNightTempC, - RiskLevel: dayRisk.Level.String(), - ACHeadroomBTUH: summaryHeadroom, - BudgetStatus: summaryBudgetStatus, - ActiveWarnings: warningHeadlines, - RiskWindows: riskWindowSummaries, - } - - bgCtx := context.Background() - summary, err := provider.Summarize(bgCtx, summaryInput) - if err != nil { - if verbose { - fmt.Fprintf(os.Stderr, "LLM summary failed: %v\n", err) - } - } else if summary != "" { - data.LLMSummary = summary - data.LLMDisclaimer = "AI-generated summary. Not a substitute for professional advice." - } - } - - return data, nil -} - -func buildHourlyData(forecasts []store.Forecast, loc *time.Location) []risk.HourlyData { - var data []risk.HourlyData - for _, f := range forecasts { - tempC := 0.0 - if f.TemperatureC != nil { - tempC = *f.TemperatureC - } - apparentC := tempC - if f.ApparentTempC != nil { - apparentC = *f.ApparentTempC - } - humPct := 50.0 - if f.HumidityPct != nil { - humPct = *f.HumidityPct - } - h := f.Timestamp.In(loc).Hour() - data = append(data, risk.HourlyData{ - Hour: h, - TempC: tempC, - ApparentC: apparentC, - HumidityPct: humPct, - IsDay: h >= 6 && h < 21, - }) - } - return data -} - -func openBrowser(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "linux": - cmd = exec.Command("xdg-open", url) - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: - return - } - cmd.Start() -} diff --git a/internal/cli/room.go b/internal/cli/room.go deleted file mode 100644 index 0ab0c35..0000000 --- a/internal/cli/room.go +++ /dev/null @@ -1,119 +0,0 @@ -package cli - -import ( - "fmt" - "strconv" - - "github.com/cnachtigall/heatwave-autopilot/internal/store" - "github.com/spf13/cobra" -) - -var ( - roomSqm float64 - roomCeiling float64 - roomFloor int - roomOrient string - roomShading string - roomShadeFact float64 - roomVent string - roomVentACH float64 - roomWinFrac float64 - roomSHGC float64 - roomInsulation string -) - -func init() { - roomCmd := &cobra.Command{ - Use: "room", - Short: "Manage rooms", - } - - addCmd := &cobra.Command{ - Use: "add ", - Short: "Add a room", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - params := store.RoomParams{ - CeilingHeightM: roomCeiling, - VentilationACH: roomVentACH, - WindowFraction: roomWinFrac, - SHGC: roomSHGC, - } - r, err := db.CreateRoom(p.ID, args[0], roomSqm, roomFloor, roomOrient, roomShading, roomShadeFact, roomVent, roomInsulation, params) - if err != nil { - return err - } - fmt.Printf("Room added: %s (ID: %d, %.0f m², %s-facing)\n", r.Name, r.ID, r.AreaSqm, r.Orientation) - return nil - }, - } - addCmd.Flags().Float64Var(&roomSqm, "sqm", 0, "area in square meters") - addCmd.Flags().Float64Var(&roomCeiling, "ceiling", 2.5, "ceiling height in meters") - addCmd.Flags().IntVar(&roomFloor, "floor", 0, "floor number") - addCmd.Flags().StringVar(&roomOrient, "orientation", "N", "orientation (N/S/E/W/NE/NW/SE/SW)") - addCmd.Flags().StringVar(&roomShading, "shading", "none", "shading type") - addCmd.Flags().Float64Var(&roomShadeFact, "shading-factor", 1.0, "shading factor (0.0-1.0)") - addCmd.Flags().StringVar(&roomVent, "ventilation", "natural", "ventilation type") - addCmd.Flags().Float64Var(&roomVentACH, "ach", 0.5, "air changes per hour") - addCmd.Flags().Float64Var(&roomWinFrac, "window-fraction", 0.15, "window area as fraction of floor area") - addCmd.Flags().Float64Var(&roomSHGC, "shgc", 0.6, "solar heat gain coefficient of glazing") - addCmd.Flags().StringVar(&roomInsulation, "insulation", "average", "insulation level") - addCmd.MarkFlagRequired("sqm") - - listCmd := &cobra.Command{ - Use: "list", - Short: "List rooms", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - rooms, err := db.ListRooms(p.ID) - if err != nil { - return err - } - if len(rooms) == 0 { - fmt.Println("No rooms found") - return nil - } - for _, r := range rooms { - fmt.Printf(" [%d] %s — %.0f m², floor %d, %s-facing, shading: %s (%.1f)\n", - r.ID, r.Name, r.AreaSqm, r.Floor, r.Orientation, r.ShadingType, r.ShadingFactor) - } - return nil - }, - } - - editCmd := &cobra.Command{ - Use: "edit ", - Short: "Edit a room field", - Args: cobra.ExactArgs(3), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid room ID: %s", args[0]) - } - return db.UpdateRoom(id, args[1], args[2]) - }, - } - - removeCmd := &cobra.Command{ - Use: "remove ", - Short: "Remove a room", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid room ID: %s", args[0]) - } - return db.DeleteRoom(id) - }, - } - - roomCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd) - rootCmd.AddCommand(roomCmd) -} diff --git a/internal/cli/root.go b/internal/cli/root.go deleted file mode 100644 index a644941..0000000 --- a/internal/cli/root.go +++ /dev/null @@ -1,146 +0,0 @@ -package cli - -import ( - "fmt" - "os" - - "github.com/cnachtigall/heatwave-autopilot/internal/config" - "github.com/cnachtigall/heatwave-autopilot/internal/llm" - "github.com/cnachtigall/heatwave-autopilot/internal/store" - "github.com/spf13/cobra" -) - -var ( - dbPath string - verbose bool - profileName string - llmFlag string - - db *store.Store - cfg config.Config -) - -var rootCmd = &cobra.Command{ - Use: "heatwave", - Short: "Heatwave Autopilot — personalized heat preparedness", - Long: "A CLI tool that ingests weather forecasts, computes personalized heat budgets, and generates actionable hour-by-hour plans.", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - cfg = config.Load() - - if llmFlag != "" { - cfg.LLM.Provider = llmFlag - } - - if cmd.Name() == "version" { - return nil - } - - path := dbPath - if path == "" { - path = config.DefaultDBPath() - } - if err := os.MkdirAll(config.DataDir(), 0o755); err != nil { - return fmt.Errorf("create data dir: %w", err) - } - - var err error - db, err = store.New(path) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - return nil - }, - PersistentPostRun: func(cmd *cobra.Command, args []string) { - if db != nil { - db.Close() - } - }, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Heatwave Autopilot — use --help for available commands") - return nil - }, -} - -func init() { - rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "path to SQLite database") - rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enable verbose output") - rootCmd.PersistentFlags().StringVar(&profileName, "profile", "", "profile name to use") - rootCmd.PersistentFlags().StringVar(&llmFlag, "llm", "", "LLM provider (anthropic, openai, gemini, ollama, none)") -} - -// Execute runs the root command. -func Execute() error { - return rootCmd.Execute() -} - -// getActiveProfile resolves the current profile from --profile flag or first available. -func getActiveProfile() (*store.Profile, error) { - if profileName != "" { - return db.GetProfileByName(profileName) - } - profiles, err := db.ListProfiles() - if err != nil { - return nil, err - } - if len(profiles) == 0 { - return nil, fmt.Errorf("no profiles found — create one with: heatwave profile create --lat --lon ") - } - return &profiles[0], nil -} - -// getLLMProvider creates an LLM provider from DB settings, with env var / config fallback. -func getLLMProvider() llm.Provider { - // Determine provider: --llm flag > DB settings > config.yaml - provider := cfg.LLM.Provider - model := cfg.LLM.Model - endpoint := cfg.LLM.Endpoint - var dbKey string - - if db != nil { - if ls, err := db.GetLLMSettings(); err == nil && ls.Provider != "" { - if llmFlag == "" { // only use DB provider when no CLI flag override - provider = ls.Provider - } - model = ls.Model - endpoint = ls.Endpoint - if key, err := config.Decrypt(ls.APIKeyEnc); err == nil { - dbKey = key - } - } - } - - envKey := func(envVar string) string { - if dbKey != "" { - return dbKey - } - return os.Getenv(envVar) - } - - switch provider { - case "anthropic": - key := envKey("ANTHROPIC_API_KEY") - if key == "" { - fmt.Fprintln(os.Stderr, "Warning: no Anthropic API key configured, LLM features disabled") - return llm.NewNoop() - } - return llm.NewAnthropic(key, model, nil) - case "openai": - key := envKey("OPENAI_API_KEY") - if key == "" { - fmt.Fprintln(os.Stderr, "Warning: no OpenAI API key configured, LLM features disabled") - return llm.NewNoop() - } - return llm.NewOpenAI(key, model, nil) - case "gemini": - key := envKey("GEMINI_API_KEY") - if key == "" { - fmt.Fprintln(os.Stderr, "Warning: no Gemini API key configured, LLM features disabled") - return llm.NewNoop() - } - return llm.NewGemini(key, model, nil) - case "ollama": - return llm.NewOllama(model, endpoint, nil) - default: - return llm.NewNoop() - } -} diff --git a/internal/cli/summary.go b/internal/cli/summary.go deleted file mode 100644 index 383a9fe..0000000 --- a/internal/cli/summary.go +++ /dev/null @@ -1,117 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "time" - - "github.com/cnachtigall/heatwave-autopilot/internal/heat" - "github.com/cnachtigall/heatwave-autopilot/internal/llm" - "github.com/cnachtigall/heatwave-autopilot/internal/risk" - "github.com/spf13/cobra" -) - -var summaryDate string - -func init() { - summaryCmd := &cobra.Command{ - Use: "summary", - Short: "Generate a 3-bullet AI summary of the heat model", - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - - provider := getLLMProvider() - if provider.Name() == "none" { - return fmt.Errorf("LLM not configured. Set llm.provider in config or use --llm flag") - } - - dateStr := summaryDate - if dateStr == "" { - dateStr = time.Now().Format("2006-01-02") - } - date, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return fmt.Errorf("invalid date: %s", dateStr) - } - - loc, _ := time.LoadLocation(p.Timezone) - from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) - to := from.Add(24 * time.Hour) - - forecasts, err := db.GetForecasts(p.ID, from, to, "") - if err != nil || len(forecasts) == 0 { - return fmt.Errorf("no forecast data for %s", dateStr) - } - - hourlyData := buildHourlyData(forecasts, loc) - dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds()) - - // Build heat sources from devices - devices, _ := db.ListAllDevices(p.ID) - var sources []llm.HeatSource - for _, d := range devices { - sources = append(sources, llm.HeatSource{ - Name: d.Name, - Watts: d.WattsTypical * d.DutyCycle, - }) - } - - // AC headroom (simplified — sum all AC units) - acUnits, _ := db.ListACUnits(p.ID) - var totalACBTU float64 - for _, ac := range acUnits { - totalACBTU += ac.CapacityBTU - } - var totalGainW float64 - for _, s := range sources { - totalGainW += s.Watts - } - headroom := totalACBTU - heat.WattsToBTUH(totalGainW) - - // Warnings - warnings, _ := db.GetActiveWarnings(p.ID, time.Now()) - var warningStrs []string - for _, w := range warnings { - warningStrs = append(warningStrs, w.Headline) - } - - // Risk windows - var riskWindows []llm.RiskWindowSummary - for _, w := range dayRisk.Windows { - riskWindows = append(riskWindows, llm.RiskWindowSummary{ - StartHour: w.StartHour, - EndHour: w.EndHour, - PeakTempC: w.PeakTempC, - Level: w.Level.String(), - }) - } - - input := llm.SummaryInput{ - Date: dateStr, - PeakTempC: dayRisk.PeakTempC, - MinNightTempC: dayRisk.MinNightTempC, - RiskLevel: dayRisk.Level.String(), - TopHeatSources: sources, - ACHeadroomBTUH: headroom, - BudgetStatus: heat.Comfortable.String(), - ActiveWarnings: warningStrs, - RiskWindows: riskWindows, - } - - result, err := provider.Summarize(context.Background(), input) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "LLM call failed: %v\nFalling back to raw data:\n", err) - fmt.Printf("Peak: %.1f°C | Night min: %.1f°C | Risk: %s\n", dayRisk.PeakTempC, dayRisk.MinNightTempC, dayRisk.Level) - return nil - } - - fmt.Println(result) - return nil - }, - } - summaryCmd.Flags().StringVar(&summaryDate, "date", "", "date (YYYY-MM-DD)") - rootCmd.AddCommand(summaryCmd) -} diff --git a/internal/cli/templates/setup.html.tmpl b/internal/cli/templates/setup.html.tmpl deleted file mode 100644 index 7668de2..0000000 --- a/internal/cli/templates/setup.html.tmpl +++ /dev/null @@ -1,588 +0,0 @@ - - - - - -Heatwave Setup - - - - - - -
- -{{if .Flash}} -
- {{.Flash}} -
-{{end}} - -

Setup

-

- Active profile: {{if .Profile}}{{.Profile.Name}}{{else}}(none){{end}} -

- - - -{{template "profiles" .}} -{{template "rooms" .}} -{{template "devices" .}} -{{template "occupants" .}} -{{template "ac_units" .}} -{{template "toggles" .}} -{{template "forecast" .}} -{{template "llm" .}} - -
-

Heatwave Autopilot

-
- -
- - - -{{define "profiles"}} -
-

Profiles

- {{if .Profiles}} -
- - - - - - - - - - - - {{range .Profiles}} - - - - - - - - {{end}} - -
NameLatitudeLongitudeTimezone
{{.Name}}{{printf "%.4f" .Latitude}}{{printf "%.4f" .Longitude}}{{.Timezone}} -
- -
-
-
- {{else}} -

No profiles yet.

- {{end}} - -
-

Add Profile

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-{{end}} - -{{define "rooms"}} -
-

Rooms

- {{if not .Profile}} -

Create a profile first.

- {{else}} - {{if .Rooms}} -
- - - - - - - - - - - - - {{range .Rooms}} - - - - - - - - - {{end}} - -
NameArea (m²)FloorOrientationCeiling (m)
{{.Name}}{{printf "%.1f" .AreaSqm}}{{.Floor}}{{.Orientation}}{{printf "%.2f" .CeilingHeightM}} -
- -
-
-
- {{else}} -

No rooms yet.

- {{end}} - -
-

Add Room

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- {{end}} -
-{{end}} - -{{define "devices"}} -
-

Devices

- {{if not .Profile}} -

Create a profile first.

- {{else}} - {{if .Devices}} -
- - - - - - - - - - - - - - - {{range .Devices}} - - - - - - - - - - - {{end}} - -
NameTypeRoomIdle (W)Typical (W)Peak (W)Duty
{{.Name}}{{.DeviceType}}{{.RoomID}}{{printf "%.0f" .WattsIdle}}{{printf "%.0f" .WattsTypical}}{{printf "%.0f" .WattsPeak}}{{printf "%.0f%%" (mul .DutyCycle 100)}} -
- -
-
-
- {{else}} -

No devices yet.

- {{end}} - - {{if .Rooms}} -
-

Add Device

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- {{else}} -

Add a room first to create devices.

- {{end}} - {{end}} -
-{{end}} - -{{define "occupants"}} -
-

Occupants

- {{if not .Profile}} -

Create a profile first.

- {{else}} - {{if .Occupants}} -
- - - - - - - - - - - - {{range .Occupants}} - - - - - - - - {{end}} - -
RoomCountActivityVulnerable
{{.RoomID}}{{.Count}}{{.ActivityLevel}}{{if .Vulnerable}}Yes{{else}}No{{end}} -
- -
-
-
- {{else}} -

No occupants yet.

- {{end}} - - {{if .Rooms}} -
-

Add Occupant

-
-
- - -
-
- - -
-
- - -
-
- -
-
- -
- {{else}} -

Add a room first to create occupants.

- {{end}} - {{end}} -
-{{end}} - -{{define "ac_units"}} -
-

AC Units

- {{if not .Profile}} -

Create a profile first.

- {{else}} - {{if .ACUnits}} -
- {{range .ACUnits}} - {{$acID := .ID}} -
-
-
- {{.Name}} - {{.ACType}} — {{printf "%.0f" .CapacityBTU}} BTU/h — EER {{printf "%.1f" .EfficiencyEER}} - {{if .HasDehumidify}}Dehumidify{{end}} -
-
- -
-
- {{if $.Rooms}} -
- Assign to: - - -
- {{if .AssignedRoomIDs}} -
- {{range .AssignedRoomIDs}} -
- - -
- {{end}} -
- {{end}} - {{end}} -
- {{end}} -
- {{else}} -

No AC units yet.

- {{end}} - -
-

Add AC Unit

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- -
- {{end}} -
-{{end}} - -{{define "toggles"}} -
-

Scenario Toggles

- {{if not .Profile}} -

Create a profile first.

- {{else}} -
-
- {{range $name, $active := .Toggles}} -
- - - {{$name}} - -
- {{end}} -
- - - -
-
-
- {{end}} -
-{{end}} - -{{define "forecast"}} -
-

Forecast

- {{if not .Profile}} -

Create a profile first.

- {{else}} -
-

- Last fetched: {{if .LastFetch}}{{.LastFetch}}{{else}}never{{end}} -

-
- -
-
- {{end}} -
-{{end}} - -{{define "llm"}} -
-

LLM Provider

-
-
-
- - -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
-
- -
-
-{{end}} diff --git a/internal/cli/toggle.go b/internal/cli/toggle.go deleted file mode 100644 index cd9e811..0000000 --- a/internal/cli/toggle.go +++ /dev/null @@ -1,34 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -func init() { - toggleCmd := &cobra.Command{ - Use: "toggle ", - Short: "Toggle a scenario (gaming, cooking, ac-off)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - p, err := getActiveProfile() - if err != nil { - return err - } - name := args[0] - active, _ := db.GetActiveToggleNames(p.ID) - newState := !active[name] - if err := db.SetToggle(p.ID, name, newState); err != nil { - return err - } - state := "OFF" - if newState { - state = "ON" - } - fmt.Printf("Toggle %q: %s\n", name, state) - return nil - }, - } - rootCmd.AddCommand(toggleCmd) -} diff --git a/internal/cli/version.go b/internal/cli/version.go deleted file mode 100644 index ebad1f1..0000000 --- a/internal/cli/version.go +++ /dev/null @@ -1,20 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -const version = "0.1.0" - -func init() { - versionCmd := &cobra.Command{ - Use: "version", - Short: "Print version", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("heatwave-autopilot v%s\n", version) - }, - } - rootCmd.AddCommand(versionCmd) -} diff --git a/internal/cli/web.go b/internal/cli/web.go deleted file mode 100644 index 570e306..0000000 --- a/internal/cli/web.go +++ /dev/null @@ -1,57 +0,0 @@ -package cli - -import ( - "fmt" - "net" - "net/http" - "time" - - "github.com/spf13/cobra" -) - -var ( - webPort int - webDate string - webOpen bool -) - -func init() { - webCmd := &cobra.Command{ - Use: "web", - Short: "Start local web server with the heat dashboard", - Long: "Generates the heat report and serves it via a local HTTP server. Shortcut for 'heatwave report serve'.", - RunE: func(cmd *cobra.Command, args []string) error { - dateStr := webDate - if dateStr == "" { - dateStr = time.Now().Format("2006-01-02") - } - return runWebServer(dateStr, webPort, webOpen) - }, - } - webCmd.Flags().IntVar(&webPort, "port", 8080, "HTTP port to serve on") - webCmd.Flags().StringVar(&webDate, "date", "", "date (YYYY-MM-DD, default: today)") - webCmd.Flags().BoolVar(&webOpen, "open", true, "open browser automatically") - - rootCmd.AddCommand(webCmd) -} - -// runWebServer starts the web server with dashboard + setup UI. -func runWebServer(dateStr string, port int, open bool) error { - mux := http.NewServeMux() - registerSetupRoutes(mux, dateStr) - - addr := fmt.Sprintf(":%d", port) - ln, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("listen %s: %w", addr, err) - } - - url := fmt.Sprintf("http://localhost:%d", port) - fmt.Printf("Serving heat dashboard at %s (Ctrl+C to stop)\n", url) - - if open { - openBrowser(url) - } - - return http.Serve(ln, mux) -} diff --git a/internal/cli/web_handlers.go b/internal/cli/web_handlers.go deleted file mode 100644 index 8be9ec7..0000000 --- a/internal/cli/web_handlers.go +++ /dev/null @@ -1,527 +0,0 @@ -package cli - -import ( - _ "embed" - "fmt" - "html/template" - "net/http" - "strconv" - - "github.com/cnachtigall/heatwave-autopilot/internal/config" - "github.com/cnachtigall/heatwave-autopilot/internal/report" - "github.com/cnachtigall/heatwave-autopilot/internal/static" - "github.com/cnachtigall/heatwave-autopilot/internal/store" -) - -//go:embed templates/setup.html.tmpl -var setupTmplStr string - -var setupTmpl *template.Template - -func init() { - funcs := template.FuncMap{ - "mul": func(a, b float64) float64 { return a * b }, - "eq": func(a, b string) bool { return a == b }, - } - setupTmpl = template.Must(template.New("setup").Funcs(funcs).Parse(setupTmplStr)) -} - -// acRoomAssignment pairs a room ID with its name for display. -type acRoomAssignment struct { - RoomID int64 - RoomName string -} - -// acUnitView wraps an AC unit with its room assignments for the template. -type acUnitView struct { - store.ACUnit - AssignedRoomIDs []acRoomAssignment -} - -// setupData holds all data for the setup page template. -type setupData struct { - CSS template.CSS - Flash string - Profile *store.Profile - Profiles []store.Profile - Rooms []store.Room - Devices []store.Device - Occupants []store.Occupant - ACUnits []acUnitView - Toggles map[string]bool - LastFetch string - LLMProvider string - LLMModel string - LLMEndpoint string -} - -func loadSetupData(w http.ResponseWriter, r *http.Request) setupData { - sd := setupData{ - CSS: template.CSS(static.TailwindCSS), - } - sd.Flash = getFlash(w, r) - sd.Profiles, _ = db.ListProfiles() - - p, err := getActiveProfile() - if err != nil || p == nil { - return sd - } - sd.Profile = p - sd.Rooms, _ = db.ListRooms(p.ID) - sd.Devices, _ = db.ListAllDevices(p.ID) - sd.Occupants, _ = db.ListAllOccupants(p.ID) - - // Build room name lookup - roomNames := make(map[int64]string) - for _, r := range sd.Rooms { - roomNames[r.ID] = r.Name - } - - acUnits, _ := db.ListACUnits(p.ID) - for _, ac := range acUnits { - view := acUnitView{ACUnit: ac} - roomIDs, _ := db.GetACRoomAssignments(ac.ID) - for _, rid := range roomIDs { - view.AssignedRoomIDs = append(view.AssignedRoomIDs, acRoomAssignment{ - RoomID: rid, - RoomName: roomNames[rid], - }) - } - sd.ACUnits = append(sd.ACUnits, view) - } - - toggles, _ := db.GetToggles(p.ID) - sd.Toggles = make(map[string]bool) - for _, t := range toggles { - sd.Toggles[t.Name] = t.Active - } - - lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo") - if err == nil { - sd.LastFetch = lastFetch.Format("2006-01-02 15:04") - } - - llmSettings, _ := db.GetLLMSettings() - sd.LLMProvider = llmSettings.Provider - sd.LLMModel = llmSettings.Model - sd.LLMEndpoint = llmSettings.Endpoint - - return sd -} - -func setupHandler(w http.ResponseWriter, r *http.Request) { - sd := loadSetupData(w, r) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := setupTmpl.Execute(w, sd); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func dashboardHandler(dateStr string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data, err := buildReportData(dateStr) - if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, ` - - -
-

Dashboard

-
-

Cannot load dashboard

-

%s

-

Go to Setup to configure your profile and fetch forecast data.

-
-
`, static.TailwindCSS, err.Error()) - return - } - data.ShowNav = true - html, err := report.GenerateString(data) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(html)) - } -} - -// --- CRUD Handlers --- - -func profileAddHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - name := r.FormValue("name") - lat := parseFloatOr(r.FormValue("latitude"), 0) - lon := parseFloatOr(r.FormValue("longitude"), 0) - tz := r.FormValue("timezone") - if tz == "" { - tz = "Europe/Berlin" - } - _, err := db.CreateProfile(name, lat, lon, tz) - if err != nil { - setFlash(w, "Error creating profile: "+err.Error()) - } else { - setFlash(w, "Profile "+name+" created.") - } - http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) -} - -func profileDeleteHandler(w http.ResponseWriter, r *http.Request) { - id := parseIntOr(r.PathValue("id"), 0) - if id == 0 { - setFlash(w, "Invalid profile ID.") - http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) - return - } - if err := db.DeleteProfile(id); err != nil { - setFlash(w, "Error deleting profile: "+err.Error()) - } else { - setFlash(w, "Profile deleted.") - } - http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) -} - -func roomAddHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - p, err := getActiveProfile() - if err != nil { - setFlash(w, "No active profile.") - http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) - return - } - name := r.FormValue("name") - areaSqm := parseFloatOr(r.FormValue("area_sqm"), 20) - floor := int(parseIntOr(r.FormValue("floor"), 0)) - orientation := r.FormValue("orientation") - shadingType := r.FormValue("shading_type") - shadingFactor := parseFloatOr(r.FormValue("shading_factor"), 1.0) - ventilation := r.FormValue("ventilation") - insulation := r.FormValue("insulation") - params := store.RoomParams{ - CeilingHeightM: parseFloatOr(r.FormValue("ceiling_height_m"), 2.50), - VentilationACH: parseFloatOr(r.FormValue("ventilation_ach"), 1.5), - WindowFraction: parseFloatOr(r.FormValue("window_fraction"), 0.30), - SHGC: parseFloatOr(r.FormValue("shgc"), 0.60), - } - _, err = db.CreateRoom(p.ID, name, areaSqm, floor, orientation, shadingType, shadingFactor, ventilation, insulation, params) - if err != nil { - setFlash(w, "Error creating room: "+err.Error()) - } else { - setFlash(w, "Room "+name+" created.") - } - http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) -} - -func roomDeleteHandler(w http.ResponseWriter, r *http.Request) { - id := parseIntOr(r.PathValue("id"), 0) - if id == 0 { - setFlash(w, "Invalid room ID.") - http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) - return - } - if err := db.DeleteRoom(id); err != nil { - setFlash(w, "Error deleting room: "+err.Error()) - } else { - setFlash(w, "Room deleted.") - } - http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) -} - -func deviceAddHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - roomID := parseIntOr(r.FormValue("room_id"), 0) - name := r.FormValue("name") - deviceType := r.FormValue("device_type") - wattsIdle := parseFloatOr(r.FormValue("watts_idle"), 10) - wattsTypical := parseFloatOr(r.FormValue("watts_typical"), 80) - wattsPeak := parseFloatOr(r.FormValue("watts_peak"), 200) - dutyCycle := parseFloatOr(r.FormValue("duty_cycle"), 0.5) - _, err := db.CreateDevice(roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle) - if err != nil { - setFlash(w, "Error creating device: "+err.Error()) - } else { - setFlash(w, "Device "+name+" created.") - } - http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) -} - -func deviceDeleteHandler(w http.ResponseWriter, r *http.Request) { - id := parseIntOr(r.PathValue("id"), 0) - if id == 0 { - setFlash(w, "Invalid device ID.") - http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) - return - } - if err := db.DeleteDevice(id); err != nil { - setFlash(w, "Error deleting device: "+err.Error()) - } else { - setFlash(w, "Device deleted.") - } - http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) -} - -func occupantAddHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - roomID := parseIntOr(r.FormValue("room_id"), 0) - count := int(parseIntOr(r.FormValue("count"), 1)) - activityLevel := r.FormValue("activity_level") - vulnerable := r.FormValue("vulnerable") == "true" - _, err := db.CreateOccupant(roomID, count, activityLevel, vulnerable) - if err != nil { - setFlash(w, "Error creating occupant: "+err.Error()) - } else { - setFlash(w, "Occupant added.") - } - http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) -} - -func occupantDeleteHandler(w http.ResponseWriter, r *http.Request) { - id := parseIntOr(r.PathValue("id"), 0) - if id == 0 { - setFlash(w, "Invalid occupant ID.") - http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) - return - } - if err := db.DeleteOccupant(id); err != nil { - setFlash(w, "Error deleting occupant: "+err.Error()) - } else { - setFlash(w, "Occupant deleted.") - } - http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) -} - -func acAddHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - p, err := getActiveProfile() - if err != nil { - setFlash(w, "No active profile.") - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) - return - } - name := r.FormValue("name") - acType := r.FormValue("ac_type") - capacityBTU := parseFloatOr(r.FormValue("capacity_btu"), 12000) - efficiencyEER := parseFloatOr(r.FormValue("efficiency_eer"), 10.0) - hasDehumidify := r.FormValue("has_dehumidify") == "true" - _, err = db.CreateACUnit(p.ID, name, acType, capacityBTU, hasDehumidify, efficiencyEER) - if err != nil { - setFlash(w, "Error creating AC unit: "+err.Error()) - } else { - setFlash(w, "AC unit "+name+" created.") - } - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) -} - -func acDeleteHandler(w http.ResponseWriter, r *http.Request) { - id := parseIntOr(r.PathValue("id"), 0) - if id == 0 { - setFlash(w, "Invalid AC unit ID.") - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) - return - } - if err := db.DeleteACUnit(id); err != nil { - setFlash(w, "Error deleting AC unit: "+err.Error()) - } else { - setFlash(w, "AC unit deleted.") - } - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) -} - -func acAssignHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - acID := parseIntOr(r.PathValue("id"), 0) - roomID := parseIntOr(r.FormValue("room_id"), 0) - if acID == 0 || roomID == 0 { - setFlash(w, "Invalid AC or room ID.") - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) - return - } - if err := db.AssignACToRoom(acID, roomID); err != nil { - setFlash(w, "Error assigning AC: "+err.Error()) - } else { - setFlash(w, "AC unit assigned to room.") - } - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) -} - -func acUnassignHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - acID := parseIntOr(r.PathValue("id"), 0) - roomID := parseIntOr(r.FormValue("room_id"), 0) - if acID == 0 || roomID == 0 { - setFlash(w, "Invalid AC or room ID.") - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) - return - } - if err := db.UnassignACFromRoom(acID, roomID); err != nil { - setFlash(w, "Error unassigning AC: "+err.Error()) - } else { - setFlash(w, "AC unit unassigned from room.") - } - http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) -} - -func toggleSetHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - p, err := getActiveProfile() - if err != nil { - setFlash(w, "No active profile.") - http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) - return - } - name := r.FormValue("name") - active := r.FormValue("active") == "true" - if name == "" { - setFlash(w, "Toggle name is required.") - http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) - return - } - if err := db.SetToggle(p.ID, name, active); err != nil { - setFlash(w, "Error setting toggle: "+err.Error()) - } else { - state := "OFF" - if active { - state = "ON" - } - setFlash(w, fmt.Sprintf("Toggle %q set to %s.", name, state)) - } - http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) -} - -func forecastFetchHandler(w http.ResponseWriter, r *http.Request) { - p, err := getActiveProfile() - if err != nil { - setFlash(w, "No active profile.") - http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther) - return - } - if err := fetchForecastForProfile(p); err != nil { - setFlash(w, "Forecast fetch failed: "+err.Error()) - } else { - setFlash(w, "Forecast fetched successfully.") - } - http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther) -} - -func llmSaveHandler(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - provider := r.FormValue("provider") - - var model, endpoint, apiKey string - switch provider { - case "anthropic", "openai", "gemini": - model = r.FormValue("cloud_model") - apiKey = r.FormValue("api_key") - case "ollama": - model = r.FormValue("local_model") - endpoint = r.FormValue("endpoint") - } - - apiKeyEnc := "" - if apiKey != "" { - var err error - apiKeyEnc, err = config.Encrypt(apiKey) - if err != nil { - setFlash(w, "Error encrypting API key: "+err.Error()) - http.Redirect(w, r, "/setup#llm", http.StatusSeeOther) - return - } - } else { - // Preserve existing encrypted key if no new one was submitted. - existing, _ := db.GetLLMSettings() - apiKeyEnc = existing.APIKeyEnc - } - - ls := &store.LLMSettings{ - Provider: provider, - Model: model, - Endpoint: endpoint, - APIKeyEnc: apiKeyEnc, - } - if err := db.SaveLLMSettings(ls); err != nil { - setFlash(w, "Error saving LLM settings: "+err.Error()) - } else { - setFlash(w, "LLM settings saved.") - } - http.Redirect(w, r, "/setup#llm", http.StatusSeeOther) -} - -// --- Flash helpers --- - -const flashCookieName = "heatwave_flash" - -func setFlash(w http.ResponseWriter, msg string) { - http.SetCookie(w, &http.Cookie{ - Name: flashCookieName, - Value: msg, - Path: "/", - MaxAge: 10, - SameSite: http.SameSiteStrictMode, - }) -} - -func getFlash(w http.ResponseWriter, r *http.Request) string { - c, err := r.Cookie(flashCookieName) - if err != nil { - return "" - } - // Clear the flash cookie immediately - http.SetCookie(w, &http.Cookie{ - Name: flashCookieName, - Value: "", - Path: "/", - MaxAge: -1, - }) - return c.Value -} - -// --- Parse helpers --- - -func parseFloatOr(s string, def float64) float64 { - v, err := strconv.ParseFloat(s, 64) - if err != nil { - return def - } - return v -} - -func parseIntOr(s string, def int64) int64 { - v, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return def - } - return v -} - -// registerSetupRoutes registers all setup-related routes on the mux. -func registerSetupRoutes(mux *http.ServeMux, dateStr string) { - mux.HandleFunc("GET /{$}", dashboardHandler(dateStr)) - mux.HandleFunc("GET /setup", setupHandler) - - mux.HandleFunc("POST /setup/profiles/add", profileAddHandler) - mux.HandleFunc("POST /setup/profiles/{id}/delete", profileDeleteHandler) - - mux.HandleFunc("POST /setup/rooms/add", roomAddHandler) - mux.HandleFunc("POST /setup/rooms/{id}/delete", roomDeleteHandler) - - mux.HandleFunc("POST /setup/devices/add", deviceAddHandler) - mux.HandleFunc("POST /setup/devices/{id}/delete", deviceDeleteHandler) - - mux.HandleFunc("POST /setup/occupants/add", occupantAddHandler) - mux.HandleFunc("POST /setup/occupants/{id}/delete", occupantDeleteHandler) - - mux.HandleFunc("POST /setup/ac/add", acAddHandler) - mux.HandleFunc("POST /setup/ac/{id}/delete", acDeleteHandler) - mux.HandleFunc("POST /setup/ac/{id}/assign", acAssignHandler) - mux.HandleFunc("POST /setup/ac/{id}/unassign", acUnassignHandler) - - mux.HandleFunc("POST /setup/toggles/set", toggleSetHandler) - mux.HandleFunc("POST /setup/forecast/fetch", forecastFetchHandler) - mux.HandleFunc("POST /setup/llm/save", llmSaveHandler) -} diff --git a/internal/compute/compute.go b/internal/compute/compute.go new file mode 100644 index 0000000..641f54d --- /dev/null +++ b/internal/compute/compute.go @@ -0,0 +1,320 @@ +package compute + +import ( + "fmt" + "time" + + "github.com/cnachtigall/heatwave-autopilot/internal/action" + "github.com/cnachtigall/heatwave-autopilot/internal/heat" + "github.com/cnachtigall/heatwave-autopilot/internal/risk" +) + +// BuildDashboard computes all dashboard data from the provided request. +// All data is passed in-memory — no DB calls. +func BuildDashboard(req ComputeRequest) (DashboardData, error) { + if len(req.Forecasts) == 0 { + return DashboardData{}, fmt.Errorf("no forecast data for %s", req.Date) + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return DashboardData{}, fmt.Errorf("invalid date: %s", req.Date) + } + + loc, err := time.LoadLocation(req.Profile.Timezone) + if err != nil { + loc = time.UTC + } + + from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) + to := from.Add(24 * time.Hour) + + // Filter forecasts for target date + var dayForecasts []Forecast + for _, f := range req.Forecasts { + if !f.Timestamp.Before(from) && f.Timestamp.Before(to) { + dayForecasts = append(dayForecasts, f) + } + } + if len(dayForecasts) == 0 { + dayForecasts = req.Forecasts + } + + // Build risk analysis + hourlyData := buildHourlyData(dayForecasts, loc) + th := risk.DefaultThresholds() + dayRisk := risk.AnalyzeDay(hourlyData, th) + + // Load actions + actions, _ := action.LoadDefaultActions() + + toggles := req.Toggles + if toggles == nil { + toggles = map[string]bool{} + } + + data := DashboardData{ + GeneratedAt: time.Now(), + ProfileName: req.Profile.Name, + Date: req.Date, + RiskLevel: dayRisk.Level.String(), + PeakTempC: dayRisk.PeakTempC, + MinNightTempC: dayRisk.MinNightTempC, + PoorNightCool: dayRisk.PoorNightCool, + } + + // Warnings (pass-through from client) + for _, w := range req.Warnings { + data.Warnings = append(data.Warnings, WarningData{ + Headline: w.Headline, + Severity: w.Severity, + Description: w.Description, + Instruction: w.Instruction, + Onset: w.Onset, + Expires: w.Expires, + }) + } + + // Risk windows + for _, w := range dayRisk.Windows { + data.RiskWindows = append(data.RiskWindows, RiskWindowData{ + StartHour: w.StartHour, + EndHour: w.EndHour, + PeakTempC: w.PeakTempC, + Level: w.Level.String(), + Reason: w.Reason, + }) + } + + // Timeline + room budget computation + var peakBudgets []roomBudgetResult + for i, h := range hourlyData { + cloudPct := 50.0 + sunMin := 0.0 + if i < len(dayForecasts) { + if dayForecasts[i].CloudCoverPct != nil { + cloudPct = *dayForecasts[i].CloudCoverPct + } + if dayForecasts[i].SunshineMin != nil { + sunMin = *dayForecasts[i].SunshineMin + } + } + + budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) + + ctx := action.HourContext{ + Hour: h.Hour, + TempC: h.TempC, + HumidityPct: h.HumidityPct, + IsDay: h.IsDay, + RiskLevel: dayRisk.Level, + BudgetStatus: worstStatus, + ActiveToggles: toggles, + } + matched := action.SelectActions(actions, ctx) + + slot := TimelineSlotData{ + Hour: h.Hour, + HourStr: fmt.Sprintf("%02d:00", h.Hour), + TempC: h.TempC, + HumidityPct: h.HumidityPct, + RiskLevel: dayRisk.Level.String(), + BudgetStatus: worstStatus.String(), + } + for _, a := range matched { + slot.Actions = append(slot.Actions, ActionData{ + Name: a.Name, + Category: string(a.Category), + Effort: string(a.Effort), + Impact: string(a.Impact), + Description: a.Description, + }) + } + data.Timeline = append(data.Timeline, slot) + + if h.TempC == dayRisk.PeakTempC && len(budgets) > 0 { + peakBudgets = budgets + } + } + + // Room budgets (computed at peak temp hour) + for _, rb := range peakBudgets { + data.RoomBudgets = append(data.RoomBudgets, RoomBudgetData{ + RoomName: rb.RoomName, + InternalGainsW: rb.Result.InternalGainsW, + SolarGainW: rb.Result.SolarGainW, + VentGainW: rb.Result.VentilationGainW, + TotalGainW: rb.Result.TotalGainW, + TotalGainBTUH: rb.Result.TotalGainBTUH, + ACCapacityBTUH: rb.Result.ACCapacityBTUH, + HeadroomBTUH: rb.Result.HeadroomBTUH, + Status: rb.Result.Status.String(), + }) + } + + // Care checklist + for _, o := range req.Occupants { + if o.Vulnerable { + roomName := roomNameByID(req.Rooms, o.RoomID) + data.CareChecklist = append(data.CareChecklist, fmt.Sprintf("Check vulnerable occupant in %s at 10:00, 14:00, 18:00", roomName)) + } + } + + return data, nil +} + +func buildHourlyData(forecasts []Forecast, loc *time.Location) []risk.HourlyData { + var data []risk.HourlyData + for _, f := range forecasts { + tempC := 0.0 + if f.TemperatureC != nil { + tempC = *f.TemperatureC + } + apparentC := tempC + if f.ApparentTempC != nil { + apparentC = *f.ApparentTempC + } + humPct := 50.0 + if f.HumidityPct != nil { + humPct = *f.HumidityPct + } + h := f.Timestamp.In(loc).Hour() + data = append(data, risk.HourlyData{ + Hour: h, + TempC: tempC, + ApparentC: apparentC, + HumidityPct: humPct, + IsDay: h >= 6 && h < 21, + }) + } + return data +} + +type roomBudgetResult struct { + RoomName string + RoomID int64 + Result heat.BudgetResult +} + +func computeRoomBudgets(req ComputeRequest, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) ([]roomBudgetResult, heat.BudgetStatus) { + if len(req.Rooms) == 0 { + return nil, heat.Comfortable + } + + var results []roomBudgetResult + worstStatus := heat.Comfortable + + for _, room := range req.Rooms { + budget := computeSingleRoomBudget(req, room, hour, tempC, cloudPct, sunMin, toggles) + results = append(results, roomBudgetResult{ + RoomName: room.Name, + RoomID: room.ID, + Result: budget, + }) + if budget.Status > worstStatus { + worstStatus = budget.Status + } + } + + return results, worstStatus +} + +func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) heat.BudgetResult { + indoorTempC := room.IndoorTempC + if indoorTempC == 0 { + indoorTempC = 25.0 + } + // Filter devices for this room + var heatDevices []heat.Device + for _, d := range req.Devices { + if d.RoomID == room.ID { + heatDevices = append(heatDevices, heat.Device{ + WattsIdle: d.WattsIdle, + WattsTypical: d.WattsTypical, + WattsPeak: d.WattsPeak, + DutyCycle: d.DutyCycle, + }) + } + } + + mode := heat.ModeTypical + if toggles["gaming"] { + mode = heat.ModePeak + } + + // Filter occupants for this room + var heatOccupants []heat.Occupant + for _, o := range req.Occupants { + if o.RoomID == room.ID { + heatOccupants = append(heatOccupants, heat.Occupant{ + Count: o.Count, + Activity: heat.ParseActivityLevel(o.ActivityLevel), + }) + } + } + + // AC capacity for this room + acCap := roomACCapacity(req.ACUnits, req.ACAssignments, room.ID) + + // Solar params + cloudFactor := 1.0 - (cloudPct / 100.0) + sunshineFraction := 0.0 + if sunMin > 0 { + sunshineFraction = sunMin / 60.0 + if sunshineFraction > 1.0 { + sunshineFraction = 1.0 + } + } + + solar := heat.SolarParams{ + AreaSqm: room.AreaSqm, + WindowFraction: room.WindowFraction, + SHGC: room.SHGC, + ShadingFactor: room.ShadingFactor, + OrientationFactor: heat.OrientationFactor(room.Orientation, hour), + CloudFactor: cloudFactor, + SunshineFraction: sunshineFraction, + PeakIrradiance: 800, + } + + volume := room.AreaSqm * room.CeilingHeightM + vent := heat.VentilationParams{ + ACH: room.VentilationACH, + VolumeCubicM: volume, + OutdoorTempC: tempC, + IndoorTempC: indoorTempC, + RhoCp: 1.2, + } + + return heat.ComputeRoomBudget(heat.BudgetInput{ + Devices: heatDevices, + DeviceMode: mode, + Occupants: heatOccupants, + Solar: solar, + Ventilation: vent, + ACCapacityBTUH: acCap, + }) +} + +func roomACCapacity(units []ACUnit, assignments []ACAssignment, roomID int64) float64 { + var total float64 + for _, a := range assignments { + if a.RoomID == roomID { + for _, u := range units { + if u.ID == a.ACID { + total += u.CapacityBTU + } + } + } + } + return total +} + +func roomNameByID(rooms []Room, id int64) string { + for _, r := range rooms { + if r.ID == id { + return r.Name + } + } + return fmt.Sprintf("room %d", id) +} diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go new file mode 100644 index 0000000..bb442fa --- /dev/null +++ b/internal/compute/compute_test.go @@ -0,0 +1,263 @@ +package compute + +import ( + "testing" + "time" +) + +func ptr(f float64) *float64 { return &f } + +func makeForecasts(baseTime time.Time, temps []float64) []Forecast { + var forecasts []Forecast + for i, t := range temps { + ts := baseTime.Add(time.Duration(i) * time.Hour) + temp := t + humid := 50.0 + cloud := 50.0 + sun := 30.0 + apparent := t + forecasts = append(forecasts, Forecast{ + Timestamp: ts, + TemperatureC: &temp, + HumidityPct: &humid, + CloudCoverPct: &cloud, + SunshineMin: &sun, + ApparentTempC: &apparent, + }) + } + return forecasts +} + +func TestBuildDashboard_NoForecasts(t *testing.T) { + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "Europe/Berlin"}, + Date: "2025-07-15", + } + _, err := BuildDashboard(req) + if err == nil { + t.Fatal("expected error for empty forecasts") + } +} + +func TestBuildDashboard_BasicComputation(t *testing.T) { + loc, _ := time.LoadLocation("Europe/Berlin") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // 24 hours: mild night, hot afternoon + temps := make([]float64, 24) + for i := range temps { + switch { + case i < 6: + temps[i] = 18 + case i < 10: + temps[i] = 22 + float64(i-6)*2 + case i < 16: + temps[i] = 30 + float64(i-10)*1.5 + case i < 20: + temps[i] = 37 - float64(i-16)*2 + default: + temps[i] = 22 - float64(i-20) + } + } + + req := ComputeRequest{ + Profile: Profile{Name: "Berlin", Timezone: "Europe/Berlin"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ + ID: 1, ProfileID: 1, Name: "Office", + AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", + ShadingFactor: 0.8, VentilationACH: 0.5, + WindowFraction: 0.15, SHGC: 0.6, + }}, + Devices: []Device{{ + ID: 1, RoomID: 1, Name: "PC", + WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0, + }}, + Occupants: []Occupant{{ + ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary", + }}, + ACUnits: []ACUnit{{ + ID: 1, ProfileID: 1, Name: "Portable AC", + CapacityBTU: 8000, EfficiencyEER: 10, + }}, + ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.ProfileName != "Berlin" { + t.Errorf("got profile name %q, want %q", data.ProfileName, "Berlin") + } + if data.Date != "2025-07-15" { + t.Errorf("got date %q, want %q", data.Date, "2025-07-15") + } + if len(data.Timeline) != 24 { + t.Errorf("got %d timeline slots, want 24", len(data.Timeline)) + } + if data.PeakTempC == 0 { + t.Error("peak temp should be > 0") + } + if data.RiskLevel == "" { + t.Error("risk level should not be empty") + } + if len(data.RoomBudgets) == 0 { + t.Error("room budgets should not be empty") + } +} + +func TestBuildDashboard_VulnerableOccupants(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + temps := make([]float64, 24) + for i := range temps { + temps[i] = 32 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}}, + Occupants: []Occupant{ + {ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary", Vulnerable: true}, + }, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data.CareChecklist) == 0 { + t.Error("expected care checklist for vulnerable occupant") + } +} + +func TestBuildDashboard_GamingToggle(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + temps := make([]float64, 24) + for i := range temps { + temps[i] = 35 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}}, + Devices: []Device{{ + ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0, + }}, + Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}}, + Toggles: map[string]bool{"gaming": true}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Gaming mode should increase heat load vs non-gaming + if len(data.RoomBudgets) == 0 { + t.Error("expected room budgets") + } +} + +func TestBuildDashboard_Warnings(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + temps := make([]float64, 24) + for i := range temps { + temps[i] = 30 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Warnings: []Warning{{ + Headline: "Heat Warning", + Severity: "Severe", + Description: "Extreme heat expected", + Instruction: "Stay hydrated", + Onset: "2025-07-15 06:00", + Expires: "2025-07-15 22:00", + }}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data.Warnings) != 1 { + t.Errorf("got %d warnings, want 1", len(data.Warnings)) + } + if data.Warnings[0].Headline != "Heat Warning" { + t.Errorf("got headline %q, want %q", data.Warnings[0].Headline, "Heat Warning") + } +} + +func TestBuildDashboard_InvalidDate(t *testing.T) { + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: []Forecast{{Timestamp: time.Now()}}, + Date: "not-a-date", + } + _, err := BuildDashboard(req) + if err == nil { + t.Fatal("expected error for invalid date") + } +} + +func TestBuildDashboard_MultipleRooms(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + temps := make([]float64, 24) + for i := range temps { + temps[i] = 35 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{ + {ID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}, + {ID: 2, Name: "Bedroom", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1.0, VentilationACH: 0.3, WindowFraction: 0.1, SHGC: 0.4}, + }, + Devices: []Device{ + {ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0}, + {ID: 2, RoomID: 2, Name: "Lamp", WattsIdle: 10, WattsTypical: 60, WattsPeak: 60, DutyCycle: 0.5}, + }, + Occupants: []Occupant{ + {ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}, + {ID: 2, RoomID: 2, Count: 2, ActivityLevel: "sleeping"}, + }, + ACUnits: []ACUnit{ + {ID: 1, ProfileID: 1, Name: "AC1", CapacityBTU: 8000}, + }, + ACAssignments: []ACAssignment{ + {ACID: 1, RoomID: 1}, + {ACID: 1, RoomID: 2}, + }, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data.RoomBudgets) != 2 { + t.Errorf("got %d room budgets, want 2", len(data.RoomBudgets)) + } +} diff --git a/internal/compute/types.go b/internal/compute/types.go new file mode 100644 index 0000000..c1ae51c --- /dev/null +++ b/internal/compute/types.go @@ -0,0 +1,171 @@ +package compute + +import "time" + +// Profile holds location profile data sent from the client. +type Profile struct { + ID int64 `json:"id"` + Name string `json:"name"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Timezone string `json:"timezone"` +} + +// Room holds room parameters sent from the client. +type Room struct { + ID int64 `json:"id"` + ProfileID int64 `json:"profileId"` + Name string `json:"name"` + AreaSqm float64 `json:"areaSqm"` + CeilingHeightM float64 `json:"ceilingHeightM"` + Floor int `json:"floor"` + Orientation string `json:"orientation"` + ShadingType string `json:"shadingType"` + ShadingFactor float64 `json:"shadingFactor"` + Ventilation string `json:"ventilation"` + VentilationACH float64 `json:"ventilationAch"` + WindowFraction float64 `json:"windowFraction"` + SHGC float64 `json:"shgc"` + Insulation string `json:"insulation"` + IndoorTempC float64 `json:"indoorTempC"` +} + +// Device holds device data sent from the client. +type Device struct { + ID int64 `json:"id"` + RoomID int64 `json:"roomId"` + Name string `json:"name"` + DeviceType string `json:"deviceType"` + WattsIdle float64 `json:"wattsIdle"` + WattsTypical float64 `json:"wattsTypical"` + WattsPeak float64 `json:"wattsPeak"` + DutyCycle float64 `json:"dutyCycle"` +} + +// Occupant holds occupant data sent from the client. +type Occupant struct { + ID int64 `json:"id"` + RoomID int64 `json:"roomId"` + Count int `json:"count"` + ActivityLevel string `json:"activityLevel"` + Vulnerable bool `json:"vulnerable"` +} + +// ACUnit holds AC unit data sent from the client. +type ACUnit struct { + ID int64 `json:"id"` + ProfileID int64 `json:"profileId"` + Name string `json:"name"` + ACType string `json:"acType"` + CapacityBTU float64 `json:"capacityBtu"` + HasDehumidify bool `json:"hasDehumidify"` + EfficiencyEER float64 `json:"efficiencyEer"` +} + +// ACAssignment maps an AC unit to a room. +type ACAssignment struct { + ACID int64 `json:"acId"` + RoomID int64 `json:"roomId"` +} + +// Forecast holds hourly forecast data sent from the client. +type Forecast struct { + Timestamp time.Time `json:"timestamp"` + TemperatureC *float64 `json:"temperatureC"` + HumidityPct *float64 `json:"humidityPct"` + CloudCoverPct *float64 `json:"cloudCoverPct"` + SunshineMin *float64 `json:"sunshineMin"` + ApparentTempC *float64 `json:"apparentTempC"` +} + +// Warning holds a weather warning sent from the client. +type Warning struct { + Headline string `json:"headline"` + Severity string `json:"severity"` + Description string `json:"description"` + Instruction string `json:"instruction"` + Onset string `json:"onset"` + Expires string `json:"expires"` +} + +// ComputeRequest holds all data needed to compute a dashboard. +type ComputeRequest struct { + Profile Profile `json:"profile"` + Rooms []Room `json:"rooms"` + Devices []Device `json:"devices"` + Occupants []Occupant `json:"occupants"` + ACUnits []ACUnit `json:"acUnits"` + ACAssignments []ACAssignment `json:"acAssignments"` + Toggles map[string]bool `json:"toggles"` + Forecasts []Forecast `json:"forecasts"` + Warnings []Warning `json:"warnings"` + Date string `json:"date"` +} + +// DashboardData holds all computed data for the dashboard response. +type DashboardData struct { + GeneratedAt time.Time `json:"generatedAt"` + ProfileName string `json:"profileName"` + Date string `json:"date"` + RiskLevel string `json:"riskLevel"` + PeakTempC float64 `json:"peakTempC"` + MinNightTempC float64 `json:"minNightTempC"` + PoorNightCool bool `json:"poorNightCool"` + Warnings []WarningData `json:"warnings"` + RiskWindows []RiskWindowData `json:"riskWindows"` + Timeline []TimelineSlotData `json:"timeline"` + RoomBudgets []RoomBudgetData `json:"roomBudgets"` + CareChecklist []string `json:"careChecklist"` +} + +// WarningData holds a weather warning for display. +type WarningData struct { + Headline string `json:"headline"` + Severity string `json:"severity"` + Description string `json:"description"` + Instruction string `json:"instruction"` + Onset string `json:"onset"` + Expires string `json:"expires"` +} + +// RiskWindowData holds a risk window for display. +type RiskWindowData struct { + StartHour int `json:"startHour"` + EndHour int `json:"endHour"` + PeakTempC float64 `json:"peakTempC"` + Level string `json:"level"` + Reason string `json:"reason"` +} + +// TimelineSlotData holds one hour's data for the timeline. +type TimelineSlotData struct { + Hour int `json:"hour"` + HourStr string `json:"hourStr"` + TempC float64 `json:"tempC"` + HumidityPct float64 `json:"humidityPct"` + RiskLevel string `json:"riskLevel"` + BudgetStatus string `json:"budgetStatus"` + Actions []ActionData `json:"actions"` +} + +// ActionData holds a single action for display. +type ActionData struct { + Name string `json:"name"` + Category string `json:"category"` + Effort string `json:"effort"` + Impact string `json:"impact"` + Description string `json:"description"` +} + +// RoomBudgetData holds a room's heat budget for display. +type RoomBudgetData struct { + RoomName string `json:"roomName"` + InternalGainsW float64 `json:"internalGainsW"` + SolarGainW float64 `json:"solarGainW"` + VentGainW float64 `json:"ventGainW"` + TotalGainW float64 `json:"totalGainW"` + TotalGainBTUH float64 `json:"totalGainBtuh"` + ACCapacityBTUH float64 `json:"acCapacityBtuh"` + HeadroomBTUH float64 `json:"headroomBtuh"` + Status string `json:"status"` +} diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go index ca1d767..3a659cf 100644 --- a/internal/llm/prompt.go +++ b/internal/llm/prompt.go @@ -32,6 +32,9 @@ Rules: // BuildSummaryPrompt constructs the user message for Summarize. func BuildSummaryPrompt(input SummaryInput) string { var b strings.Builder + if input.Language != "" { + fmt.Fprintf(&b, "Respond in: %s\n\n", input.Language) + } fmt.Fprintf(&b, "Date: %s\n", input.Date) fmt.Fprintf(&b, "Peak temperature: %.1f°C\n", input.PeakTempC) fmt.Fprintf(&b, "Minimum night temperature: %.1f°C\n", input.MinNightTempC) diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 22aaf9b..c131d14 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -35,6 +35,7 @@ type SummaryInput struct { BudgetStatus string ActiveWarnings []string RiskWindows []RiskWindowSummary + Language string } // ActionInput holds data for rewriting a technical action. diff --git a/internal/report/data.go b/internal/report/data.go deleted file mode 100644 index fcbe5ce..0000000 --- a/internal/report/data.go +++ /dev/null @@ -1,73 +0,0 @@ -package report - -import "time" - -// DashboardData holds all data needed to render the HTML report. -type DashboardData struct { - GeneratedAt time.Time - ProfileName string - Date string - ShowNav bool - Warnings []WarningData - RiskLevel string - PeakTempC float64 - MinNightTempC float64 - PoorNightCool bool - RiskWindows []RiskWindowData - Timeline []TimelineSlotData - RoomBudgets []RoomBudgetData - CareChecklist []string - LLMSummary string - LLMDisclaimer string -} - -// WarningData holds a weather warning for display. -type WarningData struct { - Headline string - Severity string - Description string - Instruction string - Onset string - Expires string -} - -// RiskWindowData holds a risk window for display. -type RiskWindowData struct { - StartHour int - EndHour int - PeakTempC float64 - Level string - Reason string -} - -// TimelineSlotData holds one hour's data for the timeline. -type TimelineSlotData struct { - Hour int - HourStr string - TempC float64 - RiskLevel string - BudgetStatus string - Actions []ActionData -} - -// ActionData holds a single action for display. -type ActionData struct { - Name string - Category string - Effort string - Impact string - Description string -} - -// RoomBudgetData holds a room's heat budget for display. -type RoomBudgetData struct { - RoomName string - InternalGainsW float64 - SolarGainW float64 - VentGainW float64 - TotalGainW float64 - TotalGainBTUH float64 - ACCapacityBTUH float64 - HeadroomBTUH float64 - Status string -} diff --git a/internal/report/generator.go b/internal/report/generator.go deleted file mode 100644 index 0a5d5a9..0000000 --- a/internal/report/generator.go +++ /dev/null @@ -1,155 +0,0 @@ -package report - -import ( - "bytes" - _ "embed" - "fmt" - "html/template" - "io" - - "github.com/cnachtigall/heatwave-autopilot/internal/static" -) - -//go:embed templates/dashboard.html.tmpl -var dashboardTmpl string - -// templateData extends DashboardData with CSS for the template. -type templateData struct { - DashboardData - CSS template.CSS -} - -var funcMap = template.FuncMap{ - "formatTemp": formatTemp, - "formatWatts": formatWatts, - "formatBTU": formatBTU, - "tempColor": tempColor, - "riskBadge": riskBadge, - "riskBg": riskBg, - "riskBadgeBg": riskBadgeBg, - "statusBadge": statusBadge, - "statusColor": statusColor, - "statusBadgeBg": statusBadgeBg, -} - -// Generate renders the dashboard HTML to the given writer. -func Generate(w io.Writer, data DashboardData) error { - tmpl, err := template.New("dashboard").Funcs(funcMap).Parse(dashboardTmpl) - if err != nil { - return fmt.Errorf("parse template: %w", err) - } - - td := templateData{ - DashboardData: data, - CSS: template.CSS(static.TailwindCSS), - } - return tmpl.Execute(w, td) -} - -// GenerateString renders the dashboard HTML to a string. -func GenerateString(data DashboardData) (string, error) { - var buf bytes.Buffer - if err := Generate(&buf, data); err != nil { - return "", err - } - return buf.String(), nil -} - -func formatTemp(c float64) string { - return fmt.Sprintf("%.1f°C", c) -} - -func formatWatts(w float64) string { - return fmt.Sprintf("%.0f W", w) -} - -func formatBTU(b float64) string { - return fmt.Sprintf("%.0f BTU/h", b) -} - -func tempColor(c float64) string { - switch { - case c >= 40: - return "text-red-700 dark:text-red-400" - case c >= 35: - return "text-red-600 dark:text-red-400" - case c >= 30: - return "text-orange-600 dark:text-orange-400" - case c >= 25: - return "text-yellow-600 dark:text-yellow-400" - default: - return "text-green-600 dark:text-green-400" - } -} - -func riskBadge(level string) string { - switch level { - case "extreme": - return "EXTREME" - case "high": - return "HIGH" - case "moderate": - return "MODERATE" - default: - return "LOW" - } -} - -func riskBg(level string) string { - switch level { - case "extreme": - return "bg-red-50 dark:bg-red-950" - case "high": - return "bg-orange-50 dark:bg-orange-950" - case "moderate": - return "bg-yellow-50 dark:bg-yellow-950" - default: - return "bg-white dark:bg-gray-800" - } -} - -func riskBadgeBg(level string) string { - switch level { - case "extreme": - return "bg-red-600" - case "high": - return "bg-orange-600" - case "moderate": - return "bg-yellow-600" - default: - return "bg-green-600" - } -} - -func statusBadge(status string) string { - switch status { - case "overloaded": - return "OVERLOADED" - case "marginal": - return "MARGINAL" - default: - return "OK" - } -} - -func statusColor(status string) string { - switch status { - case "overloaded": - return "text-red-600 dark:text-red-400" - case "marginal": - return "text-orange-600 dark:text-orange-400" - default: - return "text-green-600 dark:text-green-400" - } -} - -func statusBadgeBg(status string) string { - switch status { - case "overloaded": - return "bg-red-600" - case "marginal": - return "bg-orange-600" - default: - return "bg-green-600" - } -} diff --git a/internal/report/generator_test.go b/internal/report/generator_test.go deleted file mode 100644 index 7992a66..0000000 --- a/internal/report/generator_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package report - -import ( - "os" - "strings" - "testing" - "time" -) - -func testDashboardData() DashboardData { - return DashboardData{ - GeneratedAt: time.Date(2025, 7, 15, 10, 0, 0, 0, time.UTC), - ProfileName: "home", - Date: "2025-07-15", - RiskLevel: "high", - PeakTempC: 37.2, - MinNightTempC: 22.5, - PoorNightCool: true, - Warnings: []WarningData{ - { - Headline: "Heat warning Berlin", - Severity: "Severe", - Description: "Temperatures up to 37C", - Instruction: "Stay hydrated", - Onset: "2025-07-15 11:00", - Expires: "2025-07-16 19:00", - }, - }, - RiskWindows: []RiskWindowData{ - {StartHour: 11, EndHour: 18, PeakTempC: 37.2, Level: "high", Reason: "very hot"}, - }, - Timeline: []TimelineSlotData{ - { - Hour: 12, HourStr: "12:00", TempC: 35.5, RiskLevel: "high", BudgetStatus: "marginal", - Actions: []ActionData{ - {Name: "Hydration reminder", Category: "hydration", Impact: "medium"}, - }, - }, - { - Hour: 0, HourStr: "00:00", TempC: 22, RiskLevel: "low", BudgetStatus: "comfortable", - }, - }, - RoomBudgets: []RoomBudgetData{ - { - RoomName: "Office", - InternalGainsW: 300, - SolarGainW: 600, - VentGainW: 150, - TotalGainW: 1050, - TotalGainBTUH: 3583, - ACCapacityBTUH: 8000, - HeadroomBTUH: 4417, - Status: "comfortable", - }, - }, - CareChecklist: []string{"Check elderly at 14:00"}, - } -} - -func TestGenerate_ProducesValidHTML(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatalf("GenerateString: %v", err) - } - if !strings.Contains(html, "") { - t.Error("missing DOCTYPE") - } - if !strings.Contains(html, "") { - t.Error("missing closing html tag") - } -} - -func TestGenerate_ContainsProfileAndDate(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "home") { - t.Error("missing profile name") - } - if !strings.Contains(html, "2025-07-15") { - t.Error("missing date") - } -} - -func TestGenerate_ContainsWarning(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "Heat warning Berlin") { - t.Error("missing warning headline") - } - if !strings.Contains(html, "Stay hydrated") { - t.Error("missing warning instruction") - } -} - -func TestGenerate_ContainsTimeline(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "12:00") { - t.Error("missing timeline hour") - } - if !strings.Contains(html, "Hydration reminder") { - t.Error("missing timeline action") - } -} - -func TestGenerate_ContainsRoomBudget(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "Office") { - t.Error("missing room name") - } - if !strings.Contains(html, "8000 BTU/h") { - t.Error("missing AC capacity") - } -} - -func TestGenerate_ContainsCareChecklist(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "Check elderly at 14:00") { - t.Error("missing care checklist item") - } -} - -func TestGenerate_ContainsCSS(t *testing.T) { - html, err := GenerateString(testDashboardData()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "box-sizing") { - t.Error("missing inlined CSS") - } -} - -func TestGenerate_EmptyData(t *testing.T) { - _, err := GenerateString(DashboardData{ - GeneratedAt: time.Now(), - ProfileName: "test", - Date: "2025-07-15", - }) - if err != nil { - t.Fatalf("should handle empty data: %v", err) - } -} - -func TestGenerate_WriteFile(t *testing.T) { - path := t.TempDir() + "/report.html" - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - - if err := Generate(f, testDashboardData()); err != nil { - t.Fatalf("Generate to file: %v", err) - } - f.Close() - - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if info.Size() < 1000 { - t.Errorf("report file too small: %d bytes", info.Size()) - } -} diff --git a/internal/report/sample_test.go b/internal/report/sample_test.go deleted file mode 100644 index 94f82f1..0000000 --- a/internal/report/sample_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package report - -import ( - "fmt" - "os" - "testing" - "time" -) - -func TestGenerateSampleReport(t *testing.T) { - if os.Getenv("WRITE_SAMPLE") == "" { - t.Skip("set WRITE_SAMPLE=1 to generate sample report") - } - - temps := []float64{22, 21, 20.5, 20, 19.5, 19.5, 20, 22, 24, 27, 30, 32, 34, 35.5, 37.2, 36.8, 35, 33, 31, 28, 26, 24.5, 23.5, 22.5} - - data := DashboardData{ - GeneratedAt: time.Now(), - ProfileName: "home — Berlin", - Date: "2025-07-15", - RiskLevel: "high", - PeakTempC: 37.2, - MinNightTempC: 19.5, - PoorNightCool: true, - Warnings: []WarningData{ - { - Headline: "Amtliche WARNUNG vor HITZE", - Severity: "Severe", - Description: "Es tritt eine starke Wärmebelastung auf. Temperaturen bis 37°C erwartet.", - Instruction: "Trinken Sie ausreichend. Vermeiden Sie direkte Sonneneinstrahlung.", - Onset: "2025-07-15 11:00", - Expires: "2025-07-16 19:00", - }, - }, - RiskWindows: []RiskWindowData{ - {StartHour: 10, EndHour: 18, PeakTempC: 37.2, Level: "high", Reason: "very hot daytime temperatures"}, - }, - RoomBudgets: []RoomBudgetData{ - { - RoomName: "Office", InternalGainsW: 300, SolarGainW: 622, VentGainW: 150, - TotalGainW: 1072, TotalGainBTUH: 3658, ACCapacityBTUH: 8000, HeadroomBTUH: 4342, Status: "comfortable", - }, - { - RoomName: "Bedroom", InternalGainsW: 100, SolarGainW: 200, VentGainW: 80, - TotalGainW: 380, TotalGainBTUH: 1297, ACCapacityBTUH: 0, HeadroomBTUH: -1297, Status: "overloaded", - }, - }, - CareChecklist: []string{ - "Check elderly occupant (bedroom) at 10:00, 14:00, 18:00", - "Ensure water bottles are filled and accessible", - "Verify medication storage temperature", - }, - } - - for i, temp := range temps { - slot := TimelineSlotData{ - Hour: i, - HourStr: fmt.Sprintf("%02d:00", i), - TempC: temp, - } - switch { - case temp >= 35: - slot.RiskLevel = "high" - slot.BudgetStatus = "marginal" - case temp >= 30: - slot.RiskLevel = "moderate" - slot.BudgetStatus = "comfortable" - default: - slot.RiskLevel = "low" - slot.BudgetStatus = "comfortable" - } - - isDay := i >= 6 && i < 21 - if i >= 11 && i <= 18 && temp >= 30 { - slot.Actions = append(slot.Actions, ActionData{Name: "Hydration reminder", Category: "hydration", Impact: "medium"}) - } - if i >= 10 && i <= 18 && temp >= 30 { - slot.Actions = append(slot.Actions, ActionData{Name: "Check vulnerable occupants", Category: "care", Impact: "high"}) - } - if i <= 10 && temp >= 25 { - slot.Actions = append(slot.Actions, ActionData{Name: "Close south-facing shutters", Category: "shading", Impact: "high"}) - } - if i >= 6 && i <= 9 { - slot.Actions = append(slot.Actions, ActionData{Name: "Pre-cool rooms with AC", Category: "ac_strategy", Impact: "high"}) - } - if !isDay && temp < 25 { - slot.Actions = append(slot.Actions, ActionData{Name: "Night ventilation", Category: "ventilation", Impact: "high"}) - } - data.Timeline = append(data.Timeline, slot) - } - - f, err := os.Create("/tmp/heatwave-sample-report.html") - if err != nil { - t.Fatal(err) - } - defer f.Close() - - if err := Generate(f, data); err != nil { - t.Fatalf("Generate: %v", err) - } - t.Log("Sample report written to /tmp/heatwave-sample-report.html") -} diff --git a/internal/report/templates/dashboard.html.tmpl b/internal/report/templates/dashboard.html.tmpl deleted file mode 100644 index 1e1af26..0000000 --- a/internal/report/templates/dashboard.html.tmpl +++ /dev/null @@ -1,166 +0,0 @@ - - - - - -Heatwave Report — {{.ProfileName}} — {{.Date}} - - - -{{if .ShowNav}} - -{{end}} -
- -
-

Heatwave Report

-

{{.ProfileName}} — {{.Date}}

-

Generated {{.GeneratedAt.Format "2006-01-02 15:04"}}

-
- -{{template "warnings" .}} -{{template "risk_summary" .}} -{{template "timeline" .}} -{{template "heatbudget" .}} -{{template "checklist" .}} - -{{if .LLMSummary}} -
-

AI Summary

- {{if .LLMDisclaimer}}

{{.LLMDisclaimer}}

{{end}} -
{{.LLMSummary}}
-
-{{end}} - -
-

Heatwave Autopilot — This report is for planning purposes only. It does not constitute medical advice.

-
- -
- - - -{{define "warnings"}} -{{if .Warnings}} -
-{{range .Warnings}} -
-

{{.Headline}}

-

{{.Description}}

- {{if .Instruction}}

{{.Instruction}}

{{end}} -

{{.Onset}} — {{.Expires}}

-
-{{end}} -
-{{end}} -{{end}} - -{{define "risk_summary"}} -
-
-

Risk Level

-

{{riskBadge .RiskLevel}}

-
-
-

Peak Temperature

-

{{formatTemp .PeakTempC}}

-
-
-

Min Night Temp

-

{{formatTemp .MinNightTempC}}

- {{if .PoorNightCool}}

Poor night cooling

{{end}} -
-
- -{{if .RiskWindows}} -
-

Risk Windows

-
- {{range .RiskWindows}} -
- {{printf "%02d:00" .StartHour}} — {{printf "%02d:00" .EndHour}} - Peak {{formatTemp .PeakTempC}} - {{.Level}} -
- {{end}} -
-
-{{end}} -{{end}} - -{{define "timeline"}} -
-

Hour-by-Hour Timeline

-
- - - - - - - - - - - - {{range .Timeline}} - - - - - - - - {{end}} - -
HourTempRiskBudgetActions
{{.HourStr}}{{formatTemp .TempC}}{{riskBadge .RiskLevel}}{{statusBadge .BudgetStatus}} - {{range .Actions}} - {{.Name}} - {{end}} -
-
-
-{{end}} - -{{define "heatbudget"}} -{{if .RoomBudgets}} -
-

Room Heat Budgets

-
- {{range .RoomBudgets}} -
-

{{.RoomName}}

- - - - - - - -
Internal gains{{formatWatts .InternalGainsW}}
Solar gain{{formatWatts .SolarGainW}}
Ventilation{{formatWatts .VentGainW}}
Total heat load{{formatWatts .TotalGainW}} ({{formatBTU .TotalGainBTUH}})
AC capacity{{formatBTU .ACCapacityBTUH}}
Headroom{{formatBTU .HeadroomBTUH}}
-

{{.Status}}

-
- {{end}} -
-
-{{end}} -{{end}} - -{{define "checklist"}} -{{if .CareChecklist}} -
-

Care Checklist

-
    - {{range .CareChecklist}} -
  • {{.}}
  • - {{end}} -
-
-{{end}} -{{end}} diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..f08c50e --- /dev/null +++ b/internal/server/api.go @@ -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}) +} diff --git a/internal/server/embed.go b/internal/server/embed.go new file mode 100644 index 0000000..df33cf5 --- /dev/null +++ b/internal/server/embed.go @@ -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 diff --git a/internal/server/i18n.go b/internal/server/i18n.go new file mode 100644 index 0000000..5ffb601 --- /dev/null +++ b/internal/server/i18n.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..ec32b2c --- /dev/null +++ b/internal/server/server.go @@ -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() + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..dcb2e31 --- /dev/null +++ b/internal/server/server_test.go @@ -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"]) + } +} diff --git a/internal/static/embed.go b/internal/static/embed.go deleted file mode 100644 index bc15d48..0000000 --- a/internal/static/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package static - -import _ "embed" - -//go:embed tailwind.css -var TailwindCSS string diff --git a/internal/static/tailwind.css b/internal/static/tailwind.css deleted file mode 100644 index 351147c..0000000 --- a/internal/static/tailwind.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-400:oklch(75% .183 55.934);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-950:oklch(28.6% .066 53.813);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-400:oklch(79.2% .209 151.711);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-8{margin-top:calc(var(--spacing)*8)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.flex-1{flex:1}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-gray-300{border-color:var(--color-gray-300)}.border-orange-400{border-color:var(--color-orange-400)}.border-red-400{border-color:var(--color-red-400)}.border-yellow-400{border-color:var(--color-yellow-400)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-600{background-color:var(--color-red-600)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-600{background-color:var(--color-yellow-600)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-orange-600{color:var(--color-orange-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@media (hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-300:hover{background-color:var(--color-gray-300)}.hover\:bg-green-600:hover{background-color:var(--color-green-600)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:underline:hover{text-decoration-line:underline}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\:border-gray-600{border-color:var(--color-gray-600)}.dark\:bg-blue-900{background-color:var(--color-blue-900)}.dark\:bg-blue-950{background-color:var(--color-blue-950)}.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-green-900{background-color:var(--color-green-900)}.dark\:bg-orange-950{background-color:var(--color-orange-950)}.dark\:bg-red-950{background-color:var(--color-red-950)}.dark\:bg-yellow-950{background-color:var(--color-yellow-950)}.dark\:text-blue-200{color:var(--color-blue-200)}.dark\:text-blue-300{color:var(--color-blue-300)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-500{color:var(--color-gray-500)}.dark\:text-green-200{color:var(--color-green-200)}.dark\:text-green-400{color:var(--color-green-400)}.dark\:text-orange-400{color:var(--color-orange-400)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-yellow-400{color:var(--color-yellow-400)}.dark\:shadow-gray-700{--tw-shadow-color:oklch(37.3% .034 259.733)}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-gray-700{--tw-shadow-color:color-mix(in oklab,var(--color-gray-700)var(--tw-shadow-alpha),transparent)}}@media (hover:hover){.dark\:hover\:bg-gray-600:hover{background-color:var(--color-gray-600)}.dark\:hover\:text-blue-400:hover{color:var(--color-blue-400)}}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/internal/store/ac.go b/internal/store/ac.go deleted file mode 100644 index e6238fe..0000000 --- a/internal/store/ac.go +++ /dev/null @@ -1,133 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "time" -) - -type ACUnit struct { - ID int64 - ProfileID int64 - Name string - ACType string - CapacityBTU float64 - HasDehumidify bool - EfficiencyEER float64 - CreatedAt time.Time -} - -func (s *Store) CreateACUnit(profileID int64, name, acType string, capacityBTU float64, hasDehumidify bool, efficiencyEER float64) (*ACUnit, error) { - if acType == "" { - acType = "portable" - } - if efficiencyEER == 0 { - efficiencyEER = 10.0 - } - dehumid := 0 - if hasDehumidify { - dehumid = 1 - } - res, err := s.db.Exec( - `INSERT INTO ac_units (profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer) VALUES (?, ?, ?, ?, ?, ?)`, - profileID, name, acType, capacityBTU, dehumid, efficiencyEER, - ) - if err != nil { - return nil, fmt.Errorf("create ac unit: %w", err) - } - id, _ := res.LastInsertId() - return s.GetACUnit(id) -} - -func (s *Store) GetACUnit(id int64) (*ACUnit, error) { - a := &ACUnit{} - var created string - var dehumid int - err := s.db.QueryRow( - `SELECT id, profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer, created_at FROM ac_units WHERE id = ?`, id, - ).Scan(&a.ID, &a.ProfileID, &a.Name, &a.ACType, &a.CapacityBTU, &dehumid, &a.EfficiencyEER, &created) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("ac unit not found: %d", id) - } - if err != nil { - return nil, fmt.Errorf("get ac unit: %w", err) - } - a.HasDehumidify = dehumid != 0 - a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - return a, nil -} - -func (s *Store) ListACUnits(profileID int64) ([]ACUnit, error) { - rows, err := s.db.Query( - `SELECT id, profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer, created_at FROM ac_units WHERE profile_id = ? ORDER BY name`, profileID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var units []ACUnit - for rows.Next() { - var a ACUnit - var created string - var dehumid int - if err := rows.Scan(&a.ID, &a.ProfileID, &a.Name, &a.ACType, &a.CapacityBTU, &dehumid, &a.EfficiencyEER, &created); err != nil { - return nil, err - } - a.HasDehumidify = dehumid != 0 - a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - units = append(units, a) - } - return units, rows.Err() -} - -func (s *Store) UpdateACUnit(id int64, field, value string) error { - allowed := map[string]bool{ - "name": true, "ac_type": true, "capacity_btu": true, - "has_dehumidify": true, "efficiency_eer": true, - } - if !allowed[field] { - return fmt.Errorf("invalid field: %s", field) - } - _, err := s.db.Exec( - fmt.Sprintf(`UPDATE ac_units SET %s = ? WHERE id = ?`, field), value, id, - ) - return err -} - -func (s *Store) DeleteACUnit(id int64) error { - _, err := s.db.Exec(`DELETE FROM ac_units WHERE id = ?`, id) - return err -} - -func (s *Store) AssignACToRoom(acID, roomID int64) error { - _, err := s.db.Exec( - `INSERT OR IGNORE INTO ac_room_assignments (ac_id, room_id) VALUES (?, ?)`, - acID, roomID, - ) - return err -} - -func (s *Store) UnassignACFromRoom(acID, roomID int64) error { - _, err := s.db.Exec( - `DELETE FROM ac_room_assignments WHERE ac_id = ? AND room_id = ?`, - acID, roomID, - ) - return err -} - -func (s *Store) GetACRoomAssignments(acID int64) ([]int64, error) { - rows, err := s.db.Query(`SELECT room_id FROM ac_room_assignments WHERE ac_id = ?`, acID) - if err != nil { - return nil, err - } - defer rows.Close() - var roomIDs []int64 - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return nil, err - } - roomIDs = append(roomIDs, id) - } - return roomIDs, rows.Err() -} diff --git a/internal/store/device.go b/internal/store/device.go deleted file mode 100644 index 96a5d38..0000000 --- a/internal/store/device.go +++ /dev/null @@ -1,113 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "time" -) - -type Device struct { - ID int64 - RoomID int64 - Name string - DeviceType string - WattsIdle float64 - WattsTypical float64 - WattsPeak float64 - DutyCycle float64 - Schedule string - CreatedAt time.Time -} - -func (s *Store) CreateDevice(roomID int64, name, deviceType string, wattsIdle, wattsTypical, wattsPeak, dutyCycle float64) (*Device, error) { - if dutyCycle == 0 { - dutyCycle = 1.0 - } - res, err := s.db.Exec( - `INSERT INTO devices (room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle) VALUES (?, ?, ?, ?, ?, ?, ?)`, - roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle, - ) - if err != nil { - return nil, fmt.Errorf("create device: %w", err) - } - id, _ := res.LastInsertId() - return s.GetDevice(id) -} - -func (s *Store) GetDevice(id int64) (*Device, error) { - d := &Device{} - var created string - err := s.db.QueryRow( - `SELECT id, room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle, schedule, created_at FROM devices WHERE id = ?`, id, - ).Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("device not found: %d", id) - } - if err != nil { - return nil, fmt.Errorf("get device: %w", err) - } - d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - return d, nil -} - -func (s *Store) ListDevices(roomID int64) ([]Device, error) { - rows, err := s.db.Query( - `SELECT id, room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle, schedule, created_at FROM devices WHERE room_id = ? ORDER BY name`, roomID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var devices []Device - for rows.Next() { - var d Device - var created string - if err := rows.Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created); err != nil { - return nil, err - } - d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - devices = append(devices, d) - } - return devices, rows.Err() -} - -func (s *Store) ListAllDevices(profileID int64) ([]Device, error) { - rows, err := s.db.Query( - `SELECT d.id, d.room_id, d.name, d.device_type, d.watts_idle, d.watts_typical, d.watts_peak, d.duty_cycle, d.schedule, d.created_at - FROM devices d JOIN rooms r ON d.room_id = r.id WHERE r.profile_id = ? ORDER BY d.name`, profileID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var devices []Device - for rows.Next() { - var d Device - var created string - if err := rows.Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created); err != nil { - return nil, err - } - d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - devices = append(devices, d) - } - return devices, rows.Err() -} - -func (s *Store) UpdateDevice(id int64, field, value string) error { - allowed := map[string]bool{ - "name": true, "device_type": true, "watts_idle": true, "watts_typical": true, - "watts_peak": true, "duty_cycle": true, "schedule": true, - } - if !allowed[field] { - return fmt.Errorf("invalid field: %s", field) - } - _, err := s.db.Exec( - fmt.Sprintf(`UPDATE devices SET %s = ? WHERE id = ?`, field), value, id, - ) - return err -} - -func (s *Store) DeleteDevice(id int64) error { - _, err := s.db.Exec(`DELETE FROM devices WHERE id = ?`, id) - return err -} diff --git a/internal/store/forecast.go b/internal/store/forecast.go deleted file mode 100644 index d8b4cca..0000000 --- a/internal/store/forecast.go +++ /dev/null @@ -1,96 +0,0 @@ -package store - -import ( - "fmt" - "time" -) - -type Forecast struct { - ID int64 - ProfileID int64 - Timestamp time.Time - TemperatureC *float64 - HumidityPct *float64 - WindSpeedMs *float64 - CloudCoverPct *float64 - PrecipitationMm *float64 - SunshineMin *float64 - PressureHpa *float64 - DewPointC *float64 - ApparentTempC *float64 - Condition string - Source string - FetchedAt time.Time -} - -func (s *Store) UpsertForecast(f *Forecast) error { - _, err := s.db.Exec( - `INSERT INTO forecasts (profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(profile_id, timestamp, source) DO UPDATE SET - temperature_c=excluded.temperature_c, humidity_pct=excluded.humidity_pct, - wind_speed_ms=excluded.wind_speed_ms, cloud_cover_pct=excluded.cloud_cover_pct, - precipitation_mm=excluded.precipitation_mm, sunshine_min=excluded.sunshine_min, - pressure_hpa=excluded.pressure_hpa, dew_point_c=excluded.dew_point_c, - apparent_temp_c=excluded.apparent_temp_c, condition=excluded.condition, - fetched_at=datetime('now')`, - f.ProfileID, f.Timestamp.Format(time.RFC3339), - f.TemperatureC, f.HumidityPct, f.WindSpeedMs, f.CloudCoverPct, - f.PrecipitationMm, f.SunshineMin, f.PressureHpa, f.DewPointC, - f.ApparentTempC, f.Condition, f.Source, - ) - return err -} - -func (s *Store) GetForecasts(profileID int64, from, to time.Time, source string) ([]Forecast, error) { - query := `SELECT id, profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source, fetched_at - FROM forecasts WHERE profile_id = ? AND timestamp >= ? AND timestamp <= ?` - args := []any{profileID, from.Format(time.RFC3339), to.Format(time.RFC3339)} - if source != "" { - query += ` AND source = ?` - args = append(args, source) - } - query += ` ORDER BY timestamp` - - rows, err := s.db.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var forecasts []Forecast - for rows.Next() { - var f Forecast - var ts, fetched string - if err := rows.Scan(&f.ID, &f.ProfileID, &ts, &f.TemperatureC, &f.HumidityPct, &f.WindSpeedMs, &f.CloudCoverPct, &f.PrecipitationMm, &f.SunshineMin, &f.PressureHpa, &f.DewPointC, &f.ApparentTempC, &f.Condition, &f.Source, &fetched); err != nil { - return nil, err - } - f.Timestamp, _ = time.Parse(time.RFC3339, ts) - f.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched) - forecasts = append(forecasts, f) - } - return forecasts, rows.Err() -} - -func (s *Store) GetLastFetchTime(profileID int64, source string) (time.Time, error) { - var fetched string - err := s.db.QueryRow( - `SELECT MAX(fetched_at) FROM forecasts WHERE profile_id = ? AND source = ?`, - profileID, source, - ).Scan(&fetched) - if err != nil || fetched == "" { - return time.Time{}, fmt.Errorf("no forecasts found") - } - t, _ := time.Parse("2006-01-02 15:04:05", fetched) - return t, nil -} - -func (s *Store) CleanupOldForecasts(olderThan time.Time) (int64, error) { - res, err := s.db.Exec( - `DELETE FROM forecasts WHERE timestamp < ?`, - olderThan.Format(time.RFC3339), - ) - if err != nil { - return 0, err - } - return res.RowsAffected() -} diff --git a/internal/store/llm_settings.go b/internal/store/llm_settings.go deleted file mode 100644 index 134953a..0000000 --- a/internal/store/llm_settings.go +++ /dev/null @@ -1,41 +0,0 @@ -package store - -import "database/sql" - -// LLMSettings holds the persisted LLM configuration. -type LLMSettings struct { - Provider string - Model string - Endpoint string - APIKeyEnc string // base64-encoded AES-256-GCM ciphertext -} - -// GetLLMSettings returns the stored LLM settings, or defaults if none exist. -func (s *Store) GetLLMSettings() (*LLMSettings, error) { - ls := &LLMSettings{Provider: "none"} - err := s.db.QueryRow( - `SELECT provider, model, endpoint, api_key_enc FROM llm_settings WHERE id = 1`, - ).Scan(&ls.Provider, &ls.Model, &ls.Endpoint, &ls.APIKeyEnc) - if err == sql.ErrNoRows { - return ls, nil - } - if err != nil { - return nil, err - } - return ls, nil -} - -// SaveLLMSettings upserts the LLM configuration (single-row table). -func (s *Store) SaveLLMSettings(ls *LLMSettings) error { - _, err := s.db.Exec(` - INSERT INTO llm_settings (id, provider, model, endpoint, api_key_enc, updated_at) - VALUES (1, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(id) DO UPDATE SET - provider = excluded.provider, - model = excluded.model, - endpoint = excluded.endpoint, - api_key_enc = excluded.api_key_enc, - updated_at = datetime('now') - `, ls.Provider, ls.Model, ls.Endpoint, ls.APIKeyEnc) - return err -} diff --git a/internal/store/migrations.go b/internal/store/migrations.go deleted file mode 100644 index 35430d1..0000000 --- a/internal/store/migrations.go +++ /dev/null @@ -1,6 +0,0 @@ -package store - -import _ "embed" - -//go:embed schema.sql -var schemaSQL string diff --git a/internal/store/occupant.go b/internal/store/occupant.go deleted file mode 100644 index 4de92e7..0000000 --- a/internal/store/occupant.go +++ /dev/null @@ -1,108 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" -) - -type Occupant struct { - ID int64 - RoomID int64 - Count int - ActivityLevel string - Vulnerable bool -} - -func (s *Store) CreateOccupant(roomID int64, count int, activityLevel string, vulnerable bool) (*Occupant, error) { - if activityLevel == "" { - activityLevel = "sedentary" - } - vuln := 0 - if vulnerable { - vuln = 1 - } - res, err := s.db.Exec( - `INSERT INTO occupants (room_id, count, activity_level, vulnerable) VALUES (?, ?, ?, ?)`, - roomID, count, activityLevel, vuln, - ) - if err != nil { - return nil, fmt.Errorf("create occupant: %w", err) - } - id, _ := res.LastInsertId() - return s.GetOccupant(id) -} - -func (s *Store) GetOccupant(id int64) (*Occupant, error) { - o := &Occupant{} - var vuln int - err := s.db.QueryRow( - `SELECT id, room_id, count, activity_level, vulnerable FROM occupants WHERE id = ?`, id, - ).Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("occupant not found: %d", id) - } - if err != nil { - return nil, fmt.Errorf("get occupant: %w", err) - } - o.Vulnerable = vuln != 0 - return o, nil -} - -func (s *Store) ListOccupants(roomID int64) ([]Occupant, error) { - rows, err := s.db.Query( - `SELECT id, room_id, count, activity_level, vulnerable FROM occupants WHERE room_id = ?`, roomID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var occupants []Occupant - for rows.Next() { - var o Occupant - var vuln int - if err := rows.Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln); err != nil { - return nil, err - } - o.Vulnerable = vuln != 0 - occupants = append(occupants, o) - } - return occupants, rows.Err() -} - -func (s *Store) ListAllOccupants(profileID int64) ([]Occupant, error) { - rows, err := s.db.Query( - `SELECT o.id, o.room_id, o.count, o.activity_level, o.vulnerable - FROM occupants o JOIN rooms r ON o.room_id = r.id WHERE r.profile_id = ?`, profileID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var occupants []Occupant - for rows.Next() { - var o Occupant - var vuln int - if err := rows.Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln); err != nil { - return nil, err - } - o.Vulnerable = vuln != 0 - occupants = append(occupants, o) - } - return occupants, rows.Err() -} - -func (s *Store) UpdateOccupant(id int64, field, value string) error { - allowed := map[string]bool{"count": true, "activity_level": true, "vulnerable": true} - if !allowed[field] { - return fmt.Errorf("invalid field: %s", field) - } - _, err := s.db.Exec( - fmt.Sprintf(`UPDATE occupants SET %s = ? WHERE id = ?`, field), value, id, - ) - return err -} - -func (s *Store) DeleteOccupant(id int64) error { - _, err := s.db.Exec(`DELETE FROM occupants WHERE id = ?`, id) - return err -} diff --git a/internal/store/profile.go b/internal/store/profile.go deleted file mode 100644 index 31d6edb..0000000 --- a/internal/store/profile.go +++ /dev/null @@ -1,103 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "time" -) - -type Profile struct { - ID int64 - Name string - Latitude float64 - Longitude float64 - Timezone string - CreatedAt time.Time - UpdatedAt time.Time -} - -func (s *Store) CreateProfile(name string, lat, lon float64, tz string) (*Profile, error) { - if tz == "" { - tz = "Europe/Berlin" - } - res, err := s.db.Exec( - `INSERT INTO profiles (name, latitude, longitude, timezone) VALUES (?, ?, ?, ?)`, - name, lat, lon, tz, - ) - if err != nil { - return nil, fmt.Errorf("create profile: %w", err) - } - id, _ := res.LastInsertId() - return s.GetProfile(id) -} - -func (s *Store) GetProfile(id int64) (*Profile, error) { - p := &Profile{} - var created, updated string - err := s.db.QueryRow( - `SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles WHERE id = ?`, id, - ).Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("profile not found: %d", id) - } - if err != nil { - return nil, fmt.Errorf("get profile: %w", err) - } - p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated) - return p, nil -} - -func (s *Store) GetProfileByName(name string) (*Profile, error) { - p := &Profile{} - var created, updated string - err := s.db.QueryRow( - `SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles WHERE name = ?`, name, - ).Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("profile not found: %s", name) - } - if err != nil { - return nil, fmt.Errorf("get profile by name: %w", err) - } - p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated) - return p, nil -} - -func (s *Store) UpdateProfile(id int64, field, value string) error { - allowed := map[string]bool{"name": true, "latitude": true, "longitude": true, "timezone": true} - if !allowed[field] { - return fmt.Errorf("invalid field: %s", field) - } - _, err := s.db.Exec( - fmt.Sprintf(`UPDATE profiles SET %s = ?, updated_at = datetime('now') WHERE id = ?`, field), - value, id, - ) - return err -} - -func (s *Store) DeleteProfile(id int64) error { - _, err := s.db.Exec(`DELETE FROM profiles WHERE id = ?`, id) - return err -} - -func (s *Store) ListProfiles() ([]Profile, error) { - rows, err := s.db.Query(`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() - var profiles []Profile - for rows.Next() { - var p Profile - var created, updated string - if err := rows.Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated); err != nil { - return nil, err - } - p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated) - profiles = append(profiles, p) - } - return profiles, rows.Err() -} diff --git a/internal/store/room.go b/internal/store/room.go deleted file mode 100644 index 09833b5..0000000 --- a/internal/store/room.go +++ /dev/null @@ -1,151 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "time" -) - -type Room struct { - ID int64 - ProfileID int64 - Name string - AreaSqm float64 - CeilingHeightM float64 - Floor int - Orientation string - ShadingType string - ShadingFactor float64 - Ventilation string - VentilationACH float64 - WindowFraction float64 - SHGC float64 - Insulation string - CreatedAt time.Time -} - -// RoomParams holds optional room parameters with defaults. -type RoomParams struct { - CeilingHeightM float64 - VentilationACH float64 - WindowFraction float64 - SHGC float64 -} - -// DefaultRoomParams returns sensible defaults for room physics parameters. -func DefaultRoomParams() RoomParams { - return RoomParams{ - CeilingHeightM: 2.5, - VentilationACH: 0.5, - WindowFraction: 0.15, - SHGC: 0.6, - } -} - -func (s *Store) CreateRoom(profileID int64, name string, areaSqm float64, floor int, orientation, shadingType string, shadingFactor float64, ventilation, insulation string, params RoomParams) (*Room, error) { - if shadingType == "" { - shadingType = "none" - } - if shadingFactor == 0 { - shadingFactor = 1.0 - } - if ventilation == "" { - ventilation = "natural" - } - if insulation == "" { - insulation = "average" - } - if params.CeilingHeightM == 0 { - params.CeilingHeightM = 2.5 - } - if params.VentilationACH == 0 { - params.VentilationACH = 0.5 - } - if params.WindowFraction == 0 { - params.WindowFraction = 0.15 - } - if params.SHGC == 0 { - params.SHGC = 0.6 - } - res, err := s.db.Exec( - `INSERT INTO rooms (profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - profileID, name, areaSqm, params.CeilingHeightM, floor, orientation, shadingType, shadingFactor, ventilation, params.VentilationACH, params.WindowFraction, params.SHGC, insulation, - ) - if err != nil { - return nil, fmt.Errorf("create room: %w", err) - } - id, _ := res.LastInsertId() - return s.GetRoom(id) -} - -func (s *Store) GetRoom(id int64) (*Room, error) { - r := &Room{} - var created string - err := s.db.QueryRow( - `SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE id = ?`, id, - ).Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("room not found: %d", id) - } - if err != nil { - return nil, fmt.Errorf("get room: %w", err) - } - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - return r, nil -} - -func (s *Store) ListRooms(profileID int64) ([]Room, error) { - rows, err := s.db.Query( - `SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE profile_id = ? ORDER BY name`, profileID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var rooms []Room - for rows.Next() { - var r Room - var created string - if err := rows.Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created); err != nil { - return nil, err - } - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) - rooms = append(rooms, r) - } - return rooms, rows.Err() -} - -func (s *Store) UpdateRoom(id int64, field, value string) error { - allowed := map[string]bool{ - "name": true, "area_sqm": true, "ceiling_height_m": true, "floor": true, "orientation": true, - "shading_type": true, "shading_factor": true, "ventilation": true, "ventilation_ach": true, - "window_fraction": true, "shgc": true, "insulation": true, - } - if !allowed[field] { - return fmt.Errorf("invalid field: %s", field) - } - _, err := s.db.Exec( - fmt.Sprintf(`UPDATE rooms SET %s = ? WHERE id = ?`, field), value, id, - ) - return err -} - -func (s *Store) DeleteRoom(id int64) error { - _, err := s.db.Exec(`DELETE FROM rooms WHERE id = ?`, id) - return err -} - -// GetRoomACCapacity returns total AC capacity in BTU/h assigned to a room. -func (s *Store) GetRoomACCapacity(roomID int64) (float64, error) { - var total sql.NullFloat64 - err := s.db.QueryRow( - `SELECT SUM(a.capacity_btu) FROM ac_units a JOIN ac_room_assignments ar ON a.id = ar.ac_id WHERE ar.room_id = ?`, roomID, - ).Scan(&total) - if err != nil { - return 0, err - } - if !total.Valid { - return 0, nil - } - return total.Float64, nil -} diff --git a/internal/store/schema.sql b/internal/store/schema.sql deleted file mode 100644 index 5544077..0000000 --- a/internal/store/schema.sql +++ /dev/null @@ -1,115 +0,0 @@ -CREATE TABLE IF NOT EXISTS profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - timezone TEXT NOT NULL DEFAULT 'Europe/Berlin', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS rooms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - name TEXT NOT NULL, - area_sqm REAL NOT NULL, - ceiling_height_m REAL NOT NULL DEFAULT 2.5, - floor INTEGER NOT NULL DEFAULT 0, - orientation TEXT NOT NULL DEFAULT 'N', - shading_type TEXT NOT NULL DEFAULT 'none', - shading_factor REAL NOT NULL DEFAULT 1.0, - ventilation TEXT NOT NULL DEFAULT 'natural', - ventilation_ach REAL NOT NULL DEFAULT 0.5, - window_fraction REAL NOT NULL DEFAULT 0.15, - shgc REAL NOT NULL DEFAULT 0.6, - insulation TEXT NOT NULL DEFAULT 'average', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS occupants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - count INTEGER NOT NULL DEFAULT 1, - activity_level TEXT NOT NULL DEFAULT 'sedentary', - vulnerable INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS devices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - name TEXT NOT NULL, - device_type TEXT NOT NULL, - watts_idle REAL NOT NULL DEFAULT 0, - watts_typical REAL NOT NULL DEFAULT 0, - watts_peak REAL NOT NULL DEFAULT 0, - duty_cycle REAL NOT NULL DEFAULT 1.0, - schedule TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS ac_units ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - name TEXT NOT NULL, - ac_type TEXT NOT NULL DEFAULT 'portable', - capacity_btu REAL NOT NULL, - has_dehumidify INTEGER NOT NULL DEFAULT 0, - efficiency_eer REAL NOT NULL DEFAULT 10.0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS ac_room_assignments ( - ac_id INTEGER NOT NULL REFERENCES ac_units(id) ON DELETE CASCADE, - room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - PRIMARY KEY (ac_id, room_id) -); - -CREATE TABLE IF NOT EXISTS forecasts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - timestamp TEXT NOT NULL, - temperature_c REAL, - humidity_pct REAL, - wind_speed_ms REAL, - cloud_cover_pct REAL, - precipitation_mm REAL, - sunshine_min REAL, - pressure_hpa REAL, - dew_point_c REAL, - apparent_temp_c REAL, - condition TEXT, - source TEXT NOT NULL, - fetched_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(profile_id, timestamp, source) -); - -CREATE TABLE IF NOT EXISTS warnings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - warning_id TEXT NOT NULL UNIQUE, - event_type TEXT, - severity TEXT, - headline TEXT, - description TEXT, - instruction TEXT, - onset TEXT, - expires TEXT, - fetched_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS toggles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - name TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 0, - activated_at TEXT -); - -CREATE TABLE IF NOT EXISTS llm_settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - provider TEXT NOT NULL DEFAULT 'none', - model TEXT NOT NULL DEFAULT '', - endpoint TEXT NOT NULL DEFAULT '', - api_key_enc TEXT NOT NULL DEFAULT '', - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/internal/store/store.go b/internal/store/store.go deleted file mode 100644 index fbf6f78..0000000 --- a/internal/store/store.go +++ /dev/null @@ -1,45 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - - _ "modernc.org/sqlite" -) - -// Store wraps a SQLite database connection. -type Store struct { - db *sql.DB -} - -// New opens (or creates) a SQLite database and runs migrations. -func New(dsn string) (*Store, error) { - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("open database: %w", err) - } - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - db.Close() - return nil, fmt.Errorf("set WAL mode: %w", err) - } - if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { - db.Close() - return nil, fmt.Errorf("enable foreign keys: %w", err) - } - s := &Store{db: db} - if err := s.migrate(); err != nil { - db.Close() - return nil, fmt.Errorf("migrate: %w", err) - } - return s, nil -} - -// Close closes the database connection. -func (s *Store) Close() error { - return s.db.Close() -} - -func (s *Store) migrate() error { - _, err := s.db.Exec(schemaSQL) - return err -} diff --git a/internal/store/store_test.go b/internal/store/store_test.go deleted file mode 100644 index 6eafff9..0000000 --- a/internal/store/store_test.go +++ /dev/null @@ -1,496 +0,0 @@ -package store - -import ( - "testing" - "time" -) - -func newTestStore(t *testing.T) *Store { - t.Helper() - s, err := New(":memory:") - if err != nil { - t.Fatalf("failed to create test store: %v", err) - } - t.Cleanup(func() { s.Close() }) - return s -} - -func TestNewStore(t *testing.T) { - s := newTestStore(t) - if s == nil { - t.Fatal("store is nil") - } -} - -// --- Profile CRUD --- - -func TestProfileCRUD(t *testing.T) { - s := newTestStore(t) - - p, err := s.CreateProfile("home", 52.52, 13.41, "Europe/Berlin") - if err != nil { - t.Fatalf("CreateProfile: %v", err) - } - if p.Name != "home" || p.Latitude != 52.52 || p.Longitude != 13.41 { - t.Errorf("unexpected profile: %+v", p) - } - - got, err := s.GetProfile(p.ID) - if err != nil { - t.Fatalf("GetProfile: %v", err) - } - if got.Name != "home" { - t.Errorf("GetProfile name = %s, want home", got.Name) - } - - gotByName, err := s.GetProfileByName("home") - if err != nil { - t.Fatalf("GetProfileByName: %v", err) - } - if gotByName.ID != p.ID { - t.Errorf("GetProfileByName ID = %d, want %d", gotByName.ID, p.ID) - } - - if err := s.UpdateProfile(p.ID, "name", "office"); err != nil { - t.Fatalf("UpdateProfile: %v", err) - } - updated, _ := s.GetProfile(p.ID) - if updated.Name != "office" { - t.Errorf("updated name = %s, want office", updated.Name) - } - - profiles, err := s.ListProfiles() - if err != nil { - t.Fatalf("ListProfiles: %v", err) - } - if len(profiles) != 1 { - t.Errorf("ListProfiles len = %d, want 1", len(profiles)) - } - - if err := s.DeleteProfile(p.ID); err != nil { - t.Fatalf("DeleteProfile: %v", err) - } - _, err = s.GetProfile(p.ID) - if err == nil { - t.Error("expected error after delete") - } -} - -func TestProfileDuplicateName(t *testing.T) { - s := newTestStore(t) - _, err := s.CreateProfile("home", 52.52, 13.41, "") - if err != nil { - t.Fatal(err) - } - _, err = s.CreateProfile("home", 48.13, 11.58, "") - if err == nil { - t.Error("expected error for duplicate name") - } -} - -func TestProfileInvalidField(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - err := s.UpdateProfile(p.ID, "nonexistent", "value") - if err == nil { - t.Error("expected error for invalid field") - } -} - -// --- Room CRUD --- - -func TestRoomCRUD(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - - r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", DefaultRoomParams()) - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if r.Name != "office" || r.AreaSqm != 15 || r.Orientation != "S" || r.ShadingFactor != 0.3 { - t.Errorf("unexpected room: %+v", r) - } - - rooms, err := s.ListRooms(p.ID) - if err != nil { - t.Fatalf("ListRooms: %v", err) - } - if len(rooms) != 1 { - t.Errorf("ListRooms len = %d, want 1", len(rooms)) - } - - if err := s.UpdateRoom(r.ID, "name", "bedroom"); err != nil { - t.Fatalf("UpdateRoom: %v", err) - } - updated, _ := s.GetRoom(r.ID) - if updated.Name != "bedroom" { - t.Errorf("updated name = %s, want bedroom", updated.Name) - } - - if err := s.DeleteRoom(r.ID); err != nil { - t.Fatalf("DeleteRoom: %v", err) - } - _, err = s.GetRoom(r.ID) - if err == nil { - t.Error("expected error after delete") - } -} - -func TestRoomPhysicsFields(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - - params := RoomParams{ - CeilingHeightM: 3.0, - VentilationACH: 1.5, - WindowFraction: 0.20, - SHGC: 0.45, - } - r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", params) - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if r.CeilingHeightM != 3.0 { - t.Errorf("CeilingHeightM = %v, want 3.0", r.CeilingHeightM) - } - if r.VentilationACH != 1.5 { - t.Errorf("VentilationACH = %v, want 1.5", r.VentilationACH) - } - if r.WindowFraction != 0.20 { - t.Errorf("WindowFraction = %v, want 0.20", r.WindowFraction) - } - if r.SHGC != 0.45 { - t.Errorf("SHGC = %v, want 0.45", r.SHGC) - } -} - -func TestRoomACCapacity(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams()) - - // No AC assigned yet - cap, err := s.GetRoomACCapacity(r.ID) - if err != nil { - t.Fatalf("GetRoomACCapacity: %v", err) - } - if cap != 0 { - t.Errorf("expected 0 BTU/h, got %v", cap) - } - - // Assign AC - ac, _ := s.CreateACUnit(p.ID, "Portable", "portable", 8000, false, 10) - s.AssignACToRoom(ac.ID, r.ID) - - cap, err = s.GetRoomACCapacity(r.ID) - if err != nil { - t.Fatalf("GetRoomACCapacity: %v", err) - } - if cap != 8000 { - t.Errorf("expected 8000 BTU/h, got %v", cap) - } -} - -// --- Device CRUD --- - -func TestDeviceCRUD(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams()) - - d, err := s.CreateDevice(r.ID, "Gaming PC", "pc", 65, 200, 450, 1.0) - if err != nil { - t.Fatalf("CreateDevice: %v", err) - } - if d.Name != "Gaming PC" || d.WattsTypical != 200 { - t.Errorf("unexpected device: %+v", d) - } - - devices, err := s.ListDevices(r.ID) - if err != nil { - t.Fatalf("ListDevices: %v", err) - } - if len(devices) != 1 { - t.Errorf("ListDevices len = %d, want 1", len(devices)) - } - - allDevices, err := s.ListAllDevices(p.ID) - if err != nil { - t.Fatalf("ListAllDevices: %v", err) - } - if len(allDevices) != 1 { - t.Errorf("ListAllDevices len = %d, want 1", len(allDevices)) - } - - if err := s.DeleteDevice(d.ID); err != nil { - t.Fatalf("DeleteDevice: %v", err) - } - _, err = s.GetDevice(d.ID) - if err == nil { - t.Error("expected error after delete") - } -} - -// --- Occupant CRUD --- - -func TestOccupantCRUD(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams()) - - o, err := s.CreateOccupant(r.ID, 1, "sedentary", false) - if err != nil { - t.Fatalf("CreateOccupant: %v", err) - } - if o.Count != 1 || o.ActivityLevel != "sedentary" || o.Vulnerable { - t.Errorf("unexpected occupant: %+v", o) - } - - oVuln, err := s.CreateOccupant(r.ID, 1, "sleeping", true) - if err != nil { - t.Fatal(err) - } - if !oVuln.Vulnerable { - t.Error("expected vulnerable=true") - } - - occupants, err := s.ListOccupants(r.ID) - if err != nil { - t.Fatalf("ListOccupants: %v", err) - } - if len(occupants) != 2 { - t.Errorf("ListOccupants len = %d, want 2", len(occupants)) - } - - all, err := s.ListAllOccupants(p.ID) - if err != nil { - t.Fatalf("ListAllOccupants: %v", err) - } - if len(all) != 2 { - t.Errorf("ListAllOccupants len = %d, want 2", len(all)) - } - - if err := s.DeleteOccupant(o.ID); err != nil { - t.Fatal(err) - } - _, err = s.GetOccupant(o.ID) - if err == nil { - t.Error("expected error after delete") - } -} - -// --- AC Unit CRUD --- - -func TestACUnitCRUD(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams()) - - ac, err := s.CreateACUnit(p.ID, "Portable", "portable", 8000, true, 10.5) - if err != nil { - t.Fatalf("CreateACUnit: %v", err) - } - if ac.Name != "Portable" || ac.CapacityBTU != 8000 || !ac.HasDehumidify { - t.Errorf("unexpected ac: %+v", ac) - } - - if err := s.AssignACToRoom(ac.ID, r.ID); err != nil { - t.Fatalf("AssignACToRoom: %v", err) - } - rooms, err := s.GetACRoomAssignments(ac.ID) - if err != nil { - t.Fatalf("GetACRoomAssignments: %v", err) - } - if len(rooms) != 1 || rooms[0] != r.ID { - t.Errorf("unexpected room assignments: %v", rooms) - } - - if err := s.UnassignACFromRoom(ac.ID, r.ID); err != nil { - t.Fatal(err) - } - rooms, _ = s.GetACRoomAssignments(ac.ID) - if len(rooms) != 0 { - t.Error("expected empty after unassign") - } - - units, err := s.ListACUnits(p.ID) - if err != nil { - t.Fatal(err) - } - if len(units) != 1 { - t.Errorf("ListACUnits len = %d, want 1", len(units)) - } - - if err := s.DeleteACUnit(ac.ID); err != nil { - t.Fatal(err) - } -} - -// --- Forecast CRUD --- - -func TestForecastUpsertAndQuery(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - - ts := time.Date(2025, 7, 15, 14, 0, 0, 0, time.UTC) - temp := 35.5 - hum := 40.0 - f := &Forecast{ - ProfileID: p.ID, - Timestamp: ts, - TemperatureC: &temp, - HumidityPct: &hum, - Source: "openmeteo", - } - if err := s.UpsertForecast(f); err != nil { - t.Fatalf("UpsertForecast: %v", err) - } - - // Upsert again (should update, not duplicate) - temp2 := 36.0 - f.TemperatureC = &temp2 - if err := s.UpsertForecast(f); err != nil { - t.Fatalf("UpsertForecast update: %v", err) - } - - from := ts.Add(-time.Hour) - to := ts.Add(time.Hour) - forecasts, err := s.GetForecasts(p.ID, from, to, "openmeteo") - if err != nil { - t.Fatalf("GetForecasts: %v", err) - } - if len(forecasts) != 1 { - t.Fatalf("GetForecasts len = %d, want 1", len(forecasts)) - } - if *forecasts[0].TemperatureC != 36.0 { - t.Errorf("temperature = %v, want 36.0", *forecasts[0].TemperatureC) - } - - // Cleanup - deleted, err := s.CleanupOldForecasts(ts.Add(time.Hour * 24)) - if err != nil { - t.Fatal(err) - } - if deleted != 1 { - t.Errorf("deleted = %d, want 1", deleted) - } -} - -// --- Warning CRUD --- - -func TestWarningUpsertAndQuery(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - - now := time.Now().UTC() - w := &Warning{ - ProfileID: p.ID, - WarningID: "dwd-heat-001", - EventType: "STARKE HITZE", - Severity: "Severe", - Headline: "Heat warning Berlin", - Description: "Temperatures up to 37C expected", - Instruction: "Stay hydrated", - Onset: now, - Expires: now.Add(48 * time.Hour), - } - if err := s.UpsertWarning(w); err != nil { - t.Fatalf("UpsertWarning: %v", err) - } - - // Upsert again (should update) - w.Headline = "Updated heat warning" - if err := s.UpsertWarning(w); err != nil { - t.Fatalf("UpsertWarning update: %v", err) - } - - got, err := s.GetWarning("dwd-heat-001") - if err != nil { - t.Fatalf("GetWarning: %v", err) - } - if got.Headline != "Updated heat warning" { - t.Errorf("headline = %s, want 'Updated heat warning'", got.Headline) - } - - active, err := s.GetActiveWarnings(p.ID, now) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 { - t.Errorf("active warnings = %d, want 1", len(active)) - } - - // Check expired don't show - expired, _ := s.GetActiveWarnings(p.ID, now.Add(72*time.Hour)) - if len(expired) != 0 { - t.Errorf("expected 0 active warnings after expiry, got %d", len(expired)) - } - - // Cleanup - deleted, err := s.CleanupExpiredWarnings(now.Add(72 * time.Hour)) - if err != nil { - t.Fatal(err) - } - if deleted != 1 { - t.Errorf("deleted = %d, want 1", deleted) - } -} - -// --- Toggle --- - -func TestToggle(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - - if err := s.SetToggle(p.ID, "gaming", true); err != nil { - t.Fatalf("SetToggle: %v", err) - } - - toggles, err := s.GetToggles(p.ID) - if err != nil { - t.Fatal(err) - } - if len(toggles) != 1 || !toggles[0].Active || toggles[0].Name != "gaming" { - t.Errorf("unexpected toggles: %+v", toggles) - } - - active, err := s.GetActiveToggleNames(p.ID) - if err != nil { - t.Fatal(err) - } - if !active["gaming"] { - t.Error("expected gaming toggle active") - } - - if err := s.SetToggle(p.ID, "gaming", false); err != nil { - t.Fatal(err) - } - active, _ = s.GetActiveToggleNames(p.ID) - if active["gaming"] { - t.Error("expected gaming toggle inactive") - } -} - -// --- Cascade Delete --- - -func TestCascadeDeleteProfile(t *testing.T) { - s := newTestStore(t) - p, _ := s.CreateProfile("home", 52.52, 13.41, "") - r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams()) - s.CreateDevice(r.ID, "PC", "pc", 65, 200, 450, 1.0) - s.CreateOccupant(r.ID, 1, "sedentary", false) - s.CreateACUnit(p.ID, "AC", "portable", 8000, false, 10) - - if err := s.DeleteProfile(p.ID); err != nil { - t.Fatal(err) - } - - rooms, _ := s.ListRooms(p.ID) - if len(rooms) != 0 { - t.Error("expected rooms deleted on cascade") - } - devices, _ := s.ListDevices(r.ID) - if len(devices) != 0 { - t.Error("expected devices deleted on cascade") - } -} diff --git a/internal/store/toggle.go b/internal/store/toggle.go deleted file mode 100644 index 891b9d6..0000000 --- a/internal/store/toggle.go +++ /dev/null @@ -1,78 +0,0 @@ -package store - -import "fmt" - -type Toggle struct { - ID int64 - ProfileID int64 - Name string - Active bool - ActivatedAt string -} - -func (s *Store) SetToggle(profileID int64, name string, active bool) error { - activeInt := 0 - activatedAt := "" - if active { - activeInt = 1 - activatedAt = "datetime('now')" - } - - if active { - _, err := s.db.Exec( - `INSERT INTO toggles (profile_id, name, active, activated_at) VALUES (?, ?, ?, datetime('now')) - ON CONFLICT DO NOTHING`, - profileID, name, activeInt, - ) - if err != nil { - return fmt.Errorf("set toggle: %w", err) - } - _, err = s.db.Exec( - `UPDATE toggles SET active = ?, activated_at = datetime('now') WHERE profile_id = ? AND name = ?`, - activeInt, profileID, name, - ) - return err - } - - _ = activatedAt - _, err := s.db.Exec( - `UPDATE toggles SET active = 0, activated_at = NULL WHERE profile_id = ? AND name = ?`, - profileID, name, - ) - return err -} - -func (s *Store) GetToggles(profileID int64) ([]Toggle, error) { - rows, err := s.db.Query( - `SELECT id, profile_id, name, active, COALESCE(activated_at, '') FROM toggles WHERE profile_id = ? ORDER BY name`, profileID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var toggles []Toggle - for rows.Next() { - var t Toggle - var active int - if err := rows.Scan(&t.ID, &t.ProfileID, &t.Name, &active, &t.ActivatedAt); err != nil { - return nil, err - } - t.Active = active != 0 - toggles = append(toggles, t) - } - return toggles, rows.Err() -} - -func (s *Store) GetActiveToggleNames(profileID int64) (map[string]bool, error) { - toggles, err := s.GetToggles(profileID) - if err != nil { - return nil, err - } - m := make(map[string]bool) - for _, t := range toggles { - if t.Active { - m[t.Name] = true - } - } - return m, nil -} diff --git a/internal/store/warning.go b/internal/store/warning.go deleted file mode 100644 index 988293b..0000000 --- a/internal/store/warning.go +++ /dev/null @@ -1,91 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "time" -) - -type Warning struct { - ID int64 - ProfileID int64 - WarningID string - EventType string - Severity string - Headline string - Description string - Instruction string - Onset time.Time - Expires time.Time - FetchedAt time.Time -} - -func (s *Store) UpsertWarning(w *Warning) error { - _, err := s.db.Exec( - `INSERT INTO warnings (profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(warning_id) DO UPDATE SET - severity=excluded.severity, headline=excluded.headline, - description=excluded.description, instruction=excluded.instruction, - onset=excluded.onset, expires=excluded.expires, fetched_at=datetime('now')`, - w.ProfileID, w.WarningID, w.EventType, w.Severity, w.Headline, - w.Description, w.Instruction, - w.Onset.Format(time.RFC3339), w.Expires.Format(time.RFC3339), - ) - return err -} - -func (s *Store) GetActiveWarnings(profileID int64, now time.Time) ([]Warning, error) { - rows, err := s.db.Query( - `SELECT id, profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires, fetched_at - FROM warnings WHERE profile_id = ? AND expires > ? ORDER BY onset`, - profileID, now.Format(time.RFC3339), - ) - if err != nil { - return nil, err - } - defer rows.Close() - var warnings []Warning - for rows.Next() { - var w Warning - var onset, expires, fetched string - if err := rows.Scan(&w.ID, &w.ProfileID, &w.WarningID, &w.EventType, &w.Severity, &w.Headline, &w.Description, &w.Instruction, &onset, &expires, &fetched); err != nil { - return nil, err - } - w.Onset, _ = time.Parse(time.RFC3339, onset) - w.Expires, _ = time.Parse(time.RFC3339, expires) - w.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched) - warnings = append(warnings, w) - } - return warnings, rows.Err() -} - -func (s *Store) GetWarning(warningID string) (*Warning, error) { - w := &Warning{} - var onset, expires, fetched string - err := s.db.QueryRow( - `SELECT id, profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires, fetched_at - FROM warnings WHERE warning_id = ?`, warningID, - ).Scan(&w.ID, &w.ProfileID, &w.WarningID, &w.EventType, &w.Severity, &w.Headline, &w.Description, &w.Instruction, &onset, &expires, &fetched) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("warning not found: %s", warningID) - } - if err != nil { - return nil, fmt.Errorf("get warning: %w", err) - } - w.Onset, _ = time.Parse(time.RFC3339, onset) - w.Expires, _ = time.Parse(time.RFC3339, expires) - w.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched) - return w, nil -} - -func (s *Store) CleanupExpiredWarnings(now time.Time) (int64, error) { - res, err := s.db.Exec( - `DELETE FROM warnings WHERE expires < ?`, - now.Format(time.RFC3339), - ) - if err != nil { - return 0, err - } - return res.RowsAffected() -} diff --git a/package.json b/package.json index 6716638..7ac6eb8 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,11 @@ { - "name": "heatwave-autopilot", + "name": "heatguard", "version": "1.0.0", - "description": "", - "main": "index.js", + "private": true, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "css": "npx @tailwindcss/cli -i tailwind/input.css -o web/css/app.css --minify", + "css:watch": "npx @tailwindcss/cli -i tailwind/input.css -o web/css/app.css --watch" }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", "devDependencies": { "@tailwindcss/cli": "^4.1.18", "tailwindcss": "^4.1.18" diff --git a/tailwind/input.css b/tailwind/input.css index 91bf218..6bd1089 100644 --- a/tailwind/input.css +++ b/tailwind/input.css @@ -1,3 +1,2 @@ @import "tailwindcss"; -@source "../internal/report/templates/*.tmpl"; -@source "../internal/cli/templates/*.tmpl"; +@source "../web/**/*.{html,js}"; diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..7b79d25 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed all:templates all:js all:css all:i18n +var FS embed.FS diff --git a/web/i18n/de.json b/web/i18n/de.json new file mode 100644 index 0000000..7bbe21a --- /dev/null +++ b/web/i18n/de.json @@ -0,0 +1,249 @@ +{ + "app": { + "name": "HeatGuard", + "tagline": "Personalisierte Hitzevorsorge" + }, + "nav": { + "dashboard": "Dashboard", + "setup": "Einrichtung", + "guide": "Anleitung", + "language": "Sprache" + }, + "setup": { + "title": "Einrichtung", + "profiles": { + "title": "Profile", + "help": "Ein Profil repr\u00e4sentiert einen Standort, den Sie \u00fcberwachen m\u00f6chten.", + "name": { "label": "Name", "tooltip": "Ein beschreibender Name f\u00fcr diesen Standort" }, + "latitude": { "label": "Breitengrad", "tooltip": "Dezimalgrad (z.B. 52.52)" }, + "longitude": { "label": "L\u00e4ngengrad", "tooltip": "Dezimalgrad (z.B. 13.405)" }, + "timezone": { "label": "Zeitzone", "tooltip": "IANA-Zeitzone (z.B. Europe/Berlin)" }, + "geolocate": { + "button": "Meinen Standort verwenden", + "loading": "Standort wird erkannt\u2026", + "denied": "Standortberechtigung verweigert.", + "unavailable": "Standort nicht verf\u00fcgbar.", + "timeout": "Zeit\u00fcberschreitung bei Standortabfrage." + }, + "add": "Profil hinzuf\u00fcgen", + "edit": "Bearbeiten", + "delete": "L\u00f6schen", + "noItems": "Noch keine Profile. Erstellen Sie eines, um zu beginnen." + }, + "rooms": { + "title": "R\u00e4ume", + "help": "F\u00fcgen Sie R\u00e4ume zur \u00dcberwachung hinzu. Die Eigenschaften beeinflussen die W\u00e4rmeberechnung.", + "name": { "label": "Name", "tooltip": "Raumname (z.B. B\u00fcro, Schlafzimmer)" }, + "area": { "label": "Fl\u00e4che (m\u00b2)", "tooltip": "Bodenfl\u00e4che. Beeinflusst den Solargewinn durch Fenster." }, + "ceilingHeight": { "label": "Deckenh\u00f6he (m)", "tooltip": "Raumh\u00f6he. Beeinflusst das Luftvolumen f\u00fcr L\u00fcftungsberechnungen." }, + "floor": { "label": "Stockwerk", "tooltip": "Stockwerknummer (0 = Erdgeschoss). H\u00f6here Stockwerke sind tendenziell w\u00e4rmer." }, + "orientation": { + "label": "Ausrichtung", + "tooltip": "Hauptfensterrichtung. Beeinflusst den zeitlichen Verlauf des Solargewinns.", + "options": { "N": "Nord", "NE": "Nordost", "E": "Ost", "SE": "S\u00fcdost", "S": "S\u00fcd", "SW": "S\u00fcdwest", "W": "West", "NW": "Nordwest" } + }, + "shadingType": { + "label": "Verschattung", + "tooltip": "Art der Au\u00dfenverschattung (keine, Jalousien, Rolll\u00e4den, Markise).", + "options": { "none": "Keine", "blinds": "Jalousien", "shutters": "Rolll\u00e4den", "awning": "Markise" } + }, + "shadingFactor": { "label": "Verschattungsfaktor", "tooltip": "0 = vollst\u00e4ndig verschattet, 1 = keine Verschattung. Rolll\u00e4den \u2248 0,2, Innenjalousien \u2248 0,6." }, + "ventilation": { "label": "L\u00fcftung", "tooltip": "L\u00fcftungsart (nat\u00fcrlich, mechanisch, dicht)." }, + "ventilationAch": { "label": "Luftwechselrate", "tooltip": "Luftwechsel pro Stunde. Dicht \u2248 0,3, nat\u00fcrlich \u2248 1,5, offene Fenster \u2248 5,0." }, + "windowFraction": { "label": "Fensteranteil", "tooltip": "Anteil der Wandfl\u00e4che, die Fenster ist (0\u20131). Typisch: 0,15." }, + "shgc": { "label": "SHGC", "tooltip": "Gesamtenergiedurchlassgrad (0\u20131). W\u00e4rmeschutzglas \u2248 0,3, Klarglas \u2248 0,8." }, + "insulation": { + "label": "D\u00e4mmung", + "tooltip": "Wandd\u00e4mmqualit\u00e4t (schlecht, mittel, gut, ausgezeichnet).", + "options": { "poor": "Schlecht", "average": "Mittel", "good": "Gut", "excellent": "Ausgezeichnet" } + }, + "indoorTemp": { + "label": "Raumtemperatur (\u00b0C)", + "tooltip": "Aktuelle oder gew\u00fcnschte Raumtemperatur. Standard 25\u00b0C." + }, + "add": "Raum hinzuf\u00fcgen", + "noItems": "Noch keine R\u00e4ume. F\u00fcgen Sie R\u00e4ume zu Ihrem Profil hinzu." + }, + "devices": { + "title": "Ger\u00e4te", + "help": "W\u00e4rmeproduzierende Ger\u00e4te in jedem Raum.", + "name": { "label": "Name", "tooltip": "Ger\u00e4tename (z.B. Desktop-PC, TV)" }, + "room": { "label": "Raum", "tooltip": "In welchem Raum sich das Ger\u00e4t befindet" }, + "type": { "label": "Typ", "tooltip": "Ger\u00e4tekategorie" }, + "wattsIdle": { "label": "Watt (Leerlauf)", "tooltip": "Leistungsaufnahme im Leerlauf/Standby" }, + "wattsTypical": { "label": "Watt (Typisch)", "tooltip": "Leistungsaufnahme bei normaler Nutzung" }, + "wattsPeak": { "label": "Watt (Spitze)", "tooltip": "Leistungsaufnahme bei Maximallast (z.B. Gaming)" }, + "dutyCycle": { "label": "Einschaltdauer", "tooltip": "Anteil der aktiven Zeit (0\u20131). K\u00fchlschrank \u2248 0,3, PC \u2248 1,0." }, + "add": "Ger\u00e4t hinzuf\u00fcgen", + "noItems": "Noch keine Ger\u00e4te." + }, + "occupants": { + "title": "Bewohner", + "help": "Personen in jedem Raum. K\u00f6rperw\u00e4rme tr\u00e4gt zur Raumtemperatur bei.", + "room": { "label": "Raum", "tooltip": "Welcher Raum" }, + "count": { "label": "Anzahl", "tooltip": "Anzahl der Personen" }, + "activity": { + "label": "Aktivit\u00e4tsniveau", + "tooltip": "Schlafend \u2248 70W, Sitzend \u2248 100W, Leicht \u2248 130W, Mittel \u2248 200W, Schwer \u2248 300W pro Person.", + "options": { "sleeping": "Schlafend", "sedentary": "Sitzend", "light": "Leicht", "moderate": "Mittel", "heavy": "Schwer" } + }, + "vulnerable": { "label": "Schutzbed\u00fcrftig", "tooltip": "Ankreuzen bei \u00e4lteren Menschen, Kleinkindern oder gesundheitlich eingeschr\u00e4nkten Personen. F\u00fcgt Pflegeerinnerungen hinzu." }, + "add": "Bewohner hinzuf\u00fcgen", + "noItems": "Noch keine Bewohner." + }, + "ac": { + "title": "Klimaanlagen", + "help": "Klimager\u00e4te und deren Raumzuordnungen.", + "name": { "label": "Name", "tooltip": "Name des Klimager\u00e4ts" }, + "type": { + "label": "Typ", + "tooltip": "Mobil, Fenster, Split, Zentral", + "options": { "portable": "Mobil", "window": "Fenster", "split": "Split", "central": "Zentral" } + }, + "capacity": { "label": "Leistung (BTU)", "tooltip": "K\u00fchlleistung in BTU/h. Typisch mobil: 8.000\u201314.000 BTU." }, + "eer": { "label": "EER", "tooltip": "Energieeffizienzwert. H\u00f6her = effizienter. Typisch: 8\u201312." }, + "dehumidify": { "label": "Entfeuchtung", "tooltip": "Ob das Ger\u00e4t einen Entfeuchtungsmodus hat" }, + "rooms": { "label": "Zugewiesene R\u00e4ume", "tooltip": "Welche R\u00e4ume dieses Klimager\u00e4t versorgt" }, + "add": "Klimager\u00e4t hinzuf\u00fcgen", + "noItems": "Noch keine Klimager\u00e4te." + }, + "toggles": { + "title": "Schalter", + "help": "Aktivit\u00e4tsschalter beeinflussen die W\u00e4rmeberechnung f\u00fcr den aktuellen Tag.", + "gaming": "Gaming (Ger\u00e4te auf Spitzenlast)", + "cooking": "Kochen (zus\u00e4tzliche K\u00fcchenw\u00e4rme)", + "laundry": "W\u00e4sche / Trockner l\u00e4uft", + "guests": "Zus\u00e4tzliche G\u00e4ste anwesend" + }, + "forecast": { + "title": "Wettervorhersage", + "help": "Wetterdaten f\u00fcr Ihren Profilstandort abrufen.", + "fetch": "Vorhersage abrufen", + "lastFetched": "Zuletzt abgerufen", + "never": "Nie", + "fetching": "Vorhersage wird abgerufen\u2026" + }, + "llm": { + "title": "KI-Zusammenfassung", + "help": "Konfigurieren Sie einen KI-Anbieter f\u00fcr personalisierte Hitzezusammenfassungen.", + "provider": "Anbieter", + "model": "Modell", + "apiKey": "API-Schl\u00fcssel", + "apiKeyPlaceholder": "API-Schl\u00fcssel eingeben", + "modelPlaceholder": "Modellname (leer lassen f\u00fcr Standard)", + "save": "Einstellungen speichern", + "serverProvider": "Server-Anbieter", + "configured": "Auf Server konfiguriert", + "notConfigured": "Kein KI-Anbieter auf dem Server konfiguriert.", + "providerOptions": { "anthropic": "Anthropic", "openai": "OpenAI", "gemini": "Google Gemini" } + } + }, + "dashboard": { + "title": "Hitzebericht", + "riskLevel": "Risikostufe", + "peakTemp": "H\u00f6chsttemperatur", + "minNightTemp": "Min. Nachttemperatur", + "poorNightCool": "Schlechte Nachtk\u00fchlung", + "timeline": "24h-Zeitverlauf", + "roomBudgets": "Raum-W\u00e4rmebilanzen", + "actions": "Ma\u00dfnahmen", + "careChecklist": "Pflege-Checkliste", + "warnings": "Wetterwarnungen", + "riskWindows": "Risikozeitr\u00e4ume", + "llmSummary": "KI-Zusammenfassung", + "noData": "Noch keine Daten. Richten Sie Ihr Profil ein und rufen Sie eine Vorhersage ab.", + "getStarted": "Loslegen", + "goToSetup": "Zur Einrichtung", + "goToGuide": "Anleitung lesen", + "loading": "Laden\u2026", + "computing": "Hitzeanalyse wird berechnet\u2026", + "error": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "internalGains": "Interne Gewinne", + "solarGain": "Solargewinn", + "ventGain": "L\u00fcftungsgewinn", + "totalGain": "Gesamtgewinn", + "acCapacity": "Klimaleistung", + "headroom": "Reserve", + "comfortable": "Komfortabel", + "marginal": "Grenzwertig", + "overloaded": "\u00dcberlastet", + "fetchForecastFirst": "Keine Vorhersagedaten. Rufen Sie zuerst eine Vorhersage in der Einrichtung ab.", + "yes": "Ja", + "no": "Nein", + "riskLow": "Niedrig", + "riskModerate": "Mittel", + "riskHigh": "Hoch", + "riskExtreme": "Extrem" + }, + "guide": { + "title": "Erste Schritte", + "intro": "HeatGuard hilft Ihnen, sich auf Hitzeereignisse vorzubereiten, indem es Ihre Wohnr\u00e4ume, Wettervorhersagen analysiert und personalisierte st\u00fcndliche Aktionspl\u00e4ne erstellt.", + "step1": { + "title": "1. Profil erstellen", + "text": "Gehen Sie zur Einrichtung und erstellen Sie ein Profil f\u00fcr Ihren Standort. Geben Sie Ihre Koordinaten ein (nutzen Sie 'Meinen Standort verwenden' f\u00fcr automatische Erkennung) und die Zeitzone." + }, + "step2": { + "title": "2. R\u00e4ume hinzuf\u00fcgen", + "text": "F\u00fcgen Sie die R\u00e4ume hinzu, die Sie \u00fcberwachen m\u00f6chten. Stellen Sie Fl\u00e4che, Ausrichtung (Fensterrichtung), Deckenh\u00f6he und Fenstereigenschaften ein. Diese beeinflussen die Solar- und L\u00fcftungsberechnungen." + }, + "step3": { + "title": "3. Ger\u00e4te & Bewohner hinzuf\u00fcgen", + "text": "F\u00fcgen Sie w\u00e4rmeproduzierende Ger\u00e4te (Computer, Fernseher, Haushaltsger\u00e4te) und Bewohner zu jedem Raum hinzu. Jede Person und jedes Ger\u00e4t tr\u00e4gt zur internen W\u00e4rmelast bei." + }, + "step4": { + "title": "4. Klimaanlagen konfigurieren", + "text": "Wenn Sie eine Klimaanlage haben, f\u00fcgen Sie Ihre Ger\u00e4te hinzu und weisen Sie sie R\u00e4umen zu. So kann HeatGuard berechnen, ob Ihre K\u00fchlleistung ausreicht." + }, + "step5": { + "title": "5. Vorhersage abrufen", + "text": "Klicken Sie auf der Einrichtungsseite auf 'Vorhersage abrufen', um aktuelle Wetterdaten f\u00fcr Ihren Standort zu erhalten. Vorhersagen decken die n\u00e4chsten 3 Tage ab." + }, + "step6": { + "title": "6. Dashboard ansehen", + "text": "Kehren Sie zum Dashboard zur\u00fcck, um Ihre personalisierte Hitzeanalyse zu sehen: Risikostufe, Temperaturverlauf, Raum-W\u00e4rmebilanzen und empfohlene Ma\u00dfnahmen." + }, + "params": { + "title": "Raumparameter verstehen", + "shgc": "SHGC (Gesamtenergiedurchlassgrad): Wie viel Solarenergie durch Fenster gelangt. W\u00e4rmeschutzglas \u2248 0,3, einfaches Klarglas \u2248 0,8.", + "ventilation": "Luftwechselrate (ACH): Wie oft das Luftvolumen des Raums pro Stunde ausgetauscht wird. Dicht \u2248 0,3, offene Fenster \u2248 5,0.", + "shading": "Verschattungsfaktor: 0 = vollst\u00e4ndig verschattet (keine Sonne), 1 = keine Verschattung. Rolll\u00e4den \u2248 0,2, Innenjalousien \u2248 0,6.", + "orientation": "Ausrichtung: S\u00fcdr\u00e4ume bekommen die meiste Mittagssonne. Osten = Morgensonne, Westen = Nachmittagssonne." + }, + "risk": { + "title": "Risikostufen verstehen", + "low": "Niedrig: Temperaturen unter 30\u00b0C. Normale Bedingungen.", + "moderate": "Mittel: Temperaturen 30\u201335\u00b0C. Grundlegende Vorsichtsma\u00dfnahmen treffen.", + "high": "Hoch: Temperaturen 35\u201340\u00b0C. Erhebliches Hitzestressrisiko.", + "extreme": "Extrem: Temperaturen \u00fcber 40\u00b0C. Gef\u00e4hrliche Bedingungen." + }, + "budget": { + "title": "W\u00e4rmebilanzen verstehen", + "text": "Die W\u00e4rmebilanz jedes Raums vergleicht die gesamten W\u00e4rmegewinne (Ger\u00e4te + Personen + Solar + L\u00fcftung) mit der Klimak\u00fchlleistung. Status: Komfortabel (>20% Reserve), Grenzwertig (0\u201320% Reserve), \u00dcberlastet (Gewinne \u00fcbersteigen Kapazit\u00e4t)." + }, + "tips": { + "title": "Tipps f\u00fcr Genauigkeit", + "tip1": "Messen Sie Ihre Raumfl\u00e4che und Fenstergr\u00f6\u00dfen f\u00fcr bessere Solargewinnsch\u00e4tzungen.", + "tip2": "Pr\u00fcfen Sie Ger\u00e4teleistungen auf Typenschildern oder Herstellerangaben.", + "tip3": "Schlie\u00dfen Sie Rolll\u00e4den/Jalousien w\u00e4hrend der Spitzensonnenstunden f\u00fcr maximale Wirkung.", + "tip4": "Nutzen Sie den Gaming-Schalter an Tagen, an denen Sie Ger\u00e4te unter Volllast betreiben." + } + }, + "footer": { + "source": "Quellcode", + "license": "GPL-3.0-Lizenz" + }, + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "L\u00f6schen", + "edit": "Bearbeiten", + "saving": "Speichern\u2026", + "saved": "Gespeichert", + "error": "Etwas ist schiefgelaufen.", + "confirm": "Sind Sie sicher?", + "loading": "Laden\u2026", + "noProfile": "Kein Profil ausgew\u00e4hlt.", + "watts": "W", + "btuh": "BTU/h" + } +} diff --git a/web/i18n/en.json b/web/i18n/en.json new file mode 100644 index 0000000..a957a01 --- /dev/null +++ b/web/i18n/en.json @@ -0,0 +1,249 @@ +{ + "app": { + "name": "HeatGuard", + "tagline": "Personalized heat preparedness" + }, + "nav": { + "dashboard": "Dashboard", + "setup": "Setup", + "guide": "Guide", + "language": "Language" + }, + "setup": { + "title": "Setup", + "profiles": { + "title": "Profiles", + "help": "A profile represents a location you want to monitor.", + "name": { "label": "Name", "tooltip": "A descriptive name for this location" }, + "latitude": { "label": "Latitude", "tooltip": "Decimal degrees (e.g. 52.52)" }, + "longitude": { "label": "Longitude", "tooltip": "Decimal degrees (e.g. 13.405)" }, + "timezone": { "label": "Timezone", "tooltip": "IANA timezone (e.g. Europe/Berlin)" }, + "geolocate": { + "button": "Use my location", + "loading": "Detecting location\u2026", + "denied": "Location permission denied.", + "unavailable": "Location unavailable.", + "timeout": "Location request timed out." + }, + "add": "Add Profile", + "edit": "Edit", + "delete": "Delete", + "noItems": "No profiles yet. Create one to get started." + }, + "rooms": { + "title": "Rooms", + "help": "Add rooms to monitor. Properties affect heat calculations.", + "name": { "label": "Name", "tooltip": "Room name (e.g. Office, Bedroom)" }, + "area": { "label": "Area (m\u00b2)", "tooltip": "Floor area. Affects solar gain through windows." }, + "ceilingHeight": { "label": "Ceiling Height (m)", "tooltip": "Room height. Affects air volume for ventilation calculations." }, + "floor": { "label": "Floor", "tooltip": "Floor number (0 = ground). Higher floors tend to be warmer." }, + "orientation": { + "label": "Orientation", + "tooltip": "Primary window direction. Affects solar gain timing.", + "options": { "N": "North", "NE": "Northeast", "E": "East", "SE": "Southeast", "S": "South", "SW": "Southwest", "W": "West", "NW": "Northwest" } + }, + "shadingType": { + "label": "Shading", + "tooltip": "Type of exterior shading (none, blinds, shutters, awning).", + "options": { "none": "None", "blinds": "Blinds", "shutters": "Shutters", "awning": "Awning" } + }, + "shadingFactor": { "label": "Shading Factor", "tooltip": "0 = fully shaded, 1 = no shading. External shutters \u2248 0.2, internal blinds \u2248 0.6." }, + "ventilation": { "label": "Ventilation", "tooltip": "Ventilation type (natural, mechanical, sealed)." }, + "ventilationAch": { "label": "Ventilation ACH", "tooltip": "Air Changes per Hour. Sealed \u2248 0.3, natural \u2248 1.5, open windows \u2248 5.0." }, + "windowFraction": { "label": "Window Fraction", "tooltip": "Fraction of wall area that is window (0\u20131). Typical: 0.15." }, + "shgc": { "label": "SHGC", "tooltip": "Solar Heat Gain Coefficient (0\u20131). Low-E glass \u2248 0.3, clear glass \u2248 0.8." }, + "insulation": { + "label": "Insulation", + "tooltip": "Wall insulation quality (poor, average, good, excellent).", + "options": { "poor": "Poor", "average": "Average", "good": "Good", "excellent": "Excellent" } + }, + "indoorTemp": { + "label": "Indoor Temp (\u00b0C)", + "tooltip": "Current or target indoor temperature. Default 25\u00b0C." + }, + "add": "Add Room", + "noItems": "No rooms yet. Add rooms to your profile." + }, + "devices": { + "title": "Devices", + "help": "Heat-producing devices in each room.", + "name": { "label": "Name", "tooltip": "Device name (e.g. Desktop PC, TV)" }, + "room": { "label": "Room", "tooltip": "Which room this device is in" }, + "type": { "label": "Type", "tooltip": "Device category" }, + "wattsIdle": { "label": "Watts (Idle)", "tooltip": "Power draw when idle/standby" }, + "wattsTypical": { "label": "Watts (Typical)", "tooltip": "Power draw during normal use" }, + "wattsPeak": { "label": "Watts (Peak)", "tooltip": "Power draw at maximum load (e.g. gaming)" }, + "dutyCycle": { "label": "Duty Cycle", "tooltip": "Fraction of time active (0\u20131). Fridge \u2248 0.3, PC \u2248 1.0." }, + "add": "Add Device", + "noItems": "No devices yet." + }, + "occupants": { + "title": "Occupants", + "help": "People in each room. Metabolic heat contributes to room temperature.", + "room": { "label": "Room", "tooltip": "Which room" }, + "count": { "label": "Count", "tooltip": "Number of people" }, + "activity": { + "label": "Activity Level", + "tooltip": "Sleeping \u2248 70W, Sedentary \u2248 100W, Light \u2248 130W, Moderate \u2248 200W, Heavy \u2248 300W per person.", + "options": { "sleeping": "Sleeping", "sedentary": "Sedentary", "light": "Light", "moderate": "Moderate", "heavy": "Heavy" } + }, + "vulnerable": { "label": "Vulnerable", "tooltip": "Check if elderly, young children, or health-compromised. Adds care reminders." }, + "add": "Add Occupants", + "noItems": "No occupants yet." + }, + "ac": { + "title": "AC Units", + "help": "Air conditioning units and their room assignments.", + "name": { "label": "Name", "tooltip": "AC unit name" }, + "type": { + "label": "Type", + "tooltip": "portable, window, split, central", + "options": { "portable": "Portable", "window": "Window", "split": "Split", "central": "Central" } + }, + "capacity": { "label": "Capacity (BTU)", "tooltip": "Cooling capacity in BTU/h. Typical portable: 8,000\u201314,000 BTU." }, + "eer": { "label": "EER", "tooltip": "Energy Efficiency Ratio. Higher = more efficient. Typical: 8\u201312." }, + "dehumidify": { "label": "Dehumidify", "tooltip": "Whether the unit has a dehumidify mode" }, + "rooms": { "label": "Assigned Rooms", "tooltip": "Which rooms this AC unit serves" }, + "add": "Add AC Unit", + "noItems": "No AC units yet." + }, + "toggles": { + "title": "Toggles", + "help": "Activity toggles affect heat calculations for the current day.", + "gaming": "Gaming (devices at peak power)", + "cooking": "Cooking (additional kitchen heat)", + "laundry": "Laundry / Dryer running", + "guests": "Extra guests present" + }, + "forecast": { + "title": "Forecast", + "help": "Fetch weather forecast data for your profile location.", + "fetch": "Fetch Forecast", + "lastFetched": "Last fetched", + "never": "Never", + "fetching": "Fetching forecast\u2026" + }, + "llm": { + "title": "AI Summary", + "help": "Configure an AI provider for personalized heat summaries.", + "provider": "Provider", + "model": "Model", + "apiKey": "API Key", + "apiKeyPlaceholder": "Enter API key", + "modelPlaceholder": "Model name (leave blank for default)", + "save": "Save Settings", + "serverProvider": "Server provider", + "configured": "Configured on server", + "notConfigured": "No AI provider configured on the server.", + "providerOptions": { "anthropic": "Anthropic", "openai": "OpenAI", "gemini": "Google Gemini" } + } + }, + "dashboard": { + "title": "Heat Report", + "riskLevel": "Risk Level", + "peakTemp": "Peak Temperature", + "minNightTemp": "Min Night Temp", + "poorNightCool": "Poor Night Cooling", + "timeline": "24h Timeline", + "roomBudgets": "Room Heat Budgets", + "actions": "Actions", + "careChecklist": "Care Checklist", + "warnings": "Weather Warnings", + "riskWindows": "Risk Windows", + "llmSummary": "AI Summary", + "noData": "No data yet. Set up your profile and fetch a forecast.", + "getStarted": "Get Started", + "goToSetup": "Go to Setup", + "goToGuide": "Read the Guide", + "loading": "Loading\u2026", + "computing": "Computing heat analysis\u2026", + "error": "Failed to load data. Please try again.", + "internalGains": "Internal Gains", + "solarGain": "Solar Gain", + "ventGain": "Ventilation Gain", + "totalGain": "Total Gain", + "acCapacity": "AC Capacity", + "headroom": "Headroom", + "comfortable": "Comfortable", + "marginal": "Marginal", + "overloaded": "Overloaded", + "fetchForecastFirst": "No forecast data. Fetch a forecast in Setup first.", + "yes": "Yes", + "no": "No", + "riskLow": "Low", + "riskModerate": "Moderate", + "riskHigh": "High", + "riskExtreme": "Extreme" + }, + "guide": { + "title": "Getting Started", + "intro": "HeatGuard helps you prepare for heat events by analyzing your living space, weather forecasts, and providing personalized hour-by-hour action plans.", + "step1": { + "title": "1. Create a Profile", + "text": "Go to Setup and create a profile for your location. Enter your coordinates (use the 'Use my location' button for auto-detection) and timezone." + }, + "step2": { + "title": "2. Add Rooms", + "text": "Add the rooms you want to monitor. Set the area, orientation (which direction windows face), ceiling height, and window properties. These affect solar gain and ventilation calculations." + }, + "step3": { + "title": "3. Add Devices & Occupants", + "text": "Add heat-producing devices (computers, TVs, appliances) and occupants to each room. Each person and device contributes to the internal heat load." + }, + "step4": { + "title": "4. Configure AC Units", + "text": "If you have air conditioning, add your units and assign them to rooms. This allows HeatGuard to calculate whether your cooling capacity is sufficient." + }, + "step5": { + "title": "5. Fetch a Forecast", + "text": "On the Setup page, click 'Fetch Forecast' to get the latest weather data for your location. Forecasts cover the next 3 days." + }, + "step6": { + "title": "6. View the Dashboard", + "text": "Return to the Dashboard to see your personalized heat analysis: risk level, temperature timeline, room heat budgets, and recommended actions." + }, + "params": { + "title": "Understanding Room Parameters", + "shgc": "SHGC (Solar Heat Gain Coefficient): How much solar energy passes through windows. Low-E glass \u2248 0.3, clear single pane \u2248 0.8.", + "ventilation": "Ventilation ACH (Air Changes per Hour): How often the room's air volume is replaced. Sealed room \u2248 0.3, open windows \u2248 5.0.", + "shading": "Shading Factor: 0 = fully shaded (no sun enters), 1 = no shading. External shutters \u2248 0.2, internal blinds \u2248 0.6.", + "orientation": "Orientation: South-facing rooms get the most midday sun. East faces morning sun, West faces afternoon sun." + }, + "risk": { + "title": "Understanding Risk Levels", + "low": "Low: Temperatures below 30\u00b0C. Normal conditions.", + "moderate": "Moderate: Temperatures 30\u201335\u00b0C. Take basic precautions.", + "high": "High: Temperatures 35\u201340\u00b0C. Significant heat stress risk.", + "extreme": "Extreme: Temperatures above 40\u00b0C. Dangerous conditions." + }, + "budget": { + "title": "Understanding Heat Budgets", + "text": "Each room's heat budget compares total heat gains (devices + people + solar + ventilation) against AC cooling capacity. Status: Comfortable (>20% headroom), Marginal (0\u201320% headroom), Overloaded (gains exceed capacity)." + }, + "tips": { + "title": "Tips for Accuracy", + "tip1": "Measure your room area and window sizes for better solar gain estimates.", + "tip2": "Check device power ratings on labels or manufacturer specs.", + "tip3": "Close external shutters/blinds during peak sun hours for maximum effect.", + "tip4": "Use the gaming toggle on days when you'll be running devices at full load." + } + }, + "footer": { + "source": "Source Code", + "license": "GPL-3.0 License" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "saving": "Saving\u2026", + "saved": "Saved", + "error": "Something went wrong.", + "confirm": "Are you sure?", + "loading": "Loading\u2026", + "noProfile": "No profile selected.", + "watts": "W", + "btuh": "BTU/h" + } +} diff --git a/web/js/dashboard.js b/web/js/dashboard.js new file mode 100644 index 0000000..5b4ba84 --- /dev/null +++ b/web/js/dashboard.js @@ -0,0 +1,354 @@ +// Dashboard page logic +(function() { + "use strict"; + + const $ = (id) => document.getElementById(id); + + function show(id) { $(id).classList.remove("hidden"); } + function hide(id) { $(id).classList.add("hidden"); } + + const riskColors = { + low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" }, + moderate: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300", border: "border-yellow-500" }, + high: { bg: "bg-orange-100 dark:bg-orange-900", text: "text-orange-700 dark:text-orange-300", border: "border-orange-500" }, + extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" }, + }; + + const budgetColors = { + comfortable: "bg-green-500", + marginal: "bg-yellow-500", + overloaded: "bg-red-500", + }; + + const budgetHexColors = { + comfortable: "#22c55e", + marginal: "#eab308", + overloaded: "#ef4444", + }; + + function tempColorHex(temp) { + if (temp >= 40) return "#dc2626"; + if (temp >= 35) return "#f97316"; + if (temp >= 30) return "#facc15"; + if (temp >= 25) return "#fde68a"; + if (temp >= 20) return "#bbf7d0"; + return "#bfdbfe"; + } + + window.loadDashboard = async function() { + hide("no-data"); + hide("no-forecast"); + hide("error-state"); + hide("data-display"); + show("loading"); + + try { + const profileId = await getActiveProfileId(); + if (!profileId) { + hide("loading"); + show("no-data"); + return; + } + + const forecasts = await dbGetByIndex("forecasts", "profileId", profileId); + if (forecasts.length === 0) { + hide("loading"); + show("no-forecast"); + return; + } + + const today = new Date().toISOString().slice(0, 10); + const payload = await getComputePayload(profileId, today); + if (!payload) { + hide("loading"); + show("no-data"); + return; + } + + const resp = await fetch("/api/compute/dashboard", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Compute failed"); + } + const data = await resp.json(); + + hide("loading"); + show("data-display"); + renderDashboard(data); + + // LLM summary (async) + try { + const llmBody = { + date: data.date, + peakTempC: data.peakTempC, + minNightTempC: data.minNightTempC, + riskLevel: data.riskLevel, + acHeadroomBTUH: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].headroomBtuh : 0, + budgetStatus: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].status : "comfortable", + language: window.HG && window.HG.lang === "de" ? "German" : "English", + }; + + // Include client-side LLM settings if available + const llmProvider = await getSetting("llmProvider"); + const llmApiKey = await getSetting("llmApiKey"); + const llmModel = await getSetting("llmModel"); + if (llmProvider && llmApiKey) { + llmBody.provider = llmProvider; + llmBody.apiKey = llmApiKey; + if (llmModel) llmBody.model = llmModel; + } + + const llmResp = await fetch("/api/llm/summarize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(llmBody), + }); + if (llmResp.ok) { + const llmData = await llmResp.json(); + if (llmData.summary) { + $("llm-summary").innerHTML = `

${esc(llmData.summary)}

+

AI-generated summary. Not a substitute for professional advice.

`; + } else { + $("llm-section").classList.add("hidden"); + } + } + } catch (e) { + $("llm-section").classList.add("hidden"); + } + + } catch (err) { + hide("loading"); + show("error-state"); + console.error("Dashboard error:", err); + } + }; + + function renderDashboard(data) { + $("profile-name").textContent = data.profileName + " \u2014 " + data.date; + + // Risk card + const rc = riskColors[data.riskLevel] || riskColors.low; + $("risk-card").className = `rounded-xl p-4 text-center shadow-sm ${rc.bg}`; + $("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`; + $("risk-level").textContent = data.riskLevel; + + // Peak temp + $("peak-temp").textContent = data.peakTempC.toFixed(1) + "\u00b0C"; + + // Min night temp + $("min-night-temp").textContent = data.minNightTempC.toFixed(1) + "\u00b0C"; + if (data.poorNightCool) { + $("poor-night-cool").classList.remove("hidden"); + } + + // Warnings + if (data.warnings && data.warnings.length > 0) { + show("warnings-section"); + $("warnings-section").innerHTML = '

Warnings

' + + data.warnings.map(w => ` +
+
${esc(w.headline)}
+
${esc(w.description)}
+ ${w.instruction ? `
${esc(w.instruction)}
` : ''} +
${esc(w.onset)} \u2014 ${esc(w.expires)}
+
+ `).join(""); + } + + // Risk windows + if (data.riskWindows && data.riskWindows.length > 0) { + show("risk-windows-section"); + $("risk-windows").innerHTML = data.riskWindows.map(w => { + const wc = riskColors[w.level] || riskColors.low; + return ` +
+ ${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00 + ${w.level} + ${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)} +
+ `; + }).join(""); + } + + // Timeline SVG chart + budget strip + if (data.timeline && data.timeline.length > 0) { + renderTimelineChart(data.timeline); + renderBudgetStrip(data.timeline); + } + + // Room budgets + if (data.roomBudgets && data.roomBudgets.length > 0) { + show("budgets-section"); + $("room-budgets").innerHTML = data.roomBudgets.map(rb => { + const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1); + const gainPct = Math.min((rb.totalGainBtuh / maxVal) * 100, 100); + const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100); + return ` +
+
+ ${esc(rb.roomName)} +
+
+
Internal${rb.internalGainsW.toFixed(0)} W
+
Solar${rb.solarGainW.toFixed(0)} W
+
Ventilation${rb.ventGainW.toFixed(0)} W
+
Total${rb.totalGainBtuh.toFixed(0)} BTU/h
+
AC Capacity${rb.acCapacityBtuh.toFixed(0)} BTU/h
+
Headroom${rb.headroomBtuh.toFixed(0)} BTU/h
+
+
+
+
+
+
+
+
+
+ GainAC +
+
+
+ `; + }).join(""); + } + + // Care checklist + if (data.careChecklist && data.careChecklist.length > 0) { + show("care-section"); + $("care-checklist").innerHTML = data.careChecklist.map(item => ` +
  • + + ${esc(item)} +
  • + `).join(""); + } + } + + // ========== SVG Timeline Chart ========== + function renderTimelineChart(timeline) { + const container = $("timeline-chart"); + const width = 720; + const height = 200; + const padLeft = 40; + const padRight = 10; + const padTop = 15; + const padBottom = 25; + const chartW = width - padLeft - padRight; + const chartH = height - padTop - padBottom; + + const temps = timeline.map(s => s.tempC); + const minTemp = Math.floor(Math.min(...temps) / 5) * 5; + const maxTemp = Math.ceil(Math.max(...temps) / 5) * 5; + const tempRange = maxTemp - minTemp || 10; + + function x(i) { return padLeft + (i / (timeline.length - 1)) * chartW; } + function y(t) { return padTop + (1 - (t - minTemp) / tempRange) * chartH; } + + // Build area path (temperature curve filled to bottom) + const points = timeline.map((s, i) => `${x(i).toFixed(1)},${y(s.tempC).toFixed(1)}`); + const linePath = `M${points.join(" L")}`; + const areaPath = `${linePath} L${x(timeline.length - 1).toFixed(1)},${(padTop + chartH).toFixed(1)} L${padLeft},${(padTop + chartH).toFixed(1)} Z`; + + // Build gradient stops based on temperature + const gradientStops = timeline.map((s, i) => { + const pct = ((i / (timeline.length - 1)) * 100).toFixed(1); + return ``; + }).join(""); + + const gradientStopsLine = timeline.map((s, i) => { + const pct = ((i / (timeline.length - 1)) * 100).toFixed(1); + return ``; + }).join(""); + + // Threshold lines + const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp); + const thresholdLines = thresholds.map(t => + ` + ${t}\u00b0` + ).join(""); + + // Y-axis labels (min, max) + const yLabels = ` + ${maxTemp}\u00b0 + ${minTemp}\u00b0 + `; + + // X-axis labels (every 3 hours) + const xLabels = timeline + .filter((s, i) => s.hour % 3 === 0) + .map((s, i) => { + const idx = timeline.findIndex(t => t.hour === s.hour); + return `${String(s.hour).padStart(2, '0')}`; + }).join(""); + + // Data points (circles) + const circles = timeline.map((s, i) => + `` + ).join(""); + + const svg = ` + + ${gradientStops} + ${gradientStopsLine} + + + + ${thresholdLines} + ${yLabels} + ${xLabels} + ${circles} + `; + + container.innerHTML = svg; + + // Tooltip on hover/click + const tooltip = $("timeline-tooltip"); + container.querySelectorAll(".timeline-dot").forEach(dot => { + const handler = (e) => { + const idx = parseInt(dot.dataset.idx); + const slot = timeline[idx]; + const actions = slot.actions && slot.actions.length > 0 + ? slot.actions.map(a => `\u2022 ${esc(a.name)}`).join("
    ") + : "No actions"; + tooltip.innerHTML = ` +
    ${slot.hourStr}
    +
    ${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH
    +
    ${slot.budgetStatus}
    +
    ${actions}
    + `; + const rect = dot.getBoundingClientRect(); + tooltip.style.left = (rect.left + window.scrollX - 60) + "px"; + tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 8) + "px"; + tooltip.classList.remove("hidden"); + }; + dot.addEventListener("mouseenter", handler); + dot.addEventListener("click", handler); + }); + + container.addEventListener("mouseleave", () => tooltip.classList.add("hidden")); + } + + // ========== Budget Status Strip ========== + function renderBudgetStrip(timeline) { + const strip = $("timeline-strip"); + strip.innerHTML = timeline.map(slot => { + const color = budgetHexColors[slot.budgetStatus] || "#d1d5db"; + return `
    `; + }).join(""); + } + + function esc(s) { + if (!s) return ""; + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; + } + + // Init + loadDashboard(); +})(); diff --git a/web/js/db.js b/web/js/db.js new file mode 100644 index 0000000..a3a0e03 --- /dev/null +++ b/web/js/db.js @@ -0,0 +1,290 @@ +// IndexedDB wrapper for HeatGuard +const DB_NAME = "heatguard"; +const DB_VERSION = 1; + +const STORES = { + profiles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "name", keyPath: "name", unique: true }] }, + rooms: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] }, + devices: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] }, + occupants: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] }, + ac_units: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] }, + ac_assignments: { keyPath: ["acId", "roomId"] }, + forecasts: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] }, + warnings: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] }, + toggles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId_name", keyPath: ["profileId", "name"] }] }, + settings: { keyPath: "key" }, +}; + +let dbPromise = null; + +function openDB() { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (e) => { + const db = e.target.result; + for (const [name, cfg] of Object.entries(STORES)) { + if (!db.objectStoreNames.contains(name)) { + const opts = { keyPath: cfg.keyPath }; + if (cfg.autoIncrement) opts.autoIncrement = true; + const store = db.createObjectStore(name, opts); + if (cfg.indexes) { + for (const idx of cfg.indexes) { + store.createIndex(idx.name, idx.keyPath, { unique: !!idx.unique }); + } + } + } + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return dbPromise; +} + +async function dbPut(storeName, item) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + const req = store.put(item); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dbAdd(storeName, item) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + const req = store.add(item); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dbGet(storeName, key) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readonly"); + const req = tx.objectStore(storeName).get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dbGetAll(storeName) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readonly"); + const req = tx.objectStore(storeName).getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); +} + +async function dbGetByIndex(storeName, indexName, key) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const index = store.index(indexName); + const req = index.getAll(key); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); +} + +async function dbDelete(storeName, key) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + const req = tx.objectStore(storeName).delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function dbClear(storeName) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + const req = tx.objectStore(storeName).clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +// Cascade delete: delete a profile and all related data +async function deleteProfile(profileId) { + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + for (const room of rooms) { + await deleteRoomData(room.id); + } + await deleteByIndex("ac_units", "profileId", profileId); + await deleteByIndex("forecasts", "profileId", profileId); + await deleteByIndex("warnings", "profileId", profileId); + + // Delete toggles for this profile + const toggles = await dbGetAll("toggles"); + for (const t of toggles) { + if (t.profileId === profileId) await dbDelete("toggles", t.id); + } + + // Delete ac_assignments for ac_units that belonged to this profile + const allAssignments = await dbGetAll("ac_assignments"); + const acUnits = await dbGetByIndex("ac_units", "profileId", profileId); + const acIds = new Set(acUnits.map(u => u.id)); + for (const a of allAssignments) { + if (acIds.has(a.acId)) await dbDelete("ac_assignments", [a.acId, a.roomId]); + } + + await dbDelete("profiles", profileId); +} + +async function deleteRoomData(roomId) { + await deleteByIndex("devices", "roomId", roomId); + await deleteByIndex("occupants", "roomId", roomId); + // Delete ac_assignments for this room + const assignments = await dbGetAll("ac_assignments"); + for (const a of assignments) { + if (a.roomId === roomId) await dbDelete("ac_assignments", [a.acId, a.roomId]); + } + await dbDelete("rooms", roomId); +} + +async function deleteByIndex(storeName, indexName, key) { + const items = await dbGetByIndex(storeName, indexName, key); + for (const item of items) { + const pk = item.id !== undefined ? item.id : null; + if (pk !== null) await dbDelete(storeName, pk); + } +} + +// Settings helpers +async function getSetting(key) { + const item = await dbGet("settings", key); + return item ? item.value : null; +} + +async function setSetting(key, value) { + await dbPut("settings", { key, value }); +} + +async function getActiveProfileId() { + return await getSetting("activeProfileId"); +} + +async function setActiveProfileId(id) { + await setSetting("activeProfileId", id); +} + +// Build full compute payload from IndexedDB +async function getComputePayload(profileId, dateStr) { + const profiles = await dbGetAll("profiles"); + const profile = profiles.find(p => p.id === profileId); + if (!profile) return null; + + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + const allDevices = []; + const allOccupants = []; + for (const room of rooms) { + const devices = await dbGetByIndex("devices", "roomId", room.id); + allDevices.push(...devices); + const occupants = await dbGetByIndex("occupants", "roomId", room.id); + allOccupants.push(...occupants); + } + + const acUnits = await dbGetByIndex("ac_units", "profileId", profileId); + const allAssignments = await dbGetAll("ac_assignments"); + const acIds = new Set(acUnits.map(u => u.id)); + const acAssignments = allAssignments.filter(a => acIds.has(a.acId)); + + const forecasts = await dbGetByIndex("forecasts", "profileId", profileId); + const warnings = await dbGetByIndex("warnings", "profileId", profileId); + + // Toggles + const allToggles = await dbGetAll("toggles"); + const toggles = {}; + for (const t of allToggles) { + if (t.profileId === profileId && t.active) { + toggles[t.name] = true; + } + } + + return { + profile: { + id: profile.id, + name: profile.name, + latitude: profile.latitude, + longitude: profile.longitude, + timezone: profile.timezone || "Europe/Berlin", + }, + rooms: rooms.map(r => ({ + id: r.id, + profileId: r.profileId, + name: r.name, + areaSqm: r.areaSqm || 0, + ceilingHeightM: r.ceilingHeightM || 2.5, + floor: r.floor || 0, + orientation: r.orientation || "S", + shadingType: r.shadingType || "none", + shadingFactor: r.shadingFactor ?? 1.0, + ventilation: r.ventilation || "natural", + ventilationAch: r.ventilationAch || 0.5, + windowFraction: r.windowFraction || 0.15, + shgc: r.shgc || 0.6, + insulation: r.insulation || "average", + indoorTempC: r.indoorTempC || 0, + })), + devices: allDevices.map(d => ({ + id: d.id, + roomId: d.roomId, + name: d.name, + deviceType: d.deviceType || "electronics", + wattsIdle: d.wattsIdle || 0, + wattsTypical: d.wattsTypical || 0, + wattsPeak: d.wattsPeak || 0, + dutyCycle: d.dutyCycle ?? 1.0, + })), + occupants: allOccupants.map(o => ({ + id: o.id, + roomId: o.roomId, + count: o.count || 1, + activityLevel: o.activityLevel || "sedentary", + vulnerable: !!o.vulnerable, + })), + acUnits: acUnits.map(a => ({ + id: a.id, + profileId: a.profileId, + name: a.name, + acType: a.acType || "portable", + capacityBtu: a.capacityBtu || 0, + hasDehumidify: !!a.hasDehumidify, + efficiencyEer: a.efficiencyEer || 10, + })), + acAssignments: acAssignments.map(a => ({ + acId: a.acId, + roomId: a.roomId, + })), + toggles, + forecasts: forecasts.map(f => ({ + timestamp: f.timestamp, + temperatureC: f.temperatureC ?? null, + humidityPct: f.humidityPct ?? null, + cloudCoverPct: f.cloudCoverPct ?? null, + sunshineMin: f.sunshineMin ?? null, + apparentTempC: f.apparentTempC ?? null, + })), + warnings: warnings.map(w => ({ + headline: w.headline || "", + severity: w.severity || "", + description: w.description || "", + instruction: w.instruction || "", + onset: w.onset || "", + expires: w.expires || "", + })), + date: dateStr, + }; +} diff --git a/web/js/setup.js b/web/js/setup.js new file mode 100644 index 0000000..adf585e --- /dev/null +++ b/web/js/setup.js @@ -0,0 +1,685 @@ +// Setup page logic +(function() { + "use strict"; + + // Tab switching + const tabBtns = document.querySelectorAll(".tab-btn"); + const tabPanels = document.querySelectorAll(".tab-panel"); + + tabBtns.forEach(btn => { + btn.addEventListener("click", () => { + const tab = btn.dataset.tab; + tabBtns.forEach(b => { + b.classList.remove("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400"); + b.classList.add("border-transparent", "text-gray-500"); + }); + btn.classList.add("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400"); + btn.classList.remove("border-transparent", "text-gray-500"); + tabPanels.forEach(p => p.classList.add("hidden")); + document.getElementById("tab-" + tab).classList.remove("hidden"); + }); + }); + + // Hash-based tab navigation + if (location.hash) { + const tab = location.hash.slice(1); + const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`); + if (btn) btn.click(); + } + + // Toast + function showToast(msg, isError) { + const toast = document.getElementById("toast"); + toast.textContent = msg; + toast.className = "fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity"; + toast.classList.add(...(isError ? ["bg-red-600", "text-white"] : ["bg-green-600", "text-white"])); + toast.classList.remove("hidden"); + setTimeout(() => toast.classList.add("hidden"), 3000); + } + + // Tooltip handling + document.addEventListener("click", (e) => { + const trigger = e.target.closest(".tooltip-trigger"); + if (!trigger) { + document.querySelectorAll(".tooltip-popup").forEach(p => p.remove()); + return; + } + document.querySelectorAll(".tooltip-popup").forEach(p => p.remove()); + const tip = document.createElement("div"); + tip.className = "tooltip-popup absolute z-50 p-2 bg-gray-800 text-white text-xs rounded-lg shadow-lg max-w-xs"; + tip.textContent = trigger.dataset.tooltip; + trigger.parentElement.style.position = "relative"; + trigger.parentElement.appendChild(tip); + setTimeout(() => tip.remove(), 5000); + }); + + // Form helpers + function formData(form) { + const data = {}; + const fd = new FormData(form); + for (const [key, val] of fd.entries()) { + data[key] = val; + } + return data; + } + + function resetForm(form) { + form.reset(); + const hidden = form.querySelector('input[name="id"]'); + if (hidden) hidden.value = ""; + } + + function numOrDefault(val, def) { + const n = parseFloat(val); + return isNaN(n) ? def : n; + } + + // ========== Profiles ========== + async function loadProfiles() { + const profiles = await dbGetAll("profiles"); + const list = document.getElementById("profiles-list"); + if (profiles.length === 0) { + list.innerHTML = '

    No profiles yet.

    '; + return; + } + const activeId = await getActiveProfileId(); + list.innerHTML = profiles.map(p => { + const isActive = activeId === p.id; + return ` +
    +
    + ${esc(p.name)} + ${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")} +
    +
    + + + +
    +
    `; + }).join(""); + } + + window.activateProfile = async function(id) { + await setActiveProfileId(id); + await loadProfiles(); + await refreshRoomSelects(); + showToast("Profile activated", false); + }; + + window.editProfileUI = async function(id) { + const p = await dbGet("profiles", id); + if (!p) return; + const form = document.getElementById("profile-form"); + form.querySelector('input[name="id"]').value = p.id; + form.querySelector('input[name="name"]').value = p.name; + form.querySelector('input[name="latitude"]').value = p.latitude; + form.querySelector('input[name="longitude"]').value = p.longitude; + form.querySelector('input[name="timezone"]').value = p.timezone || "Europe/Berlin"; + form.querySelector('input[name="name"]').focus(); + }; + + window.deleteProfileUI = async function(id) { + if (!confirm("Delete this profile and all its data?")) return; + const activeId = await getActiveProfileId(); + await deleteProfile(id); + if (activeId === id) await setSetting("activeProfileId", null); + await loadProfiles(); + showToast("Profile deleted", false); + }; + + document.getElementById("profile-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const data = formData(e.target); + const profile = { + name: data.name, + latitude: numOrDefault(data.latitude, 0), + longitude: numOrDefault(data.longitude, 0), + timezone: data.timezone || "Europe/Berlin", + }; + if (data.id) { + profile.id = parseInt(data.id); + await dbPut("profiles", profile); + } else { + const id = await dbAdd("profiles", profile); + // Auto-activate if first profile + const profiles = await dbGetAll("profiles"); + if (profiles.length === 1) await setActiveProfileId(id); + } + resetForm(e.target); + await loadProfiles(); + showToast("Profile saved", false); + }); + + // Geolocation + document.getElementById("geolocate-btn").addEventListener("click", () => { + const btn = document.getElementById("geolocate-btn"); + btn.disabled = true; + btn.textContent = "⟳ Detecting…"; + navigator.geolocation.getCurrentPosition( + (pos) => { + document.querySelector('#profile-form input[name="latitude"]').value = pos.coords.latitude.toFixed(4); + document.querySelector('#profile-form input[name="longitude"]').value = pos.coords.longitude.toFixed(4); + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (tz) document.querySelector('#profile-form input[name="timezone"]').value = tz; + btn.disabled = false; + btn.textContent = "📍 Use my location"; + }, + (err) => { + const msgs = { 1: "Permission denied.", 2: "Location unavailable.", 3: "Timed out." }; + showToast(msgs[err.code] || "Location error.", true); + btn.disabled = false; + btn.textContent = "📍 Use my location"; + }, + { timeout: 10000 } + ); + }); + + // ========== Rooms ========== + async function loadRooms() { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + const list = document.getElementById("rooms-list"); + if (rooms.length === 0) { + list.innerHTML = '

    No rooms yet.

    '; + return; + } + list.innerHTML = rooms.map(r => ` +
    +
    + ${esc(r.name)} + ${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C +
    + + +
    + `).join(""); + } + + window.editRoomUI = async function(id) { + const r = await dbGet("rooms", id); + if (!r) return; + const form = document.getElementById("room-form"); + form.querySelector('input[name="id"]').value = r.id; + form.querySelector('input[name="name"]').value = r.name; + form.querySelector('input[name="areaSqm"]').value = r.areaSqm || ""; + form.querySelector('input[name="ceilingHeightM"]').value = r.ceilingHeightM || 2.5; + form.querySelector('input[name="floor"]').value = r.floor || 0; + form.querySelector('select[name="orientation"]').value = r.orientation || "S"; + form.querySelector('select[name="shadingType"]').value = r.shadingType || "none"; + form.querySelector('input[name="shadingFactor"]').value = r.shadingFactor ?? 1.0; + form.querySelector('input[name="ventilationAch"]').value = r.ventilationAch || 0.5; + form.querySelector('input[name="windowFraction"]').value = r.windowFraction || 0.15; + form.querySelector('input[name="shgc"]').value = r.shgc || 0.6; + form.querySelector('select[name="insulation"]').value = r.insulation || "average"; + form.querySelector('input[name="indoorTempC"]').value = r.indoorTempC || 25; + form.querySelector('input[name="name"]').focus(); + }; + + window.deleteRoomUI = async function(id) { + if (!confirm("Delete this room and its devices/occupants?")) return; + await deleteRoomData(id); + await loadRooms(); + await refreshRoomSelects(); + showToast("Room deleted", false); + }; + + document.getElementById("room-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const profileId = await getActiveProfileId(); + if (!profileId) { showToast("Select a profile first", true); return; } + const data = formData(e.target); + const room = { + profileId, + name: data.name, + areaSqm: numOrDefault(data.areaSqm, 15), + ceilingHeightM: numOrDefault(data.ceilingHeightM, 2.5), + floor: parseInt(data.floor) || 0, + orientation: data.orientation || "S", + shadingType: data.shadingType || "none", + shadingFactor: numOrDefault(data.shadingFactor, 1.0), + ventilation: data.ventilation || "natural", + ventilationAch: numOrDefault(data.ventilationAch, 0.5), + windowFraction: numOrDefault(data.windowFraction, 0.15), + shgc: numOrDefault(data.shgc, 0.6), + insulation: data.insulation || "average", + indoorTempC: numOrDefault(data.indoorTempC, 25), + }; + if (data.id) { + room.id = parseInt(data.id); + await dbPut("rooms", room); + } else { + await dbAdd("rooms", room); + } + resetForm(e.target); + await loadRooms(); + await refreshRoomSelects(); + showToast("Room saved", false); + }); + + // ========== Devices ========== + async function loadDevices() { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + const allDevices = []; + for (const room of rooms) { + const devices = await dbGetByIndex("devices", "roomId", room.id); + for (const d of devices) { d._roomName = room.name; allDevices.push(d); } + } + const list = document.getElementById("devices-list"); + if (allDevices.length === 0) { + list.innerHTML = '

    No devices yet.

    '; + return; + } + list.innerHTML = allDevices.map(d => ` +
    +
    + ${esc(d.name)} + ${esc(d._roomName)} · ${d.wattsTypical}W typical +
    + + +
    + `).join(""); + } + + window.editDeviceUI = async function(id) { + const d = await dbGet("devices", id); + if (!d) return; + const form = document.getElementById("device-form"); + form.querySelector('input[name="id"]').value = d.id; + form.querySelector('select[name="roomId"]').value = d.roomId; + form.querySelector('input[name="name"]').value = d.name; + form.querySelector('input[name="deviceType"]').value = d.deviceType || "electronics"; + form.querySelector('input[name="wattsIdle"]').value = d.wattsIdle || 0; + form.querySelector('input[name="wattsTypical"]').value = d.wattsTypical || 0; + form.querySelector('input[name="wattsPeak"]').value = d.wattsPeak || 0; + form.querySelector('input[name="dutyCycle"]').value = d.dutyCycle ?? 1.0; + form.querySelector('input[name="name"]').focus(); + }; + + window.deleteDeviceUI = async function(id) { + await dbDelete("devices", id); + await loadDevices(); + showToast("Device deleted", false); + }; + + document.getElementById("device-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const data = formData(e.target); + const device = { + roomId: parseInt(data.roomId), + name: data.name, + deviceType: data.deviceType || "electronics", + wattsIdle: numOrDefault(data.wattsIdle, 0), + wattsTypical: numOrDefault(data.wattsTypical, 0), + wattsPeak: numOrDefault(data.wattsPeak, 0), + dutyCycle: numOrDefault(data.dutyCycle, 1.0), + }; + if (data.id) { + device.id = parseInt(data.id); + await dbPut("devices", device); + } else { + await dbAdd("devices", device); + } + resetForm(e.target); + await loadDevices(); + showToast("Device saved", false); + }); + + // ========== Occupants ========== + async function loadOccupants() { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + const allOccupants = []; + for (const room of rooms) { + const occupants = await dbGetByIndex("occupants", "roomId", room.id); + for (const o of occupants) { o._roomName = room.name; allOccupants.push(o); } + } + const list = document.getElementById("occupants-list"); + if (allOccupants.length === 0) { + list.innerHTML = '

    No occupants yet.

    '; + return; + } + list.innerHTML = allOccupants.map(o => ` +
    +
    + ${o.count}x ${esc(o.activityLevel)} + ${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''} +
    + + +
    + `).join(""); + } + + window.editOccupantUI = async function(id) { + const o = await dbGet("occupants", id); + if (!o) return; + const form = document.getElementById("occupant-form"); + form.querySelector('input[name="id"]').value = o.id; + form.querySelector('select[name="roomId"]').value = o.roomId; + form.querySelector('input[name="count"]').value = o.count || 1; + form.querySelector('select[name="activityLevel"]').value = o.activityLevel || "sedentary"; + form.querySelector('input[name="vulnerable"]').checked = !!o.vulnerable; + form.querySelector('input[name="count"]').focus(); + }; + + window.deleteOccupantUI = async function(id) { + await dbDelete("occupants", id); + await loadOccupants(); + showToast("Occupant deleted", false); + }; + + document.getElementById("occupant-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const data = formData(e.target); + const occupant = { + roomId: parseInt(data.roomId), + count: parseInt(data.count) || 1, + activityLevel: data.activityLevel || "sedentary", + vulnerable: !!data.vulnerable, + }; + if (data.id) { + occupant.id = parseInt(data.id); + await dbPut("occupants", occupant); + } else { + await dbAdd("occupants", occupant); + } + resetForm(e.target); + await loadOccupants(); + showToast("Occupant saved", false); + }); + + // ========== AC Units ========== + async function loadACUnits() { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const units = await dbGetByIndex("ac_units", "profileId", profileId); + const list = document.getElementById("ac-list"); + if (units.length === 0) { + list.innerHTML = '

    No AC units yet.

    '; + return; + } + const assignments = await dbGetAll("ac_assignments"); + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + const roomMap = Object.fromEntries(rooms.map(r => [r.id, r.name])); + + list.innerHTML = units.map(u => { + const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId); + const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", "); + return ` +
    +
    + ${esc(u.name)} + ${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''} +
    + + +
    + `; + }).join(""); + } + + window.editACUI = async function(id) { + const u = await dbGet("ac_units", id); + if (!u) return; + const form = document.getElementById("ac-form"); + form.querySelector('input[name="id"]').value = u.id; + form.querySelector('input[name="name"]').value = u.name; + form.querySelector('select[name="acType"]').value = u.acType || "portable"; + form.querySelector('input[name="capacityBtu"]').value = u.capacityBtu || 0; + form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10; + form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify; + // Check assigned rooms + const assignments = await dbGetAll("ac_assignments"); + const assignedRoomIds = new Set(assignments.filter(a => a.acId === id).map(a => a.roomId)); + document.querySelectorAll('#ac-room-checkboxes input').forEach(cb => { + cb.checked = assignedRoomIds.has(parseInt(cb.value)); + }); + form.querySelector('input[name="name"]').focus(); + }; + + window.deleteACUI = async function(id) { + await dbDelete("ac_units", id); + const assignments = await dbGetAll("ac_assignments"); + for (const a of assignments) { + if (a.acId === id) await dbDelete("ac_assignments", [a.acId, a.roomId]); + } + await loadACUnits(); + showToast("AC unit deleted", false); + }; + + document.getElementById("ac-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const profileId = await getActiveProfileId(); + if (!profileId) { showToast("Select a profile first", true); return; } + const data = formData(e.target); + const unit = { + profileId, + name: data.name, + acType: data.acType || "portable", + capacityBtu: numOrDefault(data.capacityBtu, 0), + efficiencyEer: numOrDefault(data.efficiencyEer, 10), + hasDehumidify: !!data.hasDehumidify, + }; + let acId; + if (data.id) { + unit.id = parseInt(data.id); + await dbPut("ac_units", unit); + acId = unit.id; + } else { + acId = await dbAdd("ac_units", unit); + } + // Save room assignments + const oldAssignments = await dbGetAll("ac_assignments"); + for (const a of oldAssignments) { + if (a.acId === acId) await dbDelete("ac_assignments", [a.acId, a.roomId]); + } + const checkboxes = document.querySelectorAll('#ac-room-checkboxes input:checked'); + for (const cb of checkboxes) { + await dbPut("ac_assignments", { acId, roomId: parseInt(cb.value) }); + } + resetForm(e.target); + await loadACUnits(); + showToast("AC unit saved", false); + }); + + // ========== Toggles ========== + async function loadToggles() { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const toggles = await dbGetAll("toggles"); + document.querySelectorAll(".toggle-switch").forEach(el => { + const name = el.dataset.toggle; + const t = toggles.find(t => t.profileId === profileId && t.name === name); + el.checked = t ? t.active : false; + }); + } + + document.querySelectorAll(".toggle-switch").forEach(el => { + el.addEventListener("change", async () => { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const name = el.dataset.toggle; + const toggles = await dbGetAll("toggles"); + const existing = toggles.find(t => t.profileId === profileId && t.name === name); + if (existing) { + existing.active = el.checked; + await dbPut("toggles", existing); + } else { + await dbAdd("toggles", { profileId, name, active: el.checked }); + } + }); + }); + + // ========== Forecast ========== + document.getElementById("fetch-forecast-btn").addEventListener("click", async () => { + const profileId = await getActiveProfileId(); + if (!profileId) { showToast("Select a profile first", true); return; } + const profiles = await dbGetAll("profiles"); + const profile = profiles.find(p => p.id === profileId); + if (!profile) return; + + const btn = document.getElementById("fetch-forecast-btn"); + const spinner = document.getElementById("forecast-spinner"); + btn.disabled = true; + spinner.classList.remove("hidden"); + + try { + const resp = await fetch("/api/weather/forecast", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + lat: profile.latitude, + lon: profile.longitude, + timezone: profile.timezone || "Europe/Berlin", + }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + + // Clear old forecasts for this profile + await deleteByIndex("forecasts", "profileId", profileId); + + // Store hourly forecasts + for (const h of (data.Hourly || data.hourly || [])) { + await dbAdd("forecasts", { + profileId, + timestamp: h.Timestamp || h.timestamp, + temperatureC: h.TemperatureC ?? h.temperatureC ?? null, + humidityPct: h.HumidityPct ?? h.humidityPct ?? null, + cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null, + sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null, + apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null, + }); + } + + // Also fetch warnings + try { + const wResp = await fetch("/api/weather/warnings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }), + }); + if (wResp.ok) { + const wData = await wResp.json(); + await deleteByIndex("warnings", "profileId", profileId); + for (const w of (wData.warnings || [])) { + await dbAdd("warnings", { + profileId, + headline: w.Headline || w.headline || "", + severity: w.Severity || w.severity || "", + description: w.Description || w.description || "", + instruction: w.Instruction || w.instruction || "", + onset: w.Onset || w.onset || "", + expires: w.Expires || w.expires || "", + }); + } + } + } catch (e) { /* warnings are optional */ } + + await setSetting("lastFetched", new Date().toISOString()); + document.getElementById("last-fetched").textContent = new Date().toLocaleString(); + showToast("Forecast fetched", false); + } catch (err) { + showToast("Fetch failed: " + err.message, true); + } finally { + btn.disabled = false; + spinner.classList.add("hidden"); + } + }); + + async function loadForecastStatus() { + const lastFetched = await getSetting("lastFetched"); + if (lastFetched) { + document.getElementById("last-fetched").textContent = new Date(lastFetched).toLocaleString(); + } + } + + // ========== LLM Config ========== + async function loadLLMConfig() { + // Show server-side info + try { + const resp = await fetch("/api/llm/config"); + const data = await resp.json(); + const infoEl = document.getElementById("llm-server-info"); + if (data.available) { + infoEl.textContent = `Server: ${data.provider} (${data.model || "default"})`; + infoEl.classList.remove("hidden"); + } + } catch (e) { /* ignore */ } + + // Load saved client-side LLM settings + const provider = await getSetting("llmProvider"); + const apiKey = await getSetting("llmApiKey"); + const model = await getSetting("llmModel"); + + if (provider) document.getElementById("llm-provider-select").value = provider; + if (apiKey) document.getElementById("llm-api-key").value = apiKey; + if (model) document.getElementById("llm-model").value = model; + } + + document.getElementById("llm-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const provider = document.getElementById("llm-provider-select").value; + const apiKey = document.getElementById("llm-api-key").value; + const model = document.getElementById("llm-model").value; + + await setSetting("llmProvider", provider); + await setSetting("llmApiKey", apiKey); + await setSetting("llmModel", model); + + showToast("LLM settings saved", false); + }); + + // ========== Room selects for device/occupant/AC forms ========== + async function refreshRoomSelects() { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + const options = rooms.map(r => ``).join(""); + + ["device-room-select", "occupant-room-select"].forEach(id => { + const el = document.getElementById(id); + if (el) el.innerHTML = options; + }); + + // AC room checkboxes + const acCheckboxes = document.getElementById("ac-room-checkboxes"); + if (acCheckboxes) { + acCheckboxes.innerHTML = rooms.map(r => ` + + `).join(""); + } + } + + // ========== Utility ========== + function esc(s) { + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; + } + + // ========== Init ========== + async function init() { + await loadProfiles(); + await loadRooms(); + await loadDevices(); + await loadOccupants(); + await loadACUnits(); + await loadToggles(); + await loadForecastStatus(); + await loadLLMConfig(); + await refreshRoomSelects(); + } + + init(); +})(); diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..0993449 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,109 @@ +{{define "content"}} +
    + + + + + + + + + + + + + + +
    +{{end}} + +{{define "scripts"}} + + +{{end}} diff --git a/web/templates/guide.html b/web/templates/guide.html new file mode 100644 index 0000000..93736c9 --- /dev/null +++ b/web/templates/guide.html @@ -0,0 +1,87 @@ +{{define "content"}} +
    +

    {{t "guide.title"}}

    +

    {{t "guide.intro"}}

    + +
    +
    +

    {{t "guide.step1.title"}}

    +

    {{t "guide.step1.text"}}

    +
    + +
    +

    {{t "guide.step2.title"}}

    +

    {{t "guide.step2.text"}}

    +
    + +
    +

    {{t "guide.step3.title"}}

    +

    {{t "guide.step3.text"}}

    +
    + +
    +

    {{t "guide.step4.title"}}

    +

    {{t "guide.step4.text"}}

    +
    + +
    +

    {{t "guide.step5.title"}}

    +

    {{t "guide.step5.text"}}

    +
    + +
    +

    {{t "guide.step6.title"}}

    +

    {{t "guide.step6.text"}}

    +
    +
    + +
    +

    {{t "guide.params.title"}}

    +
    +

    {{t "guide.params.shgc"}}

    +

    {{t "guide.params.ventilation"}}

    +

    {{t "guide.params.shading"}}

    +

    {{t "guide.params.orientation"}}

    +
    +
    + +
    +

    {{t "guide.risk.title"}}

    +
    +
    + +

    {{t "guide.risk.low"}}

    +
    +
    + +

    {{t "guide.risk.moderate"}}

    +
    +
    + +

    {{t "guide.risk.high"}}

    +
    +
    + +

    {{t "guide.risk.extreme"}}

    +
    +
    +
    + +
    +

    {{t "guide.budget.title"}}

    +
    +

    {{t "guide.budget.text"}}

    +
    +
    + +
    +

    {{t "guide.tips.title"}}

    +
    +

    {{t "guide.tips.tip1"}}

    +

    {{t "guide.tips.tip2"}}

    +

    {{t "guide.tips.tip3"}}

    +

    {{t "guide.tips.tip4"}}

    +
    +
    +
    +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..34a9cc4 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,48 @@ + + + + + + {{t "app.name"}} — {{.Title}} + + + + + + + +
    + {{block "content" .}}{{end}} +
    + +
    +
    +
    {{t "app.name"}} v1.0.0 — {{t "app.tagline"}}
    +
    {{t "footer.source"}} · {{t "footer.license"}}
    +
    +
    + + {{block "scripts" .}}{{end}} + + diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..d9c96f9 --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,326 @@ +{{define "content"}} +
    +

    {{t "setup.title"}}

    + + +
    + + + + + + + + +
    + + + + + +
    +

    {{t "setup.profiles.help"}}

    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    +

    {{t "setup.profiles.noItems"}}

    +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    +{{end}} + +{{define "scripts"}} + + +{{end}}