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:
parent
32d630d5a1
commit
b58789373c
30 changed files with 4344 additions and 523 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue