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
332 lines
9.0 KiB
Go
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()
|
|
}
|