Feature: Trainingsprotokoll-Tab in Übungen, kein Tagebuch-Spam

- Neuer Tab 'Protokoll' in der Übungen-Seite: zeigt alle Trainingseinheiten
  chronologisch nach Datum gruppiert (Heute/Gestern/Datum-Label)
- Jede Einheit: Übungsname, Wdh., Erfolgs-Emoji, Stimmung, Sterne, Notiz, TOP-Badge
- 'Weitere laden' Pagination (30 Einheiten pro Seite)
- Backend: Training erstellt keine Tagebuch-Einträge mehr (weder bei ist_top noch manuell)
- Frontend: 'Als Meilenstein ins Tagebuch' Checkbox komplett entfernt
- onDogChange setzt Verlauf-State zurück
This commit is contained in:
rene 2026-05-19 18:17:50 +02:00
parent d2c2c59abb
commit cc841ef6d7
2 changed files with 167 additions and 82 deletions

View file

@ -326,7 +326,7 @@ class SessionCreate(BaseModel):
hund_stimmung: str = "aufmerksam" hund_stimmung: str = "aufmerksam"
zufriedenheit: int = 3 zufriedenheit: int = 3
notiz: Optional[str] = None notiz: Optional[str] = None
tagebuch_eintrag: bool = False tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
@router.post("/sessions") @router.post("/sessions")
@ -363,42 +363,6 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
# Badges prüfen # Badges prüfen
new_badges = _check_badges(conn, uid, dog_name) new_badges = _check_badges(conn, uid, dog_name)
# Tagebucheintrag erstellen?
diary_entry_id = None
if body.tagebuch_eintrag or ist_top:
stimmung_label = STIMMUNGS_LABELS.get(body.hund_stimmung, body.hund_stimmung)
if ist_top:
titel = f"\U0001f3af {body.exercise_name} \u2014 Top-Training!"
else:
titel = f"\U0001f3af Training: {body.exercise_name}"
text_parts = [
f"{body.wiederholungen} Wiederholungen \u00b7 "
f"Erfolgsquote: {body.erfolgsquote}% \u00b7 "
f"Stimmung: {stimmung_label}"
]
if body.notiz:
text_parts.append(f"\n\n{body.notiz}")
eintrag_text = "".join(text_parts)
diary_cur = conn.execute(
"""
INSERT INTO diary (dog_id, datum, typ, titel, text)
VALUES (?,?,?,?,?)
""",
(body.dog_id, datum, "training", titel, eintrag_text)
)
diary_entry_id = diary_cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(diary_entry_id, body.dog_id)
)
conn.execute(
"UPDATE training_sessions SET diary_entry_id=? WHERE id=?",
(diary_entry_id, session_id)
)
session = { session = {
"id": session_id, "id": session_id,
"user_id": uid, "user_id": uid,
@ -412,14 +376,12 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
"zufriedenheit": body.zufriedenheit, "zufriedenheit": body.zufriedenheit,
"notiz": body.notiz, "notiz": body.notiz,
"ist_top": bool(ist_top), "ist_top": bool(ist_top),
"diary_entry_id": diary_entry_id,
} }
return { return {
"session": session, "session": session,
"ist_top": bool(ist_top), "ist_top": bool(ist_top),
"badges": new_badges, "badges": new_badges,
"diary_entry_id": diary_entry_id,
} }

View file

@ -56,6 +56,7 @@ window.Page_uebungen = (() => {
{ id: 'welpe-basics', label: 'Welpe Basics' }, { id: 'welpe-basics', label: 'Welpe Basics' },
{ id: 'grundlagen', label: 'Trainingsgrundlagen' }, { id: 'grundlagen', label: 'Trainingsgrundlagen' },
{ id: 'ki-trainer', label: 'KI-Trainer' }, { id: 'ki-trainer', label: 'KI-Trainer' },
{ id: 'verlauf', label: 'Protokoll' },
]; ];
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -541,11 +542,13 @@ window.Page_uebungen = (() => {
_renderContent(); _renderContent();
} }
function onDogChange() { function onDogChange() {
_statsData = null; _statsData = null;
_badgesData = null; _badgesData = null;
_progressCache = {}; _progressCache = {};
_progressLoaded = false; _progressLoaded = false;
_exerciseStats = {}; _exerciseStats = {};
_verlaufSessions = [];
_verlaufOffset = 0;
_render(); _render();
_loadStatsAndBadges(); _loadStatsAndBadges();
_loadVirtualTrainer(); _loadVirtualTrainer();
@ -980,6 +983,7 @@ window.Page_uebungen = (() => {
const isExerciseTab = ['grundkommandos','tricks','problemverhalten', const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab); 'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab);
const isVerlauf = _activeTab === 'verlauf';
const showIf = v => v ? '' : 'none'; const showIf = v => v ? '' : 'none';
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement; const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
@ -990,6 +994,7 @@ window.Page_uebungen = (() => {
if (trainerEl) trainerEl.style.display = showIf(isExerciseTab); if (trainerEl) trainerEl.style.display = showIf(isExerciseTab);
if (suggestEl) suggestEl.style.display = showIf(isExerciseTab); if (suggestEl) suggestEl.style.display = showIf(isExerciseTab);
if (bannerEl) bannerEl.style.display = showIf(isExerciseTab); if (bannerEl) bannerEl.style.display = showIf(isExerciseTab);
if (isVerlauf) _loadVerlauf();
switch (_activeTab) { switch (_activeTab) {
case 'grundkommandos': case 'grundkommandos':
@ -1011,6 +1016,7 @@ window.Page_uebungen = (() => {
break; break;
} }
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
case 'verlauf': el.innerHTML = _renderVerlaufShell(); break;
case 'ki-trainer': case 'ki-trainer':
if (!App.hasPro(_appState?.user)) { if (!App.hasPro(_appState?.user)) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)"> el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
@ -1647,18 +1653,6 @@ window.Page_uebungen = (() => {
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea> background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
</div> </div>
<!-- Meilenstein-Checkbox (initially hidden) -->
<label id="ueb-log-milestone-wrap" hidden
style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
padding:var(--space-3);background:var(--c-primary-subtle);
border:1px solid var(--c-primary-light);border-radius:var(--radius-md)">
<input type="checkbox" id="ueb-log-milestone"
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
📖 Als Meilenstein ins Tagebuch eintragen
</span>
</label>
</form> </form>
<!-- Footer Buttons --> <!-- Footer Buttons -->
@ -1714,7 +1708,6 @@ window.Page_uebungen = (() => {
btn.style.background = 'var(--c-primary-subtle)'; btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)'; btn.style.borderColor = 'var(--c-primary)';
btn.style.transform = 'scale(1.15)'; btn.style.transform = 'scale(1.15)';
_checkMilestoneVisibility();
}); });
}); });
@ -1738,17 +1731,9 @@ window.Page_uebungen = (() => {
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => { overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35'; b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
}); });
_checkMilestoneVisibility();
}); });
}); });
function _checkMilestoneVisibility() {
const wrap = overlay.querySelector('#ueb-log-milestone-wrap');
if (!wrap) return;
const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4;
wrap.hidden = !show;
}
// Save // Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => { overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId(); const dogId = _dogId();
@ -1761,20 +1746,17 @@ window.Page_uebungen = (() => {
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`; const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden &&
overlay.querySelector('#ueb-log-milestone').checked;
const body = { const body = {
dog_id: dogId, dog_id: dogId,
exercise_id: exerciseId, exercise_id: exerciseId,
exercise_name: exerciseName, exercise_name: exerciseName,
datum: today, datum: today,
wiederholungen: wiederholungen, wiederholungen: wiederholungen,
erfolgsquote: erfolgsquote, erfolgsquote: erfolgsquote,
hund_stimmung: stimmung || null, hund_stimmung: stimmung || null,
zufriedenheit: zufriedenheit || null, zufriedenheit: zufriedenheit || null,
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null, notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
tagebuch_eintrag: tagebuch,
}; };
try { try {
@ -1806,12 +1788,6 @@ window.Page_uebungen = (() => {
}); });
} }
if (resp.diary_entry_id) {
setTimeout(() => {
UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
}
// Stats-Banner + Trainer aktualisieren // Stats-Banner + Trainer aktualisieren
_statsData = null; _statsData = null;
_loadStatsAndBadges(); _loadStatsAndBadges();
@ -1995,6 +1971,153 @@ window.Page_uebungen = (() => {
}); });
} }
// ----------------------------------------------------------
// TRAININGSPROTOKOLL (Verlauf-Tab)
// ----------------------------------------------------------
let _verlaufSessions = [];
let _verlaufOffset = 0;
let _verlaufHasMore = false;
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 style="font-size:var(--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)">
<div id="verlauf-list" style="display:flex;flex-direction:column;gap:var(--space-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>`;
}
async function _loadVerlauf(append = false) {
const dogId = _dogId();
if (!dogId) return;
const el = _container.querySelector('#verlauf-list');
if (!el) return;
if (!append) {
_verlaufSessions = [];
_verlaufOffset = 0;
}
const data = await _apiGet(
`/api/training/sessions?dog_id=${dogId}&limit=${_VERLAUF_LIMIT + 1}&offset=${_verlaufOffset}`
).catch(() => null);
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 _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;
}
// Nach Datum gruppieren
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' });
}
const rows = sessions.map(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 style="flex:1;min-width:0">
<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>`;
}).join('');
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)">${rows}</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));
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// TRAININGSGRUNDLAGEN // TRAININGSGRUNDLAGEN
// ---------------------------------------------------------- // ----------------------------------------------------------