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:
parent
390176383f
commit
9a78121a3e
25 changed files with 2487 additions and 248 deletions
156
backend/routes/training.py
Normal file
156
backend/routes/training.py
Normal 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 3–5 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue