Wetter-Chip auf Karte + Bugfix private Routen zählen für km-Stats

- 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
This commit is contained in:
rene 2026-04-24 07:59:15 +02:00
parent 43d33c0fd1
commit 0461f936ce
9 changed files with 185 additions and 13 deletions

View file

@ -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"])

View file

@ -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()

View file

@ -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
"""

20
backend/routes/weather.py Normal file
View file

@ -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}')

View file

@ -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;

View file

@ -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 = (() => {

View file

@ -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 = (() => {
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-weather-chip map-weather-chip--hidden" id="map-weather-chip"></div>
<div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span>
<span id="map-osm-status"></span>
@ -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 = `<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
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 = `<span class="${cls}" title="Zeckenrisiko ${w.zecken_warnung}">🦟 Zecken</span>`;
}
chip.innerHTML = `${icon}<span class="map-weather-chip__temp">${temp}</span><span class="map-weather-chip__desc">${w.desc}</span>${zeckenHtml}`;
chip.classList.remove('map-weather-chip--hidden');
} catch { /* still */ }
}
return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive };
})();

View file

@ -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

View file

@ -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}"
"&current=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},