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:
parent
43d33c0fd1
commit
0461f936ce
9 changed files with 185 additions and 13 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
20
backend/routes/weather.py
Normal 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}')
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue