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 }