feat: rewrite to stateless web app with IndexedDB frontend
Replace CLI + SQLite architecture with a Go web server + vanilla JS frontend using IndexedDB for all client-side data storage. - Remove: cli, store, report, static packages - Add: compute engine (BuildDashboard), server package, web UI - Add: setup page with CRUD for profiles, rooms, devices, occupants, AC - Add: dashboard with SVG temperature timeline, risk analysis, care checklist - Add: i18n support (English/German) with server-side Go templates - Add: LLM provider selection UI with client-side API key storage - Add: per-room indoor temperature, edit buttons, language-aware AI summary
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
bin/
|
||||
node_modules/
|
||||
web/css/app.css
|
||||
|
||||
14
Makefile
14
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
|
||||
|
||||
39
cmd/heatguard/main.go
Normal file
39
cmd/heatguard/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
21
go.mod
21
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
|
||||
|
||||
62
go.sum
62
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=
|
||||
|
||||
@@ -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 <name>",
|
||||
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 <id> <field> <value>",
|
||||
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 <id>",
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 <name>",
|
||||
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 <id> <field> <value>",
|
||||
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 <id>",
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <id> <field> <value>",
|
||||
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 <id>",
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <name>",
|
||||
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 <field> <value>",
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 <name>",
|
||||
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 <id> <field> <value>",
|
||||
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 <id>",
|
||||
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)
|
||||
}
|
||||
@@ -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 <name> --lat <lat> --lon <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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Heatwave Setup</title>
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
|
||||
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
|
||||
<span class="font-bold text-lg">Heatwave</span>
|
||||
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Dashboard</a>
|
||||
<a href="/setup" class="font-medium text-blue-600 dark:text-blue-400 underline">Setup</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mx-auto py-4 px-4 max-w-4xl">
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="mb-4 p-3 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
|
||||
{{.Flash}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h1 class="text-3xl font-bold mb-2">Setup</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Active profile: <strong>{{if .Profile}}{{.Profile.Name}}{{else}}(none){{end}}</strong>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-8 text-sm">
|
||||
<a href="#profiles" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Profiles</a>
|
||||
<a href="#rooms" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Rooms</a>
|
||||
<a href="#devices" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Devices</a>
|
||||
<a href="#occupants" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Occupants</a>
|
||||
<a href="#ac" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">AC Units</a>
|
||||
<a href="#toggles" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Toggles</a>
|
||||
<a href="#forecast" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Forecast</a>
|
||||
<a href="#llm" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">LLM</a>
|
||||
</div>
|
||||
|
||||
{{template "profiles" .}}
|
||||
{{template "rooms" .}}
|
||||
{{template "devices" .}}
|
||||
{{template "occupants" .}}
|
||||
{{template "ac_units" .}}
|
||||
{{template "toggles" .}}
|
||||
{{template "forecast" .}}
|
||||
{{template "llm" .}}
|
||||
|
||||
<footer class="mt-8 text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
||||
<p>Heatwave Autopilot</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{define "profiles"}}
|
||||
<section id="profiles" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Profiles</h2>
|
||||
{{if .Profiles}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Name</th>
|
||||
<th>Latitude</th>
|
||||
<th>Longitude</th>
|
||||
<th>Timezone</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Profiles}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td class="font-medium">{{.Name}}</td>
|
||||
<td>{{printf "%.4f" .Latitude}}</td>
|
||||
<td>{{printf "%.4f" .Longitude}}</td>
|
||||
<td>{{.Timezone}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/profiles/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete profile?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No profiles yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/profiles/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Profile</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Latitude</label>
|
||||
<input type="number" step="any" name="latitude" value="52.52" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Longitude</label>
|
||||
<input type="number" step="any" name="longitude" value="13.405" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Timezone</label>
|
||||
<input type="text" name="timezone" value="Europe/Berlin" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Profile</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "rooms"}}
|
||||
<section id="rooms" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Rooms</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .Rooms}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Name</th>
|
||||
<th>Area (m²)</th>
|
||||
<th>Floor</th>
|
||||
<th>Orientation</th>
|
||||
<th>Ceiling (m)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rooms}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td class="font-medium">{{.Name}}</td>
|
||||
<td>{{printf "%.1f" .AreaSqm}}</td>
|
||||
<td>{{.Floor}}</td>
|
||||
<td>{{.Orientation}}</td>
|
||||
<td>{{printf "%.2f" .CeilingHeightM}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/rooms/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete room?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No rooms yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/rooms/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Room</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Area (m²)</label>
|
||||
<input type="number" step="any" name="area_sqm" value="20" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Floor</label>
|
||||
<input type="number" name="floor" value="0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Orientation</label>
|
||||
<select name="orientation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="south">South</option>
|
||||
<option value="west">West</option>
|
||||
<option value="east">East</option>
|
||||
<option value="north">North</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ceiling Height (m)</label>
|
||||
<input type="number" step="any" name="ceiling_height_m" value="2.50" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Window Fraction</label>
|
||||
<input type="number" step="any" name="window_fraction" value="0.30" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">SHGC</label>
|
||||
<input type="number" step="any" name="shgc" value="0.60" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Shading Type</label>
|
||||
<select name="shading_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="none">None</option>
|
||||
<option value="blinds">Blinds</option>
|
||||
<option value="shutters">Shutters</option>
|
||||
<option value="awning">Awning</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Shading Factor</label>
|
||||
<input type="number" step="any" name="shading_factor" value="1.0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ventilation</label>
|
||||
<select name="ventilation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="natural">Natural</option>
|
||||
<option value="mechanical">Mechanical</option>
|
||||
<option value="sealed">Sealed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ventilation ACH</label>
|
||||
<input type="number" step="any" name="ventilation_ach" value="1.5" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Insulation</label>
|
||||
<select name="insulation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="poor">Poor</option>
|
||||
<option value="good">Good</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Room</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "devices"}}
|
||||
<section id="devices" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Devices</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .Devices}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Room</th>
|
||||
<th>Idle (W)</th>
|
||||
<th>Typical (W)</th>
|
||||
<th>Peak (W)</th>
|
||||
<th>Duty</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td class="font-medium">{{.Name}}</td>
|
||||
<td>{{.DeviceType}}</td>
|
||||
<td>{{.RoomID}}</td>
|
||||
<td>{{printf "%.0f" .WattsIdle}}</td>
|
||||
<td>{{printf "%.0f" .WattsTypical}}</td>
|
||||
<td>{{printf "%.0f" .WattsPeak}}</td>
|
||||
<td>{{printf "%.0f%%" (mul .DutyCycle 100)}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/devices/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete device?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No devices yet.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Rooms}}
|
||||
<form method="POST" action="/setup/devices/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Device</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Room</label>
|
||||
<select name="room_id" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
{{range .Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Type</label>
|
||||
<select name="device_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="computer">Computer</option>
|
||||
<option value="monitor">Monitor</option>
|
||||
<option value="tv">TV</option>
|
||||
<option value="console">Console</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="appliance">Appliance</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Idle</label>
|
||||
<input type="number" step="any" name="watts_idle" value="10" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Typical</label>
|
||||
<input type="number" step="any" name="watts_typical" value="80" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Peak</label>
|
||||
<input type="number" step="any" name="watts_peak" value="200" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Duty Cycle (0–1)</label>
|
||||
<input type="number" step="any" name="duty_cycle" value="0.5" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Device</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Add a room first to create devices.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "occupants"}}
|
||||
<section id="occupants" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Occupants</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .Occupants}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Room</th>
|
||||
<th>Count</th>
|
||||
<th>Activity</th>
|
||||
<th>Vulnerable</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Occupants}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td>{{.RoomID}}</td>
|
||||
<td>{{.Count}}</td>
|
||||
<td>{{.ActivityLevel}}</td>
|
||||
<td>{{if .Vulnerable}}Yes{{else}}No{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/occupants/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete occupant?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No occupants yet.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Rooms}}
|
||||
<form method="POST" action="/setup/occupants/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Occupant</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Room</label>
|
||||
<select name="room_id" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
{{range .Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Count</label>
|
||||
<input type="number" name="count" value="1" min="1" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Activity Level</label>
|
||||
<select name="activity_level" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="sedentary">Sedentary</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="active">Active</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" name="vulnerable" value="true" class="rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Vulnerable</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Occupant</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Add a room first to create occupants.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "ac_units"}}
|
||||
<section id="ac" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">AC Units</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .ACUnits}}
|
||||
<div class="space-y-3 mb-4">
|
||||
{{range .ACUnits}}
|
||||
{{$acID := .ID}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span class="font-medium">{{.Name}}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">{{.ACType}} — {{printf "%.0f" .CapacityBTU}} BTU/h — EER {{printf "%.1f" .EfficiencyEER}}</span>
|
||||
{{if .HasDehumidify}}<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded ml-1">Dehumidify</span>{{end}}
|
||||
</div>
|
||||
<form method="POST" action="/setup/ac/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete AC unit?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if $.Rooms}}
|
||||
<form method="POST" action="/setup/ac/{{.ID}}/assign" class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Assign to:</span>
|
||||
<select name="room_id" class="px-2 py-1 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-sm">
|
||||
{{range $.Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" class="px-2 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700">Assign</button>
|
||||
</form>
|
||||
{{if .AssignedRoomIDs}}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{{range .AssignedRoomIDs}}
|
||||
<form method="POST" action="/setup/ac/{{$acID}}/unassign" class="inline">
|
||||
<input type="hidden" name="room_id" value="{{.RoomID}}">
|
||||
<button type="submit" class="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{{.RoomName}} <span class="text-red-500">×</span>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No AC units yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/ac/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add AC Unit</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Type</label>
|
||||
<select name="ac_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="portable">Portable</option>
|
||||
<option value="split">Split</option>
|
||||
<option value="window">Window</option>
|
||||
<option value="central">Central</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Capacity (BTU/h)</label>
|
||||
<input type="number" step="any" name="capacity_btu" value="12000" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Efficiency (EER)</label>
|
||||
<input type="number" step="any" name="efficiency_eer" value="10.0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" name="has_dehumidify" value="true" class="rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Has Dehumidify</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add AC Unit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "toggles"}}
|
||||
<section id="toggles" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Scenario Toggles</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{{range $name, $active := .Toggles}}
|
||||
<form method="POST" action="/setup/toggles/set" class="flex items-center justify-between p-2 rounded border dark:border-gray-600">
|
||||
<input type="hidden" name="name" value="{{$name}}">
|
||||
<input type="hidden" name="active" value="{{if $active}}false{{else}}true{{end}}">
|
||||
<span class="font-medium">{{$name}}</span>
|
||||
<button type="submit" class="px-3 py-1 rounded text-sm text-white {{if $active}}bg-green-600 hover:bg-red-600{{else}}bg-gray-400 hover:bg-green-600{{end}}">
|
||||
{{if $active}}ON{{else}}OFF{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/setup/toggles/set" class="flex items-center gap-2 p-2 rounded border dark:border-gray-600">
|
||||
<input type="text" name="name" placeholder="new toggle" required class="flex-1 px-2 py-1 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-sm">
|
||||
<input type="hidden" name="active" value="true">
|
||||
<button type="submit" class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "forecast"}}
|
||||
<section id="forecast" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Forecast</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Last fetched: <strong>{{if .LastFetch}}{{.LastFetch}}{{else}}never{{end}}</strong>
|
||||
</p>
|
||||
<form method="POST" action="/setup/forecast/fetch">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Fetch Forecast Now</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "llm"}}
|
||||
<section id="llm" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">LLM Provider</h2>
|
||||
<form method="POST" action="/setup/llm/save" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Provider</label>
|
||||
<select name="provider" id="llm-provider" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700"
|
||||
onchange="document.getElementById('llm-cloud').style.display=(this.value==='anthropic'||this.value==='openai'||this.value==='gemini')?'':'none';document.getElementById('llm-local').style.display=(this.value==='ollama')?'':'none';">
|
||||
<option value="none" {{if eq .LLMProvider "none"}}selected{{end}}>None</option>
|
||||
<option value="anthropic" {{if eq .LLMProvider "anthropic"}}selected{{end}}>Anthropic</option>
|
||||
<option value="openai" {{if eq .LLMProvider "openai"}}selected{{end}}>OpenAI</option>
|
||||
<option value="gemini" {{if eq .LLMProvider "gemini"}}selected{{end}}>Gemini</option>
|
||||
<option value="ollama" {{if eq .LLMProvider "ollama"}}selected{{end}}>Ollama (local)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="llm-cloud" style="{{if or (eq .LLMProvider "anthropic") (eq .LLMProvider "openai") (eq .LLMProvider "gemini")}}{{else}}display:none{{end}}" class="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Model</label>
|
||||
<input type="text" name="cloud_model" value="{{.LLMModel}}" placeholder="e.g. claude-sonnet-4-5-20250929" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">API Key</label>
|
||||
<input type="password" name="api_key" placeholder="Leave blank to keep current key" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="llm-local" style="{{if eq .LLMProvider "ollama"}}{{else}}display:none{{end}}" class="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Endpoint URL</label>
|
||||
<input type="text" name="endpoint" value="{{.LLMEndpoint}}" placeholder="http://localhost:11434" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Model</label>
|
||||
<input type="text" name="local_model" value="{{.LLMModel}}" placeholder="e.g. llama3" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save LLM Settings</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,34 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
toggleCmd := &cobra.Command{
|
||||
Use: "toggle <scenario>",
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, `<!DOCTYPE html><html><head><style>%s</style></head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
|
||||
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
|
||||
<span class="font-bold text-lg">Heatwave</span>
|
||||
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
|
||||
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container mx-auto py-4 px-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-950 border-l-4 border-yellow-400 rounded">
|
||||
<p class="font-medium">Cannot load dashboard</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">%s</p>
|
||||
<p class="mt-3"><a href="/setup" class="text-blue-600 dark:text-blue-400 underline">Go to Setup</a> to configure your profile and fetch forecast data.</p>
|
||||
</div>
|
||||
</div></body></html>`, 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)
|
||||
}
|
||||
320
internal/compute/compute.go
Normal file
320
internal/compute/compute.go
Normal file
@@ -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)
|
||||
}
|
||||
263
internal/compute/compute_test.go
Normal file
263
internal/compute/compute_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
171
internal/compute/types.go
Normal file
171
internal/compute/types.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,7 @@ type SummaryInput struct {
|
||||
BudgetStatus string
|
||||
ActiveWarnings []string
|
||||
RiskWindows []RiskWindowSummary
|
||||
Language string
|
||||
}
|
||||
|
||||
// ActionInput holds data for rewriting a technical action.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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, "<!DOCTYPE html>") {
|
||||
t.Error("missing DOCTYPE")
|
||||
}
|
||||
if !strings.Contains(html, "</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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Heatwave Report — {{.ProfileName}} — {{.Date}}</title>
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
{{if .ShowNav}}
|
||||
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
|
||||
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
|
||||
<span class="font-bold text-lg">Heatwave</span>
|
||||
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
|
||||
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
<div class="container mx-auto py-4">
|
||||
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Heatwave Report</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{.ProfileName}} — {{.Date}}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">Generated {{.GeneratedAt.Format "2006-01-02 15:04"}}</p>
|
||||
</header>
|
||||
|
||||
{{template "warnings" .}}
|
||||
{{template "risk_summary" .}}
|
||||
{{template "timeline" .}}
|
||||
{{template "heatbudget" .}}
|
||||
{{template "checklist" .}}
|
||||
|
||||
{{if .LLMSummary}}
|
||||
<section class="mt-8 p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-2">AI Summary</h2>
|
||||
{{if .LLMDisclaimer}}<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">{{.LLMDisclaimer}}</p>{{end}}
|
||||
<div>{{.LLMSummary}}</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<footer class="mt-8 text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
||||
<p>Heatwave Autopilot — This report is for planning purposes only. It does not constitute medical advice.</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{define "warnings"}}
|
||||
{{if .Warnings}}
|
||||
<section class="mb-6">
|
||||
{{range .Warnings}}
|
||||
<div class="p-4 mb-2 border-l-4 {{if eq .Severity "Extreme"}}border-red-400 bg-red-50 dark:bg-red-950{{else}}border-orange-400 bg-orange-50 dark:bg-orange-950{{end}} rounded">
|
||||
<p class="font-bold {{if eq .Severity "Extreme"}}text-red-700 dark:text-red-400{{else}}text-orange-600 dark:text-orange-400{{end}}">{{.Headline}}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{.Description}}</p>
|
||||
{{if .Instruction}}<p class="text-sm font-medium mt-1">{{.Instruction}}</p>{{end}}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{.Onset}} — {{.Expires}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "risk_summary"}}
|
||||
<section class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="p-4 rounded-lg shadow dark:shadow-gray-700 {{riskBg .RiskLevel}}">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Risk Level</p>
|
||||
<p class="text-2xl font-bold">{{riskBadge .RiskLevel}}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Peak Temperature</p>
|
||||
<p class="text-2xl font-bold {{tempColor .PeakTempC}}">{{formatTemp .PeakTempC}}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Min Night Temp</p>
|
||||
<p class="text-2xl font-bold">{{formatTemp .MinNightTempC}}</p>
|
||||
{{if .PoorNightCool}}<p class="text-xs text-red-600 dark:text-red-400 font-medium">Poor night cooling</p>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .RiskWindows}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Risk Windows</h2>
|
||||
<div class="space-y-2">
|
||||
{{range .RiskWindows}}
|
||||
<div class="p-3 rounded {{riskBg .Level}} flex justify-between items-center">
|
||||
<span class="font-medium">{{printf "%02d:00" .StartHour}} — {{printf "%02d:00" .EndHour}}</span>
|
||||
<span>Peak {{formatTemp .PeakTempC}}</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs font-semibold text-white {{riskBadgeBg .Level}}">{{.Level}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "timeline"}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Hour-by-Hour Timeline</h2>
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Hour</th>
|
||||
<th>Temp</th>
|
||||
<th>Risk</th>
|
||||
<th>Budget</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Timeline}}
|
||||
<tr class="{{riskBg .RiskLevel}}">
|
||||
<td class="font-medium">{{.HourStr}}</td>
|
||||
<td class="{{tempColor .TempC}}">{{formatTemp .TempC}}</td>
|
||||
<td>{{riskBadge .RiskLevel}}</td>
|
||||
<td>{{statusBadge .BudgetStatus}}</td>
|
||||
<td>
|
||||
{{range .Actions}}
|
||||
<span class="inline-block px-2 py-1 text-xs rounded bg-blue-100 dark:bg-blue-900 dark:text-blue-200 mb-1">{{.Name}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "heatbudget"}}
|
||||
{{if .RoomBudgets}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Room Heat Budgets</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{{range .RoomBudgets}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-semibold text-lg mb-2">{{.RoomName}}</h3>
|
||||
<table class="text-sm">
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">Internal gains</td><td class="font-medium">{{formatWatts .InternalGainsW}}</td></tr>
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">Solar gain</td><td class="font-medium">{{formatWatts .SolarGainW}}</td></tr>
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">Ventilation</td><td class="font-medium">{{formatWatts .VentGainW}}</td></tr>
|
||||
<tr class="font-bold"><td>Total heat load</td><td>{{formatWatts .TotalGainW}} ({{formatBTU .TotalGainBTUH}})</td></tr>
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">AC capacity</td><td>{{formatBTU .ACCapacityBTUH}}</td></tr>
|
||||
<tr class="font-bold"><td>Headroom</td><td class="{{statusColor .Status}}">{{formatBTU .HeadroomBTUH}}</td></tr>
|
||||
</table>
|
||||
<p class="mt-2 px-3 py-1 rounded-full inline-block text-xs font-semibold text-white {{statusBadgeBg .Status}}">{{.Status}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "checklist"}}
|
||||
{{if .CareChecklist}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Care Checklist</h2>
|
||||
<ul class="list-disc list-inside space-y-2 p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
{{range .CareChecklist}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
168
internal/server/api.go
Normal file
168
internal/server/api.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/compute"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/weather"
|
||||
)
|
||||
|
||||
func (s *Server) handleComputeDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req compute.ComputeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := compute.BuildDashboard(req)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, data)
|
||||
}
|
||||
|
||||
type forecastRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req forecastRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider := weather.NewOpenMeteo(nil)
|
||||
resp, err := provider.FetchForecast(ctx, req.Lat, req.Lon, req.Timezone)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, resp)
|
||||
}
|
||||
|
||||
type warningsRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWeatherWarnings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req warningsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider := weather.NewDWDWFS(nil)
|
||||
warnings, err := provider.FetchWarnings(ctx, req.Lat, req.Lon)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]any{"warnings": warnings})
|
||||
}
|
||||
|
||||
type summarizeRequest struct {
|
||||
llm.SummaryInput
|
||||
Provider string `json:"provider,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLLMSummarize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req summarizeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use client-provided credentials if present, otherwise fall back to server config
|
||||
provider := s.llmProvider
|
||||
if req.Provider != "" && req.APIKey != "" {
|
||||
switch req.Provider {
|
||||
case "anthropic":
|
||||
provider = llm.NewAnthropic(req.APIKey, req.Model, nil)
|
||||
case "openai":
|
||||
provider = llm.NewOpenAI(req.APIKey, req.Model, nil)
|
||||
case "gemini":
|
||||
provider = llm.NewGemini(req.APIKey, req.Model, nil)
|
||||
}
|
||||
}
|
||||
|
||||
if provider.Name() == "none" {
|
||||
jsonResponse(w, map[string]string{"summary": ""})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
summary, err := provider.Summarize(ctx, req.SummaryInput)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]string{"summary": summary})
|
||||
}
|
||||
|
||||
func (s *Server) handleLLMConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
available := s.llmProvider.Name() != "none"
|
||||
jsonResponse(w, map[string]any{
|
||||
"provider": s.cfg.LLM.Provider,
|
||||
"model": s.cfg.LLM.Model,
|
||||
"available": available,
|
||||
})
|
||||
}
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, msg string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
6
internal/server/embed.go
Normal file
6
internal/server/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package server
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// WebFS is set by the main package to provide the embedded web/ filesystem.
|
||||
var WebFS fs.FS
|
||||
100
internal/server/i18n.go
Normal file
100
internal/server/i18n.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// translations holds the loaded translation maps keyed by language code.
|
||||
type translations struct {
|
||||
langs map[string]map[string]any // e.g. {"en": {...}, "de": {...}}
|
||||
}
|
||||
|
||||
func loadTranslations(enJSON, deJSON []byte) (*translations, error) {
|
||||
t := &translations{langs: make(map[string]map[string]any)}
|
||||
|
||||
var en map[string]any
|
||||
if err := json.Unmarshal(enJSON, &en); err != nil {
|
||||
return nil, fmt.Errorf("parse en.json: %w", err)
|
||||
}
|
||||
t.langs["en"] = en
|
||||
|
||||
var de map[string]any
|
||||
if err := json.Unmarshal(deJSON, &de); err != nil {
|
||||
return nil, fmt.Errorf("parse de.json: %w", err)
|
||||
}
|
||||
t.langs["de"] = de
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// get resolves a dotted key (e.g. "setup.rooms.title") from the translation map.
|
||||
func (t *translations) get(lang, key string) string {
|
||||
m, ok := t.langs[lang]
|
||||
if !ok {
|
||||
m = t.langs["en"]
|
||||
}
|
||||
if m == nil {
|
||||
return key
|
||||
}
|
||||
return resolve(m, key)
|
||||
}
|
||||
|
||||
func resolve(m map[string]any, key string) string {
|
||||
parts := strings.Split(key, ".")
|
||||
var current any = m
|
||||
for _, p := range parts {
|
||||
cm, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
current, ok = cm[p]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
}
|
||||
s, ok := current.(string)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// supportedLangs are the available languages.
|
||||
var supportedLangs = []string{"en", "de"}
|
||||
|
||||
// detectLanguage determines the active language from query param, cookie, or Accept-Language header.
|
||||
func detectLanguage(r *http.Request) string {
|
||||
// 1. Query parameter
|
||||
if lang := r.URL.Query().Get("lang"); isSupported(lang) {
|
||||
return lang
|
||||
}
|
||||
|
||||
// 2. Cookie
|
||||
if c, err := r.Cookie("heatguard_lang"); err == nil && isSupported(c.Value) {
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// 3. Accept-Language header
|
||||
accept := r.Header.Get("Accept-Language")
|
||||
for _, part := range strings.Split(accept, ",") {
|
||||
lang := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
lang = strings.SplitN(lang, "-", 2)[0]
|
||||
if isSupported(lang) {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
func isSupported(lang string) bool {
|
||||
for _, l := range supportedLangs {
|
||||
if l == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
205
internal/server/server.go
Normal file
205
internal/server/server.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/config"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
)
|
||||
|
||||
// Server holds the HTTP server state.
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
trans *translations
|
||||
cfg config.Config
|
||||
llmProvider llm.Provider
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// Options configures the server.
|
||||
type Options struct {
|
||||
Port int
|
||||
DevMode bool
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
// New creates a new Server and sets up routes.
|
||||
func New(opts Options) (*Server, error) {
|
||||
s := &Server{
|
||||
mux: http.NewServeMux(),
|
||||
cfg: opts.Config,
|
||||
devMode: opts.DevMode,
|
||||
}
|
||||
|
||||
// Load translations
|
||||
var enJSON, deJSON []byte
|
||||
var err error
|
||||
if opts.DevMode {
|
||||
enJSON, err = os.ReadFile(filepath.Join("web", "i18n", "en.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read en.json: %w", err)
|
||||
}
|
||||
deJSON, err = os.ReadFile(filepath.Join("web", "i18n", "de.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read de.json: %w", err)
|
||||
}
|
||||
} else {
|
||||
if WebFS == nil {
|
||||
return nil, fmt.Errorf("WebFS not set — call server.WebFS = ... before server.New()")
|
||||
}
|
||||
enJSON, err = fs.ReadFile(WebFS, "i18n/en.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded en.json: %w", err)
|
||||
}
|
||||
deJSON, err = fs.ReadFile(WebFS, "i18n/de.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded de.json: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.trans, err = loadTranslations(enJSON, deJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load translations: %w", err)
|
||||
}
|
||||
|
||||
// Set up LLM provider
|
||||
s.llmProvider = buildLLMProvider(s.cfg)
|
||||
|
||||
// Static assets
|
||||
if opts.DevMode {
|
||||
s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("web"))))
|
||||
} else {
|
||||
s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(WebFS))))
|
||||
}
|
||||
|
||||
// Page routes
|
||||
s.mux.HandleFunc("/", s.handleDashboard)
|
||||
s.mux.HandleFunc("/setup", s.handleSetup)
|
||||
s.mux.HandleFunc("/guide", s.handleGuide)
|
||||
|
||||
// API routes
|
||||
s.mux.HandleFunc("/api/compute/dashboard", s.handleComputeDashboard)
|
||||
s.mux.HandleFunc("/api/weather/forecast", s.handleWeatherForecast)
|
||||
s.mux.HandleFunc("/api/weather/warnings", s.handleWeatherWarnings)
|
||||
s.mux.HandleFunc("/api/llm/summarize", s.handleLLMSummarize)
|
||||
s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
// ListenAndServe starts the server.
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
return http.ListenAndServe(addr, s.mux)
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Lang string
|
||||
Page string
|
||||
Title string
|
||||
}
|
||||
|
||||
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page, templateFile string) {
|
||||
lang := detectLanguage(r)
|
||||
|
||||
// Set language cookie if query param was provided
|
||||
if qLang := r.URL.Query().Get("lang"); isSupported(qLang) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "heatguard_lang",
|
||||
Value: qLang,
|
||||
Path: "/",
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"t": func(key string) string {
|
||||
return s.trans.get(lang, key)
|
||||
},
|
||||
}
|
||||
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
|
||||
if s.devMode {
|
||||
tmpl, err = template.New("layout.html").Funcs(funcMap).ParseFiles(
|
||||
filepath.Join("web", "templates", "layout.html"),
|
||||
filepath.Join("web", "templates", templateFile),
|
||||
)
|
||||
} else {
|
||||
tmpl, err = template.New("layout.html").Funcs(funcMap).ParseFS(WebFS,
|
||||
"templates/layout.html",
|
||||
"templates/"+templateFile,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("template error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
title := s.trans.get(lang, "nav."+page)
|
||||
data := pageData{
|
||||
Lang: lang,
|
||||
Page: page,
|
||||
Title: title,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "dashboard", "dashboard.html")
|
||||
}
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderPage(w, r, "setup", "setup.html")
|
||||
}
|
||||
|
||||
func (s *Server) handleGuide(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderPage(w, r, "guide", "guide.html")
|
||||
}
|
||||
|
||||
func buildLLMProvider(cfg config.Config) llm.Provider {
|
||||
switch cfg.LLM.Provider {
|
||||
case "anthropic":
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewAnthropic(key, cfg.LLM.Model, nil)
|
||||
case "openai":
|
||||
key := os.Getenv("OPENAI_API_KEY")
|
||||
if key == "" {
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewOpenAI(key, cfg.LLM.Model, nil)
|
||||
case "gemini":
|
||||
key := os.Getenv("GEMINI_API_KEY")
|
||||
if key == "" {
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewGemini(key, cfg.LLM.Model, nil)
|
||||
case "ollama":
|
||||
return llm.NewOllama(cfg.LLM.Model, cfg.LLM.Endpoint, nil)
|
||||
default:
|
||||
return llm.NewNoop()
|
||||
}
|
||||
}
|
||||
167
internal/server/server_test.go
Normal file
167
internal/server/server_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/compute"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/config"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Tests run from the package directory. Set WebFS to point to web/ dir.
|
||||
WebFS = os.DirFS("../../web")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func testServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s, err := New(Options{
|
||||
Port: 0,
|
||||
DevMode: false,
|
||||
Config: config.DefaultConfig(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDashboardPage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("got content-type %q, want text/html", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupPage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/setup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuidePage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/guide", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageDetection(t *testing.T) {
|
||||
s := testServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/?lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte(`lang="de"`)) {
|
||||
t.Error("expected lang=de in response")
|
||||
}
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "heatguard_lang" && c.Value == "de" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected heatguard_lang cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAPI_NoForecasts(t *testing.T) {
|
||||
s := testServer(t)
|
||||
|
||||
reqBody := compute.ComputeRequest{
|
||||
Profile: compute.Profile{Name: "Test", Timezone: "UTC"},
|
||||
Date: "2025-07-15",
|
||||
}
|
||||
b, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/compute/dashboard", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAPI_MethodNotAllowed(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/compute/dashboard", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("got status %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMConfigAPI(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/llm/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Available {
|
||||
t.Errorf("expected available=false for noop provider, got provider=%q model=%q available=%v", resp.Provider, resp.Model, resp.Available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMSummarize_Noop(t *testing.T) {
|
||||
s := testServer(t)
|
||||
body := `{"date":"2025-07-15","peakTempC":35,"riskLevel":"high"}`
|
||||
req := httptest.NewRequest("POST", "/api/llm/summarize", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["summary"] != "" {
|
||||
t.Errorf("expected empty summary for noop, got %q", resp["summary"])
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package static
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed tailwind.css
|
||||
var TailwindCSS string
|
||||
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package store
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
12
package.json
12
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"
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@source "../internal/report/templates/*.tmpl";
|
||||
@source "../internal/cli/templates/*.tmpl";
|
||||
@source "../web/**/*.{html,js}";
|
||||
|
||||
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:templates all:js all:css all:i18n
|
||||
var FS embed.FS
|
||||
249
web/i18n/de.json
Normal file
249
web/i18n/de.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
249
web/i18n/en.json
Normal file
249
web/i18n/en.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
354
web/js/dashboard.js
Normal file
354
web/js/dashboard.js
Normal file
@@ -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 = `<p class="text-sm whitespace-pre-line">${esc(llmData.summary)}</p>
|
||||
<p class="text-xs text-gray-400 mt-2">AI-generated summary. Not a substitute for professional advice.</p>`;
|
||||
} 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 = '<h2 class="text-lg font-semibold mb-2">Warnings</h2>' +
|
||||
data.warnings.map(w => `
|
||||
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<div class="font-medium text-red-700 dark:text-red-300">${esc(w.headline)}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
|
||||
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
|
||||
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
|
||||
</div>
|
||||
`).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 `
|
||||
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2">
|
||||
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
|
||||
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).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 `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">${esc(rb.roomName)}</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between"><span>Internal</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>Solar</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>Ventilation</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between font-medium"><span>Total</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between"><span>AC Capacity</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between font-medium"><span>Headroom</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-red-400" style="width: ${gainPct}%"></div>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct}%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-400">
|
||||
<span>Gain</span><span>AC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Care checklist
|
||||
if (data.careChecklist && data.careChecklist.length > 0) {
|
||||
show("care-section");
|
||||
$("care-checklist").innerHTML = data.careChecklist.map(item => `
|
||||
<li class="flex items-start gap-2">
|
||||
<input type="checkbox" class="mt-1 rounded">
|
||||
<span class="text-sm">${esc(item)}</span>
|
||||
</li>
|
||||
`).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 `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}" stop-opacity="0.4"/>`;
|
||||
}).join("");
|
||||
|
||||
const gradientStopsLine = timeline.map((s, i) => {
|
||||
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
|
||||
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}"/>`;
|
||||
}).join("");
|
||||
|
||||
// Threshold lines
|
||||
const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp);
|
||||
const thresholdLines = thresholds.map(t =>
|
||||
`<line x1="${padLeft}" y1="${y(t).toFixed(1)}" x2="${padLeft + chartW}" y2="${y(t).toFixed(1)}" stroke="#9ca3af" stroke-width="0.5" stroke-dasharray="4,3"/>
|
||||
<text x="${padLeft - 3}" y="${(y(t) + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${t}\u00b0</text>`
|
||||
).join("");
|
||||
|
||||
// Y-axis labels (min, max)
|
||||
const yLabels = `
|
||||
<text x="${padLeft - 3}" y="${(padTop + 4).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${maxTemp}\u00b0</text>
|
||||
<text x="${padLeft - 3}" y="${(padTop + chartH + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${minTemp}\u00b0</text>
|
||||
`;
|
||||
|
||||
// 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 `<text x="${x(idx).toFixed(1)}" y="${(height - 3).toFixed(1)}" text-anchor="middle" fill="#9ca3af" font-size="9">${String(s.hour).padStart(2, '0')}</text>`;
|
||||
}).join("");
|
||||
|
||||
// Data points (circles)
|
||||
const circles = timeline.map((s, i) =>
|
||||
`<circle cx="${x(i).toFixed(1)}" cy="${y(s.tempC).toFixed(1)}" r="4" fill="${tempColorHex(s.tempC)}" stroke="white" stroke-width="1.5" class="timeline-dot" data-idx="${i}" style="cursor:pointer"/>`
|
||||
).join("");
|
||||
|
||||
const svg = `<svg viewBox="0 0 ${width} ${height}" class="w-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="1" y2="0">${gradientStops}</linearGradient>
|
||||
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">${gradientStopsLine}</linearGradient>
|
||||
</defs>
|
||||
<path d="${areaPath}" fill="url(#areaGrad)"/>
|
||||
<path d="${linePath}" fill="none" stroke="url(#lineGrad)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
${thresholdLines}
|
||||
${yLabels}
|
||||
${xLabels}
|
||||
${circles}
|
||||
</svg>`;
|
||||
|
||||
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("<br>")
|
||||
: "No actions";
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-medium mb-1">${slot.hourStr}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
|
||||
<div class="capitalize">${slot.budgetStatus}</div>
|
||||
<div class="mt-1 text-gray-300">${actions}</div>
|
||||
`;
|
||||
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 `<div class="flex-1 h-3 rounded-sm" style="background:${color}" title="${slot.hourStr}: ${slot.budgetStatus}"></div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Init
|
||||
loadDashboard();
|
||||
})();
|
||||
290
web/js/db.js
Normal file
290
web/js/db.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
685
web/js/setup.js
Normal file
685
web/js/setup.js
Normal file
@@ -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 = '<p class="text-sm text-gray-400 dark:text-gray-500">No profiles yet.</p>';
|
||||
return;
|
||||
}
|
||||
const activeId = await getActiveProfileId();
|
||||
list.innerHTML = profiles.map(p => {
|
||||
const isActive = activeId === p.id;
|
||||
return `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(p.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="activateProfile(${p.id})" class="text-xs px-2 py-1 rounded ${isActive ? 'bg-orange-600 text-white' : 'bg-gray-100 dark:bg-gray-700'}">
|
||||
${isActive ? '● Active' : 'Set Active'}
|
||||
</button>
|
||||
<button onclick="editProfileUI(${p.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteProfileUI(${p.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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 = '<p class="text-sm text-gray-400">No rooms yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rooms.map(r => `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(r.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C</span>
|
||||
</div>
|
||||
<button onclick="editRoomUI(${r.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteRoomUI(${r.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`).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 = '<p class="text-sm text-gray-400">No devices yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = allDevices.map(d => `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(d.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${esc(d._roomName)} · ${d.wattsTypical}W typical</span>
|
||||
</div>
|
||||
<button onclick="editDeviceUI(${d.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteDeviceUI(${d.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`).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 = '<p class="text-sm text-gray-400">No occupants yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = allOccupants.map(o => `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${o.count}x ${esc(o.activityLevel)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''}</span>
|
||||
</div>
|
||||
<button onclick="editOccupantUI(${o.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteOccupantUI(${o.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`).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 = '<p class="text-sm text-gray-400">No AC units yet.</p>';
|
||||
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 `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(u.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}</span>
|
||||
</div>
|
||||
<button onclick="editACUI(${u.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteACUI(${u.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`;
|
||||
}).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 => `<option value="${r.id}">${esc(r.name)}</option>`).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 => `
|
||||
<label class="flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" value="${r.id}" class="rounded"> ${esc(r.name)}
|
||||
</label>
|
||||
`).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();
|
||||
})();
|
||||
109
web/templates/dashboard.html
Normal file
109
web/templates/dashboard.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{{define "content"}}
|
||||
<div id="dashboard">
|
||||
<!-- No data state -->
|
||||
<div id="no-data" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🌡️</div>
|
||||
<h1 class="text-2xl font-bold mb-2">{{t "dashboard.title"}}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">{{t "dashboard.noData"}}</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/guide" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.goToGuide"}}</a>
|
||||
<a href="/setup" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">{{t "dashboard.goToSetup"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No forecast state -->
|
||||
<div id="no-forecast" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<h1 class="text-2xl font-bold mb-2">{{t "dashboard.title"}}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">{{t "dashboard.fetchForecastFirst"}}</p>
|
||||
<a href="/setup#forecast" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.goToSetup"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="loading" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-2 border-orange-600 border-t-transparent mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{t "dashboard.computing"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div id="error-state" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{{t "dashboard.error"}}</p>
|
||||
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data display -->
|
||||
<div id="data-display" class="hidden space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{{t "dashboard.title"}}</h1>
|
||||
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div id="warnings-section" class="hidden space-y-2"></div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div id="risk-card" class="rounded-xl p-4 text-center">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.riskLevel"}}</div>
|
||||
<div id="risk-level" class="text-2xl font-bold"></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.peakTemp"}}</div>
|
||||
<div id="peak-temp" class="text-2xl font-bold"></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.minNightTemp"}}</div>
|
||||
<div id="min-night-temp" class="text-2xl font-bold"></div>
|
||||
<div id="poor-night-cool" class="hidden text-xs text-orange-600 dark:text-orange-400 mt-1">{{t "dashboard.poorNightCool"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk windows -->
|
||||
<div id="risk-windows-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.riskWindows"}}</h2>
|
||||
<div id="risk-windows" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.timeline"}}</h2>
|
||||
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
|
||||
<div id="timeline-strip" class="flex gap-0.5 mt-2"></div>
|
||||
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Room budgets -->
|
||||
<div id="budgets-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.roomBudgets"}}</h2>
|
||||
<div id="room-budgets" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Care checklist -->
|
||||
<div id="care-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.careChecklist"}}</h2>
|
||||
<ul id="care-checklist" class="space-y-1"></ul>
|
||||
</div>
|
||||
|
||||
<!-- LLM Summary -->
|
||||
<div id="llm-section">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.llmSummary"}}</h2>
|
||||
<div id="llm-summary" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/assets/js/db.js"></script>
|
||||
<script src="/assets/js/dashboard.js"></script>
|
||||
{{end}}
|
||||
87
web/templates/guide.html
Normal file
87
web/templates/guide.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<h1 class="text-2xl font-bold">{{t "guide.title"}}</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{t "guide.intro"}}</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step1.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step1.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step2.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step2.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step3.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step3.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step4.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step4.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step5.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step5.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step6.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step6.text"}}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.params.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.shgc"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.ventilation"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.shading"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.orientation"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.risk.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.low"}}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.moderate"}}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-orange-500"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.high"}}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-red-600"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.extreme"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.budget.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.budget.text"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.tips.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-2">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip1"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip2"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip3"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip4"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
48
web/templates/layout.html
Normal file
48
web/templates/layout.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Lang}}" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{t "app.name"}} — {{.Title}}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌡</text></svg>">
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<script>
|
||||
window.HG = { lang: "{{.Lang}}" };
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-14">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/" class="font-bold text-lg text-orange-600 dark:text-orange-400">{{t "app.name"}}</a>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="hover:text-orange-600 dark:hover:text-orange-400 {{if eq .Page "dashboard"}}text-orange-600 dark:text-orange-400 font-medium{{end}}">{{t "nav.dashboard"}}</a>
|
||||
<a href="/setup" class="hover:text-orange-600 dark:hover:text-orange-400 {{if eq .Page "setup"}}text-orange-600 dark:text-orange-400 font-medium{{end}}">{{t "nav.setup"}}</a>
|
||||
<a href="/guide" class="hover:text-orange-600 dark:hover:text-orange-400 {{if eq .Page "guide"}}text-orange-600 dark:text-orange-400 font-medium{{end}}">{{t "nav.guide"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{t "nav.language"}}:</span>
|
||||
<a href="?lang=en" class="{{if eq .Lang "en"}}font-bold text-orange-600 dark:text-orange-400{{else}}text-gray-500 dark:text-gray-400 hover:text-orange-600{{end}}">EN</a>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<a href="?lang=de" class="{{if eq .Lang "de"}}font-bold text-orange-600 dark:text-orange-400{{else}}text-gray-500 dark:text-gray-400 hover:text-orange-600{{end}}">DE</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-gray-200 dark:border-gray-700 mt-12 py-4">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
|
||||
<div>{{t "app.name"}} v1.0.0 — {{t "app.tagline"}}</div>
|
||||
<div><a href="https://somegit.dev/vikingowl/HeatGuard" class="hover:text-orange-600 dark:hover:text-orange-400" target="_blank" rel="noopener">{{t "footer.source"}}</a> · {{t "footer.license"}}</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
326
web/templates/setup.html
Normal file
326
web/templates/setup.html
Normal file
@@ -0,0 +1,326 @@
|
||||
{{define "content"}}
|
||||
<div id="setup">
|
||||
<h1 class="text-2xl font-bold mb-6">{{t "setup.title"}}</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-6 overflow-x-auto border-b border-gray-200 dark:border-gray-700">
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-orange-600 text-orange-600 dark:text-orange-400 dark:border-orange-400" data-tab="profiles">{{t "setup.profiles.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="rooms">{{t "setup.rooms.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="devices">{{t "setup.devices.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="occupants">{{t "setup.occupants.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="ac">{{t "setup.ac.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="toggles">{{t "setup.toggles.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="forecast">{{t "setup.forecast.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="llm">{{t "setup.llm.title"}}</button>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="hidden fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity"></div>
|
||||
|
||||
<!-- Profiles tab -->
|
||||
<section id="tab-profiles" class="tab-panel">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.profiles.help"}}</p>
|
||||
<form id="profile-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.timezone.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.timezone.tooltip"}}">?</span></label>
|
||||
<input type="text" name="timezone" value="Europe/Berlin" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.latitude.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.latitude.tooltip"}}">?</span></label>
|
||||
<input type="number" name="latitude" step="0.0001" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.longitude.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.longitude.tooltip"}}">?</span></label>
|
||||
<input type="number" name="longitude" step="0.0001" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" id="geolocate-btn" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
📍 {{t "setup.profiles.geolocate.button"}}
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.profiles.add"}}</button>
|
||||
</div>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="profiles-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.profiles.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rooms tab -->
|
||||
<section id="tab-rooms" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.rooms.help"}}</p>
|
||||
<form id="room-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.area.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.area.tooltip"}}">?</span></label>
|
||||
<input type="number" name="areaSqm" step="0.1" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.ceilingHeight.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.ceilingHeight.tooltip"}}">?</span></label>
|
||||
<input type="number" name="ceilingHeightM" step="0.1" value="2.5" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.floor.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.floor.tooltip"}}">?</span></label>
|
||||
<input type="number" name="floor" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.orientation.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.orientation.tooltip"}}">?</span></label>
|
||||
<select name="orientation" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="N">{{t "setup.rooms.orientation.options.N"}}</option><option value="NE">{{t "setup.rooms.orientation.options.NE"}}</option><option value="E">{{t "setup.rooms.orientation.options.E"}}</option>
|
||||
<option value="SE">{{t "setup.rooms.orientation.options.SE"}}</option><option value="S" selected>{{t "setup.rooms.orientation.options.S"}}</option><option value="SW">{{t "setup.rooms.orientation.options.SW"}}</option>
|
||||
<option value="W">{{t "setup.rooms.orientation.options.W"}}</option><option value="NW">{{t "setup.rooms.orientation.options.NW"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.shadingType.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.shadingType.tooltip"}}">?</span></label>
|
||||
<select name="shadingType" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="none">{{t "setup.rooms.shadingType.options.none"}}</option><option value="blinds">{{t "setup.rooms.shadingType.options.blinds"}}</option>
|
||||
<option value="shutters">{{t "setup.rooms.shadingType.options.shutters"}}</option><option value="awning">{{t "setup.rooms.shadingType.options.awning"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.shadingFactor.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.shadingFactor.tooltip"}}">?</span></label>
|
||||
<input type="number" name="shadingFactor" step="0.1" min="0" max="1" value="1.0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.ventilationAch.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.ventilationAch.tooltip"}}">?</span></label>
|
||||
<input type="number" name="ventilationAch" step="0.1" value="0.5" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.windowFraction.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.windowFraction.tooltip"}}">?</span></label>
|
||||
<input type="number" name="windowFraction" step="0.01" min="0" max="1" value="0.15" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.shgc.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.shgc.tooltip"}}">?</span></label>
|
||||
<input type="number" name="shgc" step="0.1" min="0" max="1" value="0.6" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.insulation.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.insulation.tooltip"}}">?</span></label>
|
||||
<select name="insulation" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="poor">{{t "setup.rooms.insulation.options.poor"}}</option><option value="average" selected>{{t "setup.rooms.insulation.options.average"}}</option>
|
||||
<option value="good">{{t "setup.rooms.insulation.options.good"}}</option><option value="excellent">{{t "setup.rooms.insulation.options.excellent"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.indoorTemp.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.indoorTemp.tooltip"}}">?</span></label>
|
||||
<input type="number" name="indoorTempC" step="0.5" min="15" max="35" value="25" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.rooms.add"}}</button>
|
||||
</div>
|
||||
<input type="hidden" name="id" value="">
|
||||
<input type="hidden" name="profileId" value="">
|
||||
</form>
|
||||
<div id="rooms-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.rooms.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Devices tab -->
|
||||
<section id="tab-devices" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.devices.help"}}</p>
|
||||
<form id="device-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.room.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.room.tooltip"}}">?</span></label>
|
||||
<select name="roomId" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm" id="device-room-select"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.type.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.type.tooltip"}}">?</span></label>
|
||||
<input type="text" name="deviceType" value="electronics" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.wattsIdle.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.wattsIdle.tooltip"}}">?</span></label>
|
||||
<input type="number" name="wattsIdle" step="1" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.wattsTypical.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.wattsTypical.tooltip"}}">?</span></label>
|
||||
<input type="number" name="wattsTypical" step="1" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.wattsPeak.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.wattsPeak.tooltip"}}">?</span></label>
|
||||
<input type="number" name="wattsPeak" step="1" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.dutyCycle.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.dutyCycle.tooltip"}}">?</span></label>
|
||||
<input type="number" name="dutyCycle" step="0.1" min="0" max="1" value="1.0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.devices.add"}}</button>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="devices-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.devices.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Occupants tab -->
|
||||
<section id="tab-occupants" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.occupants.help"}}</p>
|
||||
<form id="occupant-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.occupants.room.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.room.tooltip"}}">?</span></label>
|
||||
<select name="roomId" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm" id="occupant-room-select"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.occupants.count.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.count.tooltip"}}">?</span></label>
|
||||
<input type="number" name="count" min="1" value="1" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.occupants.activity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.activity.tooltip"}}">?</span></label>
|
||||
<select name="activityLevel" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="sleeping">{{t "setup.occupants.activity.options.sleeping"}}</option><option value="sedentary" selected>{{t "setup.occupants.activity.options.sedentary"}}</option>
|
||||
<option value="light">{{t "setup.occupants.activity.options.light"}}</option><option value="moderate">{{t "setup.occupants.activity.options.moderate"}}</option><option value="heavy">{{t "setup.occupants.activity.options.heavy"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end pb-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="vulnerable" class="rounded">
|
||||
{{t "setup.occupants.vulnerable.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.vulnerable.tooltip"}}">?</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.occupants.add"}}</button>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="occupants-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.occupants.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AC Units tab -->
|
||||
<section id="tab-ac" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.ac.help"}}</p>
|
||||
<form id="ac-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.type.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.type.tooltip"}}">?</span></label>
|
||||
<select name="acType" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="portable">{{t "setup.ac.type.options.portable"}}</option><option value="window">{{t "setup.ac.type.options.window"}}</option>
|
||||
<option value="split">{{t "setup.ac.type.options.split"}}</option><option value="central">{{t "setup.ac.type.options.central"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.capacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.capacity.tooltip"}}">?</span></label>
|
||||
<input type="number" name="capacityBtu" step="100" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.eer.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.eer.tooltip"}}">?</span></label>
|
||||
<input type="number" name="efficiencyEer" step="0.1" value="10" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div class="flex items-end pb-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="hasDehumidify" class="rounded">
|
||||
{{t "setup.ac.dehumidify.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.dehumidify.tooltip"}}">?</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.rooms.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.rooms.tooltip"}}">?</span></label>
|
||||
<div id="ac-room-checkboxes" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.ac.add"}}</button>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="ac-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.ac.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toggles tab -->
|
||||
<section id="tab-toggles" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.toggles.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-gaming" class="toggle-switch rounded" data-toggle="gaming">
|
||||
<span class="text-sm">{{t "setup.toggles.gaming"}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-cooking" class="toggle-switch rounded" data-toggle="cooking">
|
||||
<span class="text-sm">{{t "setup.toggles.cooking"}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-laundry" class="toggle-switch rounded" data-toggle="laundry">
|
||||
<span class="text-sm">{{t "setup.toggles.laundry"}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-guests" class="toggle-switch rounded" data-toggle="guests">
|
||||
<span class="text-sm">{{t "setup.toggles.guests"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Forecast tab -->
|
||||
<section id="tab-forecast" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.forecast.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<button id="fetch-forecast-btn" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition text-sm">
|
||||
{{t "setup.forecast.fetch"}}
|
||||
</button>
|
||||
<span id="forecast-status" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{t "setup.forecast.lastFetched"}}: <span id="last-fetched">{{t "setup.forecast.never"}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="forecast-spinner" class="hidden text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-block animate-spin mr-2">↻</span> {{t "setup.forecast.fetching"}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LLM tab -->
|
||||
<section id="tab-llm" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.llm.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||
<div id="llm-server-info" class="text-sm text-gray-400 hidden"></div>
|
||||
<form id="llm-form" class="space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.llm.provider"}}</label>
|
||||
<select name="llmProvider" id="llm-provider-select" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="">-- {{t "setup.llm.provider"}} --</option>
|
||||
<option value="anthropic">{{t "setup.llm.providerOptions.anthropic"}}</option>
|
||||
<option value="openai">{{t "setup.llm.providerOptions.openai"}}</option>
|
||||
<option value="gemini">{{t "setup.llm.providerOptions.gemini"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.llm.apiKey"}}</label>
|
||||
<input type="password" name="llmApiKey" id="llm-api-key" placeholder="{{t "setup.llm.apiKeyPlaceholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.llm.model"}}</label>
|
||||
<input type="text" name="llmModel" id="llm-model" placeholder="{{t "setup.llm.modelPlaceholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.llm.save"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/assets/js/db.js"></script>
|
||||
<script src="/assets/js/setup.js"></script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user