Feature: Protokoll-Tab Toggle 'Nach Datum / Nach Übung'

- Toggle-Buttons oben im Protokoll-Tab
- 'Nach Übung': gruppiert alle Sessions pro Übung, sortiert nach zuletzt trainiert
- Pro Übung: Ø-Erfolgsquote als Kreis, Trend-Pfeil (↑↓→★), Anzahl Einheiten + TOP-Count
- Aufklappbare Session-Liste pro Übung (Datum · Emoji · % · Wdh.)
- Hinweis wenn mehr Sessions vorhanden als geladen
This commit is contained in:
rene 2026-05-19 18:34:30 +02:00
parent 738571d958
commit dcb966ca54

View file

@ -550,6 +550,7 @@ window.Page_uebungen = (() => {
_verlaufSessions = [];
_verlaufOffset = 0;
_verlaufLoading = false;
_verlaufView = 'datum';
_render();
_loadStatsAndBadges();
_loadVirtualTrainer();
@ -1018,12 +1019,13 @@ window.Page_uebungen = (() => {
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)"><div id="verlauf-list"></div></div>`;
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':
@ -1987,6 +1989,7 @@ window.Page_uebungen = (() => {
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: '🎉' };
@ -2000,6 +2003,7 @@ window.Page_uebungen = (() => {
</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" 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">
@ -2010,6 +2014,23 @@ window.Page_uebungen = (() => {
</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 style="display:flex;gap:var(--space-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();
@ -2043,6 +2064,28 @@ window.Page_uebungen = (() => {
_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 = `
@ -2057,8 +2100,44 @@ window.Page_uebungen = (() => {
</div>`;
return;
}
if (_verlaufView === 'uebung') {
_renderVerlaufByUebung(el);
} else {
_renderVerlaufByDatum(el);
}
}
// Nach Datum gruppieren
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 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>`;
}
function _renderVerlaufByDatum(el) {
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const groups = {};
@ -2069,44 +2148,12 @@ window.Page_uebungen = (() => {
const html = Object.entries(groups).map(([datum, sessions]) => {
let label;
if (datum === today) label = 'Heute';
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);
@ -2114,7 +2161,9 @@ window.Page_uebungen = (() => {
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 style="display:flex;flex-direction:column;gap:var(--space-1)">
${sessions.map(_sessionRow).join('')}
</div>
</div>`;
}).join('');
@ -2129,10 +2178,129 @@ window.Page_uebungen = (() => {
: '';
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 style="flex:1;min-width:0">${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 style="flex:1;min-width:0">
<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
// ----------------------------------------------------------