diff --git a/backend/database.py b/backend/database.py index 01dfc17..ab82594 100644 --- a/backend/database.py +++ b/backend/database.py @@ -189,6 +189,13 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id); + CREATE TABLE IF NOT EXISTS route_dogs ( + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (route_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id); + CREATE TABLE IF NOT EXISTS exercise_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -1974,6 +1981,38 @@ def _migrate(conn_factory): """) logger.info("Migration: futter_profil bereit.") + # Futter-Einträge & Reaktionen (Verträglichkeits-Tracking) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS futter_eintraege ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + futter_name TEXT NOT NULL, + futter_typ TEXT NOT NULL DEFAULT 'trockenfutter', + menge_g INTEGER, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC); + + CREATE TABLE IF NOT EXISTS futter_reaktionen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + reaktion_typ TEXT NOT NULL, + intensitaet INTEGER NOT NULL DEFAULT 3, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC); + """) + logger.info("Migration: futter_eintraege + futter_reaktionen bereit.") + except Exception as e: + logger.warning(f"Migration futter_eintraege/reaktionen: {e}") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( @@ -2104,6 +2143,85 @@ def _migrate(conn_factory): except Exception: pass # Spalte existiert bereits + # exercise_progress + training_plan_progress: dog_id ergänzen + existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()] + if 'dog_id' not in existing_ep: + try: + # Neue Tabelle mit dog_id erstellen + conn.execute(""" + CREATE TABLE exercise_progress_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE, + exercise_id TEXT NOT NULL, + status TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, exercise_id) + ) + """) + # Bestehende Daten migrieren: dog_id = erster Hund des Users + conn.execute(""" + INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at) + SELECT ep.user_id, + (SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1), + ep.exercise_id, ep.status, ep.updated_at + FROM exercise_progress ep + """) + conn.execute("DROP TABLE exercise_progress") + conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress") + conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)") + logger.info("Migration: exercise_progress.dog_id hinzugefügt.") + except Exception as e: + logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}") + + existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()] + if 'dog_id' not in existing_tp: + try: + conn.execute(""" + CREATE TABLE training_plan_progress_new ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE, + item_key TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 1, + checked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (dog_id, item_key) + ) + """) + conn.execute(""" + INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at) + SELECT tp.user_id, + (SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1), + tp.item_key, tp.checked, tp.checked_at + FROM training_plan_progress tp + """) + conn.execute("DROP TABLE training_plan_progress") + conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress") + logger.info("Migration: training_plan_progress.dog_id hinzugefügt.") + except Exception as e: + logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}") + + # verstorben_am: Hund als verstorben markierbar + try: + conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT") + logger.info("Migration: dogs.verstorben_am hinzugefügt.") + except Exception: + pass + + # route_dogs: bestehende Routen allen Hunden des Users zuweisen + try: + existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] + if existing == 0: + conn.execute(""" + INSERT OR IGNORE INTO route_dogs (route_id, dog_id) + SELECT r.id, d.id + FROM routes r + JOIN dogs d ON d.user_id = r.user_id + """) + logger.info("Migration: route_dogs mit bestehenden Routen befüllt.") + except Exception as e: + logger.warning(f"Migration route_dogs fehlgeschlagen: {e}") + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/main.py b/backend/main.py index 3ebd1fa..dc86828 100644 --- a/backend/main.py +++ b/backend/main.py @@ -179,7 +179,10 @@ class MediaCacheMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) if request.url.path.startswith('/media/'): - response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + if os.getenv('STAGING') == 'true': + response.headers['Cache-Control'] = 'no-cache' + else: + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' return response app.add_middleware(MediaCacheMiddleware) @@ -250,6 +253,7 @@ from routes.ernaehrung import router as ernaehrung_router from routes.challenges import router as challenges_router from routes.gassi_zeiten import router as gassi_zeiten_router from routes.help import router as help_router +from routes.feedback import router as feedback_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -312,6 +316,7 @@ app.include_router(ernaehrung_router, prefix="/api/dogs", tag app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"]) app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) +app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) # ------------------------------------------------------------------ @@ -339,9 +344,39 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) 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_VER = "826" # muss mit APP_VER in app.js übereinstimmen +STAGING = os.getenv("STAGING", "false").lower() == "true" +PROD_MEDIA_DIR = "/prod-media" + +_MIME_MAP = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4", + ".webm": "video/webm", ".pdf": "application/pdf", +} + +if STAGING and os.path.isdir(PROD_MEDIA_DIR): + # Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only) + from fastapi.responses import FileResponse as _FileResponse + + def _media_response(filepath: str): + ext = os.path.splitext(filepath)[1].lower() + mt = _MIME_MAP.get(ext, "application/octet-stream") + return _FileResponse(filepath, media_type=mt) + + @app.api_route("/media/{path:path}", methods=["GET", "HEAD"]) + async def serve_media_staging(path: str): + staging_file = os.path.join(MEDIA_DIR, path) + if os.path.isfile(staging_file): + return _media_response(staging_file) + prod_file = os.path.join(PROD_MEDIA_DIR, path) + if os.path.isfile(prod_file): + return _media_response(prod_file) + from fastapi import HTTPException as _HE + raise _HE(404, "Media not found") +else: + app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") + +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/admin.py b/backend/routes/admin.py index 5e82927..92a199d 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -583,6 +583,48 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)): return {"ok": True, "job_id": job_id} +# ------------------------------------------------------------------ +# GET /api/admin/referrals — User-wirbt-User Top 100 +# ------------------------------------------------------------------ +@router.get("/referrals") +async def referral_stats(user=Depends(require_mod)): + with db() as conn: + # Top-Werber mit Anzahl + top = conn.execute(""" + SELECT r.id, r.name, r.email, + COUNT(u.id) AS invited_count, + r.created_at AS member_since + FROM users u + JOIN users r ON r.id = u.referred_by + GROUP BY r.id + ORDER BY invited_count DESC + LIMIT 100 + """).fetchall() + + # Alle Einladungen (für Detail-Ansicht) + invites = conn.execute(""" + SELECT u.id, u.name, u.email, u.created_at, + r.id AS referrer_id, r.name AS referrer_name + FROM users u + JOIN users r ON r.id = u.referred_by + ORDER BY u.created_at DESC + LIMIT 500 + """).fetchall() + + total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + total_referred = conn.execute( + "SELECT COUNT(*) FROM users WHERE referred_by IS NOT NULL" + ).fetchone()[0] + + return { + "top_referrers": [dict(r) for r in top], + "recent_invites": [dict(r) for r in invites], + "total_users": total_users, + "total_referred": total_referred, + "viral_factor": round(total_referred / max(total_users - total_referred, 1), 2), + } + + # ------------------------------------------------------------------ # GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time) # ------------------------------------------------------------------ diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 6c35334..e2985dd 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional from database import db -from auth import get_current_user +from auth import get_current_user, has_pro_access from routes.push import send_push_to_user from media_utils import safe_media_path, preview_url_from @@ -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( @@ -131,6 +131,14 @@ def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]: @router.post("") async def create_dog(data: DogCreate, user=Depends(get_current_user)): with db() as conn: + existing = conn.execute( + "SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],) + ).fetchone()[0] + if existing >= 1 and not has_pro_access(user): + raise HTTPException( + status_code=403, + detail="Mehrere Hunde sind ein Pro-Feature. Upgrade auf Ban Yaro Pro, um weitere Hunde anzulegen." + ) conn.execute( """INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht, gewicht_kg, chip_nr, bio, is_public) @@ -180,8 +188,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): if not dog: raise HTTPException(404, "Hund nicht gefunden.") - # Zufälliges Foto aus den letzten 100 Tagebuchbildern - # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge + # Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend photos = conn.execute( """SELECT dm.url FROM diary_media dm JOIN diary d ON d.id = dm.diary_id @@ -190,12 +197,13 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() - # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill) + # Fallback: Bilder ohne Dimensionsdaten (vor dem Backfill hochgeladen) 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' + AND dm.img_width IS NULL ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() @@ -247,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 = [] @@ -280,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)] @@ -771,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: @@ -1200,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 @@ -1255,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/routes/feedback.py b/backend/routes/feedback.py new file mode 100644 index 0000000..a5c4792 --- /dev/null +++ b/backend/routes/feedback.py @@ -0,0 +1,56 @@ +""" +BAN YARO — User-Feedback per E-Mail an support@banyaro.app +""" + +from typing import Annotated, Literal +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from auth import get_current_user +from mailer import send_email, email_html + +router = APIRouter() + +SUPPORT_MAIL = "support@banyaro.app" + +KATEGORIEN = {"bug": "🐛 Bug / Fehler", "idee": "💡 Idee / Wunsch", "lob": "🎉 Lob", "sonstiges": "💬 Sonstiges"} + + +class FeedbackIn(BaseModel): + kategorie: Literal["bug", "idee", "lob", "sonstiges"] + text: Annotated[str, Field(min_length=5, max_length=2000)] + + +@router.post("") +async def submit_feedback( + payload: FeedbackIn, + user=Depends(get_current_user), +): + kat_label = KATEGORIEN.get(payload.kategorie, payload.kategorie) + username = user.get("name", "?") + email = user.get("email", "") + tier = user.get("subscription_tier", "standard") + + subject = f"[Feedback] {kat_label} von @{username}" + + body = f""" +

+ Neues Feedback aus der App: +

+ + + + + + + +
Kategorie{kat_label}
User@{username} ({email})
Tier{tier}
+
+{payload.text} +
""" + + plain = f"Feedback [{kat_label}] von @{username} ({email})\n\n{payload.text}" + + await send_email(SUPPORT_MAIL, subject, email_html(body), plain) + return {"ok": True} diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 7df14e4..a813532 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -172,6 +172,20 @@ async def send_request(target_id: int, user=Depends(get_current_user)): return {"ok": True} +@router.get("/pending") +async def pending_requests(user=Depends(get_current_user)): + """Eingehende Freundschaftsanfragen — kein Pro nötig, für Notification-Accept.""" + with db() as conn: + rows = conn.execute(""" + SELECT f.id, u.name AS requester_name, u.avatar_url + FROM friendships f + JOIN users u ON u.id = f.requester_id + WHERE f.addressee_id=? AND f.status='pending' + ORDER BY f.created_at DESC + """, (user["id"],)).fetchall() + return [dict(r) for r in rows] + + @router.post("/{friendship_id}/accept") async def accept_request(friendship_id: int, user=Depends(get_current_user)): uid = user["id"] diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 6521b90..2b16cbe 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -55,7 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" prompt=prompt, system=system, max_tokens=600, - requires_premium=False, + requires_premium=True, user_id=user["id"], ) return {"antwort": result} @@ -361,3 +361,65 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" "hinweis": parsed.get("hinweis") or None, "verbleibende_anfragen": remaining_after, } + + +# ------------------------------------------------------------------ +# POST /ki/abschied — Persönlicher Abschiedstext für verstorbenen Hund +# ------------------------------------------------------------------ +class AbschiedRequest(BaseModel): + dog_id: int + name: str + rasse: Optional[str] = None + km_total: Optional[float] = None + diary_count: Optional[int] = None + gemeinsam_tage: Optional[int] = None + last_entry_titel: Optional[str] = None + +@router.post("/abschied") +async def ki_abschied(req: AbschiedRequest, request: Request, + user=Depends(get_current_user)): + """Persönlicher Abschiedstext — einmalig generiert, DB-gecacht.""" + with db() as conn: + cached = conn.execute( + "SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=9999 AND mode='abschied'", + (req.dog_id,) + ).fetchone() + if cached: + return {"text": cached["content"], "cached": True} + + name = req.name.strip()[:40] + rasse = req.rasse or "" + km = f"{req.km_total:.0f} km" if req.km_total else None + tage = f"{req.gemeinsam_tage} gemeinsame Tage" if req.gemeinsam_tage else None + eintr = f"{req.diary_count} Tagebucheinträge" if req.diary_count else None + + stats_str = ", ".join(filter(None, [km, tage, eintr])) + rasse_str = f" ({rasse})" if rasse else "" + + system = ( + "Du bist ein einfühlsamer Begleiter für Menschen in Trauer um ihren Hund. " + "Schreibe warmherzig, persönlich und respektvoll auf Deutsch. " + "Keine Floskeln, kein Kitsch — echte Wärme. " + "Erwähne die Statistiken natürlich eingebunden." + ) + prompt = ( + f"{name}{rasse_str} ist über die Regenbogenbrücke gegangen. " + f"Schreibe einen kurzen, persönlichen Abschiedstext (ca. 80–100 Wörter) " + f"der die Verbundenheit würdigt. " + f"Statistiken: {stats_str or 'nicht bekannt'}. " + f"Sei warm, nicht sentimental überladen. Schließe mit einem hoffnungsvollen Gedanken." + ) + + try: + text = await ki_module.complete( + system=system, prompt=prompt, max_tokens=300, + requires_premium=False, user_id=user["id"], + ) + with db() as conn: + conn.execute( + "INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,9999,'abschied',?)", + (req.dog_id, text) + ) + return {"text": text, "cached": False} + except Exception as e: + raise HTTPException(503, str(e)) diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 6755ccc..e1060ef 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -58,6 +58,7 @@ class RouteCreate(BaseModel): is_public: Optional[bool] = False hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium client_time: Optional[str] = None + dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind class RouteUpdate(BaseModel): name: Optional[str] = None @@ -69,6 +70,9 @@ class RouteUpdate(BaseModel): is_public: Optional[bool] = None hunde_tauglichkeit: Optional[str] = None +class RouteDogs(BaseModel): + dog_ids: List[int] + def _simplify_track(track: list, max_pts: int = 40) -> list: """Reduziert GPS-Track auf max_pts Punkte für Vorschau.""" @@ -168,7 +172,26 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)): int(data.is_public) if data.is_public is not None else 1, data.hunde_tauglichkeit, is_valid, ct, )) - row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() + route_id = cur.lastrowid + row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone() + + # Hunde zuordnen — entweder explizit oder alle Hunde des Users + dog_ids = data.dog_ids or [] + if not dog_ids: + # Fallback: alle Hunde des Users + all_dogs = conn.execute( + "SELECT id FROM dogs WHERE user_id=?", (user['id'],) + ).fetchall() + dog_ids = [d['id'] for d in all_dogs] + for did in dog_ids: + try: + conn.execute( + "INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)", + (route_id, did) + ) + except Exception: + pass + update_streak(user['id'], conn) check_and_award(user['id'], conn) result = _parse(row) @@ -195,6 +218,10 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)): if not ORS_API_KEY: raise HTTPException(503, "ORS nicht konfiguriert") + from auth import has_pro_access + if not has_pro_access(user): + raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.") + is_privileged = ( user.get("rolle") in ("admin", "moderator") or user.get("is_moderator") or @@ -313,9 +340,14 @@ async def get_route(route_id: int): "SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?", (route_id,) ).fetchone() - if not row: - raise HTTPException(404, "Route nicht gefunden.") - return _parse(row) + if not row: + raise HTTPException(404, "Route nicht gefunden.") + dog_rows = conn.execute( + "SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,) + ).fetchall() + result = _parse(row) + result['dog_ids'] = [r['dog_id'] for r in dog_rows] + return result # ------------------------------------------------------------------ @@ -342,6 +374,26 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren return _parse(row) +# ------------------------------------------------------------------ +# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren +# ------------------------------------------------------------------ +@router.patch("/{route_id}/dogs") +async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if row['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,)) + for did in data.dog_ids: + conn.execute( + "INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)", + (route_id, did) + ) + return {"ok": True} + + # ------------------------------------------------------------------ # PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz) # ------------------------------------------------------------------ diff --git a/backend/routes/training.py b/backend/routes/training.py index 05e8e94..263d90d 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -85,28 +85,43 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ # ------------------------------------------------------------------ class ProgressUpdate(BaseModel): exercise_id: str - status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt + status: Optional[str] = None + dog_id: Optional[int] = None @router.get("/progress") -async def get_progress(user=Depends(get_current_user)): +async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - rows = conn.execute( - "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", - (uid,) - ).fetchall() + if dog_id: + rows = conn.execute( + "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?", + (dog_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() return [dict(r) for r in rows] @router.post("/progress") async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - conn.execute(""" - INSERT INTO exercise_progress (user_id, exercise_id, status) - VALUES (?,?,?) - ON CONFLICT(user_id, exercise_id) DO UPDATE - SET status=excluded.status, updated_at=datetime('now') - """, (uid, body.exercise_id, body.status)) + if body.dog_id: + conn.execute(""" + INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status) + VALUES (?,?,?,?) + ON CONFLICT(dog_id, exercise_id) DO UPDATE + SET status=excluded.status, updated_at=datetime('now') + """, (uid, body.dog_id, body.exercise_id, body.status)) + else: + conn.execute(""" + INSERT INTO exercise_progress (user_id, exercise_id, status) + VALUES (?,?,?) + ON CONFLICT(dog_id, exercise_id) DO UPDATE + SET status=excluded.status, updated_at=datetime('now') + """, (uid, body.exercise_id, body.status)) return {"ok": True} # ------------------------------------------------------------------ @@ -115,15 +130,22 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): class PlanProgress(BaseModel): item_key: str checked: bool + dog_id: Optional[int] = None @router.get("/plan-progress") -async def get_plan_progress(user=Depends(get_current_user)): +async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - rows = conn.execute( - "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", - (uid,) - ).fetchall() + if dog_id: + rows = conn.execute( + "SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?", + (dog_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", + (uid,) + ).fetchall() return [dict(r) for r in rows] @router.post("/plan-progress") @@ -132,13 +154,13 @@ async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user with db() as conn: if body.checked: conn.execute(""" - INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked) - VALUES (?,?,1) - """, (uid, body.item_key)) + INSERT OR REPLACE INTO training_plan_progress (user_id, dog_id, item_key, checked) + VALUES (?,?,?,1) + """, (uid, body.dog_id, body.item_key)) else: conn.execute( - "DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?", - (uid, body.item_key) + "DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?", + (body.dog_id, body.item_key) ) return {"ok": True} @@ -149,13 +171,19 @@ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus / TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen'] @router.get("/suggestions") -async def get_suggestions(user=Depends(get_current_user)): +async def get_suggestions(dog_id: Optional[int] = None, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - rows = conn.execute( - "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", - (uid,) - ).fetchall() + if dog_id: + rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?", + (dog_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() progress = {r["exercise_id"]: r["status"] for r in rows} diff --git a/backend/routes/weather.py b/backend/routes/weather.py index 2167b19..0bd757a 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,6 +3,7 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ +import os import json from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module @@ -11,6 +12,34 @@ from database import db router = APIRouter() +OWM_API_KEY = os.getenv("OPENWEATHERMAP_KEY", "") + + +ALLOWED_OWM_LAYERS = {"temp_new", "clouds_new", "wind_new", "pressure_new", "precipitation_new"} + + +@router.get('/radar-tiles') +async def radar_tile_config(user=Depends(get_current_user)): + """Regenradar-Tile-Config (RainViewer).""" + return {"provider": "rainviewer"} + + +@router.get('/layer-tiles') +async def layer_tile_config( + layer: str = "temp_new", + user=Depends(get_current_user), +): + """OWM-Tile-URL für Wetter-Layer (Key bleibt server-seitig).""" + if layer not in ALLOWED_OWM_LAYERS: + raise HTTPException(400, f"Unbekannter Layer. Erlaubt: {', '.join(ALLOWED_OWM_LAYERS)}") + if not OWM_API_KEY: + raise HTTPException(503, "OWM nicht konfiguriert.") + return { + "url": f"https://tile.openweathermap.org/map/{layer}/{{z}}/{{x}}/{{y}}.png?appid={OWM_API_KEY}", + "maxNativeZoom": 18, + "opacity": 0.6, + } + @router.get('') async def get_weather( diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 49d89ab..cb54b88 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3184,6 +3184,16 @@ html.modal-open { color: #fff; font-size: 1rem; } +#map-radar-btn.active { + background: #1d4ed8; + border-color: #1d4ed8; + color: #fff; +} +#map-temp-btn.active { + background: #dc2626; + border-color: #dc2626; + color: #fff; +} .map-fab:disabled { opacity: 0.5; cursor: default; } .map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; } @keyframes fab-spin { to { transform: rotate(360deg); } } diff --git a/backend/static/index.html b/backend/static/index.html index cf2117f..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/api.js b/backend/static/js/api.js index 893f1b4..5781a62 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -58,7 +58,10 @@ const API = (() => { try { data = await response.json(); } catch { data = null; } if (!response.ok) { - const message = data?.detail || data?.message || `Fehler ${response.status}`; + const _d = data?.detail; + const message = (typeof _d === 'string' ? _d + : Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe') + : null) || data?.message || `Fehler ${response.status}`; const isSwOffline = response.status === 503 && message.startsWith('Offline'); // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) @@ -139,6 +142,15 @@ const API = (() => { deletePhoto(id) { return del(`/dogs/${id}/photo`); }, getSkills(id) { return get(`/dogs/${id}/skills`); }, welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); }, + gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); }, + gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); }, + futterList(id) { return get(`/dogs/${id}/futter`); }, + futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); }, + futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); }, + reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); }, + reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); }, + reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); }, + futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); }, }; // ---------------------------------------------------------- @@ -284,6 +296,7 @@ const API = (() => { rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); }, walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); }, reverse(id) { return post(`/routes/${id}/reverse`, {}); }, + updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); }, addPhoto(id, file) { const fd = new FormData(); fd.append('file', file); @@ -297,11 +310,11 @@ const API = (() => { // TRAINING & ÜBUNGSFORTSCHRITT // ---------------------------------------------------------- const training = { - getProgress() { return get('/training/progress'); }, - setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); }, - getSuggestions() { return get('/training/suggestions'); }, - getPlanProgress() { return get('/training/plan-progress'); }, - setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); }, + getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); }, + setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); }, + getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); }, + getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); }, + setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); }, getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); }, }; @@ -461,6 +474,7 @@ const API = (() => { // ---------------------------------------------------------- const friends = { list() { return get('/friends/'); }, + pending() { return get('/friends/pending'); }, search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); }, activity() { return get('/friends/activity'); }, sendRequest(userId) { return post(`/friends/request/${userId}`, {}); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index db6b183..e732f44 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt +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 if (location.search.includes('_t=')) history.replaceState(null, '', '/'); @@ -57,7 +57,7 @@ const App = (() => { 'erste-hilfe': { title: 'Erste Hilfe', module: null }, settings: { title: 'Einstellungen', module: null }, lost: { title: 'Verlorener Hund', module: null }, - friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true }, + friends: { title: 'Freunde', module: null, requiresAuth: true }, chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true }, social: { title: 'Social Media', module: null, requiresAuth: true }, admin: { title: 'Admin', module: null, requiresAuth: true }, @@ -198,6 +198,26 @@ const App = (() => { return; } + // Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init() + const pageEl = document.getElementById(`page-${pageId}`); + if (pageEl) { + pageEl.querySelector('#pro-role-banner')?.remove(); + if (page.requiresPro && state.user) { + const t = state.user.subscription_tier || 'standard'; + const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) && + (state.user.rolle === 'admin' || state.user.rolle === 'moderator' || + state.user.is_moderator || state.user.is_social_media); + if (isRoleBased) { + const banner = document.createElement('div'); + banner.id = 'pro-role-banner'; + banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;'; + banner.innerHTML = ` + ⭐ Pro-Feature — Standard-User sehen diese Seite nicht`; + pageEl.insertBefore(banner, pageEl.firstChild); + } + } + } + if (page.module) { const hasParams = params && Object.keys(params).length > 0; if (hasParams) { @@ -885,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/admin.js b/backend/static/js/pages/admin.js index 0dfa8c7..2747256 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -25,6 +25,7 @@ window.Page_admin = (() => { { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, + { id: 'referrals', label: 'Referrals', icon: 'share-network' }, ]; // ------------------------------------------------------------------ @@ -161,6 +162,7 @@ window.Page_admin = (() => { case 'bewerbungen': await _renderBewerbungen(el); break; case 'hilfe': await _renderHilfe(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break; + case 'referrals': await _renderReferrals(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -534,22 +536,22 @@ window.Page_admin = (() => { }; el.innerHTML = ` -
- ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')} - ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')} - ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')} - ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')} - ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')} - ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')} - ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} - ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')} - ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')} - ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')} +
+ ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')} + ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')} + ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')} + ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')} + ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')} + ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')} + ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')} + ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')} + ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')} + ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')} ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')} ${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')} - ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')} - ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')} + ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')} + ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
@@ -705,11 +707,19 @@ window.Page_admin = (() => {

`; + + el.querySelector('#adm-overview-grid')?.addEventListener('click', e => { + const card = e.target.closest('[data-adm-tab]'); + if (!card) return; + const tab = card.dataset.admTab; + _container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click(); + }); } - function _statCard(icon, label, value, color) { + function _statCard(icon, label, value, color, tab = null) { + const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`; return ` -
+
Lade…
`; + let d; + try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `
Fehler beim Laden.
`; return; } + + const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0; + + const topRows = d.top_referrers.map((r, i) => ` + + ${i + 1} + ${_esc(r.name)} + ${_esc(r.email)} + + ${r.invited_count} + + `).join(''); + + const recentRows = d.recent_invites.slice(0, 50).map(r => ` + + ${_esc(r.name)} + ${_esc(r.referrer_name)} + ${(r.created_at || '').slice(0, 10)} + `).join(''); + + el.innerHTML = ` +
+
+
${d.total_referred}
+
Geworbene User
+
+
+
${pct}%
+
Anteil geworbener User
+
+
+
${d.viral_factor}
+
Virality Factor
+
+
+ +
+
Top Werber
+
+ + + + + + + + ${topRows || ''} +
#NameE-MailEingeladen
Noch keine Empfehlungen
+
+
+ +
+
Zuletzt geworbene User
+
+ + + + + + + ${recentRows || ''} +
UserGeworben vonDatum
Noch keine Daten
+
+
`; + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 66d9150..42e0b5d 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -149,12 +149,6 @@ window.Page_diary = (() => { // ---------------------------------------------------------- async function refresh() { if (!_appState.activeDog) return; - // Mehrere Hunde → Picker zeigen (User kann Hund wählen) - if (_appState.dogs.length > 1) { - _renderDogPicker(); - return; - } - // Einzelner Hund → Diary direkt neu laden _offset = 0; _entries = []; _totalStats = null; @@ -194,52 +188,7 @@ window.Page_diary = (() => { return; } - if (_appState.dogs.length > 1) { - _renderDogPicker(); - } else { - await _renderDiary(); - } - } - - // ---------------------------------------------------------- - // HUNDE-PICKER — Einstiegsseite bei mehreren Hunden - // ---------------------------------------------------------- - function _renderDogPicker() { - const activeDogId = _appState.activeDog?.id; - - const cards = _appState.dogs.map(dog => { - const isActive = dog.id === activeDogId; - const av = dog.foto_url - ? `${UI.escape(dog.name)}` - : `${UI.icon('dog')}`; - return ` -
-
${av}
-
${UI.escape(dog.name)}
- ${dog.rasse ? `
${UI.escape(dog.rasse)}
` : ''} -
`; - }).join(''); - - _container.innerHTML = ` -
-

Wessen Tagebuch?

-
${cards}
-
`; - - _container.querySelectorAll('.diary-picker-card').forEach(el => { - el.addEventListener('click', async () => { - const id = parseInt(el.dataset.dogId); - if (id === _appState.activeDog?.id) { - // Bereits aktiver Hund → direkt Diary laden - _offset = 0; _entries = []; - await _renderDiary(); - } else { - App.setActiveDog(id); - // onDogChange() → _renderDiary() via _notifyDogChange() - } - }); - }); + await _renderDiary(); } // ---------------------------------------------------------- @@ -247,6 +196,7 @@ window.Page_diary = (() => { // ---------------------------------------------------------- async function _renderDiary() { _container.innerHTML = ` + ${UI.dogChip(_appState)}
@@ -274,6 +224,7 @@ window.Page_diary = (() => { `; + UI.bindDogChip(_container, _appState); _container.querySelector('#diary-milestone-filter') ?.addEventListener('click', async () => { diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 85ce02d..6de34d5 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -1060,6 +1060,12 @@ window.Page_dog_profile = (() => {
+
@@ -1279,6 +1285,11 @@ window.Page_dog_profile = (() => { document.getElementById('dp-form-cancel') ?.addEventListener('click', UI.modal.close); + document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => { + UI.modal.close(); + _openGedenkenFlow(dog); + }); + document.getElementById('dp-delete-btn')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title : `${dog.name} löschen?`, @@ -2414,6 +2425,178 @@ window.Page_dog_profile = (() => { // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- + // ---------------------------------------------------------- + // GEDENKEN-FLOW + // ---------------------------------------------------------- + async function _openGedenkenFlow(dog) { + // Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe + UI.modal.open({ + title: `Abschied von ${dog.name}`, + body: ` +
+ +

+ ${dog.name} hinterlässt eine riesige Lücke.
+ Die gemeinsamen Erinnerungen bleiben für immer. +

+
+
+ + +
`, + footer: ` +
+ + +
`, + }); + + document.getElementById('gedenken-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.getElementById('gedenken-save-btn'); + const datum = document.getElementById('gedenken-datum').value; + await UI.asyncButton(btn, async () => { + await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum }); + // Aus aktiver Hundeliste entfernen + _appState.dogs = _appState.dogs.filter(d => d.id !== dog.id); + _appState.activeDog = _appState.dogs[0] || null; + UI.modal.close(); + // Gedenkseite öffnen + await _openGedenkseite(dog.id, dog.name); + await _render(); + }); + }); + } + + async function _openGedenkseite(dogId, dogName) { + UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: ` +
+ + + +
` }); + + let data; + try { data = await API.get(`/dogs/${dogId}/gedenkseite`); } + catch { UI.modal.close(); return; } + + const d = data; + const av = d.dog.foto_url + ? `` + : `
`; + + const photoGrid = d.photos.length ? ` +
+ ${d.photos.map(url => ``).join('')} +
` : ''; + + const statsHtml = ` +
+ ${d.km_total ? `
+ +
${d.km_total}
+
km zusammen
+
` : ''} + ${d.diary_count ? `
+ +
${d.diary_count}
+
Tagebucheinträge
+
` : ''} + ${d.media_count ? `
+ +
${d.media_count}
+
Fotos
+
` : ''} + ${d.gemeinsam_tage ? `
+ +
${d.gemeinsam_tage}
+
gemeinsame Tage
+
` : ''} +
`; + + // Trauer-Support-Texte + const supportHtml = ` +
+
+ + Für dich in dieser Zeit +
+

+ Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein. + Lass dich trauern — so lange du brauchst. Die Erinnerungen bleiben immer bei dir. +

+
+
+
+ + Sprich mit Freunden oder der Familie über ${d.dog.name} — Geschichten lebendig halten hilft. +
+
+ + Das Tagebuch bleibt erhalten — es ist ein kostbares Stück gemeinsamer Geschichte. +
+
+ + Professionelle Hilfe bei Tiertrauer: Tiertrauer-Hotline 0800 111 0 111 (kostenlos) +
+
+
+ +
`; + + const modal = UI.modal.open({ + title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`, + body: ` +
+ ${av} +
${UI.escape(d.dog.name)}
+ ${d.dog.rasse ? `
${UI.escape(d.dog.rasse)}
` : ''} + ${d.dog.verstorben_am ? `
+ + Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')} +
` : ''} +
+ ${photoGrid} + ${statsHtml} + ${supportHtml}`, + }); + + document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('gedenk-ki-btn'); + await UI.asyncButton(btn, async () => { + const result = await API.post('/ki/abschied', { + dog_id: dogId, + name: d.dog.name, + rasse: d.dog.rasse, + km_total: d.km_total, + diary_count: d.diary_count, + gemeinsam_tage: d.gemeinsam_tage, + }); + const wrap = document.getElementById('gedenk-ki-wrap'); + if (wrap) wrap.innerHTML = ` +
+ "${UI.escape(result.text)}" +
`; + }); + }); + } + return { init, refresh, onDogChange, addNew: _openCreateModal }; })(); 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 = ` +
${UI.dogChip(_appState)}
`; + UI.bindDogChip(_container, _appState); _renderTabBar(); _renderTab(); } @@ -106,10 +110,11 @@ window.Page_ernaehrung = (() => { 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; + case 'rechner': _renderRechner(el); break; + case 'guide': _renderGuide(el); break; + case 'gift': _renderGift(el); break; + case 'ki': _renderKi(el); break; + case 'vertraeglichkeit': _renderVertraeglichkeit(el); break; } } @@ -630,6 +635,531 @@ window.Page_ernaehrung = (() => { }); } + // ------------------------------------------------------------------ + // TAB 5: VERTRÄGLICHKEIT + // ------------------------------------------------------------------ + async function _renderVertraeglichkeit(el) { + const dog = _appState?.activeDog; + if (!dog) { el.innerHTML = ''; return; } + + el.innerHTML = ` +
+ + +
+ + +
+ + +
+ + Haut- & Fellsymptome zeigen sich erst nach Wochen — trage regelmäßig ein um Muster zu erkennen. +
+ + +
+
+ + Lade Analyse… +
+
+ + +
+
+ `; + + el.querySelector('#vert-btn-futter').addEventListener('click', () => _openFutterModal(el, dog)); + el.querySelector('#vert-btn-reaktion').addEventListener('click', () => _openReaktionModal(el, dog)); + + await _loadAnalyse(el, dog); + await _loadVerlauf(el, dog); + } + + function _todayStr() { + return new Date().toISOString().slice(0, 10); + } + function _nowTimeStr() { + const d = new Date(); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + + function _openFutterModal(el, dog) { + const id = `fm-${Date.now()}`; + const body = ` +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + const footer = ` + + + `; + UI.modal.open({ title: 'Futter erfassen', body, footer }); + + // Datalist mit bekannten Futter-Namen füllen + API.dogs.futterList(dog.id).then(list => { + const dl = document.getElementById('vert-futter-datalist'); + if (!dl) return; + const names = [...new Set((list || []).map(e => e.futter_name))]; + dl.innerHTML = names.map(n => `
`; _container.innerHTML = ` + ${UI.dogChip(_appState)}
+ +
@@ -283,6 +287,186 @@ window.Page_map = (() => { }); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); + document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar); + document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp); + } + + // ---------------------------------------------------------- + // REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.) + // ---------------------------------------------------------- + let _radarLayer = null; + let _radarActive = false; + let _radarTimer = null; + let _tempLayer = null; + let _tempActive = false; + let _tempMarkers = []; + let _tempDebounce = null; + + async function _toggleRadar() { + if (!App.hasPro(_appState?.user)) { + UI.toast.info('Regenradar ist ein Pro-Feature.'); + return; + } + const btn = document.getElementById('map-radar-btn'); + if (_radarActive) { + _radarActive = false; + if (_radarLayer) { _map.removeLayer(_radarLayer); _radarLayer = null; } + clearInterval(_radarTimer); + btn?.classList.remove('active'); + return; + } + _radarActive = true; + btn?.classList.add('active'); + if (_map && _map.getZoom() > 7) _map.setZoom(7); + await _loadRadar(); + _radarTimer = setInterval(_loadRadar, 5 * 60 * 1000); + } + + async function _toggleTemp() { + if (!App.hasPro(_appState?.user)) { + UI.toast.info('Temperatur-Layer ist ein Pro-Feature.'); + return; + } + const btn = document.getElementById('map-temp-btn'); + if (_tempActive) { + _tempActive = false; + if (_tempLayer) { _map.removeLayer(_tempLayer); _tempLayer = null; } + _tempMarkers.forEach(m => _map.removeLayer(m)); + _tempMarkers = []; + clearTimeout(_tempDebounce); + _map.off('moveend zoomend', _debounceTempLabels); + document.getElementById('map-temp-legend')?.remove(); + btn?.classList.remove('active'); + return; + } + _tempActive = true; + btn?.classList.add('active'); + try { + const cfg = await API.get('/weather/layer-tiles?layer=temp_new'); + _tempLayer = window.L.tileLayer(cfg.url, { + opacity: 1.0, + tileSize: 256, + zIndex: 290, + maxNativeZoom: cfg.maxNativeZoom ?? 18, + maxZoom: 18, + attribution: 'Temp © OpenWeatherMap', + }).addTo(_map); + _showTempLegend(); + _map.on('moveend zoomend', _debounceTempLabels); + await _loadTempLabels(); + } catch { + _tempActive = false; + btn?.classList.remove('active'); + UI.toast.error('Temperatur-Layer nicht verfügbar.'); + } + } + + function _debounceTempLabels() { + clearTimeout(_tempDebounce); + _tempDebounce = setTimeout(_loadTempLabels, 600); + } + + function _tempColor(t) { + if (t <= -10) return '#0033cc'; + if (t <= 0) return '#0099ff'; + if (t <= 10) return '#00cc88'; + if (t <= 15) return '#88cc00'; + if (t <= 20) return '#ffcc00'; + if (t <= 25) return '#ff8800'; + if (t <= 30) return '#ff3300'; + return '#990000'; + } + + async function _loadTempLabels() { + if (!_tempActive || !_map) return; + const bounds = _map.getBounds(); + const n = bounds.getNorth(), s = bounds.getSouth(); + const e = bounds.getEast(), w = bounds.getWest(); + + // 3×3 Raster + const rows = 3, cols = 3; + const points = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const lat = s + (n - s) * (r + 0.5) / rows; + const lon = w + (e - w) * (c + 0.5) / cols; + points.push([lat, lon]); + } + } + + const results = await Promise.all(points.map(([lat, lon]) => + fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' }) + .then(r => r.json()) + .then(d => ({ lat, lon, t: d.current?.temperature_2m })) + .catch(() => null) + )); + + // Alte Marker entfernen + _tempMarkers.forEach(m => _map.removeLayer(m)); + _tempMarkers = []; + + results.filter(Boolean).forEach(({ lat, lon, t }) => { + if (t == null) return; + const temp = Math.round(t); + const color = _tempColor(temp); + const icon = window.L.divIcon({ + className: '', + html: `
${temp}°
`, + iconSize: null, + iconAnchor: [20, 10], + }); + const m = window.L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false }); + m.addTo(_map); + _tempMarkers.push(m); + }); + } + + function _showTempLegend() { + const existing = document.getElementById('map-temp-legend'); + if (existing) return; + const steps = [ + { c: '#0000cc', v: '−20°' }, { c: '#0055ff', v: '−10°' }, + { c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' }, + { c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' }, + { c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' }, + { c: '#990000', v: '35°' }, + ]; + const gradient = steps.map(s => s.c).join(','); + const labels = steps.map(s => + `${s.v}` + ).join(''); + const el = document.createElement('div'); + el.id = 'map-temp-legend'; + el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%); + z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px; + min-width:220px;pointer-events:none`; + el.innerHTML = ` +
+
${labels}
`; + document.getElementById('central-map')?.appendChild(el); + } + + async function _loadRadar() { + if (!_radarActive || !_map) return; + try { + const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' }); + const data = await resp.json(); + const frames = [...(data.radar?.past || []), ...(data.radar?.nowcast || [])]; + if (!frames.length) return; + const latest = frames[frames.length - 1].path; + const url = `https://tilecache.rainviewer.com${latest}/256/{z}/{x}/{y}/4/1_1.png`; + if (_radarLayer) _map.removeLayer(_radarLayer); + _radarLayer = window.L.tileLayer(url, { + opacity: 0.7, + tileSize: 256, + zIndex: 300, + maxNativeZoom: 7, + maxZoom: 18, + attribution: 'Radar © RainViewer', + }).addTo(_map); + } catch { /* still */ } } // ---------------------------------------------------------- @@ -373,6 +557,7 @@ window.Page_map = (() => { pos => { const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords; _userPos = { lat, lon }; + if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); } if (_locationMarker) { _locationMarker.setLatLng([lat, lon]); _locationAccuracy?.setLatLng([lat, lon]).setRadius(acc); @@ -1503,11 +1688,34 @@ window.Page_map = (() => { } function _showRecSaveModal(track, distKm, dauMin) { + const dogs = _appState?.dogs || []; + const activeDogId = _appState?.activeDog?.id; + const dogPickerHtml = dogs.length > 1 ? ` +
+ +
+ ${dogs.map(d => { + const checked = d.id === activeDogId; + const av = d.foto_url + ? `` + : ``; + return ``; + }).join('')} +
+
` : ''; + const body = `

${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min

+ ${dogPickerHtml}
{ if (_recMarker) { _recMarker.remove(); _recMarker = null; } }); + // Hund-Checkbox Toggle-Styling + document.querySelectorAll('.rec-dog-cb').forEach(cb => { + const label = cb.closest('label'); + const update = () => { + label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)'; + label.style.background = cb.checked ? 'var(--c-primary-subtle)' : ''; + label.style.color = cb.checked ? 'var(--c-primary)' : ''; + }; + update(); + cb.addEventListener('change', update); + }); + document.getElementById('rec-save-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="rec-save-form"][type="submit"]'); const fd = UI.formData(e.target); + const dogIds = [...document.querySelectorAll('.rec-dog-cb:checked')].map(c => parseInt(c.value)); await UI.asyncButton(btn, async () => { const saved = await API.routes.create({ name: fd.name?.trim(), @@ -1604,6 +1825,7 @@ window.Page_map = (() => { leine_empfohlen: 'leine_empfohlen' in fd, is_public: 'is_public' in fd, hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', + dog_ids: dogIds.length ? dogIds : null, }); UI.modal.close(); if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } @@ -1628,13 +1850,18 @@ window.Page_map = (() => { const w = await API.weather.get(lat, lon); const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '–'; const icon = ``; - const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : ''; + const regen = w.precip_prob != null + ? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`) + : ''; + const warning = w.rain_warning_time + ? ` · ⚠ ab ${w.rain_warning_time}` + : ''; let zecken = ''; if (w.zecken_warnung) { const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E'; zecken = ` · `; } - info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${zecken}`; + info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`; info.classList.remove('map-weather-chip--hidden'); sep.classList.remove('map-weather-chip--hidden'); } catch { /* still */ } diff --git a/backend/static/js/pages/notifications.js b/backend/static/js/pages/notifications.js index 7ba6112..cedf85c 100644 --- a/backend/static/js/pages/notifications.js +++ b/backend/static/js/pages/notifications.js @@ -168,7 +168,7 @@ window.Page_notifications = (() => { ? App.callModule('poison', 'openDetail', { id: d.id }) : App.navigate('poison'); break; - case 'friend_request': App.navigate('friends'); break; + case 'friend_request': _openFriendRequestModal(); break; case 'health_reminder':App.navigate('health'); break; case 'milestone': App.navigate('diary'); break; default: @@ -379,5 +379,55 @@ window.Page_notifications = (() => { document.head.appendChild(style); } + async function _openFriendRequestModal() { + let requests; + try { requests = await API.friends.pending(); } catch { requests = []; } + + if (!requests.length) { + UI.toast.info('Keine offenen Freundschaftsanfragen.'); + return; + } + + const items = requests.map(r => ` +
+
+ ${r.avatar_url + ? `` + : ``} +
+
${UI.escape(r.requester_name)}
+ + +
`).join(''); + + UI.modal.open({ + title: 'Freundschaftsanfragen', + body: `
${items}
`, + }); + + document.querySelectorAll('[data-accept]').forEach(btn => { + btn.addEventListener('click', async () => { + await UI.asyncButton(btn, async () => { + await API.friends.accept(parseInt(btn.dataset.accept)); + UI.modal.close(); + UI.toast.success('Freundschaft angenommen!'); + }); + }); + }); + document.querySelectorAll('[data-decline]').forEach(btn => { + btn.addEventListener('click', async () => { + await UI.asyncButton(btn, async () => { + await API.friends.decline(parseInt(btn.dataset.decline)); + UI.modal.close(); + UI.toast.info('Anfrage abgelehnt.'); + }); + }); + }); + } + return { init }; })(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index deb71ae..a085587 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -320,7 +320,17 @@ window.Page_routes = (() => { if (actRow) actRow.style.display = 'none'; const filterPanel = document.getElementById('rk-filter-panel'); if (filterPanel) filterPanel.style.display = 'none'; - _renderSuggestTab(); + if (!App.hasPro(_appState?.user)) { + document.getElementById('rk-list')?.replaceChildren(); + const gate = document.createElement('div'); + gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)'; + gate.innerHTML = ` +
Ban Yaro Pro
+
Routenvorschläge sind ein Pro-Feature.
`; + document.getElementById('rk-list')?.appendChild(gate); + } else { + _renderSuggestTab(); + } } else { if (searchRow) searchRow.style.display = ''; if (actRow) actRow.style.display = ''; @@ -2146,6 +2156,7 @@ window.Page_routes = (() => { ${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')} ${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''} ${_actionBtn('rd-reverse', 'path', 'Umkehren')} + ${(_appState?.dogs?.length > 0) ? _actionBtn('rd-dogs', 'dog', 'Hunde') : ''} ${_actionBtn('rd-del', 'trash', 'Löschen', true)}
` : ''; @@ -2234,6 +2245,9 @@ window.Page_routes = (() => { } catch (err) { UI.toast.error(err.message); } }); + // Hunde bearbeiten + document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route)); + // Löschen document.getElementById('rd-del')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ @@ -2294,6 +2308,70 @@ window.Page_routes = (() => { } } + // ---------------------------------------------------------- + // Hunde einer Route bearbeiten + // ---------------------------------------------------------- + function _openEditDogsModal(route) { + const dogs = _appState?.dogs || []; + if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; } + + const currentIds = new Set(route.dog_ids || []); + + const dogRows = dogs.map(d => { + const checked = currentIds.has(d.id); + const av = d.foto_url + ? `` + : ``; + return ``; + }).join(''); + + const body = ` +

+ Welche Hunde waren bei dieser Route dabei? +

+
+ ${dogRows} +
+ `; + const footer = ` + + + `; + UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer }); + + // Checkbox-Pill Styling + document.querySelectorAll('.rd-dog-cb').forEach(cb => { + const label = cb.closest('label'); + cb.addEventListener('change', () => { + label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)'; + label.style.background = cb.checked ? 'var(--c-primary-subtle)' : ''; + label.style.color = cb.checked ? 'var(--c-primary)' : ''; + }); + }); + + document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('rd-dogs-save')?.addEventListener('click', async () => { + const btn = document.getElementById('rd-dogs-save'); + await UI.asyncButton(btn, async () => { + const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value)); + await API.routes.updateDogs(route.id, dogIds); + route.dog_ids = dogIds; + UI.modal.close(); + UI.toast.success('Hunde aktualisiert.'); + }); + }); + } + // Richtungspfeile gleichmäßig entlang des Tracks platzieren function _addRouteArrows(map, track, color = '#fff') { if (track.length < 2) return; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 9eed700..73e8ee5 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -245,6 +245,7 @@ window.Page_settings = (() => { Hunde-Profile
+
+ ${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
` + : `
+ +
`; + const jahr = d.verstorben_am ? d.verstorben_am.slice(0, 4) : ''; + return ` + `; + }).join(''); + el.querySelectorAll('.settings-erinnerung-btn').forEach(btn => { + btn.addEventListener('click', () => _openGedenkseite( + parseInt(btn.dataset.dogId), btn.dataset.dogName + )); + }); + }).catch(() => {}); + // Achievements laden (Streak + Stats + Badges) API.get('/achievements/me').then(a => { const statsEl = document.getElementById('settings-stats-body'); @@ -790,6 +829,52 @@ window.Page_settings = (() => { App.navigate('hilfe'); }); + document.getElementById('settings-feedback-btn')?.addEventListener('click', () => { + const sel = (id) => document.getElementById(id); + const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box'; + UI.modal.open({ + title: 'Feedback geben', + body: ` + +
+ + +
+
+ + +
+ + `, + footer: ` +
+ + +
+ `, + }); + + sel('feedback-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = sel('feedback-submit-btn'); + const kat = sel('feedback-kat')?.value; + const text = sel('feedback-text')?.value?.trim(); + if (!text) { UI.toast.error('Bitte schreib etwas.'); return; } + await UI.asyncButton(btn, async () => { + await API.post('/feedback', { kategorie: kat, text }); + UI.modal.close?.(); + UI.toast.success('Vielen Dank für dein Feedback!'); + }); + }); + }); + document.getElementById('settings-logout-btn')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title : 'Abmelden?', @@ -1404,6 +1489,80 @@ window.Page_settings = (() => { } catch { el.innerHTML = ''; } } + // ---------------------------------------------------------- + // GEDENKSEITE — für verstorbene Hunde + // ---------------------------------------------------------- + async function _openGedenkseite(dogId, dogName) { + UI.modal.open({ title: `Erinnerungen an ${_esc(dogName)}`, body: ` +
+ +
` }); + + let data; + try { data = await API.get(`/dogs/${dogId}/gedenkseite`); } + catch { UI.modal.close(); return; } + + const d = data; + const av = d.dog.foto_url + ? `` + : `
`; + + const photoGrid = d.photos?.length ? ` +
+ ${d.photos.map(url => ``).join('')} +
` : ''; + + const statsHtml = ` +
+ ${d.km_total ? `
+
${d.km_total}
+
km zusammen
+
` : ''} + ${d.diary_count ? `
+
${d.diary_count}
+
Tagebucheinträge
+
` : ''} + ${d.gemeinsam_tage ? `
+
${d.gemeinsam_tage}
+
gemeinsame Tage
+
` : ''} +
`; + + const passed = d.dog.verstorben_am; + const passedStr = passed + ? new Date(passed).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) + : ''; + + UI.modal.open({ + title: `Erinnerungen an ${_esc(d.dog.name)}`, + body: ` +
+ ${av} +
${_esc(d.dog.name)}
+ ${passedStr ? `
+ + ${passedStr} +
` : ''} +
+ ${statsHtml} + ${photoGrid} +
+

