- UI.pageInfo(): generische Hilfe-Funktion — erstes Öffnen zeigt Info-Banner, danach ? Button oben rechts; CSS-Klassen pinfo-* - Übungen-Seite nutzt UI.pageInfo() als erstes Beispiel - Karte POI: Mehrfachauswahl (außer Giftköder), Kombi-Typen entfernt, type als comma-separated im Backend - daily_quotes Tabelle in DB (346 Einträge via import_quotes.py importiert) - GET /widget/quote — deterministischer Tagesspruch (wechselt täglich)
2139 lines
100 KiB
JavaScript
2139 lines
100 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Übungsbibliothek
|
||
Seiten-Modul: Grundkommandos, Tricks, Problemverhalten, Grundlagen.
|
||
============================================================ */
|
||
|
||
window.Page_uebungen = (() => {
|
||
|
||
let _container = null;
|
||
let _appState = null;
|
||
let _activeTab = 'grundkommandos';
|
||
|
||
// ----------------------------------------------------------
|
||
// API HELPERS
|
||
// ----------------------------------------------------------
|
||
function _dogId() {
|
||
return _appState?.activeDog?.id || null;
|
||
}
|
||
|
||
async function _apiPost(url, body) {
|
||
const r = await fetch(url, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body)
|
||
});
|
||
return r.ok ? r.json() : null;
|
||
}
|
||
|
||
async function _apiGet(url) {
|
||
const r = await fetch(url, {credentials: 'include'});
|
||
return r.ok ? r.json() : null;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// STATS STATE
|
||
// ----------------------------------------------------------
|
||
let _statsData = null; // cached stats from /api/training/stats
|
||
let _badgesData = null; // cached badges from /api/achievements
|
||
let _exercisesByTab = {}; // aus API geladen
|
||
let _exercisesLoaded = false;
|
||
let _scrollTarget = null; // { exercise_id, name } — nach _renderContent() scrollen
|
||
let _searchQuery = ''; // aktuelle Sucheingabe
|
||
|
||
// ----------------------------------------------------------
|
||
// DATEN
|
||
// ----------------------------------------------------------
|
||
|
||
const TABS = [
|
||
{ id: 'grundkommandos', label: 'Grundkommandos' },
|
||
{ id: 'tricks', label: 'Tricks' },
|
||
{ id: 'problemverhalten', label: 'Problemverhalten' },
|
||
{ id: 'mentale-auslastung', label: 'Mentale Auslastung' },
|
||
{ id: 'hundesport', label: 'Hundesport' },
|
||
{ id: 'koerperpflege', label: 'Körperpflege' },
|
||
{ id: 'welpe-basics', label: 'Welpe Basics' },
|
||
{ id: 'grundlagen', label: 'Trainingsgrundlagen' },
|
||
{ id: 'ki-trainer', label: 'KI-Trainer' },
|
||
];
|
||
|
||
// ----------------------------------------------------------
|
||
// ÜBUNGS-STATUS
|
||
// ----------------------------------------------------------
|
||
const STATUS = [
|
||
{ id: null, icon: 'flag', color: 'var(--c-border)', label: 'Noch nicht geübt' },
|
||
{ id: 'noch-nicht', icon: 'x', color: 'var(--c-danger)', label: 'Klappt noch nicht' },
|
||
{ id: 'manchmal', icon: 'fire', color: '#f59e0b', label: 'Manchmal klappt es' },
|
||
{ id: 'meistens', icon: 'star', color: '#eab308', label: 'Meistens klappt es' },
|
||
{ id: 'sitzt', icon: 'trophy', color: 'var(--c-primary)', label: 'Sitzt!' },
|
||
];
|
||
|
||
function _statusKey(tab, name) {
|
||
return `ub_status_${tab}_${name.replace(/\s+/g, '_')}`;
|
||
}
|
||
|
||
// In-memory cache (loaded from API on init)
|
||
let _progressCache = {}; // key → statusId
|
||
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
|
||
|
||
function _progressKey(tab, name) {
|
||
return `${tab}_${name.replace(/[\s/]+/g, '_')}`;
|
||
}
|
||
|
||
function _getStatus(tab, name) {
|
||
const k = _progressKey(tab, name);
|
||
// Fallback to localStorage while API loads
|
||
return _progressCache[k] !== undefined
|
||
? _progressCache[k]
|
||
: localStorage.getItem(_statusKey(tab, name)) || null;
|
||
}
|
||
|
||
function _setStatus(tab, name, statusId) {
|
||
const k = _progressKey(tab, name);
|
||
_progressCache[k] = statusId;
|
||
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
|
||
API.training.setProgress(k, statusId).catch(() => {});
|
||
}
|
||
|
||
function _nextStatus(currentId) {
|
||
const idx = STATUS.findIndex(s => s.id === currentId);
|
||
const next = (idx + 1) % STATUS.length;
|
||
return STATUS[next].id;
|
||
}
|
||
|
||
function _statusMeta(statusId) {
|
||
return STATUS.find(s => s.id === statusId) || STATUS[0];
|
||
}
|
||
|
||
const DIFF_META = {
|
||
'Anfänger': { label: 'Anfänger', color: 'var(--c-success)' },
|
||
'Fortgeschrittener Anfänger': { label: 'Fortgeschr. Anfänger', color: '#eab308' },
|
||
'Mittel': { label: 'Mittel', color: 'var(--c-primary)' },
|
||
'Anfänger bis Fortgeschrittener': { label: 'Anfänger–Fortgeschr.', color: '#eab308' },
|
||
};
|
||
|
||
const GRUNDKOMMANDOS = [
|
||
{
|
||
name: 'Sitz',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 8 Wochen',
|
||
dauer: '3–5 Minuten',
|
||
material: 'Leckerlis, ruhige Umgebung',
|
||
beschreibung: 'Der Hund setzt sich auf ein Signal hin. Das ist meist das erste Kommando und bildet die Basis für viele weitere Übungen.',
|
||
schritte: [
|
||
'Halte ein Leckerli knapp vor die Nase deines Hundes.',
|
||
'Führe das Leckerli langsam nach oben und leicht nach hinten über seinen Kopf.',
|
||
'Der Hund folgt mit der Nase — sein Hinterteil senkt sich automatisch.',
|
||
'Sobald er sitzt: sofort Markerwort ("Ja!" oder Klicker) + Leckerli.',
|
||
'Wiederhole 5–10x, bevor du das Wort "Sitz" hinzufügst.',
|
||
'Ab Wiederholung 10: Sage "Sitz" kurz bevor du die Handbewegung machst.',
|
||
],
|
||
fehler: [
|
||
'Leckerli zu hoch halten → Hund springt hoch statt zu sitzen',
|
||
'Kommando zu früh einführen → Hund lernt das Wort bevor er die Bewegung kennt',
|
||
'Zu lange Einheiten → Hund wird unkonzentriert',
|
||
],
|
||
steigerung: 'Sitz mit Ablenkung → Sitz aus Bewegung → Sitz auf Distanz',
|
||
},
|
||
{
|
||
name: 'Platz',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 10 Wochen',
|
||
dauer: '3–5 Minuten',
|
||
material: 'Leckerlis, ruhige Umgebung',
|
||
beschreibung: 'Der Hund legt sich auf ein Signal hin. Wichtig für Ruhephasen, Wartesituationen und als Basis für "Bleib".',
|
||
schritte: [
|
||
'Beginne mit dem Hund im Sitz.',
|
||
'Halte ein Leckerli vor seine Nase und führe es langsam senkrecht nach unten zwischen seine Vorderpfoten.',
|
||
'Der Hund folgt mit der Nase und legt sich ab.',
|
||
'Sobald Ellenbogen und Hinterteil den Boden berühren: Markerwort + Leckerli.',
|
||
'Klappt es nicht: Leckerli unter ein angewinkeltes Knie halten — der Hund kriecht darunter durch und legt sich dabei ab.',
|
||
'Wort "Platz" erst nach 10–15 erfolgreichen Wiederholungen einführen.',
|
||
],
|
||
fehler: [
|
||
'Hund steht auf statt sich hinzulegen → Leckerli-Führung zu weit weg',
|
||
'Hund liegt nur kurz → zu früh belohnen, Dauer schrittweise aufbauen',
|
||
],
|
||
steigerung: 'Platz mit Dauer → Platz mit Distanz → Platz bei Ablenkung',
|
||
},
|
||
{
|
||
name: 'Bleib',
|
||
schwierigkeit: 'Anfänger bis Fortgeschrittener',
|
||
alter: 'Ab 12 Wochen',
|
||
dauer: '5 Minuten',
|
||
material: 'Leckerlis, Geduld',
|
||
beschreibung: 'Der Hund hält eine Position (Sitz oder Platz) bis er freigegeben wird. Drei Dimensionen: Dauer, Distanz, Ablenkung — immer nur eine auf einmal steigern.',
|
||
schritte: [
|
||
'Hund ins Sitz oder Platz bringen.',
|
||
'Einen Moment warten (2 Sekunden) → Markerwort + Leckerli.',
|
||
'Freigabewort einführen: "Okay" oder "Frei" — danach darf der Hund aufstehen.',
|
||
'Dauer schrittweise erhöhen: 2 → 5 → 10 → 30 Sekunden.',
|
||
'Erst wenn Dauer stabil ist: einen kleinen Schritt zurücktreten.',
|
||
'Zurückkehren zum Hund, belohnen — nicht den Hund zu dir kommen lassen.',
|
||
],
|
||
fehler: [
|
||
'Zu schnell Distanz aufbauen → Hund bricht ab',
|
||
'Hund wird zur Person gelobt statt an Ort → Hund kommt herangelaufen',
|
||
'Freigabewort vergessen → Hund weiß nicht wann er aufstehen darf',
|
||
],
|
||
steigerung: 'Bleib 1 Min → Bleib mit Sichtkontaktverlust → Bleib bei Ablenkung (Ball rollen, andere Person)',
|
||
},
|
||
{
|
||
name: 'Hier / Komm',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 8 Wochen',
|
||
dauer: '5 Minuten',
|
||
material: 'Leckerlis oder Spielzeug, Schleppleine empfohlen',
|
||
beschreibung: 'Der Hund kommt zuverlässig zurück wenn er gerufen wird. Eines der wichtigsten Kommandos — im Zweifel lebensrettend.',
|
||
schritte: [
|
||
'Beginne in der Wohnung auf kurze Distanz (2–3 Meter).',
|
||
'Knie dich hin, öffne die Arme, freudige Stimme: "Hier!" oder "Komm!"',
|
||
'Sobald der Hund ankommt: große Freude, Leckerli, Streicheln.',
|
||
'Niemals rufen und dann etwas Unangenehmes tun (Bad, Krallen schneiden).',
|
||
'Im Garten: Schleppleine verwenden — Hund kann nicht wegbleiben, Erfolg ist garantiert.',
|
||
'Ruf nur einmal — wer mehrfach ruft trainiert den Hund aufs Ignorieren.',
|
||
],
|
||
fehler: [
|
||
'Hund rufen wenn er sicher nicht kommt → schlechte Gewohnheit',
|
||
'Hund bestrafen nach dem Kommen → nächstes Mal kommt er nicht',
|
||
'Monotone Stimme → Hund motiviert sich nicht',
|
||
],
|
||
steigerung: 'Kurze Distanz innen → Garten mit Schleppleine → Park mit wenig Ablenkung → Freilauf',
|
||
},
|
||
{
|
||
name: 'Fuß',
|
||
schwierigkeit: 'Fortgeschrittener Anfänger',
|
||
alter: 'Ab 12 Wochen',
|
||
dauer: '5–10 Minuten',
|
||
material: 'Leckerlis, Leine',
|
||
beschreibung: 'Der Hund läuft ruhig an der Leine neben dir, ohne zu ziehen. Die Leine hängt locker durch.',
|
||
schritte: [
|
||
'Hund an deine linke Seite stellen (klassisch), Leckerli in der linken Hand.',
|
||
'Einen Schritt vorwärts gehen, Leckerli auf Höhe deiner linken Hüfte halten.',
|
||
'Hund folgt dem Leckerli → Markerwort + belohnen.',
|
||
'Schrittweise mehr Schritte, immer wieder belohnen wenn Leine locker ist.',
|
||
'Zieht der Hund: stehen bleiben oder Richtung wechseln — nie mitziehen lassen.',
|
||
'Wort "Fuß" einführen sobald der Hund die Position versteht.',
|
||
],
|
||
fehler: [
|
||
'Leine straff halten → Hund lernt Zug als Normalzustand',
|
||
'Zu selten belohnen am Anfang → Hund verliert Interesse an der Position',
|
||
'Zu lange Einheiten → Überforderung',
|
||
],
|
||
steigerung: 'Fuß in der Wohnung → ruhige Straße → belebte Umgebung → ohne Leckerli in der Hand',
|
||
},
|
||
{
|
||
name: 'Aus / Lass es',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 10 Wochen',
|
||
dauer: '3–5 Minuten',
|
||
material: 'Leckerlis, Spielzeug oder Gegenstand',
|
||
beschreibung: 'Der Hund lässt einen Gegenstand auf Kommando los. Wichtig für Sicherheit (Gefährliches fallen lassen) und Spielsituationen.',
|
||
schritte: [
|
||
'Gib dem Hund ein Spielzeug oder lass ihn etwas halten.',
|
||
'Halte ein Leckerli vor seine Nase — er lässt den Gegenstand fallen.',
|
||
'Sofort Markerwort + Leckerli geben.',
|
||
'Gegenstand kurz aufheben, dann wieder zurückgeben → Hund lernt: Loslassen lohnt sich.',
|
||
'Wort "Aus" kurz vor dem Leckerli-Zeigen einführen.',
|
||
],
|
||
fehler: [
|
||
'Gegenstand wegziehen → Hund lernt festhalten',
|
||
'Immer wegnehmen nach "Aus" → Hund gibt nicht mehr freiwillig her',
|
||
],
|
||
steigerung: 'Spielzeug → Leckerli auf dem Boden → Gegenstand unterwegs → hochwertige Beute',
|
||
},
|
||
{
|
||
name: 'Warte',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 10 Wochen',
|
||
dauer: '3–5 Minuten',
|
||
material: 'Leckerlis, Türschwelle oder Futterschüssel',
|
||
beschreibung: 'Der Hund wartet kurz in einer Situation bis er freigegeben wird — z.B. vor der Tür, vor dem Futter, beim Aussteigen aus dem Auto.',
|
||
schritte: [
|
||
'Stelle die Futterschüssel auf den Boden — Hund stürmt drauf zu.',
|
||
'Schüssel mit der Hand abdecken oder hochhalten.',
|
||
'Sobald der Hund zurückweicht oder sitzt: Schüssel freigeben + "Okay".',
|
||
'Alternativ: an der Türschwelle — Tür öffnen, Hund wartet bis "Okay".',
|
||
'Wort "Warte" einführen sobald das Verhalten klar ist.',
|
||
],
|
||
fehler: [
|
||
'Hund wird nie freigegeben → verliert Vertrauen ins System',
|
||
'Zu lange warten lassen am Anfang → Frustration',
|
||
],
|
||
steigerung: null,
|
||
},
|
||
];
|
||
|
||
const TRICKS = [
|
||
{
|
||
name: 'Pfote / Schütteln',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 12 Wochen',
|
||
dauer: '3 Minuten',
|
||
material: 'Leckerlis',
|
||
beschreibung: null,
|
||
schritte: [
|
||
'Hund ins Sitz bringen.',
|
||
'Leckerli in der Faust verstecken, Faust auf Kniehöhe halten.',
|
||
'Hund schnuppert, kratzt irgendwann mit der Pfote an der Faust.',
|
||
'Sofort öffnen: Markerwort + Leckerli.',
|
||
'Wort "Pfote" einführen sobald die Bewegung zuverlässig kommt.',
|
||
'Auf flache offene Hand umstellen → Hund legt Pfote rein.',
|
||
],
|
||
fehler: [],
|
||
steigerung: null,
|
||
},
|
||
{
|
||
name: 'Dreh / Runde',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 12 Wochen',
|
||
dauer: '3–5 Minuten',
|
||
material: 'Leckerlis',
|
||
beschreibung: null,
|
||
schritte: [
|
||
'Leckerli vor die Nase des Hundes halten.',
|
||
'Langsam einen Kreis in der Luft führen — der Hund folgt mit der Nase.',
|
||
'Volle Drehung → Markerwort + Leckerli.',
|
||
'Wort "Dreh" (links) und "Runde" (rechts) einführen.',
|
||
'Handbewegung schrittweise kleiner machen.',
|
||
],
|
||
fehler: [],
|
||
steigerung: null,
|
||
},
|
||
{
|
||
name: 'Platz auf Decke / Matte',
|
||
schwierigkeit: 'Fortgeschrittener Anfänger',
|
||
alter: 'Ab 4 Monate',
|
||
dauer: '5–10 Minuten',
|
||
material: 'Leckerlis, Decke oder Matte',
|
||
beschreibung: 'Der Hund geht selbstständig auf seine Decke und legt sich. Ideal für Besuche, Restaurant, ruhige Phasen.',
|
||
schritte: [
|
||
'Decke auf den Boden legen. Hund beschnuppert sie → Leckerli auf die Decke werfen.',
|
||
'Jedes Mal wenn der Hund die Decke betritt: Markerwort + Leckerli.',
|
||
'Warten bis der Hund sich spontan auf die Decke legt → große Belohnung.',
|
||
'"Platz" zeigen sobald er auf der Decke steht.',
|
||
'Decke schrittweise weiter wegstellen.',
|
||
'Wort "Decke" oder "Platz geh" einführen.',
|
||
],
|
||
fehler: [],
|
||
steigerung: null,
|
||
},
|
||
{
|
||
name: 'Suchspiel / Nasenarbeit',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 8 Wochen',
|
||
dauer: '5–10 Minuten',
|
||
material: 'Leckerlis, optional Döschen',
|
||
beschreibung: 'Nasenarbeit ist mentale Auslastung — 10 Minuten Suchen ermüdet mehr als 1 Stunde Spaziergang.',
|
||
schritte: [
|
||
'Leckerli vor den Augen des Hundes unter einem Becher verstecken.',
|
||
'Hund darf suchen → findet er es: Markerwort + Leckerli.',
|
||
'Steigerung: mehrere Becher, Hund muss den richtigen finden.',
|
||
'Später: Leckerlis im Gras verstecken → "Such!"',
|
||
'Noch später: Spielzeug oder Schlüssel suchen lassen.',
|
||
],
|
||
fehler: [],
|
||
steigerung: null,
|
||
},
|
||
];
|
||
|
||
const PROBLEMVERHALTEN = [
|
||
{
|
||
name: 'Nicht springen / Begrüßung',
|
||
schwierigkeit: 'Anfänger',
|
||
alter: 'Ab 8 Wochen',
|
||
dauer: 'Bei jeder Begrüßung',
|
||
material: 'Konsequenz aller Haushaltsmitglieder',
|
||
beschreibung: 'Der Hund begrüßt Menschen mit allen vier Pfoten auf dem Boden.',
|
||
schritte: [
|
||
'Springt der Hund hoch: keine Reaktion (kein "Nein", kein Wegdrücken, kein Augenkontakt).',
|
||
'Sobald alle vier Pfoten unten sind: sofort Markerwort + Leckerli + Aufmerksamkeit.',
|
||
'Konsequenz ist entscheidend — alle im Haushalt müssen gleich reagieren.',
|
||
'Alternative: Hund ins Sitz schicken bei Begrüßung → dann begrüßen.',
|
||
],
|
||
fehler: [],
|
||
steigerung: null,
|
||
},
|
||
{
|
||
name: 'Alleine bleiben',
|
||
schwierigkeit: 'Mittel',
|
||
alter: 'Ab 10 Wochen',
|
||
dauer: 'Mehrmals täglich kurze Einheiten',
|
||
material: 'Geduld, Zeit, Kong oder Kauartikel',
|
||
beschreibung: 'Der Hund bleibt ruhig wenn er allein ist — ohne Stress, Bellen oder Zerstören.',
|
||
schritte: [
|
||
'Hund beschäftigen (Kong mit Futter gefüllt, Kauartikel).',
|
||
'Zimmer verlassen für 10 Sekunden → zurückkommen, ruhig begrüßen.',
|
||
'Zeit schrittweise erhöhen: 30 Sek → 2 Min → 5 Min → 15 Min.',
|
||
'Nie dramatisch verabschieden oder begrüßen — Kommen und Gehen normalisieren.',
|
||
'Hund nicht bestrafen wenn er etwas angestellt hat — er versteht den Zusammenhang nicht mehr.',
|
||
],
|
||
fehler: [],
|
||
steigerung: null,
|
||
hinweis: 'Welpen sollten nie länger als 1–2 Stunden allein gelassen werden. Erwachsene Hunde maximal 4–6 Stunden.',
|
||
},
|
||
{
|
||
name: 'Leinenführigkeit — Nicht ziehen',
|
||
schwierigkeit: 'Mittel',
|
||
alter: 'Ab 12 Wochen',
|
||
dauer: 'Jeder Spaziergang',
|
||
material: 'Leine, Leckerlis, Geduld',
|
||
beschreibung: null,
|
||
schritte: [
|
||
'Beginne den Spaziergang ruhig — aufgeregte Starts fördern das Ziehen.',
|
||
'Zieht der Hund: stehen bleiben. Warten bis Leine locker ist.',
|
||
'Oder: Richtung wechseln sobald die Leine straff wird.',
|
||
'Locker Leine = Bewegung vorwärts + gelegentlich Leckerli.',
|
||
'Kein Ruck an der Leine — Hund lernt dadurch nicht.',
|
||
],
|
||
fehler: [],
|
||
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.',
|
||
},
|
||
];
|
||
|
||
// ----------------------------------------------------------
|
||
// INIT
|
||
// ----------------------------------------------------------
|
||
// DB-Kategorien → Tab-IDs
|
||
const _KAT_TO_TAB = {
|
||
'grundkommando': 'grundkommandos',
|
||
'grundkommandos': 'grundkommandos',
|
||
'trick': 'tricks',
|
||
'tricks': 'tricks',
|
||
'problemverhalten': 'problemverhalten',
|
||
'mentale auslastung': 'mentale-auslastung',
|
||
'mentale-auslastung': 'mentale-auslastung',
|
||
'hundesport': 'hundesport',
|
||
'körperpflege': 'koerperpflege',
|
||
'koerperpflege': 'koerperpflege',
|
||
'welpe basics': 'welpe-basics',
|
||
'welpe-basics': 'welpe-basics',
|
||
'grundlagen': 'grundlagen',
|
||
'ki-trainer': 'ki-trainer',
|
||
};
|
||
const _VALID_TABS = new Set(TABS.map(t => t.id));
|
||
|
||
async function init(container, appState, params = {}) {
|
||
_container = container;
|
||
_appState = appState;
|
||
// Tab aus exercise_id (JS-Format) oder kategorie ableiten
|
||
const exId = params.exercise_id || '';
|
||
if (exId) {
|
||
for (const [, tabId] of Object.entries(_KAT_TO_TAB)) {
|
||
if (exId.startsWith(tabId + '_') && _VALID_TABS.has(tabId)) {
|
||
_activeTab = tabId; break;
|
||
}
|
||
}
|
||
} else if (params.kategorie) {
|
||
const mapped = _KAT_TO_TAB[params.kategorie.toLowerCase()] || params.kategorie;
|
||
if (_VALID_TABS.has(mapped)) _activeTab = mapped;
|
||
}
|
||
_render();
|
||
UI.pageInfo(_container, {
|
||
pageId: 'uebungen',
|
||
title: 'Übungsbibliothek',
|
||
icon: 'graduation-cap',
|
||
intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.',
|
||
steps: [
|
||
{ icon: 'list-checks', title: 'Stand erfassen', text: 'Klicke auf "Stand erfassen" um schnell für alle Übungen einzutragen, was euer aktueller Stand ist.' },
|
||
{ icon: 'flag', title: 'Übung üben', text: 'Tippe auf eine Übung, um die Anleitung zu lesen. Mit den Fortschritts-Icons (Flagge → Trophäe) trackst du, wie weit ihr seid.' },
|
||
{ icon: 'star', title: 'KI-Trainer', text: 'Im Tab "KI-Trainer" analysiert unsere KI deinen Trainingsstand und gibt personalisierte Empfehlungen.' },
|
||
],
|
||
tip: 'Regelmäßiges Training stärkt die Bindung — auch 5 Minuten täglich machen einen großen Unterschied!',
|
||
});
|
||
|
||
// Übungen aus DB laden (parallel mit Progress)
|
||
if (!_exercisesLoaded) {
|
||
API.get('/training/exercises').then(data => {
|
||
_exercisesByTab = data || {};
|
||
_exercisesLoaded = true;
|
||
_renderContent(); // neu rendern sobald Daten da
|
||
}).catch(() => {});
|
||
}
|
||
|
||
if (params.exercise_id || params.name) {
|
||
_scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' };
|
||
}
|
||
|
||
// Progress vom Server laden
|
||
API.training.getProgress().then(rows => {
|
||
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
||
// localStorage-Daten migrieren falls noch nicht im Backend
|
||
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => {
|
||
const parts = lsKey.replace('ub_status_', '').split('_');
|
||
const tab = parts[0];
|
||
const name = parts.slice(1).join('_');
|
||
const apiKey = `${tab}_${name}`;
|
||
if (_progressCache[apiKey] === undefined) {
|
||
const val = localStorage.getItem(lsKey);
|
||
if (val) {
|
||
_progressCache[apiKey] = val;
|
||
API.training.setProgress(apiKey, val).catch(() => {});
|
||
}
|
||
}
|
||
});
|
||
_renderContent(); // Re-render with loaded progress
|
||
}).catch(() => {});
|
||
|
||
// Empfehlungen laden
|
||
API.training.getSuggestions().then(suggestions => {
|
||
if (suggestions.length) _showSuggestions(suggestions);
|
||
}).catch(() => {});
|
||
|
||
// Stats + Badges laden
|
||
_loadStatsAndBadges();
|
||
|
||
// Virtueller Trainer laden
|
||
_loadVirtualTrainer();
|
||
}
|
||
|
||
async function _loadStatsAndBadges() {
|
||
const dogId = _dogId();
|
||
if (!dogId) return;
|
||
const [stats, achievements] = await Promise.all([
|
||
_apiGet(`/api/training/stats?dog_id=${dogId}`),
|
||
_apiGet('/api/achievements'),
|
||
]);
|
||
_statsData = stats;
|
||
_badgesData = achievements;
|
||
_renderStatsBanner();
|
||
}
|
||
|
||
function refresh() {
|
||
if (!_VALID_TABS.has(_activeTab)) _activeTab = 'grundkommandos';
|
||
_renderContent();
|
||
}
|
||
function onDogChange() {
|
||
_statsData = null;
|
||
_badgesData = null;
|
||
_loadStatsAndBadges();
|
||
_loadVirtualTrainer();
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HAUPT-RENDER
|
||
// ----------------------------------------------------------
|
||
function _render() {
|
||
_container.innerHTML = `
|
||
<div id="ueb-wrap">
|
||
<div style="padding:var(--space-3) var(--space-4) var(--space-2)">
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<tr>
|
||
<td style="width:100%;padding-right:var(--space-2)">
|
||
<input type="search" id="ueb-search" placeholder="Übung suchen…"
|
||
style="display:block;width:100%;box-sizing:border-box;
|
||
padding:var(--space-2) var(--space-3);
|
||
border:1px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm);
|
||
outline:none" value="${_esc(_searchQuery)}">
|
||
</td>
|
||
<td style="white-space:nowrap;vertical-align:middle">
|
||
<button id="ueb-quicksetup-btn"
|
||
style="padding:5px 12px;height:100%;
|
||
background:var(--c-surface-2);border:1px solid var(--c-border);
|
||
border-radius:var(--radius-sm);cursor:pointer;
|
||
display:flex;align-items:center;gap:var(--space-2)">
|
||
<svg class="ph-icon" style="width:15px;height:15px;flex-shrink:0;color:var(--c-primary)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#list-checks"></use>
|
||
</svg>
|
||
<span style="display:flex;flex-direction:column;align-items:flex-start;gap:1px">
|
||
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--c-text);white-space:nowrap;line-height:1.2">Stand erfassen</span>
|
||
<span style="font-size:10px;color:var(--c-text-muted);white-space:nowrap;line-height:1.2">Wo stehst du bei jeder Übung?</span>
|
||
</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
${_renderTabs()}
|
||
<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);
|
||
_container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2));
|
||
_container.querySelector('#ueb-search')?.addEventListener('input', e => {
|
||
_searchQuery = e.target.value.trim().toLowerCase();
|
||
const tabs = _container.querySelector('#ueb-tabs');
|
||
if (tabs) tabs.style.display = _searchQuery ? 'none' : '';
|
||
_renderContent();
|
||
});
|
||
_bindTabs();
|
||
_renderContent();
|
||
_renderStatsBanner();
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// STATS BANNER
|
||
// ----------------------------------------------------------
|
||
function _renderStatsBanner() {
|
||
const el = _container && _container.querySelector('#ueb-stats-banner');
|
||
if (!el) return;
|
||
if (!_statsData || !_statsData.total_sessions) { el.innerHTML = ''; return; }
|
||
|
||
const s = _statsData;
|
||
const streakHtml = (s.streak_days >= 2)
|
||
? ` · ${s.streak_days}-Tage-Streak 🔥`
|
||
: '';
|
||
const avgHtml = s.avg_erfolgsquote != null
|
||
? ` · Ø ${Math.round(s.avg_erfolgsquote)}% Erfolg`
|
||
: '';
|
||
|
||
// Training-Badges filtern
|
||
let badgesHtml = '';
|
||
if (_badgesData && Array.isArray(_badgesData.user_badges)) {
|
||
const trainingBadges = _badgesData.user_badges.filter(b => b.badge_id && b.badge_id.startsWith('training_'));
|
||
if (trainingBadges.length) {
|
||
const visible = trainingBadges.slice(0, 3);
|
||
const rest = trainingBadges.length - visible.length;
|
||
const pills = visible.map(b => `
|
||
<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 8px;
|
||
border-radius:var(--radius-full,999px);
|
||
background:var(--c-primary-subtle);color:var(--c-primary);
|
||
font-size:var(--text-xs);font-weight:var(--weight-semibold)">
|
||
${b.icon || '🏅'} ${_esc(b.name || b.badge_id)}
|
||
</span>
|
||
`).join('');
|
||
const more = rest > 0
|
||
? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">+${rest} weitere</span>`
|
||
: '';
|
||
badgesHtml = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-2)">${pills}${more}</div>`;
|
||
}
|
||
}
|
||
|
||
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);
|
||
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
|
||
${s.total_sessions} Einheit${s.total_sessions !== 1 ? 'en' : ''}
|
||
</span>${avgHtml}${streakHtml}
|
||
${badgesHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 _phSm = n => `<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#${n}"></use></svg>`;
|
||
const TREND_ICON = { improving: _phSm('trend-up'), declining: _phSm('trend-down'), stable: _phSm('arrow-right'), new: _phSm('star') };
|
||
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;flex-direction:column;gap:var(--space-2);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;width:100%">
|
||
Ü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;flex-direction:column;gap:var(--space-2)">
|
||
${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">
|
||
${TABS.map(t => `
|
||
<button class="by-tab${t.id === _activeTab ? ' active' : ''}"
|
||
data-tab="${t.id}">
|
||
${_esc(t.label)}
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function _showSuggestions(suggestions) {
|
||
const el = _container.querySelector('#ueb-suggestions');
|
||
if (!el || !suggestions.length) return;
|
||
|
||
const COLORS = {
|
||
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)' },
|
||
};
|
||
|
||
el.innerHTML = suggestions.map(s => {
|
||
const c = COLORS[s.type] || COLORS.start;
|
||
return `
|
||
<div style="background:${c.bg};border:1px solid ${c.border};border-radius:var(--radius-md);
|
||
padding:var(--space-3) var(--space-4);display:flex;gap:var(--space-3);align-items:flex-start;cursor:pointer"
|
||
data-action-tab="${_esc(s.action_tab || '')}"
|
||
data-action-name="${_esc(s.action_name || '')}"
|
||
class="ueb-suggestion-card">
|
||
<svg class="ph-icon" style="width:20px;height:20px;flex-shrink:0;color:${c.text};margin-top:2px" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#${_esc(s.icon)}"></use>
|
||
</svg>
|
||
<div style="min-width:0">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:${c.text};margin-bottom:2px">
|
||
${_esc(s.title)}
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
|
||
${_esc(s.text)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
el.querySelectorAll('.ueb-suggestion-card').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
const tab = card.dataset.actionTab;
|
||
if (tab && tab !== _activeTab) {
|
||
_activeTab = tab;
|
||
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.tab === tab)
|
||
);
|
||
_renderContent();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function _bindTabs() {
|
||
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_activeTab = btn.dataset.tab;
|
||
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.tab === _activeTab)
|
||
);
|
||
_renderContent();
|
||
});
|
||
});
|
||
}
|
||
|
||
function _renderContent() {
|
||
const el = _container.querySelector('#ueb-content');
|
||
if (!el) return;
|
||
|
||
const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
|
||
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].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':
|
||
case 'tricks':
|
||
case 'problemverhalten':
|
||
case 'mentale-auslastung':
|
||
case 'hundesport':
|
||
case 'koerperpflege':
|
||
case 'welpe-basics': {
|
||
if (_searchQuery) {
|
||
el.innerHTML = _renderAllResults();
|
||
} else {
|
||
const list = _exercisesByTab[_activeTab] || [];
|
||
const html = list.length ? _renderUebungsList(list) : '';
|
||
el.innerHTML = html
|
||
? `<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">${html}</div>`
|
||
: `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">${UI.icon('spinner')} Übungen werden geladen…</div>`;
|
||
}
|
||
break;
|
||
}
|
||
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
|
||
case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break;
|
||
}
|
||
_bindAccordions();
|
||
_bindStatusButtons();
|
||
_bindLogButtons();
|
||
_bindNotizButtons();
|
||
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
|
||
_tryScrollToTarget();
|
||
}
|
||
|
||
function _tryScrollToTarget() {
|
||
if (!_scrollTarget) return;
|
||
const t = _scrollTarget;
|
||
requestAnimationFrame(() => {
|
||
const card = (t.exercise_id
|
||
? _container.querySelector('[data-exercise-id="' + CSS.escape(t.exercise_id) + '"]')
|
||
: null) || _container.querySelector('[data-exercise-name="' + CSS.escape(t.name) + '"]');
|
||
if (card) {
|
||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
_scrollTarget = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// ÜBUNGS-CARDS
|
||
// ----------------------------------------------------------
|
||
function _renderUebungsList(list) {
|
||
const filtered = _searchQuery
|
||
? list.filter(u =>
|
||
u.name.toLowerCase().includes(_searchQuery) ||
|
||
(u.beschreibung || '').toLowerCase().includes(_searchQuery)
|
||
)
|
||
: list;
|
||
if (!filtered.length) return '';
|
||
return filtered.map((u, i) => _renderCard(u, i)).join('');
|
||
}
|
||
|
||
function _renderAllResults() {
|
||
const q = _searchQuery;
|
||
const allExercises = Object.entries(_exercisesByTab)
|
||
.filter(([tab]) => !['grundlagen','ki-trainer'].includes(tab))
|
||
.flatMap(([, list]) => list)
|
||
.filter(u =>
|
||
u.name.toLowerCase().includes(q) ||
|
||
(u.beschreibung || '').toLowerCase().includes(q)
|
||
);
|
||
if (!allExercises.length) {
|
||
return `<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
|
||
${UI.icon('magnifying-glass')} Keine Übungen gefunden für „${_esc(q)}"
|
||
</div>`;
|
||
}
|
||
return `<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
|
||
${allExercises.map((u, i) => `
|
||
<div>
|
||
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.05em;
|
||
margin-bottom:var(--space-1);padding:0 var(--space-2)">${_esc(u.kategorie)}</div>
|
||
${_renderCard(u, i)}
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
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 _phXs = n => `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#${n}"></use></svg>`;
|
||
const arrow = { improving: _phXs('trend-up'), declining: _phXs('trend-down'), stable: _phXs('arrow-right'), 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}`;
|
||
const currentId = _getStatus(_activeTab, u.name);
|
||
const sm = _statusMeta(currentId);
|
||
|
||
const hasBody = (u.schritte?.length > 0) || (u.fehler?.length > 0) || !!u.steigerung || !!u.tipp;
|
||
|
||
return `
|
||
<div class="card" style="padding:0;overflow:hidden" data-exercise-name="${_esc(u.name)}" data-exercise-id="${_esc(_progressKey(_activeTab, u.name))}">
|
||
<!-- Header -->
|
||
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
|
||
<!-- 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>
|
||
<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-notiz-btn"
|
||
data-tab="${_esc(_activeTab)}"
|
||
data-name="${_esc(u.name)}"
|
||
title="Notiz hinzufügen"
|
||
style="background:none;border:1px solid var(--c-border);cursor:pointer;
|
||
padding:3px 7px;border-radius:var(--radius-sm);
|
||
display:flex;align-items:center;gap:3px;
|
||
font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#note-pencil"></use>
|
||
</svg>
|
||
Notiz
|
||
</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>
|
||
<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">
|
||
${_esc(sm.label)}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<!-- Meta -->
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:${u.beschreibung ? 'var(--space-3)' : '0'}">
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
|
||
${_esc(u.dauer)}
|
||
</span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
|
||
${_esc(u.alter)}
|
||
</span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#package"></use></svg>
|
||
${_esc(u.material)}
|
||
</span>
|
||
</div>
|
||
${u.beschreibung ? `
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0">
|
||
${_esc(u.beschreibung)}
|
||
</p>
|
||
` : ''}
|
||
${u.hinweis ? `
|
||
<div style="margin-top:var(--space-2);padding:var(--space-2) var(--space-3);
|
||
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:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||
<span>${_esc(u.hinweis)}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
${hasBody ? `
|
||
<!-- Akkordeon Toggle -->
|
||
<button class="ueb-acc-btn"
|
||
data-acc="${uid}"
|
||
style="width:100%;display:flex;align-items:center;justify-content:space-between;
|
||
padding:var(--space-2) var(--space-4);
|
||
background:var(--c-surface-2);border:none;border-top:1px solid var(--c-border);
|
||
cursor:pointer;font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<span>Anleitung anzeigen</span>
|
||
<svg class="ph-icon ueb-chevron" data-acc="${uid}" aria-hidden="true" style="width:16px;height:16px;transition:transform 0.2s">
|
||
<use href="/icons/phosphor.svg#caret-down"></use>
|
||
</svg>
|
||
</button>
|
||
<div id="${uid}" hidden style="padding:var(--space-4);border-top:1px solid var(--c-border)">
|
||
${u.schritte?.length ? `
|
||
<p 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:0.05em">
|
||
Schritt für Schritt
|
||
</p>
|
||
<ol style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
|
||
${u.schritte.map(s => `
|
||
<li style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">${_esc(s)}</li>
|
||
`).join('')}
|
||
</ol>
|
||
` : ''}
|
||
${u.fehler?.length ? `
|
||
<p 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:0.05em">
|
||
<svg class="ph-icon" style="width:12px;height:12px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||
Häufige Fehler
|
||
</p>
|
||
<ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
|
||
${u.fehler.map(f => `
|
||
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${_esc(f)}</li>
|
||
`).join('')}
|
||
</ul>
|
||
` : ''}
|
||
${u.steigerung ? `
|
||
<div style="padding:var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);
|
||
font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
|
||
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-primary)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#arrow-right"></use>
|
||
</svg>
|
||
<strong>Steigerung:</strong> ${_esc(u.steigerung)}
|
||
</div>
|
||
` : ''}
|
||
${u.tipp ? `
|
||
<div style="margin-top:var(--space-3);padding:var(--space-2) var(--space-3);
|
||
background:var(--c-primary-subtle);border-radius:var(--radius-sm);
|
||
font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
💡 ${_esc(u.tipp)}
|
||
</div>` : ''}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function _bindStatusButtons() {
|
||
_container.querySelectorAll('.ueb-status-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const tab = btn.dataset.tab;
|
||
const name = btn.dataset.name;
|
||
const cur = _getStatus(tab, name);
|
||
const next = _nextStatus(cur);
|
||
_setStatus(tab, name, next);
|
||
if (next === 'sitzt') UI.toast.success(`🏆 „${name}" sitzt! Gut gemacht!`);
|
||
|
||
// Update button in place (no full re-render)
|
||
const sm = _statusMeta(next);
|
||
btn.title = sm.label;
|
||
btn.style.color = sm.color;
|
||
btn.innerHTML = `
|
||
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#${sm.icon}"></use>
|
||
</svg>
|
||
${next ? `<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">${_esc(sm.label)}</span>` : ''}
|
||
`;
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// LOG SESSION MODAL
|
||
// ----------------------------------------------------------
|
||
function _bindLogButtons() {
|
||
_container.querySelectorAll('.ueb-log-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
_openLogModal(btn.dataset.tab, btn.dataset.name);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _bindNotizButtons() {
|
||
_container.querySelectorAll('.ueb-notiz-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const exerciseId = `${btn.dataset.tab}_${btn.dataset.name.replace(/[\s/]+/g, '_')}`;
|
||
_openNotizModal(exerciseId, btn.dataset.name, btn);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _openNotizModal(exerciseId, exerciseName, triggerBtn) {
|
||
const modalId = 'ueb-notiz-modal';
|
||
document.getElementById(modalId)?.remove();
|
||
|
||
// Lade bestehende Notiz
|
||
let existingNote = null;
|
||
if (_appState?.user) {
|
||
try {
|
||
const notes = await API.notes.get('training_session', exerciseId.length > 0 ? exerciseId : 0);
|
||
if (notes && notes.length > 0) existingNote = notes[0];
|
||
} catch (_) {}
|
||
}
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = modalId;
|
||
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.45);
|
||
`;
|
||
|
||
const noteText = existingNote?.text || '';
|
||
const meta = existingNote?.meta_json || {};
|
||
const currentErfolgsquote = meta.erfolgsquote || null;
|
||
const currentUmgebung = meta.umgebung || null;
|
||
const currentStimmung = meta.hund_stimmung || null;
|
||
|
||
overlay.innerHTML = `
|
||
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
|
||
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
|
||
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
|
||
|
||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
|
||
margin:0 0 var(--space-4);text-align:center">
|
||
Notiz: ${_esc(exerciseName)}
|
||
</h3>
|
||
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||
|
||
<!-- Freitext -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
|
||
<textarea id="ueb-notiz-text" rows="3"
|
||
placeholder="Was ist dir aufgefallen? Tipps für nächstes Mal…"
|
||
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
|
||
border-radius:var(--radius-md);font-size:var(--text-sm);
|
||
font-family:var(--font-sans);background:var(--c-surface);
|
||
color:var(--c-text);resize:vertical;outline:none;
|
||
line-height:1.5">${_esc(noteText)}</textarea>
|
||
</div>
|
||
|
||
<!-- Erfolgsquote -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung (optional)</label>
|
||
<div style="display:flex;gap:var(--space-2)">
|
||
${[1,2,3,4,5].map(n => `
|
||
<button type="button" class="ueb-notiz-pfote" data-val="${n}"
|
||
style="font-size:1.4rem;border:1.5px solid var(--c-border);
|
||
border-radius:var(--radius-md);padding:4px 10px;cursor:pointer;
|
||
background:${currentErfolgsquote === n ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
|
||
border-color:${currentErfolgsquote === n ? 'var(--c-primary)' : 'var(--c-border)'};
|
||
transition:all 0.15s">🐾</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Umgebung -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung (optional)</label>
|
||
<div style="display:flex;gap:var(--space-2)">
|
||
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji, val]) => `
|
||
<button type="button" class="ueb-notiz-umgebung" data-val="${val}"
|
||
style="font-size:1.2rem;border:1.5px solid var(--c-border);
|
||
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
|
||
background:${currentUmgebung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
|
||
border-color:${currentUmgebung === val ? 'var(--c-primary)' : 'var(--c-border)'};
|
||
transition:all 0.15s">${emoji}</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hund-Stimmung -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes (optional)</label>
|
||
<div style="display:flex;gap:var(--space-2)">
|
||
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji, val]) => `
|
||
<button type="button" class="ueb-notiz-stimmung" data-val="${val}"
|
||
style="font-size:1.2rem;border:1.5px solid var(--c-border);
|
||
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
|
||
background:${currentStimmung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
|
||
border-color:${currentStimmung === val ? 'var(--c-primary)' : 'var(--c-border)'};
|
||
transition:all 0.15s">${emoji}</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Buttons -->
|
||
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
|
||
${existingNote ? `
|
||
<button id="ueb-notiz-delete" type="button"
|
||
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
|
||
border:1.5px solid var(--c-danger);background:none;
|
||
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
|
||
Löschen
|
||
</button>
|
||
` : ''}
|
||
<button id="ueb-notiz-cancel" type="button"
|
||
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
|
||
border:1.5px solid var(--c-border);background:none;
|
||
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
|
||
Abbrechen
|
||
</button>
|
||
<button id="ueb-notiz-save" type="button"
|
||
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
|
||
border:none;background:var(--c-primary);
|
||
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
|
||
${existingNote ? 'Aktualisieren' : 'Speichern'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
// State
|
||
let selectedErfolgsquote = currentErfolgsquote;
|
||
let selectedUmgebung = currentUmgebung;
|
||
let selectedStimmung = currentStimmung;
|
||
|
||
// Pfoten-Buttons
|
||
overlay.querySelectorAll('.ueb-notiz-pfote').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const val = parseInt(btn.dataset.val, 10);
|
||
selectedErfolgsquote = selectedErfolgsquote === val ? null : val;
|
||
overlay.querySelectorAll('.ueb-notiz-pfote').forEach(b => {
|
||
const active = parseInt(b.dataset.val, 10) === selectedErfolgsquote;
|
||
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
|
||
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||
});
|
||
});
|
||
});
|
||
|
||
// Umgebung-Buttons
|
||
overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
selectedUmgebung = selectedUmgebung === btn.dataset.val ? null : btn.dataset.val;
|
||
overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(b => {
|
||
const active = b.dataset.val === selectedUmgebung;
|
||
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
|
||
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||
});
|
||
});
|
||
});
|
||
|
||
// Stimmung-Buttons
|
||
overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
selectedStimmung = selectedStimmung === btn.dataset.val ? null : btn.dataset.val;
|
||
overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(b => {
|
||
const active = b.dataset.val === selectedStimmung;
|
||
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
|
||
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||
});
|
||
});
|
||
});
|
||
|
||
function _closeNotizModal() {
|
||
overlay.remove();
|
||
}
|
||
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) _closeNotizModal(); });
|
||
overlay.querySelector('#ueb-notiz-cancel').addEventListener('click', _closeNotizModal);
|
||
|
||
// Speichern
|
||
overlay.querySelector('#ueb-notiz-save').addEventListener('click', async () => {
|
||
const text = overlay.querySelector('#ueb-notiz-text').value.trim();
|
||
if (!text) { UI.toast.warning('Bitte gib eine Notiz ein.'); return; }
|
||
|
||
const saveBtn = overlay.querySelector('#ueb-notiz-save');
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = 'Speichern…';
|
||
|
||
const meta = {};
|
||
if (selectedErfolgsquote) meta.erfolgsquote = selectedErfolgsquote;
|
||
if (selectedUmgebung) meta.umgebung = selectedUmgebung;
|
||
if (selectedStimmung) meta.hund_stimmung = selectedStimmung;
|
||
|
||
const payload = {
|
||
text,
|
||
meta_json: Object.keys(meta).length > 0 ? meta : null,
|
||
};
|
||
|
||
try {
|
||
if (existingNote) {
|
||
await API.notes.update(existingNote.id, payload);
|
||
} else {
|
||
await API.notes.create('training_session', exerciseId, payload);
|
||
}
|
||
_closeNotizModal();
|
||
UI.toast.success('Notiz gespeichert.');
|
||
// Notiz-Button leicht hervorheben
|
||
if (triggerBtn) {
|
||
triggerBtn.style.borderColor = 'var(--c-primary)';
|
||
triggerBtn.style.color = 'var(--c-primary)';
|
||
}
|
||
} catch (err) {
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = existingNote ? 'Aktualisieren' : 'Speichern';
|
||
UI.toast.error('Speichern fehlgeschlagen.');
|
||
}
|
||
});
|
||
|
||
// Löschen
|
||
overlay.querySelector('#ueb-notiz-delete')?.addEventListener('click', async () => {
|
||
if (!existingNote) return;
|
||
try {
|
||
await API.notes.delete(existingNote.id);
|
||
_closeNotizModal();
|
||
UI.toast.success('Notiz gelöscht.');
|
||
if (triggerBtn) {
|
||
triggerBtn.style.borderColor = '';
|
||
triggerBtn.style.color = '';
|
||
}
|
||
} catch (_) {
|
||
UI.toast.error('Löschen fehlgeschlagen.');
|
||
}
|
||
});
|
||
}
|
||
|
||
function _openLogModal(tab, exerciseName, initialReps) {
|
||
// Build the modal HTML
|
||
const modalId = 'ueb-log-modal';
|
||
const formId = 'ueb-log-form';
|
||
// Remove existing if present
|
||
document.getElementById(modalId)?.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = modalId;
|
||
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.45);
|
||
`;
|
||
|
||
overlay.innerHTML = `
|
||
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||
width:100%;max-width:480px;max-height:92vh;overflow-y:auto;
|
||
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
|
||
<!-- Handle -->
|
||
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
|
||
|
||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
|
||
margin:0 0 var(--space-4);text-align:center">
|
||
Einheit loggen: ${_esc(exerciseName)}
|
||
</h3>
|
||
|
||
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||
|
||
<!-- Wiederholungen -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Wiederholungen</label>
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);justify-content:center">
|
||
<button type="button" id="ueb-rep-minus"
|
||
style="width:36px;height:36px;border-radius:50%;border:1.5px solid var(--c-border);
|
||
background:var(--c-surface-2);font-size:1.2rem;cursor:pointer;
|
||
display:flex;align-items:center;justify-content:center;color:var(--c-text)">−</button>
|
||
<span id="ueb-rep-val" style="font-size:var(--text-xl);font-weight:700;color:var(--c-text);min-width:32px;text-align:center">5</span>
|
||
<button type="button" id="ueb-rep-plus"
|
||
style="width:36px;height:36px;border-radius:50%;border:1.5px solid var(--c-border);
|
||
background:var(--c-surface-2);font-size:1.2rem;cursor:pointer;
|
||
display:flex;align-items:center;justify-content:center;color:var(--c-text)">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Wie lief's -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Wie lief's?</label>
|
||
<div style="display:flex;gap:var(--space-2);justify-content:center;flex-wrap:wrap">
|
||
${[['😓','0'],['😐','25'],['🙂','50'],['😊','75'],['🎉','100']].map(([emoji, val]) => `
|
||
<button type="button" class="ueb-erfolg-btn"
|
||
data-val="${val}"
|
||
style="font-size:1.5rem;padding:var(--space-2) var(--space-3);
|
||
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
|
||
background:var(--c-surface-2);cursor:pointer;transition:all 0.15s"
|
||
title="${val}%">${emoji}</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stimmung des Hundes -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
|
||
<div style="display:flex;gap:var(--space-2);justify-content:center;flex-wrap:wrap">
|
||
${[['🎯','aufmerksam'],['😴','müde'],['🌪️','abgelenkt'],['⚡','super']].map(([emoji, val]) => `
|
||
<button type="button" class="ueb-stimmung-btn"
|
||
data-val="${val}"
|
||
style="display:flex;flex-direction:column;align-items:center;gap:2px;
|
||
font-size:1.2rem;padding:var(--space-2);min-width:60px;
|
||
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
|
||
background:var(--c-surface-2);cursor:pointer;transition:all 0.15s">
|
||
${emoji}
|
||
<span style="font-size:9px;color:var(--c-text-secondary)">${_esc(val)}</span>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Zufriedenheit -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Wie zufrieden bist du?</label>
|
||
<div style="display:flex;gap:var(--space-2);justify-content:center">
|
||
${[1,2,3,4,5].map(n => `
|
||
<button type="button" class="ueb-stern-btn"
|
||
data-val="${n}"
|
||
style="font-size:1.5rem;background:none;border:none;cursor:pointer;
|
||
padding:2px;opacity:0.35;transition:opacity 0.15s">⭐</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notiz -->
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
|
||
<textarea id="ueb-log-notiz" rows="2" placeholder="Optional: Was ist aufgefallen?"
|
||
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
font-size:var(--text-sm);font-family:inherit;resize:none;
|
||
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
|
||
</div>
|
||
|
||
<!-- Meilenstein-Checkbox (initially hidden) -->
|
||
<label id="ueb-log-milestone-wrap" hidden
|
||
style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
|
||
padding:var(--space-3);background:var(--c-primary-subtle);
|
||
border:1px solid var(--c-primary-light);border-radius:var(--radius-md)">
|
||
<input type="checkbox" id="ueb-log-milestone"
|
||
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
|
||
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
|
||
📖 Als Meilenstein ins Tagebuch eintragen
|
||
</span>
|
||
</label>
|
||
|
||
</form>
|
||
|
||
<!-- Footer Buttons -->
|
||
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
|
||
<button id="ueb-log-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="ueb-log-save" form="${formId}"
|
||
class="btn btn-primary" style="flex:2"
|
||
type="button">
|
||
Einheit speichern
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
// State
|
||
let wiederholungen = initialReps || 5;
|
||
let erfolgsquote = null; // must be selected
|
||
let stimmung = null;
|
||
let zufriedenheit = null;
|
||
|
||
// Close helpers
|
||
function _closeModal() { overlay.remove(); }
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) _closeModal(); });
|
||
overlay.querySelector('#ueb-log-cancel').addEventListener('click', _closeModal);
|
||
|
||
// 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; }
|
||
});
|
||
overlay.querySelector('#ueb-rep-plus').addEventListener('click', () => {
|
||
wiederholungen++;
|
||
repVal.textContent = wiederholungen;
|
||
});
|
||
|
||
// Erfolg-Buttons
|
||
overlay.querySelectorAll('.ueb-erfolg-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
erfolgsquote = parseInt(btn.dataset.val, 10);
|
||
overlay.querySelectorAll('.ueb-erfolg-btn').forEach(b => {
|
||
b.style.background = 'var(--c-surface-2)';
|
||
b.style.borderColor = 'var(--c-border)';
|
||
b.style.transform = '';
|
||
});
|
||
btn.style.background = 'var(--c-primary-subtle)';
|
||
btn.style.borderColor = 'var(--c-primary)';
|
||
btn.style.transform = 'scale(1.15)';
|
||
_checkMilestoneVisibility();
|
||
});
|
||
});
|
||
|
||
// Stimmung-Buttons
|
||
overlay.querySelectorAll('.ueb-stimmung-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
stimmung = btn.dataset.val;
|
||
overlay.querySelectorAll('.ueb-stimmung-btn').forEach(b => {
|
||
b.style.background = 'var(--c-surface-2)';
|
||
b.style.borderColor = 'var(--c-border)';
|
||
});
|
||
btn.style.background = 'var(--c-primary-subtle)';
|
||
btn.style.borderColor = 'var(--c-primary)';
|
||
});
|
||
});
|
||
|
||
// Stern-Buttons
|
||
overlay.querySelectorAll('.ueb-stern-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
zufriedenheit = parseInt(btn.dataset.val, 10);
|
||
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
|
||
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
|
||
});
|
||
_checkMilestoneVisibility();
|
||
});
|
||
});
|
||
|
||
function _checkMilestoneVisibility() {
|
||
const wrap = overlay.querySelector('#ueb-log-milestone-wrap');
|
||
if (!wrap) return;
|
||
const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4;
|
||
wrap.hidden = !show;
|
||
}
|
||
|
||
// Save
|
||
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
|
||
const dogId = _dogId();
|
||
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;
|
||
saveBtn.textContent = 'Speichern…';
|
||
|
||
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden &&
|
||
overlay.querySelector('#ueb-log-milestone').checked;
|
||
|
||
const body = {
|
||
dog_id: dogId,
|
||
exercise_id: exerciseId,
|
||
exercise_name: exerciseName,
|
||
datum: today,
|
||
wiederholungen: wiederholungen,
|
||
erfolgsquote: erfolgsquote,
|
||
hund_stimmung: stimmung || null,
|
||
zufriedenheit: zufriedenheit || null,
|
||
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
|
||
tagebuch_eintrag: tagebuch,
|
||
};
|
||
|
||
try {
|
||
const resp = await _apiPost('/api/training/sessions', body);
|
||
_closeModal();
|
||
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
|
||
|
||
// Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
|
||
API.post(`/streak/${body.dog_id}/ping`).then(streak => {
|
||
if (!streak) return;
|
||
if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
|
||
setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
|
||
} else if (streak.current_streak > 1) {
|
||
setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
|
||
}
|
||
}).catch(() => {});
|
||
|
||
if (resp.ist_top) {
|
||
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
|
||
} else {
|
||
UI.toast.success('Einheit gespeichert!');
|
||
}
|
||
|
||
if (resp.badges && resp.badges.length) {
|
||
resp.badges.forEach((badge, idx) => {
|
||
setTimeout(() => {
|
||
UI.toast.success(`🏅 Neues Abzeichen: "${badge.name || badge}"!`);
|
||
}, 1000 * (idx + 1));
|
||
});
|
||
}
|
||
|
||
if (resp.diary_entry_id) {
|
||
setTimeout(() => {
|
||
UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
|
||
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
|
||
}
|
||
|
||
// Stats-Banner + Trainer aktualisieren
|
||
_statsData = null;
|
||
_loadStatsAndBadges();
|
||
_loadVirtualTrainer();
|
||
} catch (err) {
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = 'Einheit speichern';
|
||
UI.toast.error('Speichern fehlgeschlagen.');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// KI-TRAINER (neu: hundebasiertes Feedback)
|
||
// ----------------------------------------------------------
|
||
function _renderKiTrainer() {
|
||
const dogId = _dogId();
|
||
if (!dogId) {
|
||
return `
|
||
<div style="padding:var(--space-6) var(--space-4);text-align:center;color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#robot"></use>
|
||
</svg>
|
||
<p style="font-size:var(--text-sm);margin:0">Wähle einen Hund aus um den KI-Trainer zu nutzen.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)" id="ki-trainer-panel">
|
||
|
||
<!-- Header -->
|
||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||
<div style="width:48px;height:48px;border-radius:50%;background:var(--c-primary-subtle);
|
||
display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||
<svg class="ph-icon" style="width:26px;height:26px;color:var(--c-primary)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#robot"></use>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||
KI-Trainer
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
Personalisiertes Feedback basierend auf deinen Trainingseinheiten
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lade-Spinner -->
|
||
<div id="ki-loading" style="text-align:center;padding:var(--space-6);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:32px;height:32px;animation:spin 1s linear infinite;margin-bottom:var(--space-2)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#spinner-gap"></use>
|
||
</svg>
|
||
<p style="font-size:var(--text-sm);margin:0">Lade KI-Feedback…</p>
|
||
</div>
|
||
|
||
<!-- Kein Sessions-Hinweis -->
|
||
<div id="ki-no-sessions" hidden
|
||
style="text-align:center;padding:var(--space-6) var(--space-4);color:var(--c-text-secondary)">
|
||
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#clipboard-text"></use>
|
||
</svg>
|
||
<p style="font-size:var(--text-sm);margin:0">
|
||
Logge deine erste Trainingseinheit um KI-Feedback zu erhalten.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Feedback-Card -->
|
||
<div id="ki-feedback-card" hidden>
|
||
<div class="card" style="border-left:3px solid var(--c-primary);background:var(--c-surface)">
|
||
<div id="ki-feedback-text"
|
||
style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;white-space:pre-wrap"></div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:var(--space-3)">
|
||
<span id="ki-feedback-meta" style="font-size:var(--text-xs);color:var(--c-text-muted)"></span>
|
||
<button id="ki-regenerate"
|
||
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-3);
|
||
border-radius:var(--radius-md);border:1px solid var(--c-border);
|
||
background:var(--c-surface-2);cursor:pointer;color:var(--c-text-secondary)">
|
||
Neu generieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hinweis -->
|
||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
|
||
KI-Tipps ersetzen keinen professionellen Hundetrainer.
|
||
</p>
|
||
|
||
</div>
|
||
<style>
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
`;
|
||
}
|
||
|
||
async function _loadKiTrainerFeedback(forceRefresh) {
|
||
const loading = _container.querySelector('#ki-loading');
|
||
const noSessions = _container.querySelector('#ki-no-sessions');
|
||
const feedbackCard = _container.querySelector('#ki-feedback-card');
|
||
const feedbackText = _container.querySelector('#ki-feedback-text');
|
||
const feedbackMeta = _container.querySelector('#ki-feedback-meta');
|
||
const regenBtn = _container.querySelector('#ki-regenerate');
|
||
if (!loading) return; // not on ki-trainer tab
|
||
|
||
const dogId = _dogId();
|
||
if (!dogId) return;
|
||
|
||
// Show loading
|
||
loading.hidden = false;
|
||
noSessions.hidden = true;
|
||
feedbackCard.hidden = true;
|
||
|
||
// Check if there are any sessions
|
||
const stats = _statsData || await _apiGet(`/api/training/stats?dog_id=${dogId}`);
|
||
if (!stats || !stats.total_sessions) {
|
||
loading.hidden = true;
|
||
noSessions.hidden = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await _apiPost('/api/training/ki-feedback', { dog_id: dogId });
|
||
loading.hidden = true;
|
||
if (!resp || !resp.feedback) {
|
||
noSessions.hidden = false;
|
||
return;
|
||
}
|
||
feedbackText.textContent = resp.feedback;
|
||
const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert';
|
||
const sessionInfo = stats.total_sessions
|
||
? ` · ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}`
|
||
: '';
|
||
const limitInfo = (resp.daily_limit && !resp.cached)
|
||
? ` · ${resp.daily_used}/${resp.daily_limit} heute` : '';
|
||
feedbackMeta.textContent = `${cachedInfo}${sessionInfo}${limitInfo}`;
|
||
if (regenBtn) {
|
||
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;
|
||
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
|
||
if (regenBtn) {
|
||
regenBtn.addEventListener('click', async () => {
|
||
regenBtn.disabled = true;
|
||
regenBtn.textContent = 'Generiere…';
|
||
loading.hidden = false;
|
||
feedbackCard.hidden = true;
|
||
await _loadKiTrainerFeedback(true);
|
||
regenBtn.disabled = false;
|
||
}, { once: true });
|
||
}
|
||
}
|
||
|
||
function _bindAccordions() {
|
||
_container.querySelectorAll('.ueb-acc-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const id = btn.dataset.acc;
|
||
const body = document.getElementById(id);
|
||
const chevron = _container.querySelector(`.ueb-chevron[data-acc="${id}"]`);
|
||
if (!body) return;
|
||
const isOpen = !body.hidden;
|
||
body.hidden = isOpen;
|
||
if (chevron) chevron.style.transform = isOpen ? '' : 'rotate(180deg)';
|
||
btn.querySelector('span').textContent = isOpen ? 'Anleitung anzeigen' : 'Anleitung ausblenden';
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// TRAININGSGRUNDLAGEN
|
||
// ----------------------------------------------------------
|
||
function _renderGrundlagen() {
|
||
return `
|
||
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
|
||
|
||
<!-- Markerwort -->
|
||
<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);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
|
||
</h3>
|
||
<p style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6;margin:0 0 var(--space-3)">
|
||
Ein Markerwort (z.B. <strong>"Ja!"</strong> oder ein Klicker) signalisiert dem Hund den <strong>exakten Moment</strong>
|
||
des richtigen Verhaltens. Es überbrückt die Zeit bis das Leckerli in seinem Mund ist.
|
||
</p>
|
||
<ul style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
|
||
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">
|
||
Einmalig einführen: Markerwort sagen → sofort Leckerli (10x wiederholen)
|
||
</li>
|
||
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">
|
||
Immer nur ein Markerwort verwenden
|
||
</li>
|
||
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">
|
||
Das Leckerli kommt <strong>immer</strong> nach dem Markerwort — sonst verliert es seinen Wert
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- 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;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>
|
||
<tr style="background:var(--c-surface-2)">
|
||
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
|
||
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Phase</th>
|
||
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
|
||
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Belohnungshäufigkeit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${[
|
||
['Neue Übung lernen', 'Jede korrekte Wiederholung'],
|
||
['Übung bekannt', 'Jede 2.–3. Wiederholung'],
|
||
['Übung gefestigt', 'Unregelmäßig (stärkt Motivation)'],
|
||
['Ablenkungstraining', 'Wieder häufiger (höhere Anforderung)'],
|
||
].map(([p, b], i) => `
|
||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text);border-bottom:1px solid var(--c-border)">${_esc(p)}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">${_esc(b)}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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-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">
|
||
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>
|
||
<tr style="background:var(--c-surface-2)">
|
||
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
|
||
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Stufe</th>
|
||
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
|
||
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Beispiele</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${[
|
||
['Niedrig', 'Trockenfutter, normale Hundekekse', '#22c55e'],
|
||
['Mittel', 'Käse, Wurst, Hühnchen (gekocht)', '#eab308'],
|
||
['Hoch', 'Leberwurst, Lachs, Pansen (für besondere Momente)', '#ef4444'],
|
||
].map(([s, b, c], i) => `
|
||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||
<td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">
|
||
<span style="font-weight:var(--weight-semibold);color:${c}">${_esc(s)}</span>
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">${_esc(b)}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Trainingsregeln -->
|
||
<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);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
|
||
</h3>
|
||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:var(--space-2)">
|
||
${[
|
||
[true, 'Kurze Einheiten (5–10 Min), lieber mehrmals täglich'],
|
||
[true, 'Immer mit Erfolg beenden'],
|
||
[true, 'Ein Kommando — eine Bedeutung'],
|
||
[true, 'Konsequenz bei allen Haushaltsmitgliedern'],
|
||
[false, 'Nie bestrafen, schreien oder Zwang anwenden'],
|
||
[false, 'Kommando nicht wiederholen wenn der Hund nicht reagiert'],
|
||
[false, 'Nicht trainieren wenn Hund müde, krank oder aufgewühlt ist'],
|
||
].map(([ok, text]) => `
|
||
<li style="display:flex;gap:var(--space-2);align-items:flex-start">
|
||
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:2px;
|
||
color:${ok ? 'var(--c-success)' : 'var(--c-danger)'}" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#${ok ? 'check-circle' : 'x-circle'}"></use>
|
||
</svg>
|
||
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">${_esc(text)}</span>
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HELPER
|
||
// ----------------------------------------------------------
|
||
function _esc(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// PUBLIC
|
||
// ----------------------------------------------------------
|
||
return { init, refresh, onDogChange };
|
||
|
||
})();
|