diff --git a/backend/database.py b/backend/database.py
index e373f02..5d992eb 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1657,3 +1657,203 @@ def _migrate(conn_factory):
);
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
""")
+
+ # Trainings-Streak-Tabelle
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS training_streaks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ current_streak INTEGER NOT NULL DEFAULT 0,
+ longest_streak INTEGER NOT NULL DEFAULT 0,
+ last_training_date TEXT,
+ UNIQUE(user_id, dog_id)
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)")
+
+ # Ausgaben-Tracker
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS expenses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ kategorie TEXT NOT NULL,
+ betrag REAL NOT NULL,
+ datum TEXT NOT NULL,
+ notiz TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC);
+ """)
+
+ # KI-Tierarztfragen Rate-Limit-Log
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS ki_tierarzt_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+
+ # KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS ki_rasse_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user
+ ON ki_rasse_log(user_id, created_at DESC)
+ """)
+
+ # feed_recalls — Rückruf-Alarm für Tierfutter (RASFF)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS feed_recalls (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ external_id TEXT NOT NULL UNIQUE,
+ titel TEXT NOT NULL,
+ produkt TEXT,
+ gefahr TEXT,
+ herkunft TEXT,
+ datum TEXT NOT NULL,
+ quelle TEXT NOT NULL DEFAULT 'rasff',
+ url TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)")
+
+ # Adoption-Cache
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS adoption_cache (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ external_id TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ rasse TEXT,
+ alter_jahre REAL,
+ geschlecht TEXT,
+ foto_url TEXT,
+ tierheim TEXT,
+ tierheim_plz TEXT,
+ tierheim_lat REAL,
+ tierheim_lon REAL,
+ adoptions_url TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ expires_at TEXT NOT NULL
+ )
+ """)
+
+ # ---- Favoriten-Tierarzt + Gesundheitsdokumente ----
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS favorite_vets (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, vet_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS health_documents (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ typ TEXT NOT NULL,
+ titel TEXT NOT NULL,
+ beschreibung TEXT,
+ file_path TEXT NOT NULL,
+ file_type TEXT NOT NULL,
+ datum TEXT,
+ vet_id INTEGER REFERENCES tieraerzte(id),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)")
+
+ # Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links
+ try:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS vaccinations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ krankheit TEXT NOT NULL,
+ datum TEXT NOT NULL,
+ naechste TEXT,
+ tierarzt TEXT,
+ charge_nr TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS medications (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ dosierung TEXT,
+ von TEXT,
+ bis TEXT,
+ notiz TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS dog_passport_meta (
+ dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE,
+ blutgruppe TEXT,
+ allergien TEXT,
+ besonderheiten TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS passport_shares (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ token TEXT NOT NULL UNIQUE,
+ valid_until TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token)
+ """)
+ logger.info("Migration: Hundepass-Tabellen bereit.")
+ except Exception as e:
+ logger.warning(f"Migration Hundepass: {e}")
+
+ # ---- Playdate ----
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS playdate_listings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ lat REAL NOT NULL,
+ lon REAL NOT NULL,
+ ort_name TEXT,
+ radius_km INTEGER NOT NULL DEFAULT 10,
+ beschreibung TEXT,
+ aktiv INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(dog_id)
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo
+ ON playdate_listings(lat, lon) WHERE aktiv=1
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS playdate_requests (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'pending',
+ nachricht TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(from_dog_id, to_dog_id)
+ )
+ """)
diff --git a/backend/main.py b/backend/main.py
index 6eb99a2..8b259f7 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -189,6 +189,13 @@ from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
from routes.outreach import router as outreach_router
from routes.jobs import router as jobs_router
+from routes.streak import router as streak_router
+from routes.expenses import router as expenses_router
+from routes.recalls import router as recalls_router
+from routes.adoption import router as adoption_router
+from routes.health_docs import router as health_docs_router
+from routes.passport import router as passport_router
+from routes.playdate import router as playdate_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -240,6 +247,13 @@ app.include_router(training_router, prefix="/api/training", tags=
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
+app.include_router(streak_router, prefix="/api", tags=["Streak"])
+app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
+app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
+app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
+app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
+app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
+app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
# ------------------------------------------------------------------
@@ -1674,6 +1688,152 @@ for _hp in _HONEYPOT_PATHS:
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
+# ------------------------------------------------------------------
+# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
+# ------------------------------------------------------------------
+@app.get("/pass/{token}")
+async def passport_share_page(token: str):
+ from fastapi.responses import HTMLResponse
+ from database import db as _db
+ from datetime import date as _date
+
+ with _db() as conn:
+ share = conn.execute(
+ "SELECT * FROM passport_shares WHERE token=?", (token,)
+ ).fetchone()
+ if not share:
+ return HTMLResponse(
+ ' '
+ '
Dieser Hundepass-Link ist ungültig.
',
+ status_code=404
+ )
+ if share["valid_until"] < _date.today().isoformat():
+ return HTMLResponse(
+ 'Dieser Hundepass-Link ist nicht mehr gültig.
',
+ status_code=410
+ )
+ dog_id = share["dog_id"]
+ dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
+ meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
+ vaccs = conn.execute(
+ "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
+ ).fetchall()
+ meds = conn.execute(
+ "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
+ ).fetchall()
+ def _fmt(d):
+ if not d:
+ return "–"
+ try:
+ from datetime import datetime as _dt
+ return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
+ except Exception:
+ return d
+
+ dog = dict(dog)
+ meta = dict(meta) if meta else {}
+ vaccs = [dict(v) for v in vaccs]
+ meds = [dict(m) for m in meds]
+
+ _g_map = {"m": "Rüde", "w": "Hündin"}
+
+ vacc_rows = "".join(f"""
+
@@ -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 = `
+
+
+ Mein Tierarzt
+
+
+
+
+
+
+ ${vet ? `
+
${_esc(vet.name)}
+ ${adresse ? `
${_esc(adresse)}
` : ''}
+ ${vet.telefon ? `
+
` : ''}
+ ${vet.notfall_telefon ? `
+
` : ''}
+ ` : `
+
+ Noch kein Tierarzt als Favorit gespeichert.
+
+
+ Tierarzt suchen
+
+ `}
+
+ ${vet ? `
+
+
+
+ ` : ''}
+
+
+ `;
+
+ 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 = `
+
+ Befund hochladen
+ `;
+
+ 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 `
+
+
+
+
+
+
${_esc(doc.titel)}
+
+ ${_esc(label)}${datum ? ' · ' + datum : ''}
+ ${doc.vet_name ? ' · ' + _esc(doc.vet_name) : ''}
+
+ ${doc.beschreibung ? `
${_esc(doc.beschreibung)}
` : ''}
+
+
+
`;
+ }).join('')
+ : `
+ Noch keine Befunde hochgeladen.
+
`;
+
+ return `
+
+
+
+ Befunde & Dokumente
+
+ ${uploadBtn}
+
+
${items}
+
+ `;
+ }
+
+ 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: `
Befund hochladen`,
+ body: `
+
+ `,
+ footer: `
+
Abbrechen
+
Hochladen
+ `,
+ });
+
+ 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: '
KI-Tierarzt',
+ body: `
+
+ Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung —
+ kein Ersatz für einen echten Tierarzt.
+
+
+
+
+
+
+ ⚠️ Hinweis: Dies ist keine medizinische Diagnose.
+ Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
+
`,
+ footer: `
+
Schließen
+
Frage stellen `,
+ });
+
+ 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 = `
+
+
+ 5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
+
`;
+ } else if (err.status === 503) {
+ resultEl.innerHTML = `
+
+ KI momentan nicht verfügbar. Bitte später versuchen.
+
`;
+ } 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, '
')
+ .replace(/\n/g, ' ');
+ const restHtml = result.limit - result.anfragen_heute > 0
+ ? `
+ Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
+
`
+ : `
+ Tageslimit erreicht. Morgen wieder verfügbar.
+
`;
+
+ resultEl.innerHTML = `
+
+
+
+ Einschätzung
+
+
${antwortHtml}
+ ${restHtml}
+
+
+ ⚠️ Dies ist keine medizinische Diagnose.
+ Bei ernsthaften Symptomen sofort zum Tierarzt.
+
`;
+ 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 };
})();
diff --git a/backend/static/js/pages/playdate.js b/backend/static/js/pages/playdate.js
new file mode 100644
index 0000000..60eba05
--- /dev/null
+++ b/backend/static/js/pages/playdate.js
@@ -0,0 +1,708 @@
+/* ============================================================
+ BAN YARO — Playdate-Matching
+ Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
+ ============================================================ */
+
+window.Page_playdate = (() => {
+
+ let _container = null;
+ let _appState = null;
+ let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
+ let _userPos = null;
+ let _radius = 10;
+ let _dogs = [];
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+ function _esc(s) {
+ return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function _fmtDate(iso) {
+ if (!iso) return '';
+ const d = new Date(iso.replace(' ', 'T'));
+ return d.toLocaleDateString('de-DE');
+ }
+
+ function _dogAvatar(foto_url, name, size = 48) {
+ const initials = _esc((name || '?').charAt(0).toUpperCase());
+ if (foto_url) {
+ return `
`;
+ }
+ return `
${initials}
`;
+ }
+
+ function _statusBadge(status) {
+ const map = {
+ pending: ['warning', 'Ausstehend'],
+ accepted: ['success', 'Angenommen'],
+ declined: ['danger', 'Abgelehnt'],
+ };
+ const [type, label] = map[status] || ['default', status];
+ const colors = {
+ warning: 'var(--c-warning, #f59e0b)',
+ success: 'var(--c-success, #10b981)',
+ danger: 'var(--c-danger, #ef4444)',
+ default: 'var(--c-text-muted)',
+ };
+ return `
${label} `;
+ }
+
+ // ------------------------------------------------------------------
+ // INIT
+ // ------------------------------------------------------------------
+ async function init(container, appState) {
+ _container = container;
+ _appState = appState;
+ _dogs = appState.dogs?.filter(d => !d.is_guest) || [];
+ _render();
+ _switchTab(_activeTab);
+ }
+
+ function refresh() {
+ _dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
+ _switchTab(_activeTab);
+ }
+
+ function onDogChange() {
+ _dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
+ if (_activeTab === 'listings') _loadListings();
+ }
+
+ // ------------------------------------------------------------------
+ // RENDER — Grundstruktur mit Tabs
+ // ------------------------------------------------------------------
+ function _render() {
+ _container.innerHTML = `
+
+
+
+
+ In der Nähe
+ Meine Inserate
+
+ Anfragen
+ 0
+
+
+
+
+
+
+
+ `;
+
+ document.getElementById('playdate-tabs').addEventListener('click', e => {
+ const btn = e.target.closest('.by-tab');
+ if (!btn) return;
+ _switchTab(btn.dataset.tab);
+ });
+ }
+
+ function _switchTab(tab) {
+ _activeTab = tab;
+ document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
+ b.classList.toggle('active', b.dataset.tab === tab);
+ });
+ const content = document.getElementById('playdate-content');
+ if (!content) return;
+
+ if (tab === 'nearby') _renderNearby(content);
+ if (tab === 'listings') _renderListings(content);
+ if (tab === 'requests') _renderRequests(content);
+ }
+
+ // ------------------------------------------------------------------
+ // TAB: IN DER NÄHE
+ // ------------------------------------------------------------------
+ async function _renderNearby(el) {
+ el.innerHTML = `
+
+
+
+
+ ${UI.icon('map-pin')}
+
+ ${_userPos ? 'Standort bekannt' : 'Kein Standort'}
+
+
+
+ 5 km
+ 10 km
+ 25 km
+ 50 km
+
+
+ ${UI.icon('crosshair')} Standort aktualisieren
+
+
+
+
+
+ ${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
+ Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
+
+
+
+
+
+ Standort wird ermittelt…
+
+
+
+ `;
+
+ document.getElementById('nearby-radius').addEventListener('change', e => {
+ _radius = parseInt(e.target.value, 10);
+ _loadNearby();
+ });
+
+ document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
+ const btn = document.getElementById('nearby-locate-btn');
+ UI.setLoading(btn, true);
+ try {
+ _userPos = await API.getLocation();
+ const label = document.getElementById('nearby-location-label');
+ if (label) label.textContent = 'Standort aktualisiert';
+ await _loadNearby();
+ } catch {
+ UI.toast.error('Standort konnte nicht ermittelt werden.');
+ } finally {
+ UI.setLoading(btn, false);
+ }
+ });
+
+ if (!_userPos) {
+ try {
+ _userPos = await API.getLocation();
+ const label = document.getElementById('nearby-location-label');
+ if (label) label.textContent = 'Standort bekannt';
+ } catch {
+ document.getElementById('nearby-results').innerHTML = `
+
+ ${UI.icon('map-pin')}
+
+ Standort konnte nicht automatisch ermittelt werden.
+ Klicke auf "Standort aktualisieren".
+
+
+ `;
+ return;
+ }
+ }
+ await _loadNearby();
+ }
+
+ async function _loadNearby() {
+ if (!_userPos) return;
+ const resultsEl = document.getElementById('nearby-results');
+ if (!resultsEl) return;
+ resultsEl.innerHTML = `
${UI.icon('spinner')} Suche…
`;
+
+ try {
+ const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
+
+ if (!data || data.length === 0) {
+ resultsEl.innerHTML = UI.emptyState({
+ icon: UI.icon('paw-print'),
+ title: 'Niemand in der Nähe',
+ text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
+ });
+ return;
+ }
+
+ resultsEl.innerHTML = `
+
+ ${data.map(d => _nearbyCard(d)).join('')}
+
+ `;
+
+ resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const toDogId = parseInt(btn.dataset.dogId, 10);
+ const dogName = btn.dataset.dogName;
+ _showRequestModal(toDogId, dogName);
+ });
+ });
+
+ } catch (err) {
+ resultsEl.innerHTML = `
${err.message}
`;
+ }
+ }
+
+ function _nearbyCard(d) {
+ return `
+
+
+ ${_dogAvatar(d.foto_url, d.dog_name, 56)}
+
+
${_esc(d.dog_name)}
+ ${d.rasse ? `
${_esc(d.rasse)}
` : ''}
+ ${d.alter ? `
${_esc(d.alter)}
` : ''}
+
+
+
+
+
+ ${UI.icon('map-pin')}
+ ${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
+
+ ${d.geschlecht ? `${_esc(d.geschlecht)} ` : ''}
+
+
+ ${d.beschreibung ? `
+
+ ${_esc(d.beschreibung)}
+
` : ''}
+
+
+ ${UI.icon('paw-print')} Spielkamerad anfragen
+
+
+ `;
+ }
+
+ function _showRequestModal(toDogId, dogName) {
+ const formId = 'playdate-req-form';
+ UI.modal.open({
+ title: `Anfrage an ${dogName}`,
+ body: `
+
+
+ Nachricht (optional)
+
+
+
+ `,
+ footer: `
+
Abbrechen
+
+ ${UI.icon('paper-plane-tilt')} Anfrage senden
+
+ `,
+ });
+
+ document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
+ document.getElementById('req-send-btn').addEventListener('click', async () => {
+ const btn = document.getElementById('req-send-btn');
+ const nachricht = document.getElementById('req-nachricht').value.trim();
+ await UI.asyncButton(btn, async () => {
+ const result = await API.post('/playdate/request', {
+ to_dog_id: toDogId,
+ nachricht: nachricht || null,
+ });
+ UI.modal.close();
+ UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
+ // Zum Chat navigieren
+ if (result.conversation_id) {
+ setTimeout(() => {
+ App.navigate('chat', true, { conversation_id: result.conversation_id });
+ }, 800);
+ }
+ }, { errorMsg: null });
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // TAB: MEINE INSERATE
+ // ------------------------------------------------------------------
+ async function _renderListings(el) {
+ el.innerHTML = `
${UI.icon('spinner')} Lädt…
`;
+ await _loadListings(el);
+ }
+
+ async function _loadListings(el) {
+ const target = el || document.getElementById('playdate-content');
+ if (!target) return;
+
+ if (_dogs.length === 0) {
+ target.innerHTML = UI.emptyState({
+ icon: UI.icon('paw-print'),
+ title: 'Noch kein Hund',
+ text: 'Lege zuerst einen Hund in deinem Profil an.',
+ action: `
Hund anlegen `,
+ });
+ return;
+ }
+
+ // Listings für alle eigenen Hunde laden
+ const listings = {};
+ await Promise.all(_dogs.map(async dog => {
+ try {
+ const data = await API.get(`/playdate/my-listing/${dog.id}`);
+ listings[dog.id] = data;
+ } catch {
+ listings[dog.id] = null;
+ }
+ }));
+
+ target.innerHTML = `
+
+ ${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
+
+ `;
+
+ // Event-Delegation für alle Buttons
+ target.addEventListener('click', async e => {
+ const btn = e.target.closest('button[data-action]');
+ if (!btn) return;
+ const action = btn.dataset.action;
+ const dogId = parseInt(btn.dataset.dogId, 10);
+ const dog = _dogs.find(d => d.id === dogId);
+
+ if (action === 'edit') {
+ _showListingModal(dog, listings[dogId], async () => {
+ await _loadListings();
+ });
+ }
+ if (action === 'deactivate') {
+ if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
+ try {
+ await API.del(`/playdate/listing/${dogId}`);
+ UI.toast.success('Inserat deaktiviert.');
+ await _loadListings();
+ } catch (err) {
+ UI.toast.error(err.message);
+ }
+ }
+ });
+ }
+
+ function _listingCard(dog, listing) {
+ const isAktiv = listing && listing.aktiv;
+ return `
+
+
+ ${_dogAvatar(dog.foto_url, dog.name, 44)}
+
+
${_esc(dog.name)}
+ ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''}
+
+
+ ${isAktiv ? 'Aktiv' : 'Inaktiv'}
+
+
+
+ ${isAktiv ? `
+
+ ${UI.icon('map-pin')}
+ ${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
+ Radius: ${listing.radius_km} km
+
+ ${listing.beschreibung ? `
+
${_esc(listing.beschreibung)}
` : ''}
+ ` : `
+
+ Noch kein Inserat — trage dich ein, damit andere dich finden können.
+
+ `}
+
+
+
+ ${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
+
+ ${isAktiv ? `
+
+ ${UI.icon('x')} Deaktivieren
+ ` : ''}
+
+
+ `;
+ }
+
+ function _showListingModal(dog, existing, onSaved) {
+ const formId = 'listing-form';
+ UI.modal.open({
+ title: `Inserat für ${dog.name}`,
+ body: `
+
+
+
+
+ Suchradius
+
+ 5 km
+ 10 km
+ 25 km
+ 50 km
+
+
+
+
+ Beschreibung (optional)
+ ${_esc(existing?.beschreibung || '')}
+
+
+ `,
+ footer: `
+
Abbrechen
+
+ ${UI.icon('floppy-disk')} Speichern
+
+ `,
+ });
+
+ // GPS-Button
+ document.getElementById('listing-gps-btn').addEventListener('click', async () => {
+ const gpsBtn = document.getElementById('listing-gps-btn');
+ UI.setLoading(gpsBtn, true);
+ try {
+ const pos = await API.getLocation();
+ document.getElementById('listing-lat').value = pos.lat;
+ document.getElementById('listing-lon').value = pos.lon;
+
+ // Reverse-Geocoding für Ortsname
+ try {
+ const rev = await fetch(
+ `https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
+ { cache: 'no-store' }
+ );
+ const geoData = await rev.json();
+ const a = geoData.address || {};
+ const ort = a.city || a.town || a.village || a.municipality || '';
+ if (ort) document.getElementById('listing-ort').value = ort;
+ } catch {}
+ UI.toast.success('Standort ermittelt.');
+ } catch {
+ UI.toast.error('Standort konnte nicht ermittelt werden.');
+ } finally {
+ UI.setLoading(gpsBtn, false);
+ }
+ });
+
+ document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
+
+ document.getElementById('listing-save-btn').addEventListener('click', async () => {
+ const btn = document.getElementById('listing-save-btn');
+ const lat = parseFloat(document.getElementById('listing-lat').value);
+ const lon = parseFloat(document.getElementById('listing-lon').value);
+ const ort = document.getElementById('listing-ort').value.trim();
+ const rad = parseInt(document.getElementById('listing-radius').value, 10);
+ const desc = document.getElementById('listing-beschreibung').value.trim();
+
+ if (!lat || !lon) {
+ UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
+ return;
+ }
+
+ await UI.asyncButton(btn, async () => {
+ await API.put('/playdate/listing', {
+ dog_id: dog.id,
+ lat,
+ lon,
+ ort_name: ort || null,
+ radius_km: rad,
+ beschreibung: desc || null,
+ });
+ UI.modal.close();
+ UI.toast.success('Inserat gespeichert!');
+ onSaved?.();
+ }, { errorMsg: null });
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // TAB: ANFRAGEN
+ // ------------------------------------------------------------------
+ async function _renderRequests(el) {
+ el.innerHTML = `
${UI.icon('spinner')} Lädt…
`;
+ try {
+ const data = await API.get('/playdate/requests');
+ const incoming = data.incoming || [];
+ const outgoing = data.outgoing || [];
+
+ // Badge aktualisieren
+ const pendingCount = incoming.filter(r => r.status === 'pending').length;
+ const badge = document.getElementById('playdate-req-badge');
+ if (badge) {
+ badge.textContent = pendingCount;
+ badge.style.display = pendingCount > 0 ? '' : 'none';
+ }
+
+ if (incoming.length === 0 && outgoing.length === 0) {
+ el.innerHTML = UI.emptyState({
+ icon: UI.icon('paw-print'),
+ title: 'Noch keine Anfragen',
+ text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
+ });
+ return;
+ }
+
+ el.innerHTML = `
+
+
+ ${incoming.length > 0 ? `
+
+
Eingehende Anfragen
+
+ ${incoming.map(r => _incomingCard(r)).join('')}
+
+
` : ''}
+
+ ${outgoing.length > 0 ? `
+
+
Ausgehende Anfragen
+
+ ${outgoing.map(r => _outgoingCard(r)).join('')}
+
+
` : ''}
+
+
+ `;
+
+ // Button-Events (Accept/Decline)
+ el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const reqId = parseInt(btn.dataset.reqId, 10);
+ const status = btn.dataset.status;
+ await UI.asyncButton(btn, async () => {
+ const result = await API.patch(`/playdate/requests/${reqId}`, { status });
+ if (status === 'accepted' && result.conversation_id) {
+ UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
+ setTimeout(() => {
+ App.navigate('chat', true, { conversation_id: result.conversation_id });
+ }, 800);
+ } else {
+ UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
+ }
+ await _renderRequests(el);
+ }, { errorMsg: null });
+ });
+ });
+
+ // Chat-Buttons
+ el.querySelectorAll('.req-chat-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ App.navigate('chat', true);
+ });
+ });
+
+ } catch (err) {
+ el.innerHTML = `
${err.message}
`;
+ }
+ }
+
+ function _incomingCard(r) {
+ const isPending = r.status === 'pending';
+ return `
+
+
+ ${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
+
+
${_esc(r.from_dog_name)}
+
+ ${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
+ ${r.alter ? _esc(r.alter) + ' · ' : ''}
+ von ${_esc(r.from_user_name)}
+
+
${_fmtDate(r.created_at)}
+
+ ${_statusBadge(r.status)}
+
+
+ ${r.nachricht ? `
+
+ "${_esc(r.nachricht)}"
+
` : ''}
+
+ ${isPending ? `
+
+
+ ${UI.icon('check')} Annehmen
+
+
+ ${UI.icon('x')} Ablehnen
+
+
` : `
+ ${r.status === 'accepted' ? `
+
+ ${UI.icon('chat-circle-dots')} Zum Chat
+ ` : ''}
+ `}
+
+ `;
+ }
+
+ function _outgoingCard(r) {
+ return `
+
+
+ ${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
+
+
${_esc(r.to_dog_name)}
+
+ ${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
+ von ${_esc(r.to_user_name)}
+
+
${_fmtDate(r.created_at)}
+
+ ${_statusBadge(r.status)}
+
+
+ ${r.nachricht ? `
+
+ "${_esc(r.nachricht)}"
+
` : ''}
+
+ ${r.status === 'accepted' ? `
+
+ ${UI.icon('chat-circle-dots')} Chat öffnen
+ ` : ''}
+
+ `;
+ }
+
+ // ------------------------------------------------------------------
+ return { init, refresh, onDogChange };
+})();
diff --git a/backend/static/js/pages/recalls.js b/backend/static/js/pages/recalls.js
new file mode 100644
index 0000000..86ac5d5
--- /dev/null
+++ b/backend/static/js/pages/recalls.js
@@ -0,0 +1,190 @@
+/* ============================================================
+ BAN YARO — Tierfutter-Rückrufe
+ Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter.
+ ============================================================ */
+
+window.Page_recalls = (() => {
+
+ // ----------------------------------------------------------
+ // MODUL-STATE
+ // ----------------------------------------------------------
+ let _container = null;
+ let _appState = null;
+ let _recalls = [];
+ let _query = '';
+
+ // ----------------------------------------------------------
+ // INIT
+ // ----------------------------------------------------------
+ async function init(container, appState) {
+ _container = container;
+ _appState = appState;
+ _query = '';
+ await _render();
+ }
+
+ // ----------------------------------------------------------
+ // REFRESH
+ // ----------------------------------------------------------
+ async function refresh() {
+ _recalls = [];
+ _query = '';
+ await _render();
+ }
+
+ // ----------------------------------------------------------
+ // RENDER
+ // ----------------------------------------------------------
+ async function _render() {
+ _container.innerHTML = `
+
+
+
+
+
+
+ Hinweis: Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer
+ bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.
+
+
+
+
+
+
+
+
+
+
+
+
+
${UI.skeleton(4)}
+ `;
+
+ // Suchfeld-Handler
+ _container.querySelector('#recalls-search').addEventListener('input', (e) => {
+ _query = e.target.value.trim();
+ _renderList();
+ });
+
+ await _loadRecalls();
+ }
+
+ // ----------------------------------------------------------
+ // DATEN LADEN
+ // ----------------------------------------------------------
+ async function _loadRecalls() {
+ try {
+ const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls';
+ _recalls = await API.get(url);
+ } catch {
+ _container.querySelector('#recalls-list').innerHTML = UI.emptyState({
+ icon: 'warning-circle',
+ title: 'Rückrufe konnten nicht geladen werden',
+ text: 'Bitte versuche es später erneut.',
+ });
+ return;
+ }
+ _renderList();
+ }
+
+ // ----------------------------------------------------------
+ // LISTE RENDERN
+ // ----------------------------------------------------------
+ function _renderList() {
+ const listEl = _container.querySelector('#recalls-list');
+ if (!listEl) return;
+
+ const filtered = _query
+ ? _recalls.filter(r => {
+ const q = _query.toLowerCase();
+ return (r.titel || '').toLowerCase().includes(q)
+ || (r.produkt || '').toLowerCase().includes(q)
+ || (r.gefahr || '').toLowerCase().includes(q)
+ || (r.herkunft || '').toLowerCase().includes(q);
+ })
+ : _recalls;
+
+ if (!filtered.length) {
+ const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
+ listEl.innerHTML = UI.emptyState({
+ icon: 'check-circle',
+ title: 'Aktuell keine Rückrufe',
+ text: `Letzte Prüfung: ${today}`,
+ });
+ return;
+ }
+
+ listEl.innerHTML = filtered.map(r => _cardHtml(r)).join('');
+ }
+
+ // ----------------------------------------------------------
+ // EINZELNE KARTE
+ // ----------------------------------------------------------
+ function _cardHtml(r) {
+ const datum = r.datum
+ ? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ : '';
+
+ const meta = [
+ r.herkunft ? `
${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)} ` : '',
+ datum ? `
${UI.icon('calendar-blank')} ${datum} ` : '',
+ r.quelle ? `
${UI.escape(r.quelle)} ` : '',
+ ].filter(Boolean).join('
· ');
+
+ const linkHtml = r.url
+ ? `
+ ${UI.icon('arrow-square-out')} Details auf RASFF
+ `
+ : '';
+
+ return `
+
+
+
+
+
+
+
+ ${UI.escape(r.produkt || r.titel)}
+
+
+
+
+ ${r.gefahr ? `
+
+ ${UI.escape(r.gefahr)}
+
` : ''}
+
+
+
+ ${meta}
+
+
+
+ ${linkHtml ? `
${linkHtml}
` : ''}
+
+ `;
+ }
+
+ // ----------------------------------------------------------
+ // PUBLIC API
+ // ----------------------------------------------------------
+ return { init, refresh };
+
+})();
diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js
index f5019a7..fed9bac 100644
--- a/backend/static/js/pages/uebungen.js
+++ b/backend/static/js/pages/uebungen.js
@@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => {
_closeModal();
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
+ // Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
+ API.post(`/streak/${body.dog_id}/ping`).then(streak => {
+ if (!streak) return;
+ if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
+ setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
+ } else if (streak.current_streak > 1) {
+ setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
+ }
+ }).catch(() => {});
+
if (resp.ist_top) {
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
} else {
diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js
index e917eca..e3514d5 100644
--- a/backend/static/js/pages/welcome.js
+++ b/backend/static/js/pages/welcome.js
@@ -463,6 +463,8 @@ window.Page_welcome = (() => {
`).join('')}
+ ${dog?.id ? `