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
166 lines
4.0 KiB
Go
166 lines
4.0 KiB
Go
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
|
||
}
|