diff --git a/backend/main.py b/backend/main.py index 25c9ce3..dc86828 100644 --- a/backend/main.py +++ b/backend/main.py @@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR): else: app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "856" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "872" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 6beeaaf..e2985dd 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -41,7 +41,7 @@ class DogUpdate(BaseModel): async def list_dogs(user=Depends(get_current_user)): with db() as conn: own = conn.execute( - "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? ORDER BY id", + "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? AND (verstorben_am IS NULL) ORDER BY id", (user["id"],) ).fetchall() shared = conn.execute( @@ -255,15 +255,16 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): day_num = (_dt.date.today() - _dt.date(2024, 1, 1)).days # Versuche JOIN (funktioniert wenn js_exercise_id-Spalte vorhanden) + # Nur Übungen des aktiven Hundes, 'sitzt' ausschließen try: joined = conn.execute( """SELECT ep.exercise_id, te.name, te.kategorie AS kategorie_raw, te.schwierigkeit, te.js_exercise_id FROM exercise_progress ep JOIN training_exercises te ON te.js_exercise_id = ep.exercise_id - WHERE ep.user_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens') + WHERE ep.dog_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens') ORDER BY ep.updated_at ASC LIMIT 50""", - (user["id"],) + (dog_id,) ).fetchall() except Exception: joined = [] @@ -288,9 +289,9 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): ) raw = conn.execute( """SELECT exercise_id FROM exercise_progress - WHERE user_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens') + WHERE dog_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens') ORDER BY updated_at ASC LIMIT 50""", - (user["id"],) + (dog_id,) ).fetchall() valid = [r["exercise_id"] for r in raw if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)] @@ -779,6 +780,21 @@ async def get_hunde_buch( return HTMLResponse(content=html_page) +# ------------------------------------------------------------------ +# GET /api/dogs/verstorben — Alle verstorbenen Hunde des Users +# ------------------------------------------------------------------ +@router.get("/verstorben") +async def get_verstorbene_hunde(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute( + """SELECT id, name, rasse, foto_url, verstorben_am, geburtstag + FROM dogs WHERE user_id=? AND verstorben_am IS NOT NULL + ORDER BY verstorben_am DESC""", + (user["id"],) + ).fetchall() + return [dict(r) for r in rows] + + @router.get("/{dog_id}") async def get_dog(dog_id: int, user=Depends(get_current_user)): with db() as conn: @@ -1208,14 +1224,15 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)): "ref_id": r["id"], }) - # --- Routen --- + # --- Routen (nur Routen wo dieser Hund mitgegangen ist) --- route_rows = conn.execute( - """SELECT id, name, distanz_km, - date(created_at) AS datum - FROM routes - WHERE user_id=? - ORDER BY created_at ASC""", - (user["id"],) + """SELECT r.id, r.name, r.distanz_km, + date(r.created_at) AS datum + FROM routes r + JOIN route_dogs rd ON rd.route_id = r.id AND rd.dog_id = ? + WHERE r.user_id = ? + ORDER BY r.created_at ASC""", + (dog_id, user["id"]) ).fetchall() route_first = True @@ -1263,3 +1280,108 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)): "geburtstag": dog["geburtstag"], "events": events, } + + +# ------------------------------------------------------------------ +# POST /api/dogs/{id}/gedenken — Hund als verstorben markieren +# ------------------------------------------------------------------ +class GedenkenData(BaseModel): + verstorben_am: str # YYYY-MM-DD + +@router.post("/{dog_id}/gedenken") +async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)): + with db() as conn: + updated = conn.execute( + "UPDATE dogs SET verstorben_am=? WHERE id=? AND user_id=?", + (data.verstorben_am, dog_id, user["id"]) + ).rowcount + if not updated: + raise HTTPException(404, "Hund nicht gefunden.") + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/dogs/{id}/gedenkseite — Memorial-Daten +# ------------------------------------------------------------------ +@router.get("/{dog_id}/gedenkseite") +async def get_gedenkseite(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + dog = conn.execute( + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404) + dog = dict(dog) + + # Statistiken + km_total = conn.execute( + "SELECT COALESCE(ROUND(SUM(distanz_km),1),0) AS km FROM routes r " + "JOIN route_dogs rd ON rd.route_id=r.id WHERE rd.dog_id=?", (dog_id,) + ).fetchone()["km"] + + diary_count = conn.execute( + "SELECT COUNT(*) FROM diary WHERE dog_id=?", (dog_id,) + ).fetchone()[0] + + media_count = conn.execute( + "SELECT COUNT(*) FROM diary_media dm JOIN diary d ON d.id=dm.diary_id " + "WHERE d.dog_id=? AND dm.media_type='image'", (dog_id,) + ).fetchone()[0] + + training_count = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE dog_id=?", (dog_id,) + ).fetchone()[0] + + # Letzter Tagebucheintrag + last_entry = conn.execute( + "SELECT titel, datum FROM diary WHERE dog_id=? ORDER BY datum DESC LIMIT 1", + (dog_id,) + ).fetchone() + + # Erste und letzte Aufnahme + first_entry = conn.execute( + "SELECT datum FROM diary WHERE dog_id=? ORDER BY datum ASC LIMIT 1", + (dog_id,) + ).fetchone() + + # Letzte 6 Fotos für Galerie + photos = conn.execute( + """SELECT dm.url FROM diary_media dm + JOIN diary d ON d.id=dm.diary_id + WHERE d.dog_id=? AND dm.media_type='image' + AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height + ORDER BY d.datum DESC, dm.id DESC LIMIT 6""", + (dog_id,) + ).fetchall() + if not photos: + photos = conn.execute( + """SELECT dm.url FROM diary_media dm + JOIN diary d ON d.id=dm.diary_id + WHERE d.dog_id=? AND dm.media_type='image' + ORDER BY d.datum DESC, dm.id DESC LIMIT 6""", + (dog_id,) + ).fetchall() + + # Gemeinsame Zeit berechnen + joined = dog.get("geburtstag") or (first_entry["datum"] if first_entry else None) + passed = dog.get("verstorben_am") + gemeinsam_tage = None + if joined and passed: + try: + from datetime import date as _date + d1 = _date.fromisoformat(joined) + d2 = _date.fromisoformat(passed) + gemeinsam_tage = (d2 - d1).days + except Exception: + pass + + return { + "dog": dog, + "km_total": km_total, + "diary_count": diary_count, + "media_count": media_count, + "training_count": training_count, + "last_entry": dict(last_entry) if last_entry else None, + "gemeinsam_tage": gemeinsam_tage, + "photos": [r["url"] for r in photos], + } diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py index c1f850e..2aa4760 100644 --- a/backend/routes/ernaehrung.py +++ b/backend/routes/ernaehrung.py @@ -143,3 +143,305 @@ async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest, raise HTTPException(503, str(e)) except Exception: raise HTTPException(500, "KI momentan nicht verfügbar.") + + +# ================================================================== +# FUTTER-VERTRÄGLICHKEIT +# ================================================================== + +REAKTION_TYPEN = { + # Positiv + "verdauung_gut": {"label": "Gute Verdauung", "kategorie": "positiv", "fenster_h": 8}, + "energie_hoch": {"label": "Viel Energie", "kategorie": "positiv", "fenster_h": 12}, + "fell_glaenzend": {"label": "Glänzendes Fell", "kategorie": "positiv", "fenster_h": 336}, # 2 Wochen + # Gastrointestinal + "erbrechen": {"label": "Erbrechen", "kategorie": "gastro_negativ", "fenster_h": 6}, + "durchfall": {"label": "Durchfall", "kategorie": "gastro_negativ", "fenster_h": 8}, + "blaehungen": {"label": "Blähungen", "kategorie": "gastro_negativ", "fenster_h": 6}, + "weicher_stuhl": {"label": "Weicher Stuhl", "kategorie": "gastro_negativ", "fenster_h": 8}, + "appetitlosigkeit":{"label": "Appetitlosigkeit", "kategorie": "gastro_negativ", "fenster_h": 12}, + # Haut & Fell + "juckreiz": {"label": "Juckreiz / Kratzen", "kategorie": "haut_negativ", "fenster_h": 72}, + "haarausfall": {"label": "Haarausfall", "kategorie": "haut_negativ", "fenster_h": 336}, + "stumpfes_fell": {"label": "Stumpfes Fell", "kategorie": "haut_negativ", "fenster_h": 336}, + "schuppenbildung":{"label": "Schuppenbildung", "kategorie": "haut_negativ", "fenster_h": 168}, + "roetungen": {"label": "Hautrötungen / Entzündung", "kategorie": "haut_negativ", "fenster_h": 72}, + "pfotenlecken": {"label": "Pfoten lecken (chronisch)", "kategorie": "haut_negativ", "fenster_h": 168}, + "ohrentzuendung": {"label": "Ohrentzündung", "kategorie": "haut_negativ", "fenster_h": 168}, + "fettiges_fell": {"label": "Fettiges Fell / Seborrhö", "kategorie": "haut_negativ", "fenster_h": 336}, + # Allgemeinbefinden + "schlappheit": {"label": "Schlappheit / Apathie", "kategorie": "allgemein_negativ", "fenster_h": 12}, + "nervositaet": {"label": "Nervosität / Unruhe", "kategorie": "allgemein_negativ", "fenster_h": 12}, + "viel_trinken": {"label": "Ungewöhnlich viel trinken", "kategorie": "allgemein_negativ", "fenster_h": 24}, + "sonstiges": {"label": "Sonstiges", "kategorie": "sonstiges", "fenster_h": 24}, +} + +_POSITIV_KAT = {"positiv"} +_NEGATIV_KAT = {"gastro_negativ", "haut_negativ", "allgemein_negativ"} +_HAUT_HINWEIS = "Haut- & Fell-Symptome wie {label} entwickeln sich typischerweise über Wochen. Mindestens 4–6 Wochen Beobachtung empfohlen — auch nach einem Futterwechsel dauert eine Besserung 2–6 Wochen." +_GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb weniger Stunden auf. Wenn sie häufig wiederkehren, ist ein Tierarztbesuch empfohlen." + + +class FutterEintragCreate(BaseModel): + datum: str + uhrzeit: str + futter_name: str + futter_typ: Optional[str] = "trockenfutter" + menge_g: Optional[int] = None + notiz: Optional[str] = None + + +class ReaktionCreate(BaseModel): + datum: str + uhrzeit: str + reaktion_typ: str + intensitaet: Optional[int] = 3 + notiz: Optional[str] = None + + +# ------------------------------------------------------------------ +# POST /dogs/{dog_id}/futter +# ------------------------------------------------------------------ +@router.post("/{dog_id}/futter") +async def create_futter_eintrag(dog_id: int, body: FutterEintragCreate, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + cur = conn.execute(""" + INSERT INTO futter_eintraege + (dog_id, datum, uhrzeit, futter_name, futter_typ, menge_g, notiz) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (dog_id, body.datum, body.uhrzeit, body.futter_name, + body.futter_typ or "trockenfutter", body.menge_g, body.notiz)) + row = conn.execute( + "SELECT * FROM futter_eintraege WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /dogs/{dog_id}/futter +# ------------------------------------------------------------------ +@router.get("/{dog_id}/futter") +async def list_futter_eintraege(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + rows = conn.execute(""" + SELECT * FROM futter_eintraege + WHERE dog_id=? + ORDER BY datum DESC, uhrzeit DESC + LIMIT 50 + """, (dog_id,)).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# DELETE /dogs/{dog_id}/futter/{entry_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/futter/{entry_id}") +async def delete_futter_eintrag(dog_id: int, entry_id: int, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + result = conn.execute( + "DELETE FROM futter_eintraege WHERE id=? AND dog_id=?", + (entry_id, dog_id) + ) + if result.rowcount == 0: + raise HTTPException(404, "Eintrag nicht gefunden.") + return {"ok": True} + + +# ------------------------------------------------------------------ +# POST /dogs/{dog_id}/futter/reaktion +# ------------------------------------------------------------------ +@router.post("/{dog_id}/futter/reaktion") +async def create_reaktion(dog_id: int, body: ReaktionCreate, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + cur = conn.execute(""" + INSERT INTO futter_reaktionen + (dog_id, datum, uhrzeit, reaktion_typ, intensitaet, notiz) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, body.datum, body.uhrzeit, body.reaktion_typ, + body.intensitaet or 3, body.notiz)) + row = conn.execute( + "SELECT * FROM futter_reaktionen WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /dogs/{dog_id}/futter/reaktionen +# ------------------------------------------------------------------ +@router.get("/{dog_id}/futter/reaktionen") +async def list_reaktionen(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + rows = conn.execute(""" + SELECT * FROM futter_reaktionen + WHERE dog_id=? + ORDER BY datum DESC, uhrzeit DESC + LIMIT 50 + """, (dog_id,)).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# DELETE /dogs/{dog_id}/futter/reaktion/{react_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/futter/reaktion/{react_id}") +async def delete_reaktion(dog_id: int, react_id: int, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + result = conn.execute( + "DELETE FROM futter_reaktionen WHERE id=? AND dog_id=?", + (react_id, dog_id) + ) + if result.rowcount == 0: + raise HTTPException(404, "Reaktion nicht gefunden.") + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /dogs/{dog_id}/futter/analyse +# ------------------------------------------------------------------ +@router.get("/{dog_id}/futter/analyse") +async def futter_analyse(dog_id: int, user=Depends(get_current_user)): + from datetime import datetime + + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + + eintraege = conn.execute( + "SELECT * FROM futter_eintraege WHERE dog_id=? ORDER BY datum, uhrzeit", + (dog_id,) + ).fetchall() + reaktionen = conn.execute( + "SELECT * FROM futter_reaktionen WHERE dog_id=? ORDER BY datum, uhrzeit", + (dog_id,) + ).fetchall() + + def parse_ts(datum, uhrzeit): + try: + return datetime.fromisoformat(f"{datum}T{uhrzeit}") + except Exception: + return None + + # futter_name → {typ, mahlzeiten, positiv, negativ, kategorien: {kat: count}} + futter_stats: dict = {} + + for e in eintraege: + name = e["futter_name"] + if name not in futter_stats: + futter_stats[name] = { + "name": name, + "typ": e["futter_typ"], + "mahlzeiten": 0, + "positiv": 0, + "negativ": 0, + "kategorien": {}, + } + futter_stats[name]["mahlzeiten"] += 1 + + for r in reaktionen: + r_ts = parse_ts(r["datum"], r["uhrzeit"]) + if not r_ts: + continue + r_typ = r["reaktion_typ"] + meta = REAKTION_TYPEN.get(r_typ, {"kategorie": "sonstiges", "fenster_h": 24}) + kat = meta["kategorie"] + fenster = meta["fenster_h"] + # Mindestfenster 1h, maximales Fenster wie angegeben + min_h = 1 + + for e in eintraege: + e_ts = parse_ts(e["datum"], e["uhrzeit"]) + if not e_ts: + continue + diff = (r_ts - e_ts).total_seconds() / 3600 + if min_h <= diff <= fenster: + name = e["futter_name"] + if name not in futter_stats: + continue + if kat in _POSITIV_KAT: + futter_stats[name]["positiv"] += 1 + elif kat in _NEGATIV_KAT: + futter_stats[name]["negativ"] += 1 + # Kategorie-Zähler + futter_stats[name]["kategorien"][kat] = \ + futter_stats[name]["kategorien"].get(kat, 0) + 1 + + result_futter = [] + for stats in futter_stats.values(): + positiv = stats["positiv"] + negativ = stats["negativ"] + total = positiv + negativ + if total == 0: + score = 50 + status = "neu" + else: + raw = (positiv - negativ * 2) / max(1, total) + # raw liegt zwischen -2 und 1 → normieren auf 0-100 + score = int(max(0, min(100, (raw + 2) / 3 * 100))) + if score >= 60: + status = "gut" + elif score >= 30: + status = "neutral" + else: + status = "problematisch" + + result_futter.append({ + "name": stats["name"], + "typ": stats["typ"], + "mahlzeiten": stats["mahlzeiten"], + "positiv": positiv, + "negativ": negativ, + "score": score, + "status": status, + "kategorien": stats["kategorien"], + }) + + # Sortierung: problematisch → neutral → gut → neu, dann nach Score + ORDER = {"problematisch": 0, "neutral": 1, "gut": 2, "neu": 3} + result_futter.sort(key=lambda x: (ORDER.get(x["status"], 9), -x["score"])) + + # Hinweis ableiten: erstes problematisches Futter mit Haut/Gastro-Symptomen + hinweis = None + for f in result_futter: + if f["status"] != "problematisch": + continue + kats = f["kategorien"] + if kats.get("haut_negativ", 0) > 0: + # Häufigstes Haut-Symptom finden + haut_rxn = [ + r["reaktion_typ"] for r in reaktionen + if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "haut_negativ" + ] + label = REAKTION_TYPEN.get(haut_rxn[0], {}).get("label", "Haut-Symptome") if haut_rxn else "Haut-Symptome" + hinweis = _HAUT_HINWEIS.format(label=label) + break + if kats.get("gastro_negativ", 0) > 0: + gastro_rxn = [ + r["reaktion_typ"] for r in reaktionen + if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "gastro_negativ" + ] + label = REAKTION_TYPEN.get(gastro_rxn[0], {}).get("label", "Magen-Darm-Symptome") if gastro_rxn else "Magen-Darm-Symptome" + hinweis = _GASTRO_HINWEIS.format(label=label) + break + + # Kategorien-Übersicht über alle Reaktionen + kategorien_gesamt: dict = {} + for r in reaktionen: + kat = REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie", "sonstiges") + kategorien_gesamt[kat] = kategorien_gesamt.get(kat, 0) + 1 + + return { + "eintraege_count": len(eintraege), + "reaktionen_count": len(reaktionen), + "futter": result_futter, + "kategorien": kategorien_gesamt, + "hinweis": hinweis, + } diff --git a/backend/static/index.html b/backend/static/index.html index 926a17e..f616f14 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + +
@@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 53572ae..e732f44 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 = '856'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '872'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen @@ -905,6 +905,12 @@ const App = (() => { if (!dog || dog.id === state.activeDog?.id) return; state.activeDog = dog; localStorage.setItem('by_active_dog', String(dogId)); + // SW-Cache für hund-spezifische Daten invalidieren + navigator.serviceWorker?.controller?.postMessage({ + type: 'INVALIDATE_CACHE', + paths: ['/api/training/progress', '/api/training/plan-progress', + '/api/training/suggestions', `/api/dogs/${dogId}/welcome-dashboard`], + }); _renderDogSwitcher(); _notifyDogChange(); } diff --git a/backend/static/js/pages/ernaehrung.js b/backend/static/js/pages/ernaehrung.js index 7cf595f..094e34e 100644 --- a/backend/static/js/pages/ernaehrung.js +++ b/backend/static/js/pages/ernaehrung.js @@ -15,6 +15,7 @@ window.Page_ernaehrung = (() => { { key: 'guide', label: 'Futter-Guide', icon: '' }, { key: 'gift', label: 'Giftliste', icon: '' }, { key: 'ki', label: 'KI-Berater', icon: '' }, + { key: 'vertraeglichkeit', label: 'Verträglichkeit', icon: '' }, ]; // ------------------------------------------------------------------ @@ -47,6 +48,7 @@ window.Page_ernaehrung = (() => { async function onDogChange() { _profil = {}; + _activeTab = 'rechner'; // Tab zurücksetzen damit neuer Hund frisch startet await _render(); } @@ -73,10 +75,12 @@ window.Page_ernaehrung = (() => { } _container.innerHTML = ` +