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

159 lines
4.6 KiB
Go

package cli
import (
"context"
"fmt"
"os"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/action"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
"github.com/spf13/cobra"
)
var (
heatplanOutput string
)
func init() {
heatplanCmd := &cobra.Command{
Use: "heatplan",
Short: "Generate a 1-page plain-language heat plan",
}
generateCmd := &cobra.Command{
Use: "generate",
Short: "Generate a heat plan document",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
provider := getLLMProvider()
if provider.Name() == "none" {
return fmt.Errorf("LLM not configured. Set llm.provider in config or use --llm flag")
}
dateStr := time.Now().Format("2006-01-02")
date, _ := time.Parse("2006-01-02", dateStr)
loc, _ := time.LoadLocation(p.Timezone)
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 || len(forecasts) == 0 {
return fmt.Errorf("no forecast data. Run: heatwave forecast fetch")
}
hourlyData := buildHourlyData(forecasts, loc)
dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds())
devices, _ := db.ListAllDevices(p.ID)
var sources []llm.HeatSource
for _, d := range devices {
sources = append(sources, llm.HeatSource{Name: d.Name, Watts: d.WattsTypical * d.DutyCycle})
}
acUnits, _ := db.ListACUnits(p.ID)
var totalACBTU float64
for _, ac := range acUnits {
totalACBTU += ac.CapacityBTU
}
var totalGainW float64
for _, s := range sources {
totalGainW += s.Watts
}
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
var warningStrs []string
for _, w := range warnings {
warningStrs = append(warningStrs, w.Headline)
}
var riskWindows []llm.RiskWindowSummary
for _, w := range dayRisk.Windows {
riskWindows = append(riskWindows, llm.RiskWindowSummary{
StartHour: w.StartHour, EndHour: w.EndHour, PeakTempC: w.PeakTempC, Level: w.Level.String(),
})
}
summaryInput := llm.SummaryInput{
Date: dateStr,
PeakTempC: dayRisk.PeakTempC,
MinNightTempC: dayRisk.MinNightTempC,
RiskLevel: dayRisk.Level.String(),
TopHeatSources: sources,
ACHeadroomBTUH: totalACBTU - heat.WattsToBTUH(totalGainW),
BudgetStatus: heat.Comfortable.String(),
ActiveWarnings: warningStrs,
RiskWindows: riskWindows,
}
// Build timeline
actions, _ := action.LoadDefaultActions()
toggles, _ := db.GetActiveToggleNames(p.ID)
var timelineSlots []llm.TimelineSlotSummary
var actionSummaries []llm.ActionSummary
for _, h := range hourlyData {
ctx := action.HourContext{
Hour: h.Hour, TempC: h.TempC, HumidityPct: h.HumidityPct,
IsDay: h.IsDay, RiskLevel: dayRisk.Level,
BudgetStatus: heat.Comfortable, ActiveToggles: toggles,
}
matched := action.SelectActions(actions, ctx)
var actionNames []string
for _, a := range matched {
actionNames = append(actionNames, a.Name)
actionSummaries = append(actionSummaries, llm.ActionSummary{
Name: a.Name, Category: string(a.Category), Impact: string(a.Impact), Hour: h.Hour,
})
}
timelineSlots = append(timelineSlots, llm.TimelineSlotSummary{
Hour: h.Hour, TempC: h.TempC, RiskLevel: dayRisk.Level.String(),
BudgetStatus: heat.Comfortable.String(), Actions: actionNames,
})
}
// Care checklist
occupants, _ := db.ListAllOccupants(p.ID)
var careList []string
for _, o := range occupants {
if o.Vulnerable {
careList = append(careList, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID))
}
}
input := llm.HeatPlanInput{
Summary: summaryInput,
Timeline: timelineSlots,
Actions: actionSummaries,
CareChecklist: careList,
}
result, err := provider.GenerateHeatPlan(context.Background(), input)
if err != nil {
return fmt.Errorf("LLM call failed: %w", err)
}
if heatplanOutput != "" {
if err := os.WriteFile(heatplanOutput, []byte(result), 0o644); err != nil {
return fmt.Errorf("write output: %w", err)
}
fmt.Printf("Heat plan written to %s\n", heatplanOutput)
} else {
fmt.Println(result)
}
return nil
},
}
generateCmd.Flags().StringVar(&heatplanOutput, "output", "", "output file path")
heatplanCmd.AddCommand(generateCmd)
rootCmd.AddCommand(heatplanCmd)
}