From 0461f936ce7ac11d769f81d02e2ac77d3b689eed Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 24 Apr 2026 07:59:15 +0200 Subject: [PATCH] =?UTF-8?q?Wetter-Chip=20auf=20Karte=20+=20Bugfix=20privat?= =?UTF-8?q?e=20Routen=20z=C3=A4hlen=20f=C3=BCr=20km-Stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/weather?lat=&lon= (Open-Meteo, 30-min TTL-Cache) - Zecken-Warnung regelbasiert: März–Okt + Temp > 7°C - Karte: Wetterchip oben rechts nach GPS-Fix - stats.py + achievements.py: is_public-Filter entfernt — private Routen zählen jetzt für eigene km/Achievements - SW by-v320, APP_VER 308 --- backend/main.py | 2 + backend/routes/achievements.py | 15 +++-- backend/routes/stats.py | 2 +- backend/routes/weather.py | 20 +++++++ backend/static/css/components.css | 36 ++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 23 ++++++++ backend/static/sw.js | 2 +- backend/weather.py | 96 ++++++++++++++++++++++++++++++- 9 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 backend/routes/weather.py diff --git a/backend/main.py b/backend/main.py index efdadb6..13b5d3d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -119,6 +119,7 @@ from routes.stats import router as stats_router from routes.achievements import router as achievements_router from routes.training import router as training_router from routes.praise import router as praise_router +from routes.weather import router as weather_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -134,6 +135,7 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) +app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"]) app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"]) app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"]) diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index 4ad6b98..12c13fa 100644 --- a/backend/routes/achievements.py +++ b/backend/routes/achievements.py @@ -125,10 +125,10 @@ def update_streak(user_id: int, conn): def check_and_award(user_id: int, conn): stats = conn.execute(""" SELECT ROUND( - COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_public=1), 0) + + COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? ), 0) + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), 1) AS total_km, - (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_public=1) AS routen, + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? ) AS routen, (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois FROM (SELECT 1) """, (user_id, user_id, user_id, user_id)).fetchone() @@ -178,17 +178,17 @@ async def my_achievements(user=Depends(get_current_user)): stats = conn.execute(""" SELECT ROUND( - COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_public=1), 0) + + COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? ), 0) + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), 1) AS total_km, - (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_public=1) AS routen, + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? ) AS routen, (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois, ROUND( - COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_public=1), 0) + + COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? ), 0) + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), 1)*1 + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?)*5 - + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_public=1)*10 AS punkte + + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? )*10 AS punkte FROM (SELECT 1) """, (uid, uid, uid, uid, uid, uid, uid, uid)).fetchone() @@ -208,8 +208,7 @@ async def my_achievements(user=Depends(get_current_user)): +COUNT(DISTINCT p.id)*5 +COUNT(DISTINCT r.id)*10 AS punkte FROM users u - LEFT JOIN routes r ON r.user_id=u.id AND r.is_public=1 - LEFT JOIN user_map_pois p ON p.user_id=u.id + LEFT JOIN routes r ON r.user_id=u.id LEFT JOIN user_map_pois p ON p.user_id=u.id GROUP BY u.id ) WHERE punkte > ? """, (stats["punkte"] if stats else 0,)).fetchone() diff --git a/backend/routes/stats.py b/backend/routes/stats.py index 1cb8835..e1cc906 100644 --- a/backend/routes/stats.py +++ b/backend/routes/stats.py @@ -13,7 +13,7 @@ _STATS_SQL = """ + COUNT(DISTINCT p.id) * 5 + COUNT(DISTINCT r.id) * 10 AS punkte FROM users u - LEFT JOIN routes r ON r.user_id = u.id AND r.is_public = 1 + LEFT JOIN routes r ON r.user_id = u.id LEFT JOIN user_map_pois p ON p.user_id = u.id GROUP BY u.id """ diff --git a/backend/routes/weather.py b/backend/routes/weather.py new file mode 100644 index 0000000..319cfd2 --- /dev/null +++ b/backend/routes/weather.py @@ -0,0 +1,20 @@ +""" +BAN YARO — Wetter-API +GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort +""" + +from fastapi import APIRouter, Query, HTTPException +import weather as weather_module + +router = APIRouter() + + +@router.get('') +async def get_weather( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), +): + try: + return await weather_module.get_weather_for_location(lat, lon) + except Exception as exc: + raise HTTPException(503, f'Wetter nicht verfügbar: {exc}') diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 6f71a25..4300fa7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -2723,6 +2723,42 @@ html.modal-open { pointer-events: none; } +/* Wetter-Chip — oben rechts auf der Karte */ +.map-weather-chip { + position: absolute; + top: var(--space-3); + right: var(--space-3); + z-index: 1000; + background: rgba(255,255,255,0.92); + backdrop-filter: blur(4px); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-full); + padding: 5px 12px; + font-size: 13px; + color: var(--c-text); + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + display: flex; + align-items: center; + gap: var(--space-2); + pointer-events: none; + user-select: none; +} +.map-weather-chip--hidden { display: none; } +.map-weather-chip__temp { font-weight: 700; } +.map-weather-chip__desc { color: var(--c-text-secondary); font-size: 12px; } +.map-weather-chip__zecken { + background: #FEF3C7; + color: #92400E; + border-radius: var(--radius-full); + padding: 1px 7px; + font-size: 11px; + font-weight: 600; +} +.map-weather-chip__zecken--hoch { + background: #FEE2E2; + color: #991B1B; +} + /* Giftköder-Marker — pulsierend, rot, sofort erkennbar */ .poison-marker { position: relative; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index da4c2ba..19066c1 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 = '307'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '308'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 4dd8d4d..f1d02cb 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -144,6 +144,7 @@ window.Page_map = (() => { _userPos = pos; if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; } _map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 }); + _loadWeather(pos.lat, pos.lon); }).catch(() => { const btn = document.getElementById('map-locate-btn'); if (btn) { @@ -195,6 +196,8 @@ window.Page_map = (() => { +
+
@@ -1512,6 +1515,26 @@ window.Page_map = (() => { }); } + // ---------------------------------------------------------- + // WETTER-CHIP + // ---------------------------------------------------------- + async function _loadWeather(lat, lon) { + const chip = document.getElementById('map-weather-chip'); + if (!chip) return; + try { + const w = await API.get(`/api/weather?lat=${lat}&lon=${lon}`); + const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '–'; + const icon = ``; + let zeckenHtml = ''; + if (w.zecken_warnung) { + const cls = w.zecken_warnung === 'hoch' ? 'map-weather-chip__zecken map-weather-chip__zecken--hoch' : 'map-weather-chip__zecken'; + zeckenHtml = `🦟 Zecken`; + } + chip.innerHTML = `${icon}${temp}${w.desc}${zeckenHtml}`; + chip.classList.remove('map-weather-chip--hidden'); + } catch { /* still */ } + } + return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive }; })(); diff --git a/backend/static/sw.js b/backend/static/sw.js index e3b1022..ec5687c 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-v319'; +const CACHE_VERSION = 'by-v320'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/backend/weather.py b/backend/weather.py index 405d998..672be1e 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -1,13 +1,105 @@ """ -BAN YARO — Wetter-Zusammenfassung via Open-Meteo -Prüft mehrere deutsche Städte und liefert max. Temperatur und Gewitterwarnung. +BAN YARO — Wetter via Open-Meteo +- get_weather_summary(): Push-Job, prüft 5 deutsche Städte +- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache """ +import time import logging import httpx +from datetime import datetime logger = logging.getLogger(__name__) +# WMO-Wettercodes → (Beschreibung, Phosphor-Icon-Name) +_WMO = { + 0: ('Klar', 'sun'), + 1: ('Überwiegend klar', 'cloud-sun'), + 2: ('Teilweise bewölkt', 'cloud-sun'), + 3: ('Bedeckt', 'cloud'), + 45: ('Nebel', 'cloud-fog'), + 48: ('Gefrierender Nebel', 'cloud-fog'), + 51: ('Leichter Nieselregen', 'cloud-rain'), + 53: ('Nieselregen', 'cloud-rain'), + 55: ('Starker Nieselregen', 'cloud-rain'), + 61: ('Leichter Regen', 'cloud-rain'), + 63: ('Regen', 'cloud-rain'), + 65: ('Starker Regen', 'cloud-rain'), + 71: ('Leichter Schnee', 'snowflake'), + 73: ('Schnee', 'snowflake'), + 75: ('Starker Schnee', 'snowflake'), + 77: ('Schneekörner', 'snowflake'), + 80: ('Leichte Schauer', 'cloud-rain'), + 81: ('Schauer', 'cloud-rain'), + 82: ('Starke Schauer', 'cloud-rain'), + 85: ('Schneeschauer', 'snowflake'), + 86: ('Starke Schneeschauer', 'snowflake'), + 95: ('Gewitter', 'cloud-lightning'), + 96: ('Gewitter mit Hagel', 'cloud-lightning'), + 99: ('Schweres Gewitter', 'cloud-lightning'), +} + +# TTL-Cache: (round(lat,1), round(lon,1)) → (timestamp, data) +_location_cache: dict = {} +_CACHE_TTL = 1800 # 30 Minuten + + +async def get_weather_for_location(lat: float, lon: float) -> dict: + """Holt aktuelles Wetter für einen Standort. 30-min TTL-Cache.""" + key = (round(lat, 1), round(lon, 1)) + now = time.time() + if key in _location_cache: + ts, cached = _location_cache[key] + if now - ts < _CACHE_TTL: + return cached + + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={lat}&longitude={lon}" + "¤t=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day" + "&daily=precipitation_probability_max,uv_index_max" + "&timezone=Europe%2FBerlin&forecast_days=1" + ) + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get(url) + resp.raise_for_status() + raw = resp.json() + + cur = raw.get('current', {}) + daily = raw.get('daily', {}) + + temp = cur.get('temperature_2m') + feels_like = cur.get('apparent_temperature') + wcode = cur.get('weathercode', 0) + wind = cur.get('windspeed_10m') + is_day = cur.get('is_day', 1) + precip = (daily.get('precipitation_probability_max') or [None])[0] + uv = (daily.get('uv_index_max') or [None])[0] + + desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud')) + if wcode == 0 and not is_day: + icon = 'moon' + + month = datetime.now().month + zecken = None + if temp is not None and temp > 7.0 and 3 <= month <= 10: + zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig') + + data = { + 'temp_c': temp, + 'feels_like_c': feels_like, + 'weathercode': wcode, + 'desc': desc, + 'icon': icon, + 'wind_kmh': wind, + 'precip_prob': precip, + 'uv_index': uv, + 'is_day': bool(is_day), + 'zecken_warnung': zecken, + } + _location_cache[key] = (now, data) + return data + # Wichtige deutsche Städte als Stichprobe CITIES = [ {"name": "Berlin", "lat": 52.52, "lon": 13.41},