Files
HeatGuard/internal/report/generator.go
vikingowl 1c9db02334 feat: add web UI with full CRUD setup page
Add server-side rendered setup UI accessible via `heatwave web`.
The dashboard is now re-rendered per request and includes a nav bar
linking to the new /setup page. Setup provides full CRUD for profiles,
rooms, devices, occupants, AC units (with room assignment), scenario
toggles, and forecast fetching — all via POST/redirect/GET forms.

- Add ShowNav field to DashboardData for conditional nav bar
- Extract fetchForecastForProfile() for reuse by web handler
- Create setup.html.tmpl with Tailwind-styled entity sections
- Create web_handlers.go with 15 route handlers and flash cookies
- Switch web.go from pre-rendered to per-request dashboard rendering
- Graceful dashboard fallback when no forecast data exists
2026-02-09 10:39:00 +01:00

156 lines
3.1 KiB
Go

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"
}
}