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: ``, + }); + 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 => ` + `).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. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + + + + +
+ `; + + 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} + + +
+ +
+ `).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 => ` + + `).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', ` +
+
+ ${_esc(frage)} +
+
+ `); + frageEl.value = ''; + + // KI-Antwort Placeholder + const placeholderId = `ern-ki-placeholder-${Date.now()}`; + chatEl.insertAdjacentHTML('beforeend', ` +
+
+ + Denke nach… +
+
+ `); + 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 = ` +

+

${antwortHtml}

+
+ `; + } + 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