Files
HeatGuard/web/templates/dashboard.html
vikingowl b30c0b5f36 feat: add OpenWeatherMap provider, retry transport, and forecast config UI
Add OpenWeatherMap One Call API 3.0 as alternative weather provider with
configurable selection in the Forecast tab. Includes server-side retry
transport with exponential backoff for all weather providers, structured
error responses with type classification, auto-fetch on stale dashboard
data, and improved error UX with specific messages.
2026-02-11 01:02:48 +01:00

322 lines
20 KiB
HTML

{{define "content"}}
<div id="dashboard">
<!-- No data state -->
<div id="no-data" class="hidden">
<div class="text-center py-16">
<div class="text-6xl mb-4">🌡️</div>
<h1 class="text-2xl font-bold mb-2">{{t "dashboard.title"}}</h1>
<p class="text-gray-500 dark:text-gray-400 mb-6">{{t "dashboard.noData"}}</p>
<div class="flex gap-4 justify-center">
<a href="/guide" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.goToGuide"}}</a>
<a href="/setup" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">{{t "dashboard.goToSetup"}}</a>
</div>
</div>
</div>
<!-- No forecast state -->
<div id="no-forecast" class="hidden">
<div class="text-center py-16">
<h1 class="text-2xl font-bold mb-2">{{t "dashboard.title"}}</h1>
<p class="text-gray-500 dark:text-gray-400 mb-6">{{t "dashboard.fetchForecastFirst"}}</p>
<a href="/setup#forecast" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.goToSetup"}}</a>
</div>
</div>
<!-- Loading state -->
<div id="loading" class="hidden">
<div class="text-center py-16">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-2 border-orange-600 border-t-transparent mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">{{t "dashboard.computing"}}</p>
</div>
</div>
<!-- Error state -->
<div id="error-state" class="hidden">
<div class="text-center py-16">
<p id="error-message" class="text-red-600 dark:text-red-400 mb-2">{{t "dashboard.error"}}</p>
<p id="error-hint" class="text-sm text-gray-500 dark:text-gray-400 mb-4"></p>
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.retry"}}</button>
</div>
</div>
<!-- Stale data warning banner -->
<div id="stale-warning" class="hidden bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg px-4 py-3 mb-4 flex items-center justify-between">
<span class="text-sm text-yellow-700 dark:text-yellow-300">{{t "dashboard.staleDataWarning"}}</span>
<button id="stale-retry-btn" type="button" class="px-3 py-1 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition">{{t "dashboard.retry"}}</button>
</div>
<!-- Auto-fetch indicator -->
<div id="auto-fetch-indicator" class="hidden text-center py-2 text-sm text-gray-500 dark:text-gray-400">
<span class="inline-block animate-spin mr-1">&#x21bb;</span> {{t "dashboard.autoFetching"}}
</div>
<!-- Data display -->
<div id="data-display" class="hidden space-y-5">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">{{t "dashboard.title"}}</h1>
<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>
<select id="profile-switcher" class="hidden text-sm text-gray-500 dark:text-gray-400 bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1"></select>
<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 -->
<div id="warnings-section" class="hidden space-y-2">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.warnings"}}</h2>
<div id="warnings-list" class="space-y-2"></div>
</div>
<!-- Two-column: Main content + Sidebar -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<!-- Left column: cards, risk windows, timeline, actions -->
<div class="lg:col-span-2 space-y-5">
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div id="risk-card" class="rounded-xl p-4 text-center transition-all duration-200 hover:shadow-md">
<div id="risk-icon" class="mb-1"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.riskLevel"}}</div>
<div id="risk-level" class="text-2xl font-bold"></div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-orange-400 transition-all duration-200 hover:shadow-md">
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6m0 0a6 6 0 106 6V8a6 6 0 10-6 0z"/></svg></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.peakTemp"}}</div>
<div id="peak-temp" class="text-2xl font-bold"></div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-blue-400 transition-all duration-200 hover:shadow-md">
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.minNightTemp"}}</div>
<div id="min-night-temp" class="text-2xl font-bold"></div>
<div id="poor-night-cool" class="hidden text-xs text-orange-600 dark:text-orange-400 mt-1">{{t "dashboard.poorNightCool"}}</div>
</div>
</div>
<!-- Risk windows -->
<div id="risk-windows-section" class="hidden">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.riskWindows"}}</h2>
<div id="risk-windows" class="space-y-2"></div>
</div>
<!-- Timeline heatmap -->
<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 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>
<!-- AI Actions -->
<div id="actions-section" class="hidden">
<div class="flex items-center gap-2 mb-3">
<h2 class="text-lg font-semibold">{{t "dashboard.actions"}}</h2>
<span id="actions-badge" class="hidden text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">AI</span>
</div>
<div id="actions-list" class="space-y-4"></div>
</div>
<!-- Actions loading skeleton (shown while AI is loading) -->
<div id="actions-loading" class="hidden">
<div class="flex items-center gap-2 mb-3">
<h2 class="text-lg font-semibold">{{t "dashboard.actions"}}</h2>
<div class="inline-block animate-spin rounded-full h-4 w-4 border-2 border-purple-500 border-t-transparent"></div>
</div>
<div class="space-y-3">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm animate-pulse"><div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3 mb-2"></div><div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div></div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm animate-pulse"><div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div><div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div></div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm animate-pulse"><div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mb-2"></div><div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/5"></div></div>
</div>
</div>
</div>
<!-- Right column: Budgets + Summary + Care -->
<div class="space-y-5">
<!-- Room budgets -->
<div id="budgets-section" class="hidden">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.roomBudgets"}}</h2>
<div id="room-budgets" class="space-y-3"></div>
</div>
<!-- LLM Summary -->
<div id="llm-section">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.llmSummary"}}</h2>
<div id="llm-summary" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border-l-4 border-purple-400 opacity-0 transition-opacity duration-500">
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
</div>
</div>
<!-- Care checklist -->
<div id="care-section" class="hidden">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.careChecklist"}}</h2>
<ul id="care-checklist" class="space-y-1"></ul>
</div>
</div>
</div>
</div>
<!-- Hidden category icons (cloned by dashboard.js) -->
<div id="category-icons" class="hidden">
<svg id="icon-shading" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>
<svg id="icon-ventilation" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>
<svg id="icon-internal_gains" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<svg id="icon-ac_strategy" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>
<svg id="icon-hydration" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>
<svg id="icon-care" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</div>
<!-- Dashboard templates (cloned by dashboard.js) -->
<template id="tpl-warning-card">
<div class="border rounded-lg p-3 transition-all duration-200 hover:shadow-md">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium" data-slot="headline"></span>
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="severity"></span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400" data-slot="description"></div>
<div class="text-sm text-orange-700 dark:text-orange-300 mt-1 hidden" data-slot="instruction"></div>
<div class="text-xs text-gray-400 mt-1" data-slot="onset-expires"></div>
</div>
</template>
<template id="tpl-risk-window">
<div class="flex items-center gap-3 rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
<span class="font-mono text-sm" data-slot="time-range"></span>
<span class="capitalize font-medium" data-slot="level"></span>
<span class="text-sm text-gray-500 dark:text-gray-400" data-slot="details"></span>
</div>
</template>
<template id="tpl-room-budget">
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4">
<div class="font-medium text-sm mb-2" data-slot="name"></div>
<div class="space-y-0.5 text-xs">
<div class="flex justify-between"><span>{{t "dashboard.internalGains"}}</span><span data-slot="internal-gains"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.solarGain"}}</span><span data-slot="solar-gain"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.ventGain"}}</span><span data-slot="vent-gain"></span></div>
<div class="flex justify-between font-medium"><span>{{t "dashboard.totalGain"}}</span><span data-slot="total-gain"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.acCapacity"}}</span><span data-slot="ac-capacity"></span></div>
<div class="flex justify-between font-medium"><span>{{t "dashboard.headroom"}}</span><span data-slot="headroom-value"></span></div>
<div class="text-xs mt-0.5 text-green-600 dark:text-green-400" data-slot="headroom-ok">{{t "dashboard.headroomOk"}}</div>
<div class="text-xs mt-0.5 text-red-600 dark:text-red-400 hidden" data-slot="headroom-bad" data-label="{{t "dashboard.headroomInsufficient"}}"></div>
</div>
<div class="mt-2 space-y-1">
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
<div class="h-full bg-amber-400" data-slot="bar-internal" title="{{t "dashboard.internalGains"}}"></div>
<div class="h-full bg-orange-400" data-slot="bar-solar" title="{{t "dashboard.solarGain"}}"></div>
<div class="h-full bg-rose-400" data-slot="bar-vent" title="{{t "dashboard.ventGain"}}"></div>
</div>
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div class="h-full rounded-full bg-blue-400" data-slot="bar-ac"></div>
</div>
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>{{t "dashboard.internalGains"}}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>{{t "dashboard.solarGain"}}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>{{t "dashboard.ventGain"}}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>{{t "dashboard.acCapacity"}}</span>
</div>
</div>
<!-- Heating mode section (hidden by default, shown by JS) -->
<div class="hidden mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 space-y-0.5 text-xs" data-slot="heating-section">
<div class="flex justify-between"><span>{{t "dashboard.heatDeficit"}}</span><span data-slot="heat-deficit"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.heatingCapacity"}}</span><span data-slot="heating-capacity"></span></div>
<div class="flex justify-between font-medium"><span>{{t "dashboard.heatingHeadroom"}}</span><span data-slot="heating-headroom-value"></span></div>
<div class="text-xs mt-0.5 text-green-600 dark:text-green-400" data-slot="heating-headroom-ok">{{t "dashboard.heatingHeadroomOk"}}</div>
<div class="text-xs mt-0.5 text-red-600 dark:text-red-400 hidden" data-slot="heating-headroom-bad" data-label="{{t "dashboard.heatingHeadroomInsufficient"}}"></div>
</div>
</div>
</template>
<template id="tpl-care-item">
<li class="flex items-start gap-2">
<input type="checkbox" class="mt-1 rounded care-check">
<span class="text-sm" data-slot="text"></span>
</li>
</template>
<template id="tpl-action-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
<div class="flex items-start justify-between gap-2">
<div class="font-medium text-sm" data-slot="name"></div>
<div class="text-xs text-gray-400 whitespace-nowrap" data-slot="hours"></div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden" data-slot="description"></div>
<div class="flex gap-2 mt-2">
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="effort" data-label="{{t "dashboard.effort"}}"></span>
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="impact" data-label="{{t "dashboard.impact"}}"></span>
</div>
</div>
</template>
<template id="tpl-action-group">
<div>
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
<span data-slot="icon"></span>
<span class="font-medium text-sm" data-slot="label"></span>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2" data-slot="cards"></div>
</div>
</template>
<template id="tpl-profile-option">
<option></option>
</template>
</div>
{{end}}
{{define "scripts"}}
<script>
window.HG.t = {
riskComfort: "{{t "dashboard.riskComfort"}}",
coolComfort: "{{t "dashboard.coolComfort"}}",
coolVentilate: "{{t "dashboard.coolVentilate"}}",
coolAC: "{{t "dashboard.coolAC"}}",
coolOverloaded: "{{t "dashboard.coolOverloaded"}}",
coolSealed: "{{t "dashboard.coolSealed"}}",
coolHeating: "{{t "dashboard.coolHeating"}}",
coolHeatInsufficient: "{{t "dashboard.coolHeatInsufficient"}}",
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
aiActions: "{{t "dashboard.aiActions"}}",
legendTemp: "{{t "dashboard.legendTemp"}}",
legendCooling: "{{t "dashboard.legendCooling"}}",
legendAI: "{{t "dashboard.legendAI"}}",
errorTimeout: "{{t "dashboard.errorTimeout"}}",
errorNetwork: "{{t "dashboard.errorNetwork"}}",
errorUpstream: "{{t "dashboard.errorUpstream"}}",
errorUnknown: "{{t "dashboard.errorUnknown"}}",
category: {
shading: "{{t "dashboard.category.shading"}}",
ventilation: "{{t "dashboard.category.ventilation"}}",
internal_gains: "{{t "dashboard.category.internal_gains"}}",
ac_strategy: "{{t "dashboard.category.ac_strategy"}}",
hydration: "{{t "dashboard.category.hydration"}}",
care: "{{t "dashboard.category.care"}}",
},
};
</script>
<script src="/assets/js/db.js"></script>
<script src="/assets/js/dashboard.js"></script>
{{end}}