From 44081a6b9d29babcab3f76c784cc4c2a2b6bfcaf Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 22 Apr 2026 19:41:22 +0200 Subject: [PATCH] Session 2026-04-22: Training, Fixes, KI-Cloud, Dark-Mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Training-System: - Einheit-Dialog Bugs behoben (UI.toast callable, _dogId via _appState, activeDog.id) - Virtueller Trainer (rein statistisch): üben/festigen/entdecken/levelup Empfehlungen auf Basis exercise_progress + sessions, Prognose bis 80% - Stand erfassen Modal: alle Übungen auf einmal setzen (onboarding) - Erfolgsindikatoren auf Karten: Ø-Quote + Trend-Pfeil + Anzahl Sessions - exercise_progress → synthetische Stats im Trainer (ohne Sessions nutzbar) - Levelup: Tricks empfehlen wenn ≥4 Grundkommandos sitzen - Kommandos & Fähigkeiten im Hundeprofil + öffentlichem Profil - 2 neue Problemverhalten-Übungen: Bellen/Kläffen, Enttriggern Mobile/UI-Fixes: - Übungskarten: Name + Difficulty oben, Buttons eigene Zeile (kein Umbruch) - Trainingsgrundlagen: Padding in allen Karten, Hinweis-Boxen Dark-Mode-sicher - Tab-Sichtbarkeit: Trainer/Suggestions nur auf Übungs-Tabs - Tagebuch FAB (Neu-Eintrag Button) + Quick-Add Eintrag - FAB Abstand fix (nav-bottom-height + safe-bottom) - Suggestion-Karten rgba (Dark-Mode) - routes.js + uebungen.js: alle Hellfarben → rgba (Dark-Mode-sicher) - ui.js: UI.toast als callable Function-Object (war nur plain Object) KI & Backend: - KI_MODE=cloud + ANTHROPIC_API_KEY gesetzt - ki.py: Cloud-Fallback wenn local nicht erreichbar + KI_MODE=cloud - KI-Trainer Tageslimit 10 Anfragen/User + ki_daily_calls Tabelle - Admin-Panel: KI-Nutzung (heute/Monat/User) - Status-Report Fix (lost-Tabelle) → 06:00 + 18:00 täglich - Wiki-Anreicherung läuft jetzt (50 Rassen Startup, 20/Nacht) - landing.html: Trainings-Features in JSON-LD + Feature-Karten --- backend/ki.py | 3 +- backend/routes/admin.py | 17 + backend/routes/dogs.py | 68 ++- backend/routes/training.py | 293 ++++++++++++- backend/scheduler.py | 9 +- backend/static/js/api.js | 2 + backend/static/js/app.js | 4 +- backend/static/js/pages/admin.js | 16 + backend/static/js/pages/diary.js | 16 + backend/static/js/pages/dog-profile.js | 69 +++ backend/static/js/pages/routes.js | 44 +- backend/static/js/pages/trainingsplaene.js | 2 +- backend/static/js/pages/uebungen.js | 480 ++++++++++++++++++--- backend/static/js/ui.js | 15 +- backend/static/landing.html | 15 +- backend/static/sw.js | 2 +- 16 files changed, 938 insertions(+), 117 deletions(-) diff --git a/backend/ki.py b/backend/ki.py index c4f6ec2..e4b87ff 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -107,7 +107,8 @@ async def complete( return await _local_complete(prompt, system, max_tokens, json_mode) except Exception as e: logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}") - if requires_premium and user_is_premium and ANTHROPIC_KEY: + # Cloud-Fallback: im cloud-Modus immer, sonst nur für Premium-User + if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)): logger.info("Fallback auf Cloud-KI.") return await _cloud_complete(prompt, system, max_tokens, json_mode) raise KIUnavailableError( diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 7168bd2..14874ab 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -148,6 +148,20 @@ async def stats(user=Depends(require_mod)): ).fetchall() } + # KI-Trainer Nutzung + try: + ki_today = conn.execute( + "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date=DATE('now')" + ).fetchone()[0] + ki_month = conn.execute( + "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','start of month')" + ).fetchone()[0] + ki_users_today = conn.execute( + "SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')" + ).fetchone()[0] + except Exception: + ki_today = ki_month = ki_users_today = 0 + return { "users_total": users_total, "users_today": users_today, @@ -165,6 +179,9 @@ async def stats(user=Depends(require_mod)): "osm_total": osm_total, "osm_tiles": osm_tiles, "osm_by_type": osm_by_type, + "ki_today": ki_today, + "ki_month": ki_month, + "ki_users_today": ki_users_today, } diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 596a762..26f07a8 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -218,20 +218,80 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)): ) +# ------------------------------------------------------------------ +# Fähigkeiten / Kommandos (für Profil + öffentliche Seite) +# ------------------------------------------------------------------ + +def _parse_exercise_name(exercise_id: str) -> str: + """grundkommandos_Hier__Komm → 'Hier / Komm'""" + parts = exercise_id.split("_", 1) + if len(parts) < 2: + return exercise_id + return parts[1].replace("__", " / ").replace("_", " ") + + +def _load_skills(conn, dog_id: int, user_id: int) -> list: + """Gibt Übungen mit Status 'sitzt' oder 'meistens' zurück, die mit diesem Hund trainiert wurden.""" + rows = conn.execute( + """ + SELECT ep.exercise_id, ep.status, + (SELECT ts.exercise_name FROM training_sessions ts + WHERE ts.user_id = ep.user_id AND ts.dog_id = ? + AND ts.exercise_id = ep.exercise_id + ORDER BY ts.datum DESC, ts.created_at DESC LIMIT 1) AS exercise_name + FROM exercise_progress ep + WHERE ep.user_id = ? + AND ep.status IN ('sitzt', 'meistens') + AND EXISTS (SELECT 1 FROM training_sessions ts2 + WHERE ts2.user_id = ep.user_id AND ts2.dog_id = ? + AND ts2.exercise_id = ep.exercise_id) + ORDER BY ep.status DESC, ep.exercise_id + """, + (dog_id, user_id, dog_id) + ).fetchall() + + return [ + { + "exercise_id": r["exercise_id"], + "exercise_name": r["exercise_name"] or _parse_exercise_name(r["exercise_id"]), + "status": r["status"], + "tab": r["exercise_id"].split("_")[0], + } + for r in rows + ] + + +@router.get("/{dog_id}/skills") +async def get_dog_skills(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + dog = conn.execute( + "SELECT id, user_id FROM dogs WHERE id=? AND (user_id=? OR id IN (SELECT dog_id FROM sitting_access WHERE friend_id=? AND expires_at > datetime('now')))", + (dog_id, uid, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return _load_skills(conn, dog_id, dog["user_id"]) + + # Öffentliches Profil (für NFC-Tag, kein Login nötig) @router.get("/public/{dog_id}") async def public_dog_profile(dog_id: int): with db() as conn: dog = conn.execute( """SELECT d.id, d.name, d.rasse, d.geburtstag, d.foto_url, d.bio, - u.name as besitzer_name + d.user_id, u.name as besitzer_name FROM dogs d JOIN users u ON d.user_id=u.id WHERE d.id=? AND d.is_public=1""", (dog_id,) ).fetchone() - if not dog: - raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.") - return dict(dog) + if not dog: + raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.") + skills = _load_skills(conn, dog_id, dog["user_id"]) + result = dict(dog) + result.pop("user_id", None) + result["skills"] = skills + return result class FoundReport(BaseModel): diff --git a/backend/routes/training.py b/backend/routes/training.py index b05c802..c1f8dbc 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -490,13 +490,267 @@ async def get_stats(dog_id: int, user=Depends(get_current_user)): } +# ------------------------------------------------------------------ +# Virtueller Trainer — statistikbasierte Empfehlungen +# ------------------------------------------------------------------ + +import math +from collections import defaultdict + +def _recommend_reps(recent_avg: float, last_reps: int, last_mood: str) -> int: + base = last_reps or 5 + if recent_avg >= 80: + reps = min(base + 1, 10) + elif recent_avg >= 60: + reps = base + elif recent_avg < 40: + reps = max(base - 2, 2) + else: + reps = max(base - 1, 3) + if last_mood == "müde": + reps = max(reps - 1, 2) + elif last_mood == "super": + reps = min(reps + 1, 10) + return reps + + +def _reason(type_: str, s: dict) -> str: + days, recent, trend = s["days_since"], s["recent_avg"], s["trend"] + if type_ == "üben": + if trend == "declining": + return f"Erfolgsquote fällt – zuletzt {round(recent)}%." + if recent < 40: + return f"Noch Luft nach oben: {round(recent)}% zuletzt." + return f"Braucht Aufmerksamkeit: {round(recent)}% Erfolg." + if type_ == "festigen": + if trend == "improving": + return f"Guter Fortschritt auf {round(recent)}% – dran bleiben." + if days >= 3: + return f"Seit {days} Tagen nicht geübt – kurz auffrischen." + return f"Läuft gut ({round(recent)}%) – festigen." + # entdecken + return f"Seit {days} Tag{'en' if days != 1 else ''} nicht geübt." + + +# Synthetische Stats wenn nur exercise_progress vorhanden (keine Sessions) +_STATUS_SYNTHETIC = { + 'noch-nicht': {'recent_avg': 25.0, 'trend': 'new', 'days_since': 5, 'suggested_reps': 3}, + 'manchmal': {'recent_avg': 45.0, 'trend': 'stable', 'days_since': 7, 'suggested_reps': 5}, + 'meistens': {'recent_avg': 72.0, 'trend': 'stable', 'days_since': 5, 'suggested_reps': 5}, + 'sitzt': {'recent_avg': 92.0, 'trend': 'stable', 'days_since': 14, 'suggested_reps': 7}, +} + +def _ex_key(tab: str, name: str) -> str: + return f"{tab}_{name.replace(' ', '_').replace('/', '')}" + +def _parse_ex_name(exercise_id: str) -> str: + parts = exercise_id.split("_", 1) + if len(parts) < 2: + return exercise_id + return parts[1].replace("__", " / ").replace("_", " ") + + +@router.get("/recommendations") +async def get_recommendations(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + today = datetime.date.today() + + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + rows = conn.execute( + """ + SELECT exercise_id, exercise_name, datum, wiederholungen, + erfolgsquote, hund_stimmung + FROM training_sessions + WHERE user_id=? AND dog_id=? + ORDER BY datum DESC, created_at DESC + """, + (uid, dog_id) + ).fetchall() + + progress_rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() + + progress = {r["exercise_id"]: r["status"] for r in progress_rows} + + # --- Session-basierte Stats --- + by_ex = defaultdict(list) + for r in rows: + by_ex[r["exercise_id"]].append(dict(r)) + + stats = [] + session_ids = set() + + for ex_id, sessions in by_ex.items(): + session_ids.add(ex_id) + last = sessions[0] + last_date = datetime.date.fromisoformat(last["datum"]) + days_since = (today - last_date).days + + all_success = [s["erfolgsquote"] for s in sessions] + avg_success = sum(all_success) / len(all_success) + recent_avg = sum(s["erfolgsquote"] for s in sessions[:3]) / len(sessions[:3]) + + older = sessions[3:6] + if older: + older_avg = sum(s["erfolgsquote"] for s in older) / len(older) + diff = recent_avg - older_avg + trend = "improving" if diff > 10 else ("declining" if diff < -10 else "stable") + else: + trend = "new" + + prognose = None + if trend == "improving" and older: + improvement = recent_avg - (sum(s["erfolgsquote"] for s in older) / len(older)) + if improvement > 0 and recent_avg < 80: + prognose = max(1, math.ceil((80 - recent_avg) / (improvement / 3))) + + stats.append({ + "exercise_id": ex_id, + "exercise_name": last["exercise_name"], + "tab": ex_id.split("_")[0], + "session_count": len(sessions), + "avg_success": round(avg_success, 1), + "recent_avg": round(recent_avg, 1), + "trend": trend, + "days_since": days_since, + "suggested_reps": _recommend_reps(recent_avg, last["wiederholungen"], last["hund_stimmung"]), + "prognose_sessions": prognose, + "from_status": False, + }) + + # --- Status-basierte synthetische Stats (Übungen ohne Sessions) --- + for ex_id, status in progress.items(): + if ex_id in session_ids or status not in _STATUS_SYNTHETIC: + continue + synth = _STATUS_SYNTHETIC[status] + stats.append({ + "exercise_id": ex_id, + "exercise_name": _parse_ex_name(ex_id), + "tab": ex_id.split("_")[0], + "session_count": 0, + "avg_success": synth["recent_avg"], + "recent_avg": synth["recent_avg"], + "trend": synth["trend"], + "days_since": synth["days_since"], + "suggested_reps": synth["suggested_reps"], + "prognose_sessions": None, + "from_status": True, + }) + + if not stats: + return {"recommendations": [], "all_stats": {}, "summary": { + "total_exercises_trained": 0, "exercises_on_track": 0, "exercises_need_work": 0}} + + # --- Levelup: Tricks empfehlen wenn Grundlage stark --- + grundk_ids = [_ex_key("grundkommandos", n) for n in GRUNDKOMMANDOS_ORDER] + grundk_mastered = [eid for eid in grundk_ids if progress.get(eid) in ("sitzt", "meistens")] + levelup_rec = None + if len(grundk_mastered) >= 4: + tricks_pairs = [(_ex_key("tricks", n), n) for n in TRICKS_FIRST] + first_trick = next( + ((eid, name) for eid, name in tricks_pairs + if progress.get(eid) is None and eid not in session_ids), + None + ) + if first_trick: + eid, name = first_trick + levelup_rec = { + "exercise_id": eid, + "exercise_name": name, + "tab": "tricks", + "session_count": 0, + "avg_success": 0.0, + "recent_avg": 0.0, + "trend": "new", + "days_since": 0, + "suggested_reps": 5, + "prognose_sessions": None, + "from_status": True, + "type": "levelup", + "reason": f"{len(grundk_mastered)} Grundkommandos sitzen — Zeit für den nächsten Level!", + } + + # --- Auswahl: üben / festigen / levelup-oder-entdecken --- + def needs_work_score(s): + score = min(s["days_since"], 7) * 0.3 + (100 - s["recent_avg"]) * 0.5 + if s["trend"] == "declining": + score += 20 + return score + + not_today = [s for s in stats if s["days_since"] > 0] + needs_work = sorted([s for s in not_today if s["recent_avg"] < 70], key=needs_work_score, reverse=True) + festigen = sorted([s for s in not_today if s["recent_avg"] >= 70], key=lambda s: s["days_since"], reverse=True) + entdecken = sorted([s for s in not_today if s["days_since"] >= 5], key=lambda s: s["days_since"], reverse=True) + + recs = [] + used = set() + + for pool, type_ in [(needs_work, "üben"), (festigen, "festigen")]: + for s in pool: + if s["exercise_id"] not in used: + recs.append({**s, "type": type_, "reason": _reason(type_, s)}) + used.add(s["exercise_id"]) + break + + # Slot 3: levelup hat Vorrang vor entdecken + if levelup_rec and levelup_rec["exercise_id"] not in used: + recs.append(levelup_rec) + used.add(levelup_rec["exercise_id"]) + else: + for s in entdecken: + if s["exercise_id"] not in used: + recs.append({**s, "type": "entdecken", "reason": _reason("entdecken", s)}) + used.add(s["exercise_id"]) + break + + # Auffüllen + if len(recs) < 3: + for s in sorted(not_today, key=lambda s: s["days_since"], reverse=True): + if len(recs) >= 3: + break + if s["exercise_id"] not in used: + recs.append({**s, "type": "üben", "reason": _reason("üben", s)}) + used.add(s["exercise_id"]) + + all_stats = { + s["exercise_id"]: { + "recent_avg": s["recent_avg"], + "session_count": s["session_count"], + "trend": s["trend"], + "days_since": s["days_since"], + } + for s in stats + } + + return { + "recommendations": recs[:3], + "all_stats": all_stats, + "summary": { + "total_exercises_trained": len(stats), + "exercises_on_track": len([s for s in stats if s["recent_avg"] >= 70]), + "exercises_need_work": len([s for s in stats if s["recent_avg"] < 60]), + }, + } + + class KiFeedbackRequest(BaseModel): dog_id: int +KI_DAILY_LIMIT = 10 + @router.post("/ki-feedback") async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): uid = user["id"] + today = datetime.date.today().isoformat() with db() as conn: dog = conn.execute( @@ -523,7 +777,31 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): ).fetchone()[0] if age_hours < 6 and new_since == 0: - return {"feedback": cache_row["feedback"], "cached": True} + daily_used = conn.execute( + "SELECT COALESCE(count,0) FROM ki_daily_calls WHERE user_id=? AND date=?", + (uid, today) + ).fetchone() + used = daily_used[0] if daily_used else 0 + return {"feedback": cache_row["feedback"], "cached": True, + "daily_used": used, "daily_limit": KI_DAILY_LIMIT} + + # Tageslimit prüfen (nur bei echtem API-Aufruf) + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_daily_calls ( + user_id INTEGER NOT NULL, + date TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, date) + ) + """) + row = conn.execute( + "SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?", + (uid, today) + ).fetchone() + daily_used = row[0] if row else 0 + + if daily_used >= KI_DAILY_LIMIT: + raise HTTPException(429, f"Tages-Limit erreicht ({KI_DAILY_LIMIT} Anfragen/Tag). Morgen wieder verfügbar.") # Hund-Alter berechnen alter_str = "unbekannt" @@ -599,7 +877,7 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): except (ki.KIUnavailableError, ki.KIPremiumRequired) as e: raise HTTPException(503, str(e)) - # Cache speichern + # Cache speichern + Tageszähler erhöhen with db() as conn: conn.execute( """ @@ -610,5 +888,14 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): """, (body.dog_id, feedback_text) ) + conn.execute(""" + INSERT INTO ki_daily_calls (user_id, date, count) VALUES (?, ?, 1) + ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1 + """, (uid, today)) + new_count = conn.execute( + "SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?", + (uid, today) + ).fetchone()[0] - return {"feedback": feedback_text, "cached": False} + return {"feedback": feedback_text, "cached": False, + "daily_used": new_count, "daily_limit": KI_DAILY_LIMIT} diff --git a/backend/scheduler.py b/backend/scheduler.py index 4702f89..e65fcc5 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -106,8 +106,8 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) - # 4× täglich Status-Report per Mail (07:00, 13:00, 19:00, 01:00) - for _h in [7, 13, 19, 1]: + # 2× täglich Status-Report per Mail (06:00, 18:00) + for _h in [6, 18]: _scheduler.add_job( _job_status_report, CronTrigger(hour=_h, minute=0), @@ -834,7 +834,10 @@ async def _job_status_report(): metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0] - metrics["lost_active"] = conn.execute("SELECT COUNT(*) FROM lost WHERE gefunden=0").fetchone()[0] + try: + metrics["lost_active"] = conn.execute("SELECT COUNT(*) FROM lost WHERE gefunden=0").fetchone()[0] + except Exception: + metrics["lost_active"] = 0 # Wiki-Interesse try: diff --git a/backend/static/js/api.js b/backend/static/js/api.js index e55833c..f3b874c 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -107,6 +107,7 @@ const API = (() => { return patch(`/dogs/${id}/photo-position`, { zoom, offset_x: offsetX, offset_y: offsetY }); }, deletePhoto(id) { return del(`/dogs/${id}/photo`); }, + getSkills(id) { return get(`/dogs/${id}/skills`); }, }; // ---------------------------------------------------------- @@ -250,6 +251,7 @@ const API = (() => { getSuggestions() { return get('/training/suggestions'); }, getPlanProgress() { return get('/training/plan-progress'); }, setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); }, + getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a20c012..ca369bb 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '268'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '290'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -387,6 +387,7 @@ const App = (() => { title: 'Schnellmeldung', body: `
+ ${authBtn('diary', 'btn-primary', 'book-open', 'Tagebucheintrag schreiben')} @@ -411,6 +412,7 @@ const App = (() => { // Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort. setTimeout(() => { if (action.startsWith('auth-')) { navigate('settings'); return; } + if (action === 'diary') { navigate('diary'); setTimeout(() => pages['diary'].module?.openNew?.(), 400); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } if (action === 'lost') { navigate('lost'); setTimeout(() => pages['lost'].module?.openNew?.(), 400); } diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 0dbd689..1b8ca90 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -201,6 +201,22 @@ window.Page_admin = (() => { ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')}
+
+

KI-Trainer Nutzung (Claude API)

+
+ ${[ + ['Anfragen heute', s.ki_today, 'var(--c-primary)'], + ['Anfragen diesen Monat', s.ki_month, 'var(--c-text-secondary)'], + ['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'], + ].map(([label, val, color]) => ` +
+ ${label} + ${val ?? 0} +
+ `).join('')} +
+
+

OSM-Cache nach Typ

diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 847bf1a..d21aeed 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -187,6 +187,20 @@ window.Page_diary = (() => { + + `; _container.querySelector('#diary-milestone-filter') @@ -201,6 +215,8 @@ window.Page_diary = (() => { _container.querySelector('#diary-import-btn') ?.addEventListener('click', _showImport); + _container.querySelector('#diary-fab') + ?.addEventListener('click', () => _showForm(null)); _container.querySelector('#diary-btn-more') ?.addEventListener('click', () => _loadMore()); diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 8a8b64e..dddb750 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -148,6 +148,8 @@ window.Page_dog_profile = (() => {
` : ''} +
+ ${dog.is_public ? `
+ + ${_esc(skill.exercise_name)} + `; + }; + + const sitztBlock = sitzt.length ? ` +
+
Sitzt
+
+ ${sitzt.map(s => badge(s, 'sitzt')).join('')} +
+
` : ''; + + const meistensBlock = meistens.length ? ` +
+
Übt noch
+
+ ${meistens.map(s => badge(s, 'meistens')).join('')} +
+
` : ''; + + el.innerHTML = ` +
+
+ + + Kommandos & Fähigkeiten + +
+ ${sitztBlock} + ${meistensBlock} +
`; + } + // ---------------------------------------------------------- // SITTER-ZUGANG // ---------------------------------------------------------- diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 51c56f2..8641995 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -55,9 +55,9 @@ window.Page_routes = (() => { const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' }; const HUNDE_TEXT = { eingeschränkt: 'Leine', gut: 'gut', sehr_gut: 'sehr gut', premium: 'premium' }; - const DIFF_COLOR = { leicht: 'background:#dcfce7;color:#15803d;border-color:#bbf7d0', - mittel: 'background:#fef9c3;color:#92400e;border-color:#fde68a', - anspruchsvoll: 'background:#fee2e2;color:#991b1b;border-color:#fecaca' }; + const DIFF_COLOR = { leicht: 'background:rgba(22,163,74,0.10);color:#4ade80;border-color:rgba(22,163,74,0.30)', + mittel: 'background:rgba(234,179,8,0.10);color:#facc15;border-color:rgba(234,179,8,0.30)', + anspruchsvoll: 'background:rgba(220,38,38,0.10);color:#f87171;border-color:rgba(220,38,38,0.30)' }; // POI-Typen entlang der Route — nur relevante/interessante Orte const NEARBY_TYPES = [ @@ -981,14 +981,14 @@ window.Page_routes = (() => { ${authorLine}
${UI.escape(r.name)}
- ${dist ? _pill(dist, '#f1f5f9','#475569','#e2e8f0') : ''} - ${dur ? _pill(dur, '#f1f5f9','#475569','#e2e8f0') : ''} + ${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} + ${dur ? _pill(dur, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${diffLabel ? _pill(diffLabel, - ({leicht:'#dcfce7',mittel:'#fef9c3',anspruchsvoll:'#fee2e2'})[r.schwierigkeit]||'#f1f5f9', - ({leicht:'#15803d',mittel:'#92400e',anspruchsvoll:'#991b1b'})[r.schwierigkeit]||'#475569', - ({leicht:'#bbf7d0',mittel:'#fde68a',anspruchsvoll:'#fecaca'})[r.schwierigkeit]||'#e2e8f0') : ''} - ${r.hunde_tauglichkeit ? _pill(HUNDE_TEXT[r.hunde_tauglichkeit]||'', '#fef3c7','#92400e','#fde68a') : ''} - ${!isDiscover && !r.is_public ? _pill('Privat','#dbeafe','#1d4ed8','#bfdbfe') : ''} + ({leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'})[r.schwierigkeit]||'rgba(107,114,128,0.10)', + ({leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'})[r.schwierigkeit]||'#9ca3af', + ({leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'})[r.schwierigkeit]||'rgba(107,114,128,0.30)') : ''} + ${r.hunde_tauglichkeit ? _pill(HUNDE_TEXT[r.hunde_tauglichkeit]||'', 'rgba(234,179,8,0.10)','#facc15','rgba(234,179,8,0.30)') : ''} + ${!isDiscover && !r.is_public ? _pill('Privat','rgba(59,130,246,0.10)','#60a5fa','rgba(59,130,246,0.30)') : ''}
@@ -1753,19 +1753,19 @@ window.Page_routes = (() => { margin-bottom:var(--space-3);background:var(--c-surface-2)">
${photoGallery}
- ${route.distanz_km ? _pill(route.distanz_km.toFixed(1)+' km', '#f1f5f9','#475569','#e2e8f0') : ''} - ${route.dauer_min ? _pill(_fmtDur(route.dauer_min), '#f1f5f9','#475569','#e2e8f0') : ''} + ${route.distanz_km ? _pill(route.distanz_km.toFixed(1)+' km', 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} + ${route.dauer_min ? _pill(_fmtDur(route.dauer_min), 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${route.schwierigkeit ? _pill(DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit, - ({leicht:'#dcfce7',mittel:'#fef9c3',anspruchsvoll:'#fee2e2'})[route.schwierigkeit]||'#f1f5f9', - ({leicht:'#15803d',mittel:'#92400e',anspruchsvoll:'#991b1b'})[route.schwierigkeit]||'#475569', - ({leicht:'#bbf7d0',mittel:'#fde68a',anspruchsvoll:'#fecaca'})[route.schwierigkeit]||'#e2e8f0') : ''} - ${route.hunde_tauglichkeit ? _pill(HUNDE_TEXT[route.hunde_tauglichkeit]||route.hunde_tauglichkeit,'#fef3c7','#92400e','#fde68a') : ''} + ({leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'})[route.schwierigkeit]||'rgba(107,114,128,0.10)', + ({leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'})[route.schwierigkeit]||'#9ca3af', + ({leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'})[route.schwierigkeit]||'rgba(107,114,128,0.30)') : ''} + ${route.hunde_tauglichkeit ? _pill(HUNDE_TEXT[route.hunde_tauglichkeit]||route.hunde_tauglichkeit,'rgba(234,179,8,0.10)','#facc15','rgba(234,179,8,0.30)') : ''} ${isOwn ? `` - : _pill(route.is_public?'Öffentlich':'Privat', route.is_public?'#dcfce7':'#dbeafe', route.is_public?'#15803d':'#1d4ed8', route.is_public?'#bbf7d0':'#bfdbfe') + : _pill(route.is_public?'Öffentlich':'Privat', route.is_public?'rgba(22,163,74,0.10)':'rgba(59,130,246,0.10)', route.is_public?'#4ade80':'#60a5fa', route.is_public?'rgba(22,163,74,0.30)':'rgba(59,130,246,0.30)') }
${route.beschreibung ? `

${UI.escape(route.beschreibung)}

` : ''} @@ -1855,9 +1855,9 @@ window.Page_routes = (() => { route.is_public = !route.is_public; if (pill) { pill.style.cssText = _pillStyle( - route.is_public ? '#dcfce7' : '#dbeafe', - route.is_public ? '#15803d' : '#1d4ed8', - route.is_public ? '#bbf7d0' : '#bfdbfe') + 'cursor:pointer;'; + route.is_public ? 'rgba(22,163,74,0.10)' : 'rgba(59,130,246,0.10)', + route.is_public ? '#4ade80' : '#60a5fa', + route.is_public ? 'rgba(22,163,74,0.30)' : 'rgba(59,130,246,0.30)') + 'cursor:pointer;'; pill.innerHTML = route.is_public ? 'Öffentlich' : 'Privat'; pill.title = route.is_public ? 'Auf Privat setzen' : 'Auf Öffentlich setzen'; } diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js index ad504d4..e22c707 100644 --- a/backend/static/js/pages/trainingsplaene.js +++ b/backend/static/js/pages/trainingsplaene.js @@ -15,7 +15,7 @@ window.Page_trainingsplaene = (() => { // API HELPERS // ---------------------------------------------------------- function _dogId() { - return window.App?.state?.activeDogId || null; + return _appState?.activeDog?.id || null; } async function _apiGet(url) { diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index e47ef23..30b1ff9 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -13,7 +13,7 @@ window.Page_uebungen = (() => { // API HELPERS // ---------------------------------------------------------- function _dogId() { - return window.App?.state?.activeDogId || null; + return _appState?.activeDog?.id || null; } async function _apiPost(url, body) { @@ -65,7 +65,8 @@ window.Page_uebungen = (() => { } // In-memory cache (loaded from API on init) - let _progressCache = {}; // key → statusId + let _progressCache = {}; // key → statusId + let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend} function _progressKey(tab, name) { return `${tab}_${name.replace(/[\s/]+/g, '_')}`; @@ -381,6 +382,52 @@ window.Page_uebungen = (() => { steigerung: null, hinweis: 'Hilfsmittel bei hartnäckigem Ziehen: Brustgeschirr mit vorderer Befestigung (kein Würge- oder Stachelband).', }, + { + name: 'Bellen / Kläffen', + schwierigkeit: 'Mittel', + alter: 'Ab 8 Wochen', + dauer: '5–10 Min täglich, konsequent', + material: 'Leckerlis, Geduld, Konsequenz', + beschreibung: 'Der Hund bellt nicht übermäßig auf Reize wie Klingel, Passanten oder andere Hunde — und beruhigt sich auf ein Signal hin.', + schritte: [ + 'Ursache kennen: Alarm-Bellen (Schutzinstinkt), Frust-Bellen (Leine, Zaun), Aufmerksamkeits-Bellen oder Angst-Bellen haben unterschiedliche Lösungswege.', + 'Aufmerksamkeits-Bellen nie belohnen — auch nicht durch Zuwendung, Schimpfen oder Anfassen.', + 'Signal „Ruhig" einführen: warten bis der Hund kurz pausiert, sofort markieren + belohnen.', + 'Klingel / Türgeräusche: Reiz kontrolliert einüben (selbst klingeln), Hund ins Körbchen schicken und dort belohnen.', + 'Bellt der Hund draußen auf Reize: Blickkontakt unterbrechen, Hund wegführen, Ablenkung mit Leckerli erst wenn er ruhig ist.', + 'Ruhiges Verhalten konsequent belohnen — Hund lernt: Stille bringt Aufmerksamkeit, Bellen bringt nichts.', + ], + fehler: [ + 'Schreien oder „Aus!"-Rufen während des Bellens — der Hund interpretiert es als Mitmachen.', + 'Leckerli geben während der Hund noch bellt — das belohnt das Bellen.', + 'Strafe: führt zu Angst, nicht zu weniger Bellen.', + ], + steigerung: 'Reiz gezielt aufbauen (z.B. Klingel-Ton am Handy), erst in ruhiger Umgebung üben, dann mit echten Auslösern.', + hinweis: 'Anhaltend starkes Bellen kann auf Angst oder unerfüllte Bedürfnisse (Bewegung, Beschäftigung) hinweisen. Im Zweifel Verhaltensberater hinzuziehen.', + }, + { + name: 'Enttriggern / Desensibilisierung', + schwierigkeit: 'Fortgeschrittener Anfänger', + alter: 'Ab 12 Wochen', + dauer: '5–15 Min, mehrmals wöchentlich', + material: 'Hochwertige Leckerlis, Geduld, Distanz', + beschreibung: 'Der Hund bleibt unter einem Reiz (Hund, Mensch, Geräusch, Fahrrad…) entspannt — kein Bellen, kein Zerren, keine Überreaktion.', + schritte: [ + 'Trigger identifizieren: Was genau löst die Reaktion aus? Ab welcher Distanz beginnt sie?', + 'Schwellenabstand ermitteln: Die Entfernung, bei der der Hund den Reiz wahrnimmt aber noch nicht überreagiert — dort arbeiten.', + 'Reiz zeigen → sofort hochwertiges Leckerli geben, bevor der Hund reagiert (Gegenkonditionierung: Reiz = super Sache).', + 'Distanz ganz langsam verringern, nur wenn der Hund auf der aktuellen Stufe entspannt bleibt.', + 'Reagiert der Hund doch: ruhig mehr Abstand schaffen, keine Strafe — er war schlicht zu nah.', + 'Ziel: Hund schaut Trigger an und dreht sich dann entspannt zu dir um (Blickwechsel als Zeichen von Entspannung).', + ], + fehler: [ + 'Zu schnell zu nah — der Hund gerät über die Reizschwelle und übt die Überreaktion ein.', + 'Hund zwingen den Trigger anzuschauen oder ihm entgegenzugehen.', + 'Training abbrechen wenn Hund bereits reagiert — lieber Abstand nehmen und neu ansetzen.', + ], + steigerung: 'Erst mit einem einzelnen, vorhersehbaren Reiz üben. Dann wechselnde Reize, engere Distanz, schließlich Bewegung des Triggers.', + hinweis: 'Bei starker Reaktivität oder Aggression unbedingt mit einem zertifizierten Verhaltenstherapeuten (IAABC, BHV) zusammenarbeiten.', + }, ]; // ---------------------------------------------------------- @@ -418,6 +465,9 @@ window.Page_uebungen = (() => { // Stats + Badges laden _loadStatsAndBadges(); + + // Virtueller Trainer laden + _loadVirtualTrainer(); } async function _loadStatsAndBadges() { @@ -437,6 +487,7 @@ window.Page_uebungen = (() => { _statsData = null; _badgesData = null; _loadStatsAndBadges(); + _loadVirtualTrainer(); } // ---------------------------------------------------------- @@ -446,11 +497,25 @@ window.Page_uebungen = (() => { _container.innerHTML = `
${_renderTabs()} -
+
+ +
+
+
`; + _container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal); _bindTabs(); _renderContent(); _renderStatsBanner(); @@ -506,6 +571,231 @@ window.Page_uebungen = (() => { `; } + // ---------------------------------------------------------- + // VIRTUELLER TRAINER + // ---------------------------------------------------------- + async function _loadVirtualTrainer() { + const dogId = _dogId(); + const el = _container?.querySelector('#ueb-trainer'); + if (!el) return; + if (!dogId) { el.innerHTML = ''; return; } + + el.innerHTML = ` +
+
Lade Trainingsplan…
+
`; + + const data = await API.training.getRecommendations(dogId).catch(() => null); + if (!data) { el.innerHTML = ''; return; } + + if (data.all_stats) { + _exerciseStats = data.all_stats; + _renderContent(); + } + + if (!data.recommendations?.length) { el.innerHTML = ''; return; } + _renderVirtualTrainer(el, data); + } + + function _renderVirtualTrainer(el, data) { + const recs = data.recommendations; + const TYPE_CFG = { + 'üben': { label: 'Üben', bg: 'rgba(234,88,12,0.10)', border: 'rgba(234,88,12,0.30)', text: '#fb923c', icon: 'fire' }, + 'festigen': { label: 'Festigen', bg: 'rgba(22,163,74,0.10)', border: 'rgba(22,163,74,0.30)', text: '#4ade80', icon: 'star' }, + 'entdecken': { label: 'Auffrischen',bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)', icon: 'clock' }, + 'levelup': { label: 'Nächster Level', bg: 'rgba(234,179,8,0.10)', border: 'rgba(234,179,8,0.30)', text: '#facc15', icon: 'graduation-cap' }, + }; + const TREND_ICON = { improving: '↑', declining: '↓', stable: '→', new: '★' }; + const TREND_COLOR = { improving: 'var(--c-success,#16a34a)', declining: '#dc2626', stable: 'var(--c-text-secondary)', new: 'var(--c-primary)' }; + + const cards = recs.map((r, i) => { + const cfg = TYPE_CFG[r.type] || TYPE_CFG['üben']; + const trend = TREND_ICON[r.trend] || ''; + const trendColor = TREND_COLOR[r.trend] || 'var(--c-text-secondary)'; + const prognose = r.prognose_sessions + ? `
+ Prognose: ~${r.prognose_sessions} Einheiten bis 80% +
` + : ''; + return ` +
+
+ + ${cfg.label} +
+
${_esc(r.exercise_name)}
+
${_esc(r.reason)}
+
+
+ ${r.suggested_reps}× empfohlen + ${trend} + ${prognose} +
+ +
+
`; + }); + + el.innerHTML = ` +
+ + + Dein Plan für heute + +
+
+ ${cards.join('')} +
`; + + el.querySelectorAll('.ueb-trainer-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + const name = btn.dataset.name; + const reps = parseInt(btn.dataset.reps, 10) || 5; + // Tab wechseln falls nötig + if (tab && tab !== _activeTab) { + _activeTab = tab; + _container.querySelectorAll('#ueb-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === tab) + ); + _renderContent(); + } + _openLogModal(tab, name, reps); + }); + }); + } + + // ---------------------------------------------------------- + // SCHNELL-SETUP: Stand aller Übungen erfassen + // ---------------------------------------------------------- + function _openQuickSetupModal() { + const ALL = [ + { group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS }, + { group: 'Tricks', tab: 'tricks', items: TRICKS }, + { group: 'Problemverhalten', tab: 'problemverhalten', items: PROBLEMVERHALTEN }, + ]; + + const STATUS_QUICK = [ + { id: null, label: '—', color: 'var(--c-text-secondary)', bg: 'var(--c-surface-2)' }, + { id: 'noch-nicht',label: 'Nein', color: '#dc2626', bg: 'rgba(220,38,38,0.10)' }, + { id: 'manchmal', label: 'Manchmal', color: '#c2410c', bg: 'rgba(234,88,12,0.10)' }, + { id: 'meistens', label: 'Meistens', color: '#ca8a04', bg: 'rgba(234,179,8,0.10)' }, + { id: 'sitzt', label: 'Sitzt ✓', color: '#15803d', bg: 'rgba(22,163,74,0.10)' }, + ]; + + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;display:flex;align-items:flex-end;justify-content:center;background:rgba(0,0,0,0.5)'; + + const rows = ALL.flatMap(g => g.items.map(u => ({ tab: g.tab, name: u.name, group: g.group }))); + let pending = {}; // key → statusId (nur geänderte) + + overlay.innerHTML = ` +
+
+
+
Stand erfassen
+
+ Tippe auf den aktuellen Stand deines Hundes — wird sofort im Profil sichtbar. +
+
+
+ ${ALL.map(g => ` +
+ ${_esc(g.group)} +
+ ${g.items.map(u => { + const key = _progressKey(g.tab, u.name); + const cur = _getStatus(g.tab, u.name); + return ` +
+ ${_esc(u.name)} +
+ ${STATUS_QUICK.map(s => ` + `).join('')} +
+
`; + }).join('')} + `).join('')} +
+
+ + +
+
`; + + document.body.appendChild(overlay); + + // Button-Interaktion + overlay.addEventListener('click', e => { + const btn = e.target.closest('.qs-btn'); + if (!btn) return; + const key = btn.dataset.key; + const val = btn.dataset.val || null; + pending[key] = val; + + // Aktiven Button in dieser Zeile highlighten + const row = overlay.querySelector(`[data-key="${CSS.escape(key)}"]`); + row?.querySelectorAll('.qs-btn').forEach(b => { + const s = STATUS_QUICK.find(s => (s.id ?? '') === (b.dataset.val)); + const active = b.dataset.val === (val ?? ''); + b.style.background = active ? s.bg : 'var(--c-surface-2)'; + b.style.color = active ? s.color : 'var(--c-text-secondary)'; + b.style.border = `1px solid ${active ? s.color + '66' : 'var(--c-border)'}`; + }); + }); + + overlay.querySelector('#qs-cancel').addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + + overlay.querySelector('#qs-save').addEventListener('click', async () => { + if (!Object.keys(pending).length) { overlay.remove(); return; } + const saveBtn = overlay.querySelector('#qs-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Speichern…'; + + // Alle geänderten Status speichern + const parts = Object.entries(pending).map(([key, val]) => { + const [tab, ...rest] = key.split('_'); + const name = rest.join('_').replace(/_/g, ' '); + _progressCache[key] = val || null; + localStorage.setItem(`ub_status_${key}`, val || ''); + return API.training.setProgress(key, val || null); + }); + await Promise.allSettled(parts); + + overlay.remove(); + _renderContent(); + UI.toast.success('Stand gespeichert!'); + }); + } + function _renderTabs() { return `
@@ -524,9 +814,9 @@ window.Page_uebungen = (() => { if (!el || !suggestions.length) return; const COLORS = { - help: { bg: '#fef2f2', border: '#fca5a5', text: '#dc2626' }, - boost: { bg: '#fff7ed', border: '#fdba74', text: '#ea580c' }, - next: { bg: '#f0fdf4', border: '#86efac', text: '#16a34a' }, + help: { bg: 'rgba(220,38,38,0.08)', border: 'rgba(220,38,38,0.25)', text: '#f87171' }, + boost: { bg: 'rgba(234,88,12,0.08)', border: 'rgba(234,88,12,0.25)', text: '#fb923c' }, + next: { bg: 'rgba(22,163,74,0.08)', border: 'rgba(22,163,74,0.25)', text: '#4ade80' }, start: { bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)' }, }; @@ -582,6 +872,19 @@ window.Page_uebungen = (() => { function _renderContent() { const el = _container.querySelector('#ueb-content'); if (!el) return; + + const isExerciseTab = ['grundkommandos', 'tricks', 'problemverhalten'].includes(_activeTab); + const showIf = v => v ? '' : 'none'; + + const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement; + if (quickWrap) quickWrap.style.display = showIf(isExerciseTab); + const trainerEl = _container.querySelector('#ueb-trainer'); + const suggestEl = _container.querySelector('#ueb-suggestions'); + const bannerEl = _container.querySelector('#ueb-stats-banner'); + if (trainerEl) trainerEl.style.display = showIf(isExerciseTab); + if (suggestEl) suggestEl.style.display = showIf(isExerciseTab); + if (bannerEl) bannerEl.style.display = showIf(isExerciseTab); + switch (_activeTab) { case 'grundkommandos': el.innerHTML = _renderUebungsList(GRUNDKOMMANDOS); break; case 'tricks': el.innerHTML = _renderUebungsList(TRICKS); break; @@ -606,6 +909,22 @@ window.Page_uebungen = (() => { `; } + function _sessionStatsChip(tab, name) { + const key = _progressKey(tab, name); + const stat = _exerciseStats[key]; + if (!stat) return ''; + const avg = Math.round(stat.recent_avg); + const color = avg >= 75 ? '#15803d' : avg >= 50 ? '#c2410c' : '#dc2626'; + const bg = avg >= 75 ? 'rgba(22,163,74,0.10)' : avg >= 50 ? 'rgba(234,88,12,0.10)' : 'rgba(220,38,38,0.10)'; + const border = avg >= 75 ? 'rgba(22,163,74,0.30)' : avg >= 50 ? 'rgba(234,88,12,0.30)' : 'rgba(220,38,38,0.30)'; + const arrow = { improving: '↑', declining: '↓', stable: '→', new: '' }[stat.trend] || ''; + return ` + + ${avg}%${arrow} · ${stat.session_count}× + `; + } + function _renderCard(u, i) { const diff = DIFF_META[u.schwierigkeit] || { label: u.schwierigkeit, color: 'var(--c-text-secondary)' }; const uid = `ueb-acc-${_activeTab}-${i}`; @@ -618,47 +937,49 @@ window.Page_uebungen = (() => {
-
- + +
+ ${_esc(u.name)} -
- - - - - - - ${_esc(diff.label)} + + ${_esc(diff.label)} + +
+ +
+ + ${_sessionStatsChip(_activeTab, u.name)} +
+
@@ -682,10 +1003,11 @@ window.Page_uebungen = (() => { ` : ''} ${u.hinweis ? `
- - ${_esc(u.hinweis)} + background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm); + font-size:var(--text-xs);color:var(--c-text);line-height:1.4; + display:flex;align-items:flex-start;gap:var(--space-2)"> + + ${_esc(u.hinweis)}
` : ''}
@@ -778,7 +1100,7 @@ window.Page_uebungen = (() => { }); } - function _openLogModal(tab, exerciseName) { + function _openLogModal(tab, exerciseName, initialReps) { // Build the modal HTML const modalId = 'ueb-log-modal'; const formId = 'ueb-log-form'; @@ -918,7 +1240,7 @@ window.Page_uebungen = (() => { document.body.appendChild(overlay); // State - let wiederholungen = 5; + let wiederholungen = initialReps || 5; let erfolgsquote = null; // must be selected let stimmung = null; let zufriedenheit = null; @@ -930,6 +1252,7 @@ window.Page_uebungen = (() => { // Stepper const repVal = overlay.querySelector('#ueb-rep-val'); + repVal.textContent = wiederholungen; overlay.querySelector('#ueb-rep-minus').addEventListener('click', () => { if (wiederholungen > 1) { wiederholungen--; repVal.textContent = wiederholungen; } }); @@ -988,8 +1311,8 @@ window.Page_uebungen = (() => { // Save overlay.querySelector('#ueb-log-save').addEventListener('click', async () => { const dogId = _dogId(); - if (!dogId) { UI.toast('Kein Hund ausgewählt.', 'warning'); return; } - if (erfolgsquote === null) { UI.toast('Bitte wähle aus, wie es gelaufen ist.', 'warning'); return; } + if (!dogId) { UI.toast.warning('Kein Hund ausgewählt.'); return; } + if (erfolgsquote === null) { UI.toast.warning('Bitte wähle aus, wie es gelaufen ist.'); return; } const saveBtn = overlay.querySelector('#ueb-log-save'); saveBtn.disabled = true; @@ -1038,9 +1361,10 @@ window.Page_uebungen = (() => { }, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000); } - // Stats-Banner aktualisieren + // Stats-Banner + Trainer aktualisieren _statsData = null; _loadStatsAndBadges(); + _loadVirtualTrainer(); } catch (err) { saveBtn.disabled = false; saveBtn.textContent = 'Einheit speichern'; @@ -1167,19 +1491,29 @@ window.Page_uebungen = (() => { return; } feedbackText.textContent = resp.feedback; - const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert'; + const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert'; const sessionInfo = stats.total_sessions - ? ` · Basiert auf ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}` + ? ` · ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}` : ''; - feedbackMeta.textContent = `Aktualisiert alle 6 Stunden · ${cachedInfo}${sessionInfo}`; + const limitInfo = (resp.daily_limit && !resp.cached) + ? ` · ${resp.daily_used}/${resp.daily_limit} heute` : ''; + feedbackMeta.textContent = `${cachedInfo}${sessionInfo}${limitInfo}`; if (regenBtn) { - regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren'; - regenBtn.style.opacity = resp.cached ? '0.6' : '1'; + const limitReached = resp.daily_used >= resp.daily_limit && !resp.cached; + regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren'; + regenBtn.style.opacity = (resp.cached && !limitReached) ? '0.6' : '1'; + regenBtn.disabled = limitReached; + if (limitReached) regenBtn.title = `Tages-Limit erreicht (${resp.daily_limit}/Tag)`; } feedbackCard.hidden = false; } catch (err) { - loading.hidden = true; - noSessions.hidden = false; + loading.hidden = true; + if (err?.status === 429 || (err?.message || '').includes('429')) { + feedbackMeta.textContent = `Tages-Limit erreicht (${10}/Tag) — morgen wieder verfügbar.`; + feedbackCard.hidden = false; + } else { + noSessions.hidden = false; + } } // Bind regenerate button @@ -1218,10 +1552,10 @@ window.Page_uebungen = (() => {
-
+

-
+

-

+
@@ -1281,16 +1617,18 @@ window.Page_uebungen = (() => {
+

-

-

+

Nicht alle Leckerlis sind gleich. Bei Ablenkung oder schwierigen Übungen brauchst du hochwertigere Belohnungen:

+
@@ -1320,10 +1658,10 @@ window.Page_uebungen = (() => { -
+

- Ban Yaro — Die deutschsprachige Hunde-Plattform - + @@ -58,7 +58,12 @@ "Hunde-Wiki mit Rassendatenbank", "Verlorener Hund Alarm", "Forum für Hundebesitzer", - "Offline-Modus via Service Worker" + "Offline-Modus via Service Worker", + "Trainings-Tagebuch mit Einheiten-Logging (Wiederholungen, Erfolgsquote, Stimmung)", + "Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose", + "Übungsfortschritt in 5 Stufen — von noch nicht bis sitzt", + "Trainings-Gamification: Streaks, Abzeichen, Trainingskalender", + "Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter" ], "screenshot": "https://banyaro.app/icons/icon-512.png", "softwareVersion": "2.0", @@ -402,7 +407,11 @@

🎯 -

Training & Übungen

Tägliches Trainings-Tagebuch, Übungen und Pläne. KI-gestützte Mustererkennung — wann lernt dein Hund am besten?

Kostenlos
+

Training & Übungen

Einheiten loggen (Wiederholungen, Erfolgsquote, Stimmung), Fortschritt in 5 Stufen verfolgen. Virtueller Trainer empfiehlt täglich welche Übungen anstehen — inkl. Trendanalyse und Prognose bis zur Meisterschaft. Streaks, Abzeichen und Trainingskalender motivieren dranzubleiben.

Kostenlos
+
+
+ 🏅 +

Kommandos & Fähigkeiten

Alle beherrschten Kommandos sichtbar im Hunde-Profil — praktisch für Hundesitter, die genau wissen müssen worauf dein Hund reagiert.

Kostenlos
🏥 diff --git a/backend/static/sw.js b/backend/static/sw.js index b38cdaf..44879f1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v280'; +const CACHE_VERSION = 'by-v302'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten