HexaWetter v1.2.0: Security, Admin, Radar, UI
All checks were successful
CI / api-check (push) Successful in 20s
CI / frontend-check (push) Successful in 6s

This commit is contained in:
TheOnlyMace
2026-06-18 23:59:08 +02:00
parent 3ebdc96381
commit 5a08d4c4ac
72 changed files with 695 additions and 409 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Admin HexaWetter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="style.css" />
@@ -47,6 +48,7 @@
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="refreshBtn" class="btn btn-secondary">Aktualisieren</button>
<button id="syncRadarBtn" class="btn btn-secondary">Radar syncen</button>
<button id="clearCacheBtn" class="btn btn-secondary">Redis-Cache leeren</button>
</div>
</div>
@@ -80,6 +82,20 @@
</form>
<p class="muted" id="settingsMsg" style="margin-top:10px"></p>
</section>
<section class="glass-card">
<div class="section-head">
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">Lokale DWD-Dateien und gerenderte Frames</p></div>
<span class="muted" id="renderedCount"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th></th></tr></thead>
<tbody id="radarFiles"></tbody>
</table>
</div>
<div class="rendered-grid" id="renderedFrames" style="margin-top:16px"></div>
</section>
</section>
</div>
</main>

View File

@@ -35,6 +35,67 @@ function formatBytes(bytes) {
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
}
async function fetchBlobUrl(path) {
const token = getToken();
const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
return URL.createObjectURL(blob);
}
async function downloadAdminFile(path, filename) {
const url = await fetchBlobUrl(path);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function loadRadarData() {
try {
const local = await api("/api/admin/radar/files");
el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => `
<tr>
<td>${file.name}</td>
<td>${formatBytes(file.size_bytes)}</td>
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
<td><button type="button" class="btn btn-secondary" data-download="${file.download_url}" data-name="${file.name}">Download</button></td>
</tr>
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
el("radarFiles").querySelectorAll("[data-download]").forEach((btn) => {
btn.addEventListener("click", () => downloadAdminFile(btn.dataset.download, btn.dataset.name));
});
} catch {
el("radarFiles").innerHTML = `<tr><td colspan="4" class="muted">Rohdaten nicht verfügbar</td></tr>`;
}
try {
const rendered = await api("/api/admin/radar/rendered?limit=12");
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
const frames = rendered.frames || [];
if (!frames.length) {
el("renderedFrames").innerHTML = `<p class="muted">Noch keine gerenderten Frames.</p>`;
return;
}
el("renderedFrames").innerHTML = frames.map((f, i) => `
<figure class="rendered-frame" id="renderedFrame${i}">
<div class="muted" style="padding:40px;text-align:center">Lade…</div>
<figcaption>${f.filename}</figcaption>
</figure>
`).join("");
await Promise.allSettled(frames.map(async (f, i) => {
const url = await fetchBlobUrl(f.image_url);
const figure = el(`renderedFrame${i}`);
if (!figure) return;
figure.innerHTML = `<img src="${url}" alt="Radar ${f.filename}" loading="lazy" /><figcaption>${f.filename}</figcaption>`;
}));
} catch {
el("renderedCount").textContent = "";
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
}
}
function showLogin() {
el("loginSection").classList.remove("admin-hidden");
el("dashboardSection").classList.add("admin-hidden");
@@ -92,6 +153,7 @@ async function loadDashboard() {
el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations;
el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings;
el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours;
await loadRadarData();
}
el("loginForm").addEventListener("submit", async (e) => {
@@ -124,6 +186,23 @@ el("clearCacheBtn").addEventListener("click", async () => {
await loadDashboard();
});
el("syncRadarBtn").addEventListener("click", async () => {
const button = el("syncRadarBtn");
button.disabled = true;
const label = button.textContent;
button.textContent = "Sync läuft…";
try {
const result = await api("/api/admin/radar/sync?limit=6", { method: "POST" });
alert(`Sync abgeschlossen: ${result.downloaded?.length || 0} neu, ${result.skipped?.length || 0} übersprungen`);
await loadRadarData();
} catch (err) {
alert(err.message);
} finally {
button.disabled = false;
button.textContent = label;
}
});
el("settingsForm").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = {

View File

@@ -10,12 +10,11 @@ let layerControl = null;
let osmLayer = null;
let radarLayer = null;
let warningLayer = null;
let localRadarOverlay = null;
let radarTimes = [];
let radarFrameIndex = 0;
let radarPlayTimer = null;
let radarPlaying = false;
let renderedFrames = [];
let hourlyChart = null;
let settings = loadSettings();
let searchTimer = null;
@@ -44,14 +43,6 @@ function saveFavorites(items) {
localStorage.setItem(FAV_KEY, JSON.stringify(items));
}
function formatBytes(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes, unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function weatherText(code) {
const map = {
0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt",
@@ -74,6 +65,139 @@ function convertWind(kmh) {
return `${Math.round(kmh)} km/h`;
}
function tempValue(celsius) {
if (settings.unitTemp === "fahrenheit") return celsius * 9 / 5 + 32;
return celsius;
}
function tempUnitLabel() {
return settings.unitTemp === "fahrenheit" ? "°F" : "°C";
}
function windUnitLabel() {
if (settings.unitWind === "ms") return "m/s";
if (settings.unitWind === "kn") return "kn";
return "km/h";
}
function windValue(kmh) {
if (settings.unitWind === "ms") return kmh / 3.6;
if (settings.unitWind === "kn") return kmh * 0.539957;
return kmh;
}
function chartThemeColors() {
const isLight = document.documentElement.dataset.theme === "light";
return {
text: isLight ? "#5f5380" : "#cfc9dd",
grid: isLight ? "rgba(20, 10, 60, 0.08)" : "rgba(255, 255, 255, 0.08)",
};
}
function destroyHourlyChart() {
if (hourlyChart) {
hourlyChart.destroy();
hourlyChart = null;
}
}
function renderHourlyChart(hourly) {
if (typeof Chart === "undefined" || !hourly?.time?.length) return;
const canvas = el("hourlyChart");
if (!canvas) return;
destroyHourlyChart();
const { text: textColor, grid: gridColor } = chartThemeColors();
const tempUnit = tempUnitLabel();
const windUnit = windUnitLabel();
const labels = hourly.time.slice(0, 48).map((time) =>
new Date(time).toLocaleString("de-DE", { weekday: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" })
);
hourlyChart = new Chart(canvas, {
type: "line",
data: {
labels,
datasets: [
{
type: "bar",
label: "Regenwahrscheinlichkeit",
data: hourly.precipitation_probability.slice(0, 48).map((v) => v ?? 0),
yAxisID: "yRain",
backgroundColor: "rgba(57, 120, 255, 0.42)",
borderColor: "rgba(57, 120, 255, 0.7)",
borderWidth: 1,
borderRadius: 4,
order: 3,
},
{
label: `Temperatur (${tempUnit})`,
data: hourly.temperature_2m.slice(0, 48).map(tempValue),
yAxisID: "yMain",
borderColor: "#ff51f9",
backgroundColor: "rgba(255, 81, 249, 0.14)",
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2.5,
order: 1,
},
{
label: `Wind (${windUnit})`,
data: hourly.wind_speed_10m.slice(0, 48).map((v) => windValue(v ?? 0)),
yAxisID: "yMain",
borderColor: "#a348ff",
borderDash: [5, 4],
tension: 0.35,
pointRadius: 0,
borderWidth: 1.5,
fill: false,
order: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: {
labels: { color: textColor, usePointStyle: true, boxWidth: 10 },
},
tooltip: {
callbacks: {
label(ctx) {
const label = ctx.dataset.label || "";
if (label.startsWith("Regen")) return `${label}: ${ctx.parsed.y}%`;
if (label.startsWith("Temperatur")) return `${label}: ${Math.round(ctx.parsed.y)} ${tempUnit}`;
return `${label}: ${ctx.parsed.y.toFixed(1)} ${windUnit}`;
},
},
},
},
scales: {
x: {
ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 14 },
grid: { color: gridColor },
},
yMain: {
position: "left",
ticks: { color: textColor },
grid: { color: gridColor },
},
yRain: {
position: "right",
min: 0,
max: 100,
grid: { drawOnChartArea: false },
ticks: { color: textColor, callback: (v) => `${v}%` },
},
},
},
});
}
async function api(path, options = {}) {
const res = await fetch(path, options);
if (!res.ok) {
@@ -126,15 +250,6 @@ async function loadHealth() {
}
}
async function loadSourceStatus() {
try {
const data = await api("/api/health/sources");
renderSourceStatus(data.sources || []);
} catch {
el("sourceStatusSummary").textContent = "Quellenstatus nicht verfügbar";
}
}
function applyDashboard(dash) {
const loc = getLocation();
if (dash.forecast?.data) {
@@ -207,18 +322,6 @@ function applyWarningsData(data) {
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
}
function renderSourceStatus(sources) {
const bad = sources.filter((s) => s.status === "error").length;
el("sourceStatusSummary").textContent = bad ? `${bad} Quelle(n) mit Problemen` : "Alle Quellen erreichbar";
el("sourceStatusGrid").innerHTML = sources.map((s) => `
<div class="source-status ${s.status}">
<strong>${s.name}</strong>
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
${s.error ? `<small>${s.error}</small>` : ""}
</div>
`).join("");
}
async function loadForecast() {
const loc = getLocation();
const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`);
@@ -270,6 +373,7 @@ function renderHourly(hourly) {
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
</tr>
`).join("");
renderHourlyChart(hourly);
}
function renderHourStrip(hourly) {
@@ -306,33 +410,7 @@ async function loadWarnings() {
const localOnly = el("warningsLocalOnly")?.checked ?? true;
try {
const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
const warnings = data.warnings || [];
el("warnCount").textContent = String(data.count_local ?? warnings.length);
el("warnMeta").textContent = localOnly
? `${warnings.length} Warnung(en) für deinen Standort`
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`;
const warncellEl = el("warncellInfo");
if (localOnly && data.warncells?.length) {
warncellEl.classList.remove("hidden");
warncellEl.innerHTML = `Zugeordnete Warngebiete: ${data.warncells.map((a) => `<strong>${a.name}</strong> (${a.warncell_id})`).join(" · ")}`;
} else {
warncellEl.classList.add("hidden");
warncellEl.innerHTML = "";
}
el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
<article class="warning-card ${warnLevelClass(w.level)}">
<div class="warning-head">
<strong>${w.headline || w.event}</strong>
<span class="warn-badge">Level ${w.level} · ${w.level_label}</span>
</div>
<p class="muted">${w.region_name || ""} · ${w.state || ""}</p>
<p>${(w.description || "").replace(/\n/g, "<br>")}</p>
${w.instruction ? `<p class="instruction">${w.instruction}</p>` : ""}
<small class="muted">Gültig: ${w.start ? formatDateTime(w.start) : ""} ${w.end ? formatDateTime(w.end) : ""}</small>
</article>
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
applyWarningsData(data);
} catch (err) {
el("warnCount").textContent = "";
el("warnMeta").textContent = err.message;
@@ -341,56 +419,6 @@ async function loadWarnings() {
}
}
async function loadRadarFiles() {
const latest = await api("/api/radar/latest?limit=12");
el("radarCount").textContent = `${latest.count} online`;
el("radarMeta").textContent = `Produkt ${latest.product.toUpperCase()} · DWD OpenData`;
const local = await api("/api/radar/files");
el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => `
<tr><td>${file.name}</td><td>${formatBytes(file.size_bytes)}</td>
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
<td><a href="${file.download_url}">Download</a></td></tr>
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
try {
const rendered = await api("/api/radar/rendered?limit=12");
renderedFrames = rendered.frames || [];
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
el("renderedFrames").innerHTML = renderedFrames.length ? renderedFrames.map((f) => `
<figure class="rendered-frame">
<img src="${f.image_url}" alt="Radar ${f.filename}" loading="lazy" />
<figcaption>${f.filename}</figcaption>
</figure>
`).join("") : `<p class="muted">Noch keine gerenderten Frames. Renderer-Worker abwarten.</p>`;
} catch {
el("renderedCount").textContent = "";
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
}
}
async function syncRadar() {
const button = el("syncRadarBtn");
button.disabled = true;
button.textContent = "Sync läuft…";
try {
await api("/api/radar/sync?limit=6", { method: "POST" });
await loadRadarFiles();
} finally {
button.disabled = false;
button.textContent = "Radar jetzt syncen";
}
}
async function loadSources() {
try {
const data = await api("/api/sources");
el("sourceList").innerHTML = Object.entries(data).map(([key, value]) => `
<div class="source-item"><span>${key}</span><code>${String(value)}</code></div>
`).join("");
} catch (err) {
el("sourceList").innerHTML = `<p class="muted">${err.message}</p>`;
}
}
function setRadarTime(index) {
if (!radarTimes.length) return;
radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1));
@@ -399,24 +427,11 @@ function setRadarTime(index) {
el("radarTimeLabel").textContent = formatDateTime(time);
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
if (el("useLocalRadar").checked && renderedFrames.length) {
showLocalRadarFrame(radarFrameIndex);
} else if (radarLayer) {
if (radarLayer) {
radarLayer.setParams({ TIME: time, _cache: Date.now() });
}
}
function showLocalRadarFrame(index) {
if (!map) return;
const frame = renderedFrames[Math.min(index, renderedFrames.length - 1)];
if (!frame?.bounds) return;
const b = frame.bounds;
const bounds = [[b.south, b.west], [b.north, b.east]];
if (localRadarOverlay) map.removeLayer(localRadarOverlay);
localRadarOverlay = L.imageOverlay(frame.image_url + `?t=${Date.now()}`, bounds, { opacity: 0.75 });
localRadarOverlay.addTo(map);
}
async function loadRadarTimes() {
const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar";
const minutes = settings.radarMinutes || 120;
@@ -600,11 +615,33 @@ function activateView(name) {
document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name));
if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150));
if (name === "warnings") loadWarnings();
if (name === "hourly" && hourlyChart) setTimeout(() => hourlyChart.resize(), 80);
}
async function loadDashboard() {
const loc = getLocation();
const localOnly = el("warningsLocalOnly")?.checked ?? true;
const dash = await api(`/api/dashboard?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
applyDashboard(dash);
if (dash.bundle_cached) {
el("forecastSource").textContent += " · Bundle-Cache";
}
return dash;
}
async function loadAll() {
await loadHealth();
await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]);
loadHealth();
const loc = getLocation();
try {
await loadDashboard();
} catch {
await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]);
const radar = await api("/api/radar/latest?limit=12").catch(() => null);
if (radar) {
el("radarCount").textContent = `${radar.count} online`;
el("radarMeta").textContent = `Produkt ${radar.product.toUpperCase()} · DWD OpenData`;
}
}
updateMapLocation();
if (map) loadRadarTimes();
}
@@ -614,20 +651,17 @@ async function init() {
await loadDefaultLocation();
renderFavorites();
await loadAll();
setInterval(loadHealth, 30000);
setInterval(loadHealth, 60000);
}
document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view)));
el("loadBtn").addEventListener("click", loadAll);
el("syncRadarBtn").addEventListener("click", syncRadar);
el("centerMapBtn").addEventListener("click", updateMapLocation);
el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers);
el("favBtn").addEventListener("click", addFavorite);
el("geoBtn").addEventListener("click", useGeolocation);
el("warningsLocalOnly")?.addEventListener("change", loadWarnings);
el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap);
el("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex));
el("radarPlayBtn").addEventListener("click", toggleRadarPlay);
el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1));
el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1));
@@ -647,6 +681,7 @@ el("themeToggle").addEventListener("click", () => {
settings.theme = settings.theme === "dark" ? "light" : "dark";
saveSettings();
applyTheme();
if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly);
});
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
@@ -656,7 +691,12 @@ el("themeToggle").addEventListener("click", () => {
if (id === "unitWind") settings.unitWind = e.target.value;
if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value);
saveSettings();
if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); loadForecast(); }
if (latestForecast) {
renderDaily(latestForecast.data.daily);
renderHourly(latestForecast.data.hourly);
renderHourStrip(latestForecast.data.hourly);
loadForecast();
}
if (id === "radarMinutes" && map) loadRadarTimes();
});
});

