Feature: Hundeernährungs-Feature — Kalorien-Rechner, Futter-Guide, Giftliste, KI-Berater (SW by-v698)

This commit is contained in:
rene 2026-05-04 20:51:45 +02:00
parent b1d9fb4f54
commit 6e4bf25581
7 changed files with 838 additions and 8 deletions

View file

@ -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 (

View file

@ -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")

View file

@ -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.")

View file

@ -93,9 +93,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=694">
<link rel="stylesheet" href="/css/layout.css?v=694">
<link rel="stylesheet" href="/css/components.css?v=694">
<link rel="stylesheet" href="/css/design-system.css?v=696">
<link rel="stylesheet" href="/css/layout.css?v=696">
<link rel="stylesheet" href="/css/components.css?v=696">
</head>
<body>
@ -499,6 +499,14 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-ernaehrung">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-personality">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
@ -562,7 +570,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=694"></script>
<script src="/js/worlds.js?v=696"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -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 },
};
// ----------------------------------------------------------

View file

@ -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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>' },
{ key: 'guide', label: 'Futter-Guide', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
{ key: 'gift', label: 'Giftliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>' },
{ key: 'ki', label: 'KI-Berater', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>' },
];
// ------------------------------------------------------------------
// Escape helper
// ------------------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ------------------------------------------------------------------
// 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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bowl-food"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}
// Profil laden
const dog = _appState.activeDog;
try {
_profil = await API.get(`/dogs/${dog.id}/ernaehrung`);
} catch (_) {
_profil = {};
}
_container.innerHTML = `
<div class="by-tabs" id="ern-tabs"></div>
<div id="ern-tab-content"></div>
`;
_renderTabBar();
_renderTab();
}
// ------------------------------------------------------------------
// TAB-BAR
// ------------------------------------------------------------------
function _renderTabBar() {
const el = _container.querySelector('#ern-tabs');
if (!el) return;
el.innerHTML = TABS.map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>`).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 = `
<div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Berechne den täglichen Kalorienbedarf deines Hundes.
</p>
<div class="by-form-group">
<label class="by-label">Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
class="by-input" value="${_esc(gewichtDefault)}" placeholder="z. B. 15">
</div>
<div class="by-form-group">
<label class="by-label">Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
class="by-input" value="${_esc(alterDefault)}" placeholder="z. B. 3">
</div>
<div class="by-form-group">
<label class="by-label">Aktivität</label>
<select id="ern-aktivitaet" class="by-select">
<option value="gering">Gering (Couch-Hund)</option>
<option value="normal" selected>Normal</option>
<option value="aktiv">Aktiv</option>
<option value="sport">Sehr aktiv (Sport)</option>
</select>
</div>
<div class="by-form-group">
<label class="by-label">Kastriert</label>
<div style="display:flex;gap:var(--space-3)">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="ja"> Ja
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="nein" checked> Nein
</label>
</div>
</div>
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>
Berechnen
</button>
<div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div>
<!-- Profil speichern -->
<div id="ern-profil-speichern" style="display:none;margin-top:var(--space-4)">
<h4 style="font-size:var(--text-sm);margin-bottom:var(--space-2)">Profil speichern</h4>
<div style="display:grid;gap:var(--space-3)">
<div class="by-form-group" style="margin:0">
<label class="by-label">Futter-Typ</label>
<select id="ern-prof-typ" class="by-select">
<option value="">-- wählen --</option>
<option value="trocken"${_profil.futter_typ === 'trocken' ? ' selected' : ''}>Trockenfutter</option>
<option value="nass"${_profil.futter_typ === 'nass' ? ' selected' : ''}>Nassfutter</option>
<option value="barf"${_profil.futter_typ === 'barf' ? ' selected' : ''}>BARF</option>
<option value="mix"${_profil.futter_typ === 'mix' ? ' selected' : ''}>Mix</option>
</select>
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Marke / Produkt</label>
<input id="ern-prof-marke" type="text" class="by-input"
value="${_esc(_profil.marke)}" placeholder="z. B. Royal Canin">
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Portionen pro Tag</label>
<input id="ern-prof-portionen" type="number" min="1" max="6"
class="by-input" value="${_profil.portionen || 2}">
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Notizen</label>
<textarea id="ern-prof-notizen" class="by-input" rows="2"
placeholder="Besonderheiten, Allergien...">${_esc(_profil.notizen)}</textarea>
</div>
<button class="btn btn-secondary" id="ern-prof-save-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
Profil speichern
</button>
</div>
</div>
</div>
`;
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 = `
<div style="text-align:center;padding:var(--space-4);
background:var(--c-primary);color:#fff;
border-radius:var(--radius-lg);margin-bottom:var(--space-4)">
<div style="font-size:var(--text-2xl);font-weight:700">ca. ${kcalFormatted} kcal</div>
<div style="font-size:var(--text-sm);opacity:0.85">pro Tag</div>
</div>
<div style="display:grid;gap:var(--space-3)">
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🌾 Trockenfutter</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~350 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${trocken} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
</div>
</div>
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥫 Nassfutter</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~85 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${nass} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
</div>
</div>
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥩 BARF</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~150 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${barf} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
</div>
</div>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
Richtwert nach Nationaler Forschungsratsformel (NRC). Immer den Körperzustand beobachten.
</p>
`;
// 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: `
<p><strong>Zusammensetzung:</strong> 70 % Muskelfleisch, 10 % rohe Knochen, 10 % Organe, 10 % Gemüse & Obst</p>
<p><strong>Vorteile:</strong> Naturnahste Ernährungsform, glänzendes Fell, weniger Kot, keine Zusatzstoffe</p>
<p><strong>Risiken:</strong> Keimbelastung durch rohes Fleisch, Calcium-Phosphor-Balance muss stimmen, zeitaufwändig und teurer</p>
<p><strong>Tipp:</strong> Niemals BARF und Trockenfutter in derselben Mahlzeit mischen unterschiedliche Verdauungszeiten können zu Problemen führen.</p>
`,
},
{
id: 'nass',
emoji: '🥫',
titel: 'Nassfutter',
inhalt: `
<p><strong>Zusammensetzung:</strong> 7080 % Wasseranteil, meist höherer Fleischanteil als Trockenfutter</p>
<p><strong>Vorteile:</strong> Hunde trinken automatisch mehr (gut für die Niere), schmackhafter, gut für wählerische Hunde</p>
<p><strong>Worauf achten:</strong> Erste Zutat auf der Liste = Fleisch (nicht Tierische Nebenerzeugnisse"), kein Zucker, kein Karamell</p>
<p><strong>Zähne:</strong> Schlechter für die Zahngesundheit als Trockenfutter öfter Zähne putzen oder Kauartikel geben.</p>
`,
},
{
id: 'trocken',
emoji: '🌾',
titel: 'Trockenfutter',
inhalt: `
<p><strong>Zusammensetzung:</strong> 610 % Wasser, ca. 350400 kcal/100 g, konzentrierte Nährstoffe</p>
<p><strong>Gute Zutaten:</strong> Benanntes Fleisch an erster Stelle (Huhn, Lachs), mind. 40 % Tierprotein, kein Getreide als Hauptzutat</p>
<p><strong>Schlechte Zutaten:</strong> „Getreide" als erste Zutat, Zucker, Karamell, Konservierungsstoffe E320 / E321</p>
<p><strong>Wichtig:</strong> Immer frisches Wasser bereitstellen Trockenfutter enthält kaum Feuchtigkeit.</p>
`,
},
];
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Klicke auf eine Karte für Details.
</p>
${cards.map(c => `
<div class="ern-guide-card" data-id="${c.id}"
style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);margin-bottom:var(--space-3);
overflow:hidden;cursor:pointer">
<div class="ern-guide-head"
style="display:flex;align-items:center;justify-content:space-between;
padding:var(--space-3) var(--space-4)">
<span style="font-weight:600;font-size:var(--text-base)">
${c.emoji} ${c.titel}
</span>
<svg class="ph-icon ern-guide-chevron" aria-hidden="true"
style="transition:transform .2s">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</div>
<div class="ern-guide-body"
style="display:none;padding:0 var(--space-4) var(--space-3);
font-size:var(--text-sm);color:var(--c-text-secondary);
line-height:1.6">
${c.inhalt}
</div>
</div>
`).join('')}
</div>
`;
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 = `
<div style="padding:var(--space-4) 0">
<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-4);
font-size:var(--text-sm)">
<strong> Notfall-Tierarzt:</strong> Bei Verdacht auf Vergiftung sofort zum Tierarzt.
Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
</div>
<div style="display:grid;gap:var(--space-2)">
${items.map(item => `
<div style="background:#fff5f5;border:1px solid #fed7d7;
border-radius:var(--radius-md);padding:var(--space-3)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:1.4rem">${item.emoji}</span>
<div>
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.name)}</div>
<div style="font-size:var(--text-xs);color:#c53030">${_esc(item.grund)}</div>
</div>
</div>
</div>
`).join('')}
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-4)">
Diese Liste ist nicht vollständig. Im Zweifel gilt: lieber weglassen.
</p>
</div>
`;
}
// ------------------------------------------------------------------
// TAB 4: KI-FUTTERBERATER
// ------------------------------------------------------------------
function _renderKi(el) {
const dog = _appState.activeDog;
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<div style="background:var(--c-surface-2,var(--c-surface));border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-4);
font-size:var(--text-sm);color:var(--c-text-secondary);
border:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
Der KI-Futterberater beantwortet Ernährungsfragen für
<strong>${_esc(dog?.name || 'deinen Hund')}</strong>.
Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
</div>
<!-- Vorschläge -->
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
${[
'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 => `
<button class="btn btn-sm btn-secondary ern-ki-vorschlag"
data-q="${_esc(q)}"
style="font-size:var(--text-xs)">${_esc(q)}</button>
`).join('')}
</div>
<!-- Chat-Verlauf -->
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
<!-- Eingabe -->
<div style="display:flex;gap:var(--space-2)">
<textarea id="ern-ki-frage" class="by-input" rows="2"
placeholder="Deine Frage zur Ernährung..."
style="flex:1;resize:vertical"></textarea>
<button class="btn btn-primary" id="ern-ki-send-btn"
style="align-self:flex-end">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
</button>
</div>
</div>
`;
// 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', `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-2)">
<div style="background:var(--c-primary);color:#fff;border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);max-width:80%;font-size:var(--text-sm)">
${_esc(frage)}
</div>
</div>
`);
frageEl.value = '';
// KI-Antwort Placeholder
const placeholderId = `ern-ki-placeholder-${Date.now()}`;
chatEl.insertAdjacentHTML('beforeend', `
<div id="${placeholderId}" style="margin-bottom:var(--space-3)">
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);color:var(--c-text-muted)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#circle-notch"></use></svg>
Denke nach
</div>
</div>
`);
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, '</p><p style="margin:var(--space-1) 0">')
.replace(/\n/g, '<br>');
const placeholder = document.getElementById(placeholderId);
if (placeholder) {
placeholder.innerHTML = `
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);line-height:1.6;max-width:90%">
<p style="margin:0">${antwortHtml}</p>
</div>
`;
}
chatEl.scrollTop = chatEl.scrollHeight;
});
}
// ------------------------------------------------------------------
// PUBLIC API
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -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