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'}) : '')}
+
+
+
+
+ ${!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}