diff --git a/backend/auth.py b/backend/auth.py
index 55c63fc..a4d5af3 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
- "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
+ "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,)
).fetchone()
diff --git a/backend/database.py b/backend/database.py
index eeb1add..5e38a96 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -540,6 +540,9 @@ def _migrate(conn_factory):
("pflege_tipps", "fell_pflege_art", "TEXT"),
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
+ # Tagebuch-Medien: Bildmaße für Querformat-Filter
+ ("diary_media", "img_width", "INTEGER"),
+ ("diary_media", "img_height", "INTEGER"),
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
("diary", "weather_json", "TEXT"),
("diary", "poi_json", "TEXT"),
@@ -568,6 +571,11 @@ def _migrate(conn_factory):
# Passwort-Zurücksetzen
("users", "password_reset_token", "TEXT"),
("users", "password_reset_expires", "TEXT"),
+ # Fell-Typ für personalisierte Wetter-Hinweise
+ ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt
+ # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz
+ ("tieraerzte", "avg_rating", "REAL DEFAULT 0"),
+ ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@@ -1923,6 +1931,44 @@ def _migrate(conn_factory):
)
""")
+ # Welten-Chip-Konfiguration pro User
+ existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
+ if 'world_config' not in existing_u:
+ conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT")
+
+ # Tagessprüche-Pool
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS daily_quotes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ text TEXT NOT NULL,
+ autor TEXT,
+ kategorie TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie);
+ """)
+
+ # Goldene Gassi-Stunde: User-Einstellung
+ existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
+ if 'gassi_stunde_push' not in existing_u:
+ conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0")
+ logger.info("Migration: users.gassi_stunde_push bereit.")
+
+ # Futter-Profil
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS futter_profil (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE,
+ futter_typ TEXT,
+ marke TEXT,
+ kcal_tag INTEGER,
+ portionen INTEGER DEFAULT 2,
+ notizen TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ """)
+ logger.info("Migration: futter_profil bereit.")
+
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
@@ -1940,3 +1986,75 @@ def _migrate(conn_factory):
);
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
""")
+
+ # ---- Tierarzt-Bewertungen ----
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS tierarzt_bewertungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5),
+ wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5),
+ freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5),
+ kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5),
+ text TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(tierarzt_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt
+ ON tierarzt_bewertungen(tierarzt_id);
+ """)
+
+ # ---- Feature: Foto-Challenge der Woche ----
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS foto_challenge (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ thema TEXT NOT NULL,
+ beschreibung TEXT,
+ start_date TEXT NOT NULL,
+ end_date TEXT NOT NULL,
+ created_by INTEGER REFERENCES users(id),
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+ CREATE TABLE IF NOT EXISTS challenge_submissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ foto_url TEXT NOT NULL,
+ caption TEXT,
+ votes INTEGER DEFAULT 0,
+ created_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(challenge_id, user_id)
+ );
+ CREATE TABLE IF NOT EXISTS challenge_votes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(submission_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal
+ ON challenge_submissions(challenge_id, created_at DESC);
+ """)
+ logger.info("Migration: Foto-Challenge-Tabellen bereit.")
+
+ # ---- Feature: Gassi-Zeiten-Pool ----
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS gassi_zeiten (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ wochentage TEXT NOT NULL,
+ uhrzeit TEXT NOT NULL,
+ ort_name TEXT,
+ lat REAL,
+ lon REAL,
+ radius_m INTEGER DEFAULT 500,
+ notiz TEXT,
+ aktiv INTEGER DEFAULT 1,
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user
+ ON gassi_zeiten(user_id, aktiv);
+ """)
+ logger.info("Migration: Gassi-Zeiten-Tabelle bereit.")
diff --git a/backend/main.py b/backend/main.py
index 229a856..1d23aef 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,9 +6,10 @@ import os
import html
import logging
from collections import deque
+import httpx
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
@@ -43,10 +44,43 @@ logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Startup / Shutdown
# ------------------------------------------------------------------
+def _backfill_image_sizes():
+ """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
+ import io
+ from database import db
+ from media_utils import get_image_size
+ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+ with db() as conn:
+ rows = conn.execute(
+ "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
+ ).fetchall()
+ if not rows:
+ return
+ logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
+ updated = 0
+ for row in rows:
+ # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
+ rel = row["url"].removeprefix("/media/")
+ path = os.path.join(MEDIA_DIR, rel)
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ size = get_image_size(data)
+ if size:
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
+ (size[0], size[1], row["id"])
+ )
+ updated += 1
+ except Exception:
+ pass
+ logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
+ _backfill_image_sizes()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
@@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
@@ -198,6 +232,7 @@ from routes.adoption import router as adoption_router
from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
+from routes.ernaehrung import router as ernaehrung_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
+app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
# ------------------------------------------------------------------
@@ -285,6 +321,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
+@app.get("/stats/script.js")
+async def umami_script_proxy():
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.get("https://umami.motocamp.de/script.js")
+ return Response(content=r.content, media_type="application/javascript",
+ headers={"Cache-Control": "public, max-age=86400"})
+
+@app.post("/stats/api/send")
+async def umami_send_proxy(request: Request):
+ body = await request.body()
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.post(
+ "https://umami.motocamp.de/api/send",
+ content=body,
+ headers={"Content-Type": "application/json",
+ "User-Agent": request.headers.get("user-agent", "")},
+ )
+ return Response(content=r.content, status_code=r.status_code,
+ media_type="application/json")
+
+
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py
index 1c0c1e8..0a2988e 100644
--- a/backend/routes/achievements.py
+++ b/backend/routes/achievements.py
@@ -92,6 +92,59 @@ CATEGORIES = [
("gold", 10, "Wiki-Fotograf"),
],
},
+ {
+ "id": "wetter_tapfer",
+ "name": "Wetter-Tapferkeit",
+ "emoji": "⛈️",
+ "metrik": "wetter_tapfer_score",
+ "einheit": " Eintrag/Einträge",
+ "stufen": [
+ ("bronze", 1, "Regentrotzdem"),
+ ("silber", 5, "Wettertrotzer"),
+ ("gold", 15, "Allwetter-Held"),
+ ("platin", 30, "Hunde-Wetterheld"),
+ ],
+ },
+ {
+ "id": "jahreszeiten",
+ "name": "Jahreszeiten-Erkunder",
+ "emoji": "🍃",
+ "metrik": "jahreszeiten_score",
+ "einheit": " Jahreszeit(en)",
+ "stufen": [
+ ("bronze", 1, "Frühlings-Erkunder"),
+ ("silber", 2, "Sommer-Genießer"),
+ ("gold", 3, "Herbst-Schnüffler"),
+ ("platin", 4, "Alle-Jahreszeiten"),
+ ],
+ },
+ {
+ "id": "schnee_held",
+ "name": "Schneeheld",
+ "emoji": "❄️",
+ "metrik": "schnee_eintraege",
+ "einheit": " Eintrag/Einträge",
+ "stufen": [
+ ("bronze", 1, "Erster Schnee"),
+ ("silber", 5, "Schneehund"),
+ ("gold", 15, "Schneeheld"),
+ ("platin", 30, "Schneewolf"),
+ ],
+ },
+ {
+ "id": "km_lebenswerk",
+ "name": "Kilometer-Lebenswerk",
+ "emoji": "🐾",
+ "metrik": "gesamt_km_lebenswerk",
+ "einheit": " km",
+ "icon": "path",
+ "stufen": [
+ ("bronze", 100, "100-km-Club"),
+ ("silber", 500, "500-km-Wanderer"),
+ ("gold", 1000, "Tausend-km-Held"),
+ ("platin", 5000, "Ultraläufer"),
+ ],
+ },
]
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
@@ -150,12 +203,48 @@ def check_and_award(user_id: int, conn):
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
+ # Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter
+ wetter_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.user_id = ?
+ AND d.weather_json IS NOT NULL
+ AND (
+ CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
+ OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
+ OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
+ )
+ """, (user_id,)).fetchone()
+
+ # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen
+ jahreszeiten_row = conn.execute("""
+ SELECT
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
+ AS jahreszeiten_score
+ FROM (SELECT 1)
+ """, (user_id, user_id, user_id, user_id)).fetchone()
+
+ # Schnee: Diary-Einträge bei Schnee (weathercode 71-77)
+ schnee_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary
+ WHERE user_id = ?
+ AND weather_json IS NOT NULL
+ AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
+ """, (user_id,)).fetchone()
+
metrics = {
- "total_km": stats["total_km"] if stats else 0,
- "routen": stats["routen"] if stats else 0,
- "pois": stats["pois"] if stats else 0,
- "streak": (streak_row["current_streak"] if streak_row else 0),
- "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "total_km": stats["total_km"] if stats else 0,
+ "routen": stats["routen"] if stats else 0,
+ "pois": stats["pois"] if stats else 0,
+ "streak": (streak_row["current_streak"] if streak_row else 0),
+ "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
+ "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
+ "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
+ "gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
}
earned = {r["badge_id"] for r in
@@ -211,6 +300,38 @@ async def my_achievements(user=Depends(get_current_user)):
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
).fetchone()
+ # Wetter-Tapferkeit
+ wetter_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.user_id = ?
+ AND d.weather_json IS NOT NULL
+ AND (
+ CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
+ OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
+ OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
+ )
+ """, (uid,)).fetchone()
+
+ # Jahreszeiten
+ jahreszeiten_row = conn.execute("""
+ SELECT
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
+ (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
+ AS jahreszeiten_score
+ FROM (SELECT 1)
+ """, (uid, uid, uid, uid)).fetchone()
+
+ # Schnee-Einträge
+ schnee_row = conn.execute("""
+ SELECT COUNT(*) AS cnt FROM diary
+ WHERE user_id = ?
+ AND weather_json IS NOT NULL
+ AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
+ """, (uid,)).fetchone()
+
earned_rows = conn.execute(
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
).fetchall()
@@ -230,11 +351,15 @@ async def my_achievements(user=Depends(get_current_user)):
""", (stats["punkte"] if stats else 0,)).fetchone()
metrics = {
- "total_km": stats["total_km"] if stats else 0,
- "routen": stats["routen"] if stats else 0,
- "pois": stats["pois"] if stats else 0,
- "streak": (streak_row["current_streak"] if streak_row else 0),
- "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "total_km": stats["total_km"] if stats else 0,
+ "routen": stats["routen"] if stats else 0,
+ "pois": stats["pois"] if stats else 0,
+ "streak": (streak_row["current_streak"] if streak_row else 0),
+ "wiki_fotos": stats["wiki_fotos"] if stats else 0,
+ "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
+ "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
+ "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
+ "gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
}
# Kategorien mit aktuellem Tier + Fortschritt aufbauen
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index a3dee2b..6f6cd12 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -9,7 +9,7 @@ from auth import get_current_user, require_admin
import ki as KI
import httpx
import weather as weather_mod
-from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from
+from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
from timeutils import safe_client_time
logger = logging.getLogger(__name__)
@@ -30,6 +30,7 @@ class DiaryCreate(BaseModel):
location_name: Optional[str] = None
is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
+ weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
class DiaryUpdate(BaseModel):
@@ -350,6 +351,19 @@ async def create_diary(dog_id: int, data: DiaryCreate,
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ elif data.weather_json:
+ # Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern
+ try:
+ json.loads(data.weather_json) # Validierung
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary SET weather_json=? WHERE id=?",
+ (data.weather_json, entry_id)
+ )
+ entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ except Exception as exc:
+ logger.warning("Client-weather_json ungültig: %s", exc)
+
return _entry_dict(entry, dogs_map, media_map)
@@ -692,10 +706,12 @@ async def upload_media(dog_id: int, entry_id: int,
media_url = f"/media/diary/{filename}"
- # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
- exif_gps = None
+ # Bildmaße + EXIF-GPS (nur bei Bilddateien)
+ exif_gps = None
+ img_size = None
if media_type == "image":
exif_gps = extract_gps_from_exif(raw_data)
+ img_size = get_image_size(raw_data)
with db() as conn:
# sort_order = nächste freie Position
@@ -706,8 +722,9 @@ async def upload_media(dog_id: int, entry_id: int,
# Erstes Item eines Eintrags wird automatisch Cover
is_cover = 1 if max_order == -1 else 0
conn.execute(
- "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
- (entry_id, media_url, media_type, max_order + 1, is_cover)
+ "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)",
+ (entry_id, media_url, media_type, max_order + 1, is_cover,
+ img_size[0] if img_size else None, img_size[1] if img_size else None)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index a44faa0..42b9b32 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Hund nicht gefunden.")
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
+ # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
photos = conn.execute(
"""SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id
WHERE d.dog_id=? AND dm.media_type='image'
- ORDER BY d.datum DESC LIMIT 100""",
+ AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
+ ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
+ # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
+ if not photos:
+ photos = conn.execute(
+ """SELECT dm.url FROM diary_media dm
+ JOIN diary d ON d.id = dm.diary_id
+ WHERE d.dog_id=? AND dm.media_type='image'
+ ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
+ (dog_id,)
+ ).fetchall()
random_photo = None
if photos:
import datetime as _dt2
- day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
- chosen_url = photos[day_num % len(photos)]["url"]
+ tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
+ chosen_url = photos[tick % len(photos)]["url"]
random_photo = {
"url": chosen_url,
"preview_url": preview_url_from(chosen_url),
@@ -294,6 +305,463 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
}
+@router.get("/{dog_id}/wrapped")
+async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)):
+ """Jahresrückblick ('Wrapped') für einen Hund."""
+ import json as _json
+ from datetime import date as _date
+
+ if year is None:
+ year = _date.today().year
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ # km gelaufen (eigene Routen des Users)
+ gesamt_km_row = conn.execute(
+ "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
+ "WHERE user_id=? AND strftime('%Y', created_at)=?",
+ (user["id"], str(year))
+ ).fetchone()
+ gesamt_km = gesamt_km_row["km"] or 0.0
+
+ # Gassi-Tage (Distinct Datum in Diary)
+ gassi_tage = conn.execute(
+ "SELECT COUNT(DISTINCT datum) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Gesamte Einträge
+ eintraege_gesamt = conn.execute(
+ "SELECT COUNT(*) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Fotos gesamt
+ fotos_gesamt = conn.execute(
+ "SELECT COUNT(*) AS n FROM diary_media dm "
+ "JOIN diary d ON d.id=dm.diary_id "
+ "WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Beste Route (längste distanz)
+ beste_route_row = conn.execute(
+ "SELECT MAX(distanz_km) AS km FROM routes "
+ "WHERE user_id=? AND strftime('%Y', created_at)=?",
+ (user["id"], str(year))
+ ).fetchone()
+ beste_route = beste_route_row["km"] or 0.0
+
+ # Lieblingsmonat (meiste diary-Einträge)
+ monat_rows = conn.execute(
+ "SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=? "
+ "GROUP BY monat ORDER BY n DESC LIMIT 1",
+ (dog_id, str(year))
+ ).fetchone()
+ lieblings_monat = None
+ if monat_rows:
+ _MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
+ try:
+ lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1]
+ except Exception:
+ pass
+
+ # Lieblingsaktivität (häufigster typ)
+ typ_row = conn.execute(
+ "SELECT typ, COUNT(*) AS n FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=? "
+ "GROUP BY typ ORDER BY n DESC LIMIT 1",
+ (dog_id, str(year))
+ ).fetchone()
+ lieblings_aktivitaet = typ_row["typ"] if typ_row else None
+
+ # Training-Sessions
+ training_sessions = conn.execute(
+ "SELECT COUNT(*) AS n FROM training_sessions "
+ "WHERE dog_id=? AND strftime('%Y', created_at)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Gesundheits-Einträge
+ gesundheit_eintraege = conn.execute(
+ "SELECT COUNT(*) AS n FROM health "
+ "WHERE dog_id=? AND strftime('%Y', datum)=?",
+ (dog_id, str(year))
+ ).fetchone()["n"]
+
+ # Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json
+ wetter_kalt = 0
+ wetter_warm = 0
+ wetter_rows = conn.execute(
+ "SELECT weather_json FROM diary "
+ "WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL",
+ (dog_id, str(year))
+ ).fetchall()
+ for wr in wetter_rows:
+ try:
+ wj = _json.loads(wr["weather_json"])
+ temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
+ if temp is not None:
+ if float(temp) < 5:
+ wetter_kalt += 1
+ elif float(temp) > 25:
+ wetter_warm += 1
+ except Exception:
+ pass
+
+ return {
+ "dog_id": dog_id,
+ "dog_name": dog["name"],
+ "year": year,
+ "gesamt_km": gesamt_km,
+ "gassi_tage": gassi_tage,
+ "eintraege_gesamt": eintraege_gesamt,
+ "fotos_gesamt": fotos_gesamt,
+ "beste_route": beste_route,
+ "lieblings_monat": lieblings_monat,
+ "lieblings_aktivitaet": lieblings_aktivitaet,
+ "training_sessions": training_sessions,
+ "gesundheit_eintraege": gesundheit_eintraege,
+ "wetter_kalt": wetter_kalt,
+ "wetter_warm": wetter_warm,
+ }
+
+
+@router.get("/{dog_id}/buch")
+async def get_hunde_buch(
+ dog_id: int,
+ jahr: int = None,
+ limit: int = 50,
+ nur_fotos: bool = False,
+ nur_meilensteine: bool = False,
+ user=Depends(get_current_user),
+):
+ """Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege."""
+ import json as _json
+ from datetime import date as _date
+ from fastapi.responses import HTMLResponse
+ from html import escape as _esc
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ dog = dict(dog)
+
+ # --- Eintraege laden ---
+ conditions = ["(d.dog_id=? OR dd.dog_id=?)"]
+ params: list = [dog_id, dog_id]
+
+ if jahr:
+ conditions.append("strftime('%Y', d.datum) = ?")
+ params.append(str(jahr))
+
+ if nur_meilensteine:
+ conditions.append("d.is_milestone = 1")
+
+ where = " AND ".join(conditions)
+
+ rows = conn.execute(
+ f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags,
+ d.gps_lat, d.gps_lon, d.location_name, d.weather_json,
+ d.is_milestone,
+ (SELECT dm.url FROM diary_media dm
+ WHERE dm.diary_id=d.id AND dm.media_type='image'
+ ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
+ FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE {where}
+ AND d.datum IS NOT NULL
+ ORDER BY d.datum ASC""",
+ params
+ ).fetchall()
+
+ rows = [dict(r) for r in rows]
+
+ # Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus
+ if nur_fotos:
+ rows = [r for r in rows if r.get("cover_url")]
+ else:
+ # Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit
+ with_photo = [r for r in rows if r.get("cover_url")]
+ milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")]
+ rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")]
+ rows = with_photo + milestones + rest
+ rows.sort(key=lambda r: r["datum"] or "")
+
+ rows = rows[:limit]
+
+ # --- Hund-Alter berechnen ---
+ alter_str = ""
+ if dog.get("geburtstag"):
+ try:
+ geb = _date.fromisoformat(dog["geburtstag"])
+ heute = _date.today()
+ jahre = (heute - geb).days // 365
+ alter_str = f"{jahre} Jahre"
+ except Exception:
+ pass
+
+ # --- HTML bauen ---
+ dog_name = _esc(dog["name"] or "Mein Hund")
+ rasse_str = _esc(dog.get("rasse") or "")
+ jahr_str = str(jahr) if jahr else "Alle Jahre"
+ foto_url = dog.get("foto_url") or ""
+
+ cover_img = (
+ f'
'
+ if foto_url else
+ f'
🐾
'
+ )
+
+ subtitle_parts = [p for p in [rasse_str, alter_str] if p]
+ subtitle = " · ".join(subtitle_parts)
+
+ _MONATE = ["Januar","Februar","März","April","Mai","Juni",
+ "Juli","August","September","Oktober","November","Dezember"]
+
+ def _fmt_datum(iso: str) -> str:
+ try:
+ d = _date.fromisoformat(iso)
+ return f"{d.day}. {_MONATE[d.month - 1]} {d.year}"
+ except Exception:
+ return iso or ""
+
+ def _wetter_chip(wj_str: str) -> str:
+ if not wj_str:
+ return ""
+ try:
+ wj = _json.loads(wj_str)
+ temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
+ if temp is None:
+ return ""
+ temp_i = int(float(temp))
+ emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅")
+ return f'{emoji} {temp_i}°C'
+ except Exception:
+ return ""
+
+ entries_html = ""
+ for e in rows:
+ milestone_class = "milestone" if e.get("is_milestone") else ""
+ datum_fmt = _fmt_datum(e.get("datum") or "")
+ titel = _esc(e.get("titel") or "")
+ text_raw = e.get("text") or ""
+ text = _esc(text_raw).replace("\n", "
")
+ wetter = _wetter_chip(e.get("weather_json") or "")
+ loc = _esc(e.get("location_name") or "")
+ cover = e.get("cover_url") or ""
+
+ foto_html = ""
+ if cover:
+ foto_html = (
+ f''
+ f'
})
'
+ f'
'
+ )
+
+ loc_html = f'📍 {loc}' if loc else ""
+ chips_html = f'{wetter}{loc_html}
' if (wetter or loc_html) else ""
+ titel_html = f'{titel}
' if titel else ""
+ text_html = f'{text}
' if text_raw else ""
+
+ entries_html += f"""
+
+ {foto_html}
+
{datum_fmt}
+ {titel_html}
+ {text_html}
+ {chips_html}
+
+"""
+
+ anzahl = len(rows)
+ html_page = f"""
+
+
+
+
+ Hunde-Buch — {dog_name}
+
+
+
+
+
+
+
+ {cover_img}
+
{dog_name}
+ {'
' + subtitle + '
' if subtitle else ''}
+
{jahr_str}
+
{anzahl} Einträge
+
+
+{entries_html}
+
+
+"""
+
+ return HTMLResponse(content=html_page)
+
+
@router.get("/{dog_id}")
async def get_dog(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
@@ -622,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
}
+
+
+# ------------------------------------------------------------------
+# LEBENS-TIMELINE
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/timeline")
+async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
+ """Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen."""
+ import json as _json
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ events = []
+
+ with db() as conn:
+ # --- Tagebuch ---
+ diary_rows = conn.execute(
+ """SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone,
+ dm.url AS foto_url
+ FROM diary d
+ LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0
+ WHERE d.dog_id=?
+ ORDER BY d.datum ASC, d.id ASC""",
+ (dog_id,)
+ ).fetchall()
+
+ for i, r in enumerate(diary_rows):
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "tagebuch",
+ "titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()),
+ "typ": r["typ"],
+ "is_first": i == 0,
+ "is_milestone": bool(r["is_milestone"]),
+ "foto_url": r["foto_url"],
+ "ref_id": r["id"],
+ })
+
+ # --- Gesundheit ---
+ health_rows = conn.execute(
+ """SELECT id, datum, bezeichnung, typ
+ FROM health
+ WHERE dog_id=?
+ ORDER BY datum ASC, id ASC""",
+ (dog_id,)
+ ).fetchall()
+
+ typ_seen = {}
+ for r in health_rows:
+ t = r["typ"]
+ is_first = t not in typ_seen
+ if is_first:
+ typ_seen[t] = True
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "gesundheit",
+ "titel": r["bezeichnung"],
+ "typ": t,
+ "is_first": is_first,
+ "is_milestone": False,
+ "foto_url": None,
+ "ref_id": r["id"],
+ })
+
+ # --- Training-Sessions ---
+ ts_rows = conn.execute(
+ """SELECT id, datum, exercise_name, erfolgsquote, ist_top
+ FROM training_sessions
+ WHERE dog_id=? AND user_id=?
+ ORDER BY datum ASC, id ASC""",
+ (dog_id, user["id"])
+ ).fetchall()
+
+ ts_first = True
+ ts_best = None
+ ts_best_score = -1
+ for r in ts_rows:
+ if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score:
+ ts_best_score = r["erfolgsquote"]
+ ts_best = r
+
+ for i, r in enumerate(ts_rows):
+ is_first = (i == 0)
+ is_best = ts_best and r["id"] == ts_best["id"] and i > 0
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "training",
+ "titel": r["exercise_name"],
+ "typ": "training",
+ "is_first": is_first,
+ "is_milestone": bool(r["ist_top"]) or is_best,
+ "foto_url": None,
+ "ref_id": r["id"],
+ })
+
+ # --- Routen ---
+ route_rows = conn.execute(
+ """SELECT id, name, distanz_km,
+ date(created_at) AS datum
+ FROM routes
+ WHERE user_id=?
+ ORDER BY created_at ASC""",
+ (user["id"],)
+ ).fetchall()
+
+ route_first = True
+ route_longest = None
+ route_max_km = -1
+ for r in route_rows:
+ km = r["distanz_km"] or 0
+ if km > route_max_km:
+ route_max_km = km
+ route_longest = r
+
+ for i, r in enumerate(route_rows):
+ is_first = (i == 0)
+ is_longest = route_longest and r["id"] == route_longest["id"] and i > 0
+ events.append({
+ "datum": r["datum"],
+ "kategorie": "route",
+ "titel": r["name"],
+ "typ": "route",
+ "is_first": is_first,
+ "is_milestone": is_longest,
+ "foto_url": None,
+ "ref_id": r["id"],
+ "distanz_km": r["distanz_km"],
+ })
+
+ # Geburtstag des Hundes als erster Eintrag
+ if dog["geburtstag"]:
+ events.append({
+ "datum": dog["geburtstag"],
+ "kategorie": "meilenstein",
+ "titel": f"{dog['name']} wird geboren",
+ "typ": "geburtstag",
+ "is_first": True,
+ "is_milestone": True,
+ "foto_url": None,
+ "ref_id": None,
+ })
+
+ # Chronologisch sortieren
+ events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"]))
+
+ return {
+ "dog_name": dog["name"],
+ "geburtstag": dog["geburtstag"],
+ "events": events,
+ }
diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py
new file mode 100644
index 0000000..c1f850e
--- /dev/null
+++ b/backend/routes/ernaehrung.py
@@ -0,0 +1,145 @@
+"""BAN YARO — Ernährungs-Routes"""
+
+import logging
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+import ki as ki_module
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class FutterProfilUpdate(BaseModel):
+ futter_typ: Optional[str] = None # trocken|nass|barf|mix
+ marke: Optional[str] = None
+ kcal_tag: Optional[int] = None
+ portionen: Optional[int] = None
+ notizen: Optional[str] = None
+
+
+class KiBeratungRequest(BaseModel):
+ frage: str
+ dog_name: Optional[str] = None
+ rasse: Optional[str] = None
+ alter: Optional[str] = None
+ gewicht: Optional[float] = None
+ aktiv: Optional[bool] = None
+
+
+# ------------------------------------------------------------------
+# Hilfsfunktion: Zugriffsprüfung
+# ------------------------------------------------------------------
+def _check_dog_access(conn, dog_id: int, user_id: int):
+ row = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+
+# ------------------------------------------------------------------
+# GET /dogs/{dog_id}/ernaehrung
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/ernaehrung")
+async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ row = conn.execute(
+ "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ if not row:
+ return {}
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# PUT /dogs/{dog_id}/ernaehrung
+# ------------------------------------------------------------------
+@router.put("/{dog_id}/ernaehrung")
+async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ existing = conn.execute(
+ "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ if existing:
+ conn.execute("""
+ UPDATE futter_profil
+ SET futter_typ=COALESCE(?, futter_typ),
+ marke=COALESCE(?, marke),
+ kcal_tag=COALESCE(?, kcal_tag),
+ portionen=COALESCE(?, portionen),
+ notizen=COALESCE(?, notizen),
+ updated_at=datetime('now')
+ WHERE dog_id=?
+ """, (body.futter_typ, body.marke, body.kcal_tag,
+ body.portionen, body.notizen, dog_id))
+ else:
+ conn.execute("""
+ INSERT INTO futter_profil
+ (dog_id, futter_typ, marke, kcal_tag, portionen, notizen)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (dog_id, body.futter_typ, body.marke, body.kcal_tag,
+ body.portionen or 2, body.notizen))
+ row = conn.execute(
+ "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# POST /dogs/{dog_id}/ernaehrung/ki-beratung
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/ernaehrung/ki-beratung")
+async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
+ request: Request,
+ user=Depends(get_current_user)):
+ if not body.frage or len(body.frage.strip()) < 3:
+ raise HTTPException(400, "Bitte stelle eine Frage.")
+ if len(body.frage) > 800:
+ raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).")
+
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+
+ dog_name = body.dog_name or "unbekannt"
+ rasse = body.rasse or "unbekannt"
+ alter = body.alter or "unbekannt"
+ gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt"
+ aktiv_str = "aktiv" if body.aktiv else "normal aktiv"
+
+ system = (
+ "Du bist Ernährungsberater für Hunde. "
+ "Antworte immer auf Deutsch, kurz und praktisch. "
+ "Keine unnötigen Füllsätze. "
+ "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. "
+ "Stelle keine medizinischen Diagnosen."
+ )
+
+ prompt = (
+ f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, "
+ f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n"
+ f"Frage: {body.frage.strip()}\n\n"
+ "Antworte konkret und praktisch, maximal 200 Wörter."
+ )
+
+ try:
+ antwort = await ki_module.complete(
+ prompt=prompt,
+ system=system,
+ max_tokens=500,
+ requires_premium=False,
+ user_id=user["id"],
+ )
+ return {"antwort": antwort}
+ except ki_module.KIUnavailableError as e:
+ raise HTTPException(503, str(e))
+ except Exception:
+ raise HTTPException(500, "KI momentan nicht verfügbar.")
diff --git a/backend/routes/osm.py b/backend/routes/osm.py
index e08742b..de4cb45 100644
--- a/backend/routes/osm.py
+++ b/backend/routes/osm.py
@@ -279,8 +279,9 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
- 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
+ 'giftkoeder', # Giftköder (exklusiv, kein Kombi)
'kotbeutel', # Kotbeutelspender
+ 'bank', # Sitzbank
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
@@ -289,7 +290,8 @@ ALLOWED_TYPES = {
@router.post('/user-poi')
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
- if body.type not in ALLOWED_TYPES:
+ types = [t.strip() for t in body.type.split(',') if t.strip()]
+ if not types or any(t not in ALLOWED_TYPES for t in types):
raise HTTPException(400, 'Ungültiger Typ')
with db() as conn:
row = conn.execute("""
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index 08f403a..9762f95 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -26,6 +26,7 @@ class ProfileUpdate(BaseModel):
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
+ gassi_stunde_push: Optional[int] = None
def _load_user(user_id: int) -> dict:
@@ -113,3 +114,28 @@ async def upload_avatar(
)
return {"avatar_url": avatar_url}
+
+
+# ----------------------------------------------------------
+# GET /profile/world-config — Welten-Chip-Konfiguration laden
+# PUT /profile/world-config — Welten-Chip-Konfiguration speichern
+# ----------------------------------------------------------
+import json as _json
+
+@router.get('/world-config')
+async def get_world_config(user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone()
+ cfg = row['world_config'] if row and row['world_config'] else None
+ return {"config": _json.loads(cfg) if cfg else None}
+
+
+class WorldConfigIn(BaseModel):
+ config: dict
+
+@router.put('/world-config')
+async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
+ with db() as conn:
+ conn.execute("UPDATE users SET world_config=? WHERE id=?",
+ (_json.dumps(body.config), user['id']))
+ return {"status": "ok"}
diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py
index 48287f9..8448478 100644
--- a/backend/routes/tieraerzte.py
+++ b/backend/routes/tieraerzte.py
@@ -27,6 +27,14 @@ class TierarztCreate(BaseModel):
osm_id: Optional[str] = None
+class BewertungCreate(BaseModel):
+ gesamt: int
+ wartezeit: Optional[int] = None
+ freundlichkeit: Optional[int] = None
+ kompetenz: Optional[int] = None
+ text: Optional[str] = None
+
+
class TierarztUpdate(BaseModel):
name: Optional[str] = None
strasse: Optional[str] = None
@@ -220,3 +228,109 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate,
)
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
return dict(row)
+
+
+# ------------------------------------------------------------------
+# BEWERTUNGEN
+# ------------------------------------------------------------------
+
+def _refresh_vet_rating(conn, tierarzt_id: int):
+ """Aktualisiert avg_rating und anz_bewertungen in tieraerzte."""
+ row = conn.execute(
+ """SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg
+ FROM tierarzt_bewertungen WHERE tierarzt_id=?""",
+ (tierarzt_id,)
+ ).fetchone()
+ n = row["n"] or 0
+ avg = row["avg"] or 0.0
+ conn.execute(
+ "UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?",
+ (round(avg, 1), n, tierarzt_id)
+ )
+
+
+@router.post("/{tierarzt_id}/bewertung", status_code=201)
+async def create_bewertung(tierarzt_id: int, data: BewertungCreate,
+ user=Depends(get_current_user)):
+ """Bewertung abgeben (1×pro User+Tierarzt, UPSERT)."""
+ if not (1 <= data.gesamt <= 5):
+ raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.")
+ for field in ("wartezeit", "freundlichkeit", "kompetenz"):
+ val = getattr(data, field)
+ if val is not None and not (1 <= val <= 5):
+ raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.")
+
+ text = (data.text or "").strip()[:500] or None
+
+ with db() as conn:
+ vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
+ if not vet:
+ raise HTTPException(404, "Tierarzt nicht gefunden.")
+
+ conn.execute(
+ """INSERT INTO tierarzt_bewertungen
+ (tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text)
+ VALUES (?,?,?,?,?,?,?)
+ ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET
+ gesamt=excluded.gesamt,
+ wartezeit=excluded.wartezeit,
+ freundlichkeit=excluded.freundlichkeit,
+ kompetenz=excluded.kompetenz,
+ text=excluded.text,
+ created_at=datetime('now')""",
+ (tierarzt_id, user["id"], data.gesamt, data.wartezeit,
+ data.freundlichkeit, data.kompetenz, text)
+ )
+ _refresh_vet_rating(conn, tierarzt_id)
+ row = conn.execute(
+ "SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)
+ ).fetchone()
+ return dict(row)
+
+
+@router.get("/{tierarzt_id}/bewertungen")
+async def list_bewertungen(tierarzt_id: int):
+ """Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte."""
+ with db() as conn:
+ vet = conn.execute(
+ "SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?",
+ (tierarzt_id,)
+ ).fetchone()
+ if not vet:
+ raise HTTPException(404, "Tierarzt nicht gefunden.")
+
+ # Stern-Verteilung
+ verteilung = {}
+ for star in range(1, 6):
+ r = conn.execute(
+ "SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?",
+ (tierarzt_id, star)
+ ).fetchone()
+ verteilung[str(star)] = r["n"]
+
+ # Letzte 5 Kommentare
+ kommentare = conn.execute(
+ """SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at
+ FROM tierarzt_bewertungen
+ WHERE tierarzt_id=? AND text IS NOT NULL AND text != ''
+ ORDER BY created_at DESC LIMIT 5""",
+ (tierarzt_id,)
+ ).fetchall()
+
+ return {
+ "avg_rating": vet["avg_rating"] or 0,
+ "anz_bewertungen": vet["anz_bewertungen"] or 0,
+ "verteilung": verteilung,
+ "kommentare": [dict(k) for k in kommentare],
+ }
+
+
+@router.get("/{tierarzt_id}/meine-bewertung")
+async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)):
+ """Eigene Bewertung für einen Tierarzt (oder null)."""
+ with db() as conn:
+ row = conn.execute(
+ "SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?",
+ (tierarzt_id, user["id"])
+ ).fetchone()
+ return dict(row) if row else None
diff --git a/backend/routes/weather.py b/backend/routes/weather.py
index fced719..ba45306 100644
--- a/backend/routes/weather.py
+++ b/backend/routes/weather.py
@@ -3,9 +3,11 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
+import json
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
from auth import get_current_user
+from database import db
router = APIRouter()
@@ -31,3 +33,57 @@ async def get_weather_forecast(
return await weather_module.get_forecast(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')
+
+
+@router.get('/records')
+async def weather_records(user=Depends(get_current_user)):
+ """Persönliche Wetterrekorde aus diary-Einträgen mit weather_json."""
+ uid = user["id"]
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT d.datum, d.weather_json, d.titel
+ FROM diary d
+ WHERE d.user_id = ? AND d.weather_json IS NOT NULL
+ ORDER BY d.datum ASC
+ """, (uid,)).fetchall()
+
+ if not rows:
+ return {"records": None}
+
+ entries = []
+ for r in rows:
+ try:
+ w = json.loads(r["weather_json"])
+ entries.append({
+ "datum": r["datum"],
+ "titel": r["titel"],
+ "temp_c": w.get("temp_c"),
+ "wind_kmh": w.get("wind_kmh"),
+ "precip_prob": w.get("precip_prob"),
+ "desc": w.get("desc", ""),
+ "weathercode": w.get("weathercode"),
+ })
+ except Exception:
+ pass
+
+ if not entries:
+ return {"records": None}
+
+ temps = [e for e in entries if e["temp_c"] is not None]
+ winds = [e for e in entries if e["wind_kmh"] is not None]
+
+ records = {}
+ if temps:
+ kaeltester = min(temps, key=lambda e: e["temp_c"])
+ heissester = max(temps, key=lambda e: e["temp_c"])
+ records["kaeltester"] = kaeltester
+ records["heissester"] = heissester
+ if winds:
+ stuermischster = max(winds, key=lambda e: e["wind_kmh"])
+ records["stuermischster"] = stuermischster
+
+ regen_count = sum(1 for e in entries if (e.get("precip_prob") or 0) > 60)
+ records["regen_eintraege"] = regen_count
+ records["gesamt_eintraege"] = len(entries)
+
+ return {"records": records}
diff --git a/backend/routes/widget.py b/backend/routes/widget.py
index f5cc940..4af2473 100644
--- a/backend/routes/widget.py
+++ b/backend/routes/widget.py
@@ -1,13 +1,33 @@
-"""BAN YARO — Widget-Snapshot Endpoint"""
+"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
import json, random
-from fastapi import APIRouter, Depends
+from datetime import date
+from fastapi import APIRouter, Depends, Query
+from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
+@router.get("/quote")
+async def daily_quote(kategorie: Optional[str] = Query(None)):
+ """Liefert einen deterministischen Tagesspruch (wechselt täglich)."""
+ day_num = (date.today() - date(2026, 1, 1)).days
+ with db() as conn:
+ if kategorie:
+ rows = conn.execute(
+ "SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?",
+ (kategorie,)
+ ).fetchall()
+ else:
+ rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall()
+ if not rows:
+ return {"quote": None}
+ q = rows[day_num % len(rows)]
+ return {"quote": dict(q)}
+
+
@router.get("/snapshot")
async def widget_snapshot(user=Depends(get_current_user)):
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""
diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py
index 45f5bfb..56df55d 100644
--- a/backend/routes/wiki.py
+++ b/backend/routes/wiki.py
@@ -414,7 +414,7 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
raise HTTPException(404, "Einreichung nicht gefunden.")
rasse = conn.execute(
- "SELECT id, external_id, slug FROM wiki_rassen WHERE id=?",
+ "SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?",
(sub["rasse_id"],)
).fetchone()
diff --git a/backend/scheduler.py b/backend/scheduler.py
index 4aeb89a..4d1dbff 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -156,8 +156,32 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
+ # Täglich 07:00 Uhr — Goldene Gassi-Stunde
+ _scheduler.add_job(
+ _job_golden_gassi_hour,
+ CronTrigger(hour=7, minute=0),
+ id="golden_gassi_hour",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren)
+ _scheduler.add_job(
+ _job_anniversary_reminders,
+ CronTrigger(hour=9, minute=0),
+ id="anniversary_reminders",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # 1. des Monats 10:00 — Monatlicher Rückblick per Push
+ _scheduler.add_job(
+ _job_monthly_recap,
+ CronTrigger(day=1, hour=10, minute=0),
+ id="monthly_recap",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
_scheduler.start()
- logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
+ logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@@ -881,6 +905,9 @@ async def _job_status_report():
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
+ "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)",
+ "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)",
+ "monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)",
}
job_rows_html = ""
job_rows_txt = ""
@@ -1288,3 +1315,329 @@ async def _job_recurring_expenses():
except Exception as e:
logger.error(f"Daueraufträge-Job Fehler: {e}")
_log_job("recurring_expenses", "error", str(e))
+
+
+# ------------------------------------------------------------------
+# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr)
+# ------------------------------------------------------------------
+async def _job_golden_gassi_hour():
+ """
+ Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1)
+ das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification.
+
+ Score-Logik pro Stunde (max. 10 Punkte):
+ - Temperatur 10–20°C → +3
+ - Temperatur 5–10°C → +1
+ - Niederschlagswahrsch. <20% → +3, <40% → +1
+ - Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1
+ - Stunden 07–19 Uhr (Tageslicht) → +2
+ Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden).
+ """
+ import httpx
+ from datetime import date as _date
+
+ logger.info("Goldene-Gassi-Stunde Job läuft")
+
+ # Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription
+ with db() as conn:
+ users = conn.execute("""
+ SELECT DISTINCT u.id AS user_id,
+ ps.last_lat, ps.last_lon
+ FROM users u
+ JOIN push_subscriptions ps ON ps.user_id = u.id
+ WHERE u.gassi_stunde_push = 1
+ """).fetchall()
+
+ users = [dict(u) for u in users]
+ logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.")
+
+ if not users:
+ _log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv")
+ return
+
+ sent_total = 0
+
+ for u in users:
+ lat = u["last_lat"] or 48.1351 # Fallback: München
+ lon = u["last_lon"] or 11.5820
+
+ try:
+ hourly = await _fetch_hourly_weather(lat, lon)
+ except Exception as e:
+ logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}")
+ continue
+
+ if not hourly:
+ continue
+
+ best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly)
+
+ if best_score < 3:
+ # Heute kein gutes Wetterfenster → kein Push
+ logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})")
+ continue
+
+ hour_end = (best_start + 2) % 24
+ temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–"
+ wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else (
+ f"{best_wind:.0f} km/h Wind" if best_wind is not None else "")
+
+ body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr",
+ f"· {temp_str}"]
+ if wind_str:
+ body_parts.append(f"· {wind_str}")
+
+ sent = send_push_to_user(u["user_id"], {
+ "type": "golden_gassi_hour",
+ "title": "☀️ Goldene Gassi-Stunde heute!",
+ "body": " ".join(body_parts),
+ "data": {"page": "wetter"},
+ "tag": f"gassi-{_date.today()}",
+ })
+ sent_total += sent
+ logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}")
+
+ logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.")
+ _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User")
+
+
+# ------------------------------------------------------------------
+# JOB: Jahrestags-Erinnerungen (täglich 09:00)
+# ------------------------------------------------------------------
+async def _job_anniversary_reminders():
+ """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push."""
+ today = datetime.now(tz=_TZ)
+ today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr
+
+ logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
+
+ with db() as conn:
+ entries = conn.execute("""
+ SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
+ (SELECT dm.url FROM diary_media dm
+ WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
+ FROM diary d
+ WHERE strftime('%m-%d', d.datum) = ?
+ AND d.datum < date('now')
+ AND d.titel IS NOT NULL
+ AND d.is_milestone = 0
+ """, (today_md,)).fetchall()
+
+ sent_total = 0
+ for e in entries:
+ try:
+ jahre = today.year - int(e['datum'][:4])
+ if jahre < 1:
+ continue
+ jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren"
+ send_push_to_user(e['user_id'], {
+ 'type': 'anniversary_reminder',
+ 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}',
+ 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund',
+ 'data': {'page': 'diary'},
+ 'tag': f'anniversary-{e["id"]}-{today.year}',
+ })
+ sent_total += 1
+ except Exception as ex:
+ logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}")
+
+ logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.")
+ _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen")
+
+
+# ------------------------------------------------------------------
+# JOB: Monatlicher Rückblick (1. des Monats 10:00)
+# ------------------------------------------------------------------
+async def _job_monthly_recap():
+ """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats."""
+ today = datetime.now(tz=_TZ)
+ first_this = today.replace(day=1)
+ last_month_end = first_this - timedelta(days=1)
+ last_month_start = last_month_end.replace(day=1)
+ year_str = last_month_start.strftime('%Y')
+ month_str = last_month_start.strftime('%m')
+ month_label = last_month_start.strftime('%B %Y')
+
+ logger.info(f"Monatlicher Rückblick Job läuft für {month_label}")
+
+ with db() as conn:
+ # Alle User mit mindestens einem Hund
+ users = conn.execute(
+ "SELECT DISTINCT user_id FROM dogs"
+ ).fetchall()
+
+ sent_total = 0
+ for u in users:
+ user_id = u["user_id"]
+ try:
+ with db() as conn:
+ # Hunde des Users
+ dog_rows = conn.execute(
+ "SELECT id, name FROM dogs WHERE user_id=?", (user_id,)
+ ).fetchall()
+ if not dog_rows:
+ continue
+
+ dog_ids = [d["id"] for d in dog_rows]
+ placeholders = ','.join('?' * len(dog_ids))
+
+ # km (Routen des Users im Vormonat)
+ km_row = conn.execute(
+ "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
+ "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
+ (user_id, year_str, month_str)
+ ).fetchone()
+ gesamt_km = km_row["km"] or 0.0
+
+ # Tagebucheinträge
+ eintraege = conn.execute(
+ f"SELECT COUNT(*) AS n FROM diary "
+ f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?",
+ (*dog_ids, year_str, month_str)
+ ).fetchone()["n"]
+
+ # Training-Sessions
+ training = conn.execute(
+ f"SELECT COUNT(*) AS n FROM training_sessions "
+ f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
+ (*dog_ids, year_str, month_str)
+ ).fetchone()["n"]
+
+ # Lieblingsfoto (erstes Foto im Vormonat)
+ foto_row = conn.execute(
+ f"SELECT dm.url FROM diary_media dm "
+ f"JOIN diary d ON d.id=dm.diary_id "
+ f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' "
+ f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? "
+ f"ORDER BY d.datum ASC LIMIT 1",
+ (*dog_ids, year_str, month_str)
+ ).fetchone()
+ foto_url = foto_row["url"] if foto_row else None
+
+ # Nur senden wenn mindestens eine Aktivität vorhanden
+ if eintraege == 0 and training == 0 and gesamt_km == 0:
+ continue
+
+ dog_name = dog_rows[0]["name"]
+ parts = []
+ if gesamt_km > 0:
+ parts.append(f"{gesamt_km} km gelaufen")
+ if eintraege > 0:
+ parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge")
+ if training > 0:
+ parts.append(f"{training} Training-Sessions")
+
+ body_text = " · ".join(parts)
+
+ send_push_to_user(user_id, {
+ 'type': 'monthly_recap',
+ 'title': f'📅 {month_label}: Rückblick für {dog_name}',
+ 'body': body_text,
+ 'data': {'page': 'diary'},
+ 'tag': f'monthly-recap-{year_str}-{month_str}',
+ })
+ sent_total += 1
+ except Exception as ex:
+ logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}")
+
+ logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.")
+ _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
+
+
+async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
+ """Holt stündliche Wetterdaten für heute von Open-Meteo."""
+ import httpx
+ from datetime import date as _date
+
+ today = _date.today().isoformat()
+ url = (
+ "https://api.open-meteo.com/v1/forecast"
+ f"?latitude={lat}&longitude={lon}"
+ "&hourly=temperature_2m,precipitation_probability,windspeed_10m"
+ "&timezone=Europe%2FBerlin&forecast_days=1"
+ )
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ raw = resp.json()
+
+ hourly = raw.get("hourly", {})
+ times = hourly.get("time", [])
+ temps = hourly.get("temperature_2m", [])
+ precips = hourly.get("precipitation_probability", [])
+ winds = hourly.get("windspeed_10m", [])
+
+ result = []
+ for i, ts in enumerate(times):
+ if not ts.startswith(today):
+ continue
+ hour = int(ts[11:13])
+ result.append({
+ "hour": hour,
+ "temp": temps[i] if i < len(temps) else None,
+ "precip": precips[i] if i < len(precips) else None,
+ "wind": winds[i] if i < len(winds) else None,
+ })
+ return result
+
+
+def _score_hour(h: dict) -> int:
+ """Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte)."""
+ score = 0
+ temp = h.get("temp")
+ precip = h.get("precip")
+ wind = h.get("wind")
+ hour = h.get("hour", 12)
+
+ # Temperatur
+ if temp is not None:
+ if 10 <= temp <= 20:
+ score += 3
+ elif 5 <= temp < 10 or 20 < temp <= 25:
+ score += 1
+
+ # Niederschlagswahrscheinlichkeit
+ if precip is not None:
+ if precip < 20:
+ score += 3
+ elif precip < 40:
+ score += 1
+
+ # Wind
+ if wind is not None:
+ if wind < 20:
+ score += 2
+ elif wind < 30:
+ score += 1
+
+ # Tageslicht (07–19 Uhr)
+ if 7 <= hour <= 19:
+ score += 2
+
+ return score
+
+
+def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]:
+ """
+ Findet das beste aufeinanderfolgende 2h-Fenster.
+ Gibt (start_hour, total_score, avg_temp, avg_wind) zurück.
+ """
+ best_start = 8
+ best_score = -1
+ best_temp = None
+ best_wind = None
+
+ for i in range(len(hourly) - 1):
+ h1 = hourly[i]
+ h2 = hourly[i + 1]
+ combined = _score_hour(h1) + _score_hour(h2)
+ if combined > best_score:
+ best_score = combined
+ best_start = h1["hour"]
+ # Durchschnittswerte für Anzeige
+ temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None]
+ winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None]
+ best_temp = sum(temps) / len(temps) if temps else None
+ best_wind = sum(winds) / len(winds) if winds else None
+
+ return best_start, best_score, best_temp, best_wind
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 27cf0d9..c8de6a6 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -5571,6 +5571,139 @@ html.modal-open {
border-radius: 0;
}
+/* ── Wiki Gallery ────────────────────────────────────────── */
+.wiki-gallery-wrap {
+ position: relative;
+ margin-bottom: var(--space-3);
+}
+.wiki-gallery-main {
+ width: 100%;
+ height: 240px;
+ object-fit: cover;
+ object-position: center top;
+ border-radius: var(--radius-lg);
+ display: block;
+}
+.wiki-gallery-strip {
+ display: flex;
+ gap: var(--space-2);
+ overflow-x: auto;
+ padding: var(--space-2) 0 0;
+ scrollbar-width: none;
+}
+.wiki-gallery-strip::-webkit-scrollbar { display: none; }
+.wiki-gallery-thumb {
+ flex-shrink: 0;
+ width: 64px; height: 64px;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 2px solid transparent;
+ padding: 0;
+ background: none;
+ cursor: pointer;
+ position: relative;
+ transition: border-color .15s;
+}
+.wiki-gallery-thumb.active { border-color: var(--c-primary); }
+.wiki-gallery-thumb img {
+ width: 100%; height: 100%; object-fit: cover;
+}
+.wiki-gallery-thumb-label {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ background: rgba(0,0,0,.55);
+ color: #fff;
+ font-size: 8px;
+ padding: 2px 4px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.wiki-gallery-expand {
+ position: absolute;
+ top: var(--space-2);
+ right: var(--space-2);
+ width: 34px; height: 34px;
+ border-radius: 50%;
+ background: rgba(0,0,0,.45);
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(4px);
+ transition: background .15s;
+}
+.wiki-gallery-expand:hover { background: rgba(0,0,0,.65); }
+
+/* ── Wiki Lightbox ───────────────────────────────────────── */
+#wiki-lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.wlb-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0,0,0,.88);
+ backdrop-filter: blur(6px);
+}
+.wlb-content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ max-width: min(92vw, 680px);
+ width: 100%;
+ gap: var(--space-2);
+}
+.wlb-img {
+ width: 100%;
+ max-height: 72vh;
+ object-fit: contain;
+ border-radius: var(--radius-lg);
+}
+.wlb-close {
+ position: absolute;
+ top: -44px;
+ right: 0;
+ background: rgba(255,255,255,.12);
+ border: none;
+ color: #fff;
+ width: 36px; height: 36px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.wlb-prev, .wlb-next {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(255,255,255,.12);
+ border: none;
+ color: #fff;
+ width: 40px; height: 40px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.4rem;
+ transition: background .15s;
+}
+.wlb-prev { left: -48px; }
+.wlb-next { right: -48px; }
+.wlb-prev:hover, .wlb-next:hover { background: rgba(255,255,255,.25); }
+.wlb-caption { color: rgba(255,255,255,.75); font-size: var(--text-sm); }
+.wlb-counter { color: rgba(255,255,255,.45); font-size: var(--text-xs); }
+
/* Steckbrief-Grid */
.wiki-steckbrief-grid {
display: grid;
@@ -6550,6 +6683,97 @@ html.modal-open {
/* ============================================================
HELP TOOLTIP
============================================================ */
+/* ============================================================
+ PAGE INFO — generische Seiten-Hilfe (UI.pageInfo)
+ ============================================================ */
+.pinfo-trigger-inline {
+ width: 26px; height: 26px;
+ border-radius: 50%;
+ background: var(--c-surface-2);
+ border: 1px solid var(--c-border-light);
+ color: var(--c-text-secondary);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background .15s, color .15s;
+}
+.pinfo-trigger-inline:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); }
+
+.pinfo-banner {
+ margin: var(--space-3) var(--space-4) 0;
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-lg);
+ background: var(--c-surface-2);
+ border-left: 3px solid var(--c-primary);
+ font-size: var(--text-sm);
+}
+.pinfo-banner-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+}
+.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; }
+.pinfo-banner-title {
+ flex: 1;
+ font-weight: var(--weight-semibold);
+ color: var(--c-text);
+}
+.pinfo-banner-close {
+ background: none; border: none; cursor: pointer;
+ color: var(--c-text-muted); padding: 2px;
+}
+.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; }
+.pinfo-banner-more {
+ background: none; border: none; cursor: pointer;
+ color: var(--c-primary);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-medium);
+ padding: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: var(--space-2);
+}
+
+/* MODAL BODY */
+.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); }
+.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; }
+.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); }
+.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); }
+.pinfo-step {
+ display: flex;
+ gap: var(--space-3);
+ align-items: flex-start;
+}
+.pinfo-step-icon {
+ width: 32px; height: 32px;
+ border-radius: var(--radius-md);
+ background: var(--c-primary-subtle, rgba(196,132,58,.12));
+ color: var(--c-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; }
+.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; }
+.pinfo-tip {
+ display: flex;
+ gap: var(--space-2);
+ align-items: flex-start;
+ padding: var(--space-3);
+ background: rgba(196,132,58,.08);
+ border-radius: var(--radius-md);
+ color: var(--c-text-secondary);
+ font-size: var(--text-sm);
+ line-height: 1.5;
+}
+.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; }
+
+
.by-help-btn {
display: inline-flex;
align-items: center;
@@ -6954,7 +7178,7 @@ svg.empty-state-icon {
/* FAB */
.exp-fab {
position: fixed;
- bottom: calc(var(--nav-height, 64px) + var(--space-4));
+ bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2));
right: var(--space-4);
z-index: 100;
width: 52px;
@@ -7600,10 +7824,28 @@ svg.empty-state-icon {
.wlabel.active { opacity: 1; }
@media (min-width: 768px) {
- #world-labels { gap: 48px; font-size: 11px; }
- .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; }
- .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); }
- .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); }
+ #world-labels {
+ gap: 40px;
+ top: calc(env(safe-area-inset-top, 0px) + 18px);
+ }
+ .wlabel {
+ font-size: 13px;
+ letter-spacing: 0.18em;
+ opacity: 0.55;
+ padding: 6px 14px;
+ border-radius: 20px;
+ text-shadow: 0 1px 6px rgba(0,0,0,0.7);
+ transition: opacity 0.18s, background 0.18s;
+ }
+ .wlabel:hover {
+ opacity: 0.85;
+ background: rgba(255, 255, 255, 0.12);
+ }
+ .wlabel.active {
+ opacity: 1;
+ background: rgba(255, 255, 255, 0.18);
+ text-shadow: 0 1px 8px rgba(0,0,0,0.5);
+ }
}
/* Settings-Button */
diff --git a/backend/static/index.html b/backend/static/index.html
index cb75a8f..77f0433 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -9,7 +9,6 @@
-
@@ -76,6 +75,7 @@
+
@@ -93,9 +93,9 @@
-
-
-
+
+
+
@@ -499,6 +499,18 @@
+
+
+
+
+
+
@@ -539,9 +551,6 @@
HUND
WELT
-
@@ -565,12 +574,12 @@
-
+
-
+
diff --git a/backend/static/js/api.js b/backend/static/js/api.js
index c6b26da..1071fdd 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -212,6 +212,9 @@ const API = (() => {
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
myFavorite() { return get('/tieraerzte/my-favorite'); },
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
+ bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); },
+ meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); },
+ bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index a248787..deb05c5 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@@ -76,6 +76,9 @@ const App = (() => {
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
wetter: { title: 'Wetter', module: null },
+ ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
+ personality: { title: 'Persönlichkeitstest', module: null },
+ reise: { title: 'Reise mit Hund', module: null },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js
index a39eccd..66d9150 100644
--- a/backend/static/js/pages/diary.js
+++ b/backend/static/js/pages/diary.js
@@ -868,9 +868,9 @@ window.Page_diary = (() => {
if (e.weather_json) {
try {
const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json;
- const temp = w?.temperature_2m ?? w?.temp_c;
+ const temp = w?.temp_c ?? w?.temperature_2m;
if (temp != null) {
- metaParts.push(`
${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°`);
+ metaParts.push(`
${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°`);
}
} catch (_) {}
}
@@ -1073,15 +1073,14 @@ window.Page_diary = (() => {
if (entry.weather_json) {
try {
const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json;
- const temp = w?.temperature_2m ?? w?.temp_c;
+ const temp = w?.temp_c ?? w?.temperature_2m;
if (w && temp != null) {
- const feels = w.apparent_temperature ?? w.feels_like_c;
- const wind = w.wind_speed_10m ?? w.wind_kmh;
+ const wind = w.wind_kmh ?? w.wind_speed_10m;
+ const precip = w.precip_prob;
const parts = [
- `${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`,
- feels != null ? `gefühlt ${Math.round(feels)}°` : null,
- wind != null ? `💨 ${Math.round(wind)} km/h` : null,
- w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null,
+ `${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`,
+ wind != null ? `${Math.round(wind)} km/h Wind` : null,
+ precip != null ? `${precip}% Regen` : null,
].filter(Boolean).join(' · ');
metaItems.push(`
${parts}`);
}
@@ -1728,6 +1727,16 @@ window.Page_diary = (() => {
});
await UI.asyncButton(submitBtn, async () => {
+ // Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort
+ let _clientWeather = null;
+ if (!isEdit && _locLat == null) {
+ try {
+ const pos = await API.getLocation();
+ const wd = await API.weather.get(pos.lat, pos.lon);
+ if (wd && wd.temp_c != null) _clientWeather = JSON.stringify(wd);
+ } catch (_) { /* GPS oder Wetter nicht verfügbar → kein Problem */ }
+ }
+
const payload = {
datum: fd.datum || null,
typ: fd.typ,
@@ -1739,6 +1748,7 @@ window.Page_diary = (() => {
gps_lon: _locLon,
location_name: _locName,
client_time: API.clientNow(),
+ weather_json: _clientWeather,
};
async function _uploadNewFiles(entryId) {
diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js
index 82a2e8a..09b729a 100644
--- a/backend/static/js/pages/dog-profile.js
+++ b/backend/static/js/pages/dog-profile.js
@@ -195,9 +195,27 @@ window.Page_dog_profile = (() => {
Hundepass
` : ''}
+ ${!dog.is_guest ? `
` : ''}
${!dog.is_guest ? `
` : ''}
+ ${!dog.is_guest ? `
` : ''}
+ ${!dog.is_guest ? `
` : ''}
+ ${!dog.is_guest ? `
` : ''}
@@ -264,6 +282,22 @@ window.Page_dog_profile = (() => {
_showPassportModal(dog);
});
+ document.getElementById('dp-vcard-btn')?.addEventListener('click', () => {
+ _showVcardModal(dog);
+ });
+
+ document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => {
+ _showWrappedModal(dog);
+ });
+
+ document.getElementById('dp-buch-btn')?.addEventListener('click', () => {
+ _showBuchModal(dog);
+ });
+
+ document.getElementById('dp-timeline-btn')?.addEventListener('click', () => {
+ _showTimelineModal(dog);
+ });
+
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@@ -750,6 +784,138 @@ window.Page_dog_profile = (() => {
// ----------------------------------------------------------
// TEILEN
// ----------------------------------------------------------
+ // ----------------------------------------------------------
+ // HUNDE-VISITENKARTE MIT QR-CODE
+ // ----------------------------------------------------------
+ function _showVcardModal(dog) {
+ const passportUrl = `https://banyaro.app/hund/${dog.id}`;
+ const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`;
+
+ const user = _appState?.user;
+ const ownerName = user?.name || '';
+ const wohnort = user?.wohnort || '';
+
+ // Alter errechnen
+ let alterStr = '';
+ if (dog.geburtstag) {
+ const birth = new Date(dog.geburtstag + 'T00:00:00');
+ const now = new Date();
+ const years = now.getFullYear() - birth.getFullYear()
+ - (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
+ alterStr = years < 1
+ ? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate`
+ : years === 1 ? '1 Jahr' : `${years} Jahre`;
+ }
+
+ const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · ');
+
+ const cardHtml = `
+
+
+
+
+
+
+
+
+ ${dog.foto_url
+ ? `
})
`
+ : `
🐾
`}
+
+
${_esc(dog.name)}
+ ${metaLine ? `
${_esc(metaLine)}
` : ''}
+ ${wohnort ? `
📍 ${_esc(wohnort)}
` : ''}
+
+
+
+
+
+
+
+
+
+ ${ownerName ? `
Besitzer
+
${_esc(ownerName)}
` : ''}
+
banyaro.app
+
+
+
})
+
Profil öffnen
+
+
+
+ `;
+
+ UI.modal.open({
+ title: 'Visitenkarte',
+ body: `
+ ${cardHtml}
+
+ QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${_esc(dog.name)} sofort öffnen.
+
+ `,
+ footer: `
+
+
+ `,
+ });
+
+ // Link kopieren
+ document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(passportUrl);
+ UI.toast.success('Link kopiert!');
+ } catch {
+ const inp = document.createElement('input');
+ inp.value = passportUrl;
+ document.body.appendChild(inp);
+ inp.select();
+ document.execCommand('copy');
+ inp.remove();
+ UI.toast.success('Link kopiert!');
+ }
+ });
+
+ // Native Share API
+ document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => {
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title: `${dog.name} auf Ban Yaro`,
+ text: `Schau dir das Profil von ${dog.name} an!`,
+ url: passportUrl,
+ });
+ } catch {}
+ } else {
+ // Fallback: kopieren
+ try {
+ await navigator.clipboard.writeText(passportUrl);
+ UI.toast.success('Link kopiert!');
+ } catch {
+ UI.toast.error('Teilen nicht verfügbar.');
+ }
+ }
+ });
+ }
+
async function _showShareModal(dog) {
UI.modal.open({
title: `${_esc(dog.name)} teilen`,
@@ -970,6 +1136,23 @@ window.Page_dog_profile = (() => {
+
+
+
+
+
@@ -1716,6 +1756,226 @@ window.Page_health = (() => {
`;
}
+ // ----------------------------------------------------------
+ // PRAXEN — Sterne-Hilfs-Funktionen
+ // ----------------------------------------------------------
+
+ /** Rendert 5 Sterne (readonly, filled bis `rating`). */
+ function _renderStarsReadonly(rating) {
+ const full = Math.round(rating);
+ return Array.from({ length: 5 }, (_, i) => {
+ const filled = i < full;
+ return `★`;
+ }).join('');
+ }
+
+ /** Rendert 5 klickbare Sterne mit data-val. */
+ function _renderStarsInput(name, current) {
+ return `
+ ${Array.from({ length: 5 }, (_, i) => {
+ const val = i + 1;
+ const filled = current >= val;
+ return `★`;
+ }).join('')}
+
`;
+ }
+
+ // ----------------------------------------------------------
+ // PRAXEN — Detail-Modal (Bewertungen anzeigen)
+ // ----------------------------------------------------------
+ async function _showPraxisDetail(praxis) {
+ // Erst mit Lade-Spinner öffnen, dann Daten laden
+ UI.modal.open({
+ title: _esc(praxis.name),
+ body: `
+
+
`,
+ footer: `
+ `,
+ });
+
+ document.getElementById('detail-bewerten-btn')
+ ?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); });
+
+ let data;
+ try {
+ data = await API.tieraerzte.bewertungen(praxis.id);
+ } catch {
+ UI.modal.open({ title: praxis.name, body: 'Bewertungen konnten nicht geladen werden.
' });
+ return;
+ }
+
+ const { avg_rating, anz_bewertungen, verteilung, kommentare } = data;
+
+ // Balkendiagramm
+ const balken = [5, 4, 3, 2, 1].map(s => {
+ const n = verteilung[String(s)] || 0;
+ const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0;
+ return ``;
+ }).join('');
+
+ const kommentarHtml = kommentare.length
+ ? kommentare.map(k => `
+
+
+ ${_renderStarsReadonly(k.gesamt)}
+
+ ${k.created_at ? k.created_at.slice(0, 10) : ''}
+
+
+ ${k.wartezeit || k.freundlichkeit || k.kompetenz ? `
+
+ ${k.wartezeit ? `Wartezeit: ${_renderStarsReadonly(k.wartezeit)}` : ''}
+ ${k.freundlichkeit ? `Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}` : ''}
+ ${k.kompetenz ? `Kompetenz: ${_renderStarsReadonly(k.kompetenz)}` : ''}
+
` : ''}
+
${_esc(k.text || '')}
+
`).join('')
+ : `Noch keine Kommentare.
`;
+
+ const bewBody = anz_bewertungen === 0
+ ? `
+ Noch keine Bewertungen — sei der Erste!
+
`
+ : `
+
+
+
${avg_rating.toFixed(1)}
+
${_renderStarsReadonly(avg_rating)}
+
${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}
+
+
${balken}
+
+ ${kommentarHtml}
`;
+
+ // Modal-Body aktualisieren (ohne Modal neu zu öffnen)
+ const modalBody = document.querySelector('.modal-body');
+ if (modalBody) modalBody.innerHTML = bewBody;
+ }
+
+ // ----------------------------------------------------------
+ // PRAXEN — Bewertungs-Modal
+ // ----------------------------------------------------------
+ async function _showBewertungModal(praxis) {
+ // Ggf. bestehende Bewertung laden
+ let existing = null;
+ try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ }
+
+ const cur = existing || {};
+
+ const body = `
+ `;
+
+ UI.modal.open({
+ title: `${_esc(praxis.name)} bewerten`,
+ body,
+ footer: `
+
+ `,
+ });
+
+ // Sterne-Interaktion
+ document.querySelectorAll('.bew-stars').forEach(group => {
+ const name = group.dataset.name;
+ const hidden = document.getElementById(`bew-${name}`);
+ const stars = group.querySelectorAll('.bew-star');
+
+ const paint = val => {
+ stars.forEach(s => {
+ s.style.color = parseInt(s.dataset.val) <= val
+ ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)';
+ });
+ };
+
+ stars.forEach(s => {
+ s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val)));
+ s.addEventListener('mouseleave', () => paint(parseInt(hidden.value)));
+ s.addEventListener('click', () => {
+ hidden.value = s.dataset.val;
+ paint(parseInt(s.dataset.val));
+ });
+ });
+
+ paint(parseInt(hidden.value));
+ });
+
+ // Submit
+ document.getElementById('bew-submit-btn').addEventListener('click', async (e) => {
+ e.preventDefault();
+ const form = document.getElementById('bew-form');
+ const gesamt = parseInt(document.getElementById('bew-gesamt').value);
+ if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; }
+
+ const payload = { gesamt };
+ const wz = parseInt(document.getElementById('bew-wartezeit').value);
+ const fr = parseInt(document.getElementById('bew-freundlichkeit').value);
+ const ko = parseInt(document.getElementById('bew-kompetenz').value);
+ if (wz) payload.wartezeit = wz;
+ if (fr) payload.freundlichkeit = fr;
+ if (ko) payload.kompetenz = ko;
+ const txt = form.querySelector('textarea[name="text"]').value.trim();
+ if (txt) payload.text = txt;
+
+ await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => {
+ const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload);
+ // _praxen-Cache aktualisieren
+ _praxen = _praxen.map(p =>
+ p.id === praxis.id
+ ? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen }
+ : p
+ );
+ UI.modal.close();
+ UI.toast.success('Bewertung gespeichert.');
+ _renderTab();
+ });
+ });
+ }
+
// ----------------------------------------------------------
// PRAXEN — Formular (Neu / Bearbeiten)
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js
index ded9a7d..fd15e1d 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -838,15 +838,17 @@ window.Page_map = (() => {
_tempMarker = null;
}
+ // Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
- { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
- { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' },
- { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' },
- { type: 'drinking_water', icon: '', label: 'Wasserstelle', color: '#0EA5E9' },
- { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' },
- { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' },
- { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' },
- { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' },
+ { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626', exclusive: true },
+ { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' },
+ { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' },
+ { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' },
+ { type: 'drinking_water',icon: '', label: 'Wasserstelle',color: '#0EA5E9' },
+ { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' },
+ { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' },
+ { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' },
+ { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
@@ -855,18 +857,18 @@ window.Page_map = (() => {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
- let _selectedType = 'giftkoeder';
+ let _selectedTypes = new Set(['giftkoeder']);
UI.modal.open({
title: ' Marker setzen',
body: `