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.
This commit is contained in:
2026-02-10 03:54:09 +01:00
parent 277d1c949f
commit 5e6696aa42
16 changed files with 492 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = `
<div class="font-medium mb-1">${slot.hourStr}</div>
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
`;
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 += `<div class="border-t border-gray-600 mt-1.5 pt-1.5">`;
shown.forEach(a => {
const color = categoryColors[a.category] || "#9ca3af";
tooltipHtml += `<div class="flex items-center gap-1.5"><span class="inline-block rounded-full flex-shrink-0" style="width:5px;height:5px;background:${color}"></span><span>${esc(a.name)}</span></div>`;
});
if (remaining > 0) {
tooltipHtml += `<div class="text-gray-400">+${remaining} more</div>`;
}
tooltipHtml += `</div>`;
}
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 `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
}).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 = `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendTemp || "Temperature")}</span>`;
html += tempSteps.map(s =>
`<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-sm" style="background:${s.color}"></span>${s.label}</span>`
).join("");
html += `</div>`;
// Cooling modes
const usedModes = [...new Set((timeline || []).map(s => s.coolMode || "ac"))];
html += `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendCooling || "Cooling")}</span>`;
html += usedModes.map(mode => {
const color = coolModeColors[mode] || "#d1d5db";
const lbl = labels[mode] || mode;
return `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
}).join("");
html += `</div>`;
// 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 += `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendAI || "AI Actions")}</span>`;
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 `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
}).join("");
html += `</div>`;
}
}
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 `<div style="height:18px"></div>`;
}
const cats = [...new Set(actions.map(a => a.category).filter(Boolean))];
const dots = cats.map(cat => {
const color = categoryColors[cat] || "#9ca3af";
return `<span class="inline-block rounded-full" style="width:5px;height:5px;background:${color}"></span>`;
}).join("");
return `<div class="flex items-center justify-center gap-px flex-wrap" style="height:18px">${dots}</div>`;
}).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) {

View File

@@ -83,7 +83,7 @@
<div class="relative">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.timeline"}}</h2>
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
<div id="cooling-legend" class="flex gap-4 text-xs text-gray-400 mt-1"></div>
<div id="cooling-legend" class="flex flex-col gap-1 text-xs text-gray-400 mt-2"></div>
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
</div>
@@ -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"}}",