+ Der Schmerz über den Verlust eines Hundes ist real und tief. Lass dich trauern — die Erinnerungen bleiben immer bei dir. +

+
+ ${d.ki_abschied ? `
+ "${_esc(d.ki_abschied)}" +
` : ''} + `, + }); + } + // ---------------------------------------------------------- // NICHT EINGELOGGT — Login / Registrierung // ---------------------------------------------------------- diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js index dfa7bee..346a4ab 100644 --- a/backend/static/js/pages/trainingsplaene.js +++ b/backend/static/js/pages/trainingsplaene.js @@ -40,7 +40,8 @@ window.Page_trainingsplaene = (() => { } function _lsKey(planId, goalIdx) { - return `tp_${planId}_${goalIdx}`; + const dogId = _dogId() || 'x'; + return `tp_d${dogId}_${planId}_${goalIdx}`; } function _saveGoal(key, checked) { @@ -537,6 +538,8 @@ window.Page_trainingsplaene = (() => { // BIND EVENTS // ---------------------------------------------------------- function _bindEvents() { + UI.bindDogChip(_container, _appState); + // Notiz-Button const dogId = _dogId(); _container.querySelector('#tp-note-btn')?.addEventListener('click', e => { @@ -612,8 +615,9 @@ window.Page_trainingsplaene = (() => { : `Erwachsener Hund – ${_activeAdultTab}`; _container.innerHTML = ` -
-
+
+ ${UI.dogChip(_appState)} +

${_icon('clipboard-text')} Trainingspläne

@@ -862,7 +866,9 @@ window.Page_trainingsplaene = (() => { } function refresh() {} - function onDogChange() {} + function onDogChange() { + _render(); + } return { init, refresh, onDogChange }; diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index 9e554fe..c6ba308 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -75,6 +75,7 @@ window.Page_uebungen = (() => { // In-memory cache (loaded from API on init) let _progressCache = {}; // key → statusId + let _progressLoaded = false; let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend} function _progressKey(tab, name) { @@ -83,17 +84,13 @@ window.Page_uebungen = (() => { function _getStatus(tab, name) { const k = _progressKey(tab, name); - // Fallback to localStorage while API loads - return _progressCache[k] !== undefined - ? _progressCache[k] - : localStorage.getItem(_statusKey(tab, name)) || null; + return _progressCache[k] ?? null; } function _setStatus(tab, name, statusId) { const k = _progressKey(tab, name); _progressCache[k] = statusId; - localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync - API.training.setProgress(k, statusId).catch(() => {}); + API.training.setProgress(k, statusId, _dogId()).catch(() => {}); } function _nextStatus(currentId) { @@ -504,28 +501,19 @@ window.Page_uebungen = (() => { _scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' }; } - // Progress vom Server laden - API.training.getProgress().then(rows => { - rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); - // localStorage-Daten migrieren falls noch nicht im Backend - Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => { - const parts = lsKey.replace('ub_status_', '').split('_'); - const tab = parts[0]; - const name = parts.slice(1).join('_'); - const apiKey = `${tab}_${name}`; - if (_progressCache[apiKey] === undefined) { - const val = localStorage.getItem(lsKey); - if (val) { - _progressCache[apiKey] = val; - API.training.setProgress(apiKey, val).catch(() => {}); - } - } - }); - _renderContent(); // Re-render with loaded progress - }).catch(() => {}); + // Progress vom Server laden (hund-spezifisch) + const _did = _dogId(); + _progressLoaded = false; + API.training.getProgress(_did) + .then(rows => { + _progressCache = {}; + rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); + _progressLoaded = true; + _renderContent(); + }).catch(() => { _progressLoaded = true; _renderContent(); }); // Empfehlungen laden - API.training.getSuggestions().then(suggestions => { + API.training.getSuggestions(_did).then(suggestions => { if (suggestions.length) _showSuggestions(suggestions); }).catch(() => {}); @@ -553,8 +541,12 @@ window.Page_uebungen = (() => { _renderContent(); } function onDogChange() { - _statsData = null; - _badgesData = null; + _statsData = null; + _badgesData = null; + _progressCache = {}; + _progressLoaded = false; + _exerciseStats = {}; + _render(); _loadStatsAndBadges(); _loadVirtualTrainer(); } @@ -565,6 +557,7 @@ window.Page_uebungen = (() => { function _render() { _container.innerHTML = `
+
${UI.dogChip(_appState)}
@@ -601,6 +594,7 @@ window.Page_uebungen = (() => {
`; + UI.bindDogChip(_container, _appState); _container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal); _container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2)); _container.querySelector('#ueb-search')?.addEventListener('input', e => { @@ -610,7 +604,12 @@ window.Page_uebungen = (() => { _renderContent(); }); _bindTabs(); - _renderContent(); + if (_progressLoaded) { + _renderContent(); + } else { + const el = _container.querySelector('#ueb-content'); + if (el) el.innerHTML = `
`; + } _renderStatsBanner(); } @@ -779,7 +778,18 @@ window.Page_uebungen = (() => { // ---------------------------------------------------------- // SCHNELL-SETUP: Stand aller Übungen erfassen // ---------------------------------------------------------- - function _openQuickSetupModal() { + async function _openQuickSetupModal() { + // Sicherstellen dass Progress geladen ist bevor das Modal öffnet + if (!_progressLoaded) { + const did = _dogId(); + try { + const rows = await API.training.getProgress(did); + _progressCache = {}; + rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); + _progressLoaded = true; + _renderContent(); + } catch { _progressLoaded = true; } + } const ALL = [ { group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS }, { group: 'Tricks', tab: 'tricks', items: TRICKS }, @@ -880,11 +890,8 @@ window.Page_uebungen = (() => { // Alle geänderten Status speichern const parts = Object.entries(pending).map(([key, val]) => { - const [tab, ...rest] = key.split('_'); - const name = rest.join('_').replace(/_/g, ' '); _progressCache[key] = val || null; - localStorage.setItem(`ub_status_${key}`, val || ''); - return API.training.setProgress(key, val || null); + return API.training.setProgress(key, val || null, _dogId()); }); await Promise.allSettled(parts); @@ -1004,7 +1011,17 @@ window.Page_uebungen = (() => { break; } case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; - case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break; + case 'ki-trainer': + if (!App.hasPro(_appState?.user)) { + el.innerHTML = `
+ +
Ban Yaro Pro
+
Der KI-Trainer ist ein Pro-Feature.
+
`; + } else { + el.innerHTML = _renderKiTrainer(); + } + break; } _bindAccordions(); _bindStatusButtons(); diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index 8eae462..2dceccd 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -397,7 +397,9 @@ window.Page_wetter = (() => { : 0; } + const locName = _data.location_name ? `
${_esc(_data.location_name)}
` : ''; el.innerHTML = ` + ${locName}
${_wmoIcon(d.weathercode, '3.5rem')}
diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 7ec576a..38d6528 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -79,15 +79,34 @@ const UI = (() => { document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); - _current = { overlay, onClose }; + + // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint + let _vvCleanup = null; + const vv = window.visualViewport; + if (vv) { + const adjust = () => { + const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); + overlay.style.paddingBottom = (kb + 16) + 'px'; + }; + vv.addEventListener('resize', adjust); + vv.addEventListener('scroll', adjust); + _vvCleanup = () => { + vv.removeEventListener('resize', adjust); + vv.removeEventListener('scroll', adjust); + overlay.style.paddingBottom = ''; + }; + } + + _current = { overlay, onClose, _vvCleanup }; return overlay.querySelector('.modal'); } function close() { if (!_current) return; - const { onClose } = _current; + const { onClose, _vvCleanup } = _current; onClose?.(); + _vvCleanup?.(); _current.overlay.remove(); document.documentElement.classList.remove('modal-open'); _current = null; @@ -976,6 +995,30 @@ const UI = (() => { _load(); } + function dogChip(appState) { + const dog = appState?.activeDog; + const dogs = appState?.dogs || []; + if (!dog) return ''; + const av = dog.foto_url + ? `` + : ``; + const sw = dogs.length > 1 + ? `` : ''; + return `
${av}${escape(dog.name)}${sw}
`; + } + + function bindDogChip(container, appState) { + if ((appState?.dogs?.length || 0) < 2) return; + container.querySelector('[data-dog-chip]')?.addEventListener('click', () => { + const dogs = appState.dogs; + const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0]; + if (next) App.setActiveDog(next.id); + }); + } + // Öffentliche API return { toast, modal, @@ -990,6 +1033,10 @@ const UI = (() => { leafletMarker, locationPicker, ratingStars, + dogChip, + bindDogChip, + dogChip, + bindDogChip, }; })(); diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index f23f983..52dddee 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -346,7 +346,8 @@ window.Worlds = (() => {
${chips.map(c => ` -
`} + ${c.pro && _isRoleBasedPro() ? `P` : ''} @@ -969,10 +971,24 @@ window.Worlds = (() => { // ── CHIP-HELPER ────────────────────────────────────────────── - function _chip(icon, label, page, locked = false) { + function _isRoleBasedPro() { + const u = _state?.user; + if (!u) return false; + const t = u.subscription_tier || 'standard'; + if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false; + return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media; + } + + function _chip(icon, label, page, locked = false, proBadge = false) { const style = locked ? 'opacity:0.25;cursor:default;' : ''; + const badge = proBadge + ? `P` + : ''; return ` -
+
+ ${badge} @@ -1097,7 +1113,11 @@ window.Worlds = (() => { ${gassiScore ?? '—'} ${gassiScore ? `/10` : ''}
- ${w ? `${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen` : ''} + ${w ? `
+ ${w.location_name ? `
${w.location_name}
` : ''} +
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
+ ${w.rain_warning_time ? `
⚠ Umschwung ab ${w.rain_warning_time}
` : w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''} +
` : ''}
@@ -1119,7 +1139,7 @@ window.Worlds = (() => {
- ${features.map(f => _chip(f.icon, f.label, f.page)).join('')} + ${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro())).join('')}