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

@ -0,0 +1,279 @@
"""Ban Yaro — Gamification: Badges, Streaks, Punkte"""
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Tier-Definitionen
# ------------------------------------------------------------------
TIERS = {
"bronze": {"name": "Bronze", "color": "#cd7f32", "dark": "#7c4a0a", "text": "#fff"},
"silber": {"name": "Silber", "color": "#94a3b8", "dark": "#475569", "text": "#fff"},
"gold": {"name": "Gold", "color": "#f59e0b", "dark": "#b45309", "text": "#fff"},
"platin": {"name": "Platin", "color": "#cbd5e1", "dark": "#94a3b8", "text": "#1e293b"},
"diamant": {"name": "Diamant", "color": "#67e8f9", "dark": "#0891b2", "text": "#fff"},
}
TIER_ORDER = ["bronze", "silber", "gold", "platin", "diamant"]
# ------------------------------------------------------------------
# Badge-Kategorien mit Stufen
# ------------------------------------------------------------------
CATEGORIES = [
{
"id": "km",
"name": "Kilometer",
"emoji": "🐾",
"metrik": "total_km",
"einheit": "km",
"stufen": [
("bronze", 5, "Erste Schritte"),
("silber", 25, "Vielläufer"),
("gold", 100, "100-km-Club"),
("platin", 500, "Ausdauer-Profi"),
("diamant", 2000, "Unermüdlich"),
],
},
{
"id": "routen",
"name": "Routen",
"emoji": "🗺️",
"metrik": "routen",
"einheit": "",
"stufen": [
("bronze", 1, "Entdecker"),
("silber", 5, "Kartograph"),
("gold", 20, "Routen-Profi"),
("platin", 50, "Gassi-Legende"),
("diamant", 100, "Route-König"),
],
},
{
"id": "pois",
"name": "POIs",
"emoji": "📍",
"metrik": "pois",
"einheit": "",
"stufen": [
("bronze", 1, "Pfadfinder"),
("silber", 5, "Community-Held"),
("gold", 20, "Botschafter"),
("platin", 50, "Karten-Profi"),
("diamant", 100, "Legende"),
],
},
{
"id": "streak",
"name": "Streak",
"emoji": "🔥",
"metrik": "streak",
"einheit": " Tage",
"stufen": [
("bronze", 3, "Auf Trab"),
("silber", 7, "Wochenheld"),
("gold", 30, "Monatsläufer"),
("platin", 100, "Eiserne Pfote"),
("diamant", 365, "Ein ganzes Jahr"),
],
},
]
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
def _all_badge_ids():
ids = []
for cat in CATEGORIES:
for tier, _, _ in cat["stufen"]:
ids.append(f"{cat['id']}_{tier}")
return ids
# ------------------------------------------------------------------
# Streak aktualisieren
# ------------------------------------------------------------------
def update_streak(user_id: int, conn):
today = date.today().isoformat()
row = conn.execute(
"SELECT current_streak, max_streak, last_activity_date FROM users WHERE id=?",
(user_id,)
).fetchone()
if not row:
return
last = row["last_activity_date"]
cur = row["current_streak"] or 0
mx = row["max_streak"] or 0
if last == today:
return
yesterday = (date.today() - timedelta(days=1)).isoformat()
cur = cur + 1 if last == yesterday else 1
mx = max(mx, cur)
conn.execute(
"UPDATE users SET current_streak=?, max_streak=?, last_activity_date=? WHERE id=?",
(cur, mx, today, user_id)
)
# ------------------------------------------------------------------
# Badges prüfen und vergeben
# ------------------------------------------------------------------
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(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 user_map_pois p WHERE p.user_id=?) AS pois
FROM (SELECT 1)
""", (user_id, user_id, user_id, user_id)).fetchone()
streak_row = conn.execute(
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
metrics = {
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
}
earned = {r["badge_id"] for r in
conn.execute("SELECT badge_id FROM user_badges WHERE user_id=?",
(user_id,)).fetchall()}
new_badges = []
for cat in CATEGORIES:
val = metrics.get(cat["metrik"], 0)
for tier, schwelle, badge_name in cat["stufen"]:
bid = f"{cat['id']}_{tier}"
if bid not in earned and val >= schwelle:
conn.execute(
"INSERT OR IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)",
(user_id, bid)
)
new_badges.append({
"id": bid, "name": badge_name,
"emoji": cat["emoji"],
"tier": TIERS[tier]["name"],
})
return new_badges
# ------------------------------------------------------------------
# API
# ------------------------------------------------------------------
@router.get("/me")
async def my_achievements(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
update_streak(uid, conn)
new_badges = check_and_award(uid, 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(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 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(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
FROM (SELECT 1)
""", (uid, uid, uid, uid, uid, uid, uid, uid)).fetchone()
streak_row = conn.execute(
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
).fetchone()
earned_rows = conn.execute(
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
).fetchall()
earned_ids = {r["badge_id"] for r in earned_rows}
rank_row = conn.execute("""
SELECT COUNT(*)+1 AS rang FROM (
SELECT u.id,
ROUND(COALESCE(SUM(r.distanz_km),0),1)*1
+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
GROUP BY u.id
) WHERE punkte > ?
""", (stats["punkte"] if stats else 0,)).fetchone()
metrics = {
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
}
# Kategorien mit aktuellem Tier + Fortschritt aufbauen
badge_categories = []
for cat in CATEGORIES:
val = metrics.get(cat["metrik"], 0)
current_tier = None
next_tier = None
next_thresh = None
prev_thresh = 0
for tier, schwelle, badge_name in cat["stufen"]:
bid = f"{cat['id']}_{tier}"
if val >= schwelle:
current_tier = {"tier": tier, "badge_id": bid,
"name": badge_name, **TIERS[tier]}
prev_thresh = schwelle
else:
if next_tier is None:
next_tier = {"tier": tier, "badge_id": bid,
"name": badge_name, "schwelle": schwelle,
**TIERS[tier]}
next_thresh = schwelle
break
# Fortschritt zur nächsten Stufe in %
if next_thresh:
progress = round(min((val - prev_thresh) / (next_thresh - prev_thresh) * 100, 99))
else:
progress = 100
badge_categories.append({
"id": cat["id"],
"name": cat["name"],
"emoji": cat["emoji"],
"einheit": cat["einheit"],
"current_value": val,
"current_tier": current_tier,
"next_tier": next_tier,
"progress": progress,
"alle_stufen": [
{
"tier": tier, "schwelle": schwelle, "name": badge_name,
"earned": f"{cat['id']}_{tier}" in earned_ids,
**TIERS[tier],
}
for tier, schwelle, badge_name in cat["stufen"]
],
})
return {
"stats": dict(stats) if stats else {},
"streak": {"current": streak_row["current_streak"] if streak_row else 0,
"max": streak_row["max_streak"] if streak_row else 0},
"rang": rank_row["rang"] if rank_row else 1,
"categories": badge_categories,
"new_badges": new_badges,
}

View file

@ -171,7 +171,11 @@ async def list_users(
u.is_moderator, u.is_banned, u.ban_reason,
u.created_at, u.last_login,
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
(SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count
(SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count,
ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km,
(SELECT COUNT(*) FROM routes r WHERE r.user_id=u.id) AS route_count,
(SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=u.id) AS poi_count,
(SELECT MAX(r.created_at) FROM routes r WHERE r.user_id=u.id) AS last_route
FROM users u
{where}
ORDER BY u.created_at DESC

View file

@ -116,14 +116,20 @@ async def logout(response: Response):
async def get_referral_info(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT referral_code, (SELECT COUNT(*) FROM users WHERE referred_by=?) AS count FROM users WHERE id=?",
"""SELECT referral_code,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=?), 0) AS count
FROM users WHERE id=?""",
(user['id'], user['id'])
).fetchone()
code = row["referral_code"] if row else None
if not code:
code = _gen_referral_code()
conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id']))
base = os.getenv("APP_URL", "https://banyaro.app")
return {
"code": row["referral_code"],
"count": row["count"],
"link": f"{base}/?ref={row['referral_code']}",
"code": code,
"count": row["count"] if row else 0,
"link": f"{base}/?ref={code}",
}

View file

@ -449,6 +449,8 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user
def _guess_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower()
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
return "pdf"
if ct.startswith("video/"):
return "video"
ext = os.path.splitext(filename or "")[1].lower()
@ -475,13 +477,14 @@ async def upload_media(dog_id: int, entry_id: int,
ALLOWED = {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
"video/mp4", "video/quicktime", "video/webm", "video/x-m4v",
"application/pdf",
}
ct = file.content_type or ""
if ct not in ALLOWED:
ext_low = os.path.splitext(file.filename or "")[1].lower()
if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".mp4",".mov",".webm",".m4v"}:
raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
".mp4",".mov",".webm",".m4v",".pdf"}:
raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"

View file

@ -68,9 +68,14 @@ class ResolveReport(BaseModel):
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
_FORUM_ALLOWED_EXT = {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".mp4",".mov",".webm",".m4v",".pdf",".avi"}
def _save_upload(file: UploadFile, data: bytes) -> str:
os.makedirs(FORUM_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
if ext not in _FORUM_ALLOWED_EXT:
raise HTTPException(415, "Dateityp nicht erlaubt.")
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f:
@ -573,7 +578,7 @@ async def members_map():
AND forum_lat IS NOT NULL
AND forum_lon IS NOT NULL"""
).fetchall()
return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 2), 'lon': round(r['lon'], 2)}
return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 3), 'lon': round(r['lon'], 3)}
for r in rows]
@ -583,16 +588,13 @@ async def members_map():
@router.patch("/members/location")
async def set_member_location(data: LocationBody, user=Depends(get_current_user)):
if data.show and data.lat is not None and data.lon is not None:
# Snap to ~1km grid (2 decimal places ≈ 1.1km)
snapped_lat = round(data.lat, 2)
snapped_lon = round(data.lon, 2)
with db() as conn:
conn.execute(
"""UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1
WHERE id=?""",
(snapped_lat, snapped_lon, user['id'])
(round(data.lat, 4), round(data.lon, 4), user['id'])
)
return {"ok": True, "lat": snapped_lat, "lon": snapped_lon}
return {"ok": True, "lat": data.lat, "lon": data.lon}
else:
with db() as conn:
conn.execute(

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": []}

44
backend/routes/stats.py Normal file
View file

@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
_STATS_SQL = """
SELECT u.id, u.name, u.avatar_url,
ROUND(COALESCE(SUM(r.distanz_km), 0), 1) AS total_km,
COUNT(DISTINCT r.id) AS routen,
COUNT(DISTINCT p.id) AS pois,
ROUND(COALESCE(SUM(r.distanz_km), 0), 1) * 1
+ 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
GROUP BY u.id
"""
@router.get("/leaderboard")
async def leaderboard(_user=Depends(get_current_user_optional)):
with db() as conn:
rows = conn.execute(f"""
SELECT * FROM ({_STATS_SQL})
ORDER BY punkte DESC, total_km DESC
LIMIT 20
""").fetchall()
return [dict(r) for r in rows]
@router.get("/me")
async def my_stats(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(f"""
SELECT s.*, rank_tbl.rang FROM ({_STATS_SQL}) s
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY punkte DESC, total_km DESC) AS rang
FROM ({_STATS_SQL})
) rank_tbl ON rank_tbl.id = s.id
WHERE s.id = ?
""", (user["id"],)).fetchone()
return dict(row) if row else {}

156
backend/routes/training.py Normal file
View file

@ -0,0 +1,156 @@
"""BAN YARO — Übungs- & Trainingsfortschritt"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Übungs-Status
# ------------------------------------------------------------------
class ProgressUpdate(BaseModel):
exercise_id: str
status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt
@router.get("/progress")
async def get_progress(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute(
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
(uid,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/progress")
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conn.execute("""
INSERT INTO exercise_progress (user_id, exercise_id, status)
VALUES (?,?,?)
ON CONFLICT(user_id, exercise_id) DO UPDATE
SET status=excluded.status, updated_at=datetime('now')
""", (uid, body.exercise_id, body.status))
return {"ok": True}
# ------------------------------------------------------------------
# Trainingsplan-Checkboxen
# ------------------------------------------------------------------
class PlanProgress(BaseModel):
item_key: str
checked: bool
@router.get("/plan-progress")
async def get_plan_progress(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute(
"SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
(uid,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/plan-progress")
async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
if body.checked:
conn.execute("""
INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked)
VALUES (?,?,1)
""", (uid, body.item_key))
else:
conn.execute(
"DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?",
(uid, body.item_key)
)
return {"ok": True}
# ------------------------------------------------------------------
# Empfehlungen (rule-based)
# ------------------------------------------------------------------
GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus / Lass es', 'Warte']
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
@router.get("/suggestions")
async def get_suggestions(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute(
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
(uid,)
).fetchall()
progress = {r["exercise_id"]: r["status"] for r in rows}
def key(name): return f"grundkommandos_{name.replace(' ', '_').replace('/', '')}"
def tkey(name): return f"tricks_{name.replace(' ', '_').replace('/', '')}"
suggestions = []
# Noch-nicht Übungen — direkte Hilfe
stuck = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'noch-nicht']
if stuck:
suggestions.append({
"type": "help",
"icon": "warning",
"title": f'\u201e{stuck[0]}\u201c klappt noch nicht',
"text": "Mach einen Schritt zurück: Kürzere Einheiten, mehr Leckerlis, weniger Ablenkung. Schau dir die Trainingsgrundlagen an.",
"action_tab": "grundkommandos",
"action_name": stuck[0],
})
# Manchmal-Übungen — intensivieren
almost = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'manchmal']
if almost:
suggestions.append({
"type": "boost",
"icon": "fire",
"title": f'Fast da: \u201e{almost[0]}\u201c',
"text": "Du bist auf dem richtigen Weg! Übe täglich 35 Minuten — dann sitzt es bald.",
"action_tab": "grundkommandos",
"action_name": almost[0],
})
# Nächste ungestartete Grundkommando
grundk_mastered = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'sitzt']
next_up = next((n for n in GRUNDKOMMANDOS_ORDER
if progress.get(key(n)) in (None, 'noch-nicht') and n not in stuck), None)
if len(grundk_mastered) == len(GRUNDKOMMANDOS_ORDER):
# Alle Grundkommandos gemeistert → Tricks empfehlen
next_trick = next((n for n in TRICKS_FIRST if progress.get(tkey(n)) is None), None)
if next_trick:
suggestions.append({
"type": "next",
"icon": "star",
"title": "Alle Grundkommandos sitzen! 🎉",
"text": f'Zeit für Tricks! Starte mit \u201e{next_trick}\u201c \u2014 Spaß garantiert.',
"action_tab": "tricks",
"action_name": next_trick,
})
elif next_up and not almost:
suggestions.append({
"type": "next",
"icon": "arrow-right",
"title": f"Bereit für den nächsten Schritt?",
"text": f'Starte jetzt mit \u201e{next_up}\u201c. {"Du hast bereits " + str(len(grundk_mastered)) + " Grundkommandos gemeistert!" if grundk_mastered else "Es ist die perfekte Basis für alles Weitere."}',
"action_tab": "grundkommandos",
"action_name": next_up,
})
elif not progress:
# Noch gar kein Fortschritt
suggestions.append({
"type": "start",
"icon": "flag",
"title": "Womit fangen wir an?",
"text": "\u201eSitz\u201c ist das erste Kommando für jeden Hund \u2014 einfach, schnell erlernt und die Basis für alles.",
"action_tab": "grundkommandos",
"action_name": "Sitz",
})
return suggestions[:2] # max 2 Empfehlungen