feat: fix cold-weather thermal logic, add comfort mode, and dashboard forecast refresh

- Fix ComputeRoomBudget: no-AC rooms check if open-window ventilation
  can offset gains instead of defaulting to Overloaded. Net-cooling
  rooms are now Comfortable; ventilation-solvable rooms are Marginal.
- Add "comfort" cool mode for hours where outdoor is >5°C below indoor
  and budget is not overloaded (winter/cold scenarios).
- Reorder determineCoolMode: sealed now before overloaded, fixing
  humid+cold+no-AC giving "overloaded" instead of "sealed".
- Update LLM prompts: document comfort coolMode, add cold-weather
  guidance for summary and actions generation.
- Add dashboard forecast refresh button: fetches fresh forecast +
  warnings, then re-runs compute and LLM pipelines.
- Extract forecast fetch into shared fetchForecastForProfile() in db.js,
  deduplicating logic between setup.js and dashboard.js.
- Add indoor humidity support, pressure display, and cool mode sealed
  integration test.
This commit is contained in:
2026-02-10 04:26:53 +01:00
parent 5e6696aa42
commit 84d645ff21
13 changed files with 592 additions and 78 deletions

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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"`

View File

@@ -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{

View File

@@ -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)
}
})
}
}

View File

@@ -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.`

View File

@@ -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") {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 `<div class="hm-temp-cell flex items-center justify-center rounded-sm cursor-pointer" data-idx="${i}" style="background:${color};color:${textColor};height:48px">`
+ `<span class="hm-temp-label hidden sm:inline text-xs font-medium">${Math.round(s.tempC)}</span></div>`;
}).join("");
@@ -531,7 +538,7 @@
const modeLabel = labels[slot.coolMode] || slot.coolMode || "";
let tooltipHtml = `
<div class="font-medium mb-1">${slot.hourStr}</div>
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}</div>
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
`;
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 = `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
@@ -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");

View File

@@ -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 || "",

View File

@@ -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) {

View File

@@ -43,7 +43,34 @@
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">{{t "dashboard.title"}}</h1>
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
<div class="flex items-center gap-3">
<button id="refresh-forecast-btn" type="button" title="{{t "dashboard.refreshForecast"}}" class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<svg id="refresh-icon" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
<span class="hidden sm:inline">{{t "dashboard.refreshForecast"}}</span>
</button>
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</div>
<!-- Quick Settings -->
<div id="quick-settings" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
<button id="qs-toggle" type="button" class="w-full flex items-center justify-between px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<span>{{t "dashboard.quickSettings"}}</span>
<svg id="qs-chevron" class="w-4 h-4 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div id="qs-body" class="hidden px-4 pb-3">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.qsIndoorTemp"}}</label>
<input id="qs-indoor-temp" type="number" step="0.5" min="15" max="35" class="w-24 px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.qsIndoorHumidity"}}</label>
<input id="qs-indoor-humidity" type="number" step="1" min="20" max="95" placeholder="50" class="w-24 px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600">
</div>
<button id="qs-apply" type="button" class="px-3 py-1 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 transition">{{t "dashboard.qsApply"}}</button>
</div>
</div>
</div>
<!-- Warnings -->
@@ -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"}}",