Files
HeatGuard/internal/cli/forecast.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.3 KiB
Go

package cli
import (
"context"
"fmt"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
"github.com/cnachtigall/heatwave-autopilot/internal/weather"
"github.com/spf13/cobra"
)
var (
forecastForce bool
forecastHours int
)
func init() {
forecastCmd := &cobra.Command{
Use: "forecast",
Short: "Manage weather forecasts",
}
fetchCmd := &cobra.Command{
Use: "fetch",
Short: "Fetch weather forecast and warnings",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
if !forecastForce {
lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo")
if err == nil && time.Since(lastFetch) < time.Hour {
fmt.Printf("Forecast is fresh (last fetched %s). Use --force to refetch.\n", lastFetch.Format("15:04"))
return nil
}
}
return fetchForecastForProfile(p)
},
}
fetchCmd.Flags().BoolVar(&forecastForce, "force", false, "force refetch even if recent data exists")
showCmd := &cobra.Command{
Use: "show",
Short: "Show forecast data",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
now := time.Now()
from := now
to := now.Add(time.Duration(forecastHours) * time.Hour)
forecasts, err := db.GetForecasts(p.ID, from, to, "")
if err != nil {
return err
}
if len(forecasts) == 0 {
fmt.Println("No forecast data. Run: heatwave forecast fetch")
return nil
}
for _, f := range forecasts {
temp := "n/a"
if f.TemperatureC != nil {
temp = fmt.Sprintf("%.1f°C", *f.TemperatureC)
}
hum := "n/a"
if f.HumidityPct != nil {
hum = fmt.Sprintf("%.0f%%", *f.HumidityPct)
}
fmt.Printf(" %s %s %s %s\n", f.Timestamp.Format("02 Jan 15:04"), temp, hum, f.Source)
}
return nil
},
}
showCmd.Flags().IntVar(&forecastHours, "hours", 48, "hours to display")
risksCmd := &cobra.Command{
Use: "risks",
Short: "Show identified risk windows",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Risk analysis requires forecast data. Run: heatwave forecast fetch")
fmt.Println("Then use: heatwave plan today")
return nil
},
}
forecastCmd.AddCommand(fetchCmd, showCmd, risksCmd)
rootCmd.AddCommand(forecastCmd)
}
// fetchForecastForProfile fetches forecast and warnings for the given profile.
func fetchForecastForProfile(p *store.Profile) error {
ctx := context.Background()
om := weather.NewOpenMeteo(nil)
resp, err := om.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone)
if err != nil {
fmt.Printf("Open-Meteo failed: %v, trying Bright Sky...\n", err)
bs := weather.NewBrightSky(nil)
resp, err = bs.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone)
if err != nil {
return fmt.Errorf("all forecast providers failed: %w", err)
}
}
count := 0
for _, h := range resp.Hourly {
f := &store.Forecast{
ProfileID: p.ID,
Timestamp: h.Timestamp,
TemperatureC: &h.TemperatureC,
HumidityPct: &h.HumidityPct,
WindSpeedMs: &h.WindSpeedMs,
CloudCoverPct: &h.CloudCoverPct,
PrecipitationMm: &h.PrecipitationMm,
SunshineMin: &h.SunshineMin,
PressureHpa: &h.PressureHpa,
DewPointC: &h.DewPointC,
ApparentTempC: &h.ApparentTempC,
Condition: h.Condition,
Source: resp.Source,
}
if err := db.UpsertForecast(f); err != nil {
return fmt.Errorf("store forecast: %w", err)
}
count++
}
fmt.Printf("Stored %d hourly forecasts from %s\n", count, resp.Source)
dwd := weather.NewDWDWFS(nil)
warnings, err := dwd.FetchWarnings(ctx, p.Latitude, p.Longitude)
if err != nil {
fmt.Printf("Warning fetch failed: %v\n", err)
} else {
for _, w := range warnings {
sw := &store.Warning{
ProfileID: p.ID,
WarningID: w.ID,
EventType: w.EventType,
Severity: w.Severity,
Headline: w.Headline,
Description: w.Description,
Instruction: w.Instruction,
Onset: w.Onset,
Expires: w.Expires,
}
if err := db.UpsertWarning(sw); err != nil {
return fmt.Errorf("store warning: %w", err)
}
}
fmt.Printf("Stored %d heat warnings\n", len(warnings))
}
cutoff := time.Now().Add(-7 * 24 * time.Hour)
deleted, _ := db.CleanupOldForecasts(cutoff)
if deleted > 0 && verbose {
fmt.Printf("Cleaned up %d old forecasts\n", deleted)
}
return nil
}