banyaro/backend/static/js/pages/uebungen.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

2455 lines
114 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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 _helpHandle = null; // Rückgabe von UI.pageInfo — für inline Trigger-Button
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' },
{ id: 'verlauf', label: 'Protokoll' },
];
// ----------------------------------------------------------
// Ü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 _progressLoaded = false;
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);
return _progressCache[k] ?? null;
}
function _setStatus(tab, name, statusId) {
const k = _progressKey(tab, name);
_progressCache[k] = statusId;
API.training.setProgress(k, statusId, _dogId()).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ängerFortgeschr.', color: '#eab308' },
};
const GRUNDKOMMANDOS = [
{
name: 'Sitz',
schwierigkeit: 'Anfänger',
alter: 'Ab 8 Wochen',
dauer: '35 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 510x, 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: '35 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 1015 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 (23 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: '510 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: '35 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: '35 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: '35 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: '510 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: '510 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 12 Stunden allein gelassen werden. Erwachsene Hunde maximal 46 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: '510 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: '515 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();
_helpHandle = UI.pageInfo(_container, {
pageId: 'uebungen',
defaultClosed: true,
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 (hund-spezifisch)
const _did = _dogId();
_progressLoaded = false;
API.training.getProgress(_did)
.then(rows => {
_progressCache = {};
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
_progressLoaded = true;
_renderContent();
}).catch(() => { _progressLoaded = true; _renderContent(); });
// Empfehlungen laden
API.training.getSuggestions(_did).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;
_progressCache = {};
_progressLoaded = false;
_exerciseStats = {};
_verlaufSessions = [];
_verlaufOffset = 0;
_verlaufLoading = false;
_verlaufView = 'datum';
_render();
_loadStatsAndBadges();
_loadVirtualTrainer();
}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div id="ueb-wrap">
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
<div style="padding:var(--space-2) var(--space-4) var(--space-2);display:flex;gap:var(--space-2);align-items:stretch">
<input type="search" id="ueb-search" placeholder="Übung suchen…"
style="flex:1;min-width:0;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">
<button id="ueb-quicksetup-btn"
style="flex-shrink:0;width:64px;
background:var(--c-primary-subtle);border:1.5px solid var(--c-primary-light);
border-radius:var(--radius-md);cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px">
<svg class="ph-icon" style="width:24px;height:24px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<span style="font-size:9px;font-weight:var(--weight-semibold);color:var(--c-primary);line-height:1.2;text-align:center">Stand<br>erfassen</span>
</button>
</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>
`;
UI.bindDogChip(_container, _appState);
_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();
if (_progressLoaded) {
_renderContent();
} else {
const el = _container.querySelector('#ueb-content');
if (el) el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)"><svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg></div>`;
}
_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)
? ` &nbsp;·&nbsp; ${s.streak_days}-Tage-Streak 🔥`
: '';
const avgHtml = s.avg_erfolgsquote != null
? ` &nbsp;·&nbsp; Ø ${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 class="text-xs-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 class="text-xs-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 class="text-xs-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>
<span id="ueb-help-anchor" style="margin-left:auto"></span>
</div>
<div class="flex-col-gap-2">
${cards.join('')}
</div>`;
if (_helpHandle) {
document.getElementById('ueb-help-anchor')?.appendChild(_helpHandle.makeTriggerBtn());
}
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
// ----------------------------------------------------------
async function _openQuickSetupModal() {
// Sicherstellen dass Progress geladen ist bevor das Modal öffnet
if (!_progressLoaded) {
const did = _dogId();
try {
const rows = await API.training.getProgress(did);
_progressCache = {};
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
_progressLoaded = true;
_renderContent();
} catch { _progressLoaded = true; }
}
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]) => {
_progressCache[key] = val || null;
return API.training.setProgress(key, val || null, _dogId());
});
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 isVerlauf = _activeTab === 'verlauf';
const showIf = v => v ? '' : 'none';
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
if (quickWrap) quickWrap.style.display = isExerciseTab ? 'flex' : 'none';
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 'verlauf': {
if (_verlaufSessions.length > 0) {
el.innerHTML = `<div id="verlauf-wrap" style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">${_verlaufToggleHtml()}<div id="verlauf-list"></div></div>`;
_renderVerlaufList(el.querySelector('#verlauf-list'));
} else {
el.innerHTML = _renderVerlaufShell();
_loadVerlauf();
}
_bindVerlaufToggle();
break;
}
case 'ki-trainer':
if (!App.hasPro(_appState?.user)) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
<div class="text-sm">Der KI-Trainer ist ein Pro-Feature.</div>
</div>`;
} else {
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 class="text-xs-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 class="text-xs-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 class="text-xs-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 class="flex-gap-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 class="flex-gap-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 class="flex-gap-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>
</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)';
});
});
// 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';
});
});
});
// 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 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,
};
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));
});
}
// 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 class="text-xs-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" class="text-xs-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';
});
});
}
// ----------------------------------------------------------
// TRAININGSPROTOKOLL (Verlauf-Tab)
// ----------------------------------------------------------
let _verlaufSessions = [];
let _verlaufOffset = 0;
let _verlaufHasMore = false;
let _verlaufLoading = false;
let _verlaufView = 'datum'; // 'datum' | 'uebung'
const _VERLAUF_LIMIT = 30;
const _ERFOLG_EMOJI = { 0: '😓', 25: '😐', 50: '🙂', 75: '😊', 100: '🎉' };
const _STIMMUNG_EMOJI = { aufmerksam: '🎯', müde: '😴', abgelenkt: '🌪️', super: '⚡' };
function _renderVerlaufShell() {
const dogId = _dogId();
if (!dogId) {
return `<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
<p class="text-sm">Wähle einen Hund aus um das Protokoll zu sehen.</p>
</div>`;
}
return `<div id="verlauf-wrap" style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
${_verlaufToggleHtml()}
<div id="verlauf-list" class="flex-col-gap-2">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>
</div>`;
}
function _verlaufToggleHtml() {
const btnBase = `padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
font-size:var(--text-xs);font-weight:var(--weight-semibold);cursor:pointer;
border:1px solid var(--c-border);transition:all .15s`;
const active = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`;
const inactive = `background:var(--c-surface-2);color:var(--c-text-secondary)`;
return `
<div class="flex-gap-2">
<button id="verlauf-btn-datum" style="${btnBase};${_verlaufView==='datum'?active:inactive}">
Nach Datum
</button>
<button id="verlauf-btn-uebung" style="${btnBase};${_verlaufView==='uebung'?active:inactive}">
Nach Übung
</button>
</div>`;
}
async function _loadVerlauf(append = false) {
if (_verlaufLoading) return;
const dogId = _dogId();
if (!dogId) return;
if (!append) {
_verlaufSessions = [];
_verlaufOffset = 0;
}
_verlaufLoading = true;
const data = await _apiGet(
`/api/training/sessions?dog_id=${dogId}&limit=${_VERLAUF_LIMIT + 1}&offset=${_verlaufOffset}`
).catch(() => null);
_verlaufLoading = false;
// Element nach await neu holen — könnte durch Re-Render veraltet sein
const el = _container?.querySelector('#verlauf-list');
if (!el) return;
if (!data) {
if (!append) el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)">Fehler beim Laden.</div>`;
return;
}
_verlaufHasMore = data.length > _VERLAUF_LIMIT;
const rows = data.slice(0, _VERLAUF_LIMIT);
_verlaufSessions = append ? [..._verlaufSessions, ...rows] : rows;
_verlaufOffset += rows.length;
_renderVerlaufList(el);
}
function _bindVerlaufToggle() {
const wrap = _container?.querySelector('#verlauf-wrap');
if (!wrap) return;
const btnDatum = wrap.querySelector('#verlauf-btn-datum');
const btnUebung = wrap.querySelector('#verlauf-btn-uebung');
const setActive = view => {
_verlaufView = view;
const active = `var(--c-primary)`;
const inBg = `var(--c-surface-2)`;
btnDatum.style.background = view === 'datum' ? active : inBg;
btnDatum.style.color = view === 'datum' ? '#fff' : 'var(--c-text-secondary)';
btnDatum.style.borderColor = view === 'datum' ? active : 'var(--c-border)';
btnUebung.style.background = view === 'uebung' ? active : inBg;
btnUebung.style.color = view === 'uebung' ? '#fff' : 'var(--c-text-secondary)';
btnUebung.style.borderColor = view === 'uebung' ? active : 'var(--c-border)';
const listEl = wrap.querySelector('#verlauf-list');
if (listEl) _renderVerlaufList(listEl);
};
btnDatum?.addEventListener('click', () => setActive('datum'));
btnUebung?.addEventListener('click', () => setActive('uebung'));
}
function _renderVerlaufList(el) {
if (!_verlaufSessions.length) {
el.innerHTML = `
<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
<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">Noch keine Trainingseinheiten geloggt.</p>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Tippe in einer Übung auf "+ Einheit" um zu starten.
</p>
</div>`;
return;
}
if (_verlaufView === 'uebung') {
_renderVerlaufByUebung(el);
} else {
_renderVerlaufByDatum(el);
}
}
function _sessionRow(s) {
const erfolg = _ERFOLG_EMOJI[s.erfolgsquote] || '🙂';
const stimmung = s.hund_stimmung ? (_STIMMUNG_EMOJI[s.hund_stimmung] || '') : '';
const topBadge = s.ist_top
? `<span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:999px;
background:rgba(22,163,74,0.12);color:#15803d;border:1px solid rgba(22,163,74,0.3)">TOP</span>`
: '';
const noteHtml = s.notiz
? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:3px;
line-height:1.4;font-style:italic">${_esc(s.notiz)}</div>`
: '';
return `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2)">
<span style="font-size:1.2rem;flex-shrink:0;margin-top:1px">${erfolg}</span>
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(s.exercise_name)}</span>
${topBadge}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:1px">
${s.wiederholungen}× Wdh.${stimmung ? ' · ' + stimmung : ''}${s.zufriedenheit ? ' · ' + '⭐'.repeat(s.zufriedenheit) : ''}
</div>
${noteHtml}
</div>
</div>`;
}
function _renderVerlaufByDatum(el) {
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const groups = {};
_verlaufSessions.forEach(s => {
groups[s.datum] = groups[s.datum] || [];
groups[s.datum].push(s);
});
const html = Object.entries(groups).map(([datum, sessions]) => {
let label;
if (datum === today) label = 'Heute';
else if (datum === yesterday) label = 'Gestern';
else {
const d = new Date(datum + 'T00:00:00');
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' });
}
return `
<div>
<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-2) 0 var(--space-1)">
${_esc(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
${sessions.map(_sessionRow).join('')}
</div>
</div>`;
}).join('');
const moreBtn = _verlaufHasMore
? `<button id="verlauf-more"
style="width:100%;padding:var(--space-3);border:1px solid var(--c-border);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary);cursor:pointer;
margin-top:var(--space-2)">
Weitere laden
</button>`
: '';
el.innerHTML = html + moreBtn;
el.querySelector('#verlauf-more')?.addEventListener('click', () => _loadVerlauf(true));
}
function _renderVerlaufByUebung(el) {
// Sessions nach Übungsname gruppieren
const groups = {};
_verlaufSessions.forEach(s => {
if (!groups[s.exercise_name]) groups[s.exercise_name] = [];
groups[s.exercise_name].push(s);
});
// Pro Gruppe Stats berechnen
const today = new Date().toISOString().slice(0, 10);
const exerciseStats = Object.entries(groups).map(([name, sessions]) => {
const avg = Math.round(sessions.reduce((a, s) => a + s.erfolgsquote, 0) / sessions.length);
const recent = sessions.slice(0, 3);
const older = sessions.slice(3, 6);
let trend = 'new';
if (older.length) {
const rAvg = recent.reduce((a, s) => a + s.erfolgsquote, 0) / recent.length;
const oAvg = older.reduce((a, s) => a + s.erfolgsquote, 0) / older.length;
trend = rAvg - oAvg > 10 ? 'up' : rAvg - oAvg < -10 ? 'down' : 'stable';
}
const lastDate = sessions[0].datum;
const daysSince = Math.floor((new Date(today) - new Date(lastDate)) / 86400000);
return { name, sessions, avg, trend, lastDate, daysSince, topCount: sessions.filter(s => s.ist_top).length };
});
// Sortieren: zuletzt trainiert zuerst
exerciseStats.sort((a, b) => a.daysSince - b.daysSince);
const TREND_ICON = { up: '↑', down: '↓', stable: '→', new: '★' };
const TREND_COLOR = { up: '#15803d', down: '#dc2626', stable: 'var(--c-text-secondary)', new: 'var(--c-primary)' };
const cards = exerciseStats.map((ex, i) => {
const uid = `vl-ex-${i}`;
const barColor = ex.avg >= 75 ? '#15803d' : ex.avg >= 50 ? '#c2410c' : '#dc2626';
const barBg = ex.avg >= 75 ? 'rgba(22,163,74,0.15)' : ex.avg >= 50 ? 'rgba(194,65,12,0.15)' : 'rgba(220,38,38,0.15)';
const lastLabel = ex.daysSince === 0 ? 'Heute'
: ex.daysSince === 1 ? 'Gestern'
: `vor ${ex.daysSince} Tagen`;
const sessionRows = ex.sessions.map(s => {
const d = new Date(s.datum + 'T00:00:00');
const dateLabel = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
const erfolg = _ERFOLG_EMOJI[s.erfolgsquote] || '🙂';
const stimmung = s.hund_stimmung ? (_STIMMUNG_EMOJI[s.hund_stimmung] || '') : '';
const top = s.ist_top ? ' ★' : '';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:4px var(--space-2);border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text-secondary)">
<span style="flex-shrink:0;min-width:52px">${_esc(dateLabel)}</span>
<span style="flex-shrink:0">${erfolg}</span>
<span style="flex-shrink:0">${s.erfolgsquote}%${top}</span>
<span class="flex-1-min">${s.wiederholungen}× Wdh.${stimmung ? ' ' + stimmung : ''}</span>
</div>`;
}).join('');
return `
<div class="card" style="padding:0;overflow:hidden">
<!-- Header (klickbar zum Aufklappen) -->
<button class="verlauf-ex-btn" data-uid="${uid}"
style="width:100%;padding:var(--space-3) var(--space-4);display:flex;
align-items:center;gap:var(--space-3);background:none;border:none;
cursor:pointer;text-align:left">
<!-- Fortschrittsring -->
<div style="flex-shrink:0;width:40px;height:40px;border-radius:50%;
background:${barBg};display:flex;flex-direction:column;
align-items:center;justify-content:center">
<span style="font-size:11px;font-weight:700;color:${barColor};line-height:1">${ex.avg}%</span>
<span style="font-size:9px;color:${barColor};line-height:1;margin-top:1px">
${TREND_ICON[ex.trend]}
</span>
</div>
<!-- Info -->
<div class="flex-1-min">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);line-height:1.3;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(ex.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${ex.sessions.length} Einheit${ex.sessions.length !== 1 ? 'en' : ''}
${ex.topCount ? ' · ' + ex.topCount + '× TOP' : ''}
· ${_esc(lastLabel)}
</div>
</div>
<!-- Chevron -->
<svg class="ph-icon verlauf-ex-chevron" data-uid="${uid}"
style="width:16px;height:16px;flex-shrink:0;color:var(--c-text-muted);transition:transform .2s"
aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<!-- Eingeklappte Session-Liste -->
<div id="${uid}" hidden
style="border-top:1px solid var(--c-border);padding:var(--space-2) var(--space-3)">
${sessionRows}
</div>
</div>`;
}).join('');
const hint = _verlaufHasMore
? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;padding:var(--space-2) 0">
Zeigt die letzten ${_verlaufSessions.length} Einheiten — ältere nicht berücksichtigt.
</div>`
: '';
el.innerHTML = cards + hint;
// Akkordeon-Binding
el.querySelectorAll('.verlauf-ex-btn').forEach(btn => {
btn.addEventListener('click', () => {
const uid = btn.dataset.uid;
const body = document.getElementById(uid);
const chev = el.querySelector(`.verlauf-ex-chevron[data-uid="${uid}"]`);
const isOpen = !body.hidden;
body.hidden = isOpen;
if (chev) chev.style.transform = isOpen ? '' : 'rotate(180deg)';
});
});
}
// ----------------------------------------------------------
// TRAININGSGRUNDLAGEN
// ----------------------------------------------------------
function _renderGrundlagen() {
return `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Markerwort -->
<div class="card p-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 (510 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, onDogChange };
})();