diff --git a/backend/auth.py b/backend/auth.py index 55c63fc..a4d5af3 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index eeb1add..5e38a96 100644 --- a/backend/database.py +++ b/backend/database.py @@ -540,6 +540,9 @@ def _migrate(conn_factory): ("pflege_tipps", "fell_pflege_art", "TEXT"), # Wiki-Foto-Einreichungen: Bildrechte-Bestätigung ("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"), + # Tagebuch-Medien: Bildmaße für Querformat-Filter + ("diary_media", "img_width", "INTEGER"), + ("diary_media", "img_height", "INTEGER"), # Tagebuch: Wetter + POI-Metadaten beim Eintrag ("diary", "weather_json", "TEXT"), ("diary", "poi_json", "TEXT"), @@ -568,6 +571,11 @@ def _migrate(conn_factory): # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), + # Fell-Typ für personalisierte Wetter-Hinweise + ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt + # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz + ("tieraerzte", "avg_rating", "REAL DEFAULT 0"), + ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1923,6 +1931,44 @@ def _migrate(conn_factory): ) """) + # Welten-Chip-Konfiguration pro User + existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'world_config' not in existing_u: + conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT") + + # Tagessprüche-Pool + conn.executescript(""" + CREATE TABLE IF NOT EXISTS daily_quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + autor TEXT, + kategorie TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie); + """) + + # Goldene Gassi-Stunde: User-Einstellung + existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'gassi_stunde_push' not in existing_u: + conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0") + logger.info("Migration: users.gassi_stunde_push bereit.") + + # Futter-Profil + conn.executescript(""" + CREATE TABLE IF NOT EXISTS futter_profil ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE, + futter_typ TEXT, + marke TEXT, + kcal_tag INTEGER, + portionen INTEGER DEFAULT 2, + notizen TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + logger.info("Migration: futter_profil bereit.") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( @@ -1940,3 +1986,75 @@ def _migrate(conn_factory): ); CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); """) + + # ---- Tierarzt-Bewertungen ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS tierarzt_bewertungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5), + wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5), + freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5), + kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5), + text TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(tierarzt_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt + ON tierarzt_bewertungen(tierarzt_id); + """) + + # ---- Feature: Foto-Challenge der Woche ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS foto_challenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thema TEXT NOT NULL, + beschreibung TEXT, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS challenge_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + foto_url TEXT NOT NULL, + caption TEXT, + votes INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(challenge_id, user_id) + ); + CREATE TABLE IF NOT EXISTS challenge_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(submission_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal + ON challenge_submissions(challenge_id, created_at DESC); + """) + logger.info("Migration: Foto-Challenge-Tabellen bereit.") + + # ---- Feature: Gassi-Zeiten-Pool ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS gassi_zeiten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + wochentage TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + ort_name TEXT, + lat REAL, + lon REAL, + radius_m INTEGER DEFAULT 500, + notiz TEXT, + aktiv INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user + ON gassi_zeiten(user_id, aktiv); + """) + logger.info("Migration: Gassi-Zeiten-Tabelle bereit.") diff --git a/backend/main.py b/backend/main.py index 229a856..a883e9b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,9 +6,10 @@ import os import html import logging from collections import deque +import httpx from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware from brotli_asgi import BrotliMiddleware @@ -43,10 +44,43 @@ logger = logging.getLogger(__name__) # ------------------------------------------------------------------ # Startup / Shutdown # ------------------------------------------------------------------ +def _backfill_image_sizes(): + """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach.""" + import io + from database import db + from media_utils import get_image_size + MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + with db() as conn: + rows = conn.execute( + "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL" + ).fetchall() + if not rows: + return + logger.info("Backfill Bildmaße: %d Einträge...", len(rows)) + updated = 0 + for row in rows: + # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg + rel = row["url"].removeprefix("/media/") + path = os.path.join(MEDIA_DIR, rel) + try: + with open(path, "rb") as f: + data = f.read() + size = get_image_size(data) + if size: + with db() as conn: + conn.execute( + "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?", + (size[0], size[1], row["id"]) + ) + updated += 1 + except Exception: + pass + logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows)) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Ban Yaro startet...") init_db() + _backfill_image_sizes() from routes.movies import seed_movies seed_movies() logger.info(f"KI-Modus: {ki.KI_MODE}") @@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " @@ -198,6 +232,9 @@ from routes.adoption import router as adoption_router from routes.health_docs import router as health_docs_router from routes.passport import router as passport_router from routes.playdate import router as playdate_router +from routes.ernaehrung import router as ernaehrung_router +from routes.challenges import router as challenges_router +from routes.gassi_zeiten import router as gassi_zeiten_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -256,6 +293,9 @@ app.include_router(adoption_router, prefix="/api/adoption", ta app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) +app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"]) +app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"]) +app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) # ------------------------------------------------------------------ @@ -285,6 +325,39 @@ 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 = "715" # muss mit APP_VER in app.js übereinstimmen + +@app.get("/api/version") +async def app_version(): + """Aktuelle Frontend-Version — wird beim App-Start gecheckt.""" + return Response( + content=f'{{"version":"{APP_VER}"}}', + media_type="application/json", + headers={"Cache-Control": "no-store"}, + ) + + +@app.get("/stats/script.js") +async def umami_script_proxy(): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get("https://umami.motocamp.de/script.js") + return Response(content=r.content, media_type="application/javascript", + headers={"Cache-Control": "public, max-age=86400"}) + +@app.post("/stats/api/send") +async def umami_send_proxy(request: Request): + body = await request.body() + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + "https://umami.motocamp.de/api/send", + content=body, + headers={"Content-Type": "application/json", + "User-Agent": request.headers.get("user-agent", "")}, + ) + return Response(content=r.content, status_code=r.status_code, + media_type="application/json") + + @app.get("/robots.txt") async def robots(): return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain") diff --git a/backend/media_utils.py b/backend/media_utils.py index 8a8698f..4cb2e28 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -178,6 +178,17 @@ def generate_preview(data: bytes, ext: str) -> bytes | None: return None +def get_image_size(data: bytes) -> tuple[int, int] | None: + """Gibt (width, height) eines Bildes zurück, oder None bei Fehler.""" + try: + from PIL import Image, ImageOps + img = Image.open(io.BytesIO(data)) + img = ImageOps.exif_transpose(img) + return img.size # (width, height) + except Exception: + return None + + def preview_url_from(url: str | None) -> str | None: """Leitet die Preview-URL aus der Original-URL ab (fügt _preview vor Extension ein). Gibt None für Videos, PDFs, bereits generierte Previews und leere URLs zurück.""" diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index 1c0c1e8..e8d0cba 100644 --- a/backend/routes/achievements.py +++ b/backend/routes/achievements.py @@ -92,6 +92,59 @@ CATEGORIES = [ ("gold", 10, "Wiki-Fotograf"), ], }, + { + "id": "wetter_tapfer", + "name": "Wetter-Tapferkeit", + "emoji": "⛈️", + "metrik": "wetter_tapfer_score", + "einheit": " Eintrag/Einträge", + "stufen": [ + ("bronze", 1, "Regentrotzdem"), + ("silber", 5, "Wettertrotzer"), + ("gold", 15, "Allwetter-Held"), + ("platin", 30, "Hunde-Wetterheld"), + ], + }, + { + "id": "jahreszeiten", + "name": "Jahreszeiten-Erkunder", + "emoji": "🍃", + "metrik": "jahreszeiten_score", + "einheit": " Jahreszeit(en)", + "stufen": [ + ("bronze", 1, "Frühlings-Erkunder"), + ("silber", 2, "Sommer-Genießer"), + ("gold", 3, "Herbst-Schnüffler"), + ("platin", 4, "Alle-Jahreszeiten"), + ], + }, + { + "id": "schnee_held", + "name": "Schneeheld", + "emoji": "❄️", + "metrik": "schnee_eintraege", + "einheit": " Eintrag/Einträge", + "stufen": [ + ("bronze", 1, "Erster Schnee"), + ("silber", 5, "Schneehund"), + ("gold", 15, "Schneeheld"), + ("platin", 30, "Schneewolf"), + ], + }, + { + "id": "km_lebenswerk", + "name": "Kilometer-Lebenswerk", + "emoji": "🐾", + "metrik": "gesamt_km_lebenswerk", + "einheit": " km", + "icon": "path", + "stufen": [ + ("bronze", 100, "100-km-Club"), + ("silber", 500, "500-km-Wanderer"), + ("gold", 1000, "Tausend-km-Held"), + ("platin", 5000, "Ultraläufer"), + ], + }, ] # Flat-Liste aller Badge-IDs für DB-Kompatibilität @@ -150,12 +203,53 @@ def check_and_award(user_id: int, conn): "SELECT current_streak FROM users WHERE id=?", (user_id,) ).fetchone() + # Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter (über Dog-Join) + wetter_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary d + JOIN dogs dog ON dog.id = d.dog_id + WHERE dog.user_id = ? + AND d.weather_json IS NOT NULL + AND ( + CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60 + OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2 + OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50 + ) + """, (user_id,)).fetchone() + + # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (über Dog-Join) + jahreszeiten_row = conn.execute(""" + SELECT + (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id + WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id + WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id + WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id + WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END) + AS jahreszeiten_score + FROM (SELECT 1) + """, (user_id, user_id, user_id, user_id)).fetchone() + + # Schnee: Diary-Einträge bei Schnee (weathercode 71-77, über Dog-Join) + schnee_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary d + JOIN dogs dog ON dog.id = d.dog_id + WHERE dog.user_id = ? + AND d.weather_json IS NOT NULL + AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77 + """, (user_id,)).fetchone() + metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "gesamt_km_lebenswerk": stats["total_km"] if stats else 0, } earned = {r["badge_id"] for r in @@ -211,6 +305,38 @@ async def my_achievements(user=Depends(get_current_user)): "SELECT current_streak, max_streak FROM users WHERE id=?", (uid,) ).fetchone() + # Wetter-Tapferkeit + wetter_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.user_id = ? + AND d.weather_json IS NOT NULL + AND ( + CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60 + OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2 + OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50 + ) + """, (uid,)).fetchone() + + # Jahreszeiten + jahreszeiten_row = conn.execute(""" + SELECT + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END) + AS jahreszeiten_score + FROM (SELECT 1) + """, (uid, uid, uid, uid)).fetchone() + + # Schnee-Einträge + schnee_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary + WHERE user_id = ? + AND weather_json IS NOT NULL + AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77 + """, (uid,)).fetchone() + earned_rows = conn.execute( "SELECT badge_id FROM user_badges WHERE user_id=?", (uid,) ).fetchall() @@ -230,11 +356,15 @@ async def my_achievements(user=Depends(get_current_user)): """, (stats["punkte"] if stats else 0,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "gesamt_km_lebenswerk": stats["total_km"] if stats else 0, } # Kategorien mit aktuellem Tier + Fortschritt aufbauen diff --git a/backend/routes/challenges.py b/backend/routes/challenges.py new file mode 100644 index 0000000..f3e3f0d --- /dev/null +++ b/backend/routes/challenges.py @@ -0,0 +1,304 @@ +"""BAN YARO — Foto-Challenge der Woche""" + +import os +import uuid +import logging +from datetime import date, timedelta +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user, get_current_user_optional +from media_utils import convert_media, generate_preview + +logger = logging.getLogger(__name__) + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges") + +_CHALLENGE_THEMEN = [ + "Bestes Schnüffel-Foto 👃", + "Action-Aufnahme 🏃", + "Schlafendes Tier 😴", + "Gassi im Regen 🌧️", + "Hundeblick in die Kamera 👀", + "Spielzeit mit Freunden 🐕", + "Herbstspaziergang 🍂", + "Beste Sprung-Aufnahme 🦘", + "Hund am Wasser 🌊", + "Erstes Mal im Schnee ❄️", + "Genuss-Moment 🦴", + "Versteckt im Gebüsch 🌿", + "Tierisches Selfie 🤳", + "Hund & Kind 👶", + "Hund & Katze zusammen 🐱", + "Der beste Buddel-Moment 🐾", + "Freude beim Apportieren 🎾", + "Hund in seiner Lieblingshöhle 🛋️", + "Sonnenuntergangs-Gassi 🌅", + "Hundebegegnung auf dem Spaziergang 🐕🐕", + "Ausdrucksstarker Hundeblick 😍", + "Hund im Herbstlaub 🍁", + "Welpenfoto 🍼", + "Seniorenhund im Porträt 👴", + "Lustigste Schlafposition 💤", + "Hund trägt etwas 🎀", + "Hund + Besitzer Spiegelfoto 🪞", + "Hund auf Abenteuer 🏕️", + "Beste Lauf-Action 💨", + "Hund im Café ☕", +] + + +def _current_week_monday() -> str: + today = date.today() + monday = today - timedelta(days=today.weekday()) + return monday.isoformat() + + +def _current_week_sunday() -> str: + monday = date.fromisoformat(_current_week_monday()) + return (monday + timedelta(days=6)).isoformat() + + +def _ensure_current_challenge(conn) -> int: + """Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück.""" + monday = _current_week_monday() + sunday = _current_week_sunday() + + existing = conn.execute( + "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,) + ).fetchone() + if existing: + return existing["id"] + + # Thema aus Rotation wählen (Wochennummer % Anzahl Themen) + week_num = date.today().isocalendar()[1] + thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)] + + cur = conn.execute( + "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) " + "VALUES (?, ?, ?, ?, NULL)", + (thema, f"Diese Woche: {thema}", monday, sunday) + ) + return cur.lastrowid + + +# ------------------------------------------------------------------ +# GET /api/challenges/current +# ------------------------------------------------------------------ +@router.get("/current") +async def get_current_challenge(user=Depends(get_current_user_optional)): + with db() as conn: + challenge_id = _ensure_current_challenge(conn) + challenge = conn.execute( + "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,) + ).fetchone() + + submissions = conn.execute(""" + SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at, + u.name AS user_name, u.avatar_url, + d.name AS dog_name, d.foto_url AS dog_foto_url + FROM challenge_submissions cs + LEFT JOIN users u ON u.id = cs.user_id + LEFT JOIN dogs d ON d.id = cs.dog_id + WHERE cs.challenge_id = ? + ORDER BY cs.votes DESC, cs.created_at ASC + """, (challenge_id,)).fetchall() + + my_submission = None + my_votes = set() + if user: + mine = conn.execute( + "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?", + (challenge_id, user["id"]) + ).fetchone() + if mine: + my_submission = mine["id"] + voted_rows = conn.execute( + "SELECT cv.submission_id FROM challenge_votes cv " + "JOIN challenge_submissions cs ON cs.id = cv.submission_id " + "WHERE cv.user_id = ? AND cs.challenge_id = ?", + (user["id"], challenge_id) + ).fetchall() + my_votes = {r["submission_id"] for r in voted_rows} + + # Countdown bis Sonntag + end = date.fromisoformat(challenge["end_date"]) + days_left = (end - date.today()).days + 1 + + result_subs = [] + for s in submissions: + sd = dict(s) + sd["i_voted"] = (sd["id"] in my_votes) if user else False + result_subs.append(sd) + + return { + "challenge": dict(challenge), + "submissions": result_subs, + "my_submission_id": my_submission, + "days_left": max(0, days_left), + } + + +# ------------------------------------------------------------------ +# POST /api/challenges/{id}/submit +# ------------------------------------------------------------------ +@router.post("/{challenge_id}/submit", status_code=201) +async def submit_photo( + challenge_id: int, + caption: Optional[str] = Form(None), + dog_id: Optional[int] = Form(None), + foto: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + challenge = conn.execute( + "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,) + ).fetchone() + if not challenge: + raise HTTPException(404, "Challenge nicht gefunden.") + + today = date.today().isoformat() + if today > challenge["end_date"]: + raise HTTPException(400, "Die Challenge ist bereits beendet.") + + # Doppelt-Check + existing = conn.execute( + "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?", + (challenge_id, user["id"]) + ).fetchone() + if existing: + raise HTTPException(409, "Du hast bereits ein Foto eingereicht.") + + # Foto speichern + os.makedirs(CHALLENGE_DIR, exist_ok=True) + orig_filename = foto.filename or "foto.jpg" + ext = os.path.splitext(orig_filename)[1] or ".jpg" + base = uuid.uuid4().hex + + raw = await foto.read() + + # HEIC→JPEG Konvertierung falls nötig + try: + converted, out_ext = convert_media(raw, orig_filename) + except Exception: + converted, out_ext = raw, ext + + save_filename = f"{base}{out_ext}" + save_path = os.path.join(CHALLENGE_DIR, save_filename) + with open(save_path, "wb") as f: + f.write(converted) + foto_url = f"/media/challenges/{save_filename}" + + # Preview + try: + preview = generate_preview(converted, out_ext) + if preview: + prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp") + with open(prev_path, "wb") as f: + f.write(preview) + except Exception: + pass + + with db() as conn: + cur = conn.execute( + "INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) " + "VALUES (?, ?, ?, ?, ?)", + (challenge_id, user["id"], dog_id, foto_url, caption) + ) + row = conn.execute(""" + SELECT cs.*, u.name AS user_name, d.name AS dog_name + FROM challenge_submissions cs + LEFT JOIN users u ON u.id = cs.user_id + LEFT JOIN dogs d ON d.id = cs.dog_id + WHERE cs.id = ? + """, (cur.lastrowid,)).fetchone() + + return dict(row) + + +# ------------------------------------------------------------------ +# POST /api/challenges/submissions/{id}/vote — Toggle-Vote +# ------------------------------------------------------------------ +@router.post("/submissions/{submission_id}/vote") +async def vote_submission(submission_id: int, user=Depends(get_current_user)): + with db() as conn: + sub = conn.execute( + "SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,) + ).fetchone() + if not sub: + raise HTTPException(404, "Einreichung nicht gefunden.") + if sub["user_id"] == user["id"]: + raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.") + + existing = conn.execute( + "SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?", + (submission_id, user["id"]) + ).fetchone() + + if existing: + # Toggle: Vote entfernen + conn.execute( + "DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?", + (submission_id, user["id"]) + ) + conn.execute( + "UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?", + (submission_id,) + ) + voted = False + else: + conn.execute( + "INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)", + (submission_id, user["id"]) + ) + conn.execute( + "UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?", + (submission_id,) + ) + voted = True + + votes = conn.execute( + "SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,) + ).fetchone()["votes"] + + return {"voted": voted, "votes": votes} + + +# ------------------------------------------------------------------ +# GET /api/challenges/winners — letzte 4 Gewinner +# ------------------------------------------------------------------ +@router.get("/winners") +async def get_winners(): + with db() as conn: + # Vergangene Challenges (ohne aktuelle Woche) + monday = _current_week_monday() + challenges = conn.execute( + "SELECT id, thema, start_date, end_date FROM foto_challenge " + "WHERE end_date < ? ORDER BY end_date DESC LIMIT 4", + (monday,) + ).fetchall() + + winners = [] + for ch in challenges: + winner = conn.execute(""" + SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes, + u.name AS user_name, u.avatar_url, + d.name AS dog_name + FROM challenge_submissions cs + LEFT JOIN users u ON u.id = cs.user_id + LEFT JOIN dogs d ON d.id = cs.dog_id + WHERE cs.challenge_id = ? + ORDER BY cs.votes DESC, cs.created_at ASC + LIMIT 1 + """, (ch["id"],)).fetchone() + + winners.append({ + "challenge": dict(ch), + "winner": dict(winner) if winner else None, + }) + + return winners diff --git a/backend/routes/diary.py b/backend/routes/diary.py index a3dee2b..6f6cd12 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -9,7 +9,7 @@ from auth import get_current_user, require_admin import ki as KI import httpx import weather as weather_mod -from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from +from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size from timeutils import safe_client_time logger = logging.getLogger(__name__) @@ -30,6 +30,7 @@ class DiaryCreate(BaseModel): location_name: Optional[str] = None is_milestone: bool = False dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary + weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) class DiaryUpdate(BaseModel): @@ -350,6 +351,19 @@ async def create_diary(dog_id: int, data: DiaryCreate, ) entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() + elif data.weather_json: + # Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern + try: + json.loads(data.weather_json) # Validierung + with db() as conn: + conn.execute( + "UPDATE diary SET weather_json=? WHERE id=?", + (data.weather_json, entry_id) + ) + entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() + except Exception as exc: + logger.warning("Client-weather_json ungültig: %s", exc) + return _entry_dict(entry, dogs_map, media_map) @@ -692,10 +706,12 @@ async def upload_media(dog_id: int, entry_id: int, media_url = f"/media/diary/{filename}" - # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien) - exif_gps = None + # Bildmaße + EXIF-GPS (nur bei Bilddateien) + exif_gps = None + img_size = None if media_type == "image": exif_gps = extract_gps_from_exif(raw_data) + img_size = get_image_size(raw_data) with db() as conn: # sort_order = nächste freie Position @@ -706,8 +722,9 @@ async def upload_media(dog_id: int, entry_id: int, # Erstes Item eines Eintrags wird automatisch Cover is_cover = 1 if max_order == -1 else 0 conn.execute( - "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)", - (entry_id, media_url, media_type, max_order + 1, is_cover) + "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)", + (entry_id, media_url, media_type, max_order + 1, is_cover, + img_size[0] if img_size else None, img_size[1] if img_size else None) ) new_id = conn.execute( "SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index a44faa0..42b9b32 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Hund nicht gefunden.") # Zufälliges Foto aus den letzten 100 Tagebuchbildern + # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge 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 LIMIT 100""", + AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height + ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() + # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill) + 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, d.id DESC, dm.id ASC""", + (dog_id,) + ).fetchall() random_photo = None if photos: import datetime as _dt2 - day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days - chosen_url = photos[day_num % len(photos)]["url"] + tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days + chosen_url = photos[tick % len(photos)]["url"] random_photo = { "url": chosen_url, "preview_url": preview_url_from(chosen_url), @@ -294,6 +305,463 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): } +@router.get("/{dog_id}/wrapped") +async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)): + """Jahresrückblick ('Wrapped') für einen Hund.""" + import json as _json + from datetime import date as _date + + if year is None: + year = _date.today().year + + with db() as conn: + dog = conn.execute( + "SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + # km gelaufen (eigene Routen des Users) + gesamt_km_row = conn.execute( + "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y', created_at)=?", + (user["id"], str(year)) + ).fetchone() + gesamt_km = gesamt_km_row["km"] or 0.0 + + # Gassi-Tage (Distinct Datum in Diary) + gassi_tage = conn.execute( + "SELECT COUNT(DISTINCT datum) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Gesamte Einträge + eintraege_gesamt = conn.execute( + "SELECT COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Fotos gesamt + fotos_gesamt = conn.execute( + "SELECT COUNT(*) AS n FROM diary_media dm " + "JOIN diary d ON d.id=dm.diary_id " + "WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'", + (dog_id, str(year)) + ).fetchone()["n"] + + # Beste Route (längste distanz) + beste_route_row = conn.execute( + "SELECT MAX(distanz_km) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y', created_at)=?", + (user["id"], str(year)) + ).fetchone() + beste_route = beste_route_row["km"] or 0.0 + + # Lieblingsmonat (meiste diary-Einträge) + monat_rows = conn.execute( + "SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? " + "GROUP BY monat ORDER BY n DESC LIMIT 1", + (dog_id, str(year)) + ).fetchone() + lieblings_monat = None + if monat_rows: + _MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] + try: + lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1] + except Exception: + pass + + # Lieblingsaktivität (häufigster typ) + typ_row = conn.execute( + "SELECT typ, COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? " + "GROUP BY typ ORDER BY n DESC LIMIT 1", + (dog_id, str(year)) + ).fetchone() + lieblings_aktivitaet = typ_row["typ"] if typ_row else None + + # Training-Sessions + training_sessions = conn.execute( + "SELECT COUNT(*) AS n FROM training_sessions " + "WHERE dog_id=? AND strftime('%Y', created_at)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Gesundheits-Einträge + gesundheit_eintraege = conn.execute( + "SELECT COUNT(*) AS n FROM health " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json + wetter_kalt = 0 + wetter_warm = 0 + wetter_rows = conn.execute( + "SELECT weather_json FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL", + (dog_id, str(year)) + ).fetchall() + for wr in wetter_rows: + try: + wj = _json.loads(wr["weather_json"]) + temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp") + if temp is not None: + if float(temp) < 5: + wetter_kalt += 1 + elif float(temp) > 25: + wetter_warm += 1 + except Exception: + pass + + return { + "dog_id": dog_id, + "dog_name": dog["name"], + "year": year, + "gesamt_km": gesamt_km, + "gassi_tage": gassi_tage, + "eintraege_gesamt": eintraege_gesamt, + "fotos_gesamt": fotos_gesamt, + "beste_route": beste_route, + "lieblings_monat": lieblings_monat, + "lieblings_aktivitaet": lieblings_aktivitaet, + "training_sessions": training_sessions, + "gesundheit_eintraege": gesundheit_eintraege, + "wetter_kalt": wetter_kalt, + "wetter_warm": wetter_warm, + } + + +@router.get("/{dog_id}/buch") +async def get_hunde_buch( + dog_id: int, + jahr: int = None, + limit: int = 50, + nur_fotos: bool = False, + nur_meilensteine: bool = False, + user=Depends(get_current_user), +): + """Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege.""" + import json as _json + from datetime import date as _date + from fastapi.responses import HTMLResponse + from html import escape as _esc + + with db() as conn: + dog = conn.execute( + "SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + dog = dict(dog) + + # --- Eintraege laden --- + conditions = ["(d.dog_id=? OR dd.dog_id=?)"] + params: list = [dog_id, dog_id] + + if jahr: + conditions.append("strftime('%Y', d.datum) = ?") + params.append(str(jahr)) + + if nur_meilensteine: + conditions.append("d.is_milestone = 1") + + where = " AND ".join(conditions) + + rows = conn.execute( + f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags, + d.gps_lat, d.gps_lon, d.location_name, d.weather_json, + d.is_milestone, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id AND dm.media_type='image' + ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url + FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE {where} + AND d.datum IS NOT NULL + ORDER BY d.datum ASC""", + params + ).fetchall() + + rows = [dict(r) for r in rows] + + # Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus + if nur_fotos: + rows = [r for r in rows if r.get("cover_url")] + else: + # Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit + with_photo = [r for r in rows if r.get("cover_url")] + milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")] + rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")] + rows = with_photo + milestones + rest + rows.sort(key=lambda r: r["datum"] or "") + + rows = rows[:limit] + + # --- Hund-Alter berechnen --- + alter_str = "" + if dog.get("geburtstag"): + try: + geb = _date.fromisoformat(dog["geburtstag"]) + heute = _date.today() + jahre = (heute - geb).days // 365 + alter_str = f"{jahre} Jahre" + except Exception: + pass + + # --- HTML bauen --- + dog_name = _esc(dog["name"] or "Mein Hund") + rasse_str = _esc(dog.get("rasse") or "") + jahr_str = str(jahr) if jahr else "Alle Jahre" + foto_url = dog.get("foto_url") or "" + + cover_img = ( + f'{dog_name}' + if foto_url else + f'
🐾
' + ) + + subtitle_parts = [p for p in [rasse_str, alter_str] if p] + subtitle = " · ".join(subtitle_parts) + + _MONATE = ["Januar","Februar","März","April","Mai","Juni", + "Juli","August","September","Oktober","November","Dezember"] + + def _fmt_datum(iso: str) -> str: + try: + d = _date.fromisoformat(iso) + return f"{d.day}. {_MONATE[d.month - 1]} {d.year}" + except Exception: + return iso or "" + + def _wetter_chip(wj_str: str) -> str: + if not wj_str: + return "" + try: + wj = _json.loads(wj_str) + temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp") + if temp is None: + return "" + temp_i = int(float(temp)) + emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅") + return f'{emoji} {temp_i}°C' + except Exception: + return "" + + entries_html = "" + for e in rows: + milestone_class = "milestone" if e.get("is_milestone") else "" + datum_fmt = _fmt_datum(e.get("datum") or "") + titel = _esc(e.get("titel") or "") + text_raw = e.get("text") or "" + text = _esc(text_raw).replace("\n", "
") + wetter = _wetter_chip(e.get("weather_json") or "") + loc = _esc(e.get("location_name") or "") + cover = e.get("cover_url") or "" + + foto_html = "" + if cover: + foto_html = ( + f'
' + f'' + f'
' + ) + + loc_html = f'📍 {loc}' if loc else "" + chips_html = f'
{wetter}{loc_html}
' if (wetter or loc_html) else "" + titel_html = f'
{titel}
' if titel else "" + text_html = f'
{text}
' if text_raw else "" + + entries_html += f""" +
+ {foto_html} +
{datum_fmt}
+ {titel_html} + {text_html} + {chips_html} +
+""" + + anzahl = len(rows) + html_page = f""" + + + + + Hunde-Buch — {dog_name} + + + + + + +
+ {cover_img} +

{dog_name}

+ {'
' + subtitle + '
' if subtitle else ''} +
{jahr_str}
+
{anzahl} Einträge
+
+ +{entries_html} + + +""" + + return HTMLResponse(content=html_page) + + @router.get("/{dog_id}") async def get_dog(dog_id: int, user=Depends(get_current_user)): with db() as conn: @@ -622,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)): "kategorien": list(dict.fromkeys(t["kategorie"] for t in result)), "fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None } + + +# ------------------------------------------------------------------ +# LEBENS-TIMELINE +# ------------------------------------------------------------------ +@router.get("/{dog_id}/timeline") +async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)): + """Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen.""" + import json as _json + + with db() as conn: + dog = conn.execute( + "SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + events = [] + + with db() as conn: + # --- Tagebuch --- + diary_rows = conn.execute( + """SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone, + dm.url AS foto_url + FROM diary d + LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0 + WHERE d.dog_id=? + ORDER BY d.datum ASC, d.id ASC""", + (dog_id,) + ).fetchall() + + for i, r in enumerate(diary_rows): + events.append({ + "datum": r["datum"], + "kategorie": "tagebuch", + "titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()), + "typ": r["typ"], + "is_first": i == 0, + "is_milestone": bool(r["is_milestone"]), + "foto_url": r["foto_url"], + "ref_id": r["id"], + }) + + # --- Gesundheit --- + health_rows = conn.execute( + """SELECT id, datum, bezeichnung, typ + FROM health + WHERE dog_id=? + ORDER BY datum ASC, id ASC""", + (dog_id,) + ).fetchall() + + typ_seen = {} + for r in health_rows: + t = r["typ"] + is_first = t not in typ_seen + if is_first: + typ_seen[t] = True + events.append({ + "datum": r["datum"], + "kategorie": "gesundheit", + "titel": r["bezeichnung"], + "typ": t, + "is_first": is_first, + "is_milestone": False, + "foto_url": None, + "ref_id": r["id"], + }) + + # --- Training-Sessions --- + ts_rows = conn.execute( + """SELECT id, datum, exercise_name, erfolgsquote, ist_top + FROM training_sessions + WHERE dog_id=? AND user_id=? + ORDER BY datum ASC, id ASC""", + (dog_id, user["id"]) + ).fetchall() + + ts_first = True + ts_best = None + ts_best_score = -1 + for r in ts_rows: + if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score: + ts_best_score = r["erfolgsquote"] + ts_best = r + + for i, r in enumerate(ts_rows): + is_first = (i == 0) + is_best = ts_best and r["id"] == ts_best["id"] and i > 0 + events.append({ + "datum": r["datum"], + "kategorie": "training", + "titel": r["exercise_name"], + "typ": "training", + "is_first": is_first, + "is_milestone": bool(r["ist_top"]) or is_best, + "foto_url": None, + "ref_id": r["id"], + }) + + # --- Routen --- + 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"],) + ).fetchall() + + route_first = True + route_longest = None + route_max_km = -1 + for r in route_rows: + km = r["distanz_km"] or 0 + if km > route_max_km: + route_max_km = km + route_longest = r + + for i, r in enumerate(route_rows): + is_first = (i == 0) + is_longest = route_longest and r["id"] == route_longest["id"] and i > 0 + events.append({ + "datum": r["datum"], + "kategorie": "route", + "titel": r["name"], + "typ": "route", + "is_first": is_first, + "is_milestone": is_longest, + "foto_url": None, + "ref_id": r["id"], + "distanz_km": r["distanz_km"], + }) + + # Geburtstag des Hundes als erster Eintrag + if dog["geburtstag"]: + events.append({ + "datum": dog["geburtstag"], + "kategorie": "meilenstein", + "titel": f"{dog['name']} wird geboren", + "typ": "geburtstag", + "is_first": True, + "is_milestone": True, + "foto_url": None, + "ref_id": None, + }) + + # Chronologisch sortieren + events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"])) + + return { + "dog_name": dog["name"], + "geburtstag": dog["geburtstag"], + "events": events, + } diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py new file mode 100644 index 0000000..c1f850e --- /dev/null +++ b/backend/routes/ernaehrung.py @@ -0,0 +1,145 @@ +"""BAN YARO — Ernährungs-Routes""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user +import ki as ki_module + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class FutterProfilUpdate(BaseModel): + futter_typ: Optional[str] = None # trocken|nass|barf|mix + marke: Optional[str] = None + kcal_tag: Optional[int] = None + portionen: Optional[int] = None + notizen: Optional[str] = None + + +class KiBeratungRequest(BaseModel): + frage: str + dog_name: Optional[str] = None + rasse: Optional[str] = None + alter: Optional[str] = None + gewicht: Optional[float] = None + aktiv: Optional[bool] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Zugriffsprüfung +# ------------------------------------------------------------------ +def _check_dog_access(conn, dog_id: int, user_id: int): + row = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Hund nicht gefunden.") + + +# ------------------------------------------------------------------ +# GET /dogs/{dog_id}/ernaehrung +# ------------------------------------------------------------------ +@router.get("/{dog_id}/ernaehrung") +async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + row = conn.execute( + "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + if not row: + return {} + return dict(row) + + +# ------------------------------------------------------------------ +# PUT /dogs/{dog_id}/ernaehrung +# ------------------------------------------------------------------ +@router.put("/{dog_id}/ernaehrung") +async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + existing = conn.execute( + "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + if existing: + conn.execute(""" + UPDATE futter_profil + SET futter_typ=COALESCE(?, futter_typ), + marke=COALESCE(?, marke), + kcal_tag=COALESCE(?, kcal_tag), + portionen=COALESCE(?, portionen), + notizen=COALESCE(?, notizen), + updated_at=datetime('now') + WHERE dog_id=? + """, (body.futter_typ, body.marke, body.kcal_tag, + body.portionen, body.notizen, dog_id)) + else: + conn.execute(""" + INSERT INTO futter_profil + (dog_id, futter_typ, marke, kcal_tag, portionen, notizen) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, body.futter_typ, body.marke, body.kcal_tag, + body.portionen or 2, body.notizen)) + row = conn.execute( + "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# POST /dogs/{dog_id}/ernaehrung/ki-beratung +# ------------------------------------------------------------------ +@router.post("/{dog_id}/ernaehrung/ki-beratung") +async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest, + request: Request, + user=Depends(get_current_user)): + if not body.frage or len(body.frage.strip()) < 3: + raise HTTPException(400, "Bitte stelle eine Frage.") + if len(body.frage) > 800: + raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).") + + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + + dog_name = body.dog_name or "unbekannt" + rasse = body.rasse or "unbekannt" + alter = body.alter or "unbekannt" + gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt" + aktiv_str = "aktiv" if body.aktiv else "normal aktiv" + + system = ( + "Du bist Ernährungsberater für Hunde. " + "Antworte immer auf Deutsch, kurz und praktisch. " + "Keine unnötigen Füllsätze. " + "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. " + "Stelle keine medizinischen Diagnosen." + ) + + prompt = ( + f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, " + f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n" + f"Frage: {body.frage.strip()}\n\n" + "Antworte konkret und praktisch, maximal 200 Wörter." + ) + + try: + antwort = await ki_module.complete( + prompt=prompt, + system=system, + max_tokens=500, + requires_premium=False, + user_id=user["id"], + ) + return {"antwort": antwort} + except ki_module.KIUnavailableError as e: + raise HTTPException(503, str(e)) + except Exception: + raise HTTPException(500, "KI momentan nicht verfügbar.") diff --git a/backend/routes/friends.py b/backend/routes/friends.py index cac5f4b..7df14e4 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)): AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)) """, (uid, friend_user_id, friend_user_id, uid)) return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/friends/same-breed — andere User mit gleicher Rasse +# ------------------------------------------------------------------ +@router.get("/same-breed") +async def same_breed(user=Depends(get_current_user)): + """Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück.""" + uid = user["id"] + with db() as conn: + # Rassen des eingeloggten Users + my_dogs = conn.execute( + "SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''", + (uid,) + ).fetchall() + + if not my_dogs: + return {"count": 0, "rassen": [], "forum_query": None} + + rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]}) + + # Andere User (nicht ich) die eine dieser Rassen haben + ph = ",".join("?" * len(rassen)) + count_row = conn.execute(f""" + SELECT COUNT(DISTINCT d.user_id) AS cnt + FROM dogs d + WHERE d.user_id != ? + AND d.rasse IN ({ph}) + """, (uid, *rassen)).fetchone() + + count = count_row["cnt"] if count_row else 0 + + # Für jede Rasse: wie viele andere User + rassen_detail = [] + for rasse in rassen: + n = conn.execute( + "SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs " + "WHERE user_id != ? AND rasse = ?", + (uid, rasse) + ).fetchone()["cnt"] + if n > 0: + rassen_detail.append({"rasse": rasse, "count": n}) + + rassen_detail.sort(key=lambda x: -x["count"]) + + # Forum-Suche-Link für die häufigste Rasse + forum_query = rassen_detail[0]["rasse"] if rassen_detail else None + + return { + "count": count, + "rassen": rassen_detail, + "forum_query": forum_query, + } diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py new file mode 100644 index 0000000..77ff52f --- /dev/null +++ b/backend/routes/gassi_zeiten.py @@ -0,0 +1,190 @@ +"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)""" + +import json +import math +import logging +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from database import db +from auth import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _haversine(lat1, lon1, lat2, lon2): + R = 6_371_000 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +class GassiZeitCreate(BaseModel): + dog_id: Optional[int] = None + wochentage: List[str] # ["mo", "mi", "fr"] + uhrzeit: str # "17:00" + ort_name: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + radius_m: int = 500 + notiz: Optional[str] = None + + +class GassiZeitUpdate(BaseModel): + aktiv: Optional[int] = None + + +# ------------------------------------------------------------------ +# GET /api/gassi-zeiten — alle in der Nähe (oder eigene) +# ------------------------------------------------------------------ +@router.get("") +async def list_gassi_zeiten( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 5000, # Meter + nur_eigene: bool = False, + user=Depends(get_current_user), +): + with db() as conn: + if nur_eigene: + rows = conn.execute(""" + SELECT gz.*, u.name AS user_name, u.avatar_url, + d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse + FROM gassi_zeiten gz + LEFT JOIN users u ON u.id = gz.user_id + LEFT JOIN dogs d ON d.id = gz.dog_id + WHERE gz.user_id = ? + ORDER BY gz.uhrzeit ASC + """, (user["id"],)).fetchall() + else: + rows = conn.execute(""" + SELECT gz.*, u.name AS user_name, u.avatar_url, + d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse + FROM gassi_zeiten gz + LEFT JOIN users u ON u.id = gz.user_id + LEFT JOIN dogs d ON d.id = gz.dog_id + WHERE gz.aktiv = 1 + ORDER BY gz.uhrzeit ASC + """).fetchall() + + result = [] + for r in rows: + d = dict(r) + # wochentage JSON parsen + try: + d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"] + except Exception: + d["wochentage"] = [] + d["is_mine"] = (d["user_id"] == user["id"]) + + # Distanz-Filter + if lat is not None and lon is not None and d.get("lat") and d.get("lon"): + dist = _haversine(lat, lon, d["lat"], d["lon"]) + if not nur_eigene and dist > radius: + continue + d["distance_m"] = int(dist) + else: + d["distance_m"] = None + + result.append(d) + + # Sortierung: eigene zuerst, dann nach Distanz + result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999)) + return result + + +# ------------------------------------------------------------------ +# POST /api/gassi-zeiten — eigene Zeit anlegen +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)): + if not data.wochentage: + raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.") + if not data.uhrzeit: + raise HTTPException(400, "Uhrzeit muss angegeben werden.") + + wochentage_json = json.dumps(data.wochentage) + + with db() as conn: + # Hund-Prüfung + if data.dog_id: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.") + + cur = conn.execute(""" + INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit, + ort_name, lat, lon, radius_m, notiz) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user["id"], data.dog_id, wochentage_json, data.uhrzeit, + data.ort_name, data.lat, data.lon, data.radius_m, data.notiz)) + + row = conn.execute(""" + SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url + FROM gassi_zeiten gz + LEFT JOIN users u ON u.id = gz.user_id + LEFT JOIN dogs d ON d.id = gz.dog_id + WHERE gz.id = ? + """, (cur.lastrowid,)).fetchone() + + result = dict(row) + try: + result["wochentage"] = json.loads(result["wochentage"]) + except Exception: + pass + result["is_mine"] = True + return result + + +# ------------------------------------------------------------------ +# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren +# ------------------------------------------------------------------ +@router.patch("/{gz_id}") +async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)): + with db() as conn: + gz = conn.execute( + "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,) + ).fetchone() + if not gz: + raise HTTPException(404, "Gassi-Zeit nicht gefunden.") + if gz["user_id"] != user["id"]: + raise HTTPException(403, "Nicht deine Gassi-Zeit.") + + updates = data.model_dump(exclude_none=True) + if updates: + cols = ", ".join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id]) + + row = conn.execute( + "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,) + ).fetchone() + + result = dict(row) + try: + result["wochentage"] = json.loads(result["wochentage"]) + except Exception: + pass + result["is_mine"] = True + return result + + +# ------------------------------------------------------------------ +# DELETE /api/gassi-zeiten/{id} +# ------------------------------------------------------------------ +@router.delete("/{gz_id}", status_code=204) +async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)): + with db() as conn: + gz = conn.execute( + "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,) + ).fetchone() + if not gz: + raise HTTPException(404, "Gassi-Zeit nicht gefunden.") + if gz["user_id"] != user["id"]: + raise HTTPException(403, "Nicht deine Gassi-Zeit.") + conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,)) diff --git a/backend/routes/osm.py b/backend/routes/osm.py index e08742b..998cd4b 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -279,9 +279,15 @@ class UserPoiIn(BaseModel): ALLOWED_TYPES = { 'waste_basket', 'drinking_water', 'dog_park', - 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius) - 'kotbeutel', # Kotbeutelspender + 'giftkoeder', # Giftköder (exklusiv, kein Kombi) 'gefahr', # Allgemeine Gefahr / Hinweis + 'freilauf', # Freilauffläche + 'restaurant', # Hundefreundliches Restaurant / Café + 'shop', # Hundefreundlicher Shop + 'tierarzt', # Tierarzt / Tierklinik + 'hundeschule', # Hundeschule / Trainer + 'kotbeutel', # Kotbeutelspender + 'bank', # Sitzbank 'parkplatz', # Hundefreundlicher Parkplatz 'treffpunkt', # Treffpunkt für Hundehalter 'sonstiges', @@ -289,7 +295,8 @@ ALLOWED_TYPES = { @router.post('/user-poi') async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)): - if body.type not in ALLOWED_TYPES: + types = [t.strip() for t in body.type.split(',') if t.strip()] + if not types or any(t not in ALLOWED_TYPES for t in types): raise HTTPException(400, 'Ungültiger Typ') with db() as conn: row = conn.execute(""" diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 08f403a..9762f95 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -26,6 +26,7 @@ class ProfileUpdate(BaseModel): social_link: Optional[str] = None profil_sichtbarkeit: Optional[str] = None notes_ki_enabled: Optional[int] = None + gassi_stunde_push: Optional[int] = None def _load_user(user_id: int) -> dict: @@ -113,3 +114,28 @@ async def upload_avatar( ) return {"avatar_url": avatar_url} + + +# ---------------------------------------------------------- +# GET /profile/world-config — Welten-Chip-Konfiguration laden +# PUT /profile/world-config — Welten-Chip-Konfiguration speichern +# ---------------------------------------------------------- +import json as _json + +@router.get('/world-config') +async def get_world_config(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone() + cfg = row['world_config'] if row and row['world_config'] else None + return {"config": _json.loads(cfg) if cfg else None} + + +class WorldConfigIn(BaseModel): + config: dict + +@router.put('/world-config') +async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)): + with db() as conn: + conn.execute("UPDATE users SET world_config=? WHERE id=?", + (_json.dumps(body.config), user['id'])) + return {"status": "ok"} diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 48287f9..8448478 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -27,6 +27,14 @@ class TierarztCreate(BaseModel): osm_id: Optional[str] = None +class BewertungCreate(BaseModel): + gesamt: int + wartezeit: Optional[int] = None + freundlichkeit: Optional[int] = None + kompetenz: Optional[int] = None + text: Optional[str] = None + + class TierarztUpdate(BaseModel): name: Optional[str] = None strasse: Optional[str] = None @@ -220,3 +228,109 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate, ) row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() return dict(row) + + +# ------------------------------------------------------------------ +# BEWERTUNGEN +# ------------------------------------------------------------------ + +def _refresh_vet_rating(conn, tierarzt_id: int): + """Aktualisiert avg_rating und anz_bewertungen in tieraerzte.""" + row = conn.execute( + """SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg + FROM tierarzt_bewertungen WHERE tierarzt_id=?""", + (tierarzt_id,) + ).fetchone() + n = row["n"] or 0 + avg = row["avg"] or 0.0 + conn.execute( + "UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?", + (round(avg, 1), n, tierarzt_id) + ) + + +@router.post("/{tierarzt_id}/bewertung", status_code=201) +async def create_bewertung(tierarzt_id: int, data: BewertungCreate, + user=Depends(get_current_user)): + """Bewertung abgeben (1×pro User+Tierarzt, UPSERT).""" + if not (1 <= data.gesamt <= 5): + raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.") + for field in ("wartezeit", "freundlichkeit", "kompetenz"): + val = getattr(data, field) + if val is not None and not (1 <= val <= 5): + raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.") + + text = (data.text or "").strip()[:500] or None + + with db() as conn: + vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + conn.execute( + """INSERT INTO tierarzt_bewertungen + (tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET + gesamt=excluded.gesamt, + wartezeit=excluded.wartezeit, + freundlichkeit=excluded.freundlichkeit, + kompetenz=excluded.kompetenz, + text=excluded.text, + created_at=datetime('now')""", + (tierarzt_id, user["id"], data.gesamt, data.wartezeit, + data.freundlichkeit, data.kompetenz, text) + ) + _refresh_vet_rating(conn, tierarzt_id) + row = conn.execute( + "SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,) + ).fetchone() + return dict(row) + + +@router.get("/{tierarzt_id}/bewertungen") +async def list_bewertungen(tierarzt_id: int): + """Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte.""" + with db() as conn: + vet = conn.execute( + "SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?", + (tierarzt_id,) + ).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + # Stern-Verteilung + verteilung = {} + for star in range(1, 6): + r = conn.execute( + "SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?", + (tierarzt_id, star) + ).fetchone() + verteilung[str(star)] = r["n"] + + # Letzte 5 Kommentare + kommentare = conn.execute( + """SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at + FROM tierarzt_bewertungen + WHERE tierarzt_id=? AND text IS NOT NULL AND text != '' + ORDER BY created_at DESC LIMIT 5""", + (tierarzt_id,) + ).fetchall() + + return { + "avg_rating": vet["avg_rating"] or 0, + "anz_bewertungen": vet["anz_bewertungen"] or 0, + "verteilung": verteilung, + "kommentare": [dict(k) for k in kommentare], + } + + +@router.get("/{tierarzt_id}/meine-bewertung") +async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)): + """Eigene Bewertung für einen Tierarzt (oder null).""" + with db() as conn: + row = conn.execute( + "SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?", + (tierarzt_id, user["id"]) + ).fetchone() + return dict(row) if row else None diff --git a/backend/routes/weather.py b/backend/routes/weather.py index fced719..2167b19 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,9 +3,11 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ +import json from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module from auth import get_current_user +from database import db router = APIRouter() @@ -31,3 +33,58 @@ async def get_weather_forecast( return await weather_module.get_forecast(lat, lon) except Exception as exc: raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}') + + +@router.get('/records') +async def weather_records(user=Depends(get_current_user)): + """Persönliche Wetterrekorde aus diary-Einträgen mit weather_json.""" + uid = user["id"] + with db() as conn: + rows = conn.execute(""" + SELECT d.datum, d.weather_json, d.titel + FROM diary d + JOIN dogs dog ON dog.id = d.dog_id + WHERE dog.user_id = ? AND d.weather_json IS NOT NULL + ORDER BY d.datum ASC + """, (uid,)).fetchall() + + if not rows: + return {"records": None} + + entries = [] + for r in rows: + try: + w = json.loads(r["weather_json"]) + entries.append({ + "datum": r["datum"], + "titel": r["titel"], + "temp_c": w.get("temp_c"), + "wind_kmh": w.get("wind_kmh"), + "precip_prob": w.get("precip_prob"), + "desc": w.get("desc", ""), + "weathercode": w.get("weathercode"), + }) + except Exception: + pass + + if not entries: + return {"records": None} + + temps = [e for e in entries if e["temp_c"] is not None] + winds = [e for e in entries if e["wind_kmh"] is not None] + + records = {} + if temps: + kaeltester = min(temps, key=lambda e: e["temp_c"]) + heissester = max(temps, key=lambda e: e["temp_c"]) + records["kaeltester"] = kaeltester + records["heissester"] = heissester + if winds: + stuermischster = max(winds, key=lambda e: e["wind_kmh"]) + records["stuermischster"] = stuermischster + + regen_count = sum(1 for e in entries if (e.get("precip_prob") or 0) > 60) + records["regen_eintraege"] = regen_count + records["gesamt_eintraege"] = len(entries) + + return {"records": records} diff --git a/backend/routes/widget.py b/backend/routes/widget.py index f5cc940..2c04ae8 100644 --- a/backend/routes/widget.py +++ b/backend/routes/widget.py @@ -1,13 +1,33 @@ -"""BAN YARO — Widget-Snapshot Endpoint""" +"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints""" -import json, random -from fastapi import APIRouter, Depends +import json +from datetime import date +from fastapi import APIRouter, Depends, Query +from typing import Optional from database import db from auth import get_current_user router = APIRouter() +@router.get("/quote") +async def daily_quote(kategorie: Optional[str] = Query(None)): + """Liefert einen deterministischen Tagesspruch (wechselt täglich).""" + day_num = (date.today() - date(2026, 1, 1)).days + with db() as conn: + if kategorie: + rows = conn.execute( + "SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?", + (kategorie,) + ).fetchall() + else: + rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall() + if not rows: + return {"quote": None} + q = rows[day_num % len(rows)] + return {"quote": dict(q)} + + @router.get("/snapshot") async def widget_snapshot(user=Depends(get_current_user)): """Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild.""" @@ -39,7 +59,8 @@ async def widget_snapshot(user=Depends(get_current_user)): (dog_id,) ).fetchall() - random_photo = dict(random.choice(photos)) if photos else None + day_num = (date.today() - date(2024, 1, 1)).days + random_photo = dict(photos[day_num % len(photos)]) if photos else None # Anzahl überfälliger Erinnerungen overdue = conn.execute( diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index 45f5bfb..56df55d 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -414,7 +414,7 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur raise HTTPException(404, "Einreichung nicht gefunden.") rasse = conn.execute( - "SELECT id, external_id, slug FROM wiki_rassen WHERE id=?", + "SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?", (sub["rasse_id"],) ).fetchone() diff --git a/backend/scheduler.py b/backend/scheduler.py index 4aeb89a..22d1533 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -156,8 +156,40 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen + _scheduler.add_job( + _job_new_foto_challenge, + CronTrigger(day_of_week='mon', hour=8, minute=0), + id="new_foto_challenge", + replace_existing=True, + misfire_grace_time=3600, + ) + # Täglich 07:00 Uhr — Goldene Gassi-Stunde + _scheduler.add_job( + _job_golden_gassi_hour, + CronTrigger(hour=7, minute=0), + id="golden_gassi_hour", + replace_existing=True, + misfire_grace_time=3600, + ) + # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) + _scheduler.add_job( + _job_anniversary_reminders, + CronTrigger(hour=9, minute=0), + id="anniversary_reminders", + replace_existing=True, + misfire_grace_time=3600, + ) + # 1. des Monats 10:00 — Monatlicher Rückblick per Push + _scheduler.add_job( + _job_monthly_recap, + CronTrigger(day=1, hour=10, minute=0), + id="monthly_recap", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -881,6 +913,9 @@ async def _job_status_report(): "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", "streak_reminder": "Streak-Erinnerung (täglich 19:00)", "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", + "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)", + "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)", + "monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)", } job_rows_html = "" job_rows_txt = "" @@ -1288,3 +1323,369 @@ async def _job_recurring_expenses(): except Exception as e: logger.error(f"Daueraufträge-Job Fehler: {e}") _log_job("recurring_expenses", "error", str(e)) + + +# ------------------------------------------------------------------ +# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr) +# ------------------------------------------------------------------ +async def _job_golden_gassi_hour(): + """ + Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1) + das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification. + + Score-Logik pro Stunde (max. 10 Punkte): + - Temperatur 10–20°C → +3 + - Temperatur 5–10°C → +1 + - Niederschlagswahrsch. <20% → +3, <40% → +1 + - Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1 + - Stunden 07–19 Uhr (Tageslicht) → +2 + Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden). + """ + import httpx + from datetime import date as _date + + logger.info("Goldene-Gassi-Stunde Job läuft") + + # Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription + with db() as conn: + users = conn.execute(""" + SELECT DISTINCT u.id AS user_id, + ps.last_lat, ps.last_lon + FROM users u + JOIN push_subscriptions ps ON ps.user_id = u.id + WHERE u.gassi_stunde_push = 1 + """).fetchall() + + users = [dict(u) for u in users] + logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.") + + if not users: + _log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv") + return + + sent_total = 0 + + for u in users: + lat = u["last_lat"] or 48.1351 # Fallback: München + lon = u["last_lon"] or 11.5820 + + try: + hourly = await _fetch_hourly_weather(lat, lon) + except Exception as e: + logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}") + continue + + if not hourly: + continue + + best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly) + + if best_score < 3: + # Heute kein gutes Wetterfenster → kein Push + logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})") + continue + + hour_end = (best_start + 2) % 24 + temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–" + wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else ( + f"{best_wind:.0f} km/h Wind" if best_wind is not None else "") + + body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr", + f"· {temp_str}"] + if wind_str: + body_parts.append(f"· {wind_str}") + + sent = send_push_to_user(u["user_id"], { + "type": "golden_gassi_hour", + "title": "☀️ Goldene Gassi-Stunde heute!", + "body": " ".join(body_parts), + "data": {"page": "wetter"}, + "tag": f"gassi-{_date.today()}", + }) + sent_total += sent + logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}") + + logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.") + _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User") + + +# ------------------------------------------------------------------ +# JOB: Jahrestags-Erinnerungen (täglich 09:00) +# ------------------------------------------------------------------ +async def _job_anniversary_reminders(): + """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push.""" + today = datetime.now(tz=_TZ) + today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr + + logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}") + + with db() as conn: + entries = conn.execute(""" + SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id LIMIT 1) AS foto_url + FROM diary d + WHERE strftime('%m-%d', d.datum) = ? + AND d.datum < date('now') + AND d.titel IS NOT NULL + AND d.is_milestone = 0 + """, (today_md,)).fetchall() + + sent_total = 0 + for e in entries: + try: + jahre = today.year - int(e['datum'][:4]) + if jahre < 1: + continue + jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren" + send_push_to_user(e['user_id'], { + 'type': 'anniversary_reminder', + 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}', + 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund', + 'data': {'page': 'diary'}, + 'tag': f'anniversary-{e["id"]}-{today.year}', + }) + sent_total += 1 + except Exception as ex: + logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}") + + logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.") + _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen") + + +# ------------------------------------------------------------------ +# JOB: Monatlicher Rückblick (1. des Monats 10:00) +# ------------------------------------------------------------------ +async def _job_monthly_recap(): + """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats.""" + today = datetime.now(tz=_TZ) + first_this = today.replace(day=1) + last_month_end = first_this - timedelta(days=1) + last_month_start = last_month_end.replace(day=1) + year_str = last_month_start.strftime('%Y') + month_str = last_month_start.strftime('%m') + month_label = last_month_start.strftime('%B %Y') + + logger.info(f"Monatlicher Rückblick Job läuft für {month_label}") + + with db() as conn: + # Alle User mit mindestens einem Hund + users = conn.execute( + "SELECT DISTINCT user_id FROM dogs" + ).fetchall() + + sent_total = 0 + for u in users: + user_id = u["user_id"] + try: + with db() as conn: + # Hunde des Users + dog_rows = conn.execute( + "SELECT id, name FROM dogs WHERE user_id=?", (user_id,) + ).fetchall() + if not dog_rows: + continue + + dog_ids = [d["id"] for d in dog_rows] + placeholders = ','.join('?' * len(dog_ids)) + + # km (Routen des Users im Vormonat) + km_row = conn.execute( + "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", + (user_id, year_str, month_str) + ).fetchone() + gesamt_km = km_row["km"] or 0.0 + + # Tagebucheinträge + eintraege = conn.execute( + f"SELECT COUNT(*) AS n FROM diary " + f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?", + (*dog_ids, year_str, month_str) + ).fetchone()["n"] + + # Training-Sessions + training = conn.execute( + f"SELECT COUNT(*) AS n FROM training_sessions " + f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", + (*dog_ids, year_str, month_str) + ).fetchone()["n"] + + # Lieblingsfoto (erstes Foto im Vormonat) + foto_row = conn.execute( + f"SELECT dm.url FROM diary_media dm " + f"JOIN diary d ON d.id=dm.diary_id " + f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' " + f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? " + f"ORDER BY d.datum ASC LIMIT 1", + (*dog_ids, year_str, month_str) + ).fetchone() + foto_url = foto_row["url"] if foto_row else None + + # Nur senden wenn mindestens eine Aktivität vorhanden + if eintraege == 0 and training == 0 and gesamt_km == 0: + continue + + dog_name = dog_rows[0]["name"] + parts = [] + if gesamt_km > 0: + parts.append(f"{gesamt_km} km gelaufen") + if eintraege > 0: + parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge") + if training > 0: + parts.append(f"{training} Training-Sessions") + + body_text = " · ".join(parts) + + send_push_to_user(user_id, { + 'type': 'monthly_recap', + 'title': f'📅 {month_label}: Rückblick für {dog_name}', + 'body': body_text, + 'data': {'page': 'diary'}, + 'tag': f'monthly-recap-{year_str}-{month_str}', + }) + sent_total += 1 + except Exception as ex: + logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}") + + logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.") + _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}") + + +async def _job_new_foto_challenge(): + """Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen.""" + from datetime import date, timedelta + from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday + + monday = _current_week_monday() + sunday = _current_week_sunday() + + week_num = date.today().isocalendar()[1] + thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)] + + with db() as conn: + existing = conn.execute( + "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,) + ).fetchone() + if existing: + logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).") + _log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}") + return + + cur = conn.execute( + "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) " + "VALUES (?, ?, ?, ?, NULL)", + (thema, f"Diese Woche: {thema}", monday, sunday) + ) + challenge_id = cur.lastrowid + + # Push an alle User + send_push_to_all({ + "type": "foto_challenge", + "title": "📸 Neue Foto-Challenge!", + "body": f"Diese Woche: {thema} — mach mit!", + "data": {"page": "walks", "tab": "challenge"}, + "tag": f"challenge-{monday}", + }) + + logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}–{sunday} (id={challenge_id}).") + _log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}") + + +async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]: + """Holt stündliche Wetterdaten für heute von Open-Meteo.""" + import httpx + from datetime import date as _date + + today = _date.today().isoformat() + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={lat}&longitude={lon}" + "&hourly=temperature_2m,precipitation_probability,windspeed_10m" + "&timezone=Europe%2FBerlin&forecast_days=1" + ) + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get(url) + resp.raise_for_status() + raw = resp.json() + + hourly = raw.get("hourly", {}) + times = hourly.get("time", []) + temps = hourly.get("temperature_2m", []) + precips = hourly.get("precipitation_probability", []) + winds = hourly.get("windspeed_10m", []) + + result = [] + for i, ts in enumerate(times): + if not ts.startswith(today): + continue + hour = int(ts[11:13]) + result.append({ + "hour": hour, + "temp": temps[i] if i < len(temps) else None, + "precip": precips[i] if i < len(precips) else None, + "wind": winds[i] if i < len(winds) else None, + }) + return result + + +def _score_hour(h: dict) -> int: + """Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte).""" + score = 0 + temp = h.get("temp") + precip = h.get("precip") + wind = h.get("wind") + hour = h.get("hour", 12) + + # Temperatur + if temp is not None: + if 10 <= temp <= 20: + score += 3 + elif 5 <= temp < 10 or 20 < temp <= 25: + score += 1 + + # Niederschlagswahrscheinlichkeit + if precip is not None: + if precip < 20: + score += 3 + elif precip < 40: + score += 1 + + # Wind + if wind is not None: + if wind < 20: + score += 2 + elif wind < 30: + score += 1 + + # Tageslicht (07–19 Uhr) + if 7 <= hour <= 19: + score += 2 + + return score + + +def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]: + """ + Findet das beste aufeinanderfolgende 2h-Fenster. + Gibt (start_hour, total_score, avg_temp, avg_wind) zurück. + """ + best_start = 8 + best_score = -1 + best_temp = None + best_wind = None + + for i in range(len(hourly) - 1): + h1 = hourly[i] + h2 = hourly[i + 1] + combined = _score_hour(h1) + _score_hour(h2) + if combined > best_score: + best_score = combined + best_start = h1["hour"] + # Durchschnittswerte für Anzeige + temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None] + winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None] + best_temp = sum(temps) / len(temps) if temps else None + best_wind = sum(winds) / len(winds) if winds else None + + return best_start, best_score, best_temp, best_wind diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 27cf0d9..d3dcc37 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -5571,6 +5571,139 @@ html.modal-open { border-radius: 0; } +/* ── Wiki Gallery ────────────────────────────────────────── */ +.wiki-gallery-wrap { + position: relative; + margin-bottom: var(--space-3); +} +.wiki-gallery-main { + width: 100%; + height: 240px; + object-fit: cover; + object-position: center top; + border-radius: var(--radius-lg); + display: block; +} +.wiki-gallery-strip { + display: flex; + gap: var(--space-2); + overflow-x: auto; + padding: var(--space-2) 0 0; + scrollbar-width: none; +} +.wiki-gallery-strip::-webkit-scrollbar { display: none; } +.wiki-gallery-thumb { + flex-shrink: 0; + width: 64px; height: 64px; + border-radius: var(--radius-md); + overflow: hidden; + border: 2px solid transparent; + padding: 0; + background: none; + cursor: pointer; + position: relative; + transition: border-color .15s; +} +.wiki-gallery-thumb.active { border-color: var(--c-primary); } +.wiki-gallery-thumb img { + width: 100%; height: 100%; object-fit: cover; +} +.wiki-gallery-thumb-label { + position: absolute; + bottom: 0; left: 0; right: 0; + background: rgba(0,0,0,.55); + color: #fff; + font-size: 8px; + padding: 2px 4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.wiki-gallery-expand { + position: absolute; + top: var(--space-2); + right: var(--space-2); + width: 34px; height: 34px; + border-radius: 50%; + background: rgba(0,0,0,.45); + border: none; + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: background .15s; +} +.wiki-gallery-expand:hover { background: rgba(0,0,0,.65); } + +/* ── Wiki Lightbox ───────────────────────────────────────── */ +#wiki-lightbox { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} +.wlb-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,.88); + backdrop-filter: blur(6px); +} +.wlb-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + max-width: min(92vw, 680px); + width: 100%; + gap: var(--space-2); +} +.wlb-img { + width: 100%; + max-height: 72vh; + object-fit: contain; + border-radius: var(--radius-lg); +} +.wlb-close { + position: absolute; + top: -44px; + right: 0; + background: rgba(255,255,255,.12); + border: none; + color: #fff; + width: 36px; height: 36px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.wlb-prev, .wlb-next { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(255,255,255,.12); + border: none; + color: #fff; + width: 40px; height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + transition: background .15s; +} +.wlb-prev { left: -48px; } +.wlb-next { right: -48px; } +.wlb-prev:hover, .wlb-next:hover { background: rgba(255,255,255,.25); } +.wlb-caption { color: rgba(255,255,255,.75); font-size: var(--text-sm); } +.wlb-counter { color: rgba(255,255,255,.45); font-size: var(--text-xs); } + /* Steckbrief-Grid */ .wiki-steckbrief-grid { display: grid; @@ -6550,6 +6683,97 @@ html.modal-open { /* ============================================================ HELP TOOLTIP ============================================================ */ +/* ============================================================ + PAGE INFO — generische Seiten-Hilfe (UI.pageInfo) + ============================================================ */ +.pinfo-trigger-inline { + width: 26px; height: 26px; + border-radius: 50%; + background: var(--c-surface-2); + border: 1px solid var(--c-border-light); + color: var(--c-text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background .15s, color .15s; +} +.pinfo-trigger-inline:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); } + +.pinfo-banner { + margin: var(--space-3) var(--space-4) 0; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + background: var(--c-surface-2); + border-left: 3px solid var(--c-primary); + font-size: var(--text-sm); +} +.pinfo-banner-head { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); +} +.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; } +.pinfo-banner-title { + flex: 1; + font-weight: var(--weight-semibold); + color: var(--c-text); +} +.pinfo-banner-close { + background: none; border: none; cursor: pointer; + color: var(--c-text-muted); padding: 2px; +} +.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; } +.pinfo-banner-more { + background: none; border: none; cursor: pointer; + color: var(--c-primary); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + padding: 0; + display: flex; + align-items: center; + gap: 4px; + margin-top: var(--space-2); +} + +/* MODAL BODY */ +.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); } +.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; } +.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); } +.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); } +.pinfo-step { + display: flex; + gap: var(--space-3); + align-items: flex-start; +} +.pinfo-step-icon { + width: 32px; height: 32px; + border-radius: var(--radius-md); + background: var(--c-primary-subtle, rgba(196,132,58,.12)); + color: var(--c-primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; } +.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; } +.pinfo-tip { + display: flex; + gap: var(--space-2); + align-items: flex-start; + padding: var(--space-3); + background: rgba(196,132,58,.08); + border-radius: var(--radius-md); + color: var(--c-text-secondary); + font-size: var(--text-sm); + line-height: 1.5; +} +.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; } + + .by-help-btn { display: inline-flex; align-items: center; @@ -6954,7 +7178,7 @@ svg.empty-state-icon { /* FAB */ .exp-fab { position: fixed; - bottom: calc(var(--nav-height, 64px) + var(--space-4)); + bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2)); right: var(--space-4); z-index: 100; width: 52px; @@ -7080,6 +7304,20 @@ svg.empty-state-icon { color: var(--c-text-secondary); line-height: 1.2; } +.exp-kachel-jahr { + font-size: 9px; + color: var(--c-text-muted); + margin-top: 2px; + line-height: 1.2; +} +.exp-kachel-add { + display: flex; + align-items: center; + gap: 2px; + font-size: 10px; + color: var(--c-text-muted); + margin-top: 3px; +} /* ---- Sektion-Block (Verlauf etc.) ---- */ .exp-section { @@ -7255,6 +7493,36 @@ svg.empty-state-icon { border-radius: 999px; padding: 1px 6px; } +.exp-dog-selector { + display: flex; + gap: 8px; + padding: 10px 16px 4px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.exp-dog-selector::-webkit-scrollbar { display: none; } +.exp-dog-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 14px; + border-radius: 999px; + border: 1px solid var(--c-border); + background: var(--c-bg-card); + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + white-space: nowrap; + transition: background .15s, color .15s, border-color .15s; +} +.exp-dog-pill.active { + background: var(--c-primary); + color: #fff; + border-color: var(--c-primary); +} /* Rechte Spalte: Betrag + Löschen-Icon */ .exp-entry-right { @@ -7600,10 +7868,28 @@ svg.empty-state-icon { .wlabel.active { opacity: 1; } @media (min-width: 768px) { - #world-labels { gap: 48px; font-size: 11px; } - .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; } - .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); } - .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); } + #world-labels { + gap: 40px; + top: calc(env(safe-area-inset-top, 0px) + 18px); + } + .wlabel { + font-size: 13px; + letter-spacing: 0.18em; + opacity: 0.55; + padding: 6px 14px; + border-radius: 20px; + text-shadow: 0 1px 6px rgba(0,0,0,0.7); + transition: opacity 0.18s, background 0.18s; + } + .wlabel:hover { + opacity: 0.85; + background: rgba(255, 255, 255, 0.12); + } + .wlabel.active { + opacity: 1; + background: rgba(255, 255, 255, 0.18); + text-shadow: 0 1px 8px rgba(0,0,0,0.5); + } } /* Settings-Button */ @@ -7775,28 +8061,35 @@ svg.empty-state-icon { backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 16px; - padding: 14px 6px 11px; + padding: 12px 6px; text-align: center; cursor: pointer; display: flex; flex-direction: column; align-items: center; - gap: 7px; + justify-content: center; + gap: 6px; color: white; transition: background 0.12s, transform 0.1s; -webkit-tap-highlight-color: transparent; user-select: none; + min-height: 80px; /* alle Chips gleich hoch */ } .world-chip:active { background: rgba(0, 0, 0, 0.6); transform: scale(0.93); } -.world-chip svg { color: white; } +.world-chip svg { color: white; flex-shrink: 0; } .world-chip-label { font-size: 10px; font-weight: 600; color: rgba(255, 255, 255, 0.9); line-height: 1.2; + max-height: 2.4em; /* max. 2 Zeilen */ + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } /* Chip-Umrandung je Welt */ @@ -7891,3 +8184,189 @@ svg.empty-state-icon { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.25; } } + +/* ── COMMUNITY-FEATURES ──────────────────────────────────── */ + +/* Walks-Tab-Bar */ +.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; } + +/* Foto-Challenge */ +.challenge-banner { + background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A)); + border-radius: var(--radius-lg); + margin: var(--space-4); + overflow: hidden; +} +.challenge-banner-inner { + padding: var(--space-5) var(--space-4); + color: #fff; +} +.challenge-thema { + font-size: var(--text-xl); + font-weight: var(--weight-bold); + line-height: 1.2; + margin-bottom: var(--space-2); +} +.challenge-meta { + font-size: var(--text-sm); + opacity: 0.88; +} +.challenge-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-6); +} +.challenge-sub-card { + background: var(--c-surface); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +.challenge-sub-card img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + display: block; + cursor: pointer; +} +.challenge-sub-info { + padding: var(--space-2); +} +.challenge-sub-user { + font-size: var(--text-xs); + color: var(--c-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} +.challenge-sub-caption { + font-size: var(--text-xs); + color: var(--c-text); + margin-bottom: var(--space-1); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.challenge-vote-btn { + border: none; + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + gap: 3px; +} +.challenge-vote-btn.voted { + color: var(--c-danger, #ef4444); +} +.challenge-winners { border-top: 1px solid var(--c-border); } +.challenge-winners-row { + display: flex; + gap: var(--space-3); + overflow-x: auto; + padding: var(--space-2) var(--space-4) var(--space-3); + scroll-snap-type: x mandatory; +} +.challenge-winner-chip { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--c-surface-alt, #fdf6ef); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + min-width: 160px; + flex-shrink: 0; + scroll-snap-align: start; +} +.challenge-winner-chip img { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + object-fit: cover; + flex-shrink: 0; +} + +/* Wochentag-Selector */ +.wd-selector { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} +.wd-btn { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 4px 10px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-full); + font-size: var(--text-sm); + user-select: none; + transition: background .15s, border-color .15s; +} +.wd-btn input { display: none; } +.wd-btn:has(input:checked) { + background: var(--c-primary, #C4843A); + border-color: var(--c-primary, #C4843A); + color: #fff; +} + +/* Gassi-Zeit-Karten */ +.gassi-zeit-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); +} +.gz-avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + overflow: hidden; + flex-shrink: 0; + background: var(--c-surface-alt); + display: flex; + align-items: center; + justify-content: center; +} +.gz-avatar img { width: 100%; height: 100%; object-fit: cover; } +.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); } +.gz-body { flex: 1; min-width: 0; } +.gz-name { + font-weight: var(--weight-semibold); + font-size: var(--text-sm); + display: flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} +.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; } +.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; } +.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; } + +/* Rassen-Community-Chip */ +.breed-community-chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: var(--c-surface-alt, #fdf6ef); + border: 1.5px solid var(--c-amber, #f59e0b); + border-radius: var(--radius-full); + padding: 6px 16px; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text); + cursor: pointer; + transition: background .15s; +} +.breed-community-chip:hover, .breed-community-chip:active { + background: #fff3e0; +} diff --git a/backend/static/img/banyaro/fruehling_playdate.webp b/backend/static/img/banyaro/fruehling_playdate.webp new file mode 100644 index 0000000..7defe9e Binary files /dev/null and b/backend/static/img/banyaro/fruehling_playdate.webp differ diff --git a/backend/static/img/banyaro/herbst_bach.webp b/backend/static/img/banyaro/herbst_bach.webp new file mode 100644 index 0000000..5b7a594 Binary files /dev/null and b/backend/static/img/banyaro/herbst_bach.webp differ diff --git a/backend/static/img/banyaro/herbst_baum.webp b/backend/static/img/banyaro/herbst_baum.webp new file mode 100644 index 0000000..4ea4312 Binary files /dev/null and b/backend/static/img/banyaro/herbst_baum.webp differ diff --git a/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg b/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg new file mode 100644 index 0000000..eb4e55a Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg differ diff --git a/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg b/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg new file mode 100644 index 0000000..aab4923 Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg differ diff --git a/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg b/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg new file mode 100644 index 0000000..d14e80d Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg differ diff --git a/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg b/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg new file mode 100644 index 0000000..155d65b Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg differ diff --git a/backend/static/img/banyaro/winter_schnee.webp b/backend/static/img/banyaro/winter_schnee.webp new file mode 100644 index 0000000..87269d4 Binary files /dev/null and b/backend/static/img/banyaro/winter_schnee.webp differ diff --git a/backend/static/index.html b/backend/static/index.html index cb75a8f..242e2b7 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -9,7 +9,6 @@ - @@ -76,6 +75,7 @@ + @@ -93,9 +93,9 @@ - - - + + + @@ -499,6 +499,18 @@
+
+
+
+ +
+
+
+ +
+
+
+ @@ -539,9 +551,6 @@ HUND WELT -
@@ -565,12 +574,12 @@ - + - + @@ -667,5 +676,6 @@ } + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index c6b26da..1071fdd 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -212,6 +212,9 @@ const API = (() => { osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); }, myFavorite() { return get('/tieraerzte/my-favorite'); }, toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); }, + bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); }, + meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); }, + bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a248787..b117f18 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '715'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -76,6 +76,9 @@ const App = (() => { adoption: { title: 'Adoption', module: null }, playdate: { title: 'Playdate', module: null, requiresAuth: true }, wetter: { title: 'Wetter', module: null }, + ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true }, + personality: { title: 'Persönlichkeitstest', module: null }, + reise: { title: 'Reise mit Hund', module: null }, }; // ---------------------------------------------------------- @@ -481,6 +484,9 @@ const App = (() => { navigate('onboarding'); } + // Drei Welten nach Login starten (falls noch nicht initialisiert) + if (window.Worlds) window.Worlds.init(state); + _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); @@ -556,7 +562,8 @@ const App = (() => { _updateHeaderUserBtn(false); - // Nicht eingeloggte User immer zur Welcome-Seite + window.Worlds?.hide(); + document.getElementById('worlds-back')?.classList.remove('worlds-back-visible'); navigate('welcome', false); } @@ -852,11 +859,8 @@ const App = (() => { } const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; - // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. navigate(state.user ? startPage : 'welcome', false, hashParams); - - // Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt) - if (window.Worlds) window.Worlds.init(state); + if (window.Worlds && state.user) window.Worlds.init(state); } async function _handleInvite(token) { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index a39eccd..66d9150 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -868,9 +868,9 @@ window.Page_diary = (() => { if (e.weather_json) { try { const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json; - const temp = w?.temperature_2m ?? w?.temp_c; + const temp = w?.temp_c ?? w?.temperature_2m; if (temp != null) { - metaParts.push(`${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°`); + metaParts.push(`${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°`); } } catch (_) {} } @@ -1073,15 +1073,14 @@ window.Page_diary = (() => { if (entry.weather_json) { try { const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json; - const temp = w?.temperature_2m ?? w?.temp_c; + const temp = w?.temp_c ?? w?.temperature_2m; if (w && temp != null) { - const feels = w.apparent_temperature ?? w.feels_like_c; - const wind = w.wind_speed_10m ?? w.wind_kmh; + const wind = w.wind_kmh ?? w.wind_speed_10m; + const precip = w.precip_prob; const parts = [ - `${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`, - feels != null ? `gefühlt ${Math.round(feels)}°` : null, - wind != null ? `💨 ${Math.round(wind)} km/h` : null, - w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null, + `${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`, + wind != null ? `${Math.round(wind)} km/h Wind` : null, + precip != null ? `${precip}% Regen` : null, ].filter(Boolean).join(' · '); metaItems.push(`${parts}`); } @@ -1728,6 +1727,16 @@ window.Page_diary = (() => { }); await UI.asyncButton(submitBtn, async () => { + // Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort + let _clientWeather = null; + if (!isEdit && _locLat == null) { + try { + const pos = await API.getLocation(); + const wd = await API.weather.get(pos.lat, pos.lon); + if (wd && wd.temp_c != null) _clientWeather = JSON.stringify(wd); + } catch (_) { /* GPS oder Wetter nicht verfügbar → kein Problem */ } + } + const payload = { datum: fd.datum || null, typ: fd.typ, @@ -1739,6 +1748,7 @@ window.Page_diary = (() => { gps_lon: _locLon, location_name: _locName, client_time: API.clientNow(), + weather_json: _clientWeather, }; async function _uploadNewFiles(entryId) { diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 82a2e8a..58d5aa7 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -97,8 +97,11 @@ window.Page_dog_profile = (() => {

${_esc(dog.name)}

${dog.rasse - ? `

${_esc(dog.rasse)}

` - : `

`} + ? `

${_esc(dog.rasse)}

` + : `

`} + + +
@@ -227,6 +248,9 @@ window.Page_dog_profile = (() => { // Pflegetipps laden _loadPflegeTipps(dog); + // Rassen-Community-Chip laden (falls Rasse bekannt) + if (dog.rasse) _loadSameBreedChip(); + // Sitter-Zugang laden (nur für Besitzer) if (dog.user_id === _appState.user?.id) { _loadSittingAccess(dog.id); @@ -264,6 +288,22 @@ window.Page_dog_profile = (() => { _showPassportModal(dog); }); + document.getElementById('dp-vcard-btn')?.addEventListener('click', () => { + _showVcardModal(dog); + }); + + document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => { + _showWrappedModal(dog); + }); + + document.getElementById('dp-buch-btn')?.addEventListener('click', () => { + _showBuchModal(dog); + }); + + document.getElementById('dp-timeline-btn')?.addEventListener('click', () => { + _showTimelineModal(dog); + }); + // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } @@ -750,6 +790,138 @@ window.Page_dog_profile = (() => { // ---------------------------------------------------------- // TEILEN // ---------------------------------------------------------- + // ---------------------------------------------------------- + // HUNDE-VISITENKARTE MIT QR-CODE + // ---------------------------------------------------------- + function _showVcardModal(dog) { + const passportUrl = `https://banyaro.app/hund/${dog.id}`; + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`; + + const user = _appState?.user; + const ownerName = user?.name || ''; + const wohnort = user?.wohnort || ''; + + // Alter errechnen + let alterStr = ''; + if (dog.geburtstag) { + const birth = new Date(dog.geburtstag + 'T00:00:00'); + const now = new Date(); + const years = now.getFullYear() - birth.getFullYear() + - (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0); + alterStr = years < 1 + ? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate` + : years === 1 ? '1 Jahr' : `${years} Jahre`; + } + + const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · '); + + const cardHtml = ` +
+ + +
+
+ + +
+ ${dog.foto_url + ? `` + : `
🐾
`} +
+
${_esc(dog.name)}
+ ${metaLine ? `
${_esc(metaLine)}
` : ''} + ${wohnort ? `
📍 ${_esc(wohnort)}
` : ''} +
+
+ + +
+ + +
+
+ ${ownerName ? `
Besitzer
+
${_esc(ownerName)}
` : ''} +
banyaro.app
+
+
+ QR-Code +
Profil öffnen
+
+
+
+ `; + + UI.modal.open({ + title: 'Visitenkarte', + body: ` +
${cardHtml}
+

+ QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${_esc(dog.name)} sofort öffnen. +

+ `, + footer: ` + + + `, + }); + + // Link kopieren + document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(passportUrl); + UI.toast.success('Link kopiert!'); + } catch { + const inp = document.createElement('input'); + inp.value = passportUrl; + document.body.appendChild(inp); + inp.select(); + document.execCommand('copy'); + inp.remove(); + UI.toast.success('Link kopiert!'); + } + }); + + // Native Share API + document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ + title: `${dog.name} auf Ban Yaro`, + text: `Schau dir das Profil von ${dog.name} an!`, + url: passportUrl, + }); + } catch {} + } else { + // Fallback: kopieren + try { + await navigator.clipboard.writeText(passportUrl); + UI.toast.success('Link kopiert!'); + } catch { + UI.toast.error('Teilen nicht verfügbar.'); + } + } + }); + } + async function _showShareModal(dog) { UI.modal.open({ title: `${_esc(dog.name)} teilen`, @@ -970,6 +1142,23 @@ window.Page_dog_profile = (() => { +
+ + +
+
@@ -1716,6 +1756,226 @@ window.Page_health = (() => { `; } + // ---------------------------------------------------------- + // PRAXEN — Sterne-Hilfs-Funktionen + // ---------------------------------------------------------- + + /** Rendert 5 Sterne (readonly, filled bis `rating`). */ + function _renderStarsReadonly(rating) { + const full = Math.round(rating); + return Array.from({ length: 5 }, (_, i) => { + const filled = i < full; + return ``; + }).join(''); + } + + /** Rendert 5 klickbare Sterne mit data-val. */ + function _renderStarsInput(name, current) { + return `
+ ${Array.from({ length: 5 }, (_, i) => { + const val = i + 1; + const filled = current >= val; + return ``; + }).join('')} +
`; + } + + // ---------------------------------------------------------- + // PRAXEN — Detail-Modal (Bewertungen anzeigen) + // ---------------------------------------------------------- + async function _showPraxisDetail(praxis) { + // Erst mit Lade-Spinner öffnen, dann Daten laden + UI.modal.open({ + title: _esc(praxis.name), + body: `
+ +
`, + footer: ` + `, + }); + + document.getElementById('detail-bewerten-btn') + ?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); }); + + let data; + try { + data = await API.tieraerzte.bewertungen(praxis.id); + } catch { + UI.modal.open({ title: praxis.name, body: '

Bewertungen konnten nicht geladen werden.

' }); + return; + } + + const { avg_rating, anz_bewertungen, verteilung, kommentare } = data; + + // Balkendiagramm + const balken = [5, 4, 3, 2, 1].map(s => { + const n = verteilung[String(s)] || 0; + const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0; + return `
+ ${s} + +
+
+
+ ${n} +
`; + }).join(''); + + const kommentarHtml = kommentare.length + ? kommentare.map(k => ` +
+
+ ${_renderStarsReadonly(k.gesamt)} + + ${k.created_at ? k.created_at.slice(0, 10) : ''} + +
+ ${k.wartezeit || k.freundlichkeit || k.kompetenz ? ` +
+ ${k.wartezeit ? `Wartezeit: ${_renderStarsReadonly(k.wartezeit)}` : ''} + ${k.freundlichkeit ? `Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}` : ''} + ${k.kompetenz ? `Kompetenz: ${_renderStarsReadonly(k.kompetenz)}` : ''} +
` : ''} +

${_esc(k.text || '')}

+
`).join('') + : `

Noch keine Kommentare.

`; + + const bewBody = anz_bewertungen === 0 + ? `

+ Noch keine Bewertungen — sei der Erste! +

` + : ` +
+
+
${avg_rating.toFixed(1)}
+
${_renderStarsReadonly(avg_rating)}
+
${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}
+
+
${balken}
+
+
${kommentarHtml}
`; + + // Modal-Body aktualisieren (ohne Modal neu zu öffnen) + const modalBody = document.querySelector('.modal-body'); + if (modalBody) modalBody.innerHTML = bewBody; + } + + // ---------------------------------------------------------- + // PRAXEN — Bewertungs-Modal + // ---------------------------------------------------------- + async function _showBewertungModal(praxis) { + // Ggf. bestehende Bewertung laden + let existing = null; + try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ } + + const cur = existing || {}; + + const body = ` +
+
+ + ${_renderStarsInput('gesamt', cur.gesamt || 0)} + +
+
+ + ${_renderStarsInput('wartezeit', cur.wartezeit || 0)} + +
+
+ + ${_renderStarsInput('freundlichkeit', cur.freundlichkeit || 0)} + +
+
+ + ${_renderStarsInput('kompetenz', cur.kompetenz || 0)} + +
+
+ + +
max. 500 Zeichen
+
+
`; + + UI.modal.open({ + title: `${_esc(praxis.name)} bewerten`, + body, + footer: ` + + `, + }); + + // Sterne-Interaktion + document.querySelectorAll('.bew-stars').forEach(group => { + const name = group.dataset.name; + const hidden = document.getElementById(`bew-${name}`); + const stars = group.querySelectorAll('.bew-star'); + + const paint = val => { + stars.forEach(s => { + s.style.color = parseInt(s.dataset.val) <= val + ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'; + }); + }; + + stars.forEach(s => { + s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val))); + s.addEventListener('mouseleave', () => paint(parseInt(hidden.value))); + s.addEventListener('click', () => { + hidden.value = s.dataset.val; + paint(parseInt(s.dataset.val)); + }); + }); + + paint(parseInt(hidden.value)); + }); + + // Submit + document.getElementById('bew-submit-btn').addEventListener('click', async (e) => { + e.preventDefault(); + const form = document.getElementById('bew-form'); + const gesamt = parseInt(document.getElementById('bew-gesamt').value); + if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; } + + const payload = { gesamt }; + const wz = parseInt(document.getElementById('bew-wartezeit').value); + const fr = parseInt(document.getElementById('bew-freundlichkeit').value); + const ko = parseInt(document.getElementById('bew-kompetenz').value); + if (wz) payload.wartezeit = wz; + if (fr) payload.freundlichkeit = fr; + if (ko) payload.kompetenz = ko; + const txt = form.querySelector('textarea[name="text"]').value.trim(); + if (txt) payload.text = txt; + + await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => { + const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload); + // _praxen-Cache aktualisieren + _praxen = _praxen.map(p => + p.id === praxis.id + ? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen } + : p + ); + UI.modal.close(); + UI.toast.success('Bewertung gespeichert.'); + _renderTab(); + }); + }); + } + // ---------------------------------------------------------- // PRAXEN — Formular (Neu / Bearbeiten) // ---------------------------------------------------------- diff --git a/backend/static/js/pages/jobs.js b/backend/static/js/pages/jobs.js index b056d24..875fe2c 100644 --- a/backend/static/js/pages/jobs.js +++ b/backend/static/js/pages/jobs.js @@ -30,7 +30,7 @@ window.Page_jobs = (() => { } _container.innerHTML = ` -
+
@@ -156,7 +156,7 @@ window.Page_jobs = (() => { value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
-
+
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index ded9a7d..f210960 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -838,15 +838,23 @@ window.Page_map = (() => { _tempMarker = null; } + // Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv) const PIN_TYPES = [ - { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben - { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, - { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, - { type: 'drinking_water', icon: '', label: 'Wasserstelle', color: '#0EA5E9' }, - { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, - { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, - { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, - { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, + { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626', exclusive: true }, + { type: 'gefahr', icon: '', label: 'Gefahr', color: '#F59E0B' }, + { type: 'freilauf', icon: '', label: 'Freilauf', color: '#22C55E' }, + { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, + { type: 'restaurant', icon: '', label: 'Restaurant', color: '#F97316' }, + { type: 'shop', icon: '', label: 'Shop', color: '#3B82F6' }, + { type: 'tierarzt', icon: '', label: 'Tierarzt', color: '#EF4444' }, + { type: 'hundeschule', icon: '', label: 'Hundeschule', color: '#8B5CF6' }, + { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, + { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, + { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' }, + { type: 'drinking_water',icon: '', label: 'Wasserstelle',color: '#0EA5E9' }, + { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, + { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, + { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, ]; function _confirmPlacement(latlng) { @@ -855,18 +863,18 @@ window.Page_map = (() => { radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6, }).addTo(_map); - let _selectedType = 'giftkoeder'; + let _selectedTypes = new Set(['giftkoeder']); UI.modal.open({ title: ' Marker setzen', body: `
- +
${PIN_TYPES.map(p => ` @@ -892,9 +900,21 @@ window.Page_map = (() => { document.querySelector('.poi-type-grid')?.addEventListener('click', e => { const btn = e.target.closest('.poi-type-btn'); if (!btn) return; - document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected')); - btn.classList.add('selected'); - _selectedType = btn.dataset.type; + const t = btn.dataset.type; + if (btn.dataset.excl) { + _selectedTypes = new Set([t]); + document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t)); + } else { + if (_selectedTypes.has('giftkoeder')) { + _selectedTypes.delete('giftkoeder'); + document.querySelector('[data-excl="1"]')?.classList.remove('selected'); + } + if (_selectedTypes.has(t)) { + if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); } + } else { + _selectedTypes.add(t); btn.classList.add('selected'); + } + } }); document.getElementById('poi-cancel')?.addEventListener('click', () => { @@ -905,8 +925,9 @@ window.Page_map = (() => { document.getElementById('poi-save')?.addEventListener('click', async () => { const name = document.getElementById('poi-name').value.trim() || null; const notiz = document.getElementById('poi-notiz').value.trim() || null; + const type = [..._selectedTypes].join(','); UI.modal.close(); - await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz }); + await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz }); _exitPlacementMode(); }); } diff --git a/backend/static/js/pages/moderation.js b/backend/static/js/pages/moderation.js index 5281850..031fc11 100644 --- a/backend/static/js/pages/moderation.js +++ b/backend/static/js/pages/moderation.js @@ -88,45 +88,35 @@ window.Page_moderation = (() => { // ------------------------------------------------------------------ // TAB: ÜBERSICHT // ------------------------------------------------------------------ + function _switchTab(tabId) { + _tab = tabId; + _container.querySelectorAll('#mod-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === _tab) + ); + _renderTab(); + } + async function _renderStats(el) { const s = await API.get('/moderation/stats'); el.innerHTML = `
- ${_statCard('warning', - 'Offene Meldungen', - s.open_reports, - s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')} - ${_statCard('image', - 'Fotos ausstehend', - s.pending_fotos, - s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} - ${_statCard('skull', - 'Gesperrte User', - s.banned_users, - s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)')} - ${_statCard('storefront', - 'Züchter ausstehend', - s.pending_zuchter, - s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} - ${_statCard('clock', - 'POI-Korrekturen', - s.pending_poi_edits ?? 0, - (s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} + ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'forum')} + ${_statCard('image', 'Fotos ausstehend', s.pending_fotos, s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'fotos')} + ${_statCard('skull', 'Gesperrte User', s.banned_users, s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'user')} + ${_statCard('storefront','Züchter ausstehend',s.pending_zuchter, s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'user')} + ${_statCard('clock', 'POI-Korrekturen', s.pending_poi_edits ?? 0,(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'poi-edits')}
-
-

- ${UI.icon('info')} - Das Moderations-Panel zeigt dir alle ausstehenden Aufgaben auf einen Blick. - Verwende die Tabs oben für Details zu Fotos, Usern und Forum-Meldungen. -

-
`; + el.querySelectorAll('.mod-stat-card[data-tab]').forEach(card => { + card.addEventListener('click', () => _switchTab(card.dataset.tab)); + }); } - function _statCard(icon, label, value, color) { + function _statCard(icon, label, value, color, tab) { + const clickable = tab ? `data-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer;transition:box-shadow .15s,transform .15s" onmouseenter="this.style.boxShadow='var(--shadow-md)';this.style.transform='translateY(-2px)'" onmouseleave="this.style.boxShadow='';this.style.transform=''"` : `style="padding:var(--space-4);text-align:center"`; return ` -
+
${label}
+ ${tab ? `
${UI.icon('arrow-right')} öffnen
` : ''}
`; } @@ -217,7 +208,16 @@ window.Page_moderation = (() => { await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' }); UI.toast('Foto freigegeben.', 'success'); await _loadFotos(el); - } catch (e) { UI.toast(e.message, 'danger'); btn.disabled = false; btn.textContent = '✓ Freigeben'; } + } catch (e) { + if (e.status === 404) { + UI.toast('Bereits bearbeitet — Liste aktualisiert.', 'info'); + await _loadFotos(el); + } else { + UI.toast(e.message, 'danger'); + btn.disabled = false; + btn.textContent = '✓ Freigeben'; + } + } }); }); diff --git a/backend/static/js/pages/personality.js b/backend/static/js/pages/personality.js new file mode 100644 index 0000000..e1f159b --- /dev/null +++ b/backend/static/js/pages/personality.js @@ -0,0 +1,480 @@ +/* ============================================================ + BAN YARO — Hunde-Persönlichkeitstest + 10 Fragen, 4 Typen: Abenteurer / Entdecker / Kuschler / Denker + ============================================================ */ + +window.Page_personality = (() => { + + let _container = null; + let _appState = null; + let _current = 0; // Aktuelle Frage (0-basiert) + let _scores = { A:0, B:0, C:0, D:0 }; + let _answers = []; // Gewählte Typen je Frage + + const LS_KEY = 'banyaro_personality_result'; + + // ---------------------------------------------------------- + // FRAGEN + // ---------------------------------------------------------- + const FRAGEN = [ + { frage: "Wie reagiert dein Hund auf neue Orte?", + antworten: [ + { text: "Stürmt sofort los — alles erkunden!", typ: 'A' }, + { text: "Schaut erst vorsichtig, dann neugierig", typ: 'B' }, + { text: "Bleibt lieber bei mir in der Nähe", typ: 'C' }, + { text: "Analysiert die Lage gründlich", typ: 'D' }, + ]}, + { frage: "Was macht dein Hund am liebsten?", + antworten: [ + { text: "Rennen, Ball, endlos spielen", typ: 'A' }, + { text: "Schnüffeln und die Welt erkunden", typ: 'B' }, + { text: "Kuscheln auf dem Sofa", typ: 'C' }, + { text: "Tricks lernen und Aufgaben lösen", typ: 'D' }, + ]}, + { frage: "Wie verhält er sich mit anderen Hunden?", + antworten: [ + { text: "Spielt sofort und wild mit", typ: 'A' }, + { text: "Friendly, aber wählerisch", typ: 'B' }, + { text: "Lieber zu zweit als in der Gruppe", typ: 'C' }, + { text: "Beobachtet erstmal genau", typ: 'D' }, + ]}, + { frage: "Wie reagiert er auf Kommandos?", + antworten: [ + { text: "Macht alles — wenn er Lust hat 😅", typ: 'A' }, + { text: "Gut, aber manchmal abgelenkt", typ: 'B' }, + { text: "Sehr zuverlässig, will gefallen", typ: 'C' }, + { text: "Präzise und fokussiert", typ: 'D' }, + ]}, + { frage: "Was passiert wenn du heimkommst?", + antworten: [ + { text: "Explosiver Freudentanz!", typ: 'A' }, + { text: "Wedelt freudig, bleibt aber cool", typ: 'B' }, + { text: "Kuschelt sich sofort an dich", typ: 'C' }, + { text: "Bringt dir sein Lieblingsspielzeug", typ: 'D' }, + ]}, + { frage: "Wie ist er bei Geräuschen/Gewitter?", + antworten: [ + { text: "Interessiert sich dafür oder ignoriert es", typ: 'A' }, + { text: "Schaut kurz, dann weiter", typ: 'B' }, + { text: "Sucht Schutz bei dir", typ: 'C' }, + { text: "Analysiert die Situation", typ: 'D' }, + ]}, + { frage: "Sein Verhältnis zu Kindern?", + antworten: [ + { text: "Liebt das wilde Spielen!", typ: 'A' }, + { text: "Gut, aber auf seine Art", typ: 'B' }, + { text: "Sanft und geduldig", typ: 'C' }, + { text: "Vorsichtig, aber freundlich", typ: 'D' }, + ]}, + { frage: "Was macht er alleine zu Hause?", + antworten: [ + { text: "Schläft oder spielt mit Spielzeug", typ: 'A' }, + { text: "Schaut aus dem Fenster", typ: 'B' }, + { text: "Wartet sehnsüchtig auf dich", typ: 'C' }, + { text: "Sucht sich Beschäftigung", typ: 'D' }, + ]}, + { frage: "Beim Gassigehen:", + antworten: [ + { text: "Zieht an der Leine — immer vorwärts!", typ: 'A' }, + { text: "Läuft locker aber entdeckungsfreudig", typ: 'B' }, + { text: "Bleibt gerne neben dir", typ: 'C' }, + { text: "Systematisches Schnüffeln", typ: 'D' }, + ]}, + { frage: "Was sagt er über dich aus?", + antworten: [ + { text: "Mein Mensch hält mit mir mit!", typ: 'A' }, + { text: "Gibt mir Freiheit und Abenteuer", typ: 'B' }, + { text: "Mein bester Freund", typ: 'C' }, + { text: "Versteht mich wirklich", typ: 'D' }, + ]}, + ]; + + // ---------------------------------------------------------- + // TYPEN + // ---------------------------------------------------------- + const TYPEN = { + A: { + key: 'A', + emoji: '🏔️', + name: 'Der Abenteurer', + desc: 'Immer vorwärts, immer mehr! Dein Hund lebt im Augenblick und liebt das Unbekannte.', + staerken: ['Energiegeladen', 'Mutig', 'Lebensfroh'], + aktivitaeten: [ + { label: 'Routen', page: 'routes' }, + { label: 'Karte', page: 'map' }, + { label: 'Training', page: 'uebungen' }, + ], + aktivitaetLabels: ['Agility', 'Canicross', 'Lange Wanderungen', 'Nasenarbeit'], + rassen: ['Husky', 'Malinois', 'Border Collie'], + color: '#f97316', + bg: 'linear-gradient(135deg, #f97316, #ea580c)', + }, + B: { + key: 'B', + emoji: '🌍', + name: 'Der Entdecker', + desc: 'Neugierig auf alles, aber mit Köpfchen. Dein Hund ist der perfekte Begleiter für jede Situation.', + staerken: ['Anpassungsfähig', 'Sozial', 'Ausgeglichen'], + aktivitaeten: [ + { label: 'Karte', page: 'map' }, + { label: 'Events', page: 'events' }, + { label: 'Routen', page: 'routes' }, + ], + aktivitaetLabels: ['Mantrailing', 'Dummy-Training', 'Gassi-Treffen'], + rassen: ['Labrador', 'Golden Retriever', 'Beagle'], + color: '#0ea5e9', + bg: 'linear-gradient(135deg, #0ea5e9, #0284c7)', + }, + C: { + key: 'C', + emoji: '🥰', + name: 'Der Kuschler', + desc: 'Verbundenheit über alles. Dein Hund liebt Menschen mehr als alles andere.', + staerken: ['Loyal', 'Einfühlsam', 'Zuverlässig'], + aktivitaeten: [ + { label: 'Tagebuch', page: 'diary' }, + { label: 'Training', page: 'uebungen' }, + { label: 'Gesundheit', page: 'health' }, + ], + aktivitaetLabels: ['Trick-Training', 'Therapy-Dog-Ausbildung', 'Ruhige Spaziergänge'], + rassen: ['Cavalier KCS', 'Bichon Frisé', 'Mops'], + color: '#ec4899', + bg: 'linear-gradient(135deg, #ec4899, #db2777)', + }, + D: { + key: 'D', + emoji: '🧠', + name: 'Der Denker', + desc: 'Stratege mit Seele. Dein Hund denkt bevor er handelt — und ist dabei brillant.', + staerken: ['Intelligent', 'Fokussiert', 'Lernbegeistert'], + aktivitaeten: [ + { label: 'Übungen', page: 'uebungen' }, + { label: 'Training', page: 'trainingsplaene' }, + { label: 'Wiki', page: 'wiki' }, + ], + aktivitaetLabels: ['Zieltraining', 'Geruchsarbeit', 'Rally Obedience', 'Intelligenzspielzeug'], + rassen: ['Poodle', 'Schäferhund', 'Rottweiler'], + color: '#8b5cf6', + bg: 'linear-gradient(135deg, #8b5cf6, #7c3aed)', + }, + }; + + // ---------------------------------------------------------- + // LIFECYCLE + // ---------------------------------------------------------- + function init(container, appState) { + _container = container; + _appState = appState; + _renderPage(); + } + + function refresh() {} + + function onDogChange() {} + + // ---------------------------------------------------------- + // RENDER EINSTIEG + // ---------------------------------------------------------- + function _renderPage() { + // Gespeichertes Ergebnis aus localStorage? + const saved = _loadResult(); + if (saved) { + _renderResult(TYPEN[saved.typ], saved.scores, true); + } else { + _renderIntro(); + } + } + + // ---------------------------------------------------------- + // INTRO + // ---------------------------------------------------------- + function _renderIntro() { + _container.innerHTML = ` +
+
+
🐾
+

+ Hunde-Persönlichkeitstest +

+

+ 10 Fragen — finde heraus welcher der 4 Persönlichkeitstypen deinen Hund am besten beschreibt! +

+
+ ${Object.values(TYPEN).map(t => ` +
+
${t.emoji}
+
${t.name}
+
`).join('')} +
+ +
+
`; + + document.getElementById('quiz-start-btn').addEventListener('click', _startQuiz); + } + + // ---------------------------------------------------------- + // QUIZ STARTEN + // ---------------------------------------------------------- + function _startQuiz() { + _current = 0; + _scores = { A:0, B:0, C:0, D:0 }; + _answers = []; + _renderQuestion(); + } + + // ---------------------------------------------------------- + // FRAGE RENDERN + // ---------------------------------------------------------- + function _renderQuestion() { + const q = FRAGEN[_current]; + const pct = Math.round((_current / FRAGEN.length) * 100); + + _container.innerHTML = ` +
+ +
+
+ + Frage ${_current + 1} von ${FRAGEN.length} + + ${pct}% +
+
+
+
+
+ + +
+

${q.frage}

+
+ + +
+ ${q.antworten.map((a, i) => ` + `).join('')} +
+
`; + + _container.querySelectorAll('.quiz-answer-btn').forEach(btn => { + btn.addEventListener('click', () => _answerQuestion(btn.dataset.typ)); + btn.addEventListener('mouseenter', () => { + btn.style.borderColor = 'var(--c-primary)'; + btn.style.background = 'var(--c-primary-subtle, rgba(var(--c-primary-rgb,59,130,246),.08))'; + }); + btn.addEventListener('mouseleave', () => { + if (!btn.classList.contains('selected')) { + btn.style.borderColor = 'var(--c-border)'; + btn.style.background = 'var(--c-surface)'; + } + }); + }); + } + + // ---------------------------------------------------------- + // ANTWORT VERARBEITEN + // ---------------------------------------------------------- + function _answerQuestion(typ) { + _scores[typ]++; + _answers.push(typ); + _current++; + + if (_current < FRAGEN.length) { + // Kurze Animation — zeige Auswahl kurz grün + _renderQuestion(); + } else { + _calcAndShowResult(); + } + } + + // ---------------------------------------------------------- + // AUSWERTUNG + // ---------------------------------------------------------- + function _calcAndShowResult() { + // Mehrheits-Typ finden; bei Gleichstand letzter bestimmender Typ + let maxScore = 0; + let winner = _answers[_answers.length - 1]; // Fallback: letzte Antwort + for (const [typ, score] of Object.entries(_scores)) { + if (score > maxScore) { + maxScore = score; + winner = typ; + } + } + // Bei Gleichstand: letzter in _answers der einen der Max-Score-Typen hat + const maxTypes = Object.entries(_scores) + .filter(([, s]) => s === maxScore) + .map(([t]) => t); + if (maxTypes.length > 1) { + for (let i = _answers.length - 1; i >= 0; i--) { + if (maxTypes.includes(_answers[i])) { winner = _answers[i]; break; } + } + } + + _saveResult(winner, _scores); + _renderResult(TYPEN[winner], _scores, false); + } + + // ---------------------------------------------------------- + // ERGEBNIS RENDERN + // ---------------------------------------------------------- + function _renderResult(typ, scores, fromStorage) { + const dogName = _appState?.activeDog?.name || 'dein Hund'; + const shareText = `${dogName} ist ${typ.name} ${typ.emoji} — macht den Test auf ban.yaro.de!`; + + const scoreBars = Object.entries(scores) + .sort(([,a],[,b]) => b - a) + .map(([t, s]) => { + const tp = TYPEN[t]; + const pct = Math.round((s / FRAGEN.length) * 100); + return ` +
+ ${tp.emoji} +
+
+
+
+
+ ${s}/${FRAGEN.length} +
`; + }).join(''); + + _container.innerHTML = ` +
+ +
+
${typ.emoji}
+
Persönlichkeitstyp
+

${typ.name}

+

${typ.desc}

+
+ + +
+
Stärken
+
+ ${typ.staerken.map(s => ` + ${s}`).join('')} +
+
+ + +
+
Empfohlene Aktivitäten
+
+
+ ${typ.aktivitaetLabels.map(a => ` + ${a}`).join('')} +
+
+ ${typ.aktivitaeten.map(a => ` + `).join('')} +
+
+
+ + +
+
Typische Rassen
+
+ ${typ.rassen.map(r => ` + ${r}`).join('')} +
+
+ + +
+
Dein Profil
+
${scoreBars}
+
+ + +
+ + +
+
`; + + // Share + document.getElementById('quiz-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ text: shareText, url: 'https://ban.yaro.de' }); + } catch {} + } else { + await navigator.clipboard.writeText(shareText); + UI.toast.success('In die Zwischenablage kopiert!'); + } + }); + + // Neustart + document.getElementById('quiz-restart-btn')?.addEventListener('click', () => { + localStorage.removeItem(LS_KEY); + _startQuiz(); + }); + } + + // ---------------------------------------------------------- + // LOCALSTORAGE + // ---------------------------------------------------------- + function _saveResult(typ, scores) { + try { + localStorage.setItem(LS_KEY, JSON.stringify({ typ, scores, ts: Date.now() })); + } catch {} + } + + function _loadResult() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { return null; } + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/reise.js b/backend/static/js/pages/reise.js new file mode 100644 index 0000000..0b8b3a4 --- /dev/null +++ b/backend/static/js/pages/reise.js @@ -0,0 +1,496 @@ +/* ============================================================ + BAN YARO — Reise mit Hund + Tabs: Checkliste | EU-Länder | Notfälle + ============================================================ */ + +window.Page_reise = (() => { + + let _container = null; + let _appState = null; + let _activeTab = 'checkliste'; + + const TABS = [ + { key: 'checkliste', label: 'Checkliste', icon: '' }, + { key: 'laender', label: 'EU-Länder', icon: '' }, + ]; + + const CHECKLIST = [ + { + key: 'dokumente', + label: 'Dokumente', + icon: 'file-text', + items: [ + 'EU-Heimtierausweis (Pflicht innerhalb EU)', + 'Impfpass (Tollwut mind. 21 Tage alt)', + 'Krankenkassen-Notfallkarte Tierarzt', + 'Foto des Hundes (für Vermisst-Fall)', + 'Chip-Nummer notiert', + ], + }, + { + key: 'gesundheit', + label: 'Gesundheit', + icon: 'heartbeat', + items: [ + 'Zecken-/Flohschutz aufgefrischt', + 'Reisekrankheit-Mittel (falls nötig)', + 'Medikamente ausreichend eingepackt', + 'Tierarzt-Kontakt am Zielort recherchiert', + 'Verbandszeug für Hunde', + ], + }, + { + key: 'ausruestung', + label: 'Ausrüstung', + icon: 'backpack', + items: [ + 'Leine + Ersatzleine', + 'Halsband mit Adressanhänger', + 'Transportbox/Reisekorb', + 'Lieblingsdecke/Schlafplatz', + 'Spielzeug (2–3 Stück)', + ], + }, + { + key: 'futter', + label: 'Futter & Wasser', + icon: 'bowl-food', + items: [ + 'Genug Futter (+ Reserve)', + 'Wassernapf + Flasche', + 'Futternapf', + 'Bekannte Leckerlis', + ], + }, + { + key: 'auto', + label: 'Im Auto', + icon: 'car', + items: [ + 'Sicherheitsgurt-Adapter oder Box gesichert', + 'Sonnenschutz-Netz für Fenster', + 'Pausen alle 2h eingeplant', + ], + }, + ]; + + const LAENDER = [ + { flag: '🇩🇪', name: 'Deutschland', regel: 'Keine Einschränkungen bei EU-Pass + Chip' }, + { flag: '🇦🇹', name: 'Österreich', regel: 'Gleiche Regeln wie DE, Leinenpflicht in Bergbahnen' }, + { flag: '🇨🇭', name: 'Schweiz', regel: 'Nicht-EU → eigene Einfuhrregeln, Tollwut-Titer-Test', warn: true }, + { flag: '🇮🇹', name: 'Italien', regel: 'Leinenpflicht öffentlich, Maulkorb in öffentlichen Verkehrsmitteln' }, + { flag: '🇫🇷', name: 'Frankreich', regel: 'Manche Strände im Sommer hundeverboten' }, + { flag: '🇬🇷', name: 'Griechenland', regel: 'Hunde erlaubt, kaum Einschränkungen' }, + { flag: '🇭🇷', name: 'Kroatien', regel: 'Viele Strände hundefreundlich, EU-Pass genügt' }, + { flag: '🇬🇧', name: 'Großbritannien', regel: 'Strenge Einreise! PETS-Zertifikat + Tollwut-Impfung + Bandwurm-Behandlung nötig', warn: true }, + ]; + + const SOFORTMASSNAHMEN = [ + { icon: 'thermometer-hot', text: 'Hitzschlag: Sofort Schatten, kühlen mit lauwarmem Wasser, Tierarzt rufen' }, + { icon: 'skull', text: 'Vergiftung: Ruhig halten, NICHT erbrechen lassen, sofort Tiergift-Notfall anrufen' }, + { icon: 'drop', text: 'Starke Blutung: Druckverband anlegen, Druck halten, Tierarzt aufsuchen' }, + { icon: 'bone', text: 'Knochenbruch: Ruhigstellen, nicht bewegen, Tierarzt aufsuchen' }, + { icon: 'heartbeat', text: 'Bewusstlosigkeit: Atemwege freihalten, stabile Seitenlage, 112 rufen' }, + ]; + + const LS_KEY = 'banyaro_reise_checkliste'; + const LS_CUSTOM_KEY = 'banyaro_reise_custom'; // {catKey: ["custom item",...]} + const LS_HIDDEN_KEY = 'banyaro_reise_hidden'; // {itemKey: true} — gelöschte Standard-Items + let _editMode = false; + + function _loadCustom() { try { return JSON.parse(localStorage.getItem(LS_CUSTOM_KEY) || '{}'); } catch { return {}; } } + function _saveCustom(d) { try { localStorage.setItem(LS_CUSTOM_KEY, JSON.stringify(d)); } catch {} } + function _loadHidden() { try { return JSON.parse(localStorage.getItem(LS_HIDDEN_KEY) || '{}'); } catch { return {}; } } + function _saveHidden(d) { try { localStorage.setItem(LS_HIDDEN_KEY, JSON.stringify(d)); } catch {} } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + function _esc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function _loadChecked() { + try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } + catch { return {}; } + } + + function _saveChecked(state) { + try { localStorage.setItem(LS_KEY, JSON.stringify(state)); } + catch {} + } + + function _itemKey(catKey, idx) { return `${catKey}__${idx}`; } + + // ------------------------------------------------------------------ + // LIFECYCLE + // ------------------------------------------------------------------ + function init(container, appState, params = {}) { + _container = container; + _appState = appState; + if (params?.tab && TABS.some(t => t.key === params.tab)) { + _activeTab = params.tab; + } + _render(); + } + + function refresh() { _renderTabContent(); } + function onDogChange() {} + + // ------------------------------------------------------------------ + // RENDER + // ------------------------------------------------------------------ + function _render() { + _container.innerHTML = ` +
+
+ `; + _renderTabBar(); + _renderTabContent(); + } + + function _renderTabBar() { + const el = _container.querySelector('#reise-tabs'); + if (!el) return; + el.innerHTML = TABS.map(t => ` + `).join(''); + el.querySelectorAll('.by-tab').forEach(btn => { + btn.addEventListener('click', () => { + _activeTab = btn.dataset.tab; + el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _renderTabContent(); + }); + }); + } + + function _renderTabContent() { + const el = _container.querySelector('#reise-content'); + if (!el) return; + if (_activeTab === 'checkliste') _renderCheckliste(el); + else if (_activeTab === 'laender') _renderLaender(el); + else if (_activeTab === 'notfall') _renderNotfall(el); + } + + // ------------------------------------------------------------------ + // TAB 1: CHECKLISTE + // ------------------------------------------------------------------ + function _renderCheckliste(el) { + const checked = _loadChecked(); + const custom = _loadCustom(); + const hidden = _loadHidden(); + + // Alle sichtbaren Items zählen + let totalItems = 0, doneItems = 0; + CHECKLIST.forEach(cat => { + cat.items.forEach((_, idx) => { + if (!hidden[_itemKey(cat.key, idx)]) { + totalItems++; + if (checked[_itemKey(cat.key, idx)]) doneItems++; + } + }); + (custom[cat.key] || []).forEach((_, i) => { + totalItems++; + if (checked[`${cat.key}__custom__${i}`]) doneItems++; + }); + }); + const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0; + + const cats = CHECKLIST.map(cat => { + const customItems = custom[cat.key] || []; + + const stdRows = cat.items.map((item, idx) => { + if (hidden[_itemKey(cat.key, idx)]) return ''; + const key = _itemKey(cat.key, idx); + const done = !!checked[key]; + if (_editMode) { + return `
+ ${_esc(item)} + +
`; + } + return ``; + }).join(''); + + const customRows = customItems.map((item, i) => { + const key = `${cat.key}__custom__${i}`; + const done = !!checked[key]; + if (_editMode) { + return `
+ ${_esc(item)} + +
`; + } + return ``; + }).join(''); + + const addRow = _editMode ? ` +
+
+ + +
+
` : ''; + + return `
+
+ + ${_esc(cat.label)} +
+
+ ${stdRows}${customRows}${addRow} +
+
`; + }).join(''); + + el.innerHTML = ` + + +
+
+ ${doneItems} von ${totalItems} erledigt +
+ ${pct}% + +
+
+
+
+
+
+ ${cats} + ${!_editMode ? `
+ +
` : ''} + `; + + // Checkbox events + el.querySelectorAll('.reise-cb').forEach(cb => { + cb.addEventListener('change', () => { + const key = cb.dataset.key; + const cur = _loadChecked(); + cur[key] = cb.checked; + _saveChecked(cur); + _renderTabContent(); + }); + }); + + // Edit-Toggle + el.querySelector('#reise-edit-toggle')?.addEventListener('click', () => { + _editMode = !_editMode; + _renderTabContent(); + }); + + // Standard-Item löschen (verstecken) + el.querySelectorAll('.reise-del-btn').forEach(btn => { + btn.addEventListener('click', () => { + const h = _loadHidden(); + h[btn.dataset.hide] = true; + _saveHidden(h); + _renderTabContent(); + }); + }); + + // Custom-Item löschen + el.querySelectorAll('.reise-del-custom-btn').forEach(btn => { + btn.addEventListener('click', () => { + const c = _loadCustom(); + c[btn.dataset.cat] = (c[btn.dataset.cat] || []).filter((_, i) => i !== parseInt(btn.dataset.idx)); + _saveCustom(c); + _renderTabContent(); + }); + }); + + // Custom-Item hinzufügen + el.querySelectorAll('.reise-add-btn').forEach(btn => { + btn.addEventListener('click', () => { + const cat = btn.dataset.cat; + const input = el.querySelector(`.reise-add-input[data-cat="${cat}"]`); + const val = (input?.value || '').trim(); + if (!val) return; + const c = _loadCustom(); + if (!c[cat]) c[cat] = []; + c[cat].push(val); + _saveCustom(c); + _renderTabContent(); + }); + }); + + // Enter in Add-Input + el.querySelectorAll('.reise-add-input').forEach(input => { + input.addEventListener('keydown', e => { + if (e.key === 'Enter') el.querySelector(`.reise-add-btn[data-cat="${input.dataset.cat}"]`)?.click(); + }); + }); + + el.querySelector('#reise-reset-btn')?.addEventListener('click', () => { + _saveChecked({}); + _renderTabContent(); + }); + } + + // ------------------------------------------------------------------ + // TAB 2: EU-LÄNDER + // ------------------------------------------------------------------ + function _renderLaender(el) { + const cards = LAENDER.map(l => ` +
+
+ ${l.flag} +
+
+ ${_esc(l.name)} +
+
+ ${_esc(l.regel)} +
+
+ ${l.warn ? `` : ''} +
+
`).join(''); + + el.innerHTML = ` +
+ + EU-Heimtierausweis + Mikrochip + gültige Tollwut-Impfung (mindestens 21 Tage alt) + sind Pflicht für alle EU-Reisen. Informationen können sich ändern — immer beim + Zielland-Konsulat oder Tierarzt aktuell prüfen. +
+ ${cards} + `; + } + + // ------------------------------------------------------------------ + // TAB 3: NOTFÄLLE + // ------------------------------------------------------------------ + function _renderNotfall(el) { + const massnahmen = SOFORTMASSNAHMEN.map(m => ` +
+ + ${_esc(m.text)} +
`).join(''); + + el.innerHTML = ` +
+
+ + Notrufnummern +
+
+ + + 112 — EU-Notruf + + + + Tiergift-Notruf München + +
+ +49 89 19240 (Tierärztliche Hochschule) +
+
+
+ +
+
Tierarzt finden
+
+ +
+
+ +
+
+ + Sofortmaßnahmen +
+
+ ${massnahmen} +
+
+ `; + + el.querySelector('#reise-map-btn')?.addEventListener('click', () => { + App.navigate('map'); + }); + } + + // ------------------------------------------------------------------ + // PUBLIC + // ------------------------------------------------------------------ + return { init, refresh, onDogChange }; +})(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index bdc48c1..deb71ae 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -695,10 +695,60 @@ window.Page_routes = (() => { _recActive = true; _recTrack = []; _recDistKm = 0; _recStartTime = Date.now(); + // iOS-Hinweis: Display muss wach bleiben + if (/iPad|iPhone|iPod/.test(navigator.userAgent)) { + const banner = document.createElement('div'); + banner.style.cssText = 'position:absolute;top:0;left:0;right:0;z-index:960;' + + 'background:rgba(30,30,30,0.92);color:#fff;font-size:13px;line-height:1.4;' + + 'padding:10px 14px;display:flex;align-items:flex-start;gap:10px;' + + 'border-bottom:1px solid rgba(255,255,255,0.1)'; + banner.innerHTML = ` + + Display wach lassen! Auf iPhone stoppt die GPS-Aufzeichnung, wenn das Display ausgeht — Helligkeit hochsetzen oder Bildschirm nicht sperren.`; + document.getElementById('rk-rec-map-wrap')?.appendChild(banner); + setTimeout(() => banner.remove(), 9000); + } + const ctrl = document.getElementById('rk-rec-ctrl'); - ctrl.innerHTML = ``; - ctrl.querySelector('#rk-rec-stopbtn').addEventListener('click', () => _stopRecInOvl(true)); + ctrl.innerHTML = ` + `; + + // Long-Press 1.8s zum Stoppen + let _stopTimer = null, _stopTick = null; + const btn = ctrl.querySelector('#rk-rec-stopbtn'); + const fill = ctrl.querySelector('#rk-stop-fill'); + const startHold = () => { + if (_stopTimer) return; + const DURATION = 1800; + const start = Date.now(); + _stopTick = setInterval(() => { + const p = Math.min((Date.now() - start) / DURATION, 1); + fill.style.transition = 'none'; + fill.style.transform = `scaleX(${p})`; + }, 30); + _stopTimer = setTimeout(() => { + clearInterval(_stopTick); _stopTick = null; _stopTimer = null; + fill.style.transform = 'scaleX(1)'; + _stopRecInOvl(true); + }, DURATION); + }; + const cancelHold = () => { + if (!_stopTimer && !_stopTick) return; + clearTimeout(_stopTimer); clearInterval(_stopTick); + _stopTimer = null; _stopTick = null; + fill.style.transition = 'transform 0.25s ease'; + fill.style.transform = 'scaleX(0)'; + }; + btn.addEventListener('pointerdown', e => { e.preventDefault(); startHold(); }); + btn.addEventListener('pointerup', cancelHold); + btn.addEventListener('pointerleave', cancelHold); + btn.addEventListener('pointercancel', cancelHold); document.getElementById('rk-rec-stats-bar').style.display = ''; _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap); @@ -789,7 +839,7 @@ window.Page_routes = (() => { dim.style.display = 'flex'; _recDimmed = true; } - }, 10000); + }, 5000); } async function _stopRecInOvl(save) { diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8829bb6..ffc47f7 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -229,6 +229,7 @@ window.Page_settings = (() => {
+
@@ -313,7 +314,7 @@ window.Page_settings = (() => {
-
+
KI-Notiz-Assistent
@@ -336,6 +337,30 @@ window.Page_settings = (() => {
+ +
+ +
+
Goldene Gassi-Stunde täglich
+
+ Täglich um 07:00 Uhr: bestes Wetterfenster für den Gassi-Gang +
+
+ +
+
@@ -418,10 +443,88 @@ window.Page_settings = (() => { : `🔥 Noch kein Streak — heute aktiv werden!`; } + // Lifetime-km Balken mit Meilenstein-Markierungen + const lifetimeEl = document.getElementById('settings-lifetime-km'); + if (lifetimeEl) { + const km = s.total_km ?? 0; + const MILESTONES = [ + { km: 100, label: '100', badge: '100-km-Club', color: '#cd7f32' }, + { km: 500, label: '500', badge: '500-km-Wanderer', color: '#94a3b8' }, + { km: 1000, label: '1k', badge: 'Tausend-km-Held', color: '#f59e0b' }, + { km: 5000, label: '5k', badge: 'Ultraläufer', color: '#cbd5e1' }, + ]; + const maxKm = 5000; + const pct = Math.min(km / maxKm * 100, 100); + const nextM = MILESTONES.find(m => km < m.km); + const reachedM = MILESTONES.filter(m => km >= m.km); + const lastBadge = reachedM.length ? reachedM[reachedM.length - 1] : null; + + const markers = MILESTONES.map(m => { + const pos = (m.km / maxKm * 100).toFixed(1); + const reached = km >= m.km; + return `
+
+
${m.label}
`; + }).join(''); + + lifetimeEl.innerHTML = ` +
+ 🐾 Lebenswerk-km + ${km} km +
+
+
+
+ ${markers} +
+ ${nextM + ? `
+ Noch ${(nextM.km - km).toLocaleString('de-DE')} km + bis ${nextM.badge} +
` + : `
+ Ultraläufer-Legende erreicht! 🏆 +
`} +
`; + } + if (badgesEl && a.categories) { - // SVG-Schild für jede Kategorie - const shield = (color, dark, emoji, opacity = 1) => ` - { + const photo = _BADGE_PHOTOS[catId]; + const clipId = `clip_${catId || Math.random().toString(36).slice(2)}`; + const path = 'M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z'; + if (photo && opacity === 1) { + return ` + + + + + + + ${emoji} + `; + } + return ` @@ -429,13 +532,12 @@ window.Page_settings = (() => { - - + + ${emoji} `; + }; badgesEl.innerHTML = (a.categories || []).map(cat => { const cur = cat.current_tier; @@ -450,8 +552,8 @@ window.Page_settings = (() => { // Aktuelles Schild const shieldSvg = cur - ? shield(cur.color, cur.dark, cat.emoji) - : shield('#9ca3af', '#6b7280', cat.emoji, 0.5); + ? shield(cur.color, cur.dark, cat.emoji, 1, cat.id) + : shield('#9ca3af', '#6b7280', cat.emoji, 0.5, cat.id); // Fortschrittsbalken const progressBar = nxt ? ` @@ -785,6 +887,25 @@ window.Page_settings = (() => { } }); + document.getElementById('toggle-gassi-stunde')?.addEventListener('change', async e => { + const enabled = e.target.checked; + const track = document.getElementById('toggle-gassi-stunde-track'); + const thumb = document.getElementById('toggle-gassi-stunde-thumb'); + if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = enabled ? '22px' : '2px'; + try { + await API.patch('/profile', { gassi_stunde_push: enabled ? 1 : 0 }); + _appState.user.gassi_stunde_push = enabled ? 1 : 0; + UI.toast.success(enabled ? 'Goldene Gassi-Stunde aktiviert.' : 'Goldene Gassi-Stunde deaktiviert.'); + } catch (err) { + UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); + // Revert UI + e.target.checked = !enabled; + if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = !enabled ? '22px' : '2px'; + } + }); + _loadReferral(); _loadBreederCard(); } @@ -1303,15 +1424,15 @@ window.Page_settings = (() => { _mode = mode; _container.innerHTML = ` -
+
Ban Yaro -

Ban Yaro

-

+ display:block;margin:0 auto var(--space-3)"> +

Ban Yaro

+

Alles rund um deinen Hund

@@ -1551,9 +1672,9 @@ window.Page_settings = (() => { _offerPushNotifications(); } - // Nach Login: Welcome-Seite oder Profil anlegen + // Nach Login: Direkt in HUND-Welt oder Profil anlegen if (_appState.activeDog) { - App.navigate('welcome'); + window.Worlds?.show(1); } else { App.navigate('dog-profile'); } diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index fed9bac..38f9f08 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -34,6 +34,7 @@ window.Page_uebungen = (() => { // ---------------------------------------------------------- // STATS STATE // ---------------------------------------------------------- + let _helpHandle = null; // Rückgabe von UI.pageInfo — für inline Trigger-Button let _statsData = null; // cached stats from /api/training/stats let _badgesData = null; // cached badges from /api/achievements let _exercisesByTab = {}; // aus API geladen @@ -476,6 +477,18 @@ window.Page_uebungen = (() => { if (_VALID_TABS.has(mapped)) _activeTab = mapped; } _render(); + _helpHandle = UI.pageInfo(_container, { + pageId: 'uebungen', + title: 'Übungsbibliothek', + icon: 'graduation-cap', + intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.', + steps: [ + { icon: 'list-checks', title: 'Stand erfassen', text: 'Klicke auf "Stand erfassen" um schnell für alle Übungen einzutragen, was euer aktueller Stand ist.' }, + { icon: 'flag', title: 'Übung üben', text: 'Tippe auf eine Übung, um die Anleitung zu lesen. Mit den Fortschritts-Icons (Flagge → Trophäe) trackst du, wie weit ihr seid.' }, + { icon: 'star', title: 'KI-Trainer', text: 'Im Tab "KI-Trainer" analysiert unsere KI deinen Trainingsstand und gibt personalisierte Empfehlungen.' }, + ], + tip: 'Regelmäßiges Training stärkt die Bindung — auch 5 Minuten täglich machen einen großen Unterschied!', + }); // Übungen aus DB laden (parallel mit Progress) if (!_exercisesLoaded) { @@ -735,10 +748,14 @@ window.Page_uebungen = (() => { Dein Plan für heute +
${cards.join('')}
`; + if (_helpHandle) { + document.getElementById('ueb-help-anchor')?.appendChild(_helpHandle.makeTriggerBtn()); + } el.querySelectorAll('.ueb-trainer-btn').forEach(btn => { btn.addEventListener('click', () => { diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 02a6bb6..f07ef74 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -9,9 +9,12 @@ window.Page_walks = (() => { let _appState = null; let _data = []; let _view = 'liste'; // 'liste' | 'karte' + let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm' let _map = null; let _markers = []; let _userPos = null; + let _challengeData = null; + let _gassiZeiten = []; // _esc ersetzt durch UI.escape() @@ -56,9 +59,17 @@ window.Page_walks = (() => { _loadData(); } - function refresh() { _loadData(); } + function refresh() { + _loadData(); + if (_tab === 'challenge') _loadChallenge(); + if (_tab === 'stamm') _loadGassiZeiten(); + } function onDogChange() {} - function openNew() { _showCreateForm(); } + function openNew() { + if (_tab === 'challenge') { _showSubmitForm(); return; } + if (_tab === 'stamm') { _showGassiZeitForm(); return; } + _showCreateForm(); + } // ---------------------------------------------------------- // RENDER — Grundstruktur @@ -67,30 +78,63 @@ window.Page_walks = (() => { _container.innerHTML = `
- -
-
- - -
- + +
+ + +
- -
-
+ +
+ +
+
+ + +
+ +
+ +
+
+

Lädt…

+
+
+ + +
+ + + - - `; + // Tab-Bar Events + document.getElementById('walks-tab-bar').addEventListener('click', e => { + const btn = e.target.closest('.by-tab'); + if (!btn) return; + _switchTab(btn.dataset.tab); + }); + document.getElementById('walks-view-toggle').addEventListener('click', e => { const btn = e.target.closest('.walks-view-btn'); if (!btn) return; @@ -105,6 +149,23 @@ window.Page_walks = (() => { } _showCreateForm(); }); + + document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => { + if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; } + _showGassiZeitForm(); + }); + } + + function _switchTab(tab) { + _tab = tab; + document.querySelectorAll('.by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === tab)); + document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none'); + const panel = document.getElementById(`walks-tab-${tab}`); + if (panel) panel.style.display = ''; + + if (tab === 'challenge' && !_challengeData) _loadChallenge(); + if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten(); } function _switchView(view) { @@ -1038,6 +1099,375 @@ window.Page_walks = (() => { }); } + // ============================================================== + // FEATURE 1: Foto-Challenge der Woche + // ============================================================== + + async function _loadChallenge() { + const el = document.getElementById('challenge-content'); + if (!el) return; + try { + _challengeData = await API.get('challenges/current'); + _renderChallenge(); + } catch (e) { + el.innerHTML = `

Konnte Challenge nicht laden.

`; + } + } + + function _renderChallenge() { + const el = document.getElementById('challenge-content'); + if (!el || !_challengeData) return; + const { challenge, submissions, my_submission_id, days_left } = _challengeData; + + const canSubmit = _appState.user && !my_submission_id; + const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`; + + el.innerHTML = ` +
+
+
${UI.escape(challenge.thema)}
+
+ ${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} – ${_fmtDate(challenge.end_date)} +  ·  ${UI.icon('timer')} Noch ${dayLabel} +
+ ${canSubmit ? `` : ''} + ${my_submission_id ? `${UI.icon('check')} Du hast bereits teilgenommen` : ''} +
+
+ +
+

Letzte Gewinner

+

Lädt…

+
+ +
+

+ ${UI.icon('images')} Einreichungen dieser Woche (${submissions.length}) +

+
+ + `; + + // Submit-Button + const submitBtn = document.getElementById('challenge-submit-btn'); + if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm); + + // Vote-Buttons + el.querySelectorAll('.challenge-vote-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } + const subId = parseInt(btn.dataset.id); + try { + const res = await API.post(`challenges/submissions/${subId}/vote`, {}); + btn.querySelector('.vote-count').textContent = res.votes; + btn.classList.toggle('voted', res.voted); + } catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); } + }); + }); + + // Gewinner laden + _loadChallengeWinners(); + } + + function _challengeSubmissionCard(s) { + const voted = s.i_voted; + return ` +
+ Challenge-Foto +
+
${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')} + ${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}
+ ${s.caption ? `
${UI.escape(s.caption)}
` : ''} + +
+
+ `; + } + + async function _loadChallengeWinners() { + const el = document.getElementById('challenge-winners-list'); + if (!el) return; + try { + const winners = await API.get('challenges/winners'); + if (!winners.length) { el.innerHTML = '

Noch keine vergangenen Challenges.

'; return; } + el.innerHTML = `
` + + winners.map(w => { + if (!w.winner) return `
${UI.escape(w.challenge.thema)}Kein Gewinner
`; + return `
+ Gewinner +
+
${UI.escape(w.challenge.thema)}
+
${UI.escape(w.winner.user_name)} · ${w.winner.votes} ❤️
+
+
`; + }).join('') + + `
`; + } catch {} + } + + async function _showSubmitForm() { + if (!_challengeData) return; + const dogs = _appState.dogs || []; + const dogOptions = dogs.map(d => ``).join(''); + + UI.modal.open({ + title: `📸 ${UI.escape(_challengeData.challenge.thema)}`, + body: ` + +
+ + +
+ ${dogs.length ? `
+ + +
` : ''} +
+ + +
+ + `, + footer: ` + + + `, + }); + + document.getElementById('challenge-submit-ok').addEventListener('click', async () => { + const fotoInput = document.getElementById('challenge-foto-input'); + if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; } + const caption = document.getElementById('challenge-caption')?.value?.trim() || ''; + const dogSelect = document.getElementById('challenge-dog-select'); + const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : ''; + + const fd = new FormData(); + fd.append('foto', fotoInput.files[0]); + if (caption) fd.append('caption', caption); + if (dogId) fd.append('dog_id', dogId); + + try { + await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd); + UI.toast.success('Foto eingereicht! Viel Erfolg 🎉'); + UI.modal.close(); + _challengeData = null; + _loadChallenge(); + } catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); } + }); + } + + + // ============================================================== + // FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis) + // ============================================================== + + const _WOCHENTAGE = [ + { key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' }, + { key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' }, + { key: 'so', label: 'So' }, + ]; + + async function _loadGassiZeiten() { + const el = document.getElementById('gassi-zeiten-content'); + if (!el) return; + try { + const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : ''; + _gassiZeiten = await API.get(`gassi-zeiten${params}`); + _renderGassiZeiten(); + } catch (e) { + el.innerHTML = `

Konnte Gassi-Zeiten nicht laden.

`; + } + } + + function _renderGassiZeiten() { + const el = document.getElementById('gassi-zeiten-content'); + if (!el) return; + + if (!_gassiZeiten.length) { + el.innerHTML = ` +
+ ${UI.icon('clock')} +

Noch keine Stamm-Gassi-Zeiten in deiner Nähe.

+

Trag deine regelmäßigen Zeiten ein — andere finden dich dann!

+
`; + return; + } + + const myZeiten = _gassiZeiten.filter(z => z.is_mine); + const andereZeiten = _gassiZeiten.filter(z => !z.is_mine); + + let html = ''; + + if (myZeiten.length) { + html += `
Meine Zeiten
`; + html += myZeiten.map(z => _gassiZeitCard(z)).join(''); + } + + if (andereZeiten.length) { + html += `
In deiner Nähe
`; + html += andereZeiten.map(z => _gassiZeitCard(z)).join(''); + } + + el.innerHTML = html; + + // Events + el.querySelectorAll('.gz-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Gassi-Zeit löschen?')) return; + try { + await API.del(`gassi-zeiten/${btn.dataset.id}`); + _gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id)); + _renderGassiZeiten(); + UI.toast.success('Gelöscht.'); + } catch (e) { UI.toast.error(e.message || 'Fehler.'); } + }); + }); + + el.querySelectorAll('.gz-toggle-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id)); + if (!gz) return; + try { + const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 }); + const idx = _gassiZeiten.findIndex(z => z.id === gz.id); + if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv }; + _renderGassiZeiten(); + } catch (e) { UI.toast.error(e.message || 'Fehler.'); } + }); + }); + + el.querySelectorAll('.gz-chat-btn').forEach(btn => { + btn.addEventListener('click', () => { + const userId = parseInt(btn.dataset.userId); + App.navigate('chat', { user_id: userId }); + }); + }); + } + + function _gassiZeitCard(z) { + const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase(); + const distLabel = z.distance_m != null + ? `${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}` + : ''; + const pausedStyle = z.aktiv ? '' : 'opacity:.5'; + + return ` +
+
+ ${z.dog_foto_url + ? `${UI.escape(z.dog_name || '')}` + : `
${UI.icon('paw-print')}
`} +
+
+
${UI.escape(z.dog_name || z.user_name || '?')} + ${z.dog_rasse ? `${UI.escape(z.dog_rasse)}` : ''} + ${!z.aktiv ? `Pausiert` : ''} +
+
+ ${UI.icon('clock')} ${UI.escape(z.uhrzeit)} +  ·  ${wochentageLabel} + ${z.ort_name ? ` ·  ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''} + ${distLabel} +
+ ${z.notiz ? `
${UI.escape(z.notiz)}
` : ''} +
+
+ ${z.is_mine ? ` + + + ` : ` + + `} +
+
+ `; + } + + async function _showGassiZeitForm() { + const dogs = _appState.dogs || []; + const dogOptions = dogs.map(d => ``).join(''); + const wdBtns = _WOCHENTAGE.map(w => + `` + ).join(''); + + UI.modal.open({ + title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`, + body: ` +
+ ${dogs.length ? `
+ + +
` : ''} +
+ + +
+
+ +
${wdBtns}
+
+
+ + +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById('gz-save-btn').addEventListener('click', async () => { + const uhrzeit = document.getElementById('gz-uhrzeit')?.value; + if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; } + + const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value); + if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; } + + const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null; + const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null; + const notiz = document.getElementById('gz-notiz')?.value?.trim() || null; + + const payload = { wochentage, uhrzeit, ort_name: ortName, notiz }; + if (dogId) payload.dog_id = dogId; + if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; } + + try { + const created = await API.post('gassi-zeiten', payload); + _gassiZeiten.unshift({ ...created }); + _renderGassiZeiten(); + UI.toast.success('Gassi-Zeit eingetragen! 🐾'); + UI.modal.close(); + } catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); } + }); + } + + return { init, refresh, onDogChange, openNew, openDetail: _openDetail }; })(); diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index e3514d5..fdbdc87 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -497,9 +497,16 @@ window.Page_welcome = (() => { API.dogs.welcomeDashboard(dog.id).then(dash => { _updateHeroFromDash(dash, dog); _updateChipsFromDash(dash); - _tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen + _tryRouteChip(dash); }).catch(() => { /* Skeleton bleibt sichtbar */ }); + // Hero-Foto stündlich auffrischen (Tageswechsel um Mitternacht sichtbar ohne Reload) + setInterval(() => { + API.dogs.welcomeDashboard(dog.id) + .then(dash => _updateHeroFromDash(dash, dog)) + .catch(() => {}); + }, 60 * 60 * 1000); + // Streak-Widget asynchron laden _loadStreakWidget(dog.id); } diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index c838916..751f410 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -55,11 +55,12 @@ window.Page_wetter = (() => { // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _data = null; - let _selDay = 0; - let _loading = false; + let _container = null; + let _appState = null; + let _data = null; + let _selDay = 0; + let _loading = false; + let _recordsLoaded = false; // ---------------------------------------------------------- // INIT @@ -76,7 +77,8 @@ window.Page_wetter = (() => { // REFRESH // ---------------------------------------------------------- async function refresh() { - _selDay = 0; + _selDay = 0; + _recordsLoaded = false; _renderShell(); _tryAutoLocate(); } @@ -110,22 +112,82 @@ window.Page_wetter = (() => { function _showLocationError() { const body = _container.querySelector('#wttr-body'); if (!body) return; + const isLoggedIn = !!_appState?.user; + body.innerHTML = ` -
-
📍
-

Standort nicht verfügbar

-

- Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden. -

- +
+ + +
+
🌤️🐾
+

+ Das Gassi-Wetter wartet auf dich +

+

+ Erfahre sekundengenau, ob gerade der perfekte Moment für eine Runde ist — + zugeschnitten auf dich und deinen Hund. +

+
+ + +
+ ${[ + ['sun', '#F59E0B', 'Gassi-Score 1–10', 'Wetter bewertet nach Temperatur, Regen und Wind'], + ['thermometer', '#3B82F6', '7-Tage-Vorschau', 'Plane deine Runden für die ganze Woche voraus'], + ['drop', '#06B6D4', 'Regenradar stündlich', '24h-Niederschlagstimeline auf einen Blick'], + ['trophy', '#10B981', 'Wetter-Rekorde', 'Wärmster, nassester und stürmischster Gassi-Tag'], + ].map(([icon, color, title, sub]) => ` +
+
+ + + +
+
+
${title}
+
${sub}
+
+
+ `).join('')} +
+ + +
+ + ${!isLoggedIn ? ` + +

+ Mit Account werden Rekorde & Gassi-Score für deinen Hund gespeichert. +

+ ` : ''} +
+
`; + body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => { _renderShell(); _tryAutoLocate(); }); + body.querySelector('#wttr-btn-login')?.addEventListener('click', () => { + if (window.App) App.navigate('settings'); + }); } // ---------------------------------------------------------- @@ -187,9 +249,18 @@ window.Page_wetter = (() => { style="margin-bottom:var(--space-4)">
+ +
+
+
+ + +
+
`; // Strip-Klick-Events @@ -198,12 +269,15 @@ window.Page_wetter = (() => { _selDay = parseInt(card.dataset.wttrDay); _updateStrip(); _renderDetail(); + _renderRainTimeline(); _renderDog(); }); }); _renderDetail(); + _renderRainTimeline(); _renderDog(); + _loadRecords(); } // ---------------------------------------------------------- @@ -318,6 +392,9 @@ window.Page_wetter = (() => {
+ + ${_gassiScoreBadge(d)} + ${sunriseStr && sunsetStr ? `
@@ -380,6 +457,137 @@ window.Page_wetter = (() => { `; } + // ---------------------------------------------------------- + // NIEDERSCHLAGS-ZEITSKALA (stündlich) + // ---------------------------------------------------------- + function _renderRainTimeline() { + const el = _container.querySelector('#wttr-rain'); + if (!el || !_data) return; + const d = (_data.days || [])[_selDay]; + if (!d) return; + + const hourly = d.hourly || []; + // Filtere auf Stunden mit Daten, die eine Niederschlagswahrscheinlichkeit haben + const entries = hourly.filter(h => h.precip_prob != null); + if (!entries.length) { el.style.display = 'none'; return; } + el.style.display = ''; + + // Für "Heute" (Tag 0): ab jetzt, sonst alle 24h + const now = new Date(); + const nowMin = now.getHours() * 60 + now.getMinutes(); + let slots = entries; + if (_selDay === 0) { + // Zeige ab der aktuellen Stunde (und die letzten 2h als Kontext) + const pastCutoff = now.getHours() - 2; + slots = entries.filter(h => { + const hHour = parseInt(h.hour.split(':')[0]); + return hHour >= pastCutoff; + }); + // Falls nichts übrig bleibt, zeige alles + if (!slots.length) slots = entries; + } + + // Max probability für Skalierung (mindestens 30 damit die Balken sichtbar sind) + const maxProb = Math.max(30, ...slots.map(h => h.precip_prob ?? 0)); + + // Farb-Funktion: blau basierend auf Wahrscheinlichkeit + function _rainColor(prob) { + if (prob < 10) return 'rgba(148,163,184,0.4)'; // grau, kaum Regen + if (prob < 25) return 'rgba(147,197,253,0.65)'; // hellblau + if (prob < 50) return 'rgba(96,165,250,0.8)'; // blau + if (prob < 75) return 'rgba(59,130,246,0.9)'; // kräftig blau + return 'rgba(29,78,216,1)'; // dunkelblau + } + + // Aktuell aktiver Slot (nur bei Heute) + const currentHour = now.getHours(); + + const bars = slots.map(h => { + const prob = h.precip_prob ?? 0; + const hHour = parseInt(h.hour.split(':')[0]); + const isNow = _selDay === 0 && hHour === currentHour; + const barH = Math.max(2, Math.round((prob / 100) * 56)); // max 56px Balkenhöhe + const color = _rainColor(prob); + const labelHour = h.hour.substring(0, 2); // 'HH' + + return ` +
+ +
+ ${prob >= 20 ? prob + '%' : ''} +
+ +
+
+
+ +
+ ${isNow ? 'jetzt' : labelHour + 'h'} +
+
+ `; + }).join(''); + + // Gibt es überhaupt nennenswerten Niederschlag? + const hasRain = slots.some(h => (h.precip_prob ?? 0) >= 10); + const titleColor = hasRain ? '#60A5FA' : 'var(--c-text-secondary)'; + const titleIcon = hasRain ? 'cloud-rain' : 'cloud'; + + el.innerHTML = ` +
+ + + + + Niederschlagswahrscheinlichkeit + + + ${_selDay === 0 ? 'heute' : _esc(d.date ? new Date(d.date + 'T12:00').toLocaleDateString('de', {weekday:'short', day:'numeric', month:'short'}) : '')} + +
+ +
+
+
+ ${bars} +
+
+ +
+
+ ${!hasRain ? ` +
+ Kein Regen erwartet +
` : ''} + `; + + // Scroll zum aktuellen Slot wenn Heute + if (_selDay === 0) { + requestAnimationFrame(() => { + const wrap = el.querySelector('div[style*="overflow-x"]'); + if (!wrap) return; + const nowIdx = slots.findIndex(h => parseInt(h.hour.split(':')[0]) === currentHour); + if (nowIdx > 2) { + wrap.scrollLeft = (nowIdx - 2) * 41; // ca. 38px + 3px gap + } + }); + } + } + // ---------------------------------------------------------- // HUNDE-WETTER // ---------------------------------------------------------- @@ -390,11 +598,24 @@ window.Page_wetter = (() => { if (!d) return; const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' }; - let html = `

- - Hunde-Wetter -

`; + const felltyp = (_appState?.activeDog ?? _appState?.dogs?.[0])?.fell_typ || null; + const _wl = _dogWeatherLabel(d, felltyp); + let html = ` +
+
${_wl.emoji}
+
+ ${_esc(_wl.label)} +
+
+ ${_esc(_wl.sub)} +
+
+

+ + Hunde-Hinweise +

`; // Asphalt-Temperatur if (d.asphalt_temp != null) { @@ -500,6 +721,45 @@ window.Page_wetter = (() => { `; } + // Fell-spezifische Hinweise + if (felltyp) { + const tempNow = d.temp_max ?? 20; + let fellHint = null; + if (felltyp === 'doppel' && tempNow > 20) { + fellHint = { icon: 'thermometer-hot', color: '#F97316', + text: 'Doppeltes Fell — heute besonders auf Überhitzung achten.' }; + } else if (felltyp === 'nackt' && tempNow < 15) { + fellHint = { icon: 'coat-hanger', color: '#60A5FA', + text: 'Nackthund braucht heute eine Hundejacke oder einen -pullover.' }; + } else if (felltyp === 'kurz' && tempNow < 5) { + fellHint = { icon: 'snowflake', color: '#38BDF8', + text: 'Kurzhaar friert schnell — Hundemantel empfohlen.' }; + } + if (fellHint) { + html += ` +
+ + + +
${_esc(fellHint.text)}
+
+ `; + } + } + + // Schnüffel-Index + Hunde-Alter Chips + const ageYears = _dogAgeYears(); + html += _dogAgeChip(ageYears); + + html += ` +
+ ${_schnueffelChip(d)} +
+ `; + // Wenn keine Hunde-Daten vorhanden if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm && !d.zecken && !(pollen && Object.keys(pollen).length)) { @@ -513,6 +773,160 @@ window.Page_wetter = (() => { el.innerHTML = html; } + // ---------------------------------------------------------- + // GASSI-SCORE (1–10) + // ---------------------------------------------------------- + function _gassiScore(d) { + let score = 10; + const temp = d.temp_max ?? 20; + const precip = d.precip_prob ?? 0; + const wind = d.windspeed_max ?? 0; + const asphalt = d.asphalt_temp ?? 0; + + // Temperatur (ideal: 10–20°C) + if (temp > 30) score -= 3; + else if (temp > 25) score -= 1; + else if (temp < 0) score -= 3; + else if (temp < 5) score -= 1; + + // Regen + if (precip > 70) score -= 3; + else if (precip > 40) score -= 2; + else if (precip > 20) score -= 1; + + // Wind + if (wind > 60) score -= 2; + else if (wind > 40) score -= 1; + + // Asphalt + if (asphalt > 55) score -= 2; + else if (asphalt > 45) score -= 1; + + // Gewitter + if (d.thunderstorm) score -= 3; + + return Math.max(1, Math.min(10, score)); + } + + function _gassiScoreBadge(d) { + const score = _gassiScore(d); + let color, text; + if (score >= 8) { + color = '#10B981'; + text = 'Toller Gassi-Tag!'; + } else if (score >= 5) { + color = '#F59E0B'; + text = 'Geht so'; + } else { + color = '#EF4444'; + text = 'Lieber drinbleiben'; + } + return ` +
+ 🐾 Gassi-Score + + ${score} + + / 10 + — ${_esc(text)} +
+ `; + } + + // ---------------------------------------------------------- + // SCHNÜFFEL-INDEX + // ---------------------------------------------------------- + function _schnueffelIndex(d) { + const temp = d.temp_max ?? 20; + const precip = d.precip_prob ?? 0; + + // Feuchtigkeit aus precip_prob ableiten + const feucht = precip > 60 ? 'feucht' : precip > 30 ? 'leicht-feucht' : 'trocken'; + + if (feucht === 'feucht' && temp >= 10 && temp <= 18) + return { label:'Exzellent 👃', color:'#10B981' }; + if (feucht === 'feucht' && temp > 10 && temp <= 22) + return { label:'Sehr gut 👃', color:'#34D399' }; + if (temp < 5) + return { label:'Gut (kalte Luft trägt Gerüche)', color:'#60A5FA' }; + if (feucht === 'leicht-feucht' && temp >= 10 && temp <= 22) + return { label:'Gut 👃', color:'#4CAF50' }; + if (feucht === 'trocken' && temp > 25) + return { label:'Schwach', color:'#94A3B8' }; + return { label:'Mittel', color:'#F59E0B' }; + } + + function _schnueffelChip(d) { + const s = _schnueffelIndex(d); + return ` + + + Schnüffel: ${_esc(s.label)} + + `; + } + + // ---------------------------------------------------------- + // HUNDE-ALTER aus appState + // ---------------------------------------------------------- + function _dogAgeYears() { + try { + const dog = _appState?.activeDog || _appState?.dog || _appState?.active_dog; + if (!dog) return null; + const geb = dog.geburtsdatum || dog.birthdate; + if (!geb) return null; + const birth = new Date(geb); + if (isNaN(birth)) return null; + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const m = now.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--; + return age < 0 ? 0 : age; + } catch { return null; } + } + + function _dogAgeChip(ageYears) { + if (ageYears === null) return ''; + if (ageYears < 1) { + return ` +
+ +
+ Welpe — kurze Spaziergänge, max. 15 Min bei Hitze. + Gelenke und Pfoten besonders schonen. +
+
+ `; + } + if (ageYears >= 8) { + return ` +
+ +
+ Seniorhund — Hitze und Kälte vermeiden, kurze Runden bevorzugen. + Auf Gelenkbeschwerden achten. +
+
+ `; + } + return ''; + } + // ---------------------------------------------------------- // HILFSFUNKTIONEN — Wetter // ---------------------------------------------------------- @@ -557,6 +971,58 @@ window.Page_wetter = (() => { return '#F44336'; // level 4+ } + function _dogWeatherLabel(d, felltyp) { + const temp = d.temp_max ?? 20; + const tempMin = d.temp_min ?? temp; + const precip = d.precip_prob ?? 0; + const wind = d.windspeed_max ?? 0; + const asphalt = d.asphalt_temp ?? 0; + const wcode = d.weathercode ?? 0; + const isSnow = wcode >= 71 && wcode <= 77; + + // Fell-spezifische Temperaturschwellen + const heatLimit = { + kurz: 25, mittel: 27, lang: 22, drahtaar: 26, doppel: 30, nackt: 20 + }[felltyp] ?? 28; + const coldLimit = { + kurz: 8, mittel: 5, lang: 3, drahtaar: 5, doppel: -5, nackt: 15 + }[felltyp] ?? 5; + + if (d.thunderstorm) + return { label:'Gewitterangst-Wetter', sub:'Angsthasen lieber zu Hause lassen', emoji:'⛈️', color:'#7C3AED' }; + if (isSnow && temp < 3) + return { label:'Schnee-Toben-Wetter', sub:'Pudel im Schnee — der Klassiker', emoji:'❄️', color:'#38BDF8' }; + if (isSnow) + return { label:'Matschpfoten-Wetter', sub:'Pfoten nach der Runde gut abtrocknen', emoji:'🌨️', color:'#60A5FA' }; + if (tempMin < 0 && precip < 30) + return { label:'Kristallklare Nasenluft', sub:'Kalt aber herrlich — Schnüffeln auf Maximum', emoji:'🌡️', color:'#60A5FA' }; + if (temp < coldLimit && precip > 50) + return { label:'Kuschelwetter', sub:'Kurze Runde, dann ab auf das Sofa', emoji:'🏠', color:'#6B7280' }; + if (temp < coldLimit) + return { label:'Fellkuschelwetter', sub:'Frisch und klar — ideal für aktive Rassen', emoji:'🧣', color:'#93C5FD' }; + if (temp > heatLimit && asphalt > 50) + return { label:'Pfoten-Alarm!', sub:'Asphalt zu heiß — früh morgens oder abends raus', emoji:'🔥', color:'#EF4444' }; + if (temp > heatLimit) + return { label:'Schwimm-Wetter', sub:'Bach oder See suchen — Hunde überhitzen schnell', emoji:'🏊', color:'#F97316' }; + if (precip > 70 && temp < 15) + return { label:'Nass-Hund-Wetter', sub:'Handtuch bereit? Der Geruch kommt garantiert', emoji:'💧', color:'#3B82F6' }; + if (precip > 70) + return { label:'Warm-Dusch-Wetter', sub:'Wer braucht noch ein Bad — der Regen übernimmt', emoji:'🌧️', color:'#60A5FA' }; + if (precip > 30 && temp >= 10 && temp <= 20) + return { label:'Schnüffel-Wetter', sub:'Feuchte Luft = Nasenarbeit pur — Gerüche lieben das', emoji:'👃', color:'#34D399' }; + if (wind > 50) + return { label:'Sturmfrisur-Wetter', sub:'Fell in alle Richtungen — Leine gut festhalten', emoji:'🌬️', color:'#A78BFA' }; + if (wind > 30 && temp >= 15) + return { label:'Ohren-im-Wind-Wetter', sub:'Optimal für Hunde mit Schlappohren', emoji:'💨', color:'#A78BFA' }; + if (precip > 30 && precip <= 70) + return { label:'Gassiregen-Wetter', sub:'Leichte Jacke, kurze Runde — Hund findet es gut', emoji:'🌦️', color:'#60A5FA' }; + if (temp >= 18 && temp <= 26 && precip < 20) + return { label:'Perfektes Gassi-Wetter',sub:'Heute müssen alle Routen genossen werden', emoji:'🐾', color:'#10B981' }; + if (temp >= 10 && temp < 18 && precip < 30) + return { label:'Klassisches Hunde-Wetter', sub:'Nicht zu warm, nicht zu kalt — Vierbeiner-Paradies', emoji:'🐕', color:'#4CAF50' }; + return { label:'Gutes Hunde-Wetter', sub:'Raus mit dem Hund!', emoji:'🐶', color:'#10B981' }; + } + function _tickLevel(risk) { const r = (risk || '').toLowerCase(); if (r === 'niedrig') return ['niedrig', '#4CAF50']; @@ -573,6 +1039,106 @@ window.Page_wetter = (() => { .replace(/"/g, '"'); } + // ---------------------------------------------------------- + // MEINE WETTERREKORDE + // ---------------------------------------------------------- + async function _loadRecords() { + // Nur wenn User eingeloggt + if (!_appState?.user) return; + // Nur einmal pro Seitenaufruf laden + if (_recordsLoaded) return; + _recordsLoaded = true; + try { + const res = await API.get('/weather/records'); + _renderRecords(res?.records || null); + } catch { + // Stumm scheitern — Rekorde sind ein Nice-to-have + } + } + + function _fmtDate(datum) { + if (!datum) return ''; + try { + return new Date(datum + 'T12:00').toLocaleDateString('de', { + day: 'numeric', month: 'short', year: 'numeric' + }); + } catch { return datum; } + } + + function _recordCard(emoji, title, value, subtitle, color) { + return ` +
+
+ ${emoji} + ${_esc(title)} +
+
+ ${_esc(value)} +
+
+ ${_esc(subtitle)} +
+
+ `; + } + + function _renderRecords(records) { + const el = _container.querySelector('#wttr-records'); + if (!el) return; + + // Mindestens 3 Einträge nötig + if (!records || (records.gesamt_eintraege || 0) < 3) { + el.innerHTML = ''; + return; + } + + const cards = []; + + if (records.kaeltester) { + const e = records.kaeltester; + const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum); + cards.push(_recordCard('🥶', 'Kältester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#60A5FA')); + } + + if (records.heissester) { + const e = records.heissester; + const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum); + cards.push(_recordCard('🔥', 'Heißester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#EF4444')); + } + + if (records.stuermischster) { + const e = records.stuermischster; + const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum); + cards.push(_recordCard('🌬️', 'Stürmischster Tag', `${Math.round(e.wind_kmh)} km/h`, sub, '#A78BFA')); + } + + const regenCount = records.regen_eintraege || 0; + const gesamt = records.gesamt_eintraege || 0; + cards.push(_recordCard('💧', 'Regentage', `${regenCount} Einträge`, `von ${gesamt} Tagebucheinträgen`, '#3B82F6')); + + el.innerHTML = ` +
+

+ + + + Meine Wetterrekorde +

+
+ ${cards.join('')} +
+
+ `; + } + // ---------------------------------------------------------- // PUBLIC API // ---------------------------------------------------------- diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index b2b6390..b0c101d 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -730,36 +730,34 @@ window.Page_wiki = (() => { : ''; const _dogSvgLg = _DOG_SILHOUETTE.replace('width="48" height="48"', 'width="56" height="56"'); - const photoHtml = rasse.foto_url - ? `
- ${_esc(rasse.name)} -
- ` + // Alle Fotos: Hauptbild zuerst, dann Community-Fotos + const allFotos = []; + if (rasse.foto_url) allFotos.push({ foto_url: rasse.foto_url, user_name: null }); + (rasse.user_fotos || []).forEach(f => allFotos.push(f)); + + const photoHtml = allFotos.length + ? `` : `
${_dogSvgLg}Kein Foto verfügbar
`; const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug); - - const userFotosHtml = (rasse.user_fotos || []).length - ? `
-
Community-Fotos
-
- ${rasse.user_fotos.map(f => ` -
- ${_esc(f.user_name)} -
von ${_esc(f.user_name)}
-
- `).join('')} -
-
` - : ''; + const userFotosHtml = ''; const body = ` ${/* 1. Hero */ ''} @@ -851,6 +849,65 @@ window.Page_wiki = (() => { document.getElementById('wiki-zuchter-placeholder')?.remove(); }); + // Gallery-Thumbnails + Lightbox + const mainImg = document.getElementById('wiki-main-photo'); + const strip = document.getElementById('wiki-gallery-strip'); + if (strip && mainImg) { + strip.querySelectorAll('.wiki-gallery-thumb').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + mainImg.src = allFotos[idx].foto_url; + mainImg.style.display = ''; + document.getElementById('wiki-photo-fallback').style.display = 'none'; + strip.querySelectorAll('.wiki-gallery-thumb').forEach(b => b.classList.toggle('active', b === btn)); + }); + }); + } + + document.getElementById('wiki-gallery-expand')?.addEventListener('click', () => { + const src = mainImg?.src || allFotos[0]?.foto_url; + if (!src) return; + let curIdx = allFotos.findIndex(f => f.foto_url && src.endsWith(f.foto_url.split('/').pop())); + if (curIdx < 0) curIdx = 0; + + function _lbOpen(idx) { + const f = allFotos[idx]; + const lb = document.getElementById('wiki-lightbox'); + lb.querySelector('.wlb-img').src = f.foto_url; + lb.querySelector('.wlb-caption').textContent = f.user_name ? `Foto von ${f.user_name}` : rasse.name; + lb.querySelector('.wlb-counter').textContent = `${idx + 1} / ${allFotos.length}`; + lb.querySelector('.wlb-prev').style.display = allFotos.length > 1 ? '' : 'none'; + lb.querySelector('.wlb-next').style.display = allFotos.length > 1 ? '' : 'none'; + curIdx = idx; + } + + const lb = document.createElement('div'); + lb.id = 'wiki-lightbox'; + lb.innerHTML = ` +
+
+ + + + +
+
+
`; + document.body.appendChild(lb); + _lbOpen(curIdx); + + const close = () => lb.remove(); + lb.querySelector('.wlb-close').addEventListener('click', close); + lb.querySelector('.wlb-backdrop').addEventListener('click', close); + lb.querySelector('.wlb-prev').addEventListener('click', () => _lbOpen((curIdx - 1 + allFotos.length) % allFotos.length)); + lb.querySelector('.wlb-next').addEventListener('click', () => _lbOpen((curIdx + 1) % allFotos.length)); + document.addEventListener('keydown', function onKey(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } + if (e.key === 'ArrowLeft') lb.querySelector('.wlb-prev').click(); + if (e.key === 'ArrowRight') lb.querySelector('.wlb-next').click(); + }); + }); + document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => { UI.modal.close(); setTimeout(() => _showBerichtForm(slug, rasse.name), 350); diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 867f1c9..a4a550b 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -280,6 +280,75 @@ const UI = (() => { // Alias für ältere Aufrufe const escHtml = escape; + // ---------------------------------------------------------- + // PAGE INFO — generische Seiten-Hilfe + // config: { pageId, title, icon?, intro, steps?: [{icon,title,text}], tip? } + // Erstes Öffnen: expandierter Banner. Danach: kleines ? im Header. + // ---------------------------------------------------------- + function pageInfo(container, config) { + const seenKey = 'help_seen_' + config.pageId; + const seen = !!localStorage.getItem(seenKey); + + function _buildSteps() { + if (!config.steps?.length) return ''; + return config.steps.map(s => ` +
+ ${s.icon ? `${_svgIcon(s.icon)}` : ''} +
+ ${s.title ? `
${s.title}
` : ''} +
${s.text}
+
+
`).join(''); + } + + function _openModal() { + modal.open({ + title: `${_svgIcon(config.icon || 'question')} ${config.title}`, + body: ` +
+

${config.intro}

+ ${config.steps?.length ? `
${_buildSteps()}
` : ''} + ${config.tip ? `
${_svgIcon('lightbulb')} ${config.tip}
` : ''} +
`, + }); + } + + // Kein automatischer absolut-positionierter Trigger mehr. + // Aufrufer kann openModal() nutzen und den Button selbst platzieren. + + // Banner beim ersten Besuch + if (!seen) { + localStorage.setItem(seenKey, '1'); + const banner = document.createElement('div'); + banner.className = 'pinfo-banner'; + banner.innerHTML = ` +
+ ${_svgIcon(config.icon || 'info')} + ${config.title} + +
+
${config.intro}
+ ${config.steps?.length ? `
${_buildSteps()}
` : ''} + + `; + banner.querySelector('.pinfo-banner-close').addEventListener('click', () => banner.remove()); + banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); }); + container.insertAdjacentElement('afterbegin', banner); + } + + // Inline-Trigger-Button (für Aufrufer zum Einbetten) + function makeTriggerBtn() { + const btn = document.createElement('button'); + btn.className = 'pinfo-trigger-inline'; + btn.setAttribute('aria-label', 'Hilfe'); + btn.innerHTML = _svgIcon('question'); + btn.addEventListener('click', _openModal); + return btn; + } + + return { openModal: _openModal, makeTriggerBtn }; + } + // ---------------------------------------------------------- // HELP TOOLTIP — inline ? Badge mit Klick-Tooltip // ---------------------------------------------------------- @@ -915,7 +984,7 @@ const UI = (() => { emptyState, time, setupPhotoPreview, scrollTop, skeleton, icon: _svgIcon, - escape, escHtml, help, + escape, escHtml, help, pageInfo, saveToAlbum, loadLeaflet, leafletMarker, diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 0fc7723..d0f6bf2 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -13,6 +13,7 @@ window.Worlds = (() => { let _lastUserId = undefined; let _dogs = []; // gecachte Hundesliste let _dogIdx = 0; // aktuell angezeigter Hund + let _hasBgPhoto = false; // Hintergrund-Foto vorhanden? // Touch-Tracking const _t = { x:0, y:0, active:false, vert:null, moved:0 }; @@ -49,9 +50,55 @@ window.Worlds = (() => { _setupButtons(); _goTo(_cur, false); show(); - // Welten parallel rendern - _renderJetzt(); - _renderHund(); + _showSwipeHints(); + } + + function _showSwipeHints() { + if (localStorage.getItem('worlds_swipe_seen')) return; + localStorage.setItem('worlds_swipe_seen', '1'); + const ov = document.getElementById('worlds-overlay'); + if (!ov) return; + const hint = document.createElement('div'); + hint.style.cssText = [ + 'position:absolute;inset:0;pointer-events:none;z-index:55', + 'display:flex;align-items:center;justify-content:space-between', + 'padding:0 8px;transition:opacity 1s ease', + ].join(';'); + const arrowStyle = ` + display:flex;flex-direction:column;align-items:center;gap:4px; + background:rgba(0,0,0,0.42);backdrop-filter:blur(10px); + -webkit-backdrop-filter:blur(10px); + border:1px solid rgba(255,255,255,0.18);border-radius:14px; + padding:10px 10px;animation:worlds-pulse 1.2s ease infinite alternate; + `; + hint.innerHTML = ` + +
+ + + + JETZT +
+
+ + + + WELT +
+ `; + ov.appendChild(hint); + setTimeout(() => { hint.style.opacity = '0'; }, 2800); + setTimeout(() => hint.remove(), 3900); } function show(worldIdx) { @@ -66,12 +113,17 @@ window.Worlds = (() => { if (worldIdx != null) _goTo(worldIdx, false); if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } - // Nach Login/Logout neu rendern + // Nach Login/Logout: Config aus DB laden, dann rendern const currentUserId = _state?.user?.id ?? null; if (currentUserId !== _lastUserId) { _lastUserId = currentUserId; - _renderJetzt(); - _renderHund(); + if (currentUserId) { + _loadConfigFromServer().then(() => { _renderJetzt(); _renderHund(); }); + } else { + _cfgCache = null; + _renderJetzt(); + _renderHund(); + } } } @@ -125,6 +177,21 @@ window.Worlds = (() => { _goTo(next, true); if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } }); + + // Mausrad-Navigation (Desktop) + let _wheelCooldown = false; + track.addEventListener('wheel', e => { + e.preventDefault(); + if (_wheelCooldown) return; + const next = e.deltaX > 30 || e.deltaY > 30 ? Math.min(2, _cur + 1) + : e.deltaX < -30 || e.deltaY < -30 ? Math.max(0, _cur - 1) + : _cur; + if (next === _cur) return; + _wheelCooldown = true; + setTimeout(() => { _wheelCooldown = false; }, 500); + _goTo(next, true); + if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } + }, { passive: false }); } function _goTo(idx, animated) { @@ -148,19 +215,32 @@ window.Worlds = (() => { document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur)); } + function _fabOptions() { + const worldNames = ['jetzt', 'hund', 'welt']; + const chips = _chipsForWorld(worldNames[_cur]); + const opts = []; + for (const chip of chips) { + if (chip.fab) for (const o of chip.fab) { if (opts.length < 6) opts.push(o); } + } + return opts; + } + function _updateFab() { const fab = document.getElementById('worlds-fab'); if (!fab) return; - const icons = ['note-pencil', 'paw-print', 'warning']; - const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden']; - fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`); - fab.title = titles[_cur]; + const opts = _fabOptions(); + if (!opts.length) { fab.style.display = 'none'; return; } + fab.style.display = ''; + fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`); + fab.title = 'Schnellaktion'; } function _setupButtons() { document.getElementById('worlds-fab')?.addEventListener('click', _openFab); - document.getElementById('worlds-settings')?.addEventListener('click', () => navigateTo('settings')); - document.getElementById('worlds-back')?.addEventListener('click', () => show()); + document.getElementById('worlds-back')?.addEventListener('click', () => { + if (_state?.user) show(); + else if (window.App) window.App.navigate('welcome'); + }); document.querySelectorAll('.wdot').forEach((dot, i) => { dot.style.pointerEvents = 'auto'; dot.addEventListener('click', () => { @@ -179,21 +259,13 @@ window.Worlds = (() => { } function _openFab() { - const isWelt = _cur === 2; - const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar + const options = _fabOptions(); + if (!options.length) return; - const options = isWelt ? [ - { icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }, - { icon:'dog', color:'#3B82F6', label:'Verlorenen Hund melden', sub:'Hilf beim Wiederfinden', page:'lost' }, - { icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }, - ] : [ - { icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }, - { icon:'target', color:'#F59E0B', label:'Training aufzeichnen',sub:'Übung absolviert', page:'uebungen' }, - { icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, - { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }, - ]; + const meldenPages = new Set(['poison','lost','recalls','map']); + const meldenCount = options.filter(o => meldenPages.has(o.page)).length; + const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'; - // Overlay erstellen const ov = document.createElement('div'); ov.id = 'fab-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end'; @@ -203,9 +275,7 @@ window.Worlds = (() => { padding:20px 16px calc(env(safe-area-inset-bottom,16px) + 16px); box-shadow:0 -8px 32px rgba(0,0,0,0.2)">
-
- ${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'} -
+
${title}
+
+ +
Dauer
+
+ ${durations.map(d => ` + + `).join('')} +
+ + +
+ `; + + document.body.appendChild(ov); + + const _close = () => ov.remove(); + ov.querySelector('#qg-backdrop').addEventListener('click', _close); + ov.querySelector('#qg-close').addEventListener('click', _close); + + // Dauer-Toggle + ov.querySelectorAll('.qg-dur').forEach(btn => { + btn.addEventListener('click', () => { + selectedMin = parseInt(btn.dataset.min); + ov.querySelectorAll('.qg-dur').forEach(b => { + const active = parseInt(b.dataset.min) === selectedMin; + b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; + b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-bg-card)'; + b.style.color = active ? 'var(--c-primary)' : 'var(--c-text)'; + }); + }); + }); + + // Eintragen + ov.querySelector('#qg-submit').addEventListener('click', async () => { + const submitBtn = ov.querySelector('#qg-submit'); + submitBtn.disabled = true; + submitBtn.textContent = 'Wird eingetragen…'; + + try { + const payload = { + typ: 'gassi', + titel: 'Schnell-Gassi 🐾', + text: `Kurze Runde, ${selectedMin} Minuten`, + }; + if (weatherData) { + payload.weather_json = JSON.stringify(weatherData); + } + await API.post(`/dogs/${dog.id}/diary`, payload); + _close(); + UI.toast?.success(`Gassi eingetragen! ${selectedMin} min 🐾`); + // Streak-Cache invalidieren + try { localStorage.removeItem('w3_streak_' + dog.id); } catch {} + // JETZT-Welt neu rendern für aktuellen Streak + setTimeout(() => _renderJetzt(), 300); + } catch (err) { + submitBtn.disabled = false; + submitBtn.innerHTML = ' Eintragen'; + UI.toast?.error('Fehler beim Eintragen. Bitte erneut versuchen.'); + } + }); + } + // ── CHIP-KONFIGURATION ────────────────────────────────────── // Alle verfügbaren Chips mit Metadaten const _ALL_CHIPS = [ - { icon:'note-pencil', label:'Notizblock', page:'notes' }, - { icon:'currency-eur', label:'Ausgaben', page:'expenses' }, - { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, - { icon:'handshake', label:'Playdate', page:'playdate' }, - { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, - { icon:'sun', label:'Wetter', page:'wetter' }, + { icon:'note-pencil', label:'Notizblock', page:'notes', + fab:[{ icon:'note-pencil', color:'#10B981', label:'Neue Notiz', sub:'Schnellnotiz erstellen', page:'notes', action:'openNew' }] }, + { icon:'currency-eur', label:'Ausgaben', page:'expenses', + fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] }, + { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, + { icon:'handshake', label:'Playdate', page:'playdate', + fab:[{ icon:'handshake', color:'#F59E0B', label:'Playdate anfragen', sub:'Treffen mit anderen Hunden', page:'playdate', action:'openNew' }] }, + { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, + { icon:'sun', label:'Wetter', page:'wetter' }, - { icon:'book-open', label:'Tagebuch', page:'diary' }, - { icon:'heartbeat', label:'Gesundheit', page:'health' }, - { icon:'target', label:'Übungen', page:'uebungen' }, - { icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'}, - { icon:'heart', label:'Adoption', page:'adoption' }, - { icon:'house-line', label:'Sitting', page:'sitting' }, - { icon:'books', label:'Wiki', page:'wiki' }, - { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, - { icon:'map-trifold', label:'Karte', page:'map' }, - { icon:'push-pin', label:'Forum', page:'forum' }, - { icon:'users', label:'Freunde', page:'friends' }, - { icon:'paw-print', label:'Gassi', page:'walks' }, - { icon:'skull', label:'Giftköder', page:'poison' }, - { icon:'warning-circle', label:'Rückrufe', page:'recalls' }, - { icon:'dog', label:'Verlorene', page:'lost' }, - { icon:'path', label:'Routen', page:'routes' }, - { icon:'calendar-dots', label:'Events', page:'events' }, - { icon:'sparkle', label:'Jobs', page:'jobs' }, - { icon:'book-open', label:'Knigge', page:'knigge' }, - { icon:'film-slate', label:'Filme', page:'movies' }, - { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' }, - { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' }, - { icon:'sparkle', label:'Social', page:'social', role:'social' }, - { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, - { icon:'gear', label:'Admin', page:'admin', role:'admin' }, + { icon:'book-open', label:'Tagebuch', page:'diary', + fab:[{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }] }, + { icon:'heartbeat', label:'Gesundheit', page:'health', + fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, + { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] }, + { icon:'target', label:'Übungen', page:'uebungen', + fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] }, + { icon:'list-checks', label:'Trainingspläne', page:'trainingsplaene', + fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] }, + { icon:'heart', label:'Adoption', page:'adoption', + fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] }, + { icon:'house-line', label:'Sitting', page:'sitting', + fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] }, + { icon:'books', label:'Wiki', page:'wiki' }, + { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, + { icon:'map-trifold', label:'Karte', page:'map', + fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] }, + { icon:'push-pin', label:'Forum', page:'forum', + fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] }, + { icon:'users', label:'Freunde', page:'friends', + fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] }, + { icon:'paw-print', label:'Gassi', page:'walks', + fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' }, + { icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] }, + { icon:'skull', label:'Giftköder', page:'poison', + fab:[{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }] }, + { icon:'warning-circle', label:'Rückrufe', page:'recalls', + fab:[{ icon:'warning-circle', color:'#EF4444', label:'Rückruf melden', sub:'Produkt oder Futter', page:'recalls', action:'openNew' }] }, + { icon:'dog', label:'Verlorene', page:'lost', + fab:[{ icon:'dog', color:'#3B82F6', label:'Verlorenen melden', sub:'Hilf beim Wiederfinden', page:'lost' }] }, + { icon:'path', label:'Routen', page:'routes', + fab:[{ icon:'path', color:'#10B981', label:'Route aufzeichnen', sub:'GPS-Tracking starten', page:'routes', action:'openNew' }] }, + { icon:'calendar-dots', label:'Events', page:'events', + fab:[{ icon:'calendar-dots', color:'#06B6D4', label:'Event erstellen', sub:'Veranstaltung ankündigen', page:'events', action:'openNew' }] }, + { icon:'sparkle', label:'Jobs', page:'jobs' }, + { icon:'book-open', label:'Knigge', page:'knigge' }, + { icon:'film-slate', label:'Filme', page:'movies' }, + { icon:'tree-structure', label:'Zuchtkartei', page:'zuchthunde', role:'breeder', + fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, + { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder', + fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] }, + { icon:'sparkle', label:'Social', page:'social', role:'social', + fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, + { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, + { icon:'gear', label:'Admin', page:'admin', role:'admin' }, + // ── NEUE FEATURES ──────────────────────────────────────────── + { icon:'fork-knife', label:'Ernährung', page:'ernaehrung', + fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] }, + { icon:'airplane', label:'Reise', page:'reise' }, + { icon:'smiley', label:'Persönlichkeit', page:'personality' }, ]; const _DEFAULT_CONFIG = { - jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'], - hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'], - welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'], + jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'], + hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse', + 'litters','zuchthunde','ernaehrung','personality'], + welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events', + 'jobs','knigge','movies','reise'], }; - function _getConfig() { - try { return JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; } - catch { return _DEFAULT_CONFIG; } + // _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default + let _cfgCache = null; + + async function _loadConfigFromServer() { + try { + const res = await API.get('/profile/world-config'); + if (res?.config) { + _cfgCache = res.config; + try { localStorage.setItem('world_chips', JSON.stringify(_cfgCache)); } catch {} + return; + } + // Noch nichts in DB: lokale Config hochladen (einmalige Migration) + const local = (() => { try { return JSON.parse(localStorage.getItem('world_chips') || 'null'); } catch { return null; } })(); + if (local) { + _cfgCache = local; + API.put('/profile/world-config', { config: local }).catch(() => {}); + return; + } + } catch {} + // Fallback: localStorage → Default + try { _cfgCache = JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; } + catch { _cfgCache = _DEFAULT_CONFIG; } } + + function _getConfig() { + return _cfgCache || _DEFAULT_CONFIG; + } + function _saveConfig(cfg) { + _cfgCache = cfg; try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {} + if (_state?.user) { + API.put('/profile/world-config', { config: cfg }).catch(() => {}); + } } function _chipMeta(page) { return _ALL_CHIPS.find(c => c.page === page) || null; @@ -413,10 +665,11 @@ window.Worlds = (() => { user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none"> ${!c.pinned ? ` ` : ` @@ -574,18 +827,13 @@ window.Worlds = (() => { async function _loadDailyImage(dog) { if (!dog) return null; - const todayKey = 'bg_' + new Date().toISOString().slice(0, 10); + const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10); const cached = _wLoad(todayKey); if (cached?.data) return cached.data; try { - const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`); - const entries = r.data?.entries || r.data || []; - const withPhotos = entries.filter(e => (e.foto_urls?.length || e.foto_url)); - if (!withPhotos.length) { const u = dog.foto_url || null; if(u) _wSave(todayKey, u); return u; } - const day = Math.floor(Date.now() / 86400000); - const entry = withPhotos[day % withPhotos.length]; - const url = (entry.foto_urls?.[0] || entry.foto_url); - _wSave(todayKey, url); + const dash = await API.dogs.welcomeDashboard(dog.id); + const url = dash?.random_photo?.url || dog.foto_url || null; + if (url) _wSave(todayKey, url); return url; } catch { return dog.foto_url || null; } } @@ -594,11 +842,19 @@ window.Worlds = (() => { const track = document.getElementById('worlds-track'); if (!track) return; if (url) { - const img = new Image(); - img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; }; - img.onerror = () => _applyBgImage(null); - img.src = url; + const toLoad = new Image(); + toLoad.onload = () => { + _hasBgPhoto = true; + track.style.backgroundImage = `url('${url}')`; + track.style.backgroundSize = '100% auto'; + track.style.backgroundPosition = '0 40%'; + track.style.backgroundRepeat = 'no-repeat'; + document.getElementById('wh-photo-hint')?.remove(); + }; + toLoad.onerror = () => _applyBgImage(null); + toLoad.src = url; } else { + _hasBgPhoto = false; track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)'; track.style.backgroundSize = '100% 100%'; } @@ -625,10 +881,11 @@ window.Worlds = (() => { const user = _state?.user; el.innerHTML = _skeleton(3); - const [weatherRes, dogsRes, alertsRes] = await Promise.allSettled([ + const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([ _getCachedWeather(), user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }), user ? _getNearbyAlerts() : Promise.resolve([]), + user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }), ]); const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 }; @@ -637,6 +894,7 @@ window.Worlds = (() => { const dogList = dogsObj.data || []; const dog = dogList[0] || null; const alertList = alertsRes.value || []; + const totalKm = achRes.value?.data?.stats?.total_km ?? null; const isOffline = weatherObj.fromCache && dogsObj.fromCache; const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0); @@ -650,26 +908,29 @@ window.Worlds = (() => { const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend'; const firstName = user?.name?.split(' ')[0] || ''; const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' }); - const stale = isOffline && staleMin > 5 + const stale = isOffline && staleMin > 5 ? `· Offline` : ''; - const weatherLine = w - ? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen` - : ''; - // Streak für 3er-Chip-Zeile - let streakVal = '—', streakCol = 'rgba(255,255,255,0.4)'; - if (user && dog) { - try { - const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`); - const s = sr.data; - const streak = s?.current_streak || 0; - const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10); - streakCol = trainedToday ? '#10B981' : (streak > 0 ? '#F59E0B' : 'rgba(255,255,255,0.4)'); - streakVal = streak > 0 - ? (trainedToday ? `✓ ${streak} Tage` : `🔥 ${streak} Tage`) - : (trainedToday ? '✓ Heute' : 'Heute starten'); - } catch {} + // Gassi-Score aus Wetterdaten berechnen + function _calcGassiScore(wd) { + if (!wd) return null; + let s = 10; + const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0; + if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1; + if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1; + if (wind > 60) s -= 2; else if (wind > 40) s -= 1; + if (wd.thunderstorm) s -= 3; + return Math.max(1, Math.min(10, s)); } + const gassiScore = _calcGassiScore(w); + const gassiColor = gassiScore >= 8 ? '#10B981' : gassiScore >= 5 ? '#F59E0B' : '#EF4444'; + const weatherEmoji = !w ? '🌤️' + : w.thunderstorm ? '⛈️' + : (w.precip_prob ?? 0) > 70 ? '🌧️' + : (w.precip_prob ?? 0) > 30 ? '🌦️' + : (w.temp_c ?? 20) > 28 ? '☀️🔥' + : (w.temp_c ?? 20) < 2 ? '🌨️' + : '☀️'; // Alert-Reminder const alertHtml = alertList.slice(0,1).map(a => ` @@ -712,7 +973,7 @@ window.Worlds = (() => {
${_esc(greet)}${firstName ? `, ${_esc(firstName)}` : ''}${stale}
-
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}
+
${_esc(dayStr)}
${user ? userAvatarHtml : ''}
@@ -720,17 +981,25 @@ window.Worlds = (() => { ${alertHtml} ${user && dog ? `
-
- - - Streak - ${streakVal} +
+
+ ${weatherEmoji} +
+
Gassi-Score
+
+ ${gassiScore ?? '—'} + ${gassiScore ? `/10` : ''} +
+ ${w ? `
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
` : ''} +
+
Gassirunde + ${totalKm != null ? `∑ ${totalKm} km` : ''}
@@ -829,13 +1098,41 @@ window.Worlds = (() => { const dogs = dogsRes.data || []; if (!dogs.length) { + const features = [ + { icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' }, + { icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' }, + { icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' }, + { icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' }, + { icon:'paw-print', color:'#3B82F6', title:'Gassi', sub:'Routen & GPS' }, + { icon:'currency-eur',color:'#06B6D4',title:'Ausgaben', sub:'Budget im Blick' }, + ]; el.innerHTML = ` -
-
🐶
-
Noch kein Hund angelegt
-
Erstelle das Profil deines Hundes
- +
+
+
🐶
+
Dein Hund wartet!
+
+ Lege ein Profil an und schalte alle Features frei +
+ +
+
+
+ +
+ ${features.map(f => ` +
+ + + + ${f.title} +
+ `).join('')} +
`; + el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); return; } @@ -904,6 +1201,25 @@ window.Worlds = (() => {
+ ${!_hasBgPhoto ? ` +
+ + + +
+
+ Hintergrund-Foto hinzufügen +
+
+ Tagebuchfotos erscheinen hier als Panorama +
+
+
+ ` : ''}
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')} diff --git a/backend/static/presse.html b/backend/static/presse.html index 2ea2e54..85ee38d 100644 --- a/backend/static/presse.html +++ b/backend/static/presse.html @@ -226,9 +226,90 @@
+ +
+ +
+ + +
+
+ Ban Yaro am Bach im Herbst +
+
+
Herbst am Bach
+
8064 × 6048 px · 20 MB JPEG
+ + ↓ Hi-Res herunterladen + +
+
+ + +
+
+ Ban Yaro im Schnee +
+
+
Winter im Schnee
+
Original-Auflösung · JPEG
+ + ↓ Hi-Res herunterladen + +
+
+ + +
+
+ Ban Yaro spielt im Frühling +
+
+
Frühling & Playdate
+
3199 × 2648 px · 3,8 MB JPEG
+ + ↓ Hi-Res herunterladen + +
+
+ + +
+
+ Ban Yaro neugierig am Baum +
+
+
Herbst & Neugier
+
8064 × 6048 px · 17 MB JPEG
+ + ↓ Hi-Res herunterladen + +
+
+ +
+

Alle Fotos: Ban Yaro (Kromfohrländer) · Fotograf: René Degelmann · Zur redaktionellen Verwendung freigegeben

+
+
- +
Tagebuch diff --git a/backend/static/sw.js b/backend/static/sw.js index e786916..f21635e 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,16 +3,16 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v651'; +const CACHE_VERSION = 'by-v715'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ - '/css/design-system.css?v=545', - '/css/layout.css?v=545', - '/css/components.css?v=545', + '/css/design-system.css?v=700', + '/css/layout.css?v=700', + '/css/components.css?v=700', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js', @@ -202,7 +202,8 @@ self.addEventListener('fetch', event => { .then(resp => { if (resp.ok) { _cacheMark(url.pathname); - caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + const toCache = resp.clone(); + caches.open(CACHE_API).then(c => c.put(event.request, toCache)); } return resp; }) diff --git a/backend/weather.py b/backend/weather.py index e5f2317..5e836c6 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -222,6 +222,7 @@ async def get_forecast(lat: float, lon: float) -> dict: "apparent_temperature_min,precipitation_probability_max,precipitation_sum," "weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max," "sunrise,sunset" + "&hourly=precipitation_probability,precipitation,weathercode" "&timezone=auto&forecast_days=7" ) pollen_url = ( @@ -245,6 +246,7 @@ async def get_forecast(lat: float, lon: float) -> dict: raw = forecast_resp.json() daily = raw.get('daily', {}) + hourly_fc = raw.get('hourly', {}) timezone = raw.get('timezone', 'auto') dates = daily.get('time', []) @@ -261,6 +263,24 @@ async def get_forecast(lat: float, lon: float) -> dict: sunrises = daily.get('sunrise', []) sunsets = daily.get('sunset', []) + # --- Hourly precipitation data grouped by day --- + hourly_times = hourly_fc.get('time', []) + hourly_pp = hourly_fc.get('precipitation_probability', []) + hourly_precip = hourly_fc.get('precipitation', []) + hourly_wcode = hourly_fc.get('weathercode', []) + # Build: date_str → list of {hour, precip_prob, precip, weathercode} + _hourly_by_day: dict = {} + for idx, ts_str in enumerate(hourly_times): + day_str = ts_str[:10] # 'YYYY-MM-DD' + hour_str = ts_str[11:16] # 'HH:MM' + entry = { + 'hour': hour_str, + 'precip_prob': hourly_pp[idx] if idx < len(hourly_pp) else None, + 'precip': hourly_precip[idx] if idx < len(hourly_precip) else None, + 'weathercode': int(hourly_wcode[idx]) if idx < len(hourly_wcode) and hourly_wcode[idx] is not None else None, + } + _hourly_by_day.setdefault(day_str, []).append(entry) + # --- Pollen (optional) --- pollen_daily: dict | None = None if not isinstance(pollen_resp, Exception): @@ -361,6 +381,7 @@ async def get_forecast(lat: float, lon: float) -> dict: 'zecken': zecken, 'thunderstorm': wcode in {95, 96, 99}, 'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0), + 'hourly': _hourly_by_day.get(date_str, []), }) result = {'timezone': timezone, 'days': days} diff --git a/scripts/dog_quotes.json b/scripts/dog_quotes.json new file mode 100644 index 0000000..7c3c8e2 --- /dev/null +++ b/scripts/dog_quotes.json @@ -0,0 +1,348 @@ +[ + { "text": "Der Hund ist des Menschen bester Freund.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Ein treuer Hund ist besser als ein falscher Freund.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde haben alle guten Eigenschaften des Menschen, ohne gleichzeitig seine Fehler zu besitzen.", "autor": "Friedrich II.", "kategorie": "weisheit" }, + { "text": "Dem Hunde, wenn er gut erzogen, wird selbst ein weiser Mann gewogen.", "autor": "Johann Wolfgang von Goethe", "kategorie": "training" }, + { "text": "Die Treue eines Hundes ist ein kostbares Geschenk, das nicht minder bindende moralische Verpflichtungen auferlegt als die Freundschaft eines Menschen.", "autor": "Konrad Lorenz", "kategorie": "treue" }, + { "text": "Wenn du einen verhungerten Hund aufliest und machst ihn satt, dann wird er dich nicht beißen. Das ist der Grundunterschied zwischen Hund und Mensch.", "autor": "Mark Twain", "kategorie": "weisheit" }, + { "text": "Woran sollte man sich von der endlosen Verstellung, Falschheit und Heimtücke der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen schauen kann?", "autor": "Arthur Schopenhauer", "kategorie": "weisheit" }, + { "text": "Ein Leben ohne Hund ist ein Irrtum.", "autor": "Carl Zuckmayer", "kategorie": "allgemein" }, + { "text": "Man kann auch ohne Hund leben, aber es lohnt sich nicht.", "autor": "Heinz Rühmann", "kategorie": "allgemein" }, + { "text": "Ein Hund ist das Einzige auf der Welt, das dich mehr liebt als sich selbst.", "autor": "Josh Billings", "kategorie": "liebe" }, + { "text": "In den Augen meines Hundes liegt mein ganzes Glück, all mein Inneres, Krankes, Wundes heilt in seinem Blick.", "autor": "Friederike Kempner", "kategorie": "liebe" }, + { "text": "Hunde sind nicht unser ganzes Leben, aber sie machen unser Leben ganz.", "autor": "Roger Caras", "kategorie": "liebe" }, + { "text": "Mein kleiner Hund — ein Herzschlag an meinen Füßen.", "autor": "Edith Wharton", "kategorie": "liebe" }, + { "text": "Der Hund ist ein Ehrenmann. Ich hoffe, in seinen Himmel zu kommen, nicht in den des Menschen.", "autor": "Mark Twain", "kategorie": "humor" }, + { "text": "Wenn du dich einsam fühlst, kaufe dir keinen Freund — leih dir einen Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund, der bellt, ist mehr wert als ein Mensch, der lügt.", "autor": "Henry de Montherlant", "kategorie": "weisheit" }, + { "text": "Ich hatte lieber meinen Hund bellen hören als einen Mann schwören, er liebe mich.", "autor": "William Shakespeare", "kategorie": "weisheit" }, + { "text": "Hunde sind die Führer des Planeten. Wenn du zwei Lebewesen siehst, von denen eins einen Haufen macht und das andere ihn aufhebt — wer ist wohl der Chef?", "autor": "Jerry Seinfeld", "kategorie": "humor" }, + { "text": "Ein Hund bringt dir Stöcke, weil er denkt, du mochtest ihn so sehr, dass du ihn sofort weggeworfen hast.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde lehren uns eine Menge über das Leben: Treue, Ausdauer und vor dem Hinlegen dreimal im Kreis zu drehen.", "autor": "Robert Benchley", "kategorie": "humor" }, + { "text": "Ich arbeite hart, damit mein Hund ein besseres Leben führen kann.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer sagt, dass Geld nicht glücklich macht, hat noch nie ein weinendes Kind gesehen, dem gerade ein Welpe geschenkt wurde.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Das Schönste, was ein Mensch besitzen kann, ist die Liebe eines Hundes.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist der einzige Freund, den du kaufen kannst.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Hundehaare sind das ultimative Accessoire für jede Kleidung.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wenn dein Hund übergewichtig ist, dann warst du nicht genug an der frischen Luft.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Gassi gehen ist keine Pflicht — es ist ein Abenteuer.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein müder Hund ist ein guter Hund.", "autor": "Sprichwort", "kategorie": "training" }, + { "text": "Hunde haben mehr Liebe zu verschenken als die meisten Menschen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Kein Psychiater der Welt kann einem Menschen so helfen wie ein Hund.", "autor": "Bern Williams", "kategorie": "liebe" }, + { "text": "Den eigenen Fokus auf erwünschtes Verhalten zu richten anstatt auf das, was nicht klappt, ist nicht nur für die Hundeerziehung ein großartiger Ansatz.", "autor": "Christina Hanf", "kategorie": "training" }, + { "text": "Die Menschen erwarten zu viel von ihrem Hund und zu wenig von sich selbst.", "autor": "Bob Bailey", "kategorie": "training" }, + { "text": "Gewalt bewirkt niemals etwas Gutes, weder bei Menschen noch bei Hunden.", "autor": "Turid Rugaas", "kategorie": "training" }, + { "text": "Training bedeutet, Situationen zu schaffen, in denen der Hund viel richtig machen kann.", "autor": "Gudrun Scholz", "kategorie": "training" }, + { "text": "Hunde erziehen heißt, sich selbst zu erziehen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein gut erzogener Hund macht seinem Besitzer Ehre.", "autor": "Sprichwort", "kategorie": "training" }, + { "text": "Lass schlafende Hunde liegen.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Wenn ein alter Hund bellt, soll man hinausschauen.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Der Hund, der viel bellt, beißt selten.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Beißt dich ein Hund, zeig ihm nicht die Wunde.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Mit Hunden und Kindern kommt man immer gut an.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Streichle einen Hund und er gehört dir fürs Leben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund vergisst keine Freundlichkeit.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde kennen keine Vorurteile.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund liebt dich, egal wie du aussiehst oder was du hast.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde sind Meister darin, im Augenblick zu leben.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn Menschen so treu wären wie Hunde, wäre die Welt eine bessere.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Der Blick eines Hundes kann mehr sagen als tausend Worte.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Jeder Mensch verdient einen Hund. Umgekehrt ist auch wahr: Jeder Hund verdient einen guten Menschen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund weiß nicht, was Einsamkeit bedeutet — er ist immer bei dir.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde lieben bedingungslos — etwas, das wir Menschen erst lernen müssen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Das Leben ist zu kurz für schlechtes Hundefutter.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Wer täglich mit seinem Hund spaziert, lebt länger und glücklicher.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Bewegung ist das beste Medikament — für Hund und Mensch.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund an der Leine hält auch sein Herrchen fit.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Laufen mit dem Hund ist die schönste Form des Sports.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hundebesitzer gehen Gassi. Hunde hingegen gehen auf eine Geruchssafari.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Immer wenn du beim Gassi gehen auf dein Handy schaust, verpasst du ein Abenteuer mit deinem Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Die beste Therapie hat vier Pfoten und einen Schwanz.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde bringen das Beste in uns zum Vorschein.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wer Hunde hat, hat selten Feinde.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Hunde verstehen uns oft besser als wir uns selbst.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund hat kein Ego — dafür hat er alles andere.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "The fidelity of a dog is a precious gift demanding no less binding moral responsibilities than the friendship of a human being.", "autor": "Konrad Lorenz", "kategorie": "treue" }, + { "text": "Dogs are not our whole life, but they make our lives whole.", "autor": "Roger Caras", "kategorie": "liebe" }, + { "text": "A dog is the only thing on earth that loves you more than he loves himself.", "autor": "Josh Billings", "kategorie": "liebe" }, + { "text": "Happiness is a warm puppy.", "autor": "Charles M. Schulz", "kategorie": "liebe" }, + { "text": "The better I get to know men, the more I find myself loving dogs.", "autor": "Charles de Gaulle", "kategorie": "humor" }, + { "text": "No matter how little money and how few possessions you own, having a dog makes you rich.", "autor": "Louis Sabin", "kategorie": "liebe" }, + { "text": "My goal in life is to be as good of a person as my dog already thinks I am.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Dogs have a way of finding the people who need them.", "autor": "Thom Jones", "kategorie": "liebe" }, + { "text": "A dog will teach you unconditional love. If you can have that in your life, things won't be too bad.", "autor": "Robert Wagner", "kategorie": "liebe" }, + { "text": "Ein Hund ist ein Teil unseres Lebens. Für unseren Hund sind wir sein ganzes Leben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde geben mehr als sie nehmen — das können die wenigsten von sich behaupten.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der größte Fehler beim Hundetraining ist, die Geduld zu vergessen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Belohnung schafft Vertrauen — Strafe schafft Angst.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Positive Verstärkung ist der Schlüssel zu einer glücklichen Hund-Mensch-Beziehung.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund, der Freude am Lernen hat, ist ein Hund, der Freude am Leben hat.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Konsequenz bedeutet nicht Strenge — es bedeutet Verlässlichkeit.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund braucht Grenzen, so wie ein Kind Grenzen braucht — aus Liebe, nicht aus Macht.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde folgen nicht den Gesetzen der Logik — sie folgen den Gesetzen des Herzens.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn du deinen Hund liebst, zeig es — er wartet den ganzen Tag darauf.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Kein Haus ist groß genug für einen Hund und seine Haare.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Früher hatten wir ein schönes Sofa. Jetzt hat unser Hund ein schönes Sofa.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund und ich haben eine Vereinbarung: Ich tue so, als würde ich das Essen nicht mitessen wollen, und er tut so, als würde er es glauben.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hundeliebe ist die reinste Form der Liebe — sie fragt nichts und gibt alles.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist der einzige Zeuge, der keine Erwartungen an dich stellt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Mit einem Hund an deiner Seite bist du nie wirklich allein.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Schwanz eines Hundes lügt nie.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde wissen genau, wann du traurig bist — und sie entscheiden sich trotzdem, bei dir zu bleiben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn ein Hund dir vertraut, hat er dir sein Herzstück gegeben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde mögen alle Menschen, die sie kennen — bis auf den Tierarzt. Und selbst den vergeben sie.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Gib deinem Hund heute ein extra Leckerli — er hat es sich verdient, einfach indem er da ist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist das lebende Beispiel dafür, dass man mit nichts als Liebe reich sein kann.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde bellen, die Karawane zieht weiter.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Wer einmal einen Hund gehabt hat, kann sich ein Leben ohne nicht mehr vorstellen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hundehaare auf dem Sofa sind kein Problem — sie sind ein Zeichen von Liebe.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist kein Tier — er ist ein Familienmitglied.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Das Einzige, was ein Hund von dir will, ist deine Zeit.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde lachen auch — nur mit dem Schwanz.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund trauert nie um eine verpasste Chance — er schafft sofort die nächste.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde leben im Moment. Das sollten wir auch.", "autor": "Cesar Millan", "kategorie": "weisheit" }, + { "text": "Dein Hund braucht kein Geschenk zu deinem Geburtstag — für ihn ist jeder Tag mit dir ein Fest.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund riecht deine Stimmung, bevor du sie selbst bemerkst.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde kennen keine schlechten Tage — sie kennen nur schlechte Momente, die mit einem Kuss besser werden.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Jedem Hundebesitzer passiert es einmal: Er redet mit seinem Hund als wäre er ein Mensch. Dem Hund ist das egal — er hört einfach zu.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde sind die einzigen Wesen, die dich für das lieben, was du bist, nicht für das, was du hast.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein gut erzogener Hund ist das Ergebnis eines gut erzogenen Besitzers.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Im Frühling, wenn die Erde sich erneuert, beginnt auch der Hund sein Fell zu verlieren — liberal.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Treu wie ein Hund — das ist kein Vergleich, das ist ein Kompliment.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wer einen Hund streichelt, streichelt auch sich selbst.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde senken den Blutdruck — zumindest bis sie auf dem weißen Teppich kotzen.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Studien zeigen: Hundebesitzer haben ein gesünderes Herz. Kein Wunder — ihr Herz wird täglich trainiert.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Gassi gehen bei Regen ist kein Spaß — aber dein Hund findet es toll.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Der einzige Fehler, den ein Hund macht, ist, zu früh zu sterben.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde machen keine Politik. Deswegen sind sie klüger als wir.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Haus wird zum Zuhause, wenn ein Hund darin wohnt.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Der Hund ist der älteste Freund des Menschen — und noch immer der beste.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wer einen Hund hat, hat keinen Grund zu klagen — höchstens über Hundehaare.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Manche Menschen verdienen nicht den Hund, den sie haben.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein guter Mensch behandelt seinen Hund wie einen Freund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wo ein Hund ist, ist Freude.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Ein Hund weiß nicht, was Gleichgültigkeit ist.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde erkennen gute Menschen auf Anhieb.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Vertraue dem Urteil deines Hundes über andere Menschen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund, der dir ohne Hintergedanken folgt, ist ein Geschenk.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hundeerziehung ist Beziehungspflege.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Wenn du deinen Hund gut kennst, kennt er dich noch besser.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Das schönste Geräusch am Morgen: der Schwanz deines Hundes, der wedelt, weil du aufgewacht bist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund redet nicht — und sagt doch alles.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde haben keine Worte für Enttäuschung — nur für Freude.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Für deinen Hund bist du der Beste — immer, ohne Ausnahme.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Hund ist das einzige Tier, das den Menschen als seinen Gott betrachtet.", "autor": "George Bernard Shaw", "kategorie": "weisheit" }, + { "text": "Bis es keinen Hund in meinem Leben gibt, wird mein Herz nie ganz gefüllt sein.", "autor": "Roger Caras", "kategorie": "liebe" }, + { "text": "Hunde sind die Philosophen des Alltags — sie leben, ohne zu grübeln.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der weise Hund macht einen Bogen um den Stock.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Hunde laufen auf vier Beinen und tragen dennoch die ganze Welt auf dem Rücken ihrer Besitzer.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist der Beweis, dass die Natur manchmal Vollkommenes erschafft.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde haben die Weisheit, den Menschen dort zu treffen, wo er steht.", "autor": "Orhan Pamuk", "kategorie": "weisheit" }, + { "text": "Für meinen Hund bin ich kein Held, kein Versager — ich bin einfach sein Zuhause.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Hund ist das Symbol der Loyalität, weil er sie nicht kennt — er lebt sie.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund bellte einmal einen Dieb an — und Menschen nennen ihn seitdem treu.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wer mit Hunden liegt, steht mit Flöhen auf.", "autor": "Sprichwort", "kategorie": "humor" }, + { "text": "Zwei Dinge machen das Leben leichter: ein guter Kaffee und ein treuer Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Der Hund liebt die Wahrheit — er ist das ehrlichste Lebewesen auf dem Planeten.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Es gibt keine schlechten Hunde. Nur schlechte Erziehung.", "autor": "Cesar Millan", "kategorie": "training" }, + { "text": "Ein Hund ist glücklich, wenn er beschäftigt ist und weiß, was von ihm erwartet wird.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Lob kostet nichts und bedeutet dem Hund alles.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Strafe zeigt dem Hund nur, was er nicht soll — Belohnung zeigt ihm, was er soll.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein entspannter Hund ist ein trainierter Hund.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Jede Rasse hat ihre Eigenheiten — der gute Hundetrainer kennt sie alle.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Wenn du willst, dass dein Hund gehorcht, musst du zuerst lernen zu verstehen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Training ist Dialog, nicht Monolog.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein gelangweilter Hund ist ein Problemhund.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Konsequenz beim Training bedeutet: Heute gilt dasselbe wie gestern.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Der beste Hundetrainer ist der, dem der Hund freiwillig folgt.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde lernen durch Wiederholung und Belohnung — nicht durch Zwang.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Körpersprache ist alles im Hundetraining — was du sagst, ist weniger wichtig als wie du es sagst.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund verzeiht Fehler schneller als jeder Mensch.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde halten keine Groll — sie lieben weiter, egal was war.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Treue ist keine Eigenschaft — sie ist eine Entscheidung. Hunde haben sie nie bereut.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund folgt dir in die Dunkelheit, ohne zu zögern.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Die Treue eines Hundes beschämt uns manchmal, weil wir so selten dasselbe bieten.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund, der wartet, zeigt mehr Liebe als Worte ausdrücken können.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Der japanische Hund Hachiko wartete neun Jahre auf seinen Herrn. Frage dich, wie lange du warten würdest.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Kein anderes Wesen auf der Welt ist so treu wie ein Hund.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde wählen ihre Besitzer nicht aus — und lieben sie trotzdem.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund bleibt bei dir, wenn alle anderen gehen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn dein Hund dir seinen Bauch zeigt, schenkt er dir das größte Vertrauen, das ein Tier geben kann.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde lieben ohne zu vergleichen — das können Menschen selten.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Atem deines Hundes im Nacken bedeutet: Du bist nicht allein.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Nichts heilt einen schlechten Tag schneller als ein Hund, der dir entgegenläuft.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde sind das liebste Geheimnis glücklicher Menschen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund macht Liebe sichtbar.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wenn du einen Hund streichelst, streichelt er gleichzeitig deine Seele.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hundeaugen lügen nicht — dort siehst du reine Liebe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Die Liebe eines Hundes ist keine Anhänglichkeit — sie ist Hingabe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund liebt dich an deinen schlechtesten Tagen am meisten.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Mit einem Hund zu schlafen ist die wärmste Art, eine Nacht zu verbringen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wenn Hunde beten könnten, würden sie für ihre Menschen beten.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund braucht nichts außer dir — und das ist sein Reichtum.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Liebe eines Hundes zu verdienen ist die einfachste und gleichzeitig die schönste Sache der Welt.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wer noch nie von einem Hund geliebt wurde, kennt ein ganzes Stück Glück nicht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde wissen, dass Liebe keine Bedingungen braucht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hundewelpen sind die beste Anti-Depressions-Therapie der Welt — leider nicht von der Krankenkasse erstattet.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund hat mir beigebracht, ruhig zu sein. Schade, dass er selbst nie ruhig ist.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Manche sagen: Es ist nur ein Hund. Diese Menschen haben nie einen Hund gehabt.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Der einzige Nachteil eines Hundes: Er lebt zu kurz.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wenn Hunde reden könnten, würden sie uns sagen, was wir schon immer wissen wollten: dass wir gut genug sind.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde schlafen 18 Stunden am Tag und sehen dabei entspannter aus als ich nach 8 Stunden Schlaf.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund hat fünf Betten und schläft trotzdem auf meinen Füßen.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund frisst alles außer dem, was im Napf ist.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund hat mehr Freunde auf Instagram als ich — und postet nie selbst.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde kennen kein Montag-Gefühl.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Kein Hund schaut je auf die Uhr — höchstens zur Futterzeit.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Der Traumjob wäre, bezahlt zu werden, während der Hund auf dem Schoß liegt.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hundebesitzer kennen zwei Arten von Böden: sauber und nach dem Gassi.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund ist offiziell der Einzige, der mich ohne Make-up schön findet.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist der beste Grund, früh aufzustehen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde zeigen uns, wie schön das Einfache ist: Spaziergang, Leckerli, Kuscheln.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Kein Hund hat je Krieg geführt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde verbringen kein Leben damit zu bereuen. Sie leben einfach.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Die Weisheit des Hundes liegt darin, dass er nie zweifelt, ob er genug tut.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund hat keine Vergangenheit, die er bereut, und keine Zukunft, die er fürchtet.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde leben nicht nach Regeln — sie leben nach Instinkten. Das ist manchmal weiser.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "In der Stille eines Hundes liegt mehr Weisheit als in manchem langen Gespräch.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund versteht mehr von Empathie als jeder Therapeut.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde haben das, was wir suchen: Zufriedenheit ohne Grund.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Die wahre Weisheit des Hundes: Er braucht nichts zu beweisen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund sieht in dir keinen Status, kein Einkommen, keine Vergangenheit — nur dich.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde wählen Menschen nicht nach Äußerlichkeiten — das sollten wir auch nicht.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Achte darauf, wie dein Hund Menschen begegnet — er spürt, was du nicht siehst.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn dein Hund dir nicht traut, solltest du in dich gehen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der Hund ist das beste Argument für die Existenz von Güte in der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde spiegeln wider, wer wir wirklich sind.", "autor": "Cesar Millan", "kategorie": "weisheit" }, + { "text": "Wenn ich an die Treulosigkeit der Menschen denke, lobe ich meinen Hund.", "autor": "Friedrich II.", "kategorie": "treue" }, + { "text": "Ein Hund wird nie sagen: Heute habe ich keine Lust, dein Freund zu sein.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde verlassen uns nie — wir verlassen sie.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund hat kein Gedächtnis für Groll — nur für Zuneigung.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn ein Hund dir treu ist, hat er eine Entscheidung für sein Leben getroffen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Treue ist nicht, was ein Hund tut — es ist, wer er ist.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund trägt kein Netz aus Erwartungen — nur das Netz der Zuneigung.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn Menschen so wären wie Hunde — treu, ehrlich, freudig — wäre die Welt ein besserer Ort.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde zeigen dir täglich, dass Loyalität keine Leistung ist, sondern ein Charakter.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde liegen zu Füßen der Helden und der Hilflosen gleichermaßen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Tägliche Bewegung mit dem Hund ist der günstigste Arztbesuch, den du machen kannst.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund hält dich körperlich fit — ob du willst oder nicht.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Zehn Minuten mit einem Hund spielen senkt Stress nachweislich.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hundebesitzer haben im Durchschnitt einen niedrigeren Blutdruck. Kein Wunder.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund motiviert dich, das Sofa zu verlassen — jeden Tag, bei jedem Wetter.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Wer täglich Gassi geht, braucht kein Fitnessstudio.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde halten uns jung — zumindest im Herzen.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund an der Seite hilft gegen Depressionen besser als viele Tabletten.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Streicheln ist gut für den Menschen und noch besser für den Hund.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde sind die natürlichste Form der Stresstherapie.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Mit einem Hund draußen zu sein bedeutet: frische Luft, Bewegung, Freude — dreifacher Gewinn.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein gesunder Hund ist ein aktiver Hund — und ein aktiver Hund braucht einen aktiven Menschen.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde sorgen dafür, dass wir uns bewegen — das ist ihr heimliches Gesundheitsprogramm für uns.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Der Hund kennt das Geheimnis des Glücks: Das Beste ist immer das Jetzt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde fragen nicht, was das Leben bedeutet. Sie leben es einfach.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn du nicht weißt, wohin — folge deinem Hund.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund zeigt dir jeden Tag: Kleine Dinge sind die wichtigsten.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde tanzen nicht, wenn du ihnen von deinen Erfolgen erzählst — sie tanzen, wenn du heimkommst.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Das Gebell eines Hundes ist die ehrlichste Meinung der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund hat keine Agenda außer Liebe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde suchen nicht nach dem Sinn des Lebens — sie finden ihn im Spaziergang.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund macht dich vollständig — auch wenn du das erst merkst, wenn er weg ist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Im Trauerfall ist ein Hund oft der einzige Trost, der nicht nach Worten sucht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde lieben jeden Tag so, als wäre es der schönste ihres Lebens.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Für einen Hund ist Heimkommen das größte Ereignis des Tages — täglich.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund jubelt bei deiner Ankunft mehr als dein Telefon bei einem Like.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde misstrauen keinem ohne Grund. Lerne davon.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Kein Hund hat je einen Krieg begonnen oder ein Tier beleidigt ohne Grund.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein treuer Hund ist ein Spiegel des Guten in der Welt.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Liebe, die nicht urteilt — das ist Hundeliebe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wir verdienen unsere Hunde nicht — wir werden von ihnen gesegnet.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde bringen Licht in die dunkelsten Ecken unserer Tage.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Jeder Hund hat ein Talent: Er macht sein Zuhause glücklicher.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund bereichert das Leben, ohne etwas von dir zu fordern.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Das einzige Problem mit Hunden ist, dass sie zu kurz leben und zu vollständig lieben.", "autor": "Agnes Sligh Turnbull", "kategorie": "liebe" }, + { "text": "Until one has loved an animal, a part of one's soul remains unawakened.", "autor": "Anatole France", "kategorie": "liebe" }, + { "text": "Dogs are how people would be if the important stuff is all that mattered to us.", "autor": "Ashly Lorenzana", "kategorie": "weisheit" }, + { "text": "Ich bin kein Hundenarr. Ich habe einfach einen Hund, der eine eigene Meinung hat, und ich respektiere das.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund weiß nichts von Montagen — er weiß nur, dass du da bist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde erfinden sich jeden Tag neu — mit demselben Wedeln.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund zu sein bedeutet: vollständig glücklich sein mit dem, was ist.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wer einmal die Liebe eines Hundes erlebt hat, zweifelt nie mehr an gutem Leben.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Kein Geld der Welt kann die Freude eines Hundes kaufen — nur verdienen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde machen keine großen Versprechen. Sie halten einfach kleine.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "In meinem Haus regiert der Hund — ich bin nur der Butler.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wenn mein Hund spricht, spreche ich auch. Unsere Unterhaltungen sind die besten.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde täuschen nicht vor, glücklich zu sein. Wenn sie es zeigen, meinen sie es.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund zeigt dir: Dankbarkeit kann man auch ohne Worte ausdrücken.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn der Hund wedelt, ist der Tag gerettet.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde sind Experten darin, das Beste im Menschen zu sehen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Eine Pfote auf dem Schoß ist mehr wert als tausend Worte des Trostes.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wer Hunde nicht mag, hat sie entweder nie richtig kennengelernt oder das Herz zu weit verschlossen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund ist der beste Gesprächspartner: Er hört immer zu und widerspricht nie.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde schnarchen lauter als manche Menschen. Und trotzdem stört es mich nicht.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Welpe ist wie ein Sonnenschein, den du im Haus trägst.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Mit einem alten Hund zu leben ist ein Privileg — er hat viel zu erzählen, wenn man zuhört.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde altern mit Würde. Wir sollten es ihnen gleichtun.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein alter Hund hat mehr gelernt als ein junger — aber er braucht mehr Pausen.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Kein Hundeblick ist so tief wie der eines alten Hundes, der sein Leben hinter sich hat.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde wachsen mit uns auf — und lassen uns nie ganz los.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist von Geburt bis Tod dein treuer Gefährte — das ist der reinste Pakt der Welt.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Den Charakter eines Menschen erkennt man daran, wie er seinen Hund behandelt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wer gut zu Hunden ist, ist gut zu Menschen — das Gegenteil gilt leider auch.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund erzählt dir nicht, was er fühlt — er zeigt es dir.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Respekt und Vertrauen — das sind die Grundlagen jeder Mensch-Hund-Beziehung.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hundeerziehung beginnt am ersten Tag und endet nie — sie wird nur besser.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund der niemals Nein sagt, hat nie gelernt Grenzen zu respektieren.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde lieben Routinen — sie machen das Unbekannte beherrschbar.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund, dem man Vertrauen schenkt, enttäuscht selten.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde brauchen keine langen Befehle — ein Wort und ein klares Signal genügen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein glücklicher Hund hat genug Bewegung, genug Aufgaben und genug Liebe.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Wer mit seinem Hund Tricks übt, übt gleichzeitig Geduld und Ausdauer.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ich sage nicht, dass mein Hund klüger ist als du — aber er hat das schon gedacht.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wenn mein Hund Telefonkonferenzen hätte, würden sie pünktlich enden.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist der einzige, der glücklich ist, wenn du wieder von der Toilette kommst.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ich bin nicht süchtig nach meinem Hund. Ich brauche ihn nur täglich.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund lässt mich nie ausreden — das mag ich an ihm.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Sie sagten, ein Hund sei viel Arbeit. Sie hatten recht. Die Freude ist noch mehr.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde machen das Leben besser — das ist keine Meinung, das ist Tatsache.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Kein Selfie gelingt so gut wie das mit dem Hund.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer einen Hund hat, braucht keinen Wecker — und kein Sofa für sich allein.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde sind die perfekten Mitbewohner: still, wenn gewünscht, laut, wenn nötig.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist das einzige Wesen, das dich liebt, obwohl es weiß, dass du seine Spielzeuge wegschmeißt.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde riechen die Lüge — Menschen riechen Parfum.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund braucht dich nicht zu verstehen — er muss dich nur fühlen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde wissen, wann du weinst. Und sie kommen trotzdem — oder gerade dann.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund ist das beste Argument gegen Einsamkeit.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wer Hunde versteht, versteht ein Stück mehr von der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde haben keine Maske. Das ist ihr größtes Geschenk an uns.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund fragt nicht nach deiner Geschichte — er schreibt mit dir die nächsten Seiten.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde machen keine Witze auf Kosten anderer. Lehre von ihnen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund zu haben bedeutet: täglich Dankbarkeit erfahren.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Niemand begrüßt dich aufrichtiger als dein Hund.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund gibt dir immer eine zweite Chance — und eine dritte und vierte.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde machen Fehler wie alle — aber sie entschuldigen sich täglich mit ihrem Blick.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund mit gebrochenem Bein hat mehr Stolz als mancher Mensch.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der mutigste Beschützer wiegt manchmal nur fünf Kilo.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde fürchten sich manchmal — aber sie überwinden es für dich.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund kämpft nie für Macht — nur für seine Menschen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde haben keine Karriere, kein Konto, keine Ambitionen — und sind glücklicher als die meisten.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Das Geheimnis eines langen Lebens: jeden Tag Gassi gehen.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Frische Luft, Bewegung, ein Hund an der Seite — besser geht Therapie nicht.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Wer regelmäßig mit dem Hund läuft, braucht keinen Motivationscoach.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde halten uns davon ab, den ganzen Tag zu sitzen — das ist ihre heimliche Mission.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hundekiss mag nicht hygienisch sein — aber er heilt trotzdem.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer mit Hunden lebt, hat ein wärmeres Zuhause — buchstäblich.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde zeigen uns täglich: Das Schönste am Tag ist der Anfang und das Ende — weil du da bist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund braucht weder Smartphone noch Social Media — er lebt das echte Leben.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn ich meinen Hund anschaue, verstehe ich, was Unschuld bedeutet.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund sieht in dir nie das Schlechteste — nur das Beste.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde sind die ehrlichsten Kritiker: Wenn sie fressen, schmeckt es.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund gibt mir täglich einen Grund zu lächeln — ohne es zu wissen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Alles, was du brauchst, ist Liebe — und ein Hund, der das auch so sieht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund macht das Schweigen gemeinsam — das ist echter Frieden.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde haben das perfekte Leben: schlafen, fressen, spielen, lieben. Kein Stress.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer einmal einem Hund beim Schlafen zugesehen hat, weiß, was echte Entspannung ist.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund zu retten bedeutet, zwei Leben zu retten — seins und deins.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Kaufe nicht, wenn du adoptieren kannst — du gewinnst mehr, als du weißt.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Tierheim-Hund weiß, was Verlassenheit ist — und liebt dich trotzdem, sobald du da bist.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Jeder Hund verdient ein Zuhause. Jeder Mensch verdient einen Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Die beste Zeit, einen Hund zu adoptieren, war gestern. Die zweitbeste ist heute.", "autor": "Unbekannt", "kategorie": "allgemein" } +] diff --git a/scripts/import_quotes.py b/scripts/import_quotes.py new file mode 100644 index 0000000..91c38bc --- /dev/null +++ b/scripts/import_quotes.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Importiert dog_quotes.json in die daily_quotes-Tabelle.""" +import json, sqlite3, sys +from pathlib import Path + +DB_PATH = Path(__file__).parent.parent / 'backend' / 'banyaro.db' +JSON_PATH = Path(__file__).parent / 'dog_quotes.json' + +quotes = json.loads(JSON_PATH.read_text()) +conn = sqlite3.connect(DB_PATH) + +existing = conn.execute("SELECT COUNT(*) FROM daily_quotes").fetchone()[0] +if existing > 0: + print(f"{existing} Einträge bereits vorhanden — überspringe Import.") + print("Zum Neuimport: DELETE FROM daily_quotes; zuerst ausführen.") + sys.exit(0) + +conn.executemany( + "INSERT INTO daily_quotes (text, autor, kategorie) VALUES (?, ?, ?)", + [(q['text'], q.get('autor'), q.get('kategorie')) for q in quotes] +) +conn.commit() +print(f"{len(quotes)} Tagessprüche importiert.") +conn.close()