From 759979ffcedead599ef5453c310a1fbb41b23b4c Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:06:30 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20St=C3=BCndliche=20Niederschlagswahrs?= =?UTF-8?q?cheinlichkeit=20auf=20Wetter-Seite=20(SW=20by-v690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Open-Meteo Forecast-Request um hourly precipitation_probability, precipitation und weathercode erweitert; stündliche Daten werden pro Tag gruppiert und im API-Response unter "hourly" je Tag ausgeliefert - Frontend: Neue _renderRainTimeline()-Funktion rendert horizontale Balken-Zeitskala für alle 24 Stunden des gewählten Tages; bei "Heute" wird automatisch zur aktuellen Stunde gescrollt und "jetzt" hervorgehoben; Farb-Gradient von hellgrau (<10%) bis dunkelblau (≥75%) - SW/APP_VER/CSS auf 690 gebumpt --- backend/static/index.html | 12 +-- backend/static/js/app.js | 2 +- backend/static/js/pages/wetter.js | 138 ++++++++++++++++++++++++++++++ backend/static/sw.js | 5 +- backend/weather.py | 21 +++++ 5 files changed, 169 insertions(+), 9 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 824ed47..cbcdd15 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -9,7 +9,6 @@ - @@ -76,6 +75,7 @@ + @@ -93,9 +93,9 @@ - - - + + + @@ -562,12 +562,12 @@ - + - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 37ede79..d2655e8 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '664'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '690'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index c838916..d9fbab2 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -187,6 +187,11 @@ window.Page_wetter = (() => { style="margin-bottom:var(--space-4)"> + +
+
+
@@ -198,11 +203,13 @@ window.Page_wetter = (() => { _selDay = parseInt(card.dataset.wttrDay); _updateStrip(); _renderDetail(); + _renderRainTimeline(); _renderDog(); }); }); _renderDetail(); + _renderRainTimeline(); _renderDog(); } @@ -380,6 +387,137 @@ window.Page_wetter = (() => { `; } + // ---------------------------------------------------------- + // NIEDERSCHLAGS-ZEITSKALA (stündlich) + // ---------------------------------------------------------- + function _renderRainTimeline() { + const el = _container.querySelector('#wttr-rain'); + if (!el || !_data) return; + const d = (_data.days || [])[_selDay]; + if (!d) return; + + const hourly = d.hourly || []; + // Filtere auf Stunden mit Daten, die eine Niederschlagswahrscheinlichkeit haben + const entries = hourly.filter(h => h.precip_prob != null); + if (!entries.length) { el.style.display = 'none'; return; } + el.style.display = ''; + + // Für "Heute" (Tag 0): ab jetzt, sonst alle 24h + const now = new Date(); + const nowMin = now.getHours() * 60 + now.getMinutes(); + let slots = entries; + if (_selDay === 0) { + // Zeige ab der aktuellen Stunde (und die letzten 2h als Kontext) + const pastCutoff = now.getHours() - 2; + slots = entries.filter(h => { + const hHour = parseInt(h.hour.split(':')[0]); + return hHour >= pastCutoff; + }); + // Falls nichts übrig bleibt, zeige alles + if (!slots.length) slots = entries; + } + + // Max probability für Skalierung (mindestens 30 damit die Balken sichtbar sind) + const maxProb = Math.max(30, ...slots.map(h => h.precip_prob ?? 0)); + + // Farb-Funktion: blau basierend auf Wahrscheinlichkeit + function _rainColor(prob) { + if (prob < 10) return 'rgba(148,163,184,0.4)'; // grau, kaum Regen + if (prob < 25) return 'rgba(147,197,253,0.65)'; // hellblau + if (prob < 50) return 'rgba(96,165,250,0.8)'; // blau + if (prob < 75) return 'rgba(59,130,246,0.9)'; // kräftig blau + return 'rgba(29,78,216,1)'; // dunkelblau + } + + // Aktuell aktiver Slot (nur bei Heute) + const currentHour = now.getHours(); + + const bars = slots.map(h => { + const prob = h.precip_prob ?? 0; + const hHour = parseInt(h.hour.split(':')[0]); + const isNow = _selDay === 0 && hHour === currentHour; + const barH = Math.max(2, Math.round((prob / 100) * 56)); // max 56px Balkenhöhe + const color = _rainColor(prob); + const labelHour = h.hour.substring(0, 2); // 'HH' + + return ` +
+ +
+ ${prob >= 20 ? prob + '%' : ''} +
+ +
+
+
+ +
+ ${isNow ? 'jetzt' : labelHour + 'h'} +
+
+ `; + }).join(''); + + // Gibt es überhaupt nennenswerten Niederschlag? + const hasRain = slots.some(h => (h.precip_prob ?? 0) >= 10); + const titleColor = hasRain ? '#60A5FA' : 'var(--c-text-secondary)'; + const titleIcon = hasRain ? 'cloud-rain' : 'cloud'; + + el.innerHTML = ` +
+ + + + + Niederschlagswahrscheinlichkeit + + + ${_selDay === 0 ? 'heute' : _esc(d.date ? new Date(d.date + 'T12:00').toLocaleDateString('de', {weekday:'short', day:'numeric', month:'short'}) : '')} + +
+ +
+
+
+ ${bars} +
+
+ +
+
+ ${!hasRain ? ` +
+ Kein Regen erwartet +
` : ''} + `; + + // Scroll zum aktuellen Slot wenn Heute + if (_selDay === 0) { + requestAnimationFrame(() => { + const wrap = el.querySelector('div[style*="overflow-x"]'); + if (!wrap) return; + const nowIdx = slots.findIndex(h => parseInt(h.hour.split(':')[0]) === currentHour); + if (nowIdx > 2) { + wrap.scrollLeft = (nowIdx - 2) * 41; // ca. 38px + 3px gap + } + }); + } + } + // ---------------------------------------------------------- // HUNDE-WETTER // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 6cb3317..ca60ee8 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v664'; +const CACHE_VERSION = 'by-v690'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache @@ -202,7 +202,8 @@ self.addEventListener('fetch', event => { .then(resp => { if (resp.ok) { _cacheMark(url.pathname); - caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + const toCache = resp.clone(); + caches.open(CACHE_API).then(c => c.put(event.request, toCache)); } return resp; }) diff --git a/backend/weather.py b/backend/weather.py index e5f2317..5e836c6 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -222,6 +222,7 @@ async def get_forecast(lat: float, lon: float) -> dict: "apparent_temperature_min,precipitation_probability_max,precipitation_sum," "weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max," "sunrise,sunset" + "&hourly=precipitation_probability,precipitation,weathercode" "&timezone=auto&forecast_days=7" ) pollen_url = ( @@ -245,6 +246,7 @@ async def get_forecast(lat: float, lon: float) -> dict: raw = forecast_resp.json() daily = raw.get('daily', {}) + hourly_fc = raw.get('hourly', {}) timezone = raw.get('timezone', 'auto') dates = daily.get('time', []) @@ -261,6 +263,24 @@ async def get_forecast(lat: float, lon: float) -> dict: sunrises = daily.get('sunrise', []) sunsets = daily.get('sunset', []) + # --- Hourly precipitation data grouped by day --- + hourly_times = hourly_fc.get('time', []) + hourly_pp = hourly_fc.get('precipitation_probability', []) + hourly_precip = hourly_fc.get('precipitation', []) + hourly_wcode = hourly_fc.get('weathercode', []) + # Build: date_str → list of {hour, precip_prob, precip, weathercode} + _hourly_by_day: dict = {} + for idx, ts_str in enumerate(hourly_times): + day_str = ts_str[:10] # 'YYYY-MM-DD' + hour_str = ts_str[11:16] # 'HH:MM' + entry = { + 'hour': hour_str, + 'precip_prob': hourly_pp[idx] if idx < len(hourly_pp) else None, + 'precip': hourly_precip[idx] if idx < len(hourly_precip) else None, + 'weathercode': int(hourly_wcode[idx]) if idx < len(hourly_wcode) and hourly_wcode[idx] is not None else None, + } + _hourly_by_day.setdefault(day_str, []).append(entry) + # --- Pollen (optional) --- pollen_daily: dict | None = None if not isinstance(pollen_resp, Exception): @@ -361,6 +381,7 @@ async def get_forecast(lat: float, lon: float) -> dict: 'zecken': zecken, 'thunderstorm': wcode in {95, 96, 99}, 'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0), + 'hourly': _hourly_by_day.get(date_str, []), }) result = {'timezone': timezone, 'days': days}