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..1d23aef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,9 +6,10 @@ import os import html import logging from collections import deque +import httpx from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware from brotli_asgi import BrotliMiddleware @@ -43,10 +44,43 @@ logger = logging.getLogger(__name__) # ------------------------------------------------------------------ # Startup / Shutdown # ------------------------------------------------------------------ +def _backfill_image_sizes(): + """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach.""" + import io + from database import db + from media_utils import get_image_size + MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + with db() as conn: + rows = conn.execute( + "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL" + ).fetchall() + if not rows: + return + logger.info("Backfill Bildmaße: %d Einträge...", len(rows)) + updated = 0 + for row in rows: + # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg + rel = row["url"].removeprefix("/media/") + path = os.path.join(MEDIA_DIR, rel) + try: + with open(path, "rb") as f: + data = f.read() + size = get_image_size(data) + if size: + with db() as conn: + conn.execute( + "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?", + (size[0], size[1], row["id"]) + ) + updated += 1 + except Exception: + pass + logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows)) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Ban Yaro startet...") init_db() + _backfill_image_sizes() from routes.movies import seed_movies seed_movies() logger.info(f"KI-Modus: {ki.KI_MODE}") @@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " @@ -198,6 +232,7 @@ from routes.adoption import router as adoption_router from routes.health_docs import router as health_docs_router from routes.passport import router as passport_router from routes.playdate import router as playdate_router +from routes.ernaehrung import router as ernaehrung_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) +app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"]) # ------------------------------------------------------------------ @@ -285,6 +321,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") +@app.get("/stats/script.js") +async def umami_script_proxy(): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get("https://umami.motocamp.de/script.js") + return Response(content=r.content, media_type="application/javascript", + headers={"Cache-Control": "public, max-age=86400"}) + +@app.post("/stats/api/send") +async def umami_send_proxy(request: Request): + body = await request.body() + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + "https://umami.motocamp.de/api/send", + content=body, + headers={"Content-Type": "application/json", + "User-Agent": request.headers.get("user-agent", "")}, + ) + return Response(content=r.content, status_code=r.status_code, + media_type="application/json") + + @app.get("/robots.txt") async def robots(): return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain") diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index 1c0c1e8..0a2988e 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,48 @@ 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 + 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 + ) + """, (user_id,)).fetchone() + + # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen + 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) + """, (user_id, user_id, user_id, user_id)).fetchone() + + # Schnee: Diary-Einträge bei Schnee (weathercode 71-77) + 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 + """, (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 +300,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 +351,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/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/osm.py b/backend/routes/osm.py index e08742b..de4cb45 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -279,8 +279,9 @@ class UserPoiIn(BaseModel): ALLOWED_TYPES = { 'waste_basket', 'drinking_water', 'dog_park', - 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius) + 'giftkoeder', # Giftköder (exklusiv, kein Kombi) 'kotbeutel', # Kotbeutelspender + 'bank', # Sitzbank 'gefahr', # Allgemeine Gefahr / Hinweis 'parkplatz', # Hundefreundlicher Parkplatz 'treffpunkt', # Treffpunkt für Hundehalter @@ -289,7 +290,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..ba45306 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,57 @@ 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 + WHERE d.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..4af2473 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 +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.""" 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..4d1dbff 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -156,8 +156,32 @@ def start(): 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. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -881,6 +905,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 +1315,329 @@ 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 _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..c8de6a6 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; @@ -7600,10 +7824,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 */ diff --git a/backend/static/index.html b/backend/static/index.html index cb75a8f..77f0433 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 @@ - + - + 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..deb05c5 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 = '700'; // ← 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 }, }; // ---------------------------------------------------------- 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..09b729a 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -195,9 +195,27 @@ window.Page_dog_profile = (() => { Hundepass ` : ''} + ${!dog.is_guest ? `` : ''} ${!dog.is_guest ? `` : ''} + ${!dog.is_guest ? `` : ''} + ${!dog.is_guest ? `` : ''} + ${!dog.is_guest ? `` : ''}
@@ -264,6 +282,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 +784,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 +1136,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/map.js b/backend/static/js/pages/map.js index ded9a7d..fd15e1d 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -838,15 +838,17 @@ 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: '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: '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' }, ]; function _confirmPlacement(latlng) { @@ -855,18 +857,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 +894,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 +919,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/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..b829dea 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(); } @@ -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/wetter.js b/backend/static/js/pages/wetter.js index c838916..755b825 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(); } @@ -187,9 +189,18 @@ window.Page_wetter = (() => { style="margin-bottom:var(--space-4)"> + +
+
+
+ + +
+
`; // Strip-Klick-Events @@ -198,12 +209,15 @@ window.Page_wetter = (() => { _selDay = parseInt(card.dataset.wttrDay); _updateStrip(); _renderDetail(); + _renderRainTimeline(); _renderDog(); }); }); _renderDetail(); + _renderRainTimeline(); _renderDog(); + _loadRecords(); } // ---------------------------------------------------------- @@ -318,6 +332,9 @@ window.Page_wetter = (() => { + + ${_gassiScoreBadge(d)} + ${sunriseStr && sunsetStr ? `
@@ -380,6 +397,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 +538,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 +661,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 +713,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 +911,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 +979,104 @@ 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..daacde0 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -49,9 +49,6 @@ window.Worlds = (() => { _setupButtons(); _goTo(_cur, false); show(); - // Welten parallel rendern - _renderJetzt(); - _renderHund(); } function show(worldIdx) { @@ -66,12 +63,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 +127,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,18 +165,28 @@ 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.querySelectorAll('.wdot').forEach((dot, i) => { dot.style.pointerEvents = 'auto'; @@ -179,21 +206,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 +222,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:'Trainings-\nplä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:'Zucht-\nkartei', 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' }, ]; const _DEFAULT_CONFIG = { @@ -296,12 +460,40 @@ window.Worlds = (() => { welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'], }; - 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; @@ -574,18 +766,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; } } @@ -625,10 +812,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 +825,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); @@ -712,7 +901,7 @@ window.Worlds = (() => {
${_esc(greet)}${firstName ? `, ${_esc(firstName)}` : ''}${stale}
-
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}
+
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}
${user ? userAvatarHtml : ''} diff --git a/backend/static/sw.js b/backend/static/sw.js index e786916..883e797 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v651'; +const CACHE_VERSION = 'by-v700'; 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 @@ -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()