diff --git a/internal/compute/compute.go b/internal/compute/compute.go index 0a953f9..c32797c 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -63,6 +63,21 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { indoorTempC = sum / float64(len(req.Rooms)) } + // Compute representative indoor humidity (average of rooms that have it) + indoorHumidityPct := 50.0 + if len(req.Rooms) > 0 { + sum, count := 0.0, 0 + for _, r := range req.Rooms { + if r.IndoorHumidityPct != nil && *r.IndoorHumidityPct > 0 { + sum += *r.IndoorHumidityPct + count++ + } + } + if count > 0 { + indoorHumidityPct = sum / float64(count) + } + } + data := DashboardData{ GeneratedAt: time.Now(), ProfileName: req.Profile.Name, @@ -71,7 +86,8 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { PeakTempC: dayRisk.PeakTempC, MinNightTempC: dayRisk.MinNightTempC, PoorNightCool: dayRisk.PoorNightCool, - IndoorTempC: indoorTempC, + IndoorTempC: indoorTempC, + IndoorHumidityPct: indoorHumidityPct, } // Warnings (pass-through from client) @@ -102,6 +118,7 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { for i, h := range hourlyData { cloudPct := 50.0 sunMin := 0.0 + pressureHpa := 0.0 if i < len(dayForecasts) { if dayForecasts[i].CloudCoverPct != nil { cloudPct = *dayForecasts[i].CloudCoverPct @@ -109,22 +126,21 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { if dayForecasts[i].SunshineMin != nil { sunMin = *dayForecasts[i].SunshineMin } + if dayForecasts[i].PressureHpa != nil { + pressureHpa = *dayForecasts[i].PressureHpa + } } budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) - coolMode := "ac" - if h.TempC < indoorTempC { - coolMode = "ventilate" - } else if worstStatus == heat.Overloaded { - coolMode = "overloaded" - } + coolMode := determineCoolMode(h.TempC, indoorTempC, h.HumidityPct, worstStatus) slot := TimelineSlotData{ Hour: h.Hour, HourStr: fmt.Sprintf("%02d:00", h.Hour), TempC: h.TempC, HumidityPct: h.HumidityPct, + PressureHpa: pressureHpa, RiskLevel: dayRisk.Level.String(), BudgetStatus: worstStatus.String(), IndoorTempC: indoorTempC, @@ -163,6 +179,30 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { return data, nil } +func determineCoolMode(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus) string { + const humidityThreshold = 80.0 + const comfortDelta = 5.0 + + outdoorCooler := outdoorTempC < indoorTempC + tooHumid := outdoorHumidityPct >= humidityThreshold + + // Cold enough outside that building envelope handles any internal gains + if worstStatus != heat.Overloaded && outdoorTempC < (indoorTempC-comfortDelta) { + return "comfort" + } + + switch { + case outdoorCooler && !tooHumid: + return "ventilate" + case outdoorCooler && tooHumid: + return "sealed" + case worstStatus == heat.Overloaded: + return "overloaded" + default: + return "ac" + } +} + func buildHourlyData(forecasts []Forecast, loc *time.Location) []risk.HourlyData { var data []risk.HourlyData for _, f := range forecasts { diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go index 14928a2..738c41c 100644 --- a/internal/compute/compute_test.go +++ b/internal/compute/compute_test.go @@ -3,6 +3,8 @@ package compute import ( "testing" "time" + + "github.com/cnachtigall/heatwave-autopilot/internal/heat" ) func ptr(f float64) *float64 { return &f } @@ -325,6 +327,193 @@ func TestBuildDashboard_CoolModeOverloaded(t *testing.T) { } } +func TestDetermineCoolMode(t *testing.T) { + tests := []struct { + name string + outdoorTempC float64 + indoorTempC float64 + outdoorHumidityPct float64 + worstStatus heat.BudgetStatus + want string + }{ + {"cool and dry → ventilate", 20, 25, 50, heat.Comfortable, "ventilate"}, + {"cool and humid → sealed", 20, 25, 90, heat.Comfortable, "sealed"}, + {"hot and overloaded → overloaded", 38, 25, 50, heat.Overloaded, "overloaded"}, + {"hot and comfortable → ac", 30, 25, 50, heat.Comfortable, "ac"}, + {"humidity boundary 79.9 → ventilate", 20, 25, 79.9, heat.Comfortable, "ventilate"}, + {"humidity boundary 80.0 → sealed", 20, 25, 80.0, heat.Comfortable, "sealed"}, + {"cold and dry → comfort", 5, 25, 50, heat.Comfortable, "comfort"}, + {"cold and humid → comfort", 5, 25, 90, heat.Marginal, "comfort"}, + {"warm but not cold enough → ventilate", 21, 25, 50, heat.Comfortable, "ventilate"}, + {"warm and humid, marginal → sealed", 21, 25, 85, heat.Marginal, "sealed"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineCoolMode(tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus) + if got != tt.want { + t.Errorf("determineCoolMode(%v, %v, %v, %v) = %q, want %q", + tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, got, tt.want) + } + }) + } +} + +func TestBuildDashboard_IndoorHumidityDefault(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + temps := make([]float64, 24) + for i := range temps { + temps[i] = 25 + } + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.IndoorHumidityPct != 50.0 { + t.Errorf("got IndoorHumidityPct %v, want 50.0", data.IndoorHumidityPct) + } +} + +func TestBuildDashboard_IndoorHumidityFromRooms(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + temps := make([]float64, 24) + for i := range temps { + temps[i] = 25 + } + h60 := 60.0 + h40 := 40.0 + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{ + {ID: 1, Name: "Room1", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorHumidityPct: &h60}, + {ID: 2, Name: "Room2", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorHumidityPct: &h40}, + }, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.IndoorHumidityPct != 50.0 { + t.Errorf("got IndoorHumidityPct %v, want 50.0", data.IndoorHumidityPct) + } +} + +func TestBuildDashboard_CoolModeSealed(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // 20°C outdoor, 90% RH, indoor 25°C → sealed + forecasts := make([]Forecast, 24) + for i := range forecasts { + ts := base.Add(time.Duration(i) * time.Hour) + temp := 20.0 + humid := 90.0 + cloud := 50.0 + sun := 30.0 + apparent := 20.0 + forecasts[i] = Forecast{ + Timestamp: ts, + TemperatureC: &temp, + HumidityPct: &humid, + CloudCoverPct: &cloud, + SunshineMin: &sun, + ApparentTempC: &apparent, + } + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: forecasts, + Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}}, + ACUnits: []ACUnit{{ID: 1, ProfileID: 1, Name: "AC", CapacityBTU: 20000}}, + ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, slot := range data.Timeline { + if slot.CoolMode != "sealed" { + t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "sealed") + } + } +} + +func TestBuildDashboard_CoolModeComfort(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 2, 10, 0, 0, 0, 0, loc) + + // Winter day: -4°C to 6°C, no AC, indoor 23°C + forecasts := make([]Forecast, 24) + for i := range forecasts { + ts := base.Add(time.Duration(i) * time.Hour) + temp := -4.0 + float64(i)*0.4 // -4 to ~5.6 + humid := 50.0 + cloud := 80.0 + sun := 0.0 + apparent := temp - 2 + forecasts[i] = Forecast{ + Timestamp: ts, + TemperatureC: &temp, + HumidityPct: &humid, + CloudCoverPct: &cloud, + SunshineMin: &sun, + ApparentTempC: &apparent, + } + } + + req := ComputeRequest{ + Profile: Profile{Name: "Winter", Timezone: "UTC"}, + Forecasts: forecasts, + Rooms: []Room{{ + ID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5, + Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, + WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 23, + }}, + Devices: []Device{{ + ID: 1, RoomID: 1, Name: "PC", + WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0, + }}, + Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}}, + Toggles: map[string]bool{}, + Date: "2025-02-10", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, slot := range data.Timeline { + if slot.CoolMode != "comfort" { + t.Errorf("hour %d (%.1f°C): got CoolMode %q, want %q", + slot.Hour, slot.TempC, slot.CoolMode, "comfort") + } + } + + // Room budget should be marginal (not overloaded) — no AC but ventilation can solve + if len(data.RoomBudgets) == 0 { + t.Fatal("expected room budgets") + } + if data.RoomBudgets[0].Status != "marginal" { + t.Errorf("got budget status %q, want %q", data.RoomBudgets[0].Status, "marginal") + } +} + func TestBuildDashboard_MultipleRooms(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) diff --git a/internal/compute/types.go b/internal/compute/types.go index fdf13ae..a274433 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -27,7 +27,8 @@ type Room struct { WindowFraction float64 `json:"windowFraction"` SHGC float64 `json:"shgc"` Insulation string `json:"insulation"` - IndoorTempC float64 `json:"indoorTempC"` + IndoorTempC float64 `json:"indoorTempC"` + IndoorHumidityPct *float64 `json:"indoorHumidityPct,omitempty"` } // Device holds device data sent from the client. @@ -76,6 +77,7 @@ type Forecast struct { CloudCoverPct *float64 `json:"cloudCoverPct"` SunshineMin *float64 `json:"sunshineMin"` ApparentTempC *float64 `json:"apparentTempC"` + PressureHpa *float64 `json:"pressureHpa"` } // Warning holds a weather warning sent from the client. @@ -111,7 +113,8 @@ type DashboardData struct { PeakTempC float64 `json:"peakTempC"` MinNightTempC float64 `json:"minNightTempC"` PoorNightCool bool `json:"poorNightCool"` - IndoorTempC float64 `json:"indoorTempC"` + IndoorTempC float64 `json:"indoorTempC"` + IndoorHumidityPct float64 `json:"indoorHumidityPct"` Warnings []WarningData `json:"warnings"` RiskWindows []RiskWindowData `json:"riskWindows"` Timeline []TimelineSlotData `json:"timeline"` @@ -144,6 +147,7 @@ type TimelineSlotData struct { HourStr string `json:"hourStr"` TempC float64 `json:"tempC"` HumidityPct float64 `json:"humidityPct"` + PressureHpa float64 `json:"pressureHpa"` RiskLevel string `json:"riskLevel"` BudgetStatus string `json:"budgetStatus"` IndoorTempC float64 `json:"indoorTempC"` diff --git a/internal/heat/budget.go b/internal/heat/budget.go index 0053493..74fa4cf 100644 --- a/internal/heat/budget.go +++ b/internal/heat/budget.go @@ -55,7 +55,10 @@ func ComputeRoomBudget(in BudgetInput) BudgetResult { headroom := in.ACCapacityBTUH - totalBTUH status := Overloaded - if in.ACCapacityBTUH > 0 { + if totalBTUH <= 0 { + // Net cooling — room is losing heat, no problem + status = Comfortable + } else if in.ACCapacityBTUH > 0 { ratio := headroom / in.ACCapacityBTUH switch { case ratio > 0.2: @@ -63,6 +66,17 @@ func ComputeRoomBudget(in BudgetInput) BudgetResult { case ratio >= 0: status = Marginal } + } else { + // No AC, positive gain — can open-window ventilation offset it? + deltaT := in.Ventilation.OutdoorTempC - in.Ventilation.IndoorTempC + if deltaT < 0 { + const openWindowACH = 5.0 + rhoCpJ := in.Ventilation.RhoCp * 1000 + maxVentW := openWindowACH * in.Ventilation.VolumeCubicM * rhoCpJ * deltaT / 3600 + if (internal + solar + maxVentW) <= 0 { + status = Marginal + } + } } return BudgetResult{ diff --git a/internal/heat/budget_test.go b/internal/heat/budget_test.go index 85cb4a2..2a40d30 100644 --- a/internal/heat/budget_test.go +++ b/internal/heat/budget_test.go @@ -100,7 +100,7 @@ func TestBudgetStatus(t *testing.T) { want: Overloaded, }, { - name: "no AC at all", + name: "no AC at all, hot outdoor", totalGainW: 500, acBTUH: 0, want: Overloaded, @@ -113,7 +113,7 @@ func TestBudgetStatus(t *testing.T) { DeviceMode: ModeIdle, Occupants: nil, Solar: SolarParams{}, - Ventilation: VentilationParams{RhoCp: 1.2}, + Ventilation: VentilationParams{RhoCp: 1.2, OutdoorTempC: 30, IndoorTempC: 25, VolumeCubicM: 50}, ACCapacityBTUH: tt.acBTUH, } // Manually set gains via devices to control the total @@ -127,3 +127,72 @@ func TestBudgetStatus(t *testing.T) { }) } } + +func TestBudgetStatus_NoACVentilation(t *testing.T) { + tests := []struct { + name string + devices []Device + solar SolarParams + vent VentilationParams + acBTUH float64 + want BudgetStatus + }{ + { + name: "no AC, net cooling via ventilation", + devices: nil, + vent: VentilationParams{ + ACH: 1.0, VolumeCubicM: 45, OutdoorTempC: 10, IndoorTempC: 25, RhoCp: 1.2, + }, + acBTUH: 0, + want: Comfortable, + }, + { + name: "no AC, cold outdoor, ventilation can solve gains", + devices: []Device{ + {WattsIdle: 500, WattsTypical: 500, WattsPeak: 500, DutyCycle: 1.0}, + }, + vent: VentilationParams{ + ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: -4, IndoorTempC: 23, RhoCp: 1.2, + }, + acBTUH: 0, + want: Marginal, + }, + { + name: "no AC, hot outdoor, delta >= 0", + devices: []Device{ + {WattsIdle: 500, WattsTypical: 500, WattsPeak: 500, DutyCycle: 1.0}, + }, + vent: VentilationParams{ + ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: 30, IndoorTempC: 25, RhoCp: 1.2, + }, + acBTUH: 0, + want: Overloaded, + }, + { + name: "no AC, warm outdoor, vent cannot solve massive gains", + devices: []Device{ + {WattsIdle: 3000, WattsTypical: 3000, WattsPeak: 3000, DutyCycle: 1.0}, + }, + vent: VentilationParams{ + ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: 20, IndoorTempC: 25, RhoCp: 1.2, + }, + acBTUH: 0, + want: Overloaded, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ComputeRoomBudget(BudgetInput{ + Devices: tt.devices, + DeviceMode: ModeIdle, + Solar: tt.solar, + Ventilation: tt.vent, + ACCapacityBTUH: tt.acBTUH, + }) + if result.Status != tt.want { + t.Errorf("Status = %v, want %v (totalW=%.1f, headroom=%.1f)", + result.Status, tt.want, result.TotalGainW, result.HeadroomBTUH) + } + }) + } +} diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go index 1c15577..c6ac390 100644 --- a/internal/llm/prompt.go +++ b/internal/llm/prompt.go @@ -11,7 +11,8 @@ Rules: - Reference ONLY the data provided below. Do not invent or assume additional information. - Use preparedness language (comfort, planning). Never give medical advice or diagnoses. - Each bullet: max 20 words, plain language, actionable insight. -- If peak temperature is below 22°C and risk level is "low", state simply that no heat risk is expected today. +- If peak temperature is below 22°C and risk level is "low", state simply that no heat risk is expected today. Focus on comfort rather than warnings. +- If budget status is "marginal" but peak temperature is below 25°C, this indicates minor internal heat gains easily managed by opening windows — not a cooling concern. - Format: "- [bullet text]" (markdown list)` const rewriteActionSystemPrompt = `You are a heat preparedness assistant. Rewrite the given technical action into a clear, friendly, plain-language instruction. @@ -30,6 +31,9 @@ Rules: - Valid impact values: low, medium, high - Be specific: reference temperatures, time windows, indoor/outdoor differentials, solar gain timing, and room orientations. - Use the coolMode data to recommend ventilation vs AC per time window. +- Valid coolMode values: "comfort" (outdoor well below indoor — no active cooling needed, building envelope handles it), "ventilate" (open windows to cool), "sealed" (too humid to ventilate, keep closed), "ac" (use AC), "overloaded" (AC cannot keep up with heat gains). +- Do NOT recommend opening windows or ventilation during "comfort" hours — the room is already comfortable. Only generate actions for hours where active intervention is needed. +- On transitional days where morning/evening hours show "comfort" but midday shows "ventilate" or "ac", focus actions on the warmer hours only. - If peak outdoor temperature is below 22°C and risk level is "low", return an empty array []. No cooling actions are needed on mild or cold days. - Generate 5-12 actions covering the most impactful strategies for the day. - Use preparedness language. Never give medical advice or diagnoses.` diff --git a/internal/llm/prompt_test.go b/internal/llm/prompt_test.go index bf3d37b..fde840e 100644 --- a/internal/llm/prompt_test.go +++ b/internal/llm/prompt_test.go @@ -147,6 +147,39 @@ func TestGenerateActionsSystemPrompt_ContainsLowRiskGuidance(t *testing.T) { } } +func TestGenerateActionsSystemPrompt_ContainsComfortMode(t *testing.T) { + p := GenerateActionsSystemPrompt() + if !strings.Contains(p, `"comfort"`) { + t.Error("system prompt should document comfort coolMode") + } + if !strings.Contains(p, "no active cooling needed") { + t.Error("system prompt should explain comfort mode meaning") + } +} + +func TestSummarizeSystemPrompt_ContainsMarginalColdGuidance(t *testing.T) { + p := SummarizeSystemPrompt() + if !strings.Contains(p, "marginal") { + t.Error("system prompt should mention marginal status in cold weather") + } +} + +func TestBuildActionsPrompt_ComfortMode(t *testing.T) { + input := ActionsInput{ + Date: "2025-02-10", + IndoorTempC: 23.0, + PeakTempC: 6.0, + RiskLevel: "low", + Timeline: []ActionsTimelineSlot{ + {Hour: 8, TempC: -2, HumidityPct: 50, BudgetStatus: "marginal", CoolMode: "comfort", GainsW: 300}, + }, + } + p := BuildActionsPrompt(input) + if !strings.Contains(p, "comfort") { + t.Error("actions prompt should include comfort coolMode from timeline") + } +} + func TestSummarizeSystemPrompt_ContainsLowRiskGuidance(t *testing.T) { p := SummarizeSystemPrompt() if !strings.Contains(p, "below 22°C") { diff --git a/web/i18n/de.json b/web/i18n/de.json index 4b2113d..23c69ee 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -183,12 +183,21 @@ "effort": "Aufwand", "impact": "Wirkung", "aiDisclaimer": "KI-generierte Zusammenfassung. Kein Ersatz für professionelle Beratung.", + "coolComfort": "Keine Kühlung nötig", "coolVentilate": "Fenster öffnen", "coolAC": "Klimaanlage", "coolOverloaded": "Klima überlastet", + "coolSealed": "Geschlossen halten", "aiActions": "KI-empfohlene Maßnahmen", "legendTemp": "Temperatur", "legendCooling": "Kühlung", + "refreshForecast": "Vorhersage aktualisieren", + "refreshing": "Aktualisierung\u2026", + "forecastRefreshed": "Vorhersage aktualisiert", + "quickSettings": "Schnelleinstellungen", + "qsIndoorTemp": "Raumtemperatur (\u00b0C)", + "qsIndoorHumidity": "Luftfeuchtigkeit (%)", + "qsApply": "Anwenden", "legendAI": "KI-Maßnahmen", "category": { "shading": "Verschattung", diff --git a/web/i18n/en.json b/web/i18n/en.json index 5b1a546..4c416f8 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -183,12 +183,21 @@ "effort": "Effort", "impact": "Impact", "aiDisclaimer": "AI-generated summary. Not a substitute for professional advice.", + "coolComfort": "No cooling needed", "coolVentilate": "Open windows", "coolAC": "AC cooling", "coolOverloaded": "AC overloaded", + "coolSealed": "Keep sealed", "aiActions": "AI-recommended actions", "legendTemp": "Temperature", "legendCooling": "Cooling", + "refreshForecast": "Refresh Forecast", + "refreshing": "Refreshing\u2026", + "forecastRefreshed": "Forecast refreshed", + "quickSettings": "Quick Settings", + "qsIndoorTemp": "Indoor Temp (\u00b0C)", + "qsIndoorHumidity": "Indoor Humidity (%)", + "qsApply": "Apply", "legendAI": "AI Actions", "category": { "shading": "Shading", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index db72922..26adc00 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -66,7 +66,9 @@ if (temp >= 30) return "#facc15"; if (temp >= 25) return "#fde68a"; if (temp >= 20) return "#bbf7d0"; - return "#bfdbfe"; + if (temp >= 10) return "#bfdbfe"; + if (temp >= 0) return "#93c5fd"; + return "#6366f1"; } function isDark() { @@ -119,6 +121,7 @@ hide("loading"); show("data-display"); renderDashboard(data); + initQuickSettings(); // LLM credentials (shared between summary and actions) const llmProvider = await getSetting("llmProvider"); @@ -464,9 +467,11 @@ // ========== Heatmap Timeline ========== const coolModeColors = { + comfort: "#6ee7b7", ventilate: "#38bdf8", ac: "#4ade80", overloaded: "#f87171", + sealed: "#a78bfa", }; const categoryColors = { @@ -479,9 +484,11 @@ }; const coolModeLabels = () => ({ + comfort: t().coolComfort || "No cooling needed", ventilate: t().coolVentilate || "Open windows", ac: t().coolAC || "AC cooling", overloaded: t().coolOverloaded || "AC overloaded", + sealed: t().coolSealed || "Keep sealed", }); function renderTimelineHeatmap(timeline) { @@ -499,7 +506,7 @@ // Temp cells const tempCellsHtml = timeline.map((s, i) => { const color = tempColorHex(s.tempC); - const textColor = s.tempC >= 35 ? "white" : "#1f2937"; + const textColor = (s.tempC >= 35 || s.tempC < 0) ? "white" : "#1f2937"; return `
` + `
`; }).join(""); @@ -531,7 +538,7 @@ const modeLabel = labels[slot.coolMode] || slot.coolMode || ""; let tooltipHtml = `
${slot.hourStr}
-
${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH
+
${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}
${slot.budgetStatus} \u00b7 ${esc(modeLabel)}
`; const hourActions = _hourActionMap && _hourActionMap[slot.hour]; @@ -587,11 +594,13 @@ // Temperature scale const tempSteps = [ - { label: "<20", color: "#bfdbfe" }, - { label: "20", color: "#bbf7d0" }, - { label: "25", color: "#fde68a" }, - { label: "30", color: "#facc15" }, - { label: "35", color: "#f97316" }, + { label: "<0", color: "#6366f1" }, + { label: "0", color: "#93c5fd" }, + { label: "10", color: "#bfdbfe" }, + { label: "20", color: "#bbf7d0" }, + { label: "25", color: "#fde68a" }, + { label: "30", color: "#facc15" }, + { label: "35", color: "#f97316" }, { label: "40+", color: "#dc2626" }, ]; let html = `
`; @@ -669,6 +678,94 @@ container.appendChild(row); } + // ========== Forecast Refresh ========== + async function refreshForecast() { + const btn = $("refresh-forecast-btn"); + const icon = $("refresh-icon"); + if (!btn) return; + + btn.disabled = true; + icon.classList.add("animate-spin"); + + try { + const profileId = await getActiveProfileId(); + if (!profileId) return; + + await fetchForecastForProfile(profileId); + + // Reset LLM/AI state so loadDashboard triggers fresh calls + _hourActionMap = null; + _currentTimeline = null; + _qsInitialized = false; + + await loadDashboard(); + } catch (err) { + console.error("Forecast refresh error:", err); + } finally { + btn.disabled = false; + icon.classList.remove("animate-spin"); + } + } + + // Attach handler after DOM is ready + const refreshBtn = $("refresh-forecast-btn"); + if (refreshBtn) refreshBtn.addEventListener("click", refreshForecast); + + // ========== Quick Settings ========== + let _qsInitialized = false; + async function initQuickSettings() { + if (_qsInitialized) return; + _qsInitialized = true; + + const toggle = $("qs-toggle"); + const body = $("qs-body"); + const chevron = $("qs-chevron"); + if (!toggle || !body) return; + + toggle.addEventListener("click", () => { + body.classList.toggle("hidden"); + chevron.style.transform = body.classList.contains("hidden") ? "" : "rotate(180deg)"; + }); + + // Load current values from first room + try { + const profileId = await getActiveProfileId(); + if (profileId) { + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + if (rooms.length > 0) { + const r = rooms[0]; + if (r.indoorTempC) $("qs-indoor-temp").value = r.indoorTempC; + if (r.indoorHumidityPct) $("qs-indoor-humidity").value = r.indoorHumidityPct; + } + } + } catch (_) { /* ignore */ } + + $("qs-apply").addEventListener("click", async () => { + const tempVal = parseFloat($("qs-indoor-temp").value); + const humVal = parseFloat($("qs-indoor-humidity").value); + try { + const profileId = await getActiveProfileId(); + if (!profileId) return; + const rooms = await dbGetByIndex("rooms", "profileId", profileId); + for (const room of rooms) { + if (!isNaN(tempVal) && tempVal >= 15 && tempVal <= 35) room.indoorTempC = tempVal; + if (!isNaN(humVal) && humVal >= 20 && humVal <= 95) { + room.indoorHumidityPct = humVal; + } else { + delete room.indoorHumidityPct; + } + await dbPut("rooms", room); + } + _qsInitialized = false; + _hourActionMap = null; + _currentTimeline = null; + loadDashboard(); + } catch (e) { + console.error("Quick settings apply error:", e); + } + }); + } + function esc(s) { if (!s) return ""; const div = document.createElement("div"); diff --git a/web/js/db.js b/web/js/db.js index a3a0e03..4f7f3d2 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -180,6 +180,72 @@ async function setActiveProfileId(id) { await setSetting("activeProfileId", id); } +// Fetch forecast + warnings for a profile and store in IndexedDB. +// Returns { forecasts: number, warnings: number } counts. +async function fetchForecastForProfile(profileId) { + const profiles = await dbGetAll("profiles"); + const profile = profiles.find(p => p.id === profileId); + if (!profile) throw new Error("Profile not found"); + + // Fetch forecast + const resp = await fetch("/api/weather/forecast", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + lat: profile.latitude, + lon: profile.longitude, + timezone: profile.timezone || "Europe/Berlin", + }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + + // Clear old forecasts and store new ones + await deleteByIndex("forecasts", "profileId", profileId); + const hourly = data.Hourly || data.hourly || []; + for (const h of hourly) { + await dbAdd("forecasts", { + profileId, + timestamp: h.Timestamp || h.timestamp, + temperatureC: h.TemperatureC ?? h.temperatureC ?? null, + humidityPct: h.HumidityPct ?? h.humidityPct ?? null, + cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null, + sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null, + apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null, + pressureHpa: h.PressureHpa ?? h.pressureHpa ?? null, + }); + } + + // Fetch warnings (optional — don't fail if this errors) + let warningCount = 0; + try { + const wResp = await fetch("/api/weather/warnings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }), + }); + if (wResp.ok) { + const wData = await wResp.json(); + await deleteByIndex("warnings", "profileId", profileId); + for (const w of (wData.warnings || [])) { + await dbAdd("warnings", { + profileId, + headline: w.Headline || w.headline || "", + severity: w.Severity || w.severity || "", + description: w.Description || w.description || "", + instruction: w.Instruction || w.instruction || "", + onset: w.Onset || w.onset || "", + expires: w.Expires || w.expires || "", + }); + warningCount++; + } + } + } catch (_) { /* warnings are optional */ } + + await setSetting("lastFetched", new Date().toISOString()); + return { forecasts: hourly.length, warnings: warningCount }; +} + // Build full compute payload from IndexedDB async function getComputePayload(profileId, dateStr) { const profiles = await dbGetAll("profiles"); @@ -237,6 +303,7 @@ async function getComputePayload(profileId, dateStr) { shgc: r.shgc || 0.6, insulation: r.insulation || "average", indoorTempC: r.indoorTempC || 0, + indoorHumidityPct: r.indoorHumidityPct || null, })), devices: allDevices.map(d => ({ id: d.id, @@ -276,6 +343,7 @@ async function getComputePayload(profileId, dateStr) { cloudCoverPct: f.cloudCoverPct ?? null, sunshineMin: f.sunshineMin ?? null, apparentTempC: f.apparentTempC ?? null, + pressureHpa: f.pressureHpa ?? null, })), warnings: warnings.map(w => ({ headline: w.headline || "", diff --git a/web/js/setup.js b/web/js/setup.js index 9e3095c..4612411 100644 --- a/web/js/setup.js +++ b/web/js/setup.js @@ -568,9 +568,6 @@ document.getElementById("fetch-forecast-btn").addEventListener("click", async () => { const profileId = await getActiveProfileId(); if (!profileId) { showToast("Select a profile first", true); return; } - const profiles = await dbGetAll("profiles"); - const profile = profiles.find(p => p.id === profileId); - if (!profile) return; const btn = document.getElementById("fetch-forecast-btn"); const spinner = document.getElementById("forecast-spinner"); @@ -578,59 +575,7 @@ spinner.classList.remove("hidden"); try { - const resp = await fetch("/api/weather/forecast", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - lat: profile.latitude, - lon: profile.longitude, - timezone: profile.timezone || "Europe/Berlin", - }), - }); - if (!resp.ok) throw new Error(await resp.text()); - const data = await resp.json(); - - // Clear old forecasts for this profile - await deleteByIndex("forecasts", "profileId", profileId); - - // Store hourly forecasts - for (const h of (data.Hourly || data.hourly || [])) { - await dbAdd("forecasts", { - profileId, - timestamp: h.Timestamp || h.timestamp, - temperatureC: h.TemperatureC ?? h.temperatureC ?? null, - humidityPct: h.HumidityPct ?? h.humidityPct ?? null, - cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null, - sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null, - apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null, - }); - } - - // Also fetch warnings - try { - const wResp = await fetch("/api/weather/warnings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }), - }); - if (wResp.ok) { - const wData = await wResp.json(); - await deleteByIndex("warnings", "profileId", profileId); - for (const w of (wData.warnings || [])) { - await dbAdd("warnings", { - profileId, - headline: w.Headline || w.headline || "", - severity: w.Severity || w.severity || "", - description: w.Description || w.description || "", - instruction: w.Instruction || w.instruction || "", - onset: w.Onset || w.onset || "", - expires: w.Expires || w.expires || "", - }); - } - } - } catch (e) { /* warnings are optional */ } - - await setSetting("lastFetched", new Date().toISOString()); + await fetchForecastForProfile(profileId); document.getElementById("last-fetched").textContent = new Date().toLocaleString(); showToast("Forecast fetched", false); } catch (err) { diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index b9db126..e4064a5 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -43,7 +43,34 @@

{{t "dashboard.title"}}

- +
+ + +
+
+ + +
+ +
@@ -152,10 +179,16 @@ totalGain: "{{t "dashboard.totalGain"}}", acCapacity: "{{t "dashboard.acCapacity"}}", headroom: "{{t "dashboard.headroom"}}", + coolComfort: "{{t "dashboard.coolComfort"}}", coolVentilate: "{{t "dashboard.coolVentilate"}}", coolAC: "{{t "dashboard.coolAC"}}", coolOverloaded: "{{t "dashboard.coolOverloaded"}}", + coolSealed: "{{t "dashboard.coolSealed"}}", aiActions: "{{t "dashboard.aiActions"}}", + quickSettings: "{{t "dashboard.quickSettings"}}", + qsIndoorTemp: "{{t "dashboard.qsIndoorTemp"}}", + qsIndoorHumidity: "{{t "dashboard.qsIndoorHumidity"}}", + qsApply: "{{t "dashboard.qsApply"}}", legendTemp: "{{t "dashboard.legendTemp"}}", legendCooling: "{{t "dashboard.legendCooling"}}", legendAI: "{{t "dashboard.legendAI"}}",