Files
HeatGuard/internal/action/selector.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

128 lines
2.3 KiB
Go

package action
import (
"sort"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
// Matches checks if an action's conditions are met for a given hour context.
func Matches(a Action, ctx HourContext) bool {
w := a.When
// Hour range check (only if at least one is non-zero)
if w.HourFrom != 0 || w.HourTo != 0 {
if ctx.Hour < w.HourFrom || ctx.Hour > w.HourTo {
return false
}
}
// Temperature threshold
if w.MinTempC > 0 && ctx.TempC < w.MinTempC {
return false
}
if w.MaxTempC > 0 && ctx.TempC > w.MaxTempC {
return false
}
// Night only
if w.NightOnly && ctx.IsDay {
return false
}
// Risk level
if w.MinRisk != "" {
required := parseRiskLevel(w.MinRisk)
if ctx.RiskLevel < required {
return false
}
}
// Budget status
if w.BudgetStatus != "" {
required := parseBudgetStatus(w.BudgetStatus)
if ctx.BudgetStatus < required {
return false
}
}
// High humidity
if w.HighHumidity && ctx.HumidityPct <= 70 {
return false
}
return true
}
// SelectActions returns all matching actions for a given hour, sorted by priority.
func SelectActions(actions []Action, ctx HourContext) []Action {
var matched []Action
for _, a := range actions {
if Matches(a, ctx) {
matched = append(matched, a)
}
}
sort.Slice(matched, func(i, j int) bool {
return priority(matched[i]) > priority(matched[j])
})
return matched
}
// priority scores an action: impact * 10 + (4 - effort)
func priority(a Action) int {
return impactScore(a.Impact)*10 + (4 - effortScore(a.Effort))
}
func impactScore(i Impact) int {
switch i {
case ImpactHigh:
return 3
case ImpactMedium:
return 2
case ImpactLow:
return 1
default:
return 0
}
}
func effortScore(e Effort) int {
switch e {
case EffortNone:
return 0
case EffortLow:
return 1
case EffortMedium:
return 2
case EffortHigh:
return 3
default:
return 0
}
}
func parseRiskLevel(s string) risk.RiskLevel {
switch s {
case "moderate":
return risk.Moderate
case "high":
return risk.High
case "extreme":
return risk.Extreme
default:
return risk.Low
}
}
func parseBudgetStatus(s string) heat.BudgetStatus {
switch s {
case "marginal":
return heat.Marginal
case "overloaded":
return heat.Overloaded
default:
return heat.Comfortable
}
}