Session 2026-04-19: Navigation, Kompass, Übungsfortschritt

Routen-Navigation:
- POI-Marker: farbige Kreise mit Phosphor-Icons (wie Hauptkarte)
- Screensaver: Navi-Pfeil dreht sich via DeviceOrientationEvent (iOS+Android)
- Pfeil-Dämpfung: EMA α=0.12 mit Wrap-Around
- GPS-Distanz-Bug: Fortschritt nur wenn <500m zur Route
- fitBounds: User-Position nur wenn <20km von Route
- Screensaver: "zur Route" vs "verbleibend" kontextabhängig
- Richtungspfeile entlang Route (blau, max 7 Stück)
- Umkehren ins Route-Detail verschoben, Detail-Map rebuildet sich
- rk-header z-index:10 (Leaflet-Tiles liefen drüber)
- 2-Sek. Screensaver-Entsperrung

km-Tracking:
- route_walks Tabelle
- POST /api/routes/{id}/walked (≥50%)
- total_km = erstellte Routes + gelaufene route_walks
- Toast bei neuem Badge

Übungsfortschritt:
- exercise_progress + training_plan_progress Tabellen
- GET/POST /api/training/progress, /plan-progress, /suggestions
- uebungen.js: API-first + localStorage-Fallback + Auto-Migration
- Empfehlungs-Banner (regelbasiert)
- Toast bei "sitzt"
This commit is contained in:
rene 2026-04-19 20:33:01 +02:00
parent 390176383f
commit 9a78121a3e
25 changed files with 2487 additions and 248 deletions

View file

@ -1,11 +1,14 @@
"""BAN YARO — Gassi-Routen"""
import json, math, os, uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user, get_current_user_optional
from routes.achievements import update_streak, check_and_award
from routes.push import send_push_to_user
router = APIRouter()
@ -26,6 +29,7 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
class GPSPoint(BaseModel):
lat: float
lon: float
alt: Optional[float] = None
class RouteCreate(BaseModel):
name: str
@ -147,6 +151,8 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
data.hunde_tauglichkeit,
))
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
update_streak(user['id'], conn)
check_and_award(user['id'], conn)
return _parse(row)
@ -189,6 +195,59 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
return _parse(row)
# ------------------------------------------------------------------
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
# ------------------------------------------------------------------
class RouteTrim(BaseModel):
gps_track: List[GPSPoint]
@router.patch("/{route_id}/trim")
async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_user)):
if len(data.gps_track) < 2:
raise HTTPException(400, "Mindestens 2 GPS-Punkte erforderlich.")
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
# Original-Werte beim ersten Kürzen einmalig sichern
if row['original_km'] is None:
conn.execute(
"UPDATE routes SET original_km=?, original_dauer_min=? WHERE id=?",
(row['distanz_km'], row['dauer_min'], route_id)
)
orig_km = row['distanz_km'] or 0
orig_min = row['dauer_min'] or 0
else:
orig_km = row['original_km']
orig_min = row['original_dauer_min'] or 0
# Neue Distanz berechnen
new_track = [p.model_dump() for p in data.gps_track]
new_km = 0.0
for i in range(1, len(new_track)):
p1, p2 = new_track[i-1], new_track[i]
dlat = math.radians(p2['lat'] - p1['lat'])
dlon = math.radians(p2['lon'] - p1['lon'])
a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2
new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
new_km = round(new_km, 2)
# Dauer proportional schätzen (Original-Pace)
pace = orig_min / orig_km if orig_km > 0 else 10
new_min = max(1, round(new_km * pace))
conn.execute(
"UPDATE routes SET gps_track=?, distanz_km=?, dauer_min=? WHERE id=?",
(json.dumps(new_track), new_km, new_min, route_id)
)
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
return _parse(row)
# ------------------------------------------------------------------
# DELETE /api/routes/{id}
# ------------------------------------------------------------------
@ -227,6 +286,46 @@ async def rate_route(route_id: int, data: RouteRate, user=Depends(get_current_us
return {'bewertung': round(avg, 2), 'anz_bewertungen': n}
# ------------------------------------------------------------------
# POST /api/routes/{id}/walked — Gelaufene km ins Profil eintragen
# ------------------------------------------------------------------
class WalkRecord(BaseModel):
walked_km: float
progress_pct: int
@router.post("/{route_id}/walked", status_code=201)
async def record_walk(route_id: int, body: WalkRecord, user=Depends(get_current_user)):
if body.progress_pct < 50:
raise HTTPException(400, "Mindestens 50 % der Route müssen absolviert sein.")
uid = user["id"]
with db() as conn:
conn.execute(
"INSERT INTO route_walks (user_id, route_id, walked_km) VALUES (?,?,?)",
(uid, route_id, round(max(0.01, body.walked_km), 2))
)
update_streak(uid, conn)
new_badges = check_and_award(uid, conn)
return {"ok": True, "new_badges": new_badges}
# ------------------------------------------------------------------
# POST /api/routes/{id}/reverse — GPS-Track umkehren
# ------------------------------------------------------------------
@router.post("/{route_id}/reverse", status_code=200)
async def reverse_route(route_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute("SELECT user_id, gps_track FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row["user_id"] != uid:
raise HTTPException(403, "Nur der Ersteller kann die Route umkehren.")
track = json.loads(row["gps_track"])
track.reverse()
conn.execute("UPDATE routes SET gps_track=? WHERE id=?", (json.dumps(track), route_id))
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/routes/{id}/photo — Foto hochladen
# ------------------------------------------------------------------
@ -259,3 +358,70 @@ async def add_route_photo(
urls.append(foto_url)
conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id))
return {'foto_url': foto_url, 'foto_urls': urls}
# ------------------------------------------------------------------
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------
class RouteFeedback(BaseModel):
text: str
@router.post("/{route_id}/feedback", status_code=201)
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)):
if len(data.text.strip()) < 5:
raise HTTPException(400, "Feedback zu kurz.")
with db() as conn:
row = conn.execute(
"SELECT user_id, name FROM routes WHERE id=?", (route_id,)
).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row["user_id"] == user["id"]:
raise HTTPException(400, "Eigene Route kann nicht bewertet werden.")
send_push_to_user(row["user_id"], {
"type": "route_feedback",
"title": "📍 Feedback zu \u201e" + row['name'] + "\u201c",
"body": data.text.strip()[:120],
"route_id": route_id,
})
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/routes/{id}/elevation — Höhenprofil via OpenTopoData
# ------------------------------------------------------------------
@router.get("/{route_id}/elevation")
async def route_elevation(route_id: int, _user=Depends(get_current_user_optional)):
with db() as conn:
row = conn.execute("SELECT gps_track FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404)
track = json.loads(row["gps_track"] or "[]")
if not track:
return {"elevations": []}
# Bereits mit Höhe gespeichert?
if all(p.get("alt") is not None for p in track):
return {"elevations": [{"lat": p["lat"], "lon": p["lon"], "alt": p["alt"]} for p in track]}
# Auf max. 60 Punkte reduzieren
step = max(1, len(track) // 60)
sample = track[::step]
if track[-1] not in sample:
sample.append(track[-1])
locations = "|".join(f"{p['lat']},{p['lon']}" for p in sample)
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
f"https://api.opentopodata.org/v1/srtm90m?locations={locations}"
)
results = r.json().get("results", [])
return {"elevations": [
{"lat": res["location"]["lat"], "lon": res["location"]["lng"], "alt": res.get("elevation", 0)}
for res in results
]}
except Exception:
return {"elevations": []}