banyaro/backend/static/js/pages/trainingsplaene.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

875 lines
40 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 — Trainingspläne
Seiten-Modul: Welpe, Junior, Erwachsener Hund
Alle Inhalte hardcoded, Checkboxen via localStorage
============================================================ */
window.Page_trainingsplaene = (() => {
let _container = null;
let _appState = null;
let _activePlan = 'welpe'; // welpe | junior | erwachsen
let _activeAdultTab = 'grundkurs'; // grundkurs | aufbaukurs
// ----------------------------------------------------------
// API HELPERS
// ----------------------------------------------------------
function _dogId() {
return _appState?.activeDog?.id || null;
}
async function _apiGet(url) {
const r = await fetch(url, {credentials: 'include'});
return r.ok ? r.json() : null;
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _icon(name) {
return `<svg class="ph-icon" aria-hidden="true" style="width:1em;height:1em;vertical-align:-0.15em"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
function _lsKey(planId, goalIdx) {
const dogId = _dogId() || 'x';
return `tp_d${dogId}_${planId}_${goalIdx}`;
}
function _saveGoal(key, checked) {
localStorage.setItem(key, checked ? 'true' : 'false');
}
function _loadGoal(key) {
return localStorage.getItem(key) === 'true';
}
// ----------------------------------------------------------
// RENDER HELPERS
// ----------------------------------------------------------
function _renderTable(headers, rows) {
const ths = headers.map(h => `<th style="padding:6px 10px;background:var(--c-surface-2);font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap">${_esc(h)}</th>`).join('');
const trs = rows.map(row => {
const tds = row.map((cell, i) => `<td style="padding:6px 10px;font-size:var(--text-sm);color:var(--c-text);border-top:1px solid var(--c-border);${i === 0 ? 'white-space:nowrap;font-weight:var(--weight-semibold)' : ''}">${_esc(cell)}</td>`).join('');
return `<tr>${tds}</tr>`;
}).join('');
return `
<div style="overflow-x:auto;margin:var(--space-3) 0">
<table style="width:100%;border-collapse:collapse;min-width:400px">
<thead><tr>${ths}</tr></thead>
<tbody>${trs}</tbody>
</table>
</div>`;
}
function _renderGoals(planId, goals) {
const total = goals.length;
let doneCount = 0;
const items = goals.map((goal, idx) => {
const key = _lsKey(planId, idx);
const checked = _loadGoal(key);
if (checked) doneCount++;
return `
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;padding:var(--space-1) 0" class="tp-goal-label">
<input type="checkbox" data-lskey="${_esc(key)}" ${checked ? 'checked' : ''}
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">${_esc(goal)}</span>
</label>`;
}).join('');
const progress = total > 0 ? Math.round((doneCount / total) * 100) : 0;
return `
<div class="tp-goals" data-plan="${_esc(planId)}" data-total="${total}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">
${_icon('check-circle')} Lernziele
</span>
<span class="tp-progress-label text-xs-secondary">
${doneCount} von ${total} erreicht
</span>
</div>
<div style="background:var(--c-surface-2);border-radius:4px;height:6px;overflow:hidden;margin-bottom:var(--space-3)">
<div class="tp-progress-bar" style="width:${progress}%;height:6px;background:var(--c-primary);border-radius:4px;transition:width 0.3s"></div>
</div>
${items}
</div>`;
}
function _renderAccordionPhase(id, title, content) {
return `
<div class="tp-acc" id="tp-acc-${_esc(id)}">
<button class="tp-acc-head" data-acc="${_esc(id)}"
style="width:100%;display:flex;justify-content:space-between;align-items:center;padding:var(--space-3) var(--space-4);background:none;border:none;border-top:1px solid var(--c-border);cursor:pointer;text-align:left">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">${title}</span>
<span class="tp-acc-arrow">${_icon('caret-down')}</span>
</button>
<div class="tp-acc-body" id="tp-acc-body-${_esc(id)}" hidden
style="padding:var(--space-3) var(--space-4) var(--space-4)">
${content}
</div>
</div>`;
}
function _renderHintBox(text) {
return `
<div style="background:var(--c-surface-2);border-left:3px solid var(--c-primary);border-radius:var(--radius-sm);padding:var(--space-3) var(--space-4);margin:var(--space-3) 0;font-size:var(--text-sm);color:var(--c-text);line-height:1.6">
${_icon('info')} ${_esc(text)}
</div>`;
}
// ----------------------------------------------------------
// PLAN SELECTOR
// ----------------------------------------------------------
function _renderPlanSelector() {
const plans = [
{ id: 'welpe', label: 'Welpe', icon: 'dog', sub: '06 Monate' },
{ id: 'junior', label: 'Junior', icon: 'dog', sub: '618 Monate' },
{ id: 'erwachsen',label: 'Erwachsener Hund', icon: 'dog', sub: 'Grund- & Aufbaukurs' },
];
const btns = plans.map(p => `
<button class="by-tab${_activePlan === p.id ? ' active' : ''}" data-plan="${p.id}"
style="flex:1;min-width:90px;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2px;white-space:normal;text-align:center;line-height:1.2">
<svg class="ph-icon" aria-hidden="true" style="width:22px;height:22px"><use href="/icons/phosphor.svg#${p.icon}"></use></svg>
<span style="font-size:var(--text-sm);font-weight:600">${p.label}</span>
<span style="font-size:var(--text-xs);opacity:0.8">${_esc(p.sub)}</span>
</button>`).join('');
return `<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap">${btns}</div>`;
}
// ----------------------------------------------------------
// WELPENPLAN
// ----------------------------------------------------------
function _renderWelpe() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Welpe ist eingezogen, Grundvertrauen wird aufgebaut.<br>
<strong>Ziel am Ende:</strong> Sitz, Platz, Hier, Warte, Leine ohne Ziehen, Alleine bleiben bis 1 Stunde, keine Begrüßungssprünge.
</p>
${_renderHintBox('Welpen sind schnell überfordert. Lieber 3×5 Minuten täglich als einmal 20 Minuten. Sozialisation (Menschen, Geräusche, Orte) hat in dieser Phase genauso Priorität wie Training.')}`;
const w12 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Name, erste Bindung, Stubenreinheit, keine Übungen erzwingen.</p>
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'],
[['MoSo', 'Name lernen', 'Markerwort einführen', 'Freies Erkunden + Beobachten']]
)}
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6;padding-left:var(--space-4);margin:var(--space-2) 0">
<li><strong>Name lernen:</strong> Namen sagen → Hund schaut → sofort Markerwort + Leckerli. 1015× täglich.</li>
<li><strong>Markerwort einführen:</strong> Markerwort sagen → sofort Leckerli (10× wiederholen). Ab jetzt: Markerwort immer vor dem Leckerli.</li>
<li><strong>Stubenreinheit:</strong> Alle 12 Stunden raus, nach Schlafen/Fressen/Spielen. Kein Schimpfen bei Unfällen.</li>
</ul>`;
const w34 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Sitz, Warte (Futter), Nicht springen.</p>
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'],
[
['Mo', 'Sitz einführen', 'Sitz wiederholen', 'Warte vor Futter'],
['Di', 'Sitz mit Handsignal', 'Nicht springen üben', 'Sitz aus verschiedenen Positionen'],
['Mi', 'Warte vor Futter', 'Sitz 5×', 'Freispiel + Name'],
['Do', 'Sitz festigen', 'Warte 5×', 'Nicht springen'],
['Fr', 'Sitz + Warte kombiniert', 'Freispiel', 'Leckerli-Suche im Gras'],
['Sa', 'Wiederholung Woche', 'Spaziergang mit Leine (ohne Ziel)', 'Entspannen auf Decke'],
['So', 'Ruhiger Tag', 'Kurze Sitz-Einheit', 'Sozialisation (Geräusche, Menschen)'],
]
)}
${_renderGoals('welpe_w34', [
'Sitz auf Handsignal (ohne Wort)',
'Sitz auf Wort "Sitz"',
'Wartet vor der Futterschüssel bis "Okay"',
'Springt nicht mehr bei Begrüßung (in Übung)',
])}`;
const w56 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Platz, Leinengewöhnung, Hier auf kurze Distanz.</p>
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'],
[
['Mo', 'Platz einführen', 'Sitz wiederholen', 'Leine anlegen + Leckerli'],
['Di', 'Platz üben', 'Hier (2 Meter, innen)', 'Leine: erste Schritte'],
['Mi', 'Platz festigen', 'Sitz + Platz abwechselnd', 'Hier aus anderem Zimmer'],
['Do', 'Leine im Garten', 'Hier mit Freude', 'Platz 5×'],
['Fr', 'Sitz + Platz + Hier kombiniert', 'Spaziergang kurz', 'Nasenarbeit (Leckerli unter Becher)'],
['Sa', 'Wiederholungstag', 'Sozialisation (Markt, Park)', 'Freispiel'],
['So', 'Ruhiger Tag', 'Kurze Einheit nach Wahl', 'Entspannen'],
]
)}
${_renderGoals('welpe_w56', [
'Platz auf Handsignal',
'Platz auf Wort "Platz"',
'Kommt auf "Hier" aus 3 Metern',
'Läuft 5 Schritte an lockerer Leine',
])}`;
const w78 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Bleib (Dauer), Alleine bleiben aufbauen, Fuß vorbereiten.</p>
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'],
[
['Mo', 'Bleib einführen (5 Sek)', 'Alleine bleiben (30 Sek)', 'Sitz + Platz Wiederholung'],
['Di', 'Bleib (10 Sek)', 'Alleine bleiben (1 Min)', 'Fuß: erste Schritte'],
['Mi', 'Bleib mit 1 Schritt Distanz', 'Hier aus Garten', 'Nasenarbeit'],
['Do', 'Bleib (20 Sek)', 'Alleine bleiben (2 Min)', 'Leine üben'],
['Fr', 'Sitz + Bleib + Hier kombiniert', 'Fuß 10 Schritte', 'Freispiel'],
['Sa', 'Ausflug (neue Umgebung)', 'Kurze Übungen unterwegs', 'Sozialisation'],
['So', 'Ruhiger Tag', 'Wiederholung nach Wahl', 'Entspannen'],
]
)}
${_renderGoals('welpe_w78', [
'Bleibt 30 Sekunden im Sitz',
'Bleibt alleine bis 5 Minuten ohne Stress',
'Kommt zuverlässig auf "Hier" (innen + Garten)',
'Fuß: 10 Schritte an lockerer Leine',
])}`;
const w912 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Alle Kommandos in Alltagssituationen, erste leichte Ablenkungen.</p>
${_renderTable(
['Woche', 'Schwerpunkt'],
[
['9', 'Alle Kommandos im Garten mit leichter Ablenkung'],
['10', 'Sitz + Bleib auf der Straße, Fuß auf kurzen Spaziergängen'],
['11', 'Hier mit Schleppleine im Park, Alleine bleiben bis 30 Min'],
['12', 'Wiederholung + erste Tricks (Pfote, Dreh)'],
]
)}
${_renderGoals('welpe_w912', [
'Alle Grundkommandos auf Wort und Handsignal',
'Bleibt alleine bis 1 Stunde ohne Stress',
'Kommt zuverlässig auf "Hier" im Garten',
'Läuft entspannt an der Leine in ruhiger Umgebung',
'Erster Trick gelernt (Pfote oder Dreh)',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('welpe-w12', 'Woche 12: Ankommen &amp; Vertrauen', w12)}
${_renderAccordionPhase('welpe-w34', 'Woche 34: Erste Kommandos', w34)}
${_renderAccordionPhase('welpe-w56', 'Woche 56: Platz &amp; erste Leine', w56)}
${_renderAccordionPhase('welpe-w78', 'Woche 78: Bleib &amp; Alleine bleiben', w78)}
${_renderAccordionPhase('welpe-w912', 'Woche 912: Festigung &amp; Alltagsintegration', w912)}
</div>`;
}
// ----------------------------------------------------------
// JUNIORPLAN
// ----------------------------------------------------------
function _renderJunior() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Grundkommandos bekannt, aber pubertätsbedingt unzuverlässig.<br>
<strong>Ziel am Ende:</strong> Alle Grundkommandos auch bei Ablenkung, zuverlässiger Rückruf, Leinenführigkeit in der Stadt.
</p>
${_renderHintBox('Die Pubertät (ca. 612 Monate) ist normal. Der Hund "vergisst" scheinbar alles — er testet Grenzen und Reize sind überwältigend. Konsequenz und Geduld, kein Rückschritt im Denken.')}`;
const m1 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Alle bekannten Kommandos mit höherer Ablenkung neu festigen.</p>
${_renderTable(
['Tag', 'Einheit 1 (5 Min)', 'Einheit 2 (510 Min)'],
[
['Mo', 'Sitz + Platz mit Ablenkung (Ball auf dem Boden)', 'Leine üben in neuer Umgebung'],
['Di', 'Hier mit Schleppleine im Park', 'Bleib mit Distanz (35 Meter)'],
['Mi', 'Fuß auf belebtem Gehweg', 'Nasenarbeit / Trick'],
['Do', 'Aus + Warte kombiniert', 'Alleine bleiben (bis 2 Stunden)'],
['Fr', 'Alle Kommandos — kurze Runde', 'Freispiel'],
['Sa', 'Ausflug + Training in neuer Umgebung', 'Sozialisation'],
['So', 'Ruhiger Tag — leichte Einheit', 'Entspannen'],
]
)}
${_renderGoals('junior_m1', [
'Sitz + Platz trotz Ball/anderen Hunden in der Nähe',
'Bleibt 1 Minute mit 5 Metern Distanz',
'Kommt auf "Hier" im Park (mit Schleppleine)',
'Fuß auf 50 Meter in ruhiger Straße',
])}`;
const m2 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Zuverlässiger Rückruf — das wichtigste Kommando in dieser Phase.</p>
<ol style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-5);margin:var(--space-2) 0">
<li><strong>Rückruf mit Schleppleine (10 Meter):</strong> Hund beschäftigt sich → "Hier!" → leichtes Anziehen wenn nötig → große Belohnung beim Ankommen</li>
<li><strong>Versteckspiel:</strong> Im Wald/Park verstecken → "Hier!" rufen → Hund sucht und findet → größte Belohnung</li>
<li><strong>Rückruf aus der Gruppe:</strong> Hund spielt mit anderen Hunden → "Hier!" → kommt er: Jackpot (5 Leckerlis + Streicheln)</li>
<li><strong>Notfallrückruf einführen:</strong> Anderes Wort (z.B. "Rapid!") nur für echte Notfälle — immer mit höchster Belohnung</li>
</ol>
${_renderGoals('junior_m2', [
'Kommt auf "Hier" aus 20 Metern mit Schleppleine',
'Kommt aus Spielsituation mit anderen Hunden zurück',
'Notfallrückruf eingeführt',
])}`;
const m3 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Ruhiges Gehen auch bei Fahrrädern, anderen Hunden, Kinderwagen.</p>
<ol style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-5);margin:var(--space-2) 0">
<li>Ruhige Straße → belohnen alle 10 Schritte bei locker hängender Leine</li>
<li>Fahrrad kommt entgegen → Sitz → warten → Fahrrad vorbei → weitergehen + Leckerli</li>
<li>Anderer Hund in Sichtweite → Fokus auf dich (Augenkontakt trainieren) → Leckerli</li>
<li>Belebte Fußgängerzone: erst beobachten lassen, dann durch</li>
</ol>
<p style="font-size:var(--text-sm);color:var(--c-text);margin:var(--space-2) 0"><strong>Augenkontakt:</strong> "Schau" sagen → Hund schaut in deine Augen → sofort Markerwort + Leckerli. In Ablenkungssituationen: "Schau" → fokussiert → dann erst weitergehen.</p>
${_renderGoals('junior_m3', [
'Läuft 500 Meter an lockerer Leine in der Stadt',
'Bleibt bei Ablenkung (Fahrrad, Jogger) fokussiert',
'Augenkontakt auf Kommando "Schau"',
])}`;
const m46 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Festigung aller Kommandos, erste Aufbauübungen, Tricks für mentale Auslastung.</p>
${_renderTable(
['Monat', 'Schwerpunkt'],
[
['4', 'Bleib auf Distanz (10 Meter), Freifolge (ohne Leine in sicherer Umgebung)'],
['5', 'Platz auf Distanz, Hier vom Freilauf, Trick-Erweiterung (Decke, Suchspiel)'],
['6', 'Alle Kommandos zuverlässig, erstes Hundesport-Schnuppern optional'],
]
)}
${_renderGoals('junior_m46', [
'Alle Grundkommandos bei mittlerer Ablenkung',
'Freifolge auf kurze Distanz (eingezäuntes Gelände)',
'23 Tricks beherrscht',
'Rückruf vom Freilauf (Schleppleine)',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('junior-m1', 'Monat 1: Grundkommandos neu aufbauen', m1)}
${_renderAccordionPhase('junior-m2', 'Monat 2: Rückruf vertiefen', m2)}
${_renderAccordionPhase('junior-m3', 'Monat 3: Leinenführigkeit in der Stadt', m3)}
${_renderAccordionPhase('junior-m46', 'Monat 46: Aufbau &amp; Tricks', m46)}
</div>`;
}
// ----------------------------------------------------------
// ERWACHSENER HUND
// ----------------------------------------------------------
function _renderErwachsenTabs() {
return `
<div class="by-tabs mb-4">
<button class="by-tab${_activeAdultTab === 'grundkurs' ? ' active' : ''}" data-tab="grundkurs">Grundkurs</button>
<button class="by-tab${_activeAdultTab === 'aufbaukurs' ? ' active' : ''}" data-tab="aufbaukurs">Aufbaukurs</button>
<button class="by-tab${_activeAdultTab === 'uebersicht' ? ' active' : ''}" data-tab="uebersicht">Übersicht</button>
</div>`;
}
function _renderGrundkurs() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Keine oder wenige Vorkenntnisse.<br>
<strong>Ziel:</strong> Alle Grundkommandos in 8 Wochen.
</p>
${_renderHintBox('Erwachsene Hunde lernen genauso gut wie Welpen — oft sogar konzentrierter. Alte Gewohnheiten brauchen länger zum Überschreiben, aber mit konsequentem Training klappt es.')}`;
const gw12 = `
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2'],
[
['Mo', 'Markerwort einführen', 'Sitz einführen'],
['Di', 'Sitz festigen', 'Warte vor Futter'],
['Mi', 'Sitz aus Bewegung', 'Markerwort in Alltagssituationen'],
['Do', 'Sitz + Warte kombiniert', 'Leine: erste Einschätzung'],
['Fr', 'Wiederholung', 'Freispiel'],
['Sa/So', 'Alltagsintegration', 'Kurze Einheit'],
]
)}`;
const gw34 = `
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2'],
[
['Mo', 'Platz einführen', 'Hier (Wohnung, 3 Meter)'],
['Di', 'Platz festigen', 'Leinenführigkeit einschätzen + üben'],
['Mi', 'Sitz + Platz abwechselnd', 'Hier aus anderem Zimmer'],
['Do', 'Hier im Garten', 'Fuß vorbereiten'],
['Fr', 'Leine 10 Minuten üben', 'Platz + Hier kombiniert'],
['Sa/So', 'Spaziergang mit Training', 'Nasenarbeit'],
]
)}`;
const gw56 = `
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2'],
[
['Mo', 'Bleib einführen (10 Sek)', 'Fuß einführen'],
['Di', 'Bleib (30 Sek, 1 Schritt)', 'Aus einführen'],
['Mi', 'Fuß 20 Schritte', 'Bleib mit Distanz'],
['Do', 'Aus mit Spielzeug', 'Fuß in der Straße'],
['Fr', 'Alle Kommandos kombiniert', 'Trick nach Wahl'],
['Sa/So', 'Ausflug mit Training', 'Sozialisation'],
]
)}`;
const gw78 = `
<p style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">Alle Kommandos in Alltagssituationen:</p>
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-4);margin:0 0 var(--space-3)">
<li>Sitz vor dem Überqueren der Straße</li>
<li>Platz wenn Besuch kommt</li>
<li>Warte vor der Haustür</li>
<li>Hier beim Freilauf im Garten</li>
<li>Fuß auf dem Bürgersteig</li>
<li>Aus bei gefundenem Gegenstand</li>
</ul>
${_renderGoals('erwachsen_gk', [
'Sitz, Platz, Bleib (30 Sek), Hier, Fuß, Aus, Warte',
'Alle Kommandos im Alltag einsetzbar',
'Leine ohne starkes Ziehen',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('gk-w12', 'Woche 12: Markerwort + Sitz + Warte', gw12)}
${_renderAccordionPhase('gk-w34', 'Woche 34: Platz + Hier + Leine', gw34)}
${_renderAccordionPhase('gk-w56', 'Woche 56: Bleib + Fuß + Aus', gw56)}
${_renderAccordionPhase('gk-w78', 'Woche 78: Festigung + Alltagsintegration', gw78)}
</div>`;
}
function _renderAufbaukurs() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Grundkurs abgeschlossen oder Kommandos bekannt.<br>
<strong>Dauer:</strong> 8 Wochen &nbsp;|&nbsp; <strong>Ziel:</strong> Kommandos bei Ablenkung, Distanzkommandos, Tricks, mentale Auslastung.
</p>`;
const aw12 = `
<p style="font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-2)">Alle bekannten Kommandos werden mit steigender Ablenkung trainiert. Immer bei der niedrigsten Stufe anfangen die noch klappt.</p>
${_renderTable(
['Ablenkungsstufe', 'Beispiele'],
[
['Stufe 1', 'Leckerli auf dem Boden, Ball in Sichtweite'],
['Stufe 2', 'Anderer Mensch im Raum, Geräusche'],
['Stufe 3', 'Anderer Hund in Sichtweite'],
['Stufe 4', 'Belebter Park, Straße'],
['Stufe 5', 'Freilauf, Spielsituation unterbrechen'],
]
)}`;
const aw34 = `
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-4);margin:0 0 var(--space-3)">
<li>Sitz aus 5 Metern → 10 Metern</li>
<li>Platz aus 5 Metern → 10 Metern</li>
<li>Bleib mit 10 Metern Distanz</li>
<li>Hier vom Freilauf (mit Schleppleine absichern)</li>
</ul>
<p style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6"><strong>Übung "Fernbedienung":</strong> Hund steht 5 Meter entfernt → "Sitz" → kurz warten → "Platz" → kurz warten → "Sitz" → "Hier". Wirkt beeindruckend, festigt Kommandos enorm.</p>`;
const aw56 = `
${_renderTable(
['Woche', 'Trick', 'Nasenarbeit'],
[
['5', 'Pfote + Dreh', 'Leckerli unter 3 Bechern suchen'],
['6', 'Decke/Matte', 'Leckerli im Gras suchen, Gegenstand apportieren'],
]
)}`;
const aw78 = `
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-4);margin:0 0 var(--space-3)">
<li>Schwächstes Kommando intensiv üben</li>
<li>Neue Umgebungen aufsuchen (anderer Wald, andere Stadt)</li>
<li>Hundesport ausprobieren: Agility, Mantrailing, Obedience, Trickdogging</li>
</ul>
${_renderGoals('erwachsen_ak', [
'Alle Kommandos bei Stufe 34 Ablenkung',
'Sitz + Platz auf 10 Meter Distanz',
'Hier vom Freilauf (Schleppleine)',
'34 Tricks',
'Nasenarbeit als regelmäßige Beschäftigung',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('ak-w12', 'Woche 12: Ablenkungstraining', aw12)}
${_renderAccordionPhase('ak-w34', 'Woche 34: Distanzkommandos', aw34)}
${_renderAccordionPhase('ak-w56', 'Woche 56: Tricks &amp; Nasenarbeit', aw56)}
${_renderAccordionPhase('ak-w78', 'Woche 78: Freie Gestaltung', aw78)}
</div>`;
}
function _renderUebersicht() {
return `
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);margin-bottom:var(--space-3);color:var(--c-text)">
${_icon('clipboard-text')} Welcher Plan für welchen Hund?
</h3>
${_renderTable(
['Situation', 'Empfohlener Plan'],
[
['Neuer Welpe (816 Wochen)', 'Welpenplan Woche 14'],
['Welpe 46 Monate', 'Welpenplan Woche 512'],
['Junghund 612 Monate (Pubertät)', 'Juniorplan Monat 13'],
['Junghund 1218 Monate', 'Juniorplan Monat 46'],
['Erwachsener Hund ohne Training', 'Grundkurs Erwachsener'],
['Erwachsener Hund mit Grundwissen', 'Aufbaukurs Erwachsener'],
['Neu eingezogener Hund (unbekannte Vorgeschichte)', 'Grundkurs Erwachsener, Tempo anpassen'],
]
)}`;
}
function _renderErwachsen() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-3)">
Wähle deinen Kurs oder sieh dir die Schnellübersicht an.
</p>`;
let content = '';
if (_activeAdultTab === 'grundkurs') {
content = _renderGrundkurs();
} else if (_activeAdultTab === 'aufbaukurs') {
content = _renderAufbaukurs();
} else {
content = `<div class="card">${_renderUebersicht()}</div>`;
}
return `${intro}${_renderErwachsenTabs()}${content}`;
}
// ----------------------------------------------------------
// BIND EVENTS
// ----------------------------------------------------------
function _bindEvents() {
UI.bindDogChip(_container, _appState);
// Notiz-Button
const dogId = _dogId();
_container.querySelector('#tp-note-btn')?.addEventListener('click', e => {
e.stopPropagation();
const planLabel = _activePlan === 'welpe' ? 'Welpe 06 Monate'
: _activePlan === 'junior' ? 'Junior 618 Monate'
: `Erwachsener Hund ${_activeAdultTab}`;
_openNoteModal('trainingsplan', dogId, planLabel, null);
});
// Plan selector
_container.querySelectorAll('[data-plan]').forEach(btn => {
btn.addEventListener('click', () => {
_activePlan = btn.dataset.plan;
_render();
});
});
// Adult sub-tabs
_container.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
_activeAdultTab = btn.dataset.tab;
_render();
});
});
// Accordion
_container.querySelectorAll('.tp-acc-head').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.acc;
const body = document.getElementById(`tp-acc-body-${id}`);
const arrow = btn.querySelector('.tp-acc-arrow');
if (!body) return;
const isOpen = !body.hidden;
body.hidden = isOpen;
arrow.innerHTML = isOpen ? _icon('caret-down') : _icon('caret-up');
});
});
// Checkboxes
_container.querySelectorAll('input[data-lskey]').forEach(cb => {
cb.addEventListener('change', () => {
const key = cb.dataset.lskey;
_saveGoal(key, cb.checked);
_updateProgress(cb);
});
});
}
function _updateProgress(cb) {
const goalsEl = cb.closest('.tp-goals');
if (!goalsEl) return;
const total = parseInt(goalsEl.dataset.total, 10) || 0;
const done = goalsEl.querySelectorAll('input[type=checkbox]:checked').length;
const label = goalsEl.querySelector('.tp-progress-label');
const bar = goalsEl.querySelector('.tp-progress-bar');
if (label) label.textContent = `${done} von ${total} erreicht`;
if (bar) bar.style.width = total > 0 ? `${Math.round((done / total) * 100)}%` : '0%';
}
// ----------------------------------------------------------
// MAIN RENDER
// ----------------------------------------------------------
function _render() {
let planContent = '';
if (_activePlan === 'welpe') planContent = _renderWelpe();
else if (_activePlan === 'junior') planContent = _renderJunior();
else planContent = _renderErwachsen();
const dogId = _dogId();
const planLabel = _activePlan === 'welpe' ? 'Welpe 06 Monate'
: _activePlan === 'junior' ? 'Junior 618 Monate'
: `Erwachsener Hund ${_activeAdultTab}`;
_container.innerHTML = `
<div style="padding:var(--space-4) var(--space-4) var(--space-8)">
${UI.dogChip(_appState)}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0">
${_icon('clipboard-text')} Trainingspläne
</h2>
${dogId ? `<button class="btn btn-ghost btn-xs" id="tp-note-btn" title="Notiz zum Plan">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>` : ''}
</div>
${_renderPlanSelector()}
${planContent}
<!-- Trainingskalender -->
<div id="tp-calendar-section" style="margin-top:var(--space-6)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
${_icon('calendar')} Trainingskalender
</h3>
<div id="tp-calendar-wrap" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);text-align:center;padding:var(--space-4)">
Lade Kalender…
</div>
</div>
</div>
</div>`;
_bindEvents();
_loadCalendar();
}
// ----------------------------------------------------------
// TRAININGSKALENDER
// ----------------------------------------------------------
async function _loadCalendar() {
const dogId = _dogId();
const wrap = _container.querySelector('#tp-calendar-wrap');
if (!wrap) return;
if (!dogId) {
wrap.innerHTML = `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);text-align:center;padding:var(--space-3)">Kein Hund ausgewählt.</p>`;
return;
}
const now = new Date();
const thisYear = now.getFullYear();
const thisMon = now.getMonth() + 1; // 1-based
// Letzter Monat
const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevYear = prevDate.getFullYear();
const prevMon = prevDate.getMonth() + 1;
const [dataCurr, dataPrev] = await Promise.all([
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${thisYear}&month=${thisMon}`),
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${prevYear}&month=${prevMon}`),
]);
wrap.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-4)">
${_renderCalendarMonth(prevYear, prevMon, dataPrev?.days || {})}
${_renderCalendarMonth(thisYear, thisMon, dataCurr?.days || {})}
</div>
<!-- Legende -->
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-top:var(--space-2);
font-size:var(--text-xs);color:var(--c-text-secondary);align-items:center">
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;border:1.5px solid var(--c-border);display:inline-block"></span>
kein Training
</span>
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary-subtle);border:1.5px solid var(--c-primary);display:inline-block"></span>
Training
</span>
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary);display:inline-block"></span>
Top-Training
</span>
</div>
`;
}
function _renderCalendarMonth(year, month, days) {
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
const WDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
const firstDay = new Date(year, month - 1, 1);
// Monday = 0 offset: getDay() returns 0=Sun, so we shift
let startOffset = firstDay.getDay(); // 0=Sun
startOffset = startOffset === 0 ? 6 : startOffset - 1; // Mon=0 … Sun=6
const daysInMonth = new Date(year, month, 0).getDate();
// Header Wochentage
const wdayHeader = WDAYS.map(d =>
`<div style="text-align:center;font-size:10px;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);padding-bottom:4px">${d}</div>`
).join('');
// Leere Slots am Anfang
let cells = '';
for (let i = 0; i < startOffset; i++) {
cells += `<div></div>`;
}
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const dayData = days[dateStr];
const isToday = dateStr === todayStr;
let bg = 'transparent';
let border = '1.5px solid var(--c-border)';
let color = 'var(--c-text-secondary)';
let title = '';
if (dayData) {
if (dayData.top) {
bg = 'var(--c-primary)';
border = '1.5px solid var(--c-primary)';
color = '#fff';
title = `Top-Training! ${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
} else {
bg = 'var(--c-primary-subtle)';
border = '1.5px solid var(--c-primary)';
color = 'var(--c-primary)';
title = `${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
}
}
const todayRing = isToday ? 'box-shadow:0 0 0 2px var(--c-primary);' : '';
cells += `
<div title="${title}"
style="aspect-ratio:1;border-radius:4px;background:${bg};border:${border};
display:flex;align-items:center;justify-content:center;
font-size:10px;font-weight:${dayData ? 'var(--weight-semibold)' : '400'};
color:${color};${todayRing}cursor:default">
${d}
</div>
`;
}
return `
<div style="flex:1;min-width:220px">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
${_esc(MONTHS[month - 1])} ${year}
</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:3px">
${wdayHeader}
${cells}
</div>
</div>
`;
}
// ----------------------------------------------------------
// NOTIZ-MODAL
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
// Vorhandenes Modal entfernen falls noch offen
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
}
function refresh() {}
function onDogChange() {
_render();
}
return { init, refresh, onDogChange };
})();