diff --git a/internal/compute/compute.go b/internal/compute/compute.go index c32797c..14e5e8d 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -82,6 +82,7 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { GeneratedAt: time.Now(), ProfileName: req.Profile.Name, Date: req.Date, + Timezone: req.Profile.Timezone, RiskLevel: dayRisk.Level.String(), PeakTempC: dayRisk.PeakTempC, MinNightTempC: dayRisk.MinNightTempC, diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go index 738c41c..781927d 100644 --- a/internal/compute/compute_test.go +++ b/internal/compute/compute_test.go @@ -505,6 +505,10 @@ func TestBuildDashboard_CoolModeComfort(t *testing.T) { } } + if data.Timezone != "UTC" { + t.Errorf("got Timezone %q, want %q", data.Timezone, "UTC") + } + // Room budget should be marginal (not overloaded) — no AC but ventilation can solve if len(data.RoomBudgets) == 0 { t.Fatal("expected room budgets") diff --git a/internal/compute/types.go b/internal/compute/types.go index a274433..8837dd4 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -109,6 +109,7 @@ type DashboardData struct { GeneratedAt time.Time `json:"generatedAt"` ProfileName string `json:"profileName"` Date string `json:"date"` + Timezone string `json:"timezone"` RiskLevel string `json:"riskLevel"` PeakTempC float64 `json:"peakTempC"` MinNightTempC float64 `json:"minNightTempC"` diff --git a/web/i18n/de.json b/web/i18n/de.json index 23c69ee..a453445 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -183,6 +183,7 @@ "effort": "Aufwand", "impact": "Wirkung", "aiDisclaimer": "KI-generierte Zusammenfassung. Kein Ersatz für professionelle Beratung.", + "riskComfort": "Komfortabel", "coolComfort": "Keine Kühlung nötig", "coolVentilate": "Fenster öffnen", "coolAC": "Klimaanlage", diff --git a/web/i18n/en.json b/web/i18n/en.json index 4c416f8..f72b0dc 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -183,6 +183,7 @@ "effort": "Effort", "impact": "Impact", "aiDisclaimer": "AI-generated summary. Not a substitute for professional advice.", + "riskComfort": "Comfortable", "coolComfort": "No cooling needed", "coolVentilate": "Open windows", "coolAC": "AC cooling", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 26adc00..ea3a52e 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -7,12 +7,15 @@ let _hourActionMap = null; let _currentTimeline = null; + let _currentTimezone = null; + let _currentDashDate = null; function show(id) { $(id).classList.remove("hidden"); } function hide(id) { $(id).classList.add("hidden"); } const riskColors = { low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" }, + comfort: { bg: "bg-teal-100 dark:bg-teal-900", text: "text-teal-700 dark:text-teal-300", border: "border-teal-500" }, moderate: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300", border: "border-yellow-500" }, high: { bg: "bg-orange-100 dark:bg-orange-900", text: "text-orange-700 dark:text-orange-300", border: "border-orange-500" }, extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" }, @@ -20,6 +23,7 @@ const riskGradients = { low: { from: "#f0fdf4", to: "#dcfce7", darkFrom: "#052e16", darkTo: "#064e3b" }, + comfort: { from: "#f0fdfa", to: "#ccfbf1", darkFrom: "#042f2e", darkTo: "#134e4a" }, moderate: { from: "#fefce8", to: "#fef9c3", darkFrom: "#422006", darkTo: "#3f3f00" }, high: { from: "#fff7ed", to: "#ffedd5", darkFrom: "#431407", darkTo: "#7c2d12" }, extreme: { from: "#fef2f2", to: "#fecaca", darkFrom: "#450a0a", darkTo: "#7f1d1d" }, @@ -262,9 +266,13 @@ function renderDashboard(data) { $("profile-name").textContent = data.profileName + " \u2014 " + data.date; + // Detect comfort day: cold weather, low risk + const isComfort = data.peakTempC < 22 && data.riskLevel === "low"; + const riskKey = isComfort ? "comfort" : data.riskLevel; + // Risk card with gradient - const rc = riskColors[data.riskLevel] || riskColors.low; - const rg = riskGradients[data.riskLevel] || riskGradients.low; + const rc = riskColors[riskKey] || riskColors.low; + const rg = riskGradients[riskKey] || riskGradients.low; const dark = isDark(); const gradFrom = dark ? rg.darkFrom : rg.from; const gradTo = dark ? rg.darkTo : rg.to; @@ -273,9 +281,10 @@ riskCard.style.background = `linear-gradient(135deg, ${gradFrom}, ${gradTo})`; const shieldSvg = ''; - $("risk-icon").innerHTML = `${shieldSvg}`; + const checkSvg = ''; + $("risk-icon").innerHTML = `${isComfort ? checkSvg : shieldSvg}`; $("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`; - $("risk-level").textContent = data.riskLevel; + $("risk-level").textContent = isComfort ? (t().riskComfort || "Comfortable") : data.riskLevel; // Peak temp $("peak-temp").textContent = data.peakTempC.toFixed(1) + "\u00b0C"; @@ -323,7 +332,7 @@ // Timeline heatmap if (data.timeline && data.timeline.length > 0) { - renderTimelineHeatmap(data.timeline); + renderTimelineHeatmap(data.timeline, data.timezone, data.date); } // Room budgets @@ -491,14 +500,31 @@ sealed: t().coolSealed || "Keep sealed", }); - function renderTimelineHeatmap(timeline) { + function renderTimelineHeatmap(timeline, timezone, dashDate) { _currentTimeline = timeline; + _currentTimezone = timezone || null; + _currentDashDate = dashDate || null; const container = $("timeline-chart"); const tooltip = $("timeline-tooltip"); const labels = coolModeLabels(); - // Hour labels (every 3h) + // Determine current hour in profile timezone (only if dashboard date is today) + let currentHour = -1; + if (timezone && dashDate) { + try { + const now = new Date(); + const todayInTz = now.toLocaleDateString("en-CA", { timeZone: timezone }); + if (todayInTz === dashDate) { + const hourStr = now.toLocaleString("en-US", { timeZone: timezone, hour12: false, hour: "2-digit" }); + currentHour = parseInt(hourStr, 10); + } + } catch (_) { /* invalid timezone, skip */ } + } + + // Hour labels (every 3h) + current hour marker const hourLabelsHtml = timeline.map(s => { + const isCurrent = s.hour === currentHour; + if (isCurrent) return `