View File

@@ -73,7 +73,6 @@
<button class="tab" data-view="warnings">Warnungen</button>
<button class="tab" data-view="hourly">Stunden</button>
<button class="tab" data-view="daily">7 Tage</button>
<button class="tab" data-view="raw">Rohdaten</button>
<button class="tab" data-view="settings">Einstellungen</button>
</nav>
@@ -92,10 +91,6 @@
<div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div>
<div class="hour-strip" id="hourStrip"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen-Status</h2><span class="muted" id="sourceStatusSummary">Prüfe…</span></div>
<div class="source-status-grid" id="sourceStatusGrid"></div>
</section>
</section>
<section class="view" data-view="radar">
@@ -116,7 +111,6 @@
<input id="radarSlider" type="range" min="0" max="0" value="0" />
</div>
<div class="timeline-meta">
<label class="inline-check"><input type="checkbox" id="useLocalRadar" /> Lokale Render-Frames</label>
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<span class="muted" id="radarFrameInfo"></span>
</div>
@@ -144,8 +138,21 @@
<section class="view" data-view="hourly">
<section class="glass-card">
<div class="section-head"><h2>Stundenansicht</h2><span class="muted">48 Stunden</span></div>
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr></thead><tbody id="hourlyTable"></tbody></table></div>
<div class="section-head">
<h2>Stundenansicht</h2>
<span class="muted">48 Stunden · Temperatur, Regen & Wind</span>
</div>
<div class="hourly-chart-wrap">
<canvas id="hourlyChart" aria-label="Stundenverlauf Temperatur, Regen und Wind"></canvas>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr>
</thead>
<tbody id="hourlyTable"></tbody>
</table>
</div>
</section>
</section>
@@ -156,24 +163,6 @@
</section>
</section>
<section class="view" data-view="raw">
<section class="glass-card">
<div class="section-head">
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">DWD OpenData · lokaler Renderer</p></div>
<button id="syncRadarBtn" class="btn btn-secondary">Sync starten</button>
</div>
<div class="table-wrap"><table><thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th>Download</th></tr></thead><tbody id="radarFiles"></tbody></table></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Gerenderte Frames</h2><span class="muted" id="renderedCount"></span></div>
<div class="rendered-grid" id="renderedFrames"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen</h2></div>
<div class="source-list" id="sourceList"></div>
</section>
</section>
<section class="view" data-view="settings">
<section class="glass-card">
<h2>Einstellungen</h2>
@@ -213,6 +202,7 @@
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -307,6 +307,19 @@ html[data-theme="light"] .search-results { background: #fff; }
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.hourly-chart-wrap {
position: relative;
height: min(340px, 48vh);
margin-bottom: 1.25rem;
padding: 0.75rem 0.5rem 0.25rem;
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
background: rgba(0, 0, 0, 0.14);
}
html[data-theme="light"] .hourly-chart-wrap {
background: rgba(255, 255, 255, 0.55);
}
.hour-card span { display: block; font-size: 1.35rem; font-weight: 800; margin: 0.4rem 0; }
.bar { height: 6px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.12); margin: 0.7rem 0; }
.bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color)); }