From 6e4bf255810cfc000de65c4c11fc7fe5ee94fdd8 Mon Sep 17 00:00:00 2001
From: rene
Date: Mon, 4 May 2026 20:51:45 +0200
Subject: [PATCH] =?UTF-8?q?Feature:=20Hundeern=C3=A4hrungs-Feature=20?=
=?UTF-8?q?=E2=80=94=20Kalorien-Rechner,=20Futter-Guide,=20Giftliste,=20KI?=
=?UTF-8?q?-Berater=20(SW=20by-v698)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/database.py | 15 +
backend/main.py | 61 ++-
backend/routes/ernaehrung.py | 145 +++++++
backend/static/index.html | 16 +-
backend/static/js/app.js | 4 +-
backend/static/js/pages/ernaehrung.js | 603 ++++++++++++++++++++++++++
backend/static/sw.js | 2 +-
7 files changed, 838 insertions(+), 8 deletions(-)
create mode 100644 backend/routes/ernaehrung.py
create mode 100644 backend/static/js/pages/ernaehrung.js
diff --git a/backend/database.py b/backend/database.py
index d6b0dfe..a98cda8 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1951,6 +1951,21 @@ def _migrate(conn_factory):
conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0")
logger.info("Migration: users.gassi_stunde_push bereit.")
+ # Futter-Profil
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS futter_profil (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE,
+ futter_typ TEXT,
+ marke TEXT,
+ kcal_tag INTEGER,
+ portionen INTEGER DEFAULT 2,
+ notizen TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ """)
+ logger.info("Migration: futter_profil bereit.")
+
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
diff --git a/backend/main.py b/backend/main.py
index 229a856..1d23aef 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,9 +6,10 @@ import os
import html
import logging
from collections import deque
+import httpx
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
@@ -43,10 +44,43 @@ logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Startup / Shutdown
# ------------------------------------------------------------------
+def _backfill_image_sizes():
+ """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
+ import io
+ from database import db
+ from media_utils import get_image_size
+ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+ with db() as conn:
+ rows = conn.execute(
+ "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
+ ).fetchall()
+ if not rows:
+ return
+ logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
+ updated = 0
+ for row in rows:
+ # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
+ rel = row["url"].removeprefix("/media/")
+ path = os.path.join(MEDIA_DIR, rel)
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ size = get_image_size(data)
+ if size:
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
+ (size[0], size[1], row["id"])
+ )
+ updated += 1
+ except Exception:
+ pass
+ logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
+ _backfill_image_sizes()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
@@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
@@ -198,6 +232,7 @@ 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
+from routes.ernaehrung import router as ernaehrung_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
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"])
+app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
# ------------------------------------------------------------------
@@ -285,6 +321,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
+@app.get("/stats/script.js")
+async def umami_script_proxy():
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.get("https://umami.motocamp.de/script.js")
+ return Response(content=r.content, media_type="application/javascript",
+ headers={"Cache-Control": "public, max-age=86400"})
+
+@app.post("/stats/api/send")
+async def umami_send_proxy(request: Request):
+ body = await request.body()
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.post(
+ "https://umami.motocamp.de/api/send",
+ content=body,
+ headers={"Content-Type": "application/json",
+ "User-Agent": request.headers.get("user-agent", "")},
+ )
+ return Response(content=r.content, status_code=r.status_code,
+ media_type="application/json")
+
+
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py
new file mode 100644
index 0000000..c1f850e
--- /dev/null
+++ b/backend/routes/ernaehrung.py
@@ -0,0 +1,145 @@
+"""BAN YARO — Ernährungs-Routes"""
+
+import logging
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+import ki as ki_module
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class FutterProfilUpdate(BaseModel):
+ futter_typ: Optional[str] = None # trocken|nass|barf|mix
+ marke: Optional[str] = None
+ kcal_tag: Optional[int] = None
+ portionen: Optional[int] = None
+ notizen: Optional[str] = None
+
+
+class KiBeratungRequest(BaseModel):
+ frage: str
+ dog_name: Optional[str] = None
+ rasse: Optional[str] = None
+ alter: Optional[str] = None
+ gewicht: Optional[float] = None
+ aktiv: Optional[bool] = None
+
+
+# ------------------------------------------------------------------
+# Hilfsfunktion: Zugriffsprüfung
+# ------------------------------------------------------------------
+def _check_dog_access(conn, dog_id: int, user_id: int):
+ row = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+
+# ------------------------------------------------------------------
+# GET /dogs/{dog_id}/ernaehrung
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/ernaehrung")
+async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ row = conn.execute(
+ "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ if not row:
+ return {}
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# PUT /dogs/{dog_id}/ernaehrung
+# ------------------------------------------------------------------
+@router.put("/{dog_id}/ernaehrung")
+async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ existing = conn.execute(
+ "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ if existing:
+ conn.execute("""
+ UPDATE futter_profil
+ SET futter_typ=COALESCE(?, futter_typ),
+ marke=COALESCE(?, marke),
+ kcal_tag=COALESCE(?, kcal_tag),
+ portionen=COALESCE(?, portionen),
+ notizen=COALESCE(?, notizen),
+ updated_at=datetime('now')
+ WHERE dog_id=?
+ """, (body.futter_typ, body.marke, body.kcal_tag,
+ body.portionen, body.notizen, dog_id))
+ else:
+ conn.execute("""
+ INSERT INTO futter_profil
+ (dog_id, futter_typ, marke, kcal_tag, portionen, notizen)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (dog_id, body.futter_typ, body.marke, body.kcal_tag,
+ body.portionen or 2, body.notizen))
+ row = conn.execute(
+ "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# POST /dogs/{dog_id}/ernaehrung/ki-beratung
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/ernaehrung/ki-beratung")
+async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
+ request: Request,
+ user=Depends(get_current_user)):
+ if not body.frage or len(body.frage.strip()) < 3:
+ raise HTTPException(400, "Bitte stelle eine Frage.")
+ if len(body.frage) > 800:
+ raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).")
+
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+
+ dog_name = body.dog_name or "unbekannt"
+ rasse = body.rasse or "unbekannt"
+ alter = body.alter or "unbekannt"
+ gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt"
+ aktiv_str = "aktiv" if body.aktiv else "normal aktiv"
+
+ system = (
+ "Du bist Ernährungsberater für Hunde. "
+ "Antworte immer auf Deutsch, kurz und praktisch. "
+ "Keine unnötigen Füllsätze. "
+ "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. "
+ "Stelle keine medizinischen Diagnosen."
+ )
+
+ prompt = (
+ f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, "
+ f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n"
+ f"Frage: {body.frage.strip()}\n\n"
+ "Antworte konkret und praktisch, maximal 200 Wörter."
+ )
+
+ try:
+ antwort = await ki_module.complete(
+ prompt=prompt,
+ system=system,
+ max_tokens=500,
+ requires_premium=False,
+ user_id=user["id"],
+ )
+ return {"antwort": antwort}
+ except ki_module.KIUnavailableError as e:
+ raise HTTPException(503, str(e))
+ except Exception:
+ raise HTTPException(500, "KI momentan nicht verfügbar.")
diff --git a/backend/static/index.html b/backend/static/index.html
index 837f2eb..37d6fcd 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -93,9 +93,9 @@
-
-
-
+
+
+
@@ -499,6 +499,14 @@
+
+
+
+
@@ -562,7 +570,7 @@
-
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 4b5071c..3ef54e9 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '695'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '698'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@@ -76,6 +76,8 @@ const App = (() => {
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
wetter: { title: 'Wetter', module: null },
+ ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
+ personality: { title: 'Persönlichkeitstest', module: null },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/ernaehrung.js b/backend/static/js/pages/ernaehrung.js
new file mode 100644
index 0000000..ec1951f
--- /dev/null
+++ b/backend/static/js/pages/ernaehrung.js
@@ -0,0 +1,603 @@
+/* ============================================================
+ BAN YARO — Ernährung
+ Tabs: Kalorien-Rechner | Futter-Guide | Giftliste | KI-Berater
+ ============================================================ */
+
+window.Page_ernaehrung = (() => {
+
+ let _container = null;
+ let _appState = null;
+ let _activeTab = 'rechner';
+ let _profil = {};
+
+ const TABS = [
+ { key: 'rechner', label: 'Kalorien', icon: ' ' },
+ { key: 'guide', label: 'Futter-Guide', icon: ' ' },
+ { key: 'gift', label: 'Giftliste', icon: ' ' },
+ { key: 'ki', label: 'KI-Berater', icon: ' ' },
+ ];
+
+ // ------------------------------------------------------------------
+ // Escape helper
+ // ------------------------------------------------------------------
+ function _esc(s) {
+ if (s == null) return '';
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ // ------------------------------------------------------------------
+ // LIFECYCLE
+ // ------------------------------------------------------------------
+ async function init(container, appState, params) {
+ _container = container;
+ _appState = appState;
+ if (params?.tab && TABS.some(t => t.key === params.tab)) {
+ _activeTab = params.tab;
+ }
+ await _render();
+ }
+
+ async function refresh() {
+ await _render();
+ }
+
+ async function onDogChange() {
+ _profil = {};
+ await _render();
+ }
+
+ // ------------------------------------------------------------------
+ // RENDER
+ // ------------------------------------------------------------------
+ async function _render() {
+ if (!_appState.activeDog) {
+ _container.innerHTML = UI.emptyState({
+ icon: ' ',
+ title: 'Noch kein Hund angelegt',
+ text: 'Erstelle zuerst ein Hundeprofil.',
+ action: `Profil erstellen `,
+ });
+ return;
+ }
+
+ // Profil laden
+ const dog = _appState.activeDog;
+ try {
+ _profil = await API.get(`/dogs/${dog.id}/ernaehrung`);
+ } catch (_) {
+ _profil = {};
+ }
+
+ _container.innerHTML = `
+
+
+ `;
+
+ _renderTabBar();
+ _renderTab();
+ }
+
+ // ------------------------------------------------------------------
+ // TAB-BAR
+ // ------------------------------------------------------------------
+ function _renderTabBar() {
+ const el = _container.querySelector('#ern-tabs');
+ if (!el) return;
+ el.innerHTML = TABS.map(t => `
+
+ ${t.icon} ${t.label}
+ `).join('');
+ el.querySelectorAll('.by-tab').forEach(btn => {
+ btn.addEventListener('click', () => {
+ _activeTab = btn.dataset.tab;
+ el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ _renderTab();
+ });
+ });
+ }
+
+ function _renderTab() {
+ const el = _container.querySelector('#ern-tab-content');
+ if (!el) return;
+ switch (_activeTab) {
+ case 'rechner': _renderRechner(el); break;
+ case 'guide': _renderGuide(el); break;
+ case 'gift': _renderGift(el); break;
+ case 'ki': _renderKi(el); break;
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // TAB 1: KALORIEN-RECHNER
+ // ------------------------------------------------------------------
+ function _renderRechner(el) {
+ const dog = _appState.activeDog;
+
+ // Auto-Werte aus Hundeprofil
+ const gewichtDefault = dog?.gewicht || '';
+ const alterDefault = dog?.alter || '';
+
+ el.innerHTML = `
+
+
+ Berechne den täglichen Kalorienbedarf deines Hundes.
+
+
+
+ Gewicht (kg)
+
+
+
+
+ Alter (Jahre)
+
+
+
+
+ Aktivität
+
+ Gering (Couch-Hund)
+ Normal
+ Aktiv
+ Sehr aktiv (Sport)
+
+
+
+
+
+
+
+ Berechnen
+
+
+
+
+
+
+
Profil speichern
+
+
+ Futter-Typ
+
+ -- wählen --
+ Trockenfutter
+ Nassfutter
+ BARF
+ Mix
+
+
+
+ Marke / Produkt
+
+
+
+ Portionen pro Tag
+
+
+
+ Notizen
+
+
+
+
+ Profil speichern
+
+
+
+
+ `;
+
+ el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
+ }
+
+ function _berechne(el) {
+ const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
+ const aktivitaet = el.querySelector('#ern-aktivitaet').value;
+ const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja';
+
+ if (!gewicht || gewicht < 0.5) {
+ UI.toast.warning('Bitte ein gültiges Gewicht eingeben.');
+ return;
+ }
+
+ const rer = 70 * Math.pow(gewicht, 0.75);
+ const faktoren = {
+ gering: { intakt: 1.2, kastriert: 1.0 },
+ normal: { intakt: 1.6, kastriert: 1.4 },
+ aktiv: { intakt: 1.8, kastriert: 1.6 },
+ sport: { intakt: 2.1, kastriert: 1.9 },
+ };
+ const kcal = Math.round(rer * faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt']);
+
+ // Umrechnung in Futtermengen
+ const trocken = Math.round(kcal / 3.5); // ~350 kcal/100g
+ const nass = Math.round(kcal / 0.85); // ~85 kcal/100g
+ const barf = Math.round(kcal / 1.5); // ~150 kcal/100g
+
+ const kcalFormatted = kcal.toLocaleString('de-DE');
+
+ const resultEl = el.querySelector('#ern-rechner-result');
+ resultEl.style.display = '';
+ resultEl.innerHTML = `
+
+
ca. ${kcalFormatted} kcal
+
pro Tag
+
+
+
+
+
🌾 Trockenfutter
+
+ (~350 kcal/100g)
+
+
+ ${trocken} g / Tag
+
+
+ = ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
+
+
+
+
+
🥫 Nassfutter
+
+ (~85 kcal/100g)
+
+
+ ${nass} g / Tag
+
+
+ = ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
+
+
+
+
+
🥩 BARF
+
+ (~150 kcal/100g)
+
+
+ ${barf} g / Tag
+
+
+ = ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
+
+
+
+
+
+ Richtwert nach Nationaler Forschungsratsformel (NRC). Immer den Körperzustand beobachten.
+
+ `;
+
+ // Profil-Speichern einblenden und kcal vorbelegen
+ const profilSection = el.querySelector('#ern-profil-speichern');
+ profilSection.style.display = '';
+
+ // kcal für Speichern merken
+ profilSection.dataset.kcal = kcal;
+
+ el.querySelector('#ern-prof-save-btn').onclick = () => _speichereProfil(el, kcal);
+ }
+
+ async function _speichereProfil(el, kcal) {
+ const dog = _appState.activeDog;
+ const futter_typ = el.querySelector('#ern-prof-typ').value || null;
+ const marke = el.querySelector('#ern-prof-marke').value.trim() || null;
+ const portionen = parseInt(el.querySelector('#ern-prof-portionen').value) || 2;
+ const notizen = el.querySelector('#ern-prof-notizen').value.trim() || null;
+
+ const btn = el.querySelector('#ern-prof-save-btn');
+ await UI.asyncButton(btn, async () => {
+ try {
+ _profil = await API.put(`/dogs/${dog.id}/ernaehrung`, {
+ futter_typ, marke, kcal_tag: kcal, portionen, notizen,
+ });
+ UI.toast.success('Profil gespeichert.');
+ } catch (err) {
+ UI.toast.error(err.message || 'Fehler beim Speichern.');
+ }
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // TAB 2: FUTTER-GUIDE
+ // ------------------------------------------------------------------
+ function _renderGuide(el) {
+ const cards = [
+ {
+ id: 'barf',
+ emoji: '🥩',
+ titel: 'BARF (Rohfütterung)',
+ inhalt: `
+ Zusammensetzung: 70 % Muskelfleisch, 10 % rohe Knochen, 10 % Organe, 10 % Gemüse & Obst
+ Vorteile: Naturnahste Ernährungsform, glänzendes Fell, weniger Kot, keine Zusatzstoffe
+ Risiken: Keimbelastung durch rohes Fleisch, Calcium-Phosphor-Balance muss stimmen, zeitaufwändig und teurer
+ Tipp: Niemals BARF und Trockenfutter in derselben Mahlzeit mischen — unterschiedliche Verdauungszeiten können zu Problemen führen.
+ `,
+ },
+ {
+ id: 'nass',
+ emoji: '🥫',
+ titel: 'Nassfutter',
+ inhalt: `
+ Zusammensetzung: 70–80 % Wasseranteil, meist höherer Fleischanteil als Trockenfutter
+ Vorteile: Hunde trinken automatisch mehr (gut für die Niere), schmackhafter, gut für wählerische Hunde
+ Worauf achten: Erste Zutat auf der Liste = Fleisch (nicht „Tierische Nebenerzeugnisse"), kein Zucker, kein Karamell
+ Zähne: Schlechter für die Zahngesundheit als Trockenfutter — öfter Zähne putzen oder Kauartikel geben.
+ `,
+ },
+ {
+ id: 'trocken',
+ emoji: '🌾',
+ titel: 'Trockenfutter',
+ inhalt: `
+ Zusammensetzung: 6–10 % Wasser, ca. 350–400 kcal/100 g, konzentrierte Nährstoffe
+ Gute Zutaten: Benanntes Fleisch an erster Stelle (Huhn, Lachs), mind. 40 % Tierprotein, kein Getreide als Hauptzutat
+ Schlechte Zutaten: „Getreide" als erste Zutat, Zucker, Karamell, Konservierungsstoffe E320 / E321
+ Wichtig: Immer frisches Wasser bereitstellen — Trockenfutter enthält kaum Feuchtigkeit.
+ `,
+ },
+ ];
+
+ el.innerHTML = `
+
+
+ Klicke auf eine Karte für Details.
+
+ ${cards.map(c => `
+
+
+
+ ${c.emoji} ${c.titel}
+
+
+
+
+
+
+ ${c.inhalt}
+
+
+ `).join('')}
+
+ `;
+
+ el.querySelectorAll('.ern-guide-card').forEach(card => {
+ card.querySelector('.ern-guide-head').addEventListener('click', () => {
+ const body = card.querySelector('.ern-guide-body');
+ const chevron = card.querySelector('.ern-guide-chevron');
+ const open = body.style.display !== 'none';
+ body.style.display = open ? 'none' : '';
+ chevron.style.transform = open ? '' : 'rotate(180deg)';
+ });
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // TAB 3: GIFTLISTE
+ // ------------------------------------------------------------------
+ function _renderGift(el) {
+ const items = [
+ { emoji: '🍫', name: 'Schokolade', grund: 'Theobromin → Herzrasen, Krämpfe, kann tödlich sein' },
+ { emoji: '🍇', name: 'Trauben & Rosinen', grund: 'Nierenversagen — auch kleinste Mengen gefährlich' },
+ { emoji: '🧅', name: 'Zwiebeln & Knoblauch', grund: 'Zerstören rote Blutkörperchen → Anämie' },
+ { emoji: '🥑', name: 'Avocado', grund: 'Persin → Erbrechen, Durchfall, Atemnot' },
+ { emoji: '🌰', name: 'Macadamia-Nüsse', grund: 'Lähmungserscheinungen, Zittern, Erbrechen' },
+ { emoji: '🍬', name: 'Xylitol (Süßstoff)', grund: 'Schwere Leberschäden, Unterzucker — oft in Kaugummi' },
+ { emoji: '🥛', name: 'Milch & Milchprodukte', grund: 'Laktose-Intoleranz bei vielen Hunden → Durchfall' },
+ { emoji: '🦴', name: 'Gekochte Knochen', grund: 'Splitter → innere Verletzungen, Darmverschluss' },
+ { emoji: '☕', name: 'Koffein (Kaffee, Tee)', grund: 'Herzrasen, Zittern, Nervensystem' },
+ { emoji: '🧂', name: 'Salz', grund: 'Natriumvergiftung → Erbrechen, Krämpfe' },
+ ];
+
+ el.innerHTML = `
+
+
+ ⚠️ Notfall-Tierarzt: Bei Verdacht auf Vergiftung sofort zum Tierarzt.
+ Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
+
+
+
+ ${items.map(item => `
+
+
+
${item.emoji}
+
+
${_esc(item.name)}
+
${_esc(item.grund)}
+
+
+
+ `).join('')}
+
+
+
+ Diese Liste ist nicht vollständig. Im Zweifel gilt: lieber weglassen.
+
+
+ `;
+ }
+
+ // ------------------------------------------------------------------
+ // TAB 4: KI-FUTTERBERATER
+ // ------------------------------------------------------------------
+ function _renderKi(el) {
+ const dog = _appState.activeDog;
+
+ el.innerHTML = `
+
+
+
+ Der KI-Futterberater beantwortet Ernährungsfragen für
+ ${_esc(dog?.name || 'deinen Hund')} .
+ Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
+
+
+
+
+ ${[
+ 'Welches Futter empfiehlst du für meine Rasse?',
+ 'Wie oft soll ich meinen Hund füttern?',
+ 'Ist Getreide im Futter schlecht?',
+ 'Welche Leckerlis sind gesund?',
+ ].map(q => `
+ ${_esc(q)}
+ `).join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Vorschläge
+ el.querySelectorAll('.ern-ki-vorschlag').forEach(btn => {
+ btn.addEventListener('click', () => {
+ el.querySelector('#ern-ki-frage').value = btn.dataset.q;
+ el.querySelector('#ern-ki-frage').focus();
+ });
+ });
+
+ // Senden
+ el.querySelector('#ern-ki-send-btn').addEventListener('click', () => _kiSenden(el));
+ el.querySelector('#ern-ki-frage').addEventListener('keydown', e => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) _kiSenden(el);
+ });
+ }
+
+ async function _kiSenden(el) {
+ const dog = _appState.activeDog;
+ const frageEl = el.querySelector('#ern-ki-frage');
+ const frage = frageEl.value.trim();
+ if (!frage) {
+ UI.toast.warning('Bitte eine Frage eingeben.');
+ return;
+ }
+
+ const chatEl = el.querySelector('#ern-ki-chat');
+ const sendBtn = el.querySelector('#ern-ki-send-btn');
+
+ // Userfrage anzeigen
+ chatEl.insertAdjacentHTML('beforeend', `
+
+ `);
+ frageEl.value = '';
+
+ // KI-Antwort Placeholder
+ const placeholderId = `ern-ki-placeholder-${Date.now()}`;
+ chatEl.insertAdjacentHTML('beforeend', `
+
+ `);
+ chatEl.scrollTop = chatEl.scrollHeight;
+
+ await UI.asyncButton(sendBtn, async () => {
+ let antwort = '';
+ try {
+ const result = await API.post(`/dogs/${dog.id}/ernaehrung/ki-beratung`, {
+ frage,
+ dog_name: dog?.name || null,
+ rasse: dog?.rasse || null,
+ alter: dog?.alter != null ? String(dog.alter) : null,
+ gewicht: dog?.gewicht || null,
+ aktiv: false,
+ });
+ antwort = result.antwort || 'Keine Antwort erhalten.';
+ } catch (err) {
+ if (err.status === 503) {
+ antwort = 'Die KI ist momentan nicht verfügbar. Bitte später versuchen.';
+ } else {
+ antwort = 'Fehler bei der KI-Anfrage. Bitte später erneut versuchen.';
+ }
+ }
+
+ const antwortHtml = _esc(antwort)
+ .replace(/\n\n/g, '
')
+ .replace(/\n/g, ' ');
+
+ const placeholder = document.getElementById(placeholderId);
+ if (placeholder) {
+ placeholder.innerHTML = `
+
+ `;
+ }
+ chatEl.scrollTop = chatEl.scrollHeight;
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // PUBLIC API
+ // ------------------------------------------------------------------
+ return { init, refresh, onDogChange };
+
+})();
diff --git a/backend/static/sw.js b/backend/static/sw.js
index 28edf7a..dc23c20 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
-const CACHE_VERSION = 'by-v695';
+const CACHE_VERSION = 'by-v698';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache