Sprint 12: UI-Vereinheitlichung + Läufigkeits-Tracker

- by-tabs/by-tab: einheitliche Tab/Pill-Navigation in allen Seiten
- by-section-label, by-toolbar: einheitliche Section-Labels und Toolbars
- Design-Tokens: fehlende --c-amber, --c-primary-soft ergänzt, Fallback-Werte entfernt
- sitting.js: sitting-layout für konsistentes flush-Layout (wie walks)
- Läufigkeits-Tracker: neuer Health-Tab für Hündinnen mit Zyklusvorhersage,
  Timeline vergangener Läufigkeiten, Erinnerungen und auto-berechnetem Nächst-Datum
- emptyState-Bug: icon-Parameter muss SVG sein, nicht Icon-Name (dog/bell/warning gefixt)
- SW-Cache: by-v103, APP_VER: 79
This commit is contained in:
rene 2026-04-16 22:31:33 +02:00
parent 32d630d5a1
commit b58789373c
30 changed files with 4344 additions and 523 deletions

View file

@ -8,11 +8,11 @@ window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {}; // { impfung:[], tierarzt:[], gewicht:[], medikament:[], allergie:[], dokument:[] }
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
const TABS = [
const BASE_TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
@ -22,6 +22,16 @@ window.Page_health = (() => {
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
function _getTabs() {
const tabs = [...BASE_TABS];
if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB);
return tabs;
}
// Backwards-compat alias
const TABS = BASE_TABS;
// ----------------------------------------------------------
// LIFECYCLE
@ -118,14 +128,14 @@ window.Page_health = (() => {
// ----------------------------------------------------------
async function _renderHealth() {
_container.innerHTML = `
<div class="health-header">
<div class="by-toolbar health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
</div>
<div id="health-reminders"></div>
<div class="health-tabs" id="health-tabs"></div>
<div id="health-tab-content"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
`;
_renderTabBar();
@ -141,7 +151,7 @@ window.Page_health = (() => {
// ERINNERUNGEN — Banner über den Tabs
// ----------------------------------------------------------
function _getErinnerungen() {
const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament'];
const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament', 'laeufigkeit'];
const now = Date.now();
const items = [];
REMINDER_TABS.forEach(typ => {
@ -167,9 +177,10 @@ window.Page_health = (() => {
if (!items.length) { el.innerHTML = ''; return; }
const ICONS = {
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
laeufigkeit: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>',
};
el.innerHTML = `
@ -258,17 +269,17 @@ window.Page_health = (() => {
}
function _renderTabBar() {
const tabsEl = _container.querySelector('#health-tabs');
tabsEl.innerHTML = TABS.map(t => `
<button class="health-tab${t.key === _activeTab ? ' active' : ''}"
const tabsEl = _container.querySelector('#by-tabs');
tabsEl.innerHTML = _getTabs().map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>
`).join('');
tabsEl.querySelectorAll('.health-tab').forEach(btn => {
tabsEl.querySelectorAll('.by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
tabsEl.querySelectorAll('.health-tab').forEach(b => b.classList.remove('active'));
tabsEl.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderTab();
});
@ -283,9 +294,10 @@ window.Page_health = (() => {
try {
const all = await API.health.list(dogId);
_data = {};
TABS.forEach(t => { _data[t.key] = []; });
_getTabs().forEach(t => { _data[t.key] = []; });
_data['laeufigkeit'] = _data['laeufigkeit'] || [];
all.forEach(e => {
if (_data[e.typ]) _data[e.typ].push(e);
if (_data[e.typ] !== undefined) _data[e.typ].push(e);
});
} catch (err) {
UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.');
@ -301,7 +313,7 @@ window.Page_health = (() => {
// TAB-INHALT RENDERN
// ----------------------------------------------------------
function _renderTab() {
const content = _container.querySelector('#health-tab-content');
const content = _container.querySelector('#by-tab-content');
if (!content) return;
const entries = _data[_activeTab] || [];
@ -310,6 +322,7 @@ window.Page_health = (() => {
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
@ -529,6 +542,98 @@ window.Page_health = (() => {
`;
}
// ----------------------------------------------------------
// LÄUFIGKEIT — Timeline + Vorhersage
// ----------------------------------------------------------
function _renderLaeufigkeit(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">${UI.icon('plus')} Läufigkeit eintragen</button>`;
const sorted = [...entries].sort((a, b) => b.datum.localeCompare(a.datum)); // neueste zuerst
// Durchschnittlicher Abstand berechnen
let avgInterval = null;
if (sorted.length >= 2) {
const asc = [...sorted].reverse();
const diffs = [];
for (let i = 1; i < asc.length; i++) {
diffs.push(Math.round((new Date(asc[i].datum) - new Date(asc[i-1].datum)) / 86400000));
}
avgInterval = Math.round(diffs.reduce((a, b) => a + b, 0) / diffs.length);
}
// Nächste vorhergesagte Läufigkeit
const last = sorted[0];
let nextPrediction = null;
if (last?.naechstes) {
nextPrediction = last.naechstes;
} else if (last?.datum && (last?.intervall_tage || avgInterval)) {
const iv = last.intervall_tage || avgInterval;
const d = new Date(last.datum);
d.setDate(d.getDate() + iv);
nextPrediction = d.toISOString().slice(0, 10);
}
// Banner für nächste Läufigkeit
let banner = '';
if (nextPrediction) {
const ampel = _impfAmpel(nextPrediction);
const tage = Math.ceil((new Date(nextPrediction) - Date.now()) / 86400000);
const label = tage < 0 ? `Überfällig seit ${Math.abs(tage)} Tagen`
: tage === 0 ? 'Könnte heute beginnen'
: tage <= 14 ? `In ${tage} Tagen`
: UI.time.format(nextPrediction + 'T00:00:00');
banner = `
<div style="margin:var(--space-4) var(--space-4) 0;padding:var(--space-3) var(--space-4);
background:var(--c-surface);border-radius:var(--radius-lg);
border-left:4px solid ${ampel.color === 'red' ? 'var(--c-danger)' : ampel.color === 'yellow' ? 'var(--c-warning)' : 'var(--c-primary)'};
display:flex;align-items:center;gap:var(--space-3)">
<span style="font-size:1.5rem">${UI.icon('gender-female')}</span>
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Nächste Läufigkeit erwartet</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${label}
${avgInterval ? ` · Ø ${avgInterval} Tage Abstand` : ''}
</div>
</div>
</div>`;
}
if (!sorted.length) return `
${UI.emptyState({
icon: UI.icon('gender-female'),
title: 'Noch keine Läufigkeit eingetragen',
text: 'Trage Läufigkeiten ein, um den Zyklus zu verfolgen.',
action: addBtn,
})}`;
const items = sorted.map((e, i) => {
const prev = sorted[i + 1];
const interval = prev
? Math.round((new Date(e.datum) - new Date(prev.datum)) / 86400000)
: null;
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
display:flex;align-items:center;justify-content:center;
flex-shrink:0;color:var(--c-text-inverse)">
${UI.icon('gender-female')}
</div>
<div class="health-card-body">
<div class="health-card-title">Läufigkeit · ${UI.time.format(e.datum + 'T00:00:00')}</div>
<div class="health-card-meta">
${e.wert ? `Dauer: ${e.wert} Tage` : 'Dauer nicht angegeben'}
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>`;
}).join('');
return `
${banner}
<div class="health-list" style="margin-top:var(--space-4)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// MEDIKAMENTE
// ----------------------------------------------------------
@ -542,7 +647,7 @@ window.Page_health = (() => {
const inaktive = entries.filter(e => !e.aktiv);
const renderGroup = (items, label) => items.length ? `
<div class="health-group-label">${label}</div>
<div class="by-section-label">${label}</div>
${items.map(e => `
<div class="health-card${e.aktiv ? '' : ' health-card--inactive'}" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
@ -686,7 +791,9 @@ window.Page_health = (() => {
const modalTitle = entry.typ === 'gewicht'
? `${tabInfo.icon} ${entry.wert} ${entry.einheit || 'kg'}`
: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`;
: entry.typ === 'laeufigkeit'
? `${tabInfo.icon} Läufigkeit ${UI.time.format(entry.datum + 'T00:00:00')}`
: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`;
UI.modal.open({ title: modalTitle, body });
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
@ -717,7 +824,8 @@ window.Page_health = (() => {
function _detailFields(e) {
const rows = [];
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
if (e.naechstes) rows.push(['Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.wert) rows.push(['Dauer', `${e.wert} Tage`]);
if (e.naechstes) rows.push([e.typ === 'laeufigkeit' ? 'Nächste erwartet' : 'Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
if (e.tierarzt_id) {
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
if (praxis) {
@ -753,7 +861,7 @@ window.Page_health = (() => {
const t = typ || _activeTab;
const commonFields = `
${t !== 'gewicht' ? `
${t !== 'gewicht' && t !== 'laeufigkeit' ? `
<div class="form-group">
<label class="form-label">Bezeichnung *</label>
<input class="form-control" type="text" name="bezeichnung"
@ -761,7 +869,7 @@ window.Page_health = (() => {
placeholder="${_formPlaceholder(t)}">
</div>` : ''}
<div class="form-group">
<label class="form-label">Datum *</label>
<label class="form-label">Start *</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
@ -854,12 +962,13 @@ window.Page_health = (() => {
function _formPlaceholder(typ) {
const ph = {
impfung: 'z.B. Tollwut, DHPP, Leptospirose',
tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung',
gewicht: '',
medikament: 'z.B. Frontline, Milbemax',
allergie: 'z.B. Hühnchen, Gras, Hausstaub',
dokument: 'z.B. Impfpass, Blutbild',
impfung: 'z.B. Tollwut, DHPP, Leptospirose',
tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung',
gewicht: '',
medikament: 'z.B. Frontline, Milbemax',
allergie: 'z.B. Hühnchen, Gras, Hausstaub',
dokument: 'z.B. Impfpass, Blutbild',
laeufigkeit: 'Läufigkeit',
};
return ph[typ] || '';
}
@ -1019,6 +1128,60 @@ window.Page_health = (() => {
<textarea class="form-control" name="reaktion" rows="2">${_esc(entry?.reaktion || '')}</textarea>
</div>
`;
case 'laeufigkeit': {
const prevCycles = (_data['laeufigkeit'] || []).filter(e => e !== entry && e?.datum);
let avgInterval = 0;
if (prevCycles.length >= 2) {
const sorted = [...prevCycles].sort((a, b) => a.datum.localeCompare(b.datum));
const intervals = [];
for (let i = 1; i < sorted.length; i++) {
intervals.push(Math.round((new Date(sorted[i].datum) - new Date(sorted[i-1].datum)) / 86400000));
}
avgInterval = Math.round(intervals.reduce((a, b) => a + b, 0) / intervals.length);
}
const defaultInterval = avgInterval || (entry?.intervall_tage) || 180;
// Auto-berechne nächstes Datum aus Startdatum + Interval
return `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Dauer (Tage)</label>
<input class="form-control" type="number" min="1" max="60" name="wert"
value="${entry?.wert ?? ''}" placeholder="z.B. 21"
id="laeufi-dauer">
</div>
<div class="form-group">
<label class="form-label">Zyklusabstand (Tage)</label>
<input class="form-control" type="number" min="60" max="400" name="intervall_tage"
value="${entry?.intervall_tage || defaultInterval}"
id="laeufi-interval">
</div>
</div>
<div class="form-group">
<label class="form-label">Nächste erwartet</label>
<input class="form-control" type="date" name="naechstes"
value="${entry?.naechstes || ''}" id="laeufi-naechstes">
</div>
<script>
(function() {
const datum = document.querySelector('[name="datum"]');
const interval = document.getElementById('laeufi-interval');
const naechstes = document.getElementById('laeufi-naechstes');
function updateNext() {
const d = datum?.value;
const iv = parseInt(interval?.value) || 0;
if (d && iv) {
const next = new Date(d);
next.setDate(next.getDate() + iv);
naechstes.value = next.toISOString().slice(0, 10);
}
}
datum?.addEventListener('change', updateNext);
interval?.addEventListener('change', updateNext);
if (!naechstes?.value) updateNext();
})();
</script>
`;
}
default: return '';
}
}
@ -1039,9 +1202,12 @@ window.Page_health = (() => {
reaktion: fd.reaktion || null,
};
if (fd.wert) {
p.wert = parseFloat(fd.wert.replace(',', '.'));
p.wert = parseFloat(fd.wert.toString().replace(',', '.'));
if (typ === 'gewicht') p.bezeichnung = `${p.wert} kg`;
}
if (typ === 'laeufigkeit') {
p.bezeichnung = p.bezeichnung || 'Läufigkeit';
}
if (fd.kosten) p.kosten = parseFloat(fd.kosten.toString().replace(',', '.'));
if (fd.tierarzt_id) {
p.tierarzt_id = parseInt(fd.tierarzt_id);
@ -1278,14 +1444,14 @@ window.Page_health = (() => {
} catch (err) {
if (err.status === 402) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning,#f59e0b)">
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning)">
<p style="margin:0;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
</p>
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger,#ef4444)">
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger)">
<p style="margin:0;font-size:var(--text-sm)">
KI-Server nicht erreichbar. Bitte später versuchen.
</p>