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)')}
+
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)') : ''}
+ background:rgba(220,38,38,0.10);color:#f87171;font-size:12px;font-weight:600;margin-bottom:6px">
⚠️ Du hast die Route verlassen
@@ -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 = `
+
`;
+
+ 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 = (() => {
-
+
-