Files
HeatGuard/internal/cli/report.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

332 lines
9.0 KiB
Go

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()
}