From 5e6696aa42da9c1d26a2495f0249d972e970ed32 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 10 Feb 2026 03:54:09 +0100 Subject: [PATCH] feat: add AI-powered actions endpoint and timeline annotations Add LLM actions endpoint that generates hour-specific heat management recommendations. Replace static action engine with AI-driven approach. Add cool mode logic (ventilate/ac/overloaded), indoor temperature tracking, and timeline legend with annotations. --- internal/compute/compute.go | 44 ++++----- internal/compute/compute_test.go | 107 +++++++++++++++++++++ internal/compute/types.go | 3 + internal/llm/anthropic.go | 4 + internal/llm/gemini.go | 4 + internal/llm/llm_test.go | 4 + internal/llm/noop.go | 3 +- internal/llm/ollama.go | 4 + internal/llm/openai.go | 4 + internal/llm/provider.go | 33 +++++++ internal/server/api.go | 155 +++++++++++++++++++++++++++++++ internal/server/server.go | 1 + web/i18n/de.json | 3 + web/i18n/en.json | 3 + web/js/dashboard.js | 148 +++++++++++++++++++++++++++-- web/templates/dashboard.html | 5 +- 16 files changed, 492 insertions(+), 33 deletions(-) diff --git a/internal/compute/compute.go b/internal/compute/compute.go index 641f54d..0a953f9 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/cnachtigall/heatwave-autopilot/internal/action" "github.com/cnachtigall/heatwave-autopilot/internal/heat" "github.com/cnachtigall/heatwave-autopilot/internal/risk" ) @@ -45,14 +44,25 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { th := risk.DefaultThresholds() dayRisk := risk.AnalyzeDay(hourlyData, th) - // Load actions - actions, _ := action.LoadDefaultActions() - toggles := req.Toggles if toggles == nil { toggles = map[string]bool{} } + // Compute representative indoor temperature (average across rooms) + indoorTempC := 25.0 + if len(req.Rooms) > 0 { + sum := 0.0 + for _, r := range req.Rooms { + t := r.IndoorTempC + if t == 0 { + t = 25.0 + } + sum += t + } + indoorTempC = sum / float64(len(req.Rooms)) + } + data := DashboardData{ GeneratedAt: time.Now(), ProfileName: req.Profile.Name, @@ -61,6 +71,7 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { PeakTempC: dayRisk.PeakTempC, MinNightTempC: dayRisk.MinNightTempC, PoorNightCool: dayRisk.PoorNightCool, + IndoorTempC: indoorTempC, } // Warnings (pass-through from client) @@ -102,16 +113,12 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) - ctx := action.HourContext{ - Hour: h.Hour, - TempC: h.TempC, - HumidityPct: h.HumidityPct, - IsDay: h.IsDay, - RiskLevel: dayRisk.Level, - BudgetStatus: worstStatus, - ActiveToggles: toggles, + coolMode := "ac" + if h.TempC < indoorTempC { + coolMode = "ventilate" + } else if worstStatus == heat.Overloaded { + coolMode = "overloaded" } - matched := action.SelectActions(actions, ctx) slot := TimelineSlotData{ Hour: h.Hour, @@ -120,15 +127,8 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { HumidityPct: h.HumidityPct, RiskLevel: dayRisk.Level.String(), BudgetStatus: worstStatus.String(), - } - for _, a := range matched { - slot.Actions = append(slot.Actions, ActionData{ - Name: a.Name, - Category: string(a.Category), - Effort: string(a.Effort), - Impact: string(a.Impact), - Description: a.Description, - }) + IndoorTempC: indoorTempC, + CoolMode: coolMode, } data.Timeline = append(data.Timeline, slot) diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go index bb442fa..14928a2 100644 --- a/internal/compute/compute_test.go +++ b/internal/compute/compute_test.go @@ -218,6 +218,113 @@ func TestBuildDashboard_InvalidDate(t *testing.T) { } } +func TestBuildDashboard_CoolModeVentilate(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // All hours at 20°C (below default indoor 25°C) → ventilate + temps := make([]float64, 24) + for i := range temps { + temps[i] = 20 + } + + 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, IndoorTempC: 25}}, + 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 != "ventilate" { + t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ventilate") + } + } + if data.IndoorTempC != 25 { + t.Errorf("got IndoorTempC %v, want 25", data.IndoorTempC) + } +} + +func TestBuildDashboard_CoolModeAC(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // All hours at 30°C (above indoor 25°C), with enough AC → "ac" + temps := make([]float64, 24) + for i := range temps { + temps[i] = 30 + } + + 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, 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 != "ac" { + t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ac") + } + } +} + +func TestBuildDashboard_CoolModeOverloaded(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // Hot temps, no AC → overloaded (heat gains exceed 0 AC capacity) + temps := make([]float64, 24) + for i := range temps { + temps[i] = 38 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ + ID: 1, Name: "Room", AreaSqm: 20, CeilingHeightM: 2.5, + Orientation: "S", ShadingFactor: 1, VentilationACH: 2.0, + WindowFraction: 0.3, SHGC: 0.8, IndoorTempC: 25, + }}, + Devices: []Device{{ + ID: 1, RoomID: 1, Name: "PC", + WattsIdle: 100, WattsTypical: 400, WattsPeak: 600, DutyCycle: 1.0, + }}, + Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 3, ActivityLevel: "moderate"}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hasOverloaded := false + for _, slot := range data.Timeline { + if slot.CoolMode == "overloaded" { + hasOverloaded = true + break + } + } + if !hasOverloaded { + t.Error("expected at least one hour with CoolMode 'overloaded' (no AC, high gains)") + } +} + 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 c1ae51c..fdf13ae 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -111,6 +111,7 @@ type DashboardData struct { PeakTempC float64 `json:"peakTempC"` MinNightTempC float64 `json:"minNightTempC"` PoorNightCool bool `json:"poorNightCool"` + IndoorTempC float64 `json:"indoorTempC"` Warnings []WarningData `json:"warnings"` RiskWindows []RiskWindowData `json:"riskWindows"` Timeline []TimelineSlotData `json:"timeline"` @@ -145,6 +146,8 @@ type TimelineSlotData struct { HumidityPct float64 `json:"humidityPct"` RiskLevel string `json:"riskLevel"` BudgetStatus string `json:"budgetStatus"` + IndoorTempC float64 `json:"indoorTempC"` + CoolMode string `json:"coolMode"` Actions []ActionData `json:"actions"` } diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index bf385fd..8a89953 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -104,3 +104,7 @@ func (a *Anthropic) RewriteAction(ctx context.Context, input ActionInput) (strin func (a *Anthropic) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return a.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input), 2000) } + +func (a *Anthropic) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return a.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input), 1500) +} diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 8b5cb1d..cec8da5 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -107,3 +107,7 @@ func (g *Gemini) RewriteAction(ctx context.Context, input ActionInput) (string, func (g *Gemini) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return g.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input)) } + +func (g *Gemini) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return g.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input)) +} diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go index c6ad98a..810e777 100644 --- a/internal/llm/llm_test.go +++ b/internal/llm/llm_test.go @@ -25,6 +25,10 @@ func TestNoopProvider(t *testing.T) { if err != nil || h != "" { t.Errorf("GenerateHeatPlan = (%q, %v), want empty", h, err) } + a, err := n.GenerateActions(context.Background(), ActionsInput{}) + if err != nil || a != "" { + t.Errorf("GenerateActions = (%q, %v), want empty", a, err) + } } func TestAnthropicProvider_MockServer(t *testing.T) { diff --git a/internal/llm/noop.go b/internal/llm/noop.go index ae1111a..7863329 100644 --- a/internal/llm/noop.go +++ b/internal/llm/noop.go @@ -9,4 +9,5 @@ func NewNoop() *Noop { func (n *Noop) Name() string { return "none" } func (n *Noop) Summarize(_ context.Context, _ SummaryInput) (string, error) { return "", nil } func (n *Noop) RewriteAction(_ context.Context, _ ActionInput) (string, error) { return "", nil } -func (n *Noop) GenerateHeatPlan(_ context.Context, _ HeatPlanInput) (string, error) { return "", nil } +func (n *Noop) GenerateHeatPlan(_ context.Context, _ HeatPlanInput) (string, error) { return "", nil } +func (n *Noop) GenerateActions(_ context.Context, _ ActionsInput) (string, error) { return "", nil } diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index 4bc13e9..fa55b03 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -97,3 +97,7 @@ func (o *Ollama) RewriteAction(ctx context.Context, input ActionInput) (string, func (o *Ollama) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input)) } + +func (o *Ollama) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return o.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input)) +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go index cb10698..96559c0 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -101,3 +101,7 @@ func (o *OpenAI) RewriteAction(ctx context.Context, input ActionInput) (string, func (o *OpenAI) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input)) } + +func (o *OpenAI) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return o.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input)) +} diff --git a/internal/llm/provider.go b/internal/llm/provider.go index c131d14..79d1db1 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -7,6 +7,7 @@ type Provider interface { Summarize(ctx context.Context, input SummaryInput) (string, error) RewriteAction(ctx context.Context, action ActionInput) (string, error) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) + GenerateActions(ctx context.Context, input ActionsInput) (string, error) Name() string } @@ -70,3 +71,35 @@ type HeatPlanInput struct { Actions []ActionSummary CareChecklist []string } + +// ActionsInput holds context for AI-generated actions. +type ActionsInput struct { + Date string + Language string + IndoorTempC float64 + PeakTempC float64 + MinNightTempC float64 + PoorNightCool bool + RiskLevel string + RiskWindows []RiskWindowSummary + Timeline []ActionsTimelineSlot + Rooms []ActionsRoom +} + +// ActionsTimelineSlot is one hour's data for AI action generation. +type ActionsTimelineSlot struct { + Hour int + TempC float64 + HumidityPct float64 + BudgetStatus string + CoolMode string + GainsW float64 +} + +// ActionsRoom describes a room for AI action generation. +type ActionsRoom struct { + Name string + Orientation string + ShadingType string + HasAC bool +} diff --git a/internal/server/api.go b/internal/server/api.go index f08c50e..ad518db 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -142,6 +142,161 @@ func (s *Server) handleLLMSummarize(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]string{"summary": summary}) } +type actionsRequest struct { + // Thermal context + Date string `json:"date"` + IndoorTempC float64 `json:"indoorTempC"` + PeakTempC float64 `json:"peakTempC"` + MinNightTempC float64 `json:"minNightTempC"` + PoorNightCool bool `json:"poorNightCool"` + RiskLevel string `json:"riskLevel"` + RiskWindows []struct { + StartHour int `json:"startHour"` + EndHour int `json:"endHour"` + PeakTempC float64 `json:"peakTempC"` + Level string `json:"level"` + } `json:"riskWindows"` + Timeline []struct { + Hour int `json:"hour"` + TempC float64 `json:"tempC"` + HumidityPct float64 `json:"humidityPct"` + BudgetStatus string `json:"budgetStatus"` + CoolMode string `json:"coolMode"` + } `json:"timeline"` + RoomBudgets []struct { + TotalGainW float64 `json:"totalGainW"` + } `json:"roomBudgets"` + // Room metadata + Rooms []struct { + Name string `json:"name"` + Orientation string `json:"orientation"` + ShadingType string `json:"shadingType"` + HasAC bool `json:"hasAC"` + } `json:"rooms"` + // LLM credentials + Provider string `json:"provider,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Model string `json:"model,omitempty"` + Language string `json:"language,omitempty"` +} + +func (s *Server) handleLLMActions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req actionsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + provider := s.llmProvider + if req.Provider != "" && req.APIKey != "" { + switch req.Provider { + case "anthropic": + provider = llm.NewAnthropic(req.APIKey, req.Model, nil) + case "openai": + provider = llm.NewOpenAI(req.APIKey, req.Model, nil) + case "gemini": + provider = llm.NewGemini(req.APIKey, req.Model, nil) + } + } + + if provider.Name() == "none" { + jsonResponse(w, map[string]any{"actions": []any{}}) + return + } + + // Build ActionsInput + input := llm.ActionsInput{ + Date: req.Date, + Language: req.Language, + IndoorTempC: req.IndoorTempC, + PeakTempC: req.PeakTempC, + MinNightTempC: req.MinNightTempC, + PoorNightCool: req.PoorNightCool, + RiskLevel: req.RiskLevel, + } + for _, rw := range req.RiskWindows { + input.RiskWindows = append(input.RiskWindows, llm.RiskWindowSummary{ + StartHour: rw.StartHour, + EndHour: rw.EndHour, + PeakTempC: rw.PeakTempC, + Level: rw.Level, + }) + } + for i, s := range req.Timeline { + gainsW := 0.0 + if i < len(req.RoomBudgets) { + gainsW = req.RoomBudgets[i].TotalGainW + } + input.Timeline = append(input.Timeline, llm.ActionsTimelineSlot{ + Hour: s.Hour, + TempC: s.TempC, + HumidityPct: s.HumidityPct, + BudgetStatus: s.BudgetStatus, + CoolMode: s.CoolMode, + GainsW: gainsW, + }) + } + for _, rm := range req.Rooms { + input.Rooms = append(input.Rooms, llm.ActionsRoom{ + Name: rm.Name, + Orientation: rm.Orientation, + ShadingType: rm.ShadingType, + HasAC: rm.HasAC, + }) + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + raw, err := provider.GenerateActions(ctx, input) + if err != nil { + jsonError(w, err.Error(), http.StatusBadGateway) + return + } + + // Parse JSON response defensively + var actions []json.RawMessage + if err := json.Unmarshal([]byte(raw), &actions); err != nil { + // Try extracting from ```json ... ``` fences + trimmed := raw + if start := findJSONStart(trimmed); start >= 0 { + trimmed = trimmed[start:] + } + if end := findJSONEnd(trimmed); end >= 0 { + trimmed = trimmed[:end+1] + } + if err2 := json.Unmarshal([]byte(trimmed), &actions); err2 != nil { + jsonError(w, "failed to parse AI actions response", http.StatusBadGateway) + return + } + } + + jsonResponse(w, map[string]any{"actions": actions}) +} + +func findJSONStart(s string) int { + for i, c := range s { + if c == '[' { + return i + } + } + return -1 +} + +func findJSONEnd(s string) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == ']' { + return i + } + } + return -1 +} + func (s *Server) handleLLMConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/server/server.go b/internal/server/server.go index ec32b2c..bb8ad7e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -87,6 +87,7 @@ func New(opts Options) (*Server, error) { s.mux.HandleFunc("/api/weather/forecast", s.handleWeatherForecast) s.mux.HandleFunc("/api/weather/warnings", s.handleWeatherWarnings) s.mux.HandleFunc("/api/llm/summarize", s.handleLLMSummarize) + s.mux.HandleFunc("/api/llm/actions", s.handleLLMActions) s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig) return s, nil diff --git a/web/i18n/de.json b/web/i18n/de.json index bcaba5c..4b2113d 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -187,6 +187,9 @@ "coolAC": "Klimaanlage", "coolOverloaded": "Klima überlastet", "aiActions": "KI-empfohlene Maßnahmen", + "legendTemp": "Temperatur", + "legendCooling": "Kühlung", + "legendAI": "KI-Maßnahmen", "category": { "shading": "Verschattung", "ventilation": "Lüftung", diff --git a/web/i18n/en.json b/web/i18n/en.json index b46aea8..5b1a546 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -187,6 +187,9 @@ "coolAC": "AC cooling", "coolOverloaded": "AC overloaded", "aiActions": "AI-recommended actions", + "legendTemp": "Temperature", + "legendCooling": "Cooling", + "legendAI": "AI Actions", "category": { "shading": "Shading", "ventilation": "Ventilation", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 4c94c96..db72922 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -5,6 +5,9 @@ const $ = (id) => document.getElementById(id); const t = () => window.HG.t || {}; + let _hourActionMap = null; + let _currentTimeline = null; + function show(id) { $(id).classList.remove("hidden"); } function hide(id) { $(id).classList.add("hidden"); } @@ -202,6 +205,11 @@ const actData = await actResp.json(); if (actData.actions && actData.actions.length > 0) { renderAIActions(actData.actions); + _hourActionMap = buildHourActionMap(actData.actions); + if (_currentTimeline) { + renderTimelineAnnotations(_currentTimeline, _hourActionMap); + renderTimelineLegend(_currentTimeline, _hourActionMap); + } } else { hide("actions-loading"); } @@ -461,6 +469,15 @@ overloaded: "#f87171", }; + const categoryColors = { + shading: "#f59e0b", + ventilation: "#38bdf8", + internal_gains: "#a78bfa", + ac_strategy: "#4ade80", + hydration: "#60a5fa", + care: "#fb7185", + }; + const coolModeLabels = () => ({ ventilate: t().coolVentilate || "Open windows", ac: t().coolAC || "AC cooling", @@ -468,6 +485,7 @@ }); function renderTimelineHeatmap(timeline) { + _currentTimeline = timeline; const container = $("timeline-chart"); const tooltip = $("timeline-tooltip"); const labels = coolModeLabels(); @@ -511,11 +529,28 @@ const idx = parseInt(cell.dataset.idx); const slot = timeline[idx]; const modeLabel = labels[slot.coolMode] || slot.coolMode || ""; - tooltip.innerHTML = ` + let tooltipHtml = `
${slot.hourStr}
${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH
${slot.budgetStatus} \u00b7 ${esc(modeLabel)}
`; + const hourActions = _hourActionMap && _hourActionMap[slot.hour]; + if (hourActions && hourActions.length > 0) { + const catLabels = (t().category) || {}; + const maxShow = 4; + const shown = hourActions.slice(0, maxShow); + const remaining = hourActions.length - maxShow; + tooltipHtml += `
`; + shown.forEach(a => { + const color = categoryColors[a.category] || "#9ca3af"; + tooltipHtml += `
${esc(a.name)}
`; + }); + if (remaining > 0) { + tooltipHtml += `
+${remaining} more
`; + } + tooltipHtml += `
`; + } + tooltip.innerHTML = tooltipHtml; const rect = cell.getBoundingClientRect(); const parentRect = tooltip.parentElement.getBoundingClientRect(); tooltip.style.left = (rect.left - parentRect.left + rect.width / 2 - 60) + "px"; @@ -527,16 +562,111 @@ }); container.addEventListener("mouseleave", () => tooltip.classList.add("hidden")); - // Legend - const usedModes = [...new Set(timeline.map(s => s.coolMode || "ac"))]; + // Legend (initial render without AI data) + renderTimelineLegend(timeline, _hourActionMap); + } + + // ========== AI Timeline Annotations ========== + function buildHourActionMap(actions) { + const map = {}; + (actions || []).forEach(a => { + (a.hours || []).forEach(h => { + if (!map[h]) map[h] = []; + map[h].push(a); + }); + }); + return Object.keys(map).length > 0 ? map : null; + } + + function renderTimelineLegend(timeline, hourActionMap) { const legend = $("cooling-legend"); - if (legend) { - legend.innerHTML = usedModes.map(mode => { - const color = coolModeColors[mode] || "#d1d5db"; - const lbl = labels[mode] || mode; - return `${esc(lbl)}`; - }).join(""); + if (!legend) return; + + const labels = coolModeLabels(); + const ts = t(); + + // 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: "40+", color: "#dc2626" }, + ]; + let html = `
`; + html += `${esc(ts.legendTemp || "Temperature")}`; + html += tempSteps.map(s => + `${s.label}` + ).join(""); + html += `
`; + + // Cooling modes + const usedModes = [...new Set((timeline || []).map(s => s.coolMode || "ac"))]; + html += `
`; + html += `${esc(ts.legendCooling || "Cooling")}`; + html += usedModes.map(mode => { + const color = coolModeColors[mode] || "#d1d5db"; + const lbl = labels[mode] || mode; + return `${esc(lbl)}`; + }).join(""); + html += `
`; + + // AI categories (only when hourActionMap has entries) + if (hourActionMap) { + const usedCats = new Set(); + Object.values(hourActionMap).forEach(actions => { + actions.forEach(a => { if (a.category) usedCats.add(a.category); }); + }); + if (usedCats.size > 0) { + const catLabels = ts.category || {}; + html += `
`; + html += `${esc(ts.legendAI || "AI Actions")}`; + const categoryOrder = ["shading", "ventilation", "internal_gains", "ac_strategy", "hydration", "care"]; + const sorted = [...usedCats].sort((a, b) => { + const ia = categoryOrder.indexOf(a); + const ib = categoryOrder.indexOf(b); + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); + }); + html += sorted.map(cat => { + const color = categoryColors[cat] || "#9ca3af"; + const lbl = catLabels[cat] || cat; + return `${esc(lbl)}`; + }).join(""); + html += `
`; + } } + + legend.innerHTML = html; + } + + function renderTimelineAnnotations(timeline, hourActionMap) { + if (!hourActionMap || !timeline) return; + const container = $("timeline-chart"); + if (!container) return; + + // Remove previous annotation row if any + const prev = container.querySelector(".hm-ai-row"); + if (prev) prev.remove(); + + const cellsHtml = timeline.map(s => { + const actions = hourActionMap[s.hour]; + if (!actions || actions.length === 0) { + return `
`; + } + const cats = [...new Set(actions.map(a => a.category).filter(Boolean))]; + const dots = cats.map(cat => { + const color = categoryColors[cat] || "#9ca3af"; + return ``; + }).join(""); + return `
${dots}
`; + }).join(""); + + const row = document.createElement("div"); + row.className = "hm-ai-row grid gap-px mt-px"; + row.style.gridTemplateColumns = `repeat(${timeline.length},minmax(0,1fr))`; + row.innerHTML = cellsHtml; + container.appendChild(row); } function esc(s) { diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index ef227e7..b9db126 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -83,7 +83,7 @@

{{t "dashboard.timeline"}}

-
+
@@ -156,6 +156,9 @@ coolAC: "{{t "dashboard.coolAC"}}", coolOverloaded: "{{t "dashboard.coolOverloaded"}}", aiActions: "{{t "dashboard.aiActions"}}", + legendTemp: "{{t "dashboard.legendTemp"}}", + legendCooling: "{{t "dashboard.legendCooling"}}", + legendAI: "{{t "dashboard.legendAI"}}", category: { shading: "{{t "dashboard.category.shading"}}", ventilation: "{{t "dashboard.category.ventilation"}}",