Files
HeatGuard/internal/store/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

97 lines
3.3 KiB
Go

package store
import (
"fmt"
"time"
)
type Forecast struct {
ID int64
ProfileID int64
Timestamp time.Time
TemperatureC *float64
HumidityPct *float64
WindSpeedMs *float64
CloudCoverPct *float64
PrecipitationMm *float64
SunshineMin *float64
PressureHpa *float64
DewPointC *float64
ApparentTempC *float64
Condition string
Source string
FetchedAt time.Time
}
func (s *Store) UpsertForecast(f *Forecast) error {
_, err := s.db.Exec(
`INSERT INTO forecasts (profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_id, timestamp, source) DO UPDATE SET
temperature_c=excluded.temperature_c, humidity_pct=excluded.humidity_pct,
wind_speed_ms=excluded.wind_speed_ms, cloud_cover_pct=excluded.cloud_cover_pct,
precipitation_mm=excluded.precipitation_mm, sunshine_min=excluded.sunshine_min,
pressure_hpa=excluded.pressure_hpa, dew_point_c=excluded.dew_point_c,
apparent_temp_c=excluded.apparent_temp_c, condition=excluded.condition,
fetched_at=datetime('now')`,
f.ProfileID, f.Timestamp.Format(time.RFC3339),
f.TemperatureC, f.HumidityPct, f.WindSpeedMs, f.CloudCoverPct,
f.PrecipitationMm, f.SunshineMin, f.PressureHpa, f.DewPointC,
f.ApparentTempC, f.Condition, f.Source,
)
return err
}
func (s *Store) GetForecasts(profileID int64, from, to time.Time, source string) ([]Forecast, error) {
query := `SELECT id, profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source, fetched_at
FROM forecasts WHERE profile_id = ? AND timestamp >= ? AND timestamp <= ?`
args := []any{profileID, from.Format(time.RFC3339), to.Format(time.RFC3339)}
if source != "" {
query += ` AND source = ?`
args = append(args, source)
}
query += ` ORDER BY timestamp`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var forecasts []Forecast
for rows.Next() {
var f Forecast
var ts, fetched string
if err := rows.Scan(&f.ID, &f.ProfileID, &ts, &f.TemperatureC, &f.HumidityPct, &f.WindSpeedMs, &f.CloudCoverPct, &f.PrecipitationMm, &f.SunshineMin, &f.PressureHpa, &f.DewPointC, &f.ApparentTempC, &f.Condition, &f.Source, &fetched); err != nil {
return nil, err
}
f.Timestamp, _ = time.Parse(time.RFC3339, ts)
f.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
forecasts = append(forecasts, f)
}
return forecasts, rows.Err()
}
func (s *Store) GetLastFetchTime(profileID int64, source string) (time.Time, error) {
var fetched string
err := s.db.QueryRow(
`SELECT MAX(fetched_at) FROM forecasts WHERE profile_id = ? AND source = ?`,
profileID, source,
).Scan(&fetched)
if err != nil || fetched == "" {
return time.Time{}, fmt.Errorf("no forecasts found")
}
t, _ := time.Parse("2006-01-02 15:04:05", fetched)
return t, nil
}
func (s *Store) CleanupOldForecasts(olderThan time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM forecasts WHERE timestamp < ?`,
olderThan.Format(time.RFC3339),
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}