diff --git a/backend/auth.py b/backend/auth.py
index 55c63fc..a4d5af3 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
- "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
+ "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,)
).fetchone()
diff --git a/backend/database.py b/backend/database.py
index eeb1add..5e38a96 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -540,6 +540,9 @@ def _migrate(conn_factory):
("pflege_tipps", "fell_pflege_art", "TEXT"),
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
+ # Tagebuch-Medien: Bildmaße für Querformat-Filter
+ ("diary_media", "img_width", "INTEGER"),
+ ("diary_media", "img_height", "INTEGER"),
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
("diary", "weather_json", "TEXT"),
("diary", "poi_json", "TEXT"),
@@ -568,6 +571,11 @@ def _migrate(conn_factory):
# Passwort-Zurücksetzen
("users", "password_reset_token", "TEXT"),
("users", "password_reset_expires", "TEXT"),
+ # Fell-Typ für personalisierte Wetter-Hinweise
+ ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt
+ # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz
+ ("tieraerzte", "avg_rating", "REAL DEFAULT 0"),
+ ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@@ -1923,6 +1931,44 @@ def _migrate(conn_factory):
)
""")
+ # Welten-Chip-Konfiguration pro User
+ existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
+ if 'world_config' not in existing_u:
+ conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT")
+
+ # Tagessprüche-Pool
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS daily_quotes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ text TEXT NOT NULL,
+ autor TEXT,
+ kategorie TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie);
+ """)
+
+ # Goldene Gassi-Stunde: User-Einstellung
+ existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
+ if 'gassi_stunde_push' not in existing_u:
+ conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0")
+ logger.info("Migration: users.gassi_stunde_push bereit.")
+
+ # Futter-Profil
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS futter_profil (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE,
+ futter_typ TEXT,
+ marke TEXT,
+ kcal_tag INTEGER,
+ portionen INTEGER DEFAULT 2,
+ notizen TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ """)
+ logger.info("Migration: futter_profil bereit.")
+
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
@@ -1940,3 +1986,75 @@ def _migrate(conn_factory):
);
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
""")
+
+ # ---- Tierarzt-Bewertungen ----
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS tierarzt_bewertungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5),
+ wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5),
+ freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5),
+ kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5),
+ text TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(tierarzt_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt
+ ON tierarzt_bewertungen(tierarzt_id);
+ """)
+
+ # ---- Feature: Foto-Challenge der Woche ----
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS foto_challenge (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ thema TEXT NOT NULL,
+ beschreibung TEXT,
+ start_date TEXT NOT NULL,
+ end_date TEXT NOT NULL,
+ created_by INTEGER REFERENCES users(id),
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+ CREATE TABLE IF NOT EXISTS challenge_submissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ foto_url TEXT NOT NULL,
+ caption TEXT,
+ votes INTEGER DEFAULT 0,
+ created_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(challenge_id, user_id)
+ );
+ CREATE TABLE IF NOT EXISTS challenge_votes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(submission_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal
+ ON challenge_submissions(challenge_id, created_at DESC);
+ """)
+ logger.info("Migration: Foto-Challenge-Tabellen bereit.")
+
+ # ---- Feature: Gassi-Zeiten-Pool ----
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS gassi_zeiten (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ wochentage TEXT NOT NULL,
+ uhrzeit TEXT NOT NULL,
+ ort_name TEXT,
+ lat REAL,
+ lon REAL,
+ radius_m INTEGER DEFAULT 500,
+ notiz TEXT,
+ aktiv INTEGER DEFAULT 1,
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user
+ ON gassi_zeiten(user_id, aktiv);
+ """)
+ logger.info("Migration: Gassi-Zeiten-Tabelle bereit.")
diff --git a/backend/main.py b/backend/main.py
index 229a856..a883e9b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,9 +6,10 @@ import os
import html
import logging
from collections import deque
+import httpx
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
@@ -43,10 +44,43 @@ logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Startup / Shutdown
# ------------------------------------------------------------------
+def _backfill_image_sizes():
+ """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
+ import io
+ from database import db
+ from media_utils import get_image_size
+ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+ with db() as conn:
+ rows = conn.execute(
+ "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
+ ).fetchall()
+ if not rows:
+ return
+ logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
+ updated = 0
+ for row in rows:
+ # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
+ rel = row["url"].removeprefix("/media/")
+ path = os.path.join(MEDIA_DIR, rel)
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ size = get_image_size(data)
+ if size:
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
+ (size[0], size[1], row["id"])
+ )
+ updated += 1
+ except Exception:
+ pass
+ logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
+ _backfill_image_sizes()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
@@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
@@ -198,6 +232,9 @@ from routes.adoption import router as adoption_router
from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
+from routes.ernaehrung import router as ernaehrung_router
+from routes.challenges import router as challenges_router
+from routes.gassi_zeiten import router as gassi_zeiten_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -256,6 +293,9 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
+app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
+app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
+app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
# ------------------------------------------------------------------
@@ -285,6 +325,39 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
+APP_VER = "715" # muss mit APP_VER in app.js übereinstimmen
+
+@app.get("/api/version")
+async def app_version():
+ """Aktuelle Frontend-Version — wird beim App-Start gecheckt."""
+ return Response(
+ content=f'{{"version":"{APP_VER}"}}',
+ media_type="application/json",
+ headers={"Cache-Control": "no-store"},
+ )
+
+
+@app.get("/stats/script.js")
+async def umami_script_proxy():
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.get("https://umami.motocamp.de/script.js")
+ return Response(content=r.content, media_type="application/javascript",
+ headers={"Cache-Control": "public, max-age=86400"})
+
+@app.post("/stats/api/send")
+async def umami_send_proxy(request: Request):
+ body = await request.body()
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.post(
+ "https://umami.motocamp.de/api/send",
+ content=body,
+ headers={"Content-Type": "application/json",
+ "User-Agent": request.headers.get("user-agent", "")},
+ )
+ return Response(content=r.content, status_code=r.status_code,
+ media_type="application/json")
+
+
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
diff --git a/backend/media_utils.py b/backend/media_utils.py
index 8a8698f..4cb2e28 100644
--- a/backend/media_utils.py
+++ b/backend/media_utils.py
@@ -178,6 +178,17 @@ def generate_preview(data: bytes, ext: str) -> bytes | None:
return None
+def get_image_size(data: bytes) -> tuple[int, int] | None:
+ """Gibt (width, height) eines Bildes zurück, oder None bei Fehler."""
+ try:
+ from PIL import Image, ImageOps
+ img = Image.open(io.BytesIO(data))
+ img = ImageOps.exif_transpose(img)
+ return img.size # (width, height)
+ except Exception:
+ return None
+
+
def preview_url_from(url: str | None) -> str | None:
"""Leitet die Preview-URL aus der Original-URL ab (fügt _preview vor Extension ein).
Gibt None für Videos, PDFs, bereits generierte Previews und leere URLs zurück."""
diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py
index 1c0c1e8..e8d0cba 100644
--- a/backend/routes/achievements.py
+++ b/backend/routes/achievements.py
@@ -92,6 +92,59 @@ CATEGORIES = [
("gold", 10, "Wiki-Fotograf"),
],
},
+ {
+ "id": "wetter_tapfer",
+ "name": "Wetter-Tapferkeit",
+ "emoji": "⛈️",
+ "metrik": "wetter_tapfer_score",
+ "einheit": " Eintrag/Einträge",
+ "stufen": [
+ ("bronze", 1, "Regentrotzdem"),
+ ("silber", 5, "Wettertrotzer"),
+ ("gold", 15, "Allwetter-Held"),
+ ("platin", 30, "Hunde-Wetterheld"),
+ ],
+ },
+ {
+ "id": "jahreszeiten",
+ "name": "Jahreszeiten-Erkunder",
+ "emoji": "🍃",
+ "metrik": "jahreszeiten_score",
+ "einheit": " Jahreszeit(en)",
+ "stufen": [
+ ("bronze", 1, "Frühlings-Erkunder"),
+ ("silber", 2, "Sommer-Genießer"),
+ ("gold", 3, "Herbst-Schnüffler"),
+ ("platin", 4, "Alle-Jahreszeiten"),
+ ],
+ },
+ {
+ "id": "schnee_held",
+ "name": "Schneeheld",
+ "emoji": "❄️",
+ "metrik": "schnee_eintraege",
+ "einheit": " Eintrag/Einträge",
+ "stufen": [
+ ("bronze", 1, "Erster Schnee"),
+ ("silber", 5, "Schneehund"),
+ ("gold", 15, "Schneeheld"),
+ ("platin", 30, "Schneewolf"),
+ ],
+ },
+ {
+ "id": "km_lebenswerk",
+ "name": "Kilometer-Lebenswerk",
+ "emoji": "🐾",
+ "metrik": "gesamt_km_lebenswerk",
+ "einheit": " km",
+ "icon": "path",
+ "stufen": [
+ ("bronze", 100, "100-km-Club"),
+ ("silber", 500, "500-km-Wanderer"),
+ ("gold", 1000, "Tausend-km-Held"),
+ ("platin", 5000, "Ultraläufer"),
+ ],
+ },
]
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
@@ -150,12 +203,53 @@ def check_and_award(user_id: int, conn):
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
+ # Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter (über Dog-Join)
+ wetter_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary d
+ JOIN dogs dog ON dog.id = d.dog_id
+ WHERE dog.user_id = ?
+ AND d.weather_json IS NOT NULL
+ AND (
+ CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
+ OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
+ OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
+ )
+ """, (user_id,)).fetchone()
+
+ # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (über Dog-Join)
+ jahreszeiten_row = conn.execute("""
+ SELECT
+ (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
+ WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
+ WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
+ WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
+ WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
+ AS jahreszeiten_score
+ FROM (SELECT 1)
+ """, (user_id, user_id, user_id, user_id)).fetchone()
+
+ # Schnee: Diary-Einträge bei Schnee (weathercode 71-77, über Dog-Join)
+ schnee_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary d
+ JOIN dogs dog ON dog.id = d.dog_id
+ WHERE dog.user_id = ?
+ AND d.weather_json IS NOT NULL
+ AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
+ """, (user_id,)).fetchone()
+
metrics = {
- "total_km": stats["total_km"] if stats else 0,
- "routen": stats["routen"] if stats else 0,
- "pois": stats["pois"] if stats else 0,
- "streak": (streak_row["current_streak"] if streak_row else 0),
- "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "total_km": stats["total_km"] if stats else 0,
+ "routen": stats["routen"] if stats else 0,
+ "pois": stats["pois"] if stats else 0,
+ "streak": (streak_row["current_streak"] if streak_row else 0),
+ "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
+ "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
+ "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
+ "gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
}
earned = {r["badge_id"] for r in
@@ -211,6 +305,38 @@ async def my_achievements(user=Depends(get_current_user)):
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
).fetchone()
+ # Wetter-Tapferkeit
+ wetter_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.user_id = ?
+ AND d.weather_json IS NOT NULL
+ AND (
+ CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
+ OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
+ OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
+ )
+ """, (uid,)).fetchone()
+
+ # Jahreszeiten
+ jahreszeiten_row = conn.execute("""
+ SELECT
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
+ AS jahreszeiten_score
+ FROM (SELECT 1)
+ """, (uid, uid, uid, uid)).fetchone()
+
+ # Schnee-Einträge
+ schnee_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary
+ WHERE user_id = ?
+ AND weather_json IS NOT NULL
+ AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
+ """, (uid,)).fetchone()
+
earned_rows = conn.execute(
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
).fetchall()
@@ -230,11 +356,15 @@ async def my_achievements(user=Depends(get_current_user)):
""", (stats["punkte"] if stats else 0,)).fetchone()
metrics = {
- "total_km": stats["total_km"] if stats else 0,
- "routen": stats["routen"] if stats else 0,
- "pois": stats["pois"] if stats else 0,
- "streak": (streak_row["current_streak"] if streak_row else 0),
- "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "total_km": stats["total_km"] if stats else 0,
+ "routen": stats["routen"] if stats else 0,
+ "pois": stats["pois"] if stats else 0,
+ "streak": (streak_row["current_streak"] if streak_row else 0),
+ "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
+ "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
+ "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
+ "gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
}
# Kategorien mit aktuellem Tier + Fortschritt aufbauen
diff --git a/backend/routes/challenges.py b/backend/routes/challenges.py
new file mode 100644
index 0000000..f3e3f0d
--- /dev/null
+++ b/backend/routes/challenges.py
@@ -0,0 +1,304 @@
+"""BAN YARO — Foto-Challenge der Woche"""
+
+import os
+import uuid
+import logging
+from datetime import date, timedelta
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user, get_current_user_optional
+from media_utils import convert_media, generate_preview
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges")
+
+_CHALLENGE_THEMEN = [
+ "Bestes Schnüffel-Foto 👃",
+ "Action-Aufnahme 🏃",
+ "Schlafendes Tier 😴",
+ "Gassi im Regen 🌧️",
+ "Hundeblick in die Kamera 👀",
+ "Spielzeit mit Freunden 🐕",
+ "Herbstspaziergang 🍂",
+ "Beste Sprung-Aufnahme 🦘",
+ "Hund am Wasser 🌊",
+ "Erstes Mal im Schnee ❄️",
+ "Genuss-Moment 🦴",
+ "Versteckt im Gebüsch 🌿",
+ "Tierisches Selfie 🤳",
+ "Hund & Kind 👶",
+ "Hund & Katze zusammen 🐱",
+ "Der beste Buddel-Moment 🐾",
+ "Freude beim Apportieren 🎾",
+ "Hund in seiner Lieblingshöhle 🛋️",
+ "Sonnenuntergangs-Gassi 🌅",
+ "Hundebegegnung auf dem Spaziergang 🐕🐕",
+ "Ausdrucksstarker Hundeblick 😍",
+ "Hund im Herbstlaub 🍁",
+ "Welpenfoto 🍼",
+ "Seniorenhund im Porträt 👴",
+ "Lustigste Schlafposition 💤",
+ "Hund trägt etwas 🎀",
+ "Hund + Besitzer Spiegelfoto 🪞",
+ "Hund auf Abenteuer 🏕️",
+ "Beste Lauf-Action 💨",
+ "Hund im Café ☕",
+]
+
+
+def _current_week_monday() -> str:
+ today = date.today()
+ monday = today - timedelta(days=today.weekday())
+ return monday.isoformat()
+
+
+def _current_week_sunday() -> str:
+ monday = date.fromisoformat(_current_week_monday())
+ return (monday + timedelta(days=6)).isoformat()
+
+
+def _ensure_current_challenge(conn) -> int:
+ """Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück."""
+ monday = _current_week_monday()
+ sunday = _current_week_sunday()
+
+ existing = conn.execute(
+ "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
+ ).fetchone()
+ if existing:
+ return existing["id"]
+
+ # Thema aus Rotation wählen (Wochennummer % Anzahl Themen)
+ week_num = date.today().isocalendar()[1]
+ thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
+
+ cur = conn.execute(
+ "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
+ "VALUES (?, ?, ?, ?, NULL)",
+ (thema, f"Diese Woche: {thema}", monday, sunday)
+ )
+ return cur.lastrowid
+
+
+# ------------------------------------------------------------------
+# GET /api/challenges/current
+# ------------------------------------------------------------------
+@router.get("/current")
+async def get_current_challenge(user=Depends(get_current_user_optional)):
+ with db() as conn:
+ challenge_id = _ensure_current_challenge(conn)
+ challenge = conn.execute(
+ "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
+ ).fetchone()
+
+ submissions = conn.execute("""
+ SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at,
+ u.name AS user_name, u.avatar_url,
+ d.name AS dog_name, d.foto_url AS dog_foto_url
+ FROM challenge_submissions cs
+ LEFT JOIN users u ON u.id = cs.user_id
+ LEFT JOIN dogs d ON d.id = cs.dog_id
+ WHERE cs.challenge_id = ?
+ ORDER BY cs.votes DESC, cs.created_at ASC
+ """, (challenge_id,)).fetchall()
+
+ my_submission = None
+ my_votes = set()
+ if user:
+ mine = conn.execute(
+ "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
+ (challenge_id, user["id"])
+ ).fetchone()
+ if mine:
+ my_submission = mine["id"]
+ voted_rows = conn.execute(
+ "SELECT cv.submission_id FROM challenge_votes cv "
+ "JOIN challenge_submissions cs ON cs.id = cv.submission_id "
+ "WHERE cv.user_id = ? AND cs.challenge_id = ?",
+ (user["id"], challenge_id)
+ ).fetchall()
+ my_votes = {r["submission_id"] for r in voted_rows}
+
+ # Countdown bis Sonntag
+ end = date.fromisoformat(challenge["end_date"])
+ days_left = (end - date.today()).days + 1
+
+ result_subs = []
+ for s in submissions:
+ sd = dict(s)
+ sd["i_voted"] = (sd["id"] in my_votes) if user else False
+ result_subs.append(sd)
+
+ return {
+ "challenge": dict(challenge),
+ "submissions": result_subs,
+ "my_submission_id": my_submission,
+ "days_left": max(0, days_left),
+ }
+
+
+# ------------------------------------------------------------------
+# POST /api/challenges/{id}/submit
+# ------------------------------------------------------------------
+@router.post("/{challenge_id}/submit", status_code=201)
+async def submit_photo(
+ challenge_id: int,
+ caption: Optional[str] = Form(None),
+ dog_id: Optional[int] = Form(None),
+ foto: UploadFile = File(...),
+ user=Depends(get_current_user),
+):
+ with db() as conn:
+ challenge = conn.execute(
+ "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
+ ).fetchone()
+ if not challenge:
+ raise HTTPException(404, "Challenge nicht gefunden.")
+
+ today = date.today().isoformat()
+ if today > challenge["end_date"]:
+ raise HTTPException(400, "Die Challenge ist bereits beendet.")
+
+ # Doppelt-Check
+ existing = conn.execute(
+ "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
+ (challenge_id, user["id"])
+ ).fetchone()
+ if existing:
+ raise HTTPException(409, "Du hast bereits ein Foto eingereicht.")
+
+ # Foto speichern
+ os.makedirs(CHALLENGE_DIR, exist_ok=True)
+ orig_filename = foto.filename or "foto.jpg"
+ ext = os.path.splitext(orig_filename)[1] or ".jpg"
+ base = uuid.uuid4().hex
+
+ raw = await foto.read()
+
+ # HEIC→JPEG Konvertierung falls nötig
+ try:
+ converted, out_ext = convert_media(raw, orig_filename)
+ except Exception:
+ converted, out_ext = raw, ext
+
+ save_filename = f"{base}{out_ext}"
+ save_path = os.path.join(CHALLENGE_DIR, save_filename)
+ with open(save_path, "wb") as f:
+ f.write(converted)
+ foto_url = f"/media/challenges/{save_filename}"
+
+ # Preview
+ try:
+ preview = generate_preview(converted, out_ext)
+ if preview:
+ prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp")
+ with open(prev_path, "wb") as f:
+ f.write(preview)
+ except Exception:
+ pass
+
+ with db() as conn:
+ cur = conn.execute(
+ "INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (challenge_id, user["id"], dog_id, foto_url, caption)
+ )
+ row = conn.execute("""
+ SELECT cs.*, u.name AS user_name, d.name AS dog_name
+ FROM challenge_submissions cs
+ LEFT JOIN users u ON u.id = cs.user_id
+ LEFT JOIN dogs d ON d.id = cs.dog_id
+ WHERE cs.id = ?
+ """, (cur.lastrowid,)).fetchone()
+
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# POST /api/challenges/submissions/{id}/vote — Toggle-Vote
+# ------------------------------------------------------------------
+@router.post("/submissions/{submission_id}/vote")
+async def vote_submission(submission_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ sub = conn.execute(
+ "SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,)
+ ).fetchone()
+ if not sub:
+ raise HTTPException(404, "Einreichung nicht gefunden.")
+ if sub["user_id"] == user["id"]:
+ raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.")
+
+ existing = conn.execute(
+ "SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?",
+ (submission_id, user["id"])
+ ).fetchone()
+
+ if existing:
+ # Toggle: Vote entfernen
+ conn.execute(
+ "DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?",
+ (submission_id, user["id"])
+ )
+ conn.execute(
+ "UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?",
+ (submission_id,)
+ )
+ voted = False
+ else:
+ conn.execute(
+ "INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)",
+ (submission_id, user["id"])
+ )
+ conn.execute(
+ "UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?",
+ (submission_id,)
+ )
+ voted = True
+
+ votes = conn.execute(
+ "SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,)
+ ).fetchone()["votes"]
+
+ return {"voted": voted, "votes": votes}
+
+
+# ------------------------------------------------------------------
+# GET /api/challenges/winners — letzte 4 Gewinner
+# ------------------------------------------------------------------
+@router.get("/winners")
+async def get_winners():
+ with db() as conn:
+ # Vergangene Challenges (ohne aktuelle Woche)
+ monday = _current_week_monday()
+ challenges = conn.execute(
+ "SELECT id, thema, start_date, end_date FROM foto_challenge "
+ "WHERE end_date < ? ORDER BY end_date DESC LIMIT 4",
+ (monday,)
+ ).fetchall()
+
+ winners = []
+ for ch in challenges:
+ winner = conn.execute("""
+ SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes,
+ u.name AS user_name, u.avatar_url,
+ d.name AS dog_name
+ FROM challenge_submissions cs
+ LEFT JOIN users u ON u.id = cs.user_id
+ LEFT JOIN dogs d ON d.id = cs.dog_id
+ WHERE cs.challenge_id = ?
+ ORDER BY cs.votes DESC, cs.created_at ASC
+ LIMIT 1
+ """, (ch["id"],)).fetchone()
+
+ winners.append({
+ "challenge": dict(ch),
+ "winner": dict(winner) if winner else None,
+ })
+
+ return winners
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index a3dee2b..6f6cd12 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -9,7 +9,7 @@ from auth import get_current_user, require_admin
import ki as KI
import httpx
import weather as weather_mod
-from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from
+from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
from timeutils import safe_client_time
logger = logging.getLogger(__name__)
@@ -30,6 +30,7 @@ class DiaryCreate(BaseModel):
location_name: Optional[str] = None
is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
+ weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
class DiaryUpdate(BaseModel):
@@ -350,6 +351,19 @@ async def create_diary(dog_id: int, data: DiaryCreate,
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ elif data.weather_json:
+ # Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern
+ try:
+ json.loads(data.weather_json) # Validierung
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary SET weather_json=? WHERE id=?",
+ (data.weather_json, entry_id)
+ )
+ entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ except Exception as exc:
+ logger.warning("Client-weather_json ungültig: %s", exc)
+
return _entry_dict(entry, dogs_map, media_map)
@@ -692,10 +706,12 @@ async def upload_media(dog_id: int, entry_id: int,
media_url = f"/media/diary/{filename}"
- # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
- exif_gps = None
+ # Bildmaße + EXIF-GPS (nur bei Bilddateien)
+ exif_gps = None
+ img_size = None
if media_type == "image":
exif_gps = extract_gps_from_exif(raw_data)
+ img_size = get_image_size(raw_data)
with db() as conn:
# sort_order = nächste freie Position
@@ -706,8 +722,9 @@ async def upload_media(dog_id: int, entry_id: int,
# Erstes Item eines Eintrags wird automatisch Cover
is_cover = 1 if max_order == -1 else 0
conn.execute(
- "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
- (entry_id, media_url, media_type, max_order + 1, is_cover)
+ "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)",
+ (entry_id, media_url, media_type, max_order + 1, is_cover,
+ img_size[0] if img_size else None, img_size[1] if img_size else None)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index a44faa0..42b9b32 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Hund nicht gefunden.")
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
+ # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
photos = conn.execute(
"""SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id
WHERE d.dog_id=? AND dm.media_type='image'
- ORDER BY d.datum DESC LIMIT 100""",
+ AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
+ ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
+ # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
+ if not photos:
+ photos = conn.execute(
+ """SELECT dm.url FROM diary_media dm
+ JOIN diary d ON d.id = dm.diary_id
+ WHERE d.dog_id=? AND dm.media_type='image'
+ ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
+ (dog_id,)
+ ).fetchall()
random_photo = None
if photos:
import datetime as _dt2
- day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
- chosen_url = photos[day_num % len(photos)]["url"]
+ tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
+ chosen_url = photos[tick % len(photos)]["url"]
random_photo = {
"url": chosen_url,
"preview_url": preview_url_from(chosen_url),
@@ -294,6 +305,463 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
}
+@router.get("/{dog_id}/wrapped")
+async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)):
+ """Jahresrückblick ('Wrapped') für einen Hund."""
+ import json as _json
+ from datetime import date as _date
+
+ if year is None:
+ year = _date.today().year
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ # km gelaufen (eigene Routen des Users)
+ gesamt_km_row = conn.execute(
+ "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
+ "WHERE user_id=? AND strftime('%Y', created_at)=?",
+ (user["id"], str(year))
+ ).fetchone()
+ gesamt_km = gesamt_km_row["km"] or 0.0
+
+ # Gassi-Tage (Distinct Datum in Diary)
+ gassi_tage = conn.execute(
+ "SELECT COUNT(DISTINCT datum) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Gesamte Einträge
+ eintraege_gesamt = conn.execute(
+ "SELECT COUNT(*) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Fotos gesamt
+ fotos_gesamt = conn.execute(
+ "SELECT COUNT(*) AS n FROM diary_media dm "
+ "JOIN diary d ON d.id=dm.diary_id "
+ "WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Beste Route (längste distanz)
+ beste_route_row = conn.execute(
+ "SELECT MAX(distanz_km) AS km FROM routes "
+ "WHERE user_id=? AND strftime('%Y', created_at)=?",
+ (user["id"], str(year))
+ ).fetchone()
+ beste_route = beste_route_row["km"] or 0.0
+
+ # Lieblingsmonat (meiste diary-Einträge)
+ monat_rows = conn.execute(
+ "SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=? "
+ "GROUP BY monat ORDER BY n DESC LIMIT 1",
+ (dog_id, str(year))
+ ).fetchone()
+ lieblings_monat = None
+ if monat_rows:
+ _MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
+ try:
+ lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1]
+ except Exception:
+ pass
+
+ # Lieblingsaktivität (häufigster typ)
+ typ_row = conn.execute(
+ "SELECT typ, COUNT(*) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=? "
+ "GROUP BY typ ORDER BY n DESC LIMIT 1",
+ (dog_id, str(year))
+ ).fetchone()
+ lieblings_aktivitaet = typ_row["typ"] if typ_row else None
+
+ # Training-Sessions
+ training_sessions = conn.execute(
+ "SELECT COUNT(*) AS n FROM training_sessions "
+ "WHERE dog_id=? AND strftime('%Y', created_at)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Gesundheits-Einträge
+ gesundheit_eintraege = conn.execute(
+ "SELECT COUNT(*) AS n FROM health "
+ "WHERE dog_id=? AND strftime('%Y', datum)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json
+ wetter_kalt = 0
+ wetter_warm = 0
+ wetter_rows = conn.execute(
+ "SELECT weather_json FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL",
+ (dog_id, str(year))
+ ).fetchall()
+ for wr in wetter_rows:
+ try:
+ wj = _json.loads(wr["weather_json"])
+ temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
+ if temp is not None:
+ if float(temp) < 5:
+ wetter_kalt += 1
+ elif float(temp) > 25:
+ wetter_warm += 1
+ except Exception:
+ pass
+
+ return {
+ "dog_id": dog_id,
+ "dog_name": dog["name"],
+ "year": year,
+ "gesamt_km": gesamt_km,
+ "gassi_tage": gassi_tage,
+ "eintraege_gesamt": eintraege_gesamt,
+ "fotos_gesamt": fotos_gesamt,
+ "beste_route": beste_route,
+ "lieblings_monat": lieblings_monat,
+ "lieblings_aktivitaet": lieblings_aktivitaet,
+ "training_sessions": training_sessions,
+ "gesundheit_eintraege": gesundheit_eintraege,
+ "wetter_kalt": wetter_kalt,
+ "wetter_warm": wetter_warm,
+ }
+
+
+@router.get("/{dog_id}/buch")
+async def get_hunde_buch(
+ dog_id: int,
+ jahr: int = None,
+ limit: int = 50,
+ nur_fotos: bool = False,
+ nur_meilensteine: bool = False,
+ user=Depends(get_current_user),
+):
+ """Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege."""
+ import json as _json
+ from datetime import date as _date
+ from fastapi.responses import HTMLResponse
+ from html import escape as _esc
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ dog = dict(dog)
+
+ # --- Eintraege laden ---
+ conditions = ["(d.dog_id=? OR dd.dog_id=?)"]
+ params: list = [dog_id, dog_id]
+
+ if jahr:
+ conditions.append("strftime('%Y', d.datum) = ?")
+ params.append(str(jahr))
+
+ if nur_meilensteine:
+ conditions.append("d.is_milestone = 1")
+
+ where = " AND ".join(conditions)
+
+ rows = conn.execute(
+ f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags,
+ d.gps_lat, d.gps_lon, d.location_name, d.weather_json,
+ d.is_milestone,
+ (SELECT dm.url FROM diary_media dm
+ WHERE dm.diary_id=d.id AND dm.media_type='image'
+ ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
+ FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE {where}
+ AND d.datum IS NOT NULL
+ ORDER BY d.datum ASC""",
+ params
+ ).fetchall()
+
+ rows = [dict(r) for r in rows]
+
+ # Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus
+ if nur_fotos:
+ rows = [r for r in rows if r.get("cover_url")]
+ else:
+ # Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit
+ with_photo = [r for r in rows if r.get("cover_url")]
+ milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")]
+ rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")]
+ rows = with_photo + milestones + rest
+ rows.sort(key=lambda r: r["datum"] or "")
+
+ rows = rows[:limit]
+
+ # --- Hund-Alter berechnen ---
+ alter_str = ""
+ if dog.get("geburtstag"):
+ try:
+ geb = _date.fromisoformat(dog["geburtstag"])
+ heute = _date.today()
+ jahre = (heute - geb).days // 365
+ alter_str = f"{jahre} Jahre"
+ except Exception:
+ pass
+
+ # --- HTML bauen ---
+ dog_name = _esc(dog["name"] or "Mein Hund")
+ rasse_str = _esc(dog.get("rasse") or "")
+ jahr_str = str(jahr) if jahr else "Alle Jahre"
+ foto_url = dog.get("foto_url") or ""
+
+ cover_img = (
+ f'
'
+ if foto_url else
+ f'
🐾
'
+ )
+
+ subtitle_parts = [p for p in [rasse_str, alter_str] if p]
+ subtitle = " · ".join(subtitle_parts)
+
+ _MONATE = ["Januar","Februar","März","April","Mai","Juni",
+ "Juli","August","September","Oktober","November","Dezember"]
+
+ def _fmt_datum(iso: str) -> str:
+ try:
+ d = _date.fromisoformat(iso)
+ return f"{d.day}. {_MONATE[d.month - 1]} {d.year}"
+ except Exception:
+ return iso or ""
+
+ def _wetter_chip(wj_str: str) -> str:
+ if not wj_str:
+ return ""
+ try:
+ wj = _json.loads(wj_str)
+ temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
+ if temp is None:
+ return ""
+ temp_i = int(float(temp))
+ emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅")
+ return f'{emoji} {temp_i}°C'
+ except Exception:
+ return ""
+
+ entries_html = ""
+ for e in rows:
+ milestone_class = "milestone" if e.get("is_milestone") else ""
+ datum_fmt = _fmt_datum(e.get("datum") or "")
+ titel = _esc(e.get("titel") or "")
+ text_raw = e.get("text") or ""
+ text = _esc(text_raw).replace("\n", "
")
+ wetter = _wetter_chip(e.get("weather_json") or "")
+ loc = _esc(e.get("location_name") or "")
+ cover = e.get("cover_url") or ""
+
+ foto_html = ""
+ if cover:
+ foto_html = (
+ f''
+ f'
})
'
+ f'
'
+ )
+
+ loc_html = f'📍 {loc}' if loc else ""
+ chips_html = f'{wetter}{loc_html}
' if (wetter or loc_html) else ""
+ titel_html = f'{titel}
' if titel else ""
+ text_html = f'{text}
' if text_raw else ""
+
+ entries_html += f"""
+
+ {foto_html}
+
{datum_fmt}
+ {titel_html}
+ {text_html}
+ {chips_html}
+
+"""
+
+ anzahl = len(rows)
+ html_page = f"""
+
+
+
+
+ Hunde-Buch — {dog_name}
+
+
+
+
+
+
+
+ {cover_img}
+
{dog_name}
+ {'
' + subtitle + '
' if subtitle else ''}
+
{jahr_str}
+
{anzahl} Einträge
+
+
+{entries_html}
+
+
+"""
+
+ return HTMLResponse(content=html_page)
+
+
@router.get("/{dog_id}")
async def get_dog(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
@@ -622,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
}
+
+
+# ------------------------------------------------------------------
+# LEBENS-TIMELINE
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/timeline")
+async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
+ """Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen."""
+ import json as _json
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ events = []
+
+ with db() as conn:
+ # --- Tagebuch ---
+ diary_rows = conn.execute(
+ """SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone,
+ dm.url AS foto_url
+ FROM diary d
+ LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0
+ WHERE d.dog_id=?
+ ORDER BY d.datum ASC, d.id ASC""",
+ (dog_id,)
+ ).fetchall()
+
+ for i, r in enumerate(diary_rows):
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "tagebuch",
+ "titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()),
+ "typ": r["typ"],
+ "is_first": i == 0,
+ "is_milestone": bool(r["is_milestone"]),
+ "foto_url": r["foto_url"],
+ "ref_id": r["id"],
+ })
+
+ # --- Gesundheit ---
+ health_rows = conn.execute(
+ """SELECT id, datum, bezeichnung, typ
+ FROM health
+ WHERE dog_id=?
+ ORDER BY datum ASC, id ASC""",
+ (dog_id,)
+ ).fetchall()
+
+ typ_seen = {}
+ for r in health_rows:
+ t = r["typ"]
+ is_first = t not in typ_seen
+ if is_first:
+ typ_seen[t] = True
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "gesundheit",
+ "titel": r["bezeichnung"],
+ "typ": t,
+ "is_first": is_first,
+ "is_milestone": False,
+ "foto_url": None,
+ "ref_id": r["id"],
+ })
+
+ # --- Training-Sessions ---
+ ts_rows = conn.execute(
+ """SELECT id, datum, exercise_name, erfolgsquote, ist_top
+ FROM training_sessions
+ WHERE dog_id=? AND user_id=?
+ ORDER BY datum ASC, id ASC""",
+ (dog_id, user["id"])
+ ).fetchall()
+
+ ts_first = True
+ ts_best = None
+ ts_best_score = -1
+ for r in ts_rows:
+ if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score:
+ ts_best_score = r["erfolgsquote"]
+ ts_best = r
+
+ for i, r in enumerate(ts_rows):
+ is_first = (i == 0)
+ is_best = ts_best and r["id"] == ts_best["id"] and i > 0
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "training",
+ "titel": r["exercise_name"],
+ "typ": "training",
+ "is_first": is_first,
+ "is_milestone": bool(r["ist_top"]) or is_best,
+ "foto_url": None,
+ "ref_id": r["id"],
+ })
+
+ # --- Routen ---
+ route_rows = conn.execute(
+ """SELECT id, name, distanz_km,
+ date(created_at) AS datum
+ FROM routes
+ WHERE user_id=?
+ ORDER BY created_at ASC""",
+ (user["id"],)
+ ).fetchall()
+
+ route_first = True
+ route_longest = None
+ route_max_km = -1
+ for r in route_rows:
+ km = r["distanz_km"] or 0
+ if km > route_max_km:
+ route_max_km = km
+ route_longest = r
+
+ for i, r in enumerate(route_rows):
+ is_first = (i == 0)
+ is_longest = route_longest and r["id"] == route_longest["id"] and i > 0
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "route",
+ "titel": r["name"],
+ "typ": "route",
+ "is_first": is_first,
+ "is_milestone": is_longest,
+ "foto_url": None,
+ "ref_id": r["id"],
+ "distanz_km": r["distanz_km"],
+ })
+
+ # Geburtstag des Hundes als erster Eintrag
+ if dog["geburtstag"]:
+ events.append({
+ "datum": dog["geburtstag"],
+ "kategorie": "meilenstein",
+ "titel": f"{dog['name']} wird geboren",
+ "typ": "geburtstag",
+ "is_first": True,
+ "is_milestone": True,
+ "foto_url": None,
+ "ref_id": None,
+ })
+
+ # Chronologisch sortieren
+ events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"]))
+
+ return {
+ "dog_name": dog["name"],
+ "geburtstag": dog["geburtstag"],
+ "events": events,
+ }
diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py
new file mode 100644
index 0000000..c1f850e
--- /dev/null
+++ b/backend/routes/ernaehrung.py
@@ -0,0 +1,145 @@
+"""BAN YARO — Ernährungs-Routes"""
+
+import logging
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+import ki as ki_module
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class FutterProfilUpdate(BaseModel):
+ futter_typ: Optional[str] = None # trocken|nass|barf|mix
+ marke: Optional[str] = None
+ kcal_tag: Optional[int] = None
+ portionen: Optional[int] = None
+ notizen: Optional[str] = None
+
+
+class KiBeratungRequest(BaseModel):
+ frage: str
+ dog_name: Optional[str] = None
+ rasse: Optional[str] = None
+ alter: Optional[str] = None
+ gewicht: Optional[float] = None
+ aktiv: Optional[bool] = None
+
+
+# ------------------------------------------------------------------
+# Hilfsfunktion: Zugriffsprüfung
+# ------------------------------------------------------------------
+def _check_dog_access(conn, dog_id: int, user_id: int):
+ row = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+
+# ------------------------------------------------------------------
+# GET /dogs/{dog_id}/ernaehrung
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/ernaehrung")
+async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ row = conn.execute(
+ "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ if not row:
+ return {}
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# PUT /dogs/{dog_id}/ernaehrung
+# ------------------------------------------------------------------
+@router.put("/{dog_id}/ernaehrung")
+async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ existing = conn.execute(
+ "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ if existing:
+ conn.execute("""
+ UPDATE futter_profil
+ SET futter_typ=COALESCE(?, futter_typ),
+ marke=COALESCE(?, marke),
+ kcal_tag=COALESCE(?, kcal_tag),
+ portionen=COALESCE(?, portionen),
+ notizen=COALESCE(?, notizen),
+ updated_at=datetime('now')
+ WHERE dog_id=?
+ """, (body.futter_typ, body.marke, body.kcal_tag,
+ body.portionen, body.notizen, dog_id))
+ else:
+ conn.execute("""
+ INSERT INTO futter_profil
+ (dog_id, futter_typ, marke, kcal_tag, portionen, notizen)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (dog_id, body.futter_typ, body.marke, body.kcal_tag,
+ body.portionen or 2, body.notizen))
+ row = conn.execute(
+ "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# POST /dogs/{dog_id}/ernaehrung/ki-beratung
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/ernaehrung/ki-beratung")
+async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
+ request: Request,
+ user=Depends(get_current_user)):
+ if not body.frage or len(body.frage.strip()) < 3:
+ raise HTTPException(400, "Bitte stelle eine Frage.")
+ if len(body.frage) > 800:
+ raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).")
+
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+
+ dog_name = body.dog_name or "unbekannt"
+ rasse = body.rasse or "unbekannt"
+ alter = body.alter or "unbekannt"
+ gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt"
+ aktiv_str = "aktiv" if body.aktiv else "normal aktiv"
+
+ system = (
+ "Du bist Ernährungsberater für Hunde. "
+ "Antworte immer auf Deutsch, kurz und praktisch. "
+ "Keine unnötigen Füllsätze. "
+ "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. "
+ "Stelle keine medizinischen Diagnosen."
+ )
+
+ prompt = (
+ f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, "
+ f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n"
+ f"Frage: {body.frage.strip()}\n\n"
+ "Antworte konkret und praktisch, maximal 200 Wörter."
+ )
+
+ try:
+ antwort = await ki_module.complete(
+ prompt=prompt,
+ system=system,
+ max_tokens=500,
+ requires_premium=False,
+ user_id=user["id"],
+ )
+ return {"antwort": antwort}
+ except ki_module.KIUnavailableError as e:
+ raise HTTPException(503, str(e))
+ except Exception:
+ raise HTTPException(500, "KI momentan nicht verfügbar.")
diff --git a/backend/routes/friends.py b/backend/routes/friends.py
index cac5f4b..7df14e4 100644
--- a/backend/routes/friends.py
+++ b/backend/routes/friends.py
@@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# GET /api/friends/same-breed — andere User mit gleicher Rasse
+# ------------------------------------------------------------------
+@router.get("/same-breed")
+async def same_breed(user=Depends(get_current_user)):
+ """Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück."""
+ uid = user["id"]
+ with db() as conn:
+ # Rassen des eingeloggten Users
+ my_dogs = conn.execute(
+ "SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''",
+ (uid,)
+ ).fetchall()
+
+ if not my_dogs:
+ return {"count": 0, "rassen": [], "forum_query": None}
+
+ rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]})
+
+ # Andere User (nicht ich) die eine dieser Rassen haben
+ ph = ",".join("?" * len(rassen))
+ count_row = conn.execute(f"""
+ SELECT COUNT(DISTINCT d.user_id) AS cnt
+ FROM dogs d
+ WHERE d.user_id != ?
+ AND d.rasse IN ({ph})
+ """, (uid, *rassen)).fetchone()
+
+ count = count_row["cnt"] if count_row else 0
+
+ # Für jede Rasse: wie viele andere User
+ rassen_detail = []
+ for rasse in rassen:
+ n = conn.execute(
+ "SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs "
+ "WHERE user_id != ? AND rasse = ?",
+ (uid, rasse)
+ ).fetchone()["cnt"]
+ if n > 0:
+ rassen_detail.append({"rasse": rasse, "count": n})
+
+ rassen_detail.sort(key=lambda x: -x["count"])
+
+ # Forum-Suche-Link für die häufigste Rasse
+ forum_query = rassen_detail[0]["rasse"] if rassen_detail else None
+
+ return {
+ "count": count,
+ "rassen": rassen_detail,
+ "forum_query": forum_query,
+ }
diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py
new file mode 100644
index 0000000..77ff52f
--- /dev/null
+++ b/backend/routes/gassi_zeiten.py
@@ -0,0 +1,190 @@
+"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
+
+import json
+import math
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from typing import Optional, List
+from database import db
+from auth import get_current_user
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+def _haversine(lat1, lon1, lat2, lon2):
+ R = 6_371_000
+ p1, p2 = math.radians(lat1), math.radians(lat2)
+ dp = math.radians(lat2 - lat1)
+ dl = math.radians(lon2 - lon1)
+ a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
+ return 2 * R * math.asin(math.sqrt(a))
+
+
+class GassiZeitCreate(BaseModel):
+ dog_id: Optional[int] = None
+ wochentage: List[str] # ["mo", "mi", "fr"]
+ uhrzeit: str # "17:00"
+ ort_name: Optional[str] = None
+ lat: Optional[float] = None
+ lon: Optional[float] = None
+ radius_m: int = 500
+ notiz: Optional[str] = None
+
+
+class GassiZeitUpdate(BaseModel):
+ aktiv: Optional[int] = None
+
+
+# ------------------------------------------------------------------
+# GET /api/gassi-zeiten — alle in der Nähe (oder eigene)
+# ------------------------------------------------------------------
+@router.get("")
+async def list_gassi_zeiten(
+ lat: Optional[float] = None,
+ lon: Optional[float] = None,
+ radius: int = 5000, # Meter
+ nur_eigene: bool = False,
+ user=Depends(get_current_user),
+):
+ with db() as conn:
+ if nur_eigene:
+ rows = conn.execute("""
+ SELECT gz.*, u.name AS user_name, u.avatar_url,
+ d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
+ FROM gassi_zeiten gz
+ LEFT JOIN users u ON u.id = gz.user_id
+ LEFT JOIN dogs d ON d.id = gz.dog_id
+ WHERE gz.user_id = ?
+ ORDER BY gz.uhrzeit ASC
+ """, (user["id"],)).fetchall()
+ else:
+ rows = conn.execute("""
+ SELECT gz.*, u.name AS user_name, u.avatar_url,
+ d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
+ FROM gassi_zeiten gz
+ LEFT JOIN users u ON u.id = gz.user_id
+ LEFT JOIN dogs d ON d.id = gz.dog_id
+ WHERE gz.aktiv = 1
+ ORDER BY gz.uhrzeit ASC
+ """).fetchall()
+
+ result = []
+ for r in rows:
+ d = dict(r)
+ # wochentage JSON parsen
+ try:
+ d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"]
+ except Exception:
+ d["wochentage"] = []
+ d["is_mine"] = (d["user_id"] == user["id"])
+
+ # Distanz-Filter
+ if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
+ dist = _haversine(lat, lon, d["lat"], d["lon"])
+ if not nur_eigene and dist > radius:
+ continue
+ d["distance_m"] = int(dist)
+ else:
+ d["distance_m"] = None
+
+ result.append(d)
+
+ # Sortierung: eigene zuerst, dann nach Distanz
+ result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999))
+ return result
+
+
+# ------------------------------------------------------------------
+# POST /api/gassi-zeiten — eigene Zeit anlegen
+# ------------------------------------------------------------------
+@router.post("", status_code=201)
+async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)):
+ if not data.wochentage:
+ raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.")
+ if not data.uhrzeit:
+ raise HTTPException(400, "Uhrzeit muss angegeben werden.")
+
+ wochentage_json = json.dumps(data.wochentage)
+
+ with db() as conn:
+ # Hund-Prüfung
+ if data.dog_id:
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.")
+
+ cur = conn.execute("""
+ INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit,
+ ort_name, lat, lon, radius_m, notiz)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (user["id"], data.dog_id, wochentage_json, data.uhrzeit,
+ data.ort_name, data.lat, data.lon, data.radius_m, data.notiz))
+
+ row = conn.execute("""
+ SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url
+ FROM gassi_zeiten gz
+ LEFT JOIN users u ON u.id = gz.user_id
+ LEFT JOIN dogs d ON d.id = gz.dog_id
+ WHERE gz.id = ?
+ """, (cur.lastrowid,)).fetchone()
+
+ result = dict(row)
+ try:
+ result["wochentage"] = json.loads(result["wochentage"])
+ except Exception:
+ pass
+ result["is_mine"] = True
+ return result
+
+
+# ------------------------------------------------------------------
+# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren
+# ------------------------------------------------------------------
+@router.patch("/{gz_id}")
+async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)):
+ with db() as conn:
+ gz = conn.execute(
+ "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
+ ).fetchone()
+ if not gz:
+ raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
+ if gz["user_id"] != user["id"]:
+ raise HTTPException(403, "Nicht deine Gassi-Zeit.")
+
+ updates = data.model_dump(exclude_none=True)
+ if updates:
+ cols = ", ".join(f"{k} = ?" for k in updates)
+ conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id])
+
+ row = conn.execute(
+ "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
+ ).fetchone()
+
+ result = dict(row)
+ try:
+ result["wochentage"] = json.loads(result["wochentage"])
+ except Exception:
+ pass
+ result["is_mine"] = True
+ return result
+
+
+# ------------------------------------------------------------------
+# DELETE /api/gassi-zeiten/{id}
+# ------------------------------------------------------------------
+@router.delete("/{gz_id}", status_code=204)
+async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ gz = conn.execute(
+ "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
+ ).fetchone()
+ if not gz:
+ raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
+ if gz["user_id"] != user["id"]:
+ raise HTTPException(403, "Nicht deine Gassi-Zeit.")
+ conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,))
diff --git a/backend/routes/osm.py b/backend/routes/osm.py
index e08742b..998cd4b 100644
--- a/backend/routes/osm.py
+++ b/backend/routes/osm.py
@@ -279,9 +279,15 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
- 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
- 'kotbeutel', # Kotbeutelspender
+ 'giftkoeder', # Giftköder (exklusiv, kein Kombi)
'gefahr', # Allgemeine Gefahr / Hinweis
+ 'freilauf', # Freilauffläche
+ 'restaurant', # Hundefreundliches Restaurant / Café
+ 'shop', # Hundefreundlicher Shop
+ 'tierarzt', # Tierarzt / Tierklinik
+ 'hundeschule', # Hundeschule / Trainer
+ 'kotbeutel', # Kotbeutelspender
+ 'bank', # Sitzbank
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
'sonstiges',
@@ -289,7 +295,8 @@ ALLOWED_TYPES = {
@router.post('/user-poi')
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
- if body.type not in ALLOWED_TYPES:
+ types = [t.strip() for t in body.type.split(',') if t.strip()]
+ if not types or any(t not in ALLOWED_TYPES for t in types):
raise HTTPException(400, 'Ungültiger Typ')
with db() as conn:
row = conn.execute("""
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index 08f403a..9762f95 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -26,6 +26,7 @@ class ProfileUpdate(BaseModel):
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
+ gassi_stunde_push: Optional[int] = None
def _load_user(user_id: int) -> dict:
@@ -113,3 +114,28 @@ async def upload_avatar(
)
return {"avatar_url": avatar_url}
+
+
+# ----------------------------------------------------------
+# GET /profile/world-config — Welten-Chip-Konfiguration laden
+# PUT /profile/world-config — Welten-Chip-Konfiguration speichern
+# ----------------------------------------------------------
+import json as _json
+
+@router.get('/world-config')
+async def get_world_config(user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone()
+ cfg = row['world_config'] if row and row['world_config'] else None
+ return {"config": _json.loads(cfg) if cfg else None}
+
+
+class WorldConfigIn(BaseModel):
+ config: dict
+
+@router.put('/world-config')
+async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
+ with db() as conn:
+ conn.execute("UPDATE users SET world_config=? WHERE id=?",
+ (_json.dumps(body.config), user['id']))
+ return {"status": "ok"}
diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py
index 48287f9..8448478 100644
--- a/backend/routes/tieraerzte.py
+++ b/backend/routes/tieraerzte.py
@@ -27,6 +27,14 @@ class TierarztCreate(BaseModel):
osm_id: Optional[str] = None
+class BewertungCreate(BaseModel):
+ gesamt: int
+ wartezeit: Optional[int] = None
+ freundlichkeit: Optional[int] = None
+ kompetenz: Optional[int] = None
+ text: Optional[str] = None
+
+
class TierarztUpdate(BaseModel):
name: Optional[str] = None
strasse: Optional[str] = None
@@ -220,3 +228,109 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate,
)
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
return dict(row)
+
+
+# ------------------------------------------------------------------
+# BEWERTUNGEN
+# ------------------------------------------------------------------
+
+def _refresh_vet_rating(conn, tierarzt_id: int):
+ """Aktualisiert avg_rating und anz_bewertungen in tieraerzte."""
+ row = conn.execute(
+ """SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg
+ FROM tierarzt_bewertungen WHERE tierarzt_id=?""",
+ (tierarzt_id,)
+ ).fetchone()
+ n = row["n"] or 0
+ avg = row["avg"] or 0.0
+ conn.execute(
+ "UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?",
+ (round(avg, 1), n, tierarzt_id)
+ )
+
+
+@router.post("/{tierarzt_id}/bewertung", status_code=201)
+async def create_bewertung(tierarzt_id: int, data: BewertungCreate,
+ user=Depends(get_current_user)):
+ """Bewertung abgeben (1×pro User+Tierarzt, UPSERT)."""
+ if not (1 <= data.gesamt <= 5):
+ raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.")
+ for field in ("wartezeit", "freundlichkeit", "kompetenz"):
+ val = getattr(data, field)
+ if val is not None and not (1 <= val <= 5):
+ raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.")
+
+ text = (data.text or "").strip()[:500] or None
+
+ with db() as conn:
+ vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
+ if not vet:
+ raise HTTPException(404, "Tierarzt nicht gefunden.")
+
+ conn.execute(
+ """INSERT INTO tierarzt_bewertungen
+ (tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text)
+ VALUES (?,?,?,?,?,?,?)
+ ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET
+ gesamt=excluded.gesamt,
+ wartezeit=excluded.wartezeit,
+ freundlichkeit=excluded.freundlichkeit,
+ kompetenz=excluded.kompetenz,
+ text=excluded.text,
+ created_at=datetime('now')""",
+ (tierarzt_id, user["id"], data.gesamt, data.wartezeit,
+ data.freundlichkeit, data.kompetenz, text)
+ )
+ _refresh_vet_rating(conn, tierarzt_id)
+ row = conn.execute(
+ "SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)
+ ).fetchone()
+ return dict(row)
+
+
+@router.get("/{tierarzt_id}/bewertungen")
+async def list_bewertungen(tierarzt_id: int):
+ """Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte."""
+ with db() as conn:
+ vet = conn.execute(
+ "SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?",
+ (tierarzt_id,)
+ ).fetchone()
+ if not vet:
+ raise HTTPException(404, "Tierarzt nicht gefunden.")
+
+ # Stern-Verteilung
+ verteilung = {}
+ for star in range(1, 6):
+ r = conn.execute(
+ "SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?",
+ (tierarzt_id, star)
+ ).fetchone()
+ verteilung[str(star)] = r["n"]
+
+ # Letzte 5 Kommentare
+ kommentare = conn.execute(
+ """SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at
+ FROM tierarzt_bewertungen
+ WHERE tierarzt_id=? AND text IS NOT NULL AND text != ''
+ ORDER BY created_at DESC LIMIT 5""",
+ (tierarzt_id,)
+ ).fetchall()
+
+ return {
+ "avg_rating": vet["avg_rating"] or 0,
+ "anz_bewertungen": vet["anz_bewertungen"] or 0,
+ "verteilung": verteilung,
+ "kommentare": [dict(k) for k in kommentare],
+ }
+
+
+@router.get("/{tierarzt_id}/meine-bewertung")
+async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)):
+ """Eigene Bewertung für einen Tierarzt (oder null)."""
+ with db() as conn:
+ row = conn.execute(
+ "SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?",
+ (tierarzt_id, user["id"])
+ ).fetchone()
+ return dict(row) if row else None
diff --git a/backend/routes/weather.py b/backend/routes/weather.py
index fced719..2167b19 100644
--- a/backend/routes/weather.py
+++ b/backend/routes/weather.py
@@ -3,9 +3,11 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
+import json
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
from auth import get_current_user
+from database import db
router = APIRouter()
@@ -31,3 +33,58 @@ async def get_weather_forecast(
return await weather_module.get_forecast(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')
+
+
+@router.get('/records')
+async def weather_records(user=Depends(get_current_user)):
+ """Persönliche Wetterrekorde aus diary-Einträgen mit weather_json."""
+ uid = user["id"]
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT d.datum, d.weather_json, d.titel
+ FROM diary d
+ JOIN dogs dog ON dog.id = d.dog_id
+ WHERE dog.user_id = ? AND d.weather_json IS NOT NULL
+ ORDER BY d.datum ASC
+ """, (uid,)).fetchall()
+
+ if not rows:
+ return {"records": None}
+
+ entries = []
+ for r in rows:
+ try:
+ w = json.loads(r["weather_json"])
+ entries.append({
+ "datum": r["datum"],
+ "titel": r["titel"],
+ "temp_c": w.get("temp_c"),
+ "wind_kmh": w.get("wind_kmh"),
+ "precip_prob": w.get("precip_prob"),
+ "desc": w.get("desc", ""),
+ "weathercode": w.get("weathercode"),
+ })
+ except Exception:
+ pass
+
+ if not entries:
+ return {"records": None}
+
+ temps = [e for e in entries if e["temp_c"] is not None]
+ winds = [e for e in entries if e["wind_kmh"] is not None]
+
+ records = {}
+ if temps:
+ kaeltester = min(temps, key=lambda e: e["temp_c"])
+ heissester = max(temps, key=lambda e: e["temp_c"])
+ records["kaeltester"] = kaeltester
+ records["heissester"] = heissester
+ if winds:
+ stuermischster = max(winds, key=lambda e: e["wind_kmh"])
+ records["stuermischster"] = stuermischster
+
+ regen_count = sum(1 for e in entries if (e.get("precip_prob") or 0) > 60)
+ records["regen_eintraege"] = regen_count
+ records["gesamt_eintraege"] = len(entries)
+
+ return {"records": records}
diff --git a/backend/routes/widget.py b/backend/routes/widget.py
index f5cc940..2c04ae8 100644
--- a/backend/routes/widget.py
+++ b/backend/routes/widget.py
@@ -1,13 +1,33 @@
-"""BAN YARO — Widget-Snapshot Endpoint"""
+"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
-import json, random
-from fastapi import APIRouter, Depends
+import json
+from datetime import date
+from fastapi import APIRouter, Depends, Query
+from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
+@router.get("/quote")
+async def daily_quote(kategorie: Optional[str] = Query(None)):
+ """Liefert einen deterministischen Tagesspruch (wechselt täglich)."""
+ day_num = (date.today() - date(2026, 1, 1)).days
+ with db() as conn:
+ if kategorie:
+ rows = conn.execute(
+ "SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?",
+ (kategorie,)
+ ).fetchall()
+ else:
+ rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall()
+ if not rows:
+ return {"quote": None}
+ q = rows[day_num % len(rows)]
+ return {"quote": dict(q)}
+
+
@router.get("/snapshot")
async def widget_snapshot(user=Depends(get_current_user)):
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""
@@ -39,7 +59,8 @@ async def widget_snapshot(user=Depends(get_current_user)):
(dog_id,)
).fetchall()
- random_photo = dict(random.choice(photos)) if photos else None
+ day_num = (date.today() - date(2024, 1, 1)).days
+ random_photo = dict(photos[day_num % len(photos)]) if photos else None
# Anzahl überfälliger Erinnerungen
overdue = conn.execute(
diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py
index 45f5bfb..56df55d 100644
--- a/backend/routes/wiki.py
+++ b/backend/routes/wiki.py
@@ -414,7 +414,7 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
raise HTTPException(404, "Einreichung nicht gefunden.")
rasse = conn.execute(
- "SELECT id, external_id, slug FROM wiki_rassen WHERE id=?",
+ "SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?",
(sub["rasse_id"],)
).fetchone()
diff --git a/backend/scheduler.py b/backend/scheduler.py
index 4aeb89a..22d1533 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -156,8 +156,40 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
+ # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen
+ _scheduler.add_job(
+ _job_new_foto_challenge,
+ CronTrigger(day_of_week='mon', hour=8, minute=0),
+ id="new_foto_challenge",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # Täglich 07:00 Uhr — Goldene Gassi-Stunde
+ _scheduler.add_job(
+ _job_golden_gassi_hour,
+ CronTrigger(hour=7, minute=0),
+ id="golden_gassi_hour",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren)
+ _scheduler.add_job(
+ _job_anniversary_reminders,
+ CronTrigger(hour=9, minute=0),
+ id="anniversary_reminders",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # 1. des Monats 10:00 — Monatlicher Rückblick per Push
+ _scheduler.add_job(
+ _job_monthly_recap,
+ CronTrigger(day=1, hour=10, minute=0),
+ id="monthly_recap",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
_scheduler.start()
- logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
+ logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@@ -881,6 +913,9 @@ async def _job_status_report():
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
+ "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)",
+ "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)",
+ "monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)",
}
job_rows_html = ""
job_rows_txt = ""
@@ -1288,3 +1323,369 @@ async def _job_recurring_expenses():
except Exception as e:
logger.error(f"Daueraufträge-Job Fehler: {e}")
_log_job("recurring_expenses", "error", str(e))
+
+
+# ------------------------------------------------------------------
+# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr)
+# ------------------------------------------------------------------
+async def _job_golden_gassi_hour():
+ """
+ Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1)
+ das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification.
+
+ Score-Logik pro Stunde (max. 10 Punkte):
+ - Temperatur 10–20°C → +3
+ - Temperatur 5–10°C → +1
+ - Niederschlagswahrsch. <20% → +3, <40% → +1
+ - Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1
+ - Stunden 07–19 Uhr (Tageslicht) → +2
+ Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden).
+ """
+ import httpx
+ from datetime import date as _date
+
+ logger.info("Goldene-Gassi-Stunde Job läuft")
+
+ # Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription
+ with db() as conn:
+ users = conn.execute("""
+ SELECT DISTINCT u.id AS user_id,
+ ps.last_lat, ps.last_lon
+ FROM users u
+ JOIN push_subscriptions ps ON ps.user_id = u.id
+ WHERE u.gassi_stunde_push = 1
+ """).fetchall()
+
+ users = [dict(u) for u in users]
+ logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.")
+
+ if not users:
+ _log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv")
+ return
+
+ sent_total = 0
+
+ for u in users:
+ lat = u["last_lat"] or 48.1351 # Fallback: München
+ lon = u["last_lon"] or 11.5820
+
+ try:
+ hourly = await _fetch_hourly_weather(lat, lon)
+ except Exception as e:
+ logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}")
+ continue
+
+ if not hourly:
+ continue
+
+ best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly)
+
+ if best_score < 3:
+ # Heute kein gutes Wetterfenster → kein Push
+ logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})")
+ continue
+
+ hour_end = (best_start + 2) % 24
+ temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–"
+ wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else (
+ f"{best_wind:.0f} km/h Wind" if best_wind is not None else "")
+
+ body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr",
+ f"· {temp_str}"]
+ if wind_str:
+ body_parts.append(f"· {wind_str}")
+
+ sent = send_push_to_user(u["user_id"], {
+ "type": "golden_gassi_hour",
+ "title": "☀️ Goldene Gassi-Stunde heute!",
+ "body": " ".join(body_parts),
+ "data": {"page": "wetter"},
+ "tag": f"gassi-{_date.today()}",
+ })
+ sent_total += sent
+ logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}")
+
+ logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.")
+ _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User")
+
+
+# ------------------------------------------------------------------
+# JOB: Jahrestags-Erinnerungen (täglich 09:00)
+# ------------------------------------------------------------------
+async def _job_anniversary_reminders():
+ """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push."""
+ today = datetime.now(tz=_TZ)
+ today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr
+
+ logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
+
+ with db() as conn:
+ entries = conn.execute("""
+ SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
+ (SELECT dm.url FROM diary_media dm
+ WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
+ FROM diary d
+ WHERE strftime('%m-%d', d.datum) = ?
+ AND d.datum < date('now')
+ AND d.titel IS NOT NULL
+ AND d.is_milestone = 0
+ """, (today_md,)).fetchall()
+
+ sent_total = 0
+ for e in entries:
+ try:
+ jahre = today.year - int(e['datum'][:4])
+ if jahre < 1:
+ continue
+ jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren"
+ send_push_to_user(e['user_id'], {
+ 'type': 'anniversary_reminder',
+ 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}',
+ 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund',
+ 'data': {'page': 'diary'},
+ 'tag': f'anniversary-{e["id"]}-{today.year}',
+ })
+ sent_total += 1
+ except Exception as ex:
+ logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}")
+
+ logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.")
+ _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen")
+
+
+# ------------------------------------------------------------------
+# JOB: Monatlicher Rückblick (1. des Monats 10:00)
+# ------------------------------------------------------------------
+async def _job_monthly_recap():
+ """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats."""
+ today = datetime.now(tz=_TZ)
+ first_this = today.replace(day=1)
+ last_month_end = first_this - timedelta(days=1)
+ last_month_start = last_month_end.replace(day=1)
+ year_str = last_month_start.strftime('%Y')
+ month_str = last_month_start.strftime('%m')
+ month_label = last_month_start.strftime('%B %Y')
+
+ logger.info(f"Monatlicher Rückblick Job läuft für {month_label}")
+
+ with db() as conn:
+ # Alle User mit mindestens einem Hund
+ users = conn.execute(
+ "SELECT DISTINCT user_id FROM dogs"
+ ).fetchall()
+
+ sent_total = 0
+ for u in users:
+ user_id = u["user_id"]
+ try:
+ with db() as conn:
+ # Hunde des Users
+ dog_rows = conn.execute(
+ "SELECT id, name FROM dogs WHERE user_id=?", (user_id,)
+ ).fetchall()
+ if not dog_rows:
+ continue
+
+ dog_ids = [d["id"] for d in dog_rows]
+ placeholders = ','.join('?' * len(dog_ids))
+
+ # km (Routen des Users im Vormonat)
+ km_row = conn.execute(
+ "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
+ "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
+ (user_id, year_str, month_str)
+ ).fetchone()
+ gesamt_km = km_row["km"] or 0.0
+
+ # Tagebucheinträge
+ eintraege = conn.execute(
+ f"SELECT COUNT(*) AS n FROM diary "
+ f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?",
+ (*dog_ids, year_str, month_str)
+ ).fetchone()["n"]
+
+ # Training-Sessions
+ training = conn.execute(
+ f"SELECT COUNT(*) AS n FROM training_sessions "
+ f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
+ (*dog_ids, year_str, month_str)
+ ).fetchone()["n"]
+
+ # Lieblingsfoto (erstes Foto im Vormonat)
+ foto_row = conn.execute(
+ f"SELECT dm.url FROM diary_media dm "
+ f"JOIN diary d ON d.id=dm.diary_id "
+ f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' "
+ f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? "
+ f"ORDER BY d.datum ASC LIMIT 1",
+ (*dog_ids, year_str, month_str)
+ ).fetchone()
+ foto_url = foto_row["url"] if foto_row else None
+
+ # Nur senden wenn mindestens eine Aktivität vorhanden
+ if eintraege == 0 and training == 0 and gesamt_km == 0:
+ continue
+
+ dog_name = dog_rows[0]["name"]
+ parts = []
+ if gesamt_km > 0:
+ parts.append(f"{gesamt_km} km gelaufen")
+ if eintraege > 0:
+ parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge")
+ if training > 0:
+ parts.append(f"{training} Training-Sessions")
+
+ body_text = " · ".join(parts)
+
+ send_push_to_user(user_id, {
+ 'type': 'monthly_recap',
+ 'title': f'📅 {month_label}: Rückblick für {dog_name}',
+ 'body': body_text,
+ 'data': {'page': 'diary'},
+ 'tag': f'monthly-recap-{year_str}-{month_str}',
+ })
+ sent_total += 1
+ except Exception as ex:
+ logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}")
+
+ logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.")
+ _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
+
+
+async def _job_new_foto_challenge():
+ """Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen."""
+ from datetime import date, timedelta
+ from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday
+
+ monday = _current_week_monday()
+ sunday = _current_week_sunday()
+
+ week_num = date.today().isocalendar()[1]
+ thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
+
+ with db() as conn:
+ existing = conn.execute(
+ "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
+ ).fetchone()
+ if existing:
+ logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).")
+ _log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}")
+ return
+
+ cur = conn.execute(
+ "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
+ "VALUES (?, ?, ?, ?, NULL)",
+ (thema, f"Diese Woche: {thema}", monday, sunday)
+ )
+ challenge_id = cur.lastrowid
+
+ # Push an alle User
+ send_push_to_all({
+ "type": "foto_challenge",
+ "title": "📸 Neue Foto-Challenge!",
+ "body": f"Diese Woche: {thema} — mach mit!",
+ "data": {"page": "walks", "tab": "challenge"},
+ "tag": f"challenge-{monday}",
+ })
+
+ logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}–{sunday} (id={challenge_id}).")
+ _log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}")
+
+
+async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
+ """Holt stündliche Wetterdaten für heute von Open-Meteo."""
+ import httpx
+ from datetime import date as _date
+
+ today = _date.today().isoformat()
+ url = (
+ "https://api.open-meteo.com/v1/forecast"
+ f"?latitude={lat}&longitude={lon}"
+ "&hourly=temperature_2m,precipitation_probability,windspeed_10m"
+ "&timezone=Europe%2FBerlin&forecast_days=1"
+ )
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ raw = resp.json()
+
+ hourly = raw.get("hourly", {})
+ times = hourly.get("time", [])
+ temps = hourly.get("temperature_2m", [])
+ precips = hourly.get("precipitation_probability", [])
+ winds = hourly.get("windspeed_10m", [])
+
+ result = []
+ for i, ts in enumerate(times):
+ if not ts.startswith(today):
+ continue
+ hour = int(ts[11:13])
+ result.append({
+ "hour": hour,
+ "temp": temps[i] if i < len(temps) else None,
+ "precip": precips[i] if i < len(precips) else None,
+ "wind": winds[i] if i < len(winds) else None,
+ })
+ return result
+
+
+def _score_hour(h: dict) -> int:
+ """Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte)."""
+ score = 0
+ temp = h.get("temp")
+ precip = h.get("precip")
+ wind = h.get("wind")
+ hour = h.get("hour", 12)
+
+ # Temperatur
+ if temp is not None:
+ if 10 <= temp <= 20:
+ score += 3
+ elif 5 <= temp < 10 or 20 < temp <= 25:
+ score += 1
+
+ # Niederschlagswahrscheinlichkeit
+ if precip is not None:
+ if precip < 20:
+ score += 3
+ elif precip < 40:
+ score += 1
+
+ # Wind
+ if wind is not None:
+ if wind < 20:
+ score += 2
+ elif wind < 30:
+ score += 1
+
+ # Tageslicht (07–19 Uhr)
+ if 7 <= hour <= 19:
+ score += 2
+
+ return score
+
+
+def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]:
+ """
+ Findet das beste aufeinanderfolgende 2h-Fenster.
+ Gibt (start_hour, total_score, avg_temp, avg_wind) zurück.
+ """
+ best_start = 8
+ best_score = -1
+ best_temp = None
+ best_wind = None
+
+ for i in range(len(hourly) - 1):
+ h1 = hourly[i]
+ h2 = hourly[i + 1]
+ combined = _score_hour(h1) + _score_hour(h2)
+ if combined > best_score:
+ best_score = combined
+ best_start = h1["hour"]
+ # Durchschnittswerte für Anzeige
+ temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None]
+ winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None]
+ best_temp = sum(temps) / len(temps) if temps else None
+ best_wind = sum(winds) / len(winds) if winds else None
+
+ return best_start, best_score, best_temp, best_wind
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 27cf0d9..d3dcc37 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -5571,6 +5571,139 @@ html.modal-open {
border-radius: 0;
}
+/* ── Wiki Gallery ────────────────────────────────────────── */
+.wiki-gallery-wrap {
+ position: relative;
+ margin-bottom: var(--space-3);
+}
+.wiki-gallery-main {
+ width: 100%;
+ height: 240px;
+ object-fit: cover;
+ object-position: center top;
+ border-radius: var(--radius-lg);
+ display: block;
+}
+.wiki-gallery-strip {
+ display: flex;
+ gap: var(--space-2);
+ overflow-x: auto;
+ padding: var(--space-2) 0 0;
+ scrollbar-width: none;
+}
+.wiki-gallery-strip::-webkit-scrollbar { display: none; }
+.wiki-gallery-thumb {
+ flex-shrink: 0;
+ width: 64px; height: 64px;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 2px solid transparent;
+ padding: 0;
+ background: none;
+ cursor: pointer;
+ position: relative;
+ transition: border-color .15s;
+}
+.wiki-gallery-thumb.active { border-color: var(--c-primary); }
+.wiki-gallery-thumb img {
+ width: 100%; height: 100%; object-fit: cover;
+}
+.wiki-gallery-thumb-label {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ background: rgba(0,0,0,.55);
+ color: #fff;
+ font-size: 8px;
+ padding: 2px 4px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.wiki-gallery-expand {
+ position: absolute;
+ top: var(--space-2);
+ right: var(--space-2);
+ width: 34px; height: 34px;
+ border-radius: 50%;
+ background: rgba(0,0,0,.45);
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(4px);
+ transition: background .15s;
+}
+.wiki-gallery-expand:hover { background: rgba(0,0,0,.65); }
+
+/* ── Wiki Lightbox ───────────────────────────────────────── */
+#wiki-lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.wlb-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0,0,0,.88);
+ backdrop-filter: blur(6px);
+}
+.wlb-content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ max-width: min(92vw, 680px);
+ width: 100%;
+ gap: var(--space-2);
+}
+.wlb-img {
+ width: 100%;
+ max-height: 72vh;
+ object-fit: contain;
+ border-radius: var(--radius-lg);
+}
+.wlb-close {
+ position: absolute;
+ top: -44px;
+ right: 0;
+ background: rgba(255,255,255,.12);
+ border: none;
+ color: #fff;
+ width: 36px; height: 36px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.wlb-prev, .wlb-next {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(255,255,255,.12);
+ border: none;
+ color: #fff;
+ width: 40px; height: 40px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.4rem;
+ transition: background .15s;
+}
+.wlb-prev { left: -48px; }
+.wlb-next { right: -48px; }
+.wlb-prev:hover, .wlb-next:hover { background: rgba(255,255,255,.25); }
+.wlb-caption { color: rgba(255,255,255,.75); font-size: var(--text-sm); }
+.wlb-counter { color: rgba(255,255,255,.45); font-size: var(--text-xs); }
+
/* Steckbrief-Grid */
.wiki-steckbrief-grid {
display: grid;
@@ -6550,6 +6683,97 @@ html.modal-open {
/* ============================================================
HELP TOOLTIP
============================================================ */
+/* ============================================================
+ PAGE INFO — generische Seiten-Hilfe (UI.pageInfo)
+ ============================================================ */
+.pinfo-trigger-inline {
+ width: 26px; height: 26px;
+ border-radius: 50%;
+ background: var(--c-surface-2);
+ border: 1px solid var(--c-border-light);
+ color: var(--c-text-secondary);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background .15s, color .15s;
+}
+.pinfo-trigger-inline:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); }
+
+.pinfo-banner {
+ margin: var(--space-3) var(--space-4) 0;
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-lg);
+ background: var(--c-surface-2);
+ border-left: 3px solid var(--c-primary);
+ font-size: var(--text-sm);
+}
+.pinfo-banner-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+}
+.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; }
+.pinfo-banner-title {
+ flex: 1;
+ font-weight: var(--weight-semibold);
+ color: var(--c-text);
+}
+.pinfo-banner-close {
+ background: none; border: none; cursor: pointer;
+ color: var(--c-text-muted); padding: 2px;
+}
+.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; }
+.pinfo-banner-more {
+ background: none; border: none; cursor: pointer;
+ color: var(--c-primary);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-medium);
+ padding: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: var(--space-2);
+}
+
+/* MODAL BODY */
+.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); }
+.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; }
+.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); }
+.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); }
+.pinfo-step {
+ display: flex;
+ gap: var(--space-3);
+ align-items: flex-start;
+}
+.pinfo-step-icon {
+ width: 32px; height: 32px;
+ border-radius: var(--radius-md);
+ background: var(--c-primary-subtle, rgba(196,132,58,.12));
+ color: var(--c-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; }
+.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; }
+.pinfo-tip {
+ display: flex;
+ gap: var(--space-2);
+ align-items: flex-start;
+ padding: var(--space-3);
+ background: rgba(196,132,58,.08);
+ border-radius: var(--radius-md);
+ color: var(--c-text-secondary);
+ font-size: var(--text-sm);
+ line-height: 1.5;
+}
+.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; }
+
+
.by-help-btn {
display: inline-flex;
align-items: center;
@@ -6954,7 +7178,7 @@ svg.empty-state-icon {
/* FAB */
.exp-fab {
position: fixed;
- bottom: calc(var(--nav-height, 64px) + var(--space-4));
+ bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2));
right: var(--space-4);
z-index: 100;
width: 52px;
@@ -7080,6 +7304,20 @@ svg.empty-state-icon {
color: var(--c-text-secondary);
line-height: 1.2;
}
+.exp-kachel-jahr {
+ font-size: 9px;
+ color: var(--c-text-muted);
+ margin-top: 2px;
+ line-height: 1.2;
+}
+.exp-kachel-add {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 10px;
+ color: var(--c-text-muted);
+ margin-top: 3px;
+}
/* ---- Sektion-Block (Verlauf etc.) ---- */
.exp-section {
@@ -7255,6 +7493,36 @@ svg.empty-state-icon {
border-radius: 999px;
padding: 1px 6px;
}
+.exp-dog-selector {
+ display: flex;
+ gap: 8px;
+ padding: 10px 16px 4px;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+.exp-dog-selector::-webkit-scrollbar { display: none; }
+.exp-dog-pill {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 14px;
+ border-radius: 999px;
+ border: 1px solid var(--c-border);
+ background: var(--c-bg-card);
+ color: var(--c-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background .15s, color .15s, border-color .15s;
+}
+.exp-dog-pill.active {
+ background: var(--c-primary);
+ color: #fff;
+ border-color: var(--c-primary);
+}
/* Rechte Spalte: Betrag + Löschen-Icon */
.exp-entry-right {
@@ -7600,10 +7868,28 @@ svg.empty-state-icon {
.wlabel.active { opacity: 1; }
@media (min-width: 768px) {
- #world-labels { gap: 48px; font-size: 11px; }
- .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; }
- .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); }
- .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); }
+ #world-labels {
+ gap: 40px;
+ top: calc(env(safe-area-inset-top, 0px) + 18px);
+ }
+ .wlabel {
+ font-size: 13px;
+ letter-spacing: 0.18em;
+ opacity: 0.55;
+ padding: 6px 14px;
+ border-radius: 20px;
+ text-shadow: 0 1px 6px rgba(0,0,0,0.7);
+ transition: opacity 0.18s, background 0.18s;
+ }
+ .wlabel:hover {
+ opacity: 0.85;
+ background: rgba(255, 255, 255, 0.12);
+ }
+ .wlabel.active {
+ opacity: 1;
+ background: rgba(255, 255, 255, 0.18);
+ text-shadow: 0 1px 8px rgba(0,0,0,0.5);
+ }
}
/* Settings-Button */
@@ -7775,28 +8061,35 @@ svg.empty-state-icon {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
- padding: 14px 6px 11px;
+ padding: 12px 6px;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
- gap: 7px;
+ justify-content: center;
+ gap: 6px;
color: white;
transition: background 0.12s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
user-select: none;
+ min-height: 80px; /* alle Chips gleich hoch */
}
.world-chip:active {
background: rgba(0, 0, 0, 0.6);
transform: scale(0.93);
}
-.world-chip svg { color: white; }
+.world-chip svg { color: white; flex-shrink: 0; }
.world-chip-label {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1.2;
+ max-height: 2.4em; /* max. 2 Zeilen */
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
}
/* Chip-Umrandung je Welt */
@@ -7891,3 +8184,189 @@ svg.empty-state-icon {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
+
+/* ── COMMUNITY-FEATURES ──────────────────────────────────── */
+
+/* Walks-Tab-Bar */
+.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; }
+
+/* Foto-Challenge */
+.challenge-banner {
+ background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A));
+ border-radius: var(--radius-lg);
+ margin: var(--space-4);
+ overflow: hidden;
+}
+.challenge-banner-inner {
+ padding: var(--space-5) var(--space-4);
+ color: #fff;
+}
+.challenge-thema {
+ font-size: var(--text-xl);
+ font-weight: var(--weight-bold);
+ line-height: 1.2;
+ margin-bottom: var(--space-2);
+}
+.challenge-meta {
+ font-size: var(--text-sm);
+ opacity: 0.88;
+}
+.challenge-gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: var(--space-3);
+ padding: 0 var(--space-4) var(--space-6);
+}
+.challenge-sub-card {
+ background: var(--c-surface);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ box-shadow: var(--shadow-sm);
+}
+.challenge-sub-card img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ display: block;
+ cursor: pointer;
+}
+.challenge-sub-info {
+ padding: var(--space-2);
+}
+.challenge-sub-user {
+ font-size: var(--text-xs);
+ color: var(--c-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 2px;
+}
+.challenge-sub-caption {
+ font-size: var(--text-xs);
+ color: var(--c-text);
+ margin-bottom: var(--space-1);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+.challenge-vote-btn {
+ border: none;
+ background: transparent;
+ color: var(--c-text-secondary);
+ font-size: var(--text-xs);
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+.challenge-vote-btn.voted {
+ color: var(--c-danger, #ef4444);
+}
+.challenge-winners { border-top: 1px solid var(--c-border); }
+.challenge-winners-row {
+ display: flex;
+ gap: var(--space-3);
+ overflow-x: auto;
+ padding: var(--space-2) var(--space-4) var(--space-3);
+ scroll-snap-type: x mandatory;
+}
+.challenge-winner-chip {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ background: var(--c-surface-alt, #fdf6ef);
+ border-radius: var(--radius-md);
+ padding: var(--space-2) var(--space-3);
+ min-width: 160px;
+ flex-shrink: 0;
+ scroll-snap-align: start;
+}
+.challenge-winner-chip img {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-full);
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+/* Wochentag-Selector */
+.wd-selector {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+.wd-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ padding: 4px 10px;
+ border: 1.5px solid var(--c-border);
+ border-radius: var(--radius-full);
+ font-size: var(--text-sm);
+ user-select: none;
+ transition: background .15s, border-color .15s;
+}
+.wd-btn input { display: none; }
+.wd-btn:has(input:checked) {
+ background: var(--c-primary, #C4843A);
+ border-color: var(--c-primary, #C4843A);
+ color: #fff;
+}
+
+/* Gassi-Zeit-Karten */
+.gassi-zeit-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--c-border);
+ background: var(--c-surface);
+}
+.gz-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ flex-shrink: 0;
+ background: var(--c-surface-alt);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.gz-avatar img { width: 100%; height: 100%; object-fit: cover; }
+.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); }
+.gz-body { flex: 1; min-width: 0; }
+.gz-name {
+ font-weight: var(--weight-semibold);
+ font-size: var(--text-sm);
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ flex-wrap: wrap;
+}
+.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; }
+.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; }
+.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; }
+
+/* Rassen-Community-Chip */
+.breed-community-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ background: var(--c-surface-alt, #fdf6ef);
+ border: 1.5px solid var(--c-amber, #f59e0b);
+ border-radius: var(--radius-full);
+ padding: 6px 16px;
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ color: var(--c-text);
+ cursor: pointer;
+ transition: background .15s;
+}
+.breed-community-chip:hover, .breed-community-chip:active {
+ background: #fff3e0;
+}
diff --git a/backend/static/img/banyaro/fruehling_playdate.webp b/backend/static/img/banyaro/fruehling_playdate.webp
new file mode 100644
index 0000000..7defe9e
Binary files /dev/null and b/backend/static/img/banyaro/fruehling_playdate.webp differ
diff --git a/backend/static/img/banyaro/herbst_bach.webp b/backend/static/img/banyaro/herbst_bach.webp
new file mode 100644
index 0000000..5b7a594
Binary files /dev/null and b/backend/static/img/banyaro/herbst_bach.webp differ
diff --git a/backend/static/img/banyaro/herbst_baum.webp b/backend/static/img/banyaro/herbst_baum.webp
new file mode 100644
index 0000000..4ea4312
Binary files /dev/null and b/backend/static/img/banyaro/herbst_baum.webp differ
diff --git a/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg b/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg
new file mode 100644
index 0000000..eb4e55a
Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg differ
diff --git a/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg b/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg
new file mode 100644
index 0000000..aab4923
Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg differ
diff --git a/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg b/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg
new file mode 100644
index 0000000..d14e80d
Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg differ
diff --git a/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg b/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg
new file mode 100644
index 0000000..155d65b
Binary files /dev/null and b/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg differ
diff --git a/backend/static/img/banyaro/winter_schnee.webp b/backend/static/img/banyaro/winter_schnee.webp
new file mode 100644
index 0000000..87269d4
Binary files /dev/null and b/backend/static/img/banyaro/winter_schnee.webp differ
diff --git a/backend/static/index.html b/backend/static/index.html
index cb75a8f..242e2b7 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -9,7 +9,6 @@
-
@@ -76,6 +75,7 @@
+
@@ -93,9 +93,9 @@
-
-
-
+
+
+
@@ -499,6 +499,18 @@
+
+
+
+
+
+
@@ -539,9 +551,6 @@
HUND
WELT
-
@@ -565,12 +574,12 @@
-
+
-
+
@@ -667,5 +676,6 @@
}
+