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