Session 2026-04-22: Training, Fixes, KI-Cloud, Dark-Mode

Training-System:
- Einheit-Dialog Bugs behoben (UI.toast callable, _dogId via _appState, activeDog.id)
- Virtueller Trainer (rein statistisch): üben/festigen/entdecken/levelup
  Empfehlungen auf Basis exercise_progress + sessions, Prognose bis 80%
- Stand erfassen Modal: alle Übungen auf einmal setzen (onboarding)
- Erfolgsindikatoren auf Karten: Ø-Quote + Trend-Pfeil + Anzahl Sessions
- exercise_progress → synthetische Stats im Trainer (ohne Sessions nutzbar)
- Levelup: Tricks empfehlen wenn ≥4 Grundkommandos sitzen
- Kommandos & Fähigkeiten im Hundeprofil + öffentlichem Profil
- 2 neue Problemverhalten-Übungen: Bellen/Kläffen, Enttriggern

Mobile/UI-Fixes:
- Übungskarten: Name + Difficulty oben, Buttons eigene Zeile (kein Umbruch)
- Trainingsgrundlagen: Padding in allen Karten, Hinweis-Boxen Dark-Mode-sicher
- Tab-Sichtbarkeit: Trainer/Suggestions nur auf Übungs-Tabs
- Tagebuch FAB (Neu-Eintrag Button) + Quick-Add Eintrag
- FAB Abstand fix (nav-bottom-height + safe-bottom)
- Suggestion-Karten rgba (Dark-Mode)
- routes.js + uebungen.js: alle Hellfarben → rgba (Dark-Mode-sicher)
- ui.js: UI.toast als callable Function-Object (war nur plain Object)

KI & Backend:
- KI_MODE=cloud + ANTHROPIC_API_KEY gesetzt
- ki.py: Cloud-Fallback wenn local nicht erreichbar + KI_MODE=cloud
- KI-Trainer Tageslimit 10 Anfragen/User + ki_daily_calls Tabelle
- Admin-Panel: KI-Nutzung (heute/Monat/User)
- Status-Report Fix (lost-Tabelle) → 06:00 + 18:00 täglich
- Wiki-Anreicherung läuft jetzt (50 Rassen Startup, 20/Nacht)
- landing.html: Trainings-Features in JSON-LD + Feature-Karten
This commit is contained in:
rene 2026-04-22 19:41:22 +02:00
parent 2b442ebd98
commit 44081a6b9d
16 changed files with 938 additions and 117 deletions

View file

