Session 2026-04-22: Training, Fixes, KI-Cloud, Dark-Mode
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
This commit is contained in:
parent
2b442ebd98
commit
44081a6b9d
16 changed files with 938 additions and 117 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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: `
|
||||
<div class="flex flex-col gap-3">
|
||||
${authBtn('diary', 'btn-primary', 'book-open', 'Tagebucheintrag schreiben')}
|
||||
<button class="btn btn-danger w-full" data-quick="poison">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
|
||||
</button>
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -201,6 +201,22 @@ window.Page_admin = (() => {
|
|||
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')}
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Trainer Nutzung (Claude API)</p>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${[
|
||||
['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]) => `
|
||||
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
|
||||
<span style="color:var(--c-text-secondary)">${label}</span>
|
||||
<span style="font-weight:600;color:${color}">${val ?? 0}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">OSM-Cache nach Typ</p>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
|
|
|
|||
|
|
@ -187,6 +187,20 @@ window.Page_diary = (() => {
|
|||
<div id="diary-load-more" style="display:none; text-align:center; padding:var(--space-4)">
|
||||
<button class="btn btn-secondary" id="diary-btn-more">Weitere laden</button>
|
||||
</div>
|
||||
<!-- FAB: Neuer Eintrag -->
|
||||
<button id="diary-fab"
|
||||
aria-label="Neuer Tagebucheintrag"
|
||||
style="position:fixed;
|
||||
bottom:calc(var(--nav-bottom-height,64px) + var(--safe-bottom,0px) + 12px);
|
||||
right:16px;z-index:200;width:52px;height:52px;border-radius:50%;
|
||||
background:var(--c-primary);color:#fff;cursor:pointer;
|
||||
border:3px solid var(--c-surface);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-shadow:0 4px 20px rgba(0,0,0,0.35);transition:transform .15s">
|
||||
<svg class="ph-icon" style="width:24px;height:24px" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#plus"></use>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
_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());
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
` : ''}
|
||||
|
||||
<div id="dp-skills" style="margin-bottom:var(--space-5);text-align:left"></div>
|
||||
|
||||
${dog.is_public ? `
|
||||
<div style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
|
||||
border-radius:var(--radius-md);padding:var(--space-4);
|
||||
|
|
@ -215,6 +217,9 @@ window.Page_dog_profile = (() => {
|
|||
_showPhotoEditor(dog);
|
||||
});
|
||||
|
||||
// Skills laden
|
||||
_loadSkills(dog);
|
||||
|
||||
// Sitter-Zugang laden (nur für Besitzer)
|
||||
if (dog.user_id === _appState.user?.id) {
|
||||
_loadSittingAccess(dog.id);
|
||||
|
|
@ -255,6 +260,70 @@ window.Page_dog_profile = (() => {
|
|||
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// FÄHIGKEITEN & KOMMANDOS
|
||||
// ----------------------------------------------------------
|
||||
async function _loadSkills(dog) {
|
||||
const el = document.getElementById('dp-skills');
|
||||
if (!el) return;
|
||||
|
||||
const skills = await API.dogs.getSkills(dog.id).catch(() => null);
|
||||
if (!skills || !skills.length) { el.innerHTML = ''; return; }
|
||||
|
||||
const sitzt = skills.filter(s => s.status === 'sitzt');
|
||||
const meistens = skills.filter(s => s.status === 'meistens');
|
||||
|
||||
const badge = (skill, type) => {
|
||||
const isGreen = type === 'sitzt';
|
||||
return `<span style="
|
||||
display:inline-flex;align-items:center;gap:4px;
|
||||
padding:3px 10px;border-radius:var(--radius-full,999px);
|
||||
font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
background:${isGreen ? '#f0fdf4' : '#fff7ed'};
|
||||
color:${isGreen ? '#15803d' : '#c2410c'};
|
||||
border:1px solid ${isGreen ? '#86efac' : '#fdba74'}">
|
||||
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use>
|
||||
</svg>
|
||||
${_esc(skill.exercise_name)}
|
||||
</span>`;
|
||||
};
|
||||
|
||||
const sitztBlock = sitzt.length ? `
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2);
|
||||
text-transform:uppercase;letter-spacing:.04em">Sitzt</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||||
${sitzt.map(s => badge(s, 'sitzt')).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const meistensBlock = meistens.length ? `
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2);
|
||||
text-transform:uppercase;letter-spacing:.04em">Übt noch</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||||
${meistens.map(s => badge(s, 'meistens')).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#list-checks"></use>
|
||||
</svg>
|
||||
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||
Kommandos & Fähigkeiten
|
||||
</span>
|
||||
</div>
|
||||
${sitztBlock}
|
||||
${meistensBlock}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SITTER-ZUGANG
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<div class="rk-card-name">${UI.escape(r.name)}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;margin:var(--space-2) 0">
|
||||
${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)') : ''}
|
||||
</div>
|
||||
<div class="rk-card-footer">
|
||||
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
|
||||
|
|
@ -1148,7 +1148,7 @@ window.Page_routes = (() => {
|
|||
</div>
|
||||
</div>
|
||||
<div id="rk-nav-offwarn" style="display:none;text-align:center;padding:4px 8px;border-radius:6px;
|
||||
background:#fef2f2;color:#dc2626;font-size:12px;font-weight:600;margin-bottom:6px">
|
||||
background:rgba(220,38,38,0.10);color:#f87171;font-size:12px;font-weight:600;margin-bottom:6px">
|
||||
⚠️ Du hast die Route verlassen
|
||||
</div>
|
||||
<div style="height:4px;background:var(--c-border);border-radius:2px;overflow:hidden">
|
||||
|
|
@ -1753,19 +1753,19 @@ window.Page_routes = (() => {
|
|||
margin-bottom:var(--space-3);background:var(--c-surface-2)"></div>
|
||||
${photoGallery}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin:var(--space-3) 0">
|
||||
${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
|
||||
? `<button type="button" id="rd-vis-pill" title="${route.is_public ? 'Auf Privat setzen' : 'Auf Öffentlich setzen'}"
|
||||
style="${_pillStyle(route.is_public?'#dcfce7':'#dbeafe', route.is_public?'#15803d':'#1d4ed8', route.is_public?'#bbf7d0':'#bfdbfe')}cursor:pointer;">
|
||||
style="${_pillStyle(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;">
|
||||
${route.is_public ? 'Öffentlich' : 'Privat'}
|
||||
</button>`
|
||||
: _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)')
|
||||
}
|
||||
</div>
|
||||
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${UI.escape(route.beschreibung)}</p>` : ''}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div id="ueb-wrap">
|
||||
${_renderTabs()}
|
||||
<div id="ueb-stats-banner" style="padding:0 var(--space-4);margin-bottom:var(--space-2)"></div>
|
||||
<div style="padding:var(--space-3) var(--space-4) 0;display:flex;justify-content:flex-end">
|
||||
<button id="ueb-quicksetup-btn"
|
||||
style="font-size:var(--text-xs);padding:4px 10px;
|
||||
background:var(--c-surface-2);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-secondary);
|
||||
display:flex;align-items:center;gap:4px">
|
||||
<svg class="ph-icon" style="width:13px;height:13px" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#list-checks"></use>
|
||||
</svg>
|
||||
Stand erfassen
|
||||
</button>
|
||||
</div>
|
||||
<div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div>
|
||||
<div id="ueb-trainer" style="padding:0 var(--space-4);margin-bottom:var(--space-2)"></div>
|
||||
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
|
||||
<div id="ueb-content"></div>
|
||||
</div>
|
||||
`;
|
||||
_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 = `
|
||||
<div style="background:var(--c-surface-2);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Lade Trainingsplan…</div>
|
||||
</div>`;
|
||||
|
||||
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
|
||||
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
Prognose: ~${r.prognose_sessions} Einheiten bis 80%
|
||||
</div>`
|
||||
: '';
|
||||
return `
|
||||
<div style="background:${cfg.bg};border:1px solid ${cfg.border};
|
||||
border-radius:var(--radius-md);padding:var(--space-3);
|
||||
display:flex;flex-direction:column;gap:var(--space-2);flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0;color:${cfg.text}" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${cfg.icon}"></use>
|
||||
</svg>
|
||||
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${cfg.text};
|
||||
text-transform:uppercase;letter-spacing:.04em">${cfg.label}</span>
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);line-height:1.3">${_esc(r.exercise_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.4">${_esc(r.reason)}</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:var(--space-1)">
|
||||
<div>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${r.suggested_reps}× empfohlen</span>
|
||||
<span style="font-size:var(--text-xs);font-weight:700;color:${trendColor};margin-left:4px">${trend}</span>
|
||||
${prognose}
|
||||
</div>
|
||||
<button class="ueb-trainer-btn btn btn-primary"
|
||||
data-tab="${_esc(r.tab)}" data-name="${_esc(r.exercise_name)}" data-reps="${r.suggested_reps}"
|
||||
style="font-size:var(--text-xs);padding:4px 10px;flex-shrink:0">
|
||||
Üben
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#target"></use>
|
||||
</svg>
|
||||
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||
Dein Plan für heute
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);align-items:stretch">
|
||||
${cards.join('')}
|
||||
</div>`;
|
||||
|
||||
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 = `
|
||||
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||||
width:100%;max-width:560px;max-height:90vh;display:flex;flex-direction:column;
|
||||
box-shadow:0 -4px 24px rgba(0,0,0,0.2)">
|
||||
<div style="padding:var(--space-4) var(--space-4) var(--space-2);border-bottom:1px solid var(--c-border);flex-shrink:0">
|
||||
<div style="width:36px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-3)"></div>
|
||||
<div style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">Stand erfassen</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
Tippe auf den aktuellen Stand deines Hundes — wird sofort im Profil sichtbar.
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-y:auto;flex:1;padding:var(--space-2) var(--space-4)">
|
||||
${ALL.map(g => `
|
||||
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--c-text-secondary);
|
||||
text-transform:uppercase;letter-spacing:.05em;padding:var(--space-3) 0 var(--space-1)">
|
||||
${_esc(g.group)}
|
||||
</div>
|
||||
${g.items.map(u => {
|
||||
const key = _progressKey(g.tab, u.name);
|
||||
const cur = _getStatus(g.tab, u.name);
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||||
padding:var(--space-2) 0;border-bottom:1px solid var(--c-border)" data-key="${_esc(key)}">
|
||||
<span style="flex:1;font-size:var(--text-sm);color:var(--c-text);min-width:0;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.name)}</span>
|
||||
<div style="display:flex;gap:3px;flex-shrink:0">
|
||||
${STATUS_QUICK.map(s => `
|
||||
<button class="qs-btn" data-key="${_esc(key)}" data-val="${s.id === null ? '' : _esc(s.id)}"
|
||||
style="font-size:9px;font-weight:700;padding:3px 6px;border-radius:999px;cursor:pointer;
|
||||
white-space:nowrap;transition:all .15s;
|
||||
background:${cur === s.id ? s.bg : 'var(--c-surface-2)'};
|
||||
color:${cur === s.id ? s.color : 'var(--c-text-secondary)'};
|
||||
border:1px solid ${cur === s.id ? s.color + '66' : 'var(--c-border)'}">
|
||||
${_esc(s.label)}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
`).join('')}
|
||||
</div>
|
||||
<div style="padding:var(--space-4);border-top:1px solid var(--c-border);display:flex;gap:var(--space-3);flex-shrink:0">
|
||||
<button id="qs-cancel" style="flex:1;padding:var(--space-3);border:1.5px solid var(--c-border);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
font-size:var(--text-sm);cursor:pointer;color:var(--c-text)">Abbrechen</button>
|
||||
<button id="qs-save" class="btn btn-primary" style="flex:2">Übernehmen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 `
|
||||
<div class="by-tabs" style="padding:var(--space-4) var(--space-4) 0" id="ueb-tabs">
|
||||
|
|
@ -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 `
|
||||
<span style="font-size:9px;font-weight:600;padding:1px 6px;border-radius:999px;
|
||||
background:${bg};color:${color};border:1px solid ${border};white-space:nowrap">
|
||||
${avg}%${arrow} · ${stat.session_count}×
|
||||
</span>`;
|
||||
}
|
||||
|
||||
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 = (() => {
|
|||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<!-- Header -->
|
||||
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
|
||||
<span style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||
<!-- Zeile 1: Name + Schwierigkeits-Badge -->
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-2)">
|
||||
<span style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);line-height:1.3">
|
||||
${_esc(u.name)}
|
||||
</span>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
<!-- Log-Button -->
|
||||
<button class="ueb-log-btn"
|
||||
data-tab="${_esc(_activeTab)}"
|
||||
data-name="${_esc(u.name)}"
|
||||
title="Trainingseinheit loggen"
|
||||
style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
|
||||
color:var(--c-primary);cursor:pointer;padding:2px 7px;
|
||||
display:flex;align-items:center;gap:3px;
|
||||
font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
border-radius:var(--radius-sm)">
|
||||
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#plus"></use>
|
||||
</svg>
|
||||
Einheit
|
||||
</button>
|
||||
<!-- Status-Button -->
|
||||
<button class="ueb-status-btn"
|
||||
data-tab="${_esc(_activeTab)}"
|
||||
data-name="${_esc(u.name)}"
|
||||
title="${_esc(sm.label)}"
|
||||
style="background:none;border:none;cursor:pointer;padding:2px;
|
||||
display:flex;align-items:center;gap:4px;
|
||||
font-size:var(--text-xs);color:${sm.color};
|
||||
border-radius:var(--radius-sm)">
|
||||
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${sm.icon}"></use>
|
||||
</svg>
|
||||
${currentId ? `<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">${_esc(sm.label)}</span>` : ''}
|
||||
</button>
|
||||
<!-- Schwierigkeits-Badge -->
|
||||
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
padding:2px var(--space-2);border-radius:var(--radius-sm);
|
||||
background:${diff.color}22;color:${diff.color}">
|
||||
${_esc(diff.label)}
|
||||
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);white-space:nowrap;
|
||||
padding:2px var(--space-2);border-radius:var(--radius-sm);flex-shrink:0;
|
||||
background:${diff.color}22;color:${diff.color}">
|
||||
${_esc(diff.label)}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Zeile 2: Aktions-Buttons -->
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);flex-wrap:wrap">
|
||||
<button class="ueb-log-btn"
|
||||
data-tab="${_esc(_activeTab)}"
|
||||
data-name="${_esc(u.name)}"
|
||||
title="Trainingseinheit loggen"
|
||||
style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
|
||||
color:var(--c-primary);cursor:pointer;padding:3px 9px;
|
||||
display:flex;align-items:center;gap:3px;
|
||||
font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
border-radius:var(--radius-sm)">
|
||||
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#plus"></use>
|
||||
</svg>
|
||||
Einheit
|
||||
</button>
|
||||
${_sessionStatsChip(_activeTab, u.name)}
|
||||
<button class="ueb-status-btn"
|
||||
data-tab="${_esc(_activeTab)}"
|
||||
data-name="${_esc(u.name)}"
|
||||
title="${_esc(sm.label)}"
|
||||
style="background:none;border:none;cursor:pointer;padding:2px;
|
||||
display:flex;align-items:center;gap:4px;
|
||||
font-size:var(--text-xs);color:${sm.color};
|
||||
border-radius:var(--radius-sm)">
|
||||
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${sm.icon}"></use>
|
||||
</svg>
|
||||
<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">
|
||||
${_esc(sm.label)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Meta -->
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:${u.beschreibung ? 'var(--space-3)' : '0'}">
|
||||
|
|
@ -682,10 +1003,11 @@ window.Page_uebungen = (() => {
|
|||
` : ''}
|
||||
${u.hinweis ? `
|
||||
<div style="margin-top:var(--space-2);padding:var(--space-2) var(--space-3);
|
||||
background:var(--c-warning-bg,#fef3c7);border-radius:var(--radius-sm);
|
||||
font-size:var(--text-xs);color:var(--c-text);line-height:1.4">
|
||||
<svg class="ph-icon" style="width:12px;height:12px;color:#d97706" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||||
${_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)">
|
||||
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||||
<span>${_esc(u.hinweis)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
|
@ -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 = (() => {
|
|||
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
|
||||
<!-- Markerwort -->
|
||||
<div class="card">
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin:0 0 var(--space-3)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
|
||||
color:var(--c-text);margin:0 0 var(--space-3);display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#megaphone-simple"></use>
|
||||
</svg>
|
||||
Das Markerwort
|
||||
|
|
@ -1245,13 +1579,15 @@ window.Page_uebungen = (() => {
|
|||
|
||||
<!-- Wann belohnen -->
|
||||
<div class="card">
|
||||
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
|
||||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin:0 0 var(--space-3)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
|
||||
color:var(--c-text);margin:0;display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#star"></use>
|
||||
</svg>
|
||||
Wann belohnen?
|
||||
</h3>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||||
<thead>
|
||||
|
|
@ -1281,16 +1617,18 @@ window.Page_uebungen = (() => {
|
|||
|
||||
<!-- Leckerli-Hierarchie -->
|
||||
<div class="card">
|
||||
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
|
||||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin:0 0 var(--space-3)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
|
||||
color:var(--c-text);margin:0 0 var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#trophy"></use>
|
||||
</svg>
|
||||
Leckerli-Hierarchie
|
||||
</h3>
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0 0 var(--space-3)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0">
|
||||
Nicht alle Leckerlis sind gleich. Bei Ablenkung oder schwierigen Übungen brauchst du hochwertigere Belohnungen:
|
||||
</p>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||||
<thead>
|
||||
|
|
@ -1320,10 +1658,10 @@ window.Page_uebungen = (() => {
|
|||
</div>
|
||||
|
||||
<!-- Trainingsregeln -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
|
||||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin:0 0 var(--space-3)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
|
||||
color:var(--c-text);margin:0 0 var(--space-3);display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#list-checks"></use>
|
||||
</svg>
|
||||
Trainingsregeln auf einen Blick
|
||||
|
|
|
|||
|
|
@ -40,13 +40,14 @@ const UI = (() => {
|
|||
el.addEventListener('animationend', () => { clearTimeout(fallback); el.remove(); }, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
success: (msg, dur) => show(msg, 'success', dur),
|
||||
error: (msg, dur) => show(msg, 'danger', dur || 5000),
|
||||
warning: (msg, dur) => show(msg, 'warning', dur),
|
||||
info: (msg, dur) => show(msg, 'info', dur),
|
||||
};
|
||||
// callable as UI.toast(msg, type) and UI.toast.success(msg) etc.
|
||||
function t(msg, type = 'default', dur) { show(msg, type, dur); }
|
||||
t.show = show;
|
||||
t.success = (msg, dur) => show(msg, 'success', dur);
|
||||
t.error = (msg, dur) => show(msg, 'danger', dur || 5000);
|
||||
t.warning = (msg, dur) => show(msg, 'warning', dur);
|
||||
t.info = (msg, dur) => show(msg, 'info', dur);
|
||||
return t;
|
||||
})();
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ban Yaro — Die deutschsprachige Hunde-Plattform</title>
|
||||
<meta name="description" content="Ban Yaro ist die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting — DSGVO-konform, ohne App Store.">
|
||||
<meta name="description" content="Ban Yaro ist die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting, Trainings-Tracker — DSGVO-konform, ohne App Store.">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://banyaro.app/info">
|
||||
|
||||
|
|
@ -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 @@
|
|||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🎯</span>
|
||||
<div><h3>Training & Übungen</h3><p>Tägliches Trainings-Tagebuch, Übungen und Pläne. KI-gestützte Mustererkennung — wann lernt dein Hund am besten?</p><span class="feature-tag">Kostenlos</span></div>
|
||||
<div><h3>Training & Übungen</h3><p>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.</p><span class="feature-tag">Kostenlos</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🏅</span>
|
||||
<div><h3>Kommandos & Fähigkeiten</h3><p>Alle beherrschten Kommandos sichtbar im Hunde-Profil — praktisch für Hundesitter, die genau wissen müssen worauf dein Hund reagiert.</p><span class="feature-tag">Kostenlos</span></div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🏥</span>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue