Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)

- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
This commit is contained in:
rene 2026-05-02 09:29:48 +02:00
parent 031c6028ac
commit 742ad189e8
26 changed files with 5734 additions and 27 deletions

View file

@ -6,11 +6,13 @@
window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _favoritVet = null;
let _healthDocs = [];
const BASE_TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
@ -150,8 +152,12 @@ window.Page_health = (() => {
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
</button>
</div>
${transponderHtml}
<div id="health-mein-tierarzt"></div>
<div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></div>
<div id="health-reminders"></div>
@ -162,6 +168,8 @@ window.Page_health = (() => {
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn')
.addEventListener('click', _showKiTierarzt);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
@ -170,6 +178,7 @@ window.Page_health = (() => {
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
_loadMeinTierarzt();
}
// ----------------------------------------------------------
@ -342,6 +351,16 @@ window.Page_health = (() => {
} catch (err) {
_data['gewicht_chart'] = [];
}
try {
_favoritVet = await API.tieraerzte.myFavorite();
} catch (err) {
_favoritVet = null;
}
try {
_healthDocs = await API.healthDocs.list(dogId);
} catch (err) {
_healthDocs = [];
}
}
// ----------------------------------------------------------
@ -901,7 +920,8 @@ window.Page_health = (() => {
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
${_renderBefundeSection()}`;
}
// ----------------------------------------------------------
@ -957,6 +977,32 @@ window.Page_health = (() => {
// Praxis hinzufügen
content.querySelector('[data-action="add-praxis"]')
?.addEventListener('click', () => _showPraxForm(null));
// Favorit-Toggle für Praxen
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const vetId = parseInt(btn.dataset.praxisId);
await UI.asyncButton(btn, async () => {
const res = await API.tieraerzte.toggleFavorite(vetId);
if (res.is_favorite) {
_favoritVet = _praxen.find(p => p.id === vetId) || null;
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
} else {
_favoritVet = null;
UI.toast.success('Favorit entfernt.');
}
// is_favorite in _praxen aktualisieren
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
const elFav = _container.querySelector('#health-mein-tierarzt');
if (elFav) _renderMeinTierarztKachel(elFav);
_renderTab();
});
});
});
// Befunde & Dokumente
if (_activeTab === 'dokument') {
_bindBefundeEvents(content);
}
}
// ----------------------------------------------------------
@ -1597,7 +1643,9 @@ window.Page_health = (() => {
action: addBtn
});
const renderCard = p => `
const renderCard = p => {
const isFav = _favoritVet?.id === p.id || p.is_favorite;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
@ -1626,10 +1674,21 @@ window.Page_health = (() => {
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
</div>
</div>
</div>
`;
`};
return `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
@ -2156,6 +2215,306 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// MEIN TIERARZT — Kachel
// ----------------------------------------------------------
async function _loadMeinTierarzt() {
const el = _container.querySelector('#health-mein-tierarzt');
if (!el) return;
_renderMeinTierarztKachel(el);
}
function _renderMeinTierarztKachel(el) {
if (!el) return;
const vet = _favoritVet;
const adresse = vet
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
: '';
el.innerHTML = `
<div style="margin:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Mein Tierarzt
</div>
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.6rem;flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
${vet ? `
<div class="health-card-title">${_esc(vet.name)}</div>
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
${vet.telefon ? `
<div style="margin-top:var(--space-2)">
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
</a>
</div>` : ''}
${vet.notfall_telefon ? `
<div style="margin-top:var(--space-1)">
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
</a>
</div>` : ''}
` : `
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch kein Tierarzt als Favorit gespeichert.
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
id="health-suche-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
</button>
`}
</div>
${vet ? `
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
</button>
` : ''}
</div>
</div>
`;
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
App.navigate('map', { filter: 'tierarzt' });
});
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await UI.asyncButton(btn, async () => {
await API.tieraerzte.toggleFavorite(_favoritVet.id);
_favoritVet = null;
const elAgain = _container.querySelector('#health-mein-tierarzt');
if (elAgain) _renderMeinTierarztKachel(elAgain);
UI.toast.success('Tierarzt-Favorit entfernt.');
});
});
}
// ----------------------------------------------------------
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
// ----------------------------------------------------------
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
function _renderBefundeSection() {
const dog = _appState.activeDog;
const docs = _healthDocs;
const DOC_ICONS = {
blutbild: 'drop',
roentgen: 'file-text',
rezept: 'note',
impfausweis:'certificate',
sonstiges: 'file-text',
};
const DOC_LABELS = {
blutbild: 'Blutbild',
roentgen: 'Röntgen',
rezept: 'Rezept',
impfausweis:'Impfausweis',
sonstiges: 'Sonstiges',
};
const uploadBtn = `
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
</button>`;
const items = docs.length
? docs.map(doc => {
const icon = DOC_ICONS[doc.typ] || 'file-text';
const label = DOC_LABELS[doc.typ] || doc.typ;
const isImg = !['pdf'].includes(doc.file_type);
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
return `
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
<div class="health-card-title">${_esc(doc.titel)}</div>
<div class="health-card-meta">
${_esc(label)}${datum ? ' · ' + datum : ''}
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
</div>
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
${isImg
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
</a>
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
data-action="delete-hdoc" data-doc-id="${doc.id}"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
</div>`;
}).join('')
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
Noch keine Befunde hochgeladen.
</p>`;
return `
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde &amp; Dokumente
</div>
${uploadBtn}
</div>
<div class="health-list" id="health-docs-list">${items}</div>
</div>
`;
}
function _bindBefundeEvents(content) {
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
_showBefundUploadModal();
});
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const docId = parseInt(btn.dataset.docId);
const ok = window.confirm('Befund wirklich löschen?');
if (!ok) return;
await UI.asyncButton(btn, async () => {
await API.healthDocs.delete(docId);
_healthDocs = _healthDocs.filter(d => d.id !== docId);
_renderTab();
UI.toast.success('Befund gelöscht.');
});
});
});
}
function _showBefundUploadModal() {
const aktivePraxen = _praxen.filter(p => p.aktiv);
const dog = _appState.activeDog;
UI.modal.open({
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
body: `
<form id="befund-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Art des Dokuments *</label>
<select class="form-control" name="typ" required>
<option value=""> bitte wählen </option>
<option value="blutbild">Blutbild</option>
<option value="roentgen">Röntgen</option>
<option value="rezept">Rezept</option>
<option value="impfausweis">Impfausweis</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel" required
placeholder="z.B. Blutbild März 2026">
</div>
<div class="form-group">
<label class="form-label">Untersuchungsdatum</label>
<input class="form-control" type="date" name="datum"
value="${new Date().toISOString().slice(0,10)}">
</div>
${aktivePraxen.length ? `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<select class="form-control" name="vet_id">
<option value=""> optional </option>
${aktivePraxen.map(p =>
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
).join('')}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Zusätzliche Infos (optional)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Datei * (PDF, JPG, PNG, WebP max. 10 MB)</label>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
<input type="file" name="file" id="befund-file-input"
accept=".pdf,image/*"
required
style="position:absolute;opacity:0;width:1px;height:1px">
</label>
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
color:var(--c-text-secondary)"></div>
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
`,
});
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('befund-file-input')?.addEventListener('change', function () {
const preview = document.getElementById('befund-file-preview');
if (this.files?.length) {
const f = this.files[0];
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
} else {
preview.textContent = '';
}
});
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.querySelector('[form="befund-form"][type="submit"]');
const form = e.target;
const fd = UI.formData(form);
const fileInput = form.querySelector('[name="file"]');
const file = fileInput?.files?.[0];
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
if (file.size > 10 * 1024 * 1024) {
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
return;
}
await UI.asyncButton(btn, async () => {
const formData = new FormData();
formData.append('dog_id', String(dog.id));
formData.append('typ', fd.typ);
formData.append('titel', fd.titel);
formData.append('beschreibung', fd.beschreibung || '');
formData.append('datum', fd.datum || '');
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
formData.append('file', file);
try {
const doc = await API.healthDocs.upload(formData);
_healthDocs.unshift(doc);
UI.modal.close();
_renderTab();
UI.toast.success('Befund hochgeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
@ -2323,6 +2682,129 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// KI-TIERARZTFRAGEN
// ----------------------------------------------------------
function _showKiTierarzt() {
const dog = _appState.activeDog;
const dogName = dog?.name || '';
const rasse = dog?.rasse || '';
const placeholder = dogName
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung
kein Ersatz für einen echten Tierarzt.
</p>
<div class="form-group">
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
placeholder="${_esc(placeholder)}"></textarea>
</div>
<div id="ki-tierarzt-result" style="display:none"></div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fff3cd;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#856404;
border:1px solid #ffc107">
<strong>&#9888;&#65039; Hinweis:</strong> Dies ist keine medizinische Diagnose.
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
});
document.getElementById('ki-tierarzt-submit-btn')
.addEventListener('click', async function () {
const btn = this;
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
const resultEl = document.getElementById('ki-tierarzt-result');
if (!symptom) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post('/ki/tierarzt', {
symptom,
dog_id: dog?.id || null,
dog_name: dogName || null,
rasse: rasse || null,
});
} catch (err) {
if (err.status === 429) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-warning);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-danger);
font-size:var(--text-sm)">
KI momentan nicht verfügbar. Bitte später versuchen.
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
return;
}
resultEl.style.display = '';
return;
}
const antwortHtml = _esc(result.antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
.replace(/\n/g, '<br>');
const restHtml = result.limit - result.anfragen_heute > 0
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
</p>`
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Tageslimit erreicht. Morgen wieder verfügbar.
</p>`;
resultEl.innerHTML = `
<div style="margin-top:var(--space-4);padding:var(--space-4);
background:var(--c-surface);border-radius:var(--radius-md);
border:1px solid var(--c-border)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
Einschätzung
</div>
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
${restHtml}
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fee2e2;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#991b1b;
border:1px solid #fca5a5">
<strong>&#9888;&#65039; Dies ist keine medizinische Diagnose.</strong>
Bei ernsthaften Symptomen sofort zum Tierarzt.
</div>`;
resultEl.style.display = '';
// Submit-Button ausblenden wenn Limit erschöpft
if (result.anfragen_heute >= result.limit) {
btn.disabled = true;
btn.textContent = 'Limit erreicht';
}
});
});
}
return { init, refresh, openNew, onDogChange };
})();