@ -13,7 +13,7 @@ window.Page_uebungen = (() => {
// API HELPERS
// ----------------------------------------------------------
function _dogId() {
return window.App?.state?.activeDogId || null;
return _appState?.activeDog?.id || null;
}
async function _apiPost(url, body) {
@ -65,7 +65,8 @@ window.Page_uebungen = (() => {
}
// In-memory cache (loaded from API on init)
let _progressCache = {}; // key → statusId
let _progressCache = {}; // key → statusId
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
function _progressKey(tab, name) {
return `${tab}_${name.replace(/[\s/]+/g, '_')}`;
@ -381,6 +382,52 @@ window.Page_uebungen = (() => {
steigerung: null,
hinweis: 'Hilfsmittel bei hartnäckigem Ziehen: Brustgeschirr mit vorderer Befestigung (kein Würge- oder Stachelband).',
},
{
name: 'Bellen / Kläffen',
schwierigkeit: 'Mittel',
alter: 'Ab 8 Wochen',
dauer: '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.',
},
];
// ----------------------------------------------------------
@ -418,6 +465,9 @@ window.Page_uebungen = (() => {
// Stats + Badges laden
_loadStatsAndBadges();
// Virtueller Trainer laden
_loadVirtualTrainer();
}
async function _loadStatsAndBadges() {
@ -437,6 +487,7 @@ window.Page_uebungen = (() => {
_statsData = null;
_badgesData = null;
_loadStatsAndBadges();
_loadVirtualTrainer();
}
// ----------------------------------------------------------
@ -446,11 +497,25 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
${_renderTabs()}
<div id="ueb-stats-banner" style="padding:0 var(--space-4);margin-bottom:var(--space-2)"></div>
<div style="padding:var(--space-3) var(--space-4) 0;display:flex;justify-content:flex-end">
<button id="ueb-quicksetup-btn"
style="font-size:var(--text-xs);padding:4px 10px;
background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-secondary);
display:flex;align-items:center;gap:4px">
<svg class="ph-icon" style="width:13px;height:13px" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
</svg>
Stand erfassen
</button>
</div>
<div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div>
<div id="ueb-trainer" style="padding:0 var(--space-4);margin-bottom:var(--space-2)"></div>
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
<div id="ueb-content"></div>
</div>
`;
_container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal);
_bindTabs();
_renderContent();
_renderStatsBanner();
@ -506,6 +571,231 @@ window.Page_uebungen = (() => {
`;
}
// ----------------------------------------------------------
// VIRTUELLER TRAINER
// ----------------------------------------------------------
async function _loadVirtualTrainer() {
const dogId = _dogId();
const el = _container?.querySelector('#ueb-trainer');
if (!el) return;
if (!dogId) { el.innerHTML = ''; return; }
el.innerHTML = `
<div style="background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Lade Trainingsplan</div>
</div>`;
const data = await API.training.getRecommendations(dogId).catch(() => null);
if (!data) { el.innerHTML = ''; return; }
if (data.all_stats) {
_exerciseStats = data.all_stats;
_renderContent();
}
if (!data.recommendations?.length) { el.innerHTML = ''; return; }
_renderVirtualTrainer(el, data);
}
function _renderVirtualTrainer(el, data) {
const recs = data.recommendations;
const TYPE_CFG = {
'üben': { label: 'Üben', bg: 'rgba(234,88,12,0.10)', border: 'rgba(234,88,12,0.30)', text: '#fb923c', icon: 'fire' },
'festigen': { label: 'Festigen', bg: 'rgba(22,163,74,0.10)', border: 'rgba(22,163,74,0.30)', text: '#4ade80', icon: 'star' },
'entdecken': { label: 'Auffrischen',bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)', icon: 'clock' },
'levelup': { label: 'Nächster Level', bg: 'rgba(234,179,8,0.10)', border: 'rgba(234,179,8,0.30)', text: '#facc15', icon: 'graduation-cap' },
};
const TREND_ICON = { improving: '↑', declining: '↓', stable: '→', new: '★' };
const TREND_COLOR = { improving: 'var(--c-success,#16a34a)', declining: '#dc2626', stable: 'var(--c-text-secondary)', new: 'var(--c-primary)' };
const cards = recs.map((r, i) => {
const cfg = TYPE_CFG[r.type] || TYPE_CFG['üben'];
const trend = TREND_ICON[r.trend] || '';
const trendColor = TREND_COLOR[r.trend] || 'var(--c-text-secondary)';
const prognose = r.prognose_sessions
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Prognose: ~${r.prognose_sessions} Einheiten bis 80%
</div>`
: '';
return `
<div style="background:${cfg.bg};border:1px solid ${cfg.border};
border-radius:var(--radius-md);padding:var(--space-3);
display:flex;flex-direction:column;gap:var(--space-2);flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0;color:${cfg.text}" aria-hidden="true">
<use href="/icons/phosphor.svg#${cfg.icon}"></use>
</svg>
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${cfg.text};
text-transform:uppercase;letter-spacing:.04em">${cfg.label}</span>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);line-height:1.3">${_esc(r.exercise_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.4">${_esc(r.reason)}</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:var(--space-1)">
<div>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${r.suggested_reps}× empfohlen</span>
<span style="font-size:var(--text-xs);font-weight:700;color:${trendColor};margin-left:4px">${trend}</span>
${prognose}
</div>
<button class="ueb-trainer-btn btn btn-primary"
data-tab="${_esc(r.tab)}" data-name="${_esc(r.exercise_name)}" data-reps="${r.suggested_reps}"
style="font-size:var(--text-xs);padding:4px 10px;flex-shrink:0">
Üben
</button>
</div>
</div>`;
});
el.innerHTML = `
<div style="margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#target"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
Dein Plan für heute
</span>
</div>
<div style="display:flex;gap:var(--space-2);align-items:stretch">
${cards.join('')}
</div>`;
el.querySelectorAll('.ueb-trainer-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
const name = btn.dataset.name;
const reps = parseInt(btn.dataset.reps, 10) || 5;
// Tab wechseln falls nötig
if (tab && tab !== _activeTab) {
_activeTab = tab;
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab)
);
_renderContent();
}
_openLogModal(tab, name, reps);
});
});
}
// ----------------------------------------------------------
// SCHNELL-SETUP: Stand aller Übungen erfassen
// ----------------------------------------------------------
function _openQuickSetupModal() {
const ALL = [
{ group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS },
{ group: 'Tricks', tab: 'tricks', items: TRICKS },
{ group: 'Problemverhalten', tab: 'problemverhalten', items: PROBLEMVERHALTEN },
];
const STATUS_QUICK = [
{ id: null, label: '—', color: 'var(--c-text-secondary)', bg: 'var(--c-surface-2)' },
{ id: 'noch-nicht',label: 'Nein', color: '#dc2626', bg: 'rgba(220,38,38,0.10)' },
{ id: 'manchmal', label: 'Manchmal', color: '#c2410c', bg: 'rgba(234,88,12,0.10)' },
{ id: 'meistens', label: 'Meistens', color: '#ca8a04', bg: 'rgba(234,179,8,0.10)' },
{ id: 'sitzt', label: 'Sitzt ✓', color: '#15803d', bg: 'rgba(22,163,74,0.10)' },
];
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;display:flex;align-items:flex-end;justify-content:center;background:rgba(0,0,0,0.5)';
const rows = ALL.flatMap(g => g.items.map(u => ({ tab: g.tab, name: u.name, group: g.group })));
let pending = {}; // key → statusId (nur geänderte)
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:560px;max-height:90vh;display:flex;flex-direction:column;
box-shadow:0 -4px 24px rgba(0,0,0,0.2)">
<div style="padding:var(--space-4) var(--space-4) var(--space-2);border-bottom:1px solid var(--c-border);flex-shrink:0">
<div style="width:36px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-3)"></div>
<div style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">Stand erfassen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Tippe auf den aktuellen Stand deines Hundes wird sofort im Profil sichtbar.
</div>
</div>
<div style="overflow-y:auto;flex:1;padding:var(--space-2) var(--space-4)">
${ALL.map(g => `
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em;padding:var(--space-3) 0 var(--space-1)">
${_esc(g.group)}
</div>
${g.items.map(u => {
const key = _progressKey(g.tab, u.name);
const cur = _getStatus(g.tab, u.name);
return `
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) 0;border-bottom:1px solid var(--c-border)" data-key="${_esc(key)}">
<span style="flex:1;font-size:var(--text-sm);color:var(--c-text);min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.name)}</span>
<div style="display:flex;gap:3px;flex-shrink:0">
${STATUS_QUICK.map(s => `
<button class="qs-btn" data-key="${_esc(key)}" data-val="${s.id === null ? '' : _esc(s.id)}"
style="font-size:9px;font-weight:700;padding:3px 6px;border-radius:999px;cursor:pointer;
white-space:nowrap;transition:all .15s;
background:${cur === s.id ? s.bg : 'var(--c-surface-2)'};
color:${cur === s.id ? s.color : 'var(--c-text-secondary)'};
border:1px solid ${cur === s.id ? s.color + '66' : 'var(--c-border)'}">
${_esc(s.label)}
</button>`).join('')}
</div>
</div>`;
}).join('')}
`).join('')}
</div>
<div style="padding:var(--space-4);border-top:1px solid var(--c-border);display:flex;gap:var(--space-3);flex-shrink:0">
<button id="qs-cancel" style="flex:1;padding:var(--space-3);border:1.5px solid var(--c-border);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);cursor:pointer;color:var(--c-text)">Abbrechen</button>
<button id="qs-save" class="btn btn-primary" style="flex:2">Übernehmen</button>
</div>
</div>`;
document.body.appendChild(overlay);
// Button-Interaktion
overlay.addEventListener('click', e => {
const btn = e.target.closest('.qs-btn');
if (!btn) return;
const key = btn.dataset.key;
const val = btn.dataset.val || null;
pending[key] = val;
// Aktiven Button in dieser Zeile highlighten
const row = overlay.querySelector(`[data-key="${CSS.escape(key)}"]`);
row?.querySelectorAll('.qs-btn').forEach(b => {
const s = STATUS_QUICK.find(s => (s.id ?? '') === (b.dataset.val));
const active = b.dataset.val === (val ?? '');
b.style.background = active ? s.bg : 'var(--c-surface-2)';
b.style.color = active ? s.color : 'var(--c-text-secondary)';
b.style.border = `1px solid ${active ? s.color + '66' : 'var(--c-border)'}`;
});
});
overlay.querySelector('#qs-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#qs-save').addEventListener('click', async () => {
if (!Object.keys(pending).length) { overlay.remove(); return; }
const saveBtn = overlay.querySelector('#qs-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichern…';
// Alle geänderten Status speichern
const parts = Object.entries(pending).map(([key, val]) => {
const [tab, ...rest] = key.split('_');
const name = rest.join('_').replace(/_/g, ' ');
_progressCache[key] = val || null;
localStorage.setItem(`ub_status_${key}`, val || '');
return API.training.setProgress(key, val || null);
});
await Promise.allSettled(parts);
overlay.remove();
_renderContent();
UI.toast.success('Stand gespeichert!');
});
}
function _renderTabs() {
return `
<div class="by-tabs" style="padding:var(--space-4) var(--space-4) 0" id="ueb-tabs">
@ -524,9 +814,9 @@ window.Page_uebungen = (() => {
if (!el || !suggestions.length) return;
const COLORS = {
help: { bg: '#fef2f2', border: '#fca5a5', text: '#dc2626' },
boost: { bg: '#fff7ed', border: '#fdba74', text: '#ea580c' },
next: { bg: '#f0fdf4', border: '#86efac', text: '#16a34a' },
help: { bg: 'rgba(220,38,38,0.08)', border: 'rgba(220,38,38,0.25)', text: '#f87171' },
boost: { bg: 'rgba(234,88,12,0.08)', border: 'rgba(234,88,12,0.25)', text: '#fb923c' },
next: { bg: 'rgba(22,163,74,0.08)', border: 'rgba(22,163,74,0.25)', text: '#4ade80' },
start: { bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)' },
};
@ -582,6 +872,19 @@ window.Page_uebungen = (() => {
function _renderContent() {
const el = _container.querySelector('#ueb-content');
if (!el) return;
const isExerciseTab = ['grundkommandos', 'tricks', 'problemverhalten'].includes(_activeTab);
const showIf = v => v ? '' : 'none';
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
if (quickWrap) quickWrap.style.display = showIf(isExerciseTab);
const trainerEl = _container.querySelector('#ueb-trainer');
const suggestEl = _container.querySelector('#ueb-suggestions');
const bannerEl = _container.querySelector('#ueb-stats-banner');
if (trainerEl) trainerEl.style.display = showIf(isExerciseTab);
if (suggestEl) suggestEl.style.display = showIf(isExerciseTab);
if (bannerEl) bannerEl.style.display = showIf(isExerciseTab);
switch (_activeTab) {
case 'grundkommandos': el.innerHTML = _renderUebungsList(GRUNDKOMMANDOS); break;
case 'tricks': el.innerHTML = _renderUebungsList(TRICKS); break;
@ -606,6 +909,22 @@ window.Page_uebungen = (() => {
`;
}
function _sessionStatsChip(tab, name) {
const key = _progressKey(tab, name);
const stat = _exerciseStats[key];
if (!stat) return '';
const avg = Math.round(stat.recent_avg);
const color = avg >= 75 ? '#15803d' : avg >= 50 ? '#c2410c' : '#dc2626';
const bg = avg >= 75 ? 'rgba(22,163,74,0.10)' : avg >= 50 ? 'rgba(234,88,12,0.10)' : 'rgba(220,38,38,0.10)';
const border = avg >= 75 ? 'rgba(22,163,74,0.30)' : avg >= 50 ? 'rgba(234,88,12,0.30)' : 'rgba(220,38,38,0.30)';
const arrow = { improving: '↑', declining: '↓', stable: '→', new: '' }[stat.trend] || '';
return `
<span style="font-size:9px;font-weight:600;padding:1px 6px;border-radius:999px;
background:${bg};color:${color};border:1px solid ${border};white-space:nowrap">
${avg}%${arrow} · ${stat.session_count}×
</span>`;
}
function _renderCard(u, i) {
const diff = DIFF_META[u.schwierigkeit] || { label: u.schwierigkeit, color: 'var(--c-text-secondary)' };
const uid = `ueb-acc-${_activeTab}-${i}`;
@ -618,47 +937,49 @@ window.Page_uebungen = (() => {
<div class="card" style="padding:0;overflow:hidden">
<!-- Header -->
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">
<!-- Zeile 1: Name + Schwierigkeits-Badge -->
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);line-height:1.3">
${_esc(u.name)}
</span>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<!-- Log-Button -->
<button class="ueb-log-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="Trainingseinheit loggen"
style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
color:var(--c-primary);cursor:pointer;padding:2px 7px;
display:flex;align-items:center;gap:3px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#plus"></use>
</svg>
Einheit
</button>
<!-- Status-Button -->
<button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="${_esc(sm.label)}"
style="background:none;border:none;cursor:pointer;padding:2px;
display:flex;align-items:center;gap:4px;
font-size:var(--text-xs);color:${sm.color};
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#${sm.icon}"></use>
</svg>
${currentId ? `<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">${_esc(sm.label)}</span>` : ''}
</button>
<!-- Schwierigkeits-Badge -->
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
padding:2px var(--space-2);border-radius:var(--radius-sm);
background:${diff.color}22;color:${diff.color}">
${_esc(diff.label)}
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);white-space:nowrap;
padding:2px var(--space-2);border-radius:var(--radius-sm);flex-shrink:0;
background:${diff.color}22;color:${diff.color}">
${_esc(diff.label)}
</span>
</div>
<!-- Zeile 2: Aktions-Buttons -->
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);flex-wrap:wrap">
<button class="ueb-log-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="Trainingseinheit loggen"
style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
color:var(--c-primary);cursor:pointer;padding:3px 9px;
display:flex;align-items:center;gap:3px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#plus"></use>
</svg>
Einheit
</button>
${_sessionStatsChip(_activeTab, u.name)}
<button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="${_esc(sm.label)}"
style="background:none;border:none;cursor:pointer;padding:2px;
display:flex;align-items:center;gap:4px;
font-size:var(--text-xs);color:${sm.color};
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#${sm.icon}"></use>
</svg>
<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">
${_esc(sm.label)}
</span>
</div>
</button>
</div>
<!-- Meta -->
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:${u.beschreibung ? 'var(--space-3)' : '0'}">
@ -682,10 +1003,11 @@ window.Page_uebungen = (() => {
` : ''}
${u.hinweis ? `
<div style="margin-top:var(--space-2);padding:var(--space-2) var(--space-3);
background:var(--c-warning-bg,#fef3c7);border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text);line-height:1.4">
<svg class="ph-icon" style="width:12px;height:12px;color:#d97706" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
${_esc(u.hinweis)}
background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text);line-height:1.4;
display:flex;align-items:flex-start;gap:var(--space-2)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
<span>${_esc(u.hinweis)}</span>
</div>
` : ''}
</div>
@ -778,7 +1100,7 @@ window.Page_uebungen = (() => {
});
}
function _openLogModal(tab, exerciseName) {
function _openLogModal(tab, exerciseName, initialReps) {
// Build the modal HTML
const modalId = 'ueb-log-modal';
const formId = 'ueb-log-form';
@ -918,7 +1240,7 @@ window.Page_uebungen = (() => {
document.body.appendChild(overlay);
// State
let wiederholungen = 5;
let wiederholungen = initialReps || 5;
let erfolgsquote = null; // must be selected
let stimmung = null;
let zufriedenheit = null;
@ -930,6 +1252,7 @@ window.Page_uebungen = (() => {
// Stepper
const repVal = overlay.querySelector('#ueb-rep-val');
repVal.textContent = wiederholungen;
overlay.querySelector('#ueb-rep-minus').addEventListener('click', () => {
if (wiederholungen > 1) { wiederholungen--; repVal.textContent = wiederholungen; }
});
@ -988,8 +1311,8 @@ window.Page_uebungen = (() => {
// Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId();
if (!dogId) { UI.toast('Kein Hund ausgewählt.', 'warning'); return; }
if (erfolgsquote === null) { UI.toast('Bitte wähle aus, wie es gelaufen ist.', 'warning'); return; }
if (!dogId) { UI.toast.warning('Kein Hund ausgewählt.'); return; }
if (erfolgsquote === null) { UI.toast.warning('Bitte wähle aus, wie es gelaufen ist.'); return; }
const saveBtn = overlay.querySelector('#ueb-log-save');
saveBtn.disabled = true;
@ -1038,9 +1361,10 @@ window.Page_uebungen = (() => {
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
}
// Stats-Banner aktualisieren
// Stats-Banner + Trainer aktualisieren
_statsData = null;
_loadStatsAndBadges();
_loadVirtualTrainer();
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = 'Einheit speichern';
@ -1167,19 +1491,29 @@ window.Page_uebungen = (() => {
return;
}
feedbackText.textContent = resp.feedback;
const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert';
const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert';
const sessionInfo = stats.total_sessions
? ` · Basiert auf ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}`
? ` · ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}`
: '';
feedbackMeta.textContent = `Aktualisiert alle 6 Stunden · ${cachedInfo}${sessionInfo}`;
const limitInfo = (resp.daily_limit && !resp.cached)
? ` · ${resp.daily_used}/${resp.daily_limit} heute` : '';
feedbackMeta.textContent = `${cachedInfo}${sessionInfo}${limitInfo}`;
if (regenBtn) {
regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren';
regenBtn.style.opacity = resp.cached ? '0.6' : '1';
const limitReached = resp.daily_used >= resp.daily_limit && !resp.cached;
regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren';
regenBtn.style.opacity = (resp.cached && !limitReached) ? '0.6' : '1';
regenBtn.disabled = limitReached;
if (limitReached) regenBtn.title = `Tages-Limit erreicht (${resp.daily_limit}/Tag)`;
}
feedbackCard.hidden = false;
} catch (err) {
loading.hidden = true;
noSessions.hidden = false;
loading.hidden = true;
if (err?.status === 429 || (err?.message || '').includes('429')) {
feedbackMeta.textContent = `Tages-Limit erreicht (${10}/Tag) — morgen wieder verfügbar.`;
feedbackCard.hidden = false;
} else {
noSessions.hidden = false;
}
}
// Bind regenerate button
@ -1218,10 +1552,10 @@ window.Page_uebungen = (() => {
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Markerwort -->
<div class="card">
<div class="card" style="padding:var(--space-4)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
color:var(--c-text);margin:0 0 var(--space-3);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#megaphone-simple"></use>
</svg>
Das Markerwort
@ -1245,13 +1579,15 @@ window.Page_uebungen = (() => {
<!-- Wann belohnen -->
<div class="card">
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
color:var(--c-text);margin:0;display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#star"></use>
</svg>
Wann belohnen?
</h3>
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
@ -1281,16 +1617,18 @@ window.Page_uebungen = (() => {
<!-- Leckerli-Hierarchie -->
<div class="card">
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
color:var(--c-text);margin:0 0 var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#trophy"></use>
</svg>
Leckerli-Hierarchie
</h3>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0 0 var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0">
Nicht alle Leckerlis sind gleich. Bei Ablenkung oder schwierigen Übungen brauchst du hochwertigere Belohnungen:
</p>
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
@ -1320,10 +1658,10 @@ window.Page_uebungen = (() => {
</div>
<!-- Trainingsregeln -->
<div class="card" style="margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
color:var(--c-text);margin:0 0 var(--space-3);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
</svg>
Trainingsregeln auf einen Blick