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

166 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}