Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)

- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
This commit is contained in:
rene 2026-05-02 09:29:48 +02:00
parent 031c6028ac
commit 742ad189e8
26 changed files with 5734 additions and 27 deletions

View file

@ -1657,3 +1657,203 @@ def _migrate(conn_factory):
);
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
""")
# Trainings-Streak-Tabelle
conn.execute("""
CREATE TABLE IF NOT EXISTS training_streaks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
current_streak INTEGER NOT NULL DEFAULT 0,
longest_streak INTEGER NOT NULL DEFAULT 0,
last_training_date TEXT,
UNIQUE(user_id, dog_id)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)")
# Ausgaben-Tracker
conn.executescript("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
kategorie TEXT NOT NULL,
betrag REAL NOT NULL,
datum TEXT NOT NULL,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC);
""")
# KI-Tierarztfragen Rate-Limit-Log
conn.execute("""
CREATE TABLE IF NOT EXISTS ki_tierarzt_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User)
conn.execute("""
CREATE TABLE IF NOT EXISTS ki_rasse_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user
ON ki_rasse_log(user_id, created_at DESC)
""")
# feed_recalls — Rückruf-Alarm für Tierfutter (RASFF)
conn.execute("""
CREATE TABLE IF NOT EXISTS feed_recalls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT NOT NULL UNIQUE,
titel TEXT NOT NULL,
produkt TEXT,
gefahr TEXT,
herkunft TEXT,
datum TEXT NOT NULL,
quelle TEXT NOT NULL DEFAULT 'rasff',
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)")
# Adoption-Cache
conn.execute("""
CREATE TABLE IF NOT EXISTS adoption_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
rasse TEXT,
alter_jahre REAL,
geschlecht TEXT,
foto_url TEXT,
tierheim TEXT,
tierheim_plz TEXT,
tierheim_lat REAL,
tierheim_lon REAL,
adoptions_url TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
)
""")
# ---- Favoriten-Tierarzt + Gesundheitsdokumente ----
conn.execute("""
CREATE TABLE IF NOT EXISTS favorite_vets (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, vet_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS health_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
typ TEXT NOT NULL,
titel TEXT NOT NULL,
beschreibung TEXT,
file_path TEXT NOT NULL,
file_type TEXT NOT NULL,
datum TEXT,
vet_id INTEGER REFERENCES tieraerzte(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)")
# Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS vaccinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
krankheit TEXT NOT NULL,
datum TEXT NOT NULL,
naechste TEXT,
tierarzt TEXT,
charge_nr TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS medications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
dosierung TEXT,
von TEXT,
bis TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS dog_passport_meta (
dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE,
blutgruppe TEXT,
allergien TEXT,
besonderheiten TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS passport_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
valid_until TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token)
""")
logger.info("Migration: Hundepass-Tabellen bereit.")
except Exception as e:
logger.warning(f"Migration Hundepass: {e}")
# ---- Playdate ----
conn.execute("""
CREATE TABLE IF NOT EXISTS playdate_listings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lat REAL NOT NULL,
lon REAL NOT NULL,
ort_name TEXT,
radius_km INTEGER NOT NULL DEFAULT 10,
beschreibung TEXT,
aktiv INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(dog_id)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo
ON playdate_listings(lat, lon) WHERE aktiv=1
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS playdate_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
nachricht TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(from_dog_id, to_dog_id)
)
""")

View file

@ -189,6 +189,13 @@ from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
from routes.outreach import router as outreach_router
from routes.jobs import router as jobs_router
from routes.streak import router as streak_router
from routes.expenses import router as expenses_router
from routes.recalls import router as recalls_router
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
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -240,6 +247,13 @@ app.include_router(training_router, prefix="/api/training", tags=
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
app.include_router(streak_router, prefix="/api", tags=["Streak"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
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"])
# ------------------------------------------------------------------
@ -1674,6 +1688,152 @@ for _hp in _HONEYPOT_PATHS:
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
# ------------------------------------------------------------------
# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
# ------------------------------------------------------------------
@app.get("/pass/{token}")
async def passport_share_page(token: str):
from fastapi.responses import HTMLResponse
from database import db as _db
from datetime import date as _date
with _db() as conn:
share = conn.execute(
"SELECT * FROM passport_shares WHERE token=?", (token,)
).fetchone()
if not share:
return HTMLResponse(
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
'<h2>Link nicht gefunden</h2><p>Dieser Hundepass-Link ist ungültig.</p>',
status_code=404
)
if share["valid_until"] < _date.today().isoformat():
return HTMLResponse(
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
'<h2>Link abgelaufen</h2><p>Dieser Hundepass-Link ist nicht mehr gültig.</p>',
status_code=410
)
dog_id = share["dog_id"]
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
vaccs = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
).fetchall()
meds = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
).fetchall()
def _fmt(d):
if not d:
return ""
try:
from datetime import datetime as _dt
return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return d
dog = dict(dog)
meta = dict(meta) if meta else {}
vaccs = [dict(v) for v in vaccs]
meds = [dict(m) for m in meds]
_g_map = {"m": "Rüde", "w": "Hündin"}
vacc_rows = "".join(f"""
<tr>
<td>{v['krankheit'] or ''}</td>
<td>{_fmt(v['datum'])}</td>
<td>{_fmt(v['naechste'])}</td>
<td>{v['tierarzt'] or ''}</td>
<td>{v['charge_nr'] or ''}</td>
</tr>""" for v in vaccs) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
med_rows = "".join(f"""
<tr>
<td>{m['name'] or ''}</td>
<td>{m['dosierung'] or ''}</td>
<td>{_fmt(m['von'])}</td>
<td>{_fmt(m['bis']) if m['bis'] else 'dauerhaft'}</td>
<td>{m['notiz'] or ''}</td>
</tr>""" for m in meds) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hundepass {dog['name']}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7f5; color: #222; }}
.header {{ background: #28a764; color: #fff; padding: 24px 20px; text-align: center; }}
.header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
.header p {{ font-size: 0.9rem; opacity: 0.85; }}
.container {{ max-width: 760px; margin: 24px auto; padding: 0 16px; }}
.card {{ background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); }}
.card h2 {{ font-size: 1rem; color: #28a764; margin-bottom: 14px; display: flex;
align-items: center; gap: 8px; border-bottom: 1px solid #e8f5ee; padding-bottom: 10px; }}
.info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
.info-item label {{ font-size: 0.75rem; color: #888; display: block; margin-bottom: 2px; }}
.info-item span {{ font-size: 0.9rem; font-weight: 500; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.85rem; }}
th {{ background: #e8f5ee; text-align: left; padding: 8px; font-size: 0.8rem;
color: #444; font-weight: 600; }}
td {{ padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }}
.footer {{ text-align: center; font-size: 0.75rem; color: #aaa; margin: 24px 0; }}
@media (max-width: 500px) {{ .info-grid {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="header">
<h1>Ban Yaro</h1>
<p>Digitaler Hundepass &mdash; {dog['name']}</p>
</div>
<div class="container">
<div class="card">
<h2>Hundeangaben</h2>
<div class="info-grid">
<div class="info-item"><label>Name</label><span>{dog['name']}</span></div>
<div class="info-item"><label>Rasse</label><span>{dog.get('rasse') or ''}</span></div>
<div class="info-item"><label>Geburtstag</label><span>{_fmt(dog.get('geburtstag'))}</span></div>
<div class="info-item"><label>Geschlecht</label><span>{_g_map.get(dog.get('geschlecht',''), '')}</span></div>
<div class="info-item"><label>Chip-Nr.</label><span>{dog.get('chip_nr') or ''}</span></div>
<div class="info-item"><label>Blutgruppe</label><span>{meta.get('blutgruppe') or ''}</span></div>
</div>
{('<div style="margin-top:14px"><label style="font-size:.75rem;color:#888">Allergien</label>'
f'<div style="font-size:.9rem">{meta["allergien"]}</div></div>') if meta.get("allergien") else ''}
{('<div style="margin-top:10px"><label style="font-size:.75rem;color:#888">Besonderheiten</label>'
f'<div style="font-size:.9rem">{meta["besonderheiten"]}</div></div>') if meta.get("besonderheiten") else ''}
</div>
<div class="card">
<h2>Impfungen</h2>
<table>
<thead><tr>
<th>Krankheit</th><th>Datum</th><th>Nächste</th><th>Tierarzt</th><th>Charge</th>
</tr></thead>
<tbody>{vacc_rows}</tbody>
</table>
</div>
<div class="card">
<h2>Medikamente</h2>
<table>
<thead><tr>
<th>Medikament</th><th>Dosierung</th><th>Von</th><th>Bis</th><th>Notiz</th>
</tr></thead>
<tbody>{med_rows}</tbody>
</table>
</div>
</div>
<div class="footer">Erstellt mit Ban Yaro &mdash; banyaro.app</div>
</body>
</html>"""
return HTMLResponse(html)
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):

View file

@ -13,3 +13,4 @@ pywebpush==2.0.0
apscheduler==3.10.4
odfpy==1.4.1
polyline==2.0.2
fpdf2==2.8.3

292
backend/routes/adoption.py Normal file
View file

@ -0,0 +1,292 @@
"""
BAN YARO Adoption (Tierheim-Hunde in der Nähe)
Strategie:
1. PetFinder API (falls API-Key gesetzt) hat kaum deutsche Tierheime, nur als Bonus
2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten
3. Fallback: Weiterleitung zu tierheimhelden.de
Caching: adoption_cache Tabelle, 24h TTL.
"""
import os
import math
import logging
import asyncio
import httpx
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks
from database import db
logger = logging.getLogger(__name__)
router = APIRouter()
PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "")
PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "")
# ------------------------------------------------------------------
# Haversine — Distanz in km
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
p1 = math.radians(lat1)
p2 = 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))
# ------------------------------------------------------------------
# Statische Tierheim-Daten (große deutsche Tierheime)
# ------------------------------------------------------------------
GERMAN_SHELTERS = [
# (id, name, plz, stadt, lat, lon, url)
("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"),
("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"),
("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"),
("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"),
("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"),
("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"),
("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"),
("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"),
("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"),
("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"),
("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"),
("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"),
("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"),
("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"),
("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"),
("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"),
("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"),
("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"),
("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"),
("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"),
("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"),
("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"),
("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"),
("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"),
("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"),
]
# ------------------------------------------------------------------
# PetFinder OAuth2 Token
# ------------------------------------------------------------------
_pf_token = None
_pf_token_exp = 0.0
async def _get_pf_token() -> str | None:
global _pf_token, _pf_token_exp
if not (PETFINDER_KEY and PETFINDER_SECRET):
return None
now = asyncio.get_event_loop().time()
if _pf_token and now < _pf_token_exp - 60:
return _pf_token
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(
"https://api.petfinder.com/v2/oauth2/token",
data={"grant_type": "client_credentials",
"client_id": PETFINDER_KEY,
"client_secret": PETFINDER_SECRET},
)
if r.status_code == 200:
data = r.json()
_pf_token = data.get("access_token")
_pf_token_exp = now + data.get("expires_in", 3600)
return _pf_token
except Exception as e:
logger.warning(f"PetFinder OAuth: {e}")
return None
# ------------------------------------------------------------------
# PetFinder: Hunde in der Nähe holen
# ------------------------------------------------------------------
async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]:
token = await _get_pf_token()
if not token:
return []
try:
async with httpx.AsyncClient(timeout=12) as client:
r = await client.get(
"https://api.petfinder.com/v2/animals",
headers={"Authorization": f"Bearer {token}"},
params={
"type": "dog",
"location": f"{lat},{lon}",
"distance": radius,
"limit": 20,
"sort": "distance",
"status": "adoptable",
},
)
if r.status_code != 200:
logger.warning(f"PetFinder API: HTTP {r.status_code}")
return []
animals = r.json().get("animals", [])
result = []
for a in animals:
org = a.get("organization_id", "")
loc = a.get("contact", {}).get("address", {})
photos = a.get("photos", [])
foto = photos[0].get("medium") if photos else None
age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0}
result.append({
"external_id": f"pf_{a['id']}",
"name": a.get("name", "Unbekannt"),
"rasse": ", ".join(
filter(None, [
a.get("breeds", {}).get("primary"),
a.get("breeds", {}).get("secondary"),
])
) or None,
"alter_jahre": age_map.get(a.get("age"), None),
"geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None),
"foto_url": foto,
"tierheim": org,
"tierheim_plz": loc.get("postcode"),
"tierheim_lat": None,
"tierheim_lon": None,
"adoptions_url": a.get("url", "https://www.petfinder.com/"),
"quelle": "petfinder",
})
return result
except Exception as e:
logger.warning(f"PetFinder Fetch: {e}")
return []
# ------------------------------------------------------------------
# Cache befüllen
# ------------------------------------------------------------------
async def _refresh_cache(lat: float, lon: float, radius: int):
"""Holt frische Daten und schreibt sie in adoption_cache."""
animals = await _fetch_petfinder(lat, lon, radius)
if not animals:
return
expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
with db() as conn:
for a in animals:
try:
conn.execute("""
INSERT INTO adoption_cache
(external_id, name, rasse, alter_jahre, geschlecht,
foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon,
adoptions_url, expires_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(external_id) DO UPDATE SET
name=excluded.name,
rasse=excluded.rasse,
alter_jahre=excluded.alter_jahre,
geschlecht=excluded.geschlecht,
foto_url=excluded.foto_url,
tierheim=excluded.tierheim,
tierheim_plz=excluded.tierheim_plz,
tierheim_lat=excluded.tierheim_lat,
tierheim_lon=excluded.tierheim_lon,
adoptions_url=excluded.adoptions_url,
expires_at=excluded.expires_at
""", (
a["external_id"], a["name"], a["rasse"], a["alter_jahre"],
a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"],
a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires,
))
except Exception as e:
logger.warning(f"Cache insert: {e}")
# ------------------------------------------------------------------
# GET /api/adoption/nearby
# ------------------------------------------------------------------
@router.get("/nearby")
async def adoption_nearby(
lat: float = Query(..., description="Breitengrad"),
lon: float = Query(..., description="Längengrad"),
radius: int = Query(50, ge=5, le=200, description="Radius in km"),
background_tasks: BackgroundTasks = None,
):
"""
Gibt Adoptionshunde in der Nähe zurück.
Priorisierung:
1. Frische PetFinder-Einträge aus Cache
2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung)
"""
now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# ------ Cache lesen ------
cached_animals = []
with db() as conn:
rows = conn.execute("""
SELECT * FROM adoption_cache
WHERE expires_at > ?
ORDER BY created_at DESC
""", (now_str,)).fetchall()
for row in rows:
d = dict(row)
if d.get("tierheim_lat") and d.get("tierheim_lon"):
dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
if dist <= radius:
d["distanz_km"] = round(dist, 1)
cached_animals.append(d)
else:
# PetFinder-Einträge ohne Koordinaten: immer anzeigen
d["distanz_km"] = None
cached_animals.append(d)
# ------ Cache refreshen wenn leer oder alt ------
if not cached_animals and background_tasks is not None:
background_tasks.add_task(_refresh_cache, lat, lon, radius)
# ------ Statische Tierheime (immer) ------
shelters = []
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
dist = _haversine(lat, lon, slat, slon)
if dist <= radius:
shelters.append({
"id": sid,
"name": name,
"plz": plz,
"stadt": stadt,
"lat": slat,
"lon": slon,
"url": url,
"distanz_km": round(dist, 1),
})
shelters.sort(key=lambda x: x["distanz_km"])
return {
"animals": cached_animals[:40],
"shelters": shelters[:10],
"has_petfinder": bool(PETFINDER_KEY),
}
# ------------------------------------------------------------------
# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim
# ------------------------------------------------------------------
@router.get("/geocode")
async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
"""Wandelt eine PLZ in Koordinaten um (via Nominatim)."""
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
"q": f"{plz}, Germany",
"format": "json",
"limit": 1,
"accept-language": "de",
"countrycodes": "de",
},
headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"},
)
results = r.json()
if results:
return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)}
except Exception as e:
logger.warning(f"Geocode PLZ {plz}: {e}")
return {"lat": None, "lon": None, "display": plz}

228
backend/routes/expenses.py Normal file
View file

@ -0,0 +1,228 @@
"""BAN YARO — Ausgaben-Tracker Routes"""
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"}
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
betrag: float
datum: str
notiz: Optional[str] = None
class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
betrag: Optional[float] = None
datum: Optional[str] = None
notiz: Optional[str] = None
def _serialize(row) -> dict:
return dict(row)
# ------------------------------------------------------------------
# GET /api/expenses/summary — Monats- und Jahressummen
# WICHTIG: Diese Route muss VOR /{id} stehen!
# ------------------------------------------------------------------
@router.get("/summary")
async def get_summary(
dog_id: Optional[int] = Query(default=None),
user=Depends(get_current_user),
):
today = date.today()
monat_prefix = today.strftime("%Y-%m")
jahr_prefix = today.strftime("%Y")
extra_cond = ""
extra_params: list = []
if dog_id is not None:
extra_cond = " AND dog_id=?"
extra_params = [dog_id]
with db() as conn:
# Monats-Summen pro Kategorie
rows_monat = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{monat_prefix}%"] + extra_params,
).fetchall()
# Jahres-Summen pro Kategorie
rows_jahr = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{jahr_prefix}%"] + extra_params,
).fetchall()
monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat}
jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr}
gesamt_monat = round(sum(monat.values()), 2)
gesamt_jahr = round(sum(jahr.values()), 2)
return {
"monat": monat,
"jahr": jahr,
"gesamt_monat": gesamt_monat,
"gesamt_jahr": gesamt_jahr,
}
# ------------------------------------------------------------------
# GET /api/expenses — Liste mit optionalen Filtern
# ------------------------------------------------------------------
@router.get("")
async def list_expenses(
dog_id: Optional[int] = Query(default=None),
von: Optional[str] = Query(default=None),
bis: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=500),
offset: int = Query(default=0),
user=Depends(get_current_user),
):
conditions = ["e.user_id=?"]
params: list = [user["id"]]
if dog_id is not None:
conditions.append("e.dog_id=?")
params.append(dog_id)
if von:
conditions.append("e.datum >= ?")
params.append(von)
if bis:
conditions.append("e.datum <= ?")
params.append(bis)
where = " AND ".join(conditions)
params += [limit, offset]
with db() as conn:
rows = conn.execute(
f"""SELECT e.*, d.name AS dog_name
FROM expenses e
LEFT JOIN dogs d ON d.id = e.dog_id
WHERE {where}
ORDER BY e.datum DESC, e.id DESC
LIMIT ? OFFSET ?""",
params,
).fetchall()
return [_serialize(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/expenses — neuer Eintrag
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
with db() as conn:
# dog_id prüfen — muss dem User gehören
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
conn.execute(
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz),
)
row = conn.execute(
"SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],),
).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# PATCH /api/expenses/{id} — bearbeiten
# ------------------------------------------------------------------
@router.patch("/{expense_id}")
async def update_expense(
expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user)
):
with db() as conn:
row = conn.execute(
"SELECT * FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
updates = {}
if data.kategorie is not None:
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
updates["kategorie"] = data.kategorie
if data.betrag is not None:
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
updates["betrag"] = data.betrag
if data.datum is not None:
updates["datum"] = data.datum
if data.notiz is not None:
updates["notiz"] = data.notiz
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
updates["dog_id"] = data.dog_id
if not updates:
return _serialize(row)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [expense_id]
conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# DELETE /api/expenses/{id} — löschen
# ------------------------------------------------------------------
@router.delete("/{expense_id}", status_code=204)
async def delete_expense(expense_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
return None

View file

@ -0,0 +1,138 @@
"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)"""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"}
def _check_dog_owner(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
# ------------------------------------------------------------------
# GET /api/health-docs?dog_id=...
# ------------------------------------------------------------------
@router.get("")
async def list_docs(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
rows = conn.execute(
"""SELECT hd.*, t.name AS vet_name
FROM health_documents hd
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
WHERE hd.dog_id=?
ORDER BY hd.created_at DESC""",
(dog_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/health-docs/upload (multipart/form-data)
# ------------------------------------------------------------------
@router.post("/upload", status_code=201)
async def upload_doc(
dog_id: int = Form(...),
typ: str = Form(...),
titel: str = Form(...),
beschreibung: Optional[str] = Form(None),
datum: Optional[str] = Form(None),
vet_id: Optional[int] = Form(None),
file: UploadFile = File(...),
user=Depends(get_current_user),
):
if typ not in ERLAUBTE_TYPEN:
raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}")
ext = os.path.splitext(file.filename or "")[1].lower()
if not ext:
ext = ".jpg"
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
content = await file.read()
if len(content) > MAX_SIZE_BYTES:
raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.")
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
if vet_id:
vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone()
if not vet:
vet_id = None
# Datei speichern
dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id))
os.makedirs(dog_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(dog_dir, filename)
with open(filepath, "wb") as f:
f.write(content)
file_url = f"/media/health_docs/{dog_id}/{filename}"
file_type = "pdf" if ext == ".pdf" else ext.lstrip(".")
with db() as conn:
conn.execute(
"""INSERT INTO health_documents
(dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id)
VALUES (?,?,?,?,?,?,?,?,?)""",
(dog_id, user["id"], typ, titel.strip(), beschreibung,
file_url, file_type, datum or None, vet_id)
)
row = conn.execute(
"""SELECT hd.*, t.name AS vet_name
FROM health_documents hd
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
WHERE hd.id = last_insert_rowid()"""
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/health-docs/{id}
# ------------------------------------------------------------------
@router.delete("/{doc_id}", status_code=204)
async def delete_doc(doc_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT * FROM health_documents WHERE id=? AND user_id=?",
(doc_id, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Dokument nicht gefunden.")
# Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf
file_path = row["file_path"]
if file_path:
# /media/... → MEDIA_DIR/...
rel = file_path.lstrip("/")
if rel.startswith("media/"):
rel = rel[len("media/"):]
abs_path = os.path.join(MEDIA_DIR, rel)
if os.path.isfile(abs_path):
try:
os.remove(abs_path)
except OSError:
pass
conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,))
return None

View file

@ -1,10 +1,11 @@
"""BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from typing import Optional
import ki as ki_module
from auth import get_current_user
from ratelimit import check as rl_check
from database import db
router = APIRouter()
@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, "KI momentan nicht verfügbar.")
# ------------------------------------------------------------------
# POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------
class TierarztRequest(BaseModel):
symptom: str
dog_id: Optional[int] = None
dog_name: Optional[str] = None
rasse: Optional[str] = None
@router.post("/tierarzt")
async def ki_tierarzt(req: TierarztRequest, request: Request,
user=Depends(get_current_user)):
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
if not req.symptom or len(req.symptom.strip()) < 5:
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.")
if len(req.symptom) > 1000:
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
# Rate-Limit: max 5 Anfragen pro User pro Tag
with db() as conn:
count = conn.execute(
"SELECT COUNT(*) FROM ki_tierarzt_log "
"WHERE user_id=? AND created_at >= datetime('now','-1 day')",
(user["id"],)
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.")
dog_name = req.dog_name or "unbekannt"
rasse = req.rasse or "unbekannt"
system = (
"Du bist ein erfahrener Tierarzt-Assistent für Hunde. "
"Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — "
"kein Ersatz für eine echte tierärztliche Untersuchung. "
"Antworte immer auf Deutsch, klar und verständlich. "
"Stelle keine medizinischen Diagnosen. "
"Empfehle im Zweifel immer den Gang zum Tierarzt."
)
prompt = f"""Hund: {dog_name}, Rasse: {rasse}
Symptom: {req.symptom.strip()}
Gib eine strukturierte, verständliche Einschätzung:
1. Mögliche Ursachen (2-3 wahrscheinlichste)
2. Was der Besitzer jetzt tun kann (Erstmaßnahmen)
3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort)
Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter.
Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt."""
try:
antwort = await ki_module.complete(
prompt=prompt,
system=system,
max_tokens=600,
requires_premium=False,
user_id=user["id"],
)
# Erfolg: Rate-Limit-Eintrag speichern
with db() as conn:
conn.execute(
"INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)",
(user["id"], req.dog_id)
)
return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5}
except ki_module.KIUnavailableError as e:
raise HTTPException(503, str(e))
except HTTPException:
raise
except Exception:
raise HTTPException(500, "KI momentan nicht verfügbar.")
# ------------------------------------------------------------------
# Rate-Limit-Helfer für Rassen-Erkennung
# ------------------------------------------------------------------
_RASSE_DAILY_LIMIT = 10
def _check_rasse_limit(user_id: int) -> int:
"""Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht."""
with db() as conn:
used = conn.execute(
"""SELECT COUNT(*) FROM ki_rasse_log
WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""",
(user_id,)
).fetchone()[0]
remaining = _RASSE_DAILY_LIMIT - used
if remaining <= 0:
raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.")
return remaining
def _log_rasse_request(user_id: int):
with db() as conn:
conn.execute(
"INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,)
)
# ------------------------------------------------------------------
# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung
# ------------------------------------------------------------------
@router.post("/rasse-erkennung")
async def ki_rasse_erkennung(
request: Request,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
"""Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag)."""
import base64
import json
import re
import anthropic
# Dateigröße prüfen
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.")
# MIME-Typ prüfen
ct = (file.content_type or "").lower()
if not ct.startswith("image/"):
raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).")
# MIME-Typ auf erlaubte Werte beschränken
allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"}
mime_type = ct if ct in allowed_mimes else "image/jpeg"
# Rate-Limit prüfen
remaining_before = _check_rasse_limit(user["id"])
# Anthropic-Client holen (nutzt cached Instanz aus ki.py)
if not ki_module.ANTHROPIC_KEY:
raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
api_key = ki_module.ANTHROPIC_KEY
base64_data = base64.standard_b64encode(content).decode("utf-8")
prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).
Antworte NUR im folgenden JSON-Format (kein anderer Text):
{
"rassen": [
{"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"},
{"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"}
],
"ist_hund": true,
"hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität"
}
Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100).
Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
try:
def _sync_call():
client = anthropic.Anthropic(api_key=api_key)
return client.messages.create(
model="claude-opus-4-7",
max_tokens=500,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
}
},
{
"type": "text",
"text": prompt_text,
}
]
}]
)
import asyncio
response = await asyncio.get_event_loop().run_in_executor(None, _sync_call)
raw = response.content[0].text.strip()
except anthropic.APIError as e:
raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}")
except Exception as e:
raise HTTPException(500, "Fehler bei der Bildanalyse.")
# JSON parsen — Claude kann manchmal ```json ... ``` wrappen
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.")
# Usage loggen (erst nach erfolgreicher KI-Antwort)
_log_rasse_request(user["id"])
remaining_after = remaining_before - 1
# Wiki-Slugs für erkannte Rassen nachschlagen
rassen = parsed.get("rassen", [])
if rassen:
with db() as conn:
for r in rassen:
name = r.get("name", "")
# Exakter Name-Match (case-insensitive)
row = conn.execute(
"SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,)
).fetchone()
r["wiki_slug"] = row["slug"] if row else None
return {
"rassen": rassen,
"ist_hund": parsed.get("ist_hund", False),
"hinweis": parsed.get("hinweis") or None,
"verbleibende_anfragen": remaining_after,
}

377
backend/routes/passport.py Normal file
View file

@ -0,0 +1,377 @@
"""BAN YARO — Digitaler Hundepass"""
import io
import secrets
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PassportMeta(BaseModel):
blutgruppe: Optional[str] = None
allergien: Optional[str] = None
besonderheiten: Optional[str] = None
class VaccinationCreate(BaseModel):
krankheit: str
datum: str
naechste: Optional[str] = None
tierarzt: Optional[str] = None
charge_nr: Optional[str] = None
class MedicationCreate(BaseModel):
name: str
dosierung: Optional[str] = None
von: Optional[str] = None
bis: Optional[str] = None
notiz: Optional[str] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Eigentümer-Prüfung
# ------------------------------------------------------------------
def _get_own_dog(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
def _load_passport_data(conn, dog_id: int) -> dict:
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
meta = conn.execute(
"SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)
).fetchone()
vaccinations = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
).fetchall()
medications = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
).fetchall()
return {
"dog": dict(dog),
"meta": dict(meta) if meta else {},
"vaccinations": [dict(v) for v in vaccinations],
"medications": [dict(m) for m in medications],
}
# ------------------------------------------------------------------
# GET /passport/{dog_id} — vollständige Passdaten
# ------------------------------------------------------------------
@router.get("/{dog_id}")
async def get_passport(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
return _load_passport_data(conn, dog_id)
# ------------------------------------------------------------------
# PUT /passport/{dog_id}/meta
# ------------------------------------------------------------------
@router.put("/{dog_id}/meta")
async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(dog_id) DO UPDATE SET
blutgruppe = excluded.blutgruppe,
allergien = excluded.allergien,
besonderheiten = excluded.besonderheiten,
updated_at = excluded.updated_at
""", (dog_id, data.blutgruppe, data.allergien, data.besonderheiten))
return {"ok": True}
# ------------------------------------------------------------------
# POST /passport/{dog_id}/vaccinations
# ------------------------------------------------------------------
@router.post("/{dog_id}/vaccinations")
async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr))
row = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /passport/{dog_id}/vaccinations/{vacc_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204)
async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute(
"DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id)
)
# ------------------------------------------------------------------
# POST /passport/{dog_id}/medications
# ------------------------------------------------------------------
@router.post("/{dog_id}/medications")
async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz))
row = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /passport/{dog_id}/medications/{med_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/medications/{med_id}", status_code=204)
async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute(
"DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id)
)
# ------------------------------------------------------------------
# POST /passport/{dog_id}/share — Share-Token erstellen
# ------------------------------------------------------------------
@router.post("/{dog_id}/share")
async def create_share(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
token = secrets.token_urlsafe(32)
valid_until = (date.today() + timedelta(days=30)).isoformat()
conn.execute("""
INSERT INTO passport_shares (dog_id, token, valid_until)
VALUES (?, ?, ?)
""", (dog_id, token, valid_until))
return {
"token": token,
"valid_until": valid_until,
"url": f"/pass/{token}",
}
# ------------------------------------------------------------------
# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth)
# ------------------------------------------------------------------
@router.get("/share/{token}")
async def get_shared_passport(token: str):
with db() as conn:
share = conn.execute(
"SELECT * FROM passport_shares WHERE token=?", (token,)
).fetchone()
if not share:
raise HTTPException(404, "Link nicht gefunden.")
if share["valid_until"] < date.today().isoformat():
raise HTTPException(410, "Dieser Link ist abgelaufen.")
return _load_passport_data(conn, share["dog_id"])
# ------------------------------------------------------------------
# GET /passport/{dog_id}/pdf — PDF generieren
# ------------------------------------------------------------------
@router.get("/{dog_id}/pdf")
async def download_pdf(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
data = _load_passport_data(conn, dog_id)
pdf_bytes = _generate_pdf(data)
dog_name = data["dog"]["name"].replace(" ", "_")
filename = f"Hundepass_{dog_name}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ------------------------------------------------------------------
# PDF-Generierung mit fpdf2
# ------------------------------------------------------------------
def _generate_pdf(data: dict) -> bytes:
try:
from fpdf import FPDF
except ImportError:
raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.")
dog = data["dog"]
meta = data["meta"]
vaccs = data["vaccinations"]
meds = data["medications"]
# Datumsformatierung DE
def _fmt_date(d):
if not d:
return ""
try:
return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return d
# Geschlecht
geschlecht_map = {"m": "Rüde", "w": "Hündin"}
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_page()
# ---- Header ----
pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün
pdf.rect(0, 0, 210, 38, style="F")
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", style="B", size=20)
pdf.set_y(8)
pdf.cell(0, 10, "Ban Yaro", align="C", ln=True)
pdf.set_font("Helvetica", size=11)
pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True)
pdf.set_font("Helvetica", size=8)
pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True)
pdf.set_text_color(30, 30, 30)
pdf.set_y(46)
# ---- Hundedaten ----
pdf.set_fill_color(245, 250, 247)
pdf.set_draw_color(200, 200, 200)
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B")
pdf.ln(3)
def _info_row(label, value):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, label + ":", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.cell(0, 6, str(value) if value else "", ln=True)
_info_row("Name", dog["name"])
_info_row("Rasse", dog.get("rasse") or "")
_info_row("Geburtstag", _fmt_date(dog.get("geburtstag")))
_info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), ""))
_info_row("Chip-Nr.", dog.get("chip_nr") or "")
if meta.get("blutgruppe"):
_info_row("Blutgruppe", meta["blutgruppe"])
pdf.ln(5)
# ---- Allergien & Besonderheiten ----
if meta.get("allergien") or meta.get("besonderheiten"):
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B")
pdf.ln(3)
if meta.get("allergien"):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, "Allergien:", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.multi_cell(0, 6, meta["allergien"])
if meta.get("besonderheiten"):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, "Besonderheiten:", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.multi_cell(0, 6, meta["besonderheiten"])
pdf.ln(5)
# ---- Impfungen ----
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B")
pdf.ln(3)
if vaccs:
# Tabellen-Header
pdf.set_fill_color(220, 240, 228)
pdf.set_font("Helvetica", style="B", size=8)
pdf.cell(50, 6, "Krankheit", border=1, fill=True)
pdf.cell(25, 6, "Datum", border=1, fill=True)
pdf.cell(25, 6, "Nächste fällig", border=1, fill=True)
pdf.cell(55, 6, "Tierarzt", border=1, fill=True)
pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True)
pdf.set_font("Helvetica", size=8)
for i, v in enumerate(vaccs):
fill = (i % 2 == 0)
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill)
pdf.cell(55, 6, (v["tierarzt"] or "")[:32], border=1, fill=fill)
pdf.cell(35, 6, (v["charge_nr"] or "")[:20], border=1, fill=fill, ln=True)
else:
pdf.set_font("Helvetica", style="I", size=9)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True)
pdf.set_text_color(30, 30, 30)
pdf.ln(5)
# ---- Medikamente ----
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B")
pdf.ln(3)
if meds:
pdf.set_fill_color(220, 240, 228)
pdf.set_font("Helvetica", style="B", size=8)
pdf.cell(55, 6, "Medikament", border=1, fill=True)
pdf.cell(35, 6, "Dosierung", border=1, fill=True)
pdf.cell(25, 6, "Von", border=1, fill=True)
pdf.cell(25, 6, "Bis", border=1, fill=True)
pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True)
pdf.set_font("Helvetica", size=8)
for i, m in enumerate(meds):
fill = (i % 2 == 0)
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill)
pdf.cell(35, 6, (m["dosierung"] or "")[:22], border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill)
bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft"
pdf.cell(25, 6, bis, border=1, fill=fill)
pdf.cell(50, 6, (m["notiz"] or "")[:30], border=1, fill=fill, ln=True)
else:
pdf.set_font("Helvetica", style="I", size=9)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True)
pdf.set_text_color(30, 30, 30)
# ---- Footer ----
pdf.set_y(-15)
pdf.set_font("Helvetica", style="I", size=8)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True)
return bytes(pdf.output())

364
backend/routes/playdate.py Normal file
View file

@ -0,0 +1,364 @@
"""BAN YARO — Playdate-Matching"""
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Haversine
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
if not geburtstag:
return None
try:
from datetime import date
geb = date.fromisoformat(geburtstag[:10])
today = date.today()
monate = (today.year - geb.year) * 12 + (today.month - geb.month)
if today.day < geb.day:
monate -= 1
if monate < 0:
return None
if monate < 24:
return f"{monate} {'Monat' if monate == 1 else 'Monate'}"
jahre = monate // 12
return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}"
except Exception:
return None
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ListingUpsert(BaseModel):
dog_id: int
lat: float
lon: float
ort_name: Optional[str] = None
radius_km: int = 10
beschreibung: Optional[str] = None
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = None
class RequestPatch(BaseModel):
status: str # accepted | declined
# ------------------------------------------------------------------
# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht)
# ------------------------------------------------------------------
def _ensure_conversation(conn, user_a: int, user_b: int) -> int:
a, b = (min(user_a, user_b), max(user_a, user_b))
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?",
(a, b)
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)",
(a, b)
)
return cur.lastrowid
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@router.get("/nearby")
async def nearby(lat: float, lon: float, radius: int = 10,
user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT pl.id AS listing_id,
pl.lat, pl.lon, pl.ort_name, pl.beschreibung,
d.id AS dog_id, d.name AS dog_name, d.rasse,
d.geburtstag, d.foto_url, d.geschlecht
FROM playdate_listings pl
JOIN dogs d ON d.id = pl.dog_id
WHERE pl.aktiv = 1
AND pl.user_id != ?
""", (uid,)).fetchall()
result = []
for r in rows:
dist = _haversine(lat, lon, r["lat"], r["lon"])
if dist <= radius:
result.append({
"listing_id": r["listing_id"],
"dog_id": r["dog_id"],
"dog_name": r["dog_name"],
"rasse": r["rasse"],
"alter": _calc_alter(r["geburtstag"]),
"geschlecht": r["geschlecht"],
"foto_url": r["foto_url"],
"ort_name": r["ort_name"],
"beschreibung": r["beschreibung"],
"entfernung_km": round(dist, 1),
})
result.sort(key=lambda x: x["entfernung_km"])
return result
@router.put("/listing", status_code=200)
async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Sicherstellen dass der Hund dem User gehört
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
existing = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=?",
(data.dog_id,)
).fetchone()
if existing:
conn.execute("""
UPDATE playdate_listings
SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?,
aktiv=1, updated_at=datetime('now')
WHERE dog_id=?
""", (data.lat, data.lon, data.ort_name, data.radius_km,
data.beschreibung, data.dog_id))
return {"ok": True, "id": existing["id"]}
else:
cur = conn.execute("""
INSERT INTO playdate_listings
(dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung)
VALUES (?,?,?,?,?,?,?)
""", (data.dog_id, uid, data.lat, data.lon, data.ort_name,
data.radius_km, data.beschreibung))
return {"ok": True, "id": cur.lastrowid}
@router.delete("/listing/{dog_id}", status_code=200)
async def deactivate_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?",
(dog_id, uid)
).fetchone()
if not row:
raise HTTPException(404, "Inserat nicht gefunden.")
conn.execute(
"UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?",
(dog_id,)
)
return {"ok": True}
@router.get("/my-listing/{dog_id}")
async def my_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"""SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv
FROM playdate_listings WHERE dog_id=? AND user_id=?""",
(dog_id, uid)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/request", status_code=201)
async def create_request(data: RequestCreate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users
own_dog = conn.execute(
"SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1",
(uid,)
).fetchone()
if not own_dog:
raise HTTPException(400, "Du hast noch keinen Hund eingetragen.")
from_dog_id = own_dog["id"]
# Zielhund + Besitzer prüfen
target = conn.execute(
"SELECT d.id, d.user_id FROM dogs d WHERE d.id=?",
(data.to_dog_id,)
).fetchone()
if not target:
raise HTTPException(404, "Zielhund nicht gefunden.")
if target["user_id"] == uid:
raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.")
to_user_id = target["user_id"]
# Doppelte Anfrage verhindern
existing = conn.execute(
"SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?",
(from_dog_id, data.to_dog_id)
).fetchone()
if existing:
if existing["status"] == "pending":
raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.")
# Alte abgelehnte Anfrage: löschen und neu anlegen
conn.execute(
"DELETE FROM playdate_requests WHERE id=?",
(existing["id"],)
)
cur = conn.execute("""
INSERT INTO playdate_requests
(from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht)
VALUES (?,?,?,?,?)
""", (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht))
request_id = cur.lastrowid
# Chat-Konversation anlegen (ohne Freundschaftspflicht)
conv_id = _ensure_conversation(conn, uid, to_user_id)
# Erste Nachricht mit Kontext senden
intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt."
if data.nachricht:
intro += f" Meine Nachricht: {data.nachricht}"
conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text)
VALUES (?,?,?)
""", (conv_id, uid, intro))
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
send_push_to_user(to_user_id, {
"title": "Playdate-Anfrage",
"body": f"{user['name']} möchte ein Treffen vereinbaren!",
"type": "playdate_request",
"tag": f"playdate-{request_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "request_id": request_id, "conversation_id": conv_id}
@router.get("/requests")
async def list_requests(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
incoming = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.from_user_id,
uf.name AS from_user_name,
df.name AS from_dog_name, df.rasse AS from_dog_rasse,
df.foto_url AS from_dog_foto,
df.geburtstag AS from_dog_geburtstag,
dt.name AS to_dog_name
FROM playdate_requests pr
JOIN users uf ON uf.id = pr.from_user_id
JOIN dogs df ON df.id = pr.from_dog_id
JOIN dogs dt ON dt.id = pr.to_dog_id
WHERE pr.to_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
outgoing = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.to_user_id,
ut.name AS to_user_name,
dt.name AS to_dog_name, dt.rasse AS to_dog_rasse,
dt.foto_url AS to_dog_foto,
df.name AS from_dog_name
FROM playdate_requests pr
JOIN users ut ON ut.id = pr.to_user_id
JOIN dogs dt ON dt.id = pr.to_dog_id
JOIN dogs df ON df.id = pr.from_dog_id
WHERE pr.from_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
def _enrich(rows, direction):
result = []
for r in rows:
d = dict(r)
d["direction"] = direction
if direction == "incoming":
d["alter"] = _calc_alter(d.get("from_dog_geburtstag"))
result.append(d)
return result
return {
"incoming": _enrich(incoming, "incoming"),
"outgoing": _enrich(outgoing, "outgoing"),
}
@router.patch("/requests/{req_id}", status_code=200)
async def patch_request(req_id: int, data: RequestPatch,
user=Depends(get_current_user)):
uid = user["id"]
if data.status not in ("accepted", "declined"):
raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.")
with db() as conn:
req = conn.execute(
"SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?",
(req_id, uid)
).fetchone()
if not req:
raise HTTPException(404, "Anfrage nicht gefunden.")
if req["status"] != "pending":
raise HTTPException(409, "Anfrage wurde bereits beantwortet.")
conn.execute(
"UPDATE playdate_requests SET status=? WHERE id=?",
(data.status, req_id)
)
conv_id = None
if data.status == "accepted":
conv_id = _ensure_conversation(conn, uid, req["from_user_id"])
try:
from routes.push import send_push_to_user
verb = "angenommen" if data.status == "accepted" else "abgelehnt"
send_push_to_user(req["from_user_id"], {
"title": f"Playdate {verb}!",
"body": f"{user['name']} hat deine Anfrage {verb}.",
"type": "playdate_response",
"tag": f"playdate-{req_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "conversation_id": conv_id}

138
backend/routes/recalls.py Normal file
View file

@ -0,0 +1,138 @@
"""BAN YARO — Rückruf-Alarm (Tierfutter)
RASFF EU Rapid Alert System for Food and Feed
"""
import logging
import httpx
from fastapi import APIRouter
from database import db
router = APIRouter()
logger = logging.getLogger(__name__)
RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters"
RASFF_PARAMS = {
"filters": '{"subject.product_category":["pet food and animal feed"]}',
"pageNumber": 0,
"pageSize": 20,
"sortColumn": "notificationDate",
"sortDirection": "DESC",
}
# ------------------------------------------------------------------
# GET /api/recalls — Letzte 50 Rückrufe
# ------------------------------------------------------------------
@router.get("")
async def list_recalls(q: str = ""):
with db() as conn:
if q:
like = f"%{q}%"
rows = conn.execute("""
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
FROM feed_recalls
WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ?
ORDER BY datum DESC
LIMIT 50
""", (like, like, like, like)).fetchall()
else:
rows = conn.execute("""
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
FROM feed_recalls
ORDER BY datum DESC
LIMIT 50
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Interne Hilfsfunktion: RASFF API abfragen
# ------------------------------------------------------------------
async def fetch_rasff_recalls() -> list[dict]:
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.error(f"RASFF API-Fehler: {e}")
return []
entries = []
try:
items = data.get("data", {}).get("list", [])
for item in items:
reference = item.get("reference", "")
if not reference:
continue
# Datum
datum_raw = item.get("notificationDate", "")
datum = datum_raw[:10] if datum_raw else ""
# Produkt
subject = item.get("subject") or {}
produkt = subject.get("product", "") or ""
# Gefahr
hazards = subject.get("hazard") or []
gefahr = ""
if hazards:
gefahr = hazards[0].get("hazardDescription", "") or ""
# Herkunft
origin = item.get("origin") or {}
herkunft = origin.get("name", "") or ""
# URL zur RASFF-Seite
url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}"
entries.append({
"external_id": reference,
"titel": produkt or reference,
"produkt": produkt,
"gefahr": gefahr,
"herkunft": herkunft,
"datum": datum,
"quelle": "rasff",
"url": url,
})
except Exception as e:
logger.error(f"RASFF Parsing-Fehler: {e}")
return entries
# ------------------------------------------------------------------
# Interne Hilfsfunktion: Neue Einträge in DB speichern
# ------------------------------------------------------------------
def save_new_recalls(entries: list[dict]) -> list[dict]:
"""Speichert neue Einträge und gibt die Liste der neuen Einträge zurück."""
new_entries = []
for entry in entries:
try:
with db() as conn:
exists = conn.execute(
"SELECT id FROM feed_recalls WHERE external_id=?",
(entry["external_id"],)
).fetchone()
if not exists:
conn.execute("""
INSERT INTO feed_recalls
(external_id, titel, produkt, gefahr, herkunft, datum, quelle, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["external_id"],
entry["titel"],
entry["produkt"],
entry["gefahr"],
entry["herkunft"],
entry["datum"],
entry["quelle"],
entry["url"],
))
new_entries.append(entry)
except Exception as e:
logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}")
return new_entries

114
backend/routes/streak.py Normal file
View file

@ -0,0 +1,114 @@
"""BAN YARO — Trainings-Streak"""
import datetime
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
_today = lambda: datetime.date.today().isoformat()
_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
# ------------------------------------------------------------------
# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde)
# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst.
# ------------------------------------------------------------------
@router.get("/streak/leaderboard")
async def get_leaderboard(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute("""
SELECT
u.name AS user_name,
d.name AS dog_name,
d.rasse,
d.foto_url,
ts.current_streak
FROM training_streaks ts
JOIN dogs d ON d.id = ts.dog_id
JOIN users u ON u.id = ts.user_id
WHERE ts.current_streak > 0
AND (d.is_public = 1 OR d.user_id = ts.user_id)
ORDER BY ts.current_streak DESC
LIMIT 10
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /streak/{dog_id} — aktueller Streak eines Hundes
# ------------------------------------------------------------------
@router.get("/streak/{dog_id}")
async def get_streak(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
row = conn.execute(
"SELECT current_streak, longest_streak, last_training_date "
"FROM training_streaks WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
if not row:
return {"current_streak": 0, "longest_streak": 0, "last_training_date": None}
return dict(row)
# ------------------------------------------------------------------
# POST /streak/{dog_id}/ping — Training heute registrieren
# ------------------------------------------------------------------
@router.post("/streak/{dog_id}/ping")
async def ping_streak(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
today = _today()
yest = _yesterday()
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
row = conn.execute(
"SELECT current_streak, longest_streak, last_training_date "
"FROM training_streaks WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
if row:
cur = row["current_streak"]
longest = row["longest_streak"]
last = row["last_training_date"]
if last == today:
# Bereits heute gepingt — nichts tun
return {"current_streak": cur, "longest_streak": longest, "last_training_date": last}
elif last == yest:
cur += 1
else:
cur = 1
longest = max(longest, cur)
conn.execute(
"UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? "
"WHERE user_id=? AND dog_id=?",
(cur, longest, today, uid, dog_id)
)
else:
cur = 1
longest = 1
conn.execute(
"INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) "
"VALUES (?,?,?,?,?)",
(uid, dog_id, cur, longest, today)
)
return {"current_streak": cur, "longest_streak": longest, "last_training_date": today}

View file

@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None:
return result
@router.get("/my-favorite")
async def get_my_favorite(user=Depends(get_current_user)):
"""Favoriten-Tierarzt des Users (oder null)."""
with db() as conn:
row = conn.execute(
"""SELECT t.* FROM tieraerzte t
JOIN favorite_vets fv ON fv.vet_id = t.id
WHERE fv.user_id = ?
LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/{vet_id}/favorite")
async def toggle_favorite(vet_id: int, user=Depends(get_current_user)):
"""Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück."""
with db() as conn:
vet = conn.execute(
"SELECT id FROM tieraerzte WHERE id=?", (vet_id,)
).fetchone()
if not vet:
raise HTTPException(404, "Tierarzt nicht gefunden.")
existing = conn.execute(
"SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?",
(user["id"], vet_id)
).fetchone()
if existing:
conn.execute(
"DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?",
(user["id"], vet_id)
)
return {"is_favorite": False}
else:
conn.execute(
"INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)",
(user["id"], vet_id)
)
return {"is_favorite": True}
@router.get("")
async def list_tieraerzte(user=Depends(get_current_user)):
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive."""
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
(user["id"],)
).fetchall()
return [dict(r) for r in rows]
favs = {r["vet_id"] for r in conn.execute(
"SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],)
).fetchall()}
result = []
for r in rows:
d = dict(r)
d["is_favorite"] = r["id"] in favs
result.append(d)
return result
@router.get("/osm-nearby")

View file

@ -132,8 +132,24 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 19:00 Uhr — Streak-Erinnerung
_scheduler.add_job(
_job_streak_reminder,
CronTrigger(hour=19, minute=0),
id="streak_reminder",
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF)
_scheduler.add_job(
_job_recall_check,
CronTrigger(hour=8, minute=0),
id="recall_check",
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. 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. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -855,6 +871,8 @@ async def _job_status_report():
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
"ki_health_report": "KI-Gesundheitsberichte",
"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)",
}
job_rows_html = ""
job_rows_txt = ""
@ -1172,3 +1190,79 @@ async def _job_hdm_winner():
logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.")
_log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)")
# ------------------------------------------------------------------
# JOB: Streak-Erinnerung (täglich 19:00)
# ------------------------------------------------------------------
async def _job_streak_reminder():
"""
Findet alle User die heute noch nicht trainiert haben (last_training_date < heute)
und deren current_streak > 0. Sendet einen motivierenden Push pro Hund.
"""
today = str(date.today())
logger.info(f"Streak-Reminder Job läuft für {today}")
with db() as conn:
rows = conn.execute("""
SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name
FROM training_streaks ts
JOIN dogs d ON d.id = ts.dog_id
WHERE ts.current_streak > 0
AND (ts.last_training_date IS NULL OR ts.last_training_date < ?)
""", (today,)).fetchall()
sent_total = 0
for r in rows:
n = r["current_streak"]
sent = send_push_to_user(r["user_id"], {
"type": "streak_reminder",
"title": f"🔥 {r['dog_name']} wartet auf sein Training!",
"body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.",
"data": {"page": "uebungen"},
"tag": f"streak-{r['dog_id']}-{today}",
})
sent_total += sent
logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.")
_log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde")
# ------------------------------------------------------------------
# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00)
# ------------------------------------------------------------------
async def _job_recall_check():
"""
Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab.
Neue Einträge werden in DB gespeichert, für jeden wird ein Push
an alle abonnierten User gesendet.
"""
logger.info("Rückruf-Check Job läuft")
try:
from routes.recalls import fetch_rasff_recalls, save_new_recalls
entries = await fetch_rasff_recalls()
if not entries:
logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).")
_log_job("recall_check", "ok", "0 neue Rückrufe (API leer)")
return
new_entries = save_new_recalls(entries)
logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.")
for entry in new_entries:
produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt"
gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen"
ext_id = entry["external_id"]
body = f"{produkt}{gefahr[:80]}"
send_push_to_all({
"title": "⚠️ Tierfutter-Rückruf",
"body": body,
"data": {"page": "recalls"},
"tag": f"recall-{ext_id}",
})
logger.info(f"Rückruf-Push gesendet: {ext_id}{produkt}")
_log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe")
except Exception as e:
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
_log_job("recall_check", "error", str(e))

View file

@ -6803,3 +6803,124 @@ svg.empty-state-icon {
pointer-events: none;
letter-spacing: 0.01em;
}
/* ------------------------------------------------------------
STREAK-WIDGET (Welcome-Seite)
------------------------------------------------------------ */
.wc-streak-card {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-5);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg, 14px);
background: linear-gradient(135deg, #ff6b00 0%, #c0392b 100%);
color: #fff;
box-shadow: 0 4px 18px rgba(196, 63, 0, 0.35);
position: relative;
overflow: hidden;
}
.wc-streak-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 10% 50%, rgba(255,255,255,0.15) 0%, transparent 60%);
pointer-events: none;
}
.wc-streak-flame-wrap {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.wc-streak-flame {
font-size: 2.2rem;
line-height: 1;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));
}
.wc-streak-number {
font-size: 2.6rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.wc-streak-info {
flex: 1;
min-width: 0;
}
.wc-streak-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
opacity: 0.95;
}
.wc-streak-best {
font-size: var(--text-xs);
opacity: 0.75;
margin-top: 2px;
}
.wc-streak-lb-btn {
background: rgba(255,255,255,0.2);
border: 1.5px solid rgba(255,255,255,0.45);
border-radius: 50%;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
transition: background 0.15s;
}
.wc-streak-lb-btn:active { background: rgba(255,255,255,0.35); }
/* ------------------------------------------------------------
KI RASSEN-ERKENNUNG Ergebnis-Block
------------------------------------------------------------ */
.rasse-result-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-3);
}
.rasse-result-card--top {
border-color: var(--c-primary);
background: var(--c-primary-subtle, #f0f9ff);
}
.rasse-result-name {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
color: var(--c-text);
margin-bottom: var(--space-1);
}
.rasse-result-bar-wrap {
background: var(--c-surface-2);
border-radius: 999px;
height: 8px;
overflow: hidden;
margin: var(--space-2) 0;
}
.rasse-result-bar {
height: 8px;
border-radius: 999px;
background: var(--c-primary);
transition: width 0.6s ease;
}
.rasse-result-bar--dim {
background: var(--c-text-muted, #9ca3af);
}
.rasse-result-pct {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-primary);
}
.rasse-result-pct--dim {
color: var(--c-text-muted);
}
.rasse-result-desc {
font-size: var(--text-xs);
color: var(--c-text-secondary);
margin-top: var(--space-1);
line-height: 1.4;
}

View file

@ -158,6 +158,9 @@
<div class="sidebar-item" data-page="notes">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
</div>
<div class="sidebar-item" data-page="expenses">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#receipt"></use></svg> Ausgaben
</div>
<span class="sidebar-section-label">Entdecken</span>
<div class="sidebar-item" data-page="map">
@ -172,12 +175,18 @@
<div class="sidebar-item" data-page="jobs">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg> Jobs
</div>
<div class="sidebar-item" data-page="adoption">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg> Adoption
</div>
<span class="sidebar-section-label">Soziales</span>
<div class="sidebar-item" data-page="friends">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="playdate">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Playdate
</div>
<div class="sidebar-item" data-page="chat">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
@ -189,6 +198,9 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="recalls">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Rückrufe
</div>
<div class="sidebar-item" data-page="walks">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
</div>
@ -459,6 +471,22 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-expenses">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-recalls">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-adoption">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-playdate">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->

View file

@ -195,6 +195,17 @@ const API = (() => {
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
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`); },
};
// ----------------------------------------------------------
// GESUNDHEITSDOKUMENTE
// ----------------------------------------------------------
const healthDocs = {
list(dogId) { return get(`/health-docs?dog_id=${dogId}`); },
upload(formData) { return upload('/health-docs/upload', formData); },
delete(id) { return del(`/health-docs/${id}`); },
};
// ----------------------------------------------------------
@ -712,7 +723,7 @@ const API = (() => {
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
breeder, litters, breederPhotos, zuchthunde, zuchtKi,

View file

@ -71,6 +71,10 @@ const App = (() => {
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null },
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
};
// ----------------------------------------------------------
@ -86,6 +90,7 @@ const App = (() => {
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null },
};
// ----------------------------------------------------------

View file

@ -0,0 +1,483 @@
/* ============================================================
BAN YARO Adoption (Tierheim-Hunde in der Nähe)
Seiten-Modul: Hunde aus deutschen Tierheimen finden.
============================================================ */
window.Page_adoption = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _lat = null;
let _lon = null;
let _radius = 50;
let _rasseFilter = '';
let _activeTab = 'hunde';
let _data = null; // { animals, shelters, has_petfinder }
let _loading = false;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
// Standort automatisch versuchen
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
if (_lat && _lon) {
await _loadData();
}
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<!-- Filter-Leiste -->
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3);align-items:center">
<select id="adp-radius" class="form-control" style="width:auto;min-width:110px">
<option value="10">10 km</option>
<option value="25">25 km</option>
<option value="50" selected>50 km</option>
<option value="100">100 km</option>
</select>
<input id="adp-rasse" class="form-control" type="text"
placeholder="Rasse filtern…"
style="flex:1;min-width:120px;max-width:220px"
value="${_esc(_rasseFilter)}">
<button class="btn btn-secondary" id="adp-btn-locate"
style="white-space:nowrap">
${UI.icon('map-pin')} Mein Standort
</button>
</div>
<!-- PLZ-Fallback (anfangs versteckt) -->
<div id="adp-plz-row" style="display:none;margin-bottom:var(--space-3)">
<div style="display:flex;gap:var(--space-2);align-items:center">
<input id="adp-plz" class="form-control" type="text"
inputmode="numeric" maxlength="5"
placeholder="PLZ eingeben (z.B. 80331)"
style="max-width:180px">
<button class="btn btn-primary" id="adp-btn-geocode">Suchen</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">
Kein Standort verfügbar PLZ als Ausgangspunkt eingeben.
</p>
</div>
<!-- Tabs -->
<div style="display:flex;gap:var(--space-1);margin-bottom:var(--space-4);
border-bottom:1px solid var(--c-border)">
<button class="adp-tab adp-tab--active" data-tab="hunde"
style="padding:var(--space-2) var(--space-3);background:none;border:none;
cursor:pointer;font-weight:600;color:var(--c-primary);
border-bottom:2px solid var(--c-primary);font-size:var(--text-sm)">
${UI.icon('paw-print')} Hunde
</button>
<button class="adp-tab" data-tab="tierheime"
style="padding:var(--space-2) var(--space-3);background:none;border:none;
cursor:pointer;color:var(--c-text-secondary);
border-bottom:2px solid transparent;font-size:var(--text-sm)">
${UI.icon('house-line')} Tierheime
</button>
</div>
<!-- Inhalt -->
<div id="adp-content">
${UI.skeleton(4)}
</div>
`;
// Events
_container.querySelector('#adp-radius')
?.addEventListener('change', e => {
_radius = parseInt(e.target.value);
if (_lat && _lon) _loadData();
});
_container.querySelector('#adp-rasse')
?.addEventListener('input', e => {
_rasseFilter = e.target.value.trim().toLowerCase();
_renderContent();
});
_container.querySelector('#adp-btn-locate')
?.addEventListener('click', _locateUser);
_container.querySelector('#adp-btn-geocode')
?.addEventListener('click', _geocodePLZ);
_container.querySelector('#adp-plz')
?.addEventListener('keydown', e => {
if (e.key === 'Enter') _geocodePLZ();
});
_container.querySelectorAll('.adp-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
_container.querySelectorAll('.adp-tab').forEach(b => {
const isActive = b.dataset.tab === _activeTab;
b.style.color = isActive ? 'var(--c-primary)' : 'var(--c-text-secondary)';
b.style.fontWeight = isActive ? '600' : 'normal';
b.style.borderBottom = isActive ? '2px solid var(--c-primary)' : '2px solid transparent';
});
_renderContent();
});
});
}
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() {
try {
const pos = await API.getLocation({ timeout: 6000, maximumAge: 300_000 });
_lat = pos.lat;
_lon = pos.lon;
await _loadData();
} catch {
// Standort nicht verfügbar → PLZ-Eingabe zeigen
document.getElementById('adp-plz-row')?.style.setProperty('display', 'flex', 'important');
document.getElementById('adp-plz-row').style.display = 'flex';
_showNoLocation();
}
}
async function _locateUser() {
const btn = _container.querySelector('#adp-btn-locate');
if (btn) btn.disabled = true;
try {
const pos = await API.getLocation({ timeout: 10000 });
_lat = pos.lat;
_lon = pos.lon;
document.getElementById('adp-plz-row').style.display = 'none';
await _loadData();
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden. Bitte PLZ eingeben.');
document.getElementById('adp-plz-row').style.display = 'flex';
} finally {
if (btn) btn.disabled = false;
}
}
async function _geocodePLZ() {
const plz = (_container.querySelector('#adp-plz')?.value || '').trim();
if (!plz) return;
const btn = _container.querySelector('#adp-btn-geocode');
if (btn) btn.disabled = true;
try {
const geo = await API.get(`/adoption/geocode?plz=${encodeURIComponent(plz)}`);
if (geo.lat && geo.lon) {
_lat = geo.lat;
_lon = geo.lon;
await _loadData();
} else {
UI.toast.error(`PLZ "${plz}" nicht gefunden.`);
}
} catch {
UI.toast.error('Geocoding fehlgeschlagen. Bitte erneut versuchen.');
} finally {
if (btn) btn.disabled = false;
}
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadData() {
if (_loading || !_lat || !_lon) return;
_loading = true;
const content = _container.querySelector('#adp-content');
if (content) content.innerHTML = UI.skeleton(4);
try {
_data = await API.get(`/adoption/nearby?lat=${_lat}&lon=${_lon}&radius=${_radius}`);
_renderContent();
} catch {
if (content) content.innerHTML = UI.emptyState({
icon: 'warning',
title: 'Daten konnten nicht geladen werden',
text: 'Bitte versuche es erneut.',
});
} finally {
_loading = false;
}
}
// ----------------------------------------------------------
// INHALT RENDERN (je nach Tab)
// ----------------------------------------------------------
function _renderContent() {
const content = _container.querySelector('#adp-content');
if (!content) return;
if (!_data) { _showNoLocation(); return; }
if (_activeTab === 'hunde') _renderHunde(content);
else _renderTierheime(content);
}
function _showNoLocation() {
const content = _container.querySelector('#adp-content');
if (!content) return;
content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
<h3 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
in deiner Umgebung zu finden.
</p>
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
${UI.icon('arrow-square-out')} Alle Hunde auf Tierheimhelden.de
</a>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: HUNDE
// ------------------------------------------------------------------
function _renderHunde(content) {
let animals = (_data?.animals || []);
// Rasse-Filter
if (_rasseFilter) {
animals = animals.filter(a =>
(a.rasse || '').toLowerCase().includes(_rasseFilter) ||
(a.name || '').toLowerCase().includes(_rasseFilter)
);
}
const hasPetFinder = _data?.has_petfinder;
const infoText = hasPetFinder
? `${animals.length} Hunde im Umkreis von ${_radius} km (via PetFinder)`
: '';
if (!animals.length) {
content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
${UI.icon('arrow-square-out')} Hunde auf Tierheimhelden.de suchen
</a>
<a href="https://www.tierschutz.com/tierheimsuche/"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary">
${UI.icon('magnifying-glass')} Tierheimsuche auf tierschutz.com
</a>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-4)">
Tipp: Schau auch im Tab Tierheime" nach lokalen Tierheimen direkt.
</p>
`;
return;
}
content.innerHTML = `
${infoText ? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">${infoText}</p>` : ''}
<div class="adp-grid"
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:var(--space-3)">
${animals.map(a => _animalCard(a)).join('')}
</div>
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Mehr Hunde finden:
</p>
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary" style="font-size:var(--text-sm)">
${UI.icon('arrow-square-out')} Tierheimhelden.de alle Hunde
</a>
</div>
`;
// Klick-Events
content.querySelectorAll('[data-adp-url]').forEach(card => {
card.addEventListener('click', () => {
window.open(card.dataset.adpUrl, '_blank', 'noopener,noreferrer');
});
});
}
function _animalCard(a) {
const foto = a.foto_url
? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : '';
const alterTxt = a.alter_jahre != null ? `${_formatAlter(a.alter_jahre)}` : '';
const rasseTxt = a.rasse || '';
const tierheim = a.tierheim || '';
return `
<div data-adp-url="${_esc(a.adoptions_url)}"
style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2);cursor:pointer;
box-shadow:0 1px 4px rgba(0,0,0,0.08);
transition:transform .15s,box-shadow .15s"
onmouseenter="this.style.transform='translateY(-2px)';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'"
onmouseleave="this.style.transform='';this.style.boxShadow='0 1px 4px rgba(0,0,0,0.08)'">
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
${foto}
</div>
<div style="padding:var(--space-2) var(--space-2) var(--space-3)">
<div style="font-weight:600;font-size:var(--text-sm);
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(a.name)}
</div>
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(rasseTxt)}
</div>` : ''}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterTxt)}
</span>` : ''}
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${a.geschlecht === 'männlich' ? '♂' : '♀'}
</span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${_esc(distTxt)}
</span>` : ''}
</div>
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
${UI.icon('house-line')} ${_esc(tierheim)}
</div>` : ''}
</div>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: TIERHEIME
// ------------------------------------------------------------------
function _renderTierheime(content) {
const shelters = _data?.shelters || [];
if (!shelters.length) {
content.innerHTML = `
<div style="text-align:center;padding:var(--space-6) var(--space-4)">
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Keine Tierheime im Umkreis von ${_radius} km gefunden.
</p>
<a href="https://www.tierheimhelden.de"
target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
${UI.icon('arrow-square-out')} Tierheimhelden.de
</a>
</div>
`;
return;
}
content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${shelters.map(s => _shelterRow(s)).join('')}
</div>
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Noch mehr Tierheime:
</p>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<a href="https://www.tierheimhelden.de"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
${UI.icon('arrow-square-out')} Tierheimhelden.de
</a>
<a href="https://www.tierschutz.com/tierheimsuche/"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
${UI.icon('magnifying-glass')} tierschutz.com
</a>
</div>
</div>
`;
}
function _shelterRow(s) {
return `
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit;
border:1px solid var(--c-border);
transition:background .15s"
onmouseenter="this.style.background='var(--c-surface-3)'"
onmouseleave="this.style.background='var(--c-surface-2)'">
<div style="width:40px;height:40px;border-radius:50%;
background:var(--c-primary-light,#ede9fe);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:1.2rem">
🏠
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(s.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_esc(s.plz)} ${_esc(s.stadt)}
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
<span style="font-size:var(--text-xs);font-weight:600;
color:var(--c-primary);background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:2px 8px">
${s.distanz_km} km
</span>
<span style="font-size:10px;color:var(--c-text-muted)">Hunde ansehen ${UI.icon('arrow-right')}</span>
</div>
</a>
`;
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN
// ----------------------------------------------------------
function _formatAlter(jahre) {
if (jahre < 0.5) return 'Welpe';
if (jahre < 1) return 'Jungtier';
if (jahre < 2) return `${Math.round(jahre)} Jahr`;
if (jahre < 10) return `${Math.round(jahre)} Jahre`;
return 'Senior';
}
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -97,19 +97,8 @@ window.Page_dog_profile = (() => {
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
${(dog.hdm_wins?.length) ? `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);justify-content:center;margin-bottom:var(--space-5)">
${dog.hdm_wins.map(m => {
const [y, mo] = m.split('-');
const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+y, +mo - 1, 1));
return `<span class="dp-hdm-badge" title="Hund des Monats ${label}">🏆 ${label}</span>`;
}).join('')}
</div>
` : `<div style="margin-bottom:var(--space-5)"></div>`}
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-5)"></p>`}
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
@ -208,6 +197,10 @@ window.Page_dog_profile = (() => {
Teilen
</button>` : ''}
</div>
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-passport-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
Hundepass
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>` : ''}
@ -276,6 +269,10 @@ window.Page_dog_profile = (() => {
_showShareModal(dog);
});
document.getElementById('dp-passport-btn')?.addEventListener('click', () => {
_showPassportModal(dog);
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@ -1007,7 +1004,7 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">Foto</label>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
<img id="dp-form-preview"
src="${dog?.foto_url || ''}"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
@ -1018,6 +1015,16 @@ window.Page_dog_profile = (() => {
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
style="margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Rasse erkennen
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="dp-rasse-foto-input" style="display:none">
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen
</div>
</div>
@ -1097,6 +1104,9 @@ window.Page_dog_profile = (() => {
});
}
// Rassen-Erkennung per KI
_bindRasseErkennung();
document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close);
@ -1182,6 +1192,152 @@ window.Page_dog_profile = (() => {
});
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Formular)
// ----------------------------------------------------------
function _bindRasseErkennung() {
const btn = document.getElementById('dp-rasse-erkennen-btn');
const fileInput = document.getElementById('dp-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast.error('Bild zu groß (max. 5 MB).');
return;
}
const origLabel = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origLabel;
_showRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origLabel;
UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.');
}
});
}
function _showRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p style="color:var(--c-text-secondary)">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Diese wählen
</button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
data-slug="${_esc(r.wiki_slug)}">
Im Wiki
</button>` : ''}
</div>
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="dp-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('dp-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => {
btn.addEventListener('click', () => {
const rasse = btn.dataset.rasse;
const rasseInput = document.getElementById('dp-rasse-input');
const rasseIdInput = document.getElementById('dp-rasse-id');
const matchBadge = document.getElementById('dp-rasse-match');
if (rasseInput) {
rasseInput.value = rasse;
rasseInput.dispatchEvent(new Event('input'));
}
UI.modal.close();
UI.toast.success(`Rasse "${rasse}" übernommen.`);
});
});
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
App.navigate('wiki');
setTimeout(() => {
if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') {
Page_wiki._openBreedDetail(btn.dataset.slug);
}
}, 400);
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
@ -1207,6 +1363,431 @@ window.Page_dog_profile = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// HUNDEPASS
// ----------------------------------------------------------
async function _showPassportModal(dog) {
UI.modal.open({
title: `Hundepass — ${_esc(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-secondary" id="pp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
Link teilen
</button>
<a class="btn btn-primary" id="pp-pdf-btn"
href="/api/passport/${dog.id}/pdf" target="_blank" download>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-pdf"></use></svg>
PDF herunterladen
</a>
</div>`,
size: 'large',
});
document.getElementById('pp-share-btn')?.addEventListener('click', () => {
_createPassportShare(dog);
});
await _loadPassportBody(dog);
}
async function _loadPassportBody(dog) {
const wrap = document.getElementById('pp-body');
if (!wrap) return;
let data;
try {
data = await API.get(`/passport/${dog.id}`);
} catch (e) {
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
return;
}
const _fmt = d => {
if (!d) return '';
try {
const p = d.substring(0, 10).split('-');
return `${p[2]}.${p[1]}.${p[0]}`;
} catch { return d; }
};
const meta = data.meta || {};
const vaccs = data.vaccinations || [];
const meds = data.medications || [];
wrap.innerHTML = `
<!-- Meta: Blutgruppe, Allergien, Besonderheiten -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Gesundheits-Info
</span>
<button class="btn btn-link btn-sm" id="pp-meta-edit-btn">Bearbeiten</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
</div>
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
</div>
</div>
</div>
${meta.besonderheiten ? `
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
${_esc(meta.besonderheiten)}
</div>
</div>` : ''}
</div>
<!-- Impfungen -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
Impfungen
</span>
<button class="btn btn-primary btn-sm" id="pp-vacc-add-btn">+ Eintragen</button>
</div>
<div id="pp-vacc-list">
${vaccs.length === 0
? '<p style="color:var(--c-text-muted);font-size:var(--text-sm);margin:0">Keine Impfungen eingetragen.</p>'
: vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}"
style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
<!-- Medikamente -->
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
Medikamente
</span>
<button class="btn btn-primary btn-sm" id="pp-med-add-btn">+ Eintragen</button>
</div>
<div id="pp-med-list">
${meds.length === 0
? '<p style="color:var(--c-text-muted);font-size:var(--text-sm);margin:0">Keine Medikamente eingetragen.</p>'
: meds.map(m => `
<div class="pp-med-row" data-id="${m.id}"
style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
`;
// Meta bearbeiten
document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => {
_editPassportMeta(dog, meta, () => _loadPassportBody(dog));
});
// Impfung hinzufügen
document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => {
_addVaccination(dog, () => _loadPassportBody(dog));
});
// Impfung löschen
wrap.querySelectorAll('.pp-vacc-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Impfung wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
// Medikament hinzufügen
document.getElementById('pp-med-add-btn')?.addEventListener('click', () => {
_addMedication(dog, () => _loadPassportBody(dog));
});
// Medikament löschen
wrap.querySelectorAll('.pp-med-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Medikament wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
}
function _editPassportMeta(dog, current, onSave) {
UI.modal.open({
title: 'Gesundheits-Info bearbeiten',
body: `
<div class="form-group">
<label class="form-label">Blutgruppe</label>
<input id="pp-meta-bg" class="form-control" type="text"
value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
</div>
<div class="form-group">
<label class="form-label">Allergien</label>
<textarea id="pp-meta-al" class="form-control" rows="2"
placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Besonderheiten</label>
<textarea id="pp-meta-be" class="form-control" rows="2"
placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
</div>`,
});
document.getElementById('pp-meta-save').addEventListener('click', async () => {
const btn = document.getElementById('pp-meta-save');
UI.setLoading(btn, true);
try {
await API.put(`/passport/${dog.id}/meta`, {
blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null,
allergien: document.getElementById('pp-meta-al').value.trim() || null,
besonderheiten: document.getElementById('pp-meta-be').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Gesundheits-Info gespeichert.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addVaccination(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Impfung eintragen',
body: `
<div class="form-group">
<label class="form-label">Krankheit *</label>
<input id="pp-vacc-krankheit" class="form-control" type="text"
placeholder="z. B. Staupe, Parvovirose, Tollwut, DHPP" list="pp-vacc-list">
<datalist id="pp-vacc-list">
<option value="Staupe">
<option value="Parvovirose">
<option value="Hepatitis (HCC)">
<option value="Leptospirose">
<option value="Tollwut">
<option value="Kennel-Husten (Bordetella)">
<option value="Borreliose">
<option value="DHPP (Kombi)">
</datalist>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Nächste fällig</label>
<input id="pp-vacc-naechste" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input id="pp-vacc-tierarzt" class="form-control" type="text" placeholder="Name der Praxis">
</div>
<div class="form-group">
<label class="form-label">Charge-Nr.</label>
<input id="pp-vacc-charge" class="form-control" type="text" placeholder="Chargennummer des Impfstoffs">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
</div>`,
});
document.getElementById('pp-vacc-save').addEventListener('click', async () => {
const krankheit = document.getElementById('pp-vacc-krankheit').value.trim();
const datum = document.getElementById('pp-vacc-datum').value;
if (!krankheit || !datum) {
UI.toast.warning('Bitte Krankheit und Datum angeben.');
return;
}
const btn = document.getElementById('pp-vacc-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/vaccinations`, {
krankheit,
datum,
naechste: document.getElementById('pp-vacc-naechste').value || null,
tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null,
charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Impfung eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addMedication(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Medikament eintragen',
body: `
<div class="form-group">
<label class="form-label">Medikament *</label>
<input id="pp-med-name" class="form-control" type="text"
placeholder="z. B. Frontline, Milbemax, Onsior">
</div>
<div class="form-group">
<label class="form-label">Dosierung</label>
<input id="pp-med-dosierung" class="form-control" type="text"
placeholder="z. B. 1× täglich, 5 mg">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
<input id="pp-med-bis" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input id="pp-med-notiz" class="form-control" type="text"
placeholder="z. B. nach dem Fressen geben">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
</div>`,
});
document.getElementById('pp-med-save').addEventListener('click', async () => {
const name = document.getElementById('pp-med-name').value.trim();
if (!name) {
UI.toast.warning('Bitte einen Namen angeben.');
return;
}
const btn = document.getElementById('pp-med-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/medications`, {
name,
dosierung: document.getElementById('pp-med-dosierung').value.trim() || null,
von: document.getElementById('pp-med-von').value || null,
bis: document.getElementById('pp-med-bis').value || null,
notiz: document.getElementById('pp-med-notiz').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Medikament eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
async function _createPassportShare(dog) {
const btn = document.getElementById('pp-share-btn');
if (btn) UI.setLoading(btn, true);
try {
const res = await API.post(`/passport/${dog.id}/share`, {});
const url = `${location.origin}${res.url}`;
if (btn) UI.setLoading(btn, false);
// Zeige Share-Link im Modal (window.confirm wäre zu kurz)
const shareWrap = document.createElement('div');
shareWrap.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen.
</p>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input id="pp-sharelink-input" class="form-control" type="text" readonly
value="${_esc(url)}" style="font-size:var(--text-xs)">
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
Gültig bis: ${res.valid_until.split('-').reverse().join('.')}
</p>`;
UI.modal.open({
title: 'Hundepass-Link teilen',
body: shareWrap.innerHTML,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(url).catch(() => {});
UI.toast.success('Link kopiert!');
});
} catch (e) {
if (btn) UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler beim Erstellen des Links.');
}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -0,0 +1,493 @@
/* ============================================================
BAN YARO Ausgaben-Tracker
Tabs: Übersicht | Einträge | Statistik
============================================================ */
window.Page_expenses = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
// Cache
let _summary = null;
let _entries = [];
// Monats-Statistik-Daten (pro Monat und Kategorie)
let _statsData = null;
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Einträge', icon: 'list-bullets' },
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
];
const KATEGORIEN = [
{ id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' },
{ id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' },
{ id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' },
{ id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' },
{ id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' },
{ id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' },
];
function _kat(id) {
return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' };
}
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_summary = null;
_entries = [];
_statsData = null;
_render();
}
async function refresh() {
_summary = null;
_entries = [];
_statsData = null;
await _renderTab();
}
// ----------------------------------------------------------
// SHELL
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-tabs exp-tabs" id="exp-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
</button>
`;
_container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.tab;
_container.querySelectorAll('#exp-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
_container.querySelector('#exp-fab')
?.addEventListener('click', () => _showForm(null));
_renderTab();
}
// ----------------------------------------------------------
// TAB ROUTER
// ----------------------------------------------------------
async function _renderTab() {
const el = _container.querySelector('#exp-content');
if (!el) return;
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
try {
switch (_tab) {
case 'uebersicht': await _renderUebersicht(el); break;
case 'eintraege': await _renderEintraege(el); break;
case 'statistik': await _renderStatistik(el); break;
}
} catch (e) {
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
}
}
// ----------------------------------------------------------
// TAB: ÜBERSICHT
// ----------------------------------------------------------
async function _renderUebersicht(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
const s = _summary;
const kacheln = KATEGORIEN.map(k => {
const betrag = s.monat[k.id] || 0;
return `
<div class="exp-kachel">
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-kachel-label">${k.label}</div>
<div class="exp-kachel-betrag">${_fmt(betrag)}</div>
</div>`;
}).join('');
const letzteMonat = await _getLetzteMonateData();
const vergleich = letzteMonat.length > 1
? _vergleichHtml(letzteMonat)
: '';
el.innerHTML = `
<div class="exp-gesamt-card">
<div class="exp-gesamt-label">Dieser Monat</div>
<div class="exp-gesamt-betrag">${_fmt(s.gesamt_monat)}</div>
<div class="exp-gesamt-sub">
${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)}
</div>
</div>
<div class="exp-kachel-grid">${kacheln}</div>
${vergleich}
<div style="height:80px"></div>
`;
}
async function _getLetzteMonateData() {
// Letzten 6 Monate aus den Einträgen berechnen
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const monatMap = {};
_entries.forEach(e => {
const m = e.datum.substring(0, 7); // YYYY-MM
monatMap[m] = (monatMap[m] || 0) + e.betrag;
});
return Object.entries(monatMap)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 6)
.reverse();
}
function _vergleichHtml(data) {
if (!data.length) return '';
const max = Math.max(...data.map(d => d[1]), 1);
const balken = data.map(([monat, summe]) => {
const pct = Math.round((summe / max) * 100);
const [y, m] = monat.split('-');
const label = new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('de-DE', { month: 'short' });
return `
<div class="exp-bar-item">
<div class="exp-bar-track">
<div class="exp-bar-fill" style="height:${pct}%"></div>
</div>
<div class="exp-bar-label">${label}</div>
<div class="exp-bar-val">${_fmtShort(summe)}</div>
</div>`;
}).join('');
return `
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Verlauf (6 Monate)</div>
<div class="exp-bar-chart">${balken}</div>
</div>`;
}
// ----------------------------------------------------------
// TAB: EINTRÄGE
// ----------------------------------------------------------
async function _renderEintraege(el) {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
if (!_entries.length) {
el.innerHTML = UI.emptyState({
icon: UI.icon('receipt'),
title: 'Noch keine Ausgaben',
text: 'Tippe auf + um deine erste Ausgabe einzutragen.',
});
return;
}
// Nach Monat gruppieren
const groups = {};
_entries.forEach(e => {
const m = e.datum.substring(0, 7);
if (!groups[m]) groups[m] = [];
groups[m].push(e);
});
const html = Object.entries(groups)
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([monat, items]) => {
const [y, m] = monat.split('-');
const titel = new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('de-DE', { month: 'long', year: 'numeric' });
const summe = items.reduce((s, e) => s + e.betrag, 0);
const rows = items.map(e => {
const k = _kat(e.kategorie);
const datum = new Date(e.datum + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const dogBadge = e.dog_name
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
: '';
const notiz = e.notiz
? `<span class="exp-notiz">${_esc(e.notiz)}</span>`
: '';
return `
<div class="exp-entry" data-id="${e.id}">
<div class="exp-entry-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
<span class="exp-entry-datum">${datum}</span>
</div>
${notiz}
</div>
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
</div>`;
}).join('');
return `
<div class="exp-month-group">
<div class="exp-month-header">
<span class="exp-month-title">${titel}</span>
<span class="exp-month-summe">${_fmt(summe)}</span>
</div>
${rows}
</div>`;
}).join('');
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
el.querySelectorAll('.exp-entry').forEach(row => {
row.addEventListener('click', () => {
const id = parseInt(row.dataset.id);
const entry = _entries.find(e => e.id === id);
if (entry) _showForm(entry);
});
});
}
// ----------------------------------------------------------
// TAB: STATISTIK
// ----------------------------------------------------------
async function _renderStatistik(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const s = _summary;
const gesamtJahr = s.gesamt_jahr || 1;
// Jahres-Aufteilung nach Kategorien
const katBalken = KATEGORIEN
.filter(k => (s.jahr[k.id] || 0) > 0)
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
.map(k => {
const val = s.jahr[k.id] || 0;
const pct = Math.round((val / gesamtJahr) * 100);
return `
<div class="exp-stat-row">
<div class="exp-stat-label">
<span style="color:${k.color}">${UI.icon(k.icon)}</span>
${k.label}
</div>
<div class="exp-stat-bar-wrap">
<div class="exp-stat-bar" style="width:${pct}%;background:${k.color}"></div>
</div>
<div class="exp-stat-pct">${pct}%</div>
<div class="exp-stat-val">${_fmt(val)}</div>
</div>`;
}).join('');
// Monats-Balken (aktuelles Jahr, Monat für Monat)
const heute = new Date();
const jahrStr = heute.getFullYear().toString();
const monatMap = {};
_entries
.filter(e => e.datum.startsWith(jahrStr))
.forEach(e => {
const m = parseInt(e.datum.split('-')[1]);
monatMap[m] = (monatMap[m] || 0) + e.betrag;
});
const maxMonat = Math.max(...Object.values(monatMap), 1);
const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
const monatsBalken = MONATE.map((label, i) => {
const val = monatMap[i + 1] || 0;
const pct = Math.round((val / maxMonat) * 100);
const isAktiv = (i + 1) === (heute.getMonth() + 1);
return `
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
<div class="exp-bar-track">
<div class="exp-bar-fill${isAktiv ? ' exp-bar-fill--aktiv' : ''}"
style="height:${pct}%"></div>
</div>
<div class="exp-bar-label">${label}</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="exp-gesamt-card exp-gesamt-card--sm">
<div class="exp-gesamt-label">Gesamt dieses Jahr</div>
<div class="exp-gesamt-betrag">${_fmt(s.gesamt_jahr)}</div>
</div>
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}</div>
<div class="exp-bar-chart exp-bar-chart--12">${monatsBalken}</div>
</div>
<div class="exp-section">
<div class="exp-section-title">${UI.icon('pie-chart')} Aufteilung nach Kategorie</div>
<div class="exp-stat-rows">
${katBalken || `<div class="exp-empty-hint">Noch keine Ausgaben dieses Jahr.</div>`}
</div>
</div>
<div style="height:80px"></div>
`;
}
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form';
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join('');
const katOptions = KATEGORIEN.map(k =>
`<option value="${k.id}"${(entry?.kategorie || 'sonstiges') === k.id ? ' selected' : ''}>
${k.label}
</option>`
).join('');
const body = `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Datum</label>
<input type="date" name="datum" class="form-input"
value="${entry?.datum || today}" required>
</div>
<div class="form-group">
<label class="form-label">Kategorie</label>
<select name="kategorie" class="form-input" required>
${katOptions}
</select>
</div>
<div class="form-group">
<label class="form-label">Betrag ()</label>
<input type="number" name="betrag" class="form-input"
value="${entry?.betrag || ''}"
min="0.01" step="0.01" placeholder="0,00" required>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund (optional)</label>
<select name="dog_id" class="form-input">
<option value=""> kein Hund zugeordnet </option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Notiz (optional)</label>
<input type="text" name="notiz" class="form-input"
value="${_esc(entry?.notiz || '')}"
placeholder="z. B. Impfung, Trockenfutter Vorrat …">
</div>
</form>`;
const footer = isEdit ? `
<button type="button" class="btn btn-danger" id="exp-delete-btn">Löschen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
` : `
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
`;
const modal = UI.modal.open({
title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe',
body,
footer,
});
if (isEdit) {
modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => {
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
try {
await API.del(`/expenses/${entry.id}`);
UI.modal.close();
UI.toast.success('Ausgabe gelöscht.');
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
}
modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = UI.formData(ev.target);
const body = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
datum: fd.datum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
};
try {
if (isEdit) {
await API.patch(`/expenses/${entry.id}`, body);
UI.toast.success('Ausgabe aktualisiert.');
} else {
await API.post('/expenses', body);
UI.toast.success('Ausgabe gespeichert.');
}
UI.modal.close();
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Speichern.');
}
});
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _invalidateCache() {
_summary = null;
_entries = [];
_statsData = null;
}
function _fmt(val) {
return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
function _fmtShort(val) {
if (!val) return '0 €';
if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€';
return Math.round(val) + ' €';
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh };
})();

View file

@ -6,11 +6,13 @@
window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _favoritVet = null;
let _healthDocs = [];
const BASE_TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
@ -150,8 +152,12 @@ window.Page_health = (() => {
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
</button>
</div>
${transponderHtml}
<div id="health-mein-tierarzt"></div>
<div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></div>
<div id="health-reminders"></div>
@ -162,6 +168,8 @@ window.Page_health = (() => {
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn')
.addEventListener('click', _showKiTierarzt);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
@ -170,6 +178,7 @@ window.Page_health = (() => {
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
_loadMeinTierarzt();
}
// ----------------------------------------------------------
@ -342,6 +351,16 @@ window.Page_health = (() => {
} catch (err) {
_data['gewicht_chart'] = [];
}
try {
_favoritVet = await API.tieraerzte.myFavorite();
} catch (err) {
_favoritVet = null;
}
try {
_healthDocs = await API.healthDocs.list(dogId);
} catch (err) {
_healthDocs = [];
}
}
// ----------------------------------------------------------
@ -901,7 +920,8 @@ window.Page_health = (() => {
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
${_renderBefundeSection()}`;
}
// ----------------------------------------------------------
@ -957,6 +977,32 @@ window.Page_health = (() => {
// Praxis hinzufügen
content.querySelector('[data-action="add-praxis"]')
?.addEventListener('click', () => _showPraxForm(null));
// Favorit-Toggle für Praxen
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const vetId = parseInt(btn.dataset.praxisId);
await UI.asyncButton(btn, async () => {
const res = await API.tieraerzte.toggleFavorite(vetId);
if (res.is_favorite) {
_favoritVet = _praxen.find(p => p.id === vetId) || null;
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
} else {
_favoritVet = null;
UI.toast.success('Favorit entfernt.');
}
// is_favorite in _praxen aktualisieren
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
const elFav = _container.querySelector('#health-mein-tierarzt');
if (elFav) _renderMeinTierarztKachel(elFav);
_renderTab();
});
});
});
// Befunde & Dokumente
if (_activeTab === 'dokument') {
_bindBefundeEvents(content);
}
}
// ----------------------------------------------------------
@ -1597,7 +1643,9 @@ window.Page_health = (() => {
action: addBtn
});
const renderCard = p => `
const renderCard = p => {
const isFav = _favoritVet?.id === p.id || p.is_favorite;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
@ -1626,10 +1674,21 @@ window.Page_health = (() => {
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
</div>
</div>
</div>
`;
`};
return `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
@ -2156,6 +2215,306 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// MEIN TIERARZT — Kachel
// ----------------------------------------------------------
async function _loadMeinTierarzt() {
const el = _container.querySelector('#health-mein-tierarzt');
if (!el) return;
_renderMeinTierarztKachel(el);
}
function _renderMeinTierarztKachel(el) {
if (!el) return;
const vet = _favoritVet;
const adresse = vet
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
: '';
el.innerHTML = `
<div style="margin:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Mein Tierarzt
</div>
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.6rem;flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
${vet ? `
<div class="health-card-title">${_esc(vet.name)}</div>
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
${vet.telefon ? `
<div style="margin-top:var(--space-2)">
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
</a>
</div>` : ''}
${vet.notfall_telefon ? `
<div style="margin-top:var(--space-1)">
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
</a>
</div>` : ''}
` : `
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch kein Tierarzt als Favorit gespeichert.
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
id="health-suche-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
</button>
`}
</div>
${vet ? `
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
</button>
` : ''}
</div>
</div>
`;
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
App.navigate('map', { filter: 'tierarzt' });
});
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await UI.asyncButton(btn, async () => {
await API.tieraerzte.toggleFavorite(_favoritVet.id);
_favoritVet = null;
const elAgain = _container.querySelector('#health-mein-tierarzt');
if (elAgain) _renderMeinTierarztKachel(elAgain);
UI.toast.success('Tierarzt-Favorit entfernt.');
});
});
}
// ----------------------------------------------------------
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
// ----------------------------------------------------------
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
function _renderBefundeSection() {
const dog = _appState.activeDog;
const docs = _healthDocs;
const DOC_ICONS = {
blutbild: 'drop',
roentgen: 'file-text',
rezept: 'note',
impfausweis:'certificate',
sonstiges: 'file-text',
};
const DOC_LABELS = {
blutbild: 'Blutbild',
roentgen: 'Röntgen',
rezept: 'Rezept',
impfausweis:'Impfausweis',
sonstiges: 'Sonstiges',
};
const uploadBtn = `
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
</button>`;
const items = docs.length
? docs.map(doc => {
const icon = DOC_ICONS[doc.typ] || 'file-text';
const label = DOC_LABELS[doc.typ] || doc.typ;
const isImg = !['pdf'].includes(doc.file_type);
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
return `
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
<div class="health-card-title">${_esc(doc.titel)}</div>
<div class="health-card-meta">
${_esc(label)}${datum ? ' · ' + datum : ''}
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
</div>
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
${isImg
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
</a>
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
data-action="delete-hdoc" data-doc-id="${doc.id}"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
</div>`;
}).join('')
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
Noch keine Befunde hochgeladen.
</p>`;
return `
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde &amp; Dokumente
</div>
${uploadBtn}
</div>
<div class="health-list" id="health-docs-list">${items}</div>
</div>
`;
}
function _bindBefundeEvents(content) {
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
_showBefundUploadModal();
});
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const docId = parseInt(btn.dataset.docId);
const ok = window.confirm('Befund wirklich löschen?');
if (!ok) return;
await UI.asyncButton(btn, async () => {
await API.healthDocs.delete(docId);
_healthDocs = _healthDocs.filter(d => d.id !== docId);
_renderTab();
UI.toast.success('Befund gelöscht.');
});
});
});
}
function _showBefundUploadModal() {
const aktivePraxen = _praxen.filter(p => p.aktiv);
const dog = _appState.activeDog;
UI.modal.open({
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
body: `
<form id="befund-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Art des Dokuments *</label>
<select class="form-control" name="typ" required>
<option value=""> bitte wählen </option>
<option value="blutbild">Blutbild</option>
<option value="roentgen">Röntgen</option>
<option value="rezept">Rezept</option>
<option value="impfausweis">Impfausweis</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel" required
placeholder="z.B. Blutbild März 2026">
</div>
<div class="form-group">
<label class="form-label">Untersuchungsdatum</label>
<input class="form-control" type="date" name="datum"
value="${new Date().toISOString().slice(0,10)}">
</div>
${aktivePraxen.length ? `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<select class="form-control" name="vet_id">
<option value=""> optional </option>
${aktivePraxen.map(p =>
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
).join('')}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Zusätzliche Infos (optional)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Datei * (PDF, JPG, PNG, WebP max. 10 MB)</label>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
<input type="file" name="file" id="befund-file-input"
accept=".pdf,image/*"
required
style="position:absolute;opacity:0;width:1px;height:1px">
</label>
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
color:var(--c-text-secondary)"></div>
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
`,
});
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('befund-file-input')?.addEventListener('change', function () {
const preview = document.getElementById('befund-file-preview');
if (this.files?.length) {
const f = this.files[0];
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
} else {
preview.textContent = '';
}
});
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.querySelector('[form="befund-form"][type="submit"]');
const form = e.target;
const fd = UI.formData(form);
const fileInput = form.querySelector('[name="file"]');
const file = fileInput?.files?.[0];
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
if (file.size > 10 * 1024 * 1024) {
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
return;
}
await UI.asyncButton(btn, async () => {
const formData = new FormData();
formData.append('dog_id', String(dog.id));
formData.append('typ', fd.typ);
formData.append('titel', fd.titel);
formData.append('beschreibung', fd.beschreibung || '');
formData.append('datum', fd.datum || '');
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
formData.append('file', file);
try {
const doc = await API.healthDocs.upload(formData);
_healthDocs.unshift(doc);
UI.modal.close();
_renderTab();
UI.toast.success('Befund hochgeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
@ -2323,6 +2682,129 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// KI-TIERARZTFRAGEN
// ----------------------------------------------------------
function _showKiTierarzt() {
const dog = _appState.activeDog;
const dogName = dog?.name || '';
const rasse = dog?.rasse || '';
const placeholder = dogName
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung
kein Ersatz für einen echten Tierarzt.
</p>
<div class="form-group">
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
placeholder="${_esc(placeholder)}"></textarea>
</div>
<div id="ki-tierarzt-result" style="display:none"></div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fff3cd;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#856404;
border:1px solid #ffc107">
<strong>&#9888;&#65039; Hinweis:</strong> Dies ist keine medizinische Diagnose.
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
});
document.getElementById('ki-tierarzt-submit-btn')
.addEventListener('click', async function () {
const btn = this;
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
const resultEl = document.getElementById('ki-tierarzt-result');
if (!symptom) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post('/ki/tierarzt', {
symptom,
dog_id: dog?.id || null,
dog_name: dogName || null,
rasse: rasse || null,
});
} catch (err) {
if (err.status === 429) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-warning);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-danger);
font-size:var(--text-sm)">
KI momentan nicht verfügbar. Bitte später versuchen.
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
return;
}
resultEl.style.display = '';
return;
}
const antwortHtml = _esc(result.antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
.replace(/\n/g, '<br>');
const restHtml = result.limit - result.anfragen_heute > 0
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
</p>`
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Tageslimit erreicht. Morgen wieder verfügbar.
</p>`;
resultEl.innerHTML = `
<div style="margin-top:var(--space-4);padding:var(--space-4);
background:var(--c-surface);border-radius:var(--radius-md);
border:1px solid var(--c-border)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
Einschätzung
</div>
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
${restHtml}
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fee2e2;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#991b1b;
border:1px solid #fca5a5">
<strong>&#9888;&#65039; Dies ist keine medizinische Diagnose.</strong>
Bei ernsthaften Symptomen sofort zum Tierarzt.
</div>`;
resultEl.style.display = '';
// Submit-Button ausblenden wenn Limit erschöpft
if (result.anfragen_heute >= result.limit) {
btn.disabled = true;
btn.textContent = 'Limit erreicht';
}
});
});
}
return { init, refresh, openNew, onDogChange };
})();

View file

@ -0,0 +1,708 @@
/* ============================================================
BAN YARO Playdate-Matching
Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
============================================================ */
window.Page_playdate = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
let _userPos = null;
let _radius = 10;
let _dogs = [];
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T'));
return d.toLocaleDateString('de-DE');
}
function _dogAvatar(foto_url, name, size = 48) {
const initials = _esc((name || '?').charAt(0).toUpperCase());
if (foto_url) {
return `<img src="${_esc(foto_url)}" alt="${initials}"
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
}
return `<div style="width:${size}px;height:${size}px;border-radius:50%;
background:var(--c-primary-subtle);display:flex;align-items:center;
justify-content:center;font-size:${Math.round(size * 0.45)}px;
font-weight:700;color:var(--c-primary);">${initials}</div>`;
}
function _statusBadge(status) {
const map = {
pending: ['warning', 'Ausstehend'],
accepted: ['success', 'Angenommen'],
declined: ['danger', 'Abgelehnt'],
};
const [type, label] = map[status] || ['default', status];
const colors = {
warning: 'var(--c-warning, #f59e0b)',
success: 'var(--c-success, #10b981)',
danger: 'var(--c-danger, #ef4444)',
default: 'var(--c-text-muted)',
};
return `<span style="font-size:var(--text-xs);font-weight:600;
color:${colors[type]};padding:2px 8px;border-radius:999px;
background:${colors[type]}18">${label}</span>`;
}
// ------------------------------------------------------------------
// INIT
// ------------------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_dogs = appState.dogs?.filter(d => !d.is_guest) || [];
_render();
_switchTab(_activeTab);
}
function refresh() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
_switchTab(_activeTab);
}
function onDogChange() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
if (_activeTab === 'listings') _loadListings();
}
// ------------------------------------------------------------------
// RENDER — Grundstruktur mit Tabs
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="playdate-layout">
<!-- Tabs -->
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
<button class="by-tab active" data-tab="nearby">In der Nähe</button>
<button class="by-tab" data-tab="listings">Meine Inserate</button>
<button class="by-tab" data-tab="requests">
Anfragen
<span id="playdate-req-badge" style="display:none;margin-left:4px;
background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 6px;font-size:var(--text-xs);font-weight:700">0</span>
</button>
</div>
<!-- Tab-Inhalt -->
<div id="playdate-content"></div>
</div>
`;
document.getElementById('playdate-tabs').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
}
function _switchTab(tab) {
_activeTab = tab;
document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
const content = document.getElementById('playdate-content');
if (!content) return;
if (tab === 'nearby') _renderNearby(content);
if (tab === 'listings') _renderListings(content);
if (tab === 'requests') _renderRequests(content);
}
// ------------------------------------------------------------------
// TAB: IN DER NÄHE
// ------------------------------------------------------------------
async function _renderNearby(el) {
el.innerHTML = `
<div>
<!-- Toolbar: Radius-Auswahl + Standort-Button -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('map-pin')}
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
</span>
</div>
<select id="nearby-radius" class="form-select" style="width:auto;font-size:var(--text-sm)">
<option value="5" ${_radius===5 ? 'selected' : ''}>5 km</option>
<option value="10" ${_radius===10 ? 'selected' : ''}>10 km</option>
<option value="25" ${_radius===25 ? 'selected' : ''}>25 km</option>
<option value="50" ${_radius===50 ? 'selected' : ''}>50 km</option>
</select>
<button class="btn btn-ghost btn-sm" id="nearby-locate-btn">
${UI.icon('crosshair')} Standort aktualisieren
</button>
</div>
<!-- Info-Hinweis -->
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-4);padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md)">
${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
Dein genauer Standort bleibt privat es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
</div>
<!-- Ergebnisse -->
<div id="nearby-results">
<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">
Standort wird ermittelt
</p>
</div>
</div>
`;
document.getElementById('nearby-radius').addEventListener('change', e => {
_radius = parseInt(e.target.value, 10);
_loadNearby();
});
document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
const btn = document.getElementById('nearby-locate-btn');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort aktualisiert';
await _loadNearby();
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(btn, false);
}
});
if (!_userPos) {
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort bekannt';
} catch {
document.getElementById('nearby-results').innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('map-pin')}
<p style="margin:var(--space-3) 0 var(--space-4)">
Standort konnte nicht automatisch ermittelt werden.<br>
Klicke auf "Standort aktualisieren".
</p>
</div>
`;
return;
}
}
await _loadNearby();
}
async function _loadNearby() {
if (!_userPos) return;
const resultsEl = document.getElementById('nearby-results');
if (!resultsEl) return;
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Suche…</p>`;
try {
const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
if (!data || data.length === 0) {
resultsEl.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Niemand in der Nähe',
text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
});
return;
}
resultsEl.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3)">
${data.map(d => _nearbyCard(d)).join('')}
</div>
`;
resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
btn.addEventListener('click', () => {
const toDogId = parseInt(btn.dataset.dogId, 10);
const dogName = btn.dataset.dogName;
_showRequestModal(toDogId, dogName);
});
});
} catch (err) {
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
}
}
function _nearbyCard(d) {
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(d.foto_url, d.dog_name, 56)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
color:var(--c-text)">${_esc(d.dog_name)}</div>
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
</div>
</div>
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap">
<span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
${UI.icon('map-pin')}
${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
</span>
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
</div>
${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">
${_esc(d.beschreibung)}
</p>` : ''}
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
data-dog-id="${d.dog_id}"
data-dog-name="${_esc(d.dog_name)}">
${UI.icon('paw-print')} Spielkamerad anfragen
</button>
</div>
`;
}
function _showRequestModal(toDogId, dogName) {
const formId = 'playdate-req-form';
UI.modal.open({
title: `Anfrage an ${dogName}`,
body: `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Nachricht (optional)</label>
<textarea id="req-nachricht" class="form-control" rows="3" maxlength="500"
placeholder="Hallo! Unsere Hunde könnten super zusammenpassen…"></textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="req-cancel-btn">Abbrechen</button>
<button class="btn btn-primary" id="req-send-btn" form="${formId}">
${UI.icon('paper-plane-tilt')} Anfrage senden
</button>
`,
});
document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('req-send-btn').addEventListener('click', async () => {
const btn = document.getElementById('req-send-btn');
const nachricht = document.getElementById('req-nachricht').value.trim();
await UI.asyncButton(btn, async () => {
const result = await API.post('/playdate/request', {
to_dog_id: toDogId,
nachricht: nachricht || null,
});
UI.modal.close();
UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
// Zum Chat navigieren
if (result.conversation_id) {
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
}
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: MEINE INSERATE
// ------------------------------------------------------------------
async function _renderListings(el) {
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
await _loadListings(el);
}
async function _loadListings(el) {
const target = el || document.getElementById('playdate-content');
if (!target) return;
if (_dogs.length === 0) {
target.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch kein Hund',
text: 'Lege zuerst einen Hund in deinem Profil an.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
});
return;
}
// Listings für alle eigenen Hunde laden
const listings = {};
await Promise.all(_dogs.map(async dog => {
try {
const data = await API.get(`/playdate/my-listing/${dog.id}`);
listings[dog.id] = data;
} catch {
listings[dog.id] = null;
}
}));
target.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
</div>
`;
// Event-Delegation für alle Buttons
target.addEventListener('click', async e => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const dogId = parseInt(btn.dataset.dogId, 10);
const dog = _dogs.find(d => d.id === dogId);
if (action === 'edit') {
_showListingModal(dog, listings[dogId], async () => {
await _loadListings();
});
}
if (action === 'deactivate') {
if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
try {
await API.del(`/playdate/listing/${dogId}`);
UI.toast.success('Inserat deaktiviert.');
await _loadListings();
} catch (err) {
UI.toast.error(err.message);
}
}
});
}
function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv;
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
${_dogAvatar(dog.foto_url, dog.name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
</div>
<span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px;
background:${isAktiv ? 'var(--c-success-subtle,#d1fae5)' : 'var(--c-surface-2)'};
color:${isAktiv ? 'var(--c-success,#10b981)' : 'var(--c-text-muted)'}">
${isAktiv ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
${isAktiv ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${UI.icon('map-pin')}
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km
</div>
${listing.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">${_esc(listing.beschreibung)}</p>` : ''}
` : `
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)">
Noch kein Inserat trage dich ein, damit andere dich finden können.
</p>
`}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-primary btn-sm"
data-action="edit" data-dog-id="${dog.id}">
${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
</button>
${isAktiv ? `
<button class="btn btn-ghost btn-sm"
data-action="deactivate" data-dog-id="${dog.id}">
${UI.icon('x')} Deaktivieren
</button>` : ''}
</div>
</div>
`;
}
function _showListingModal(dog, existing, onSaved) {
const formId = 'listing-form';
UI.modal.open({
title: `Inserat für ${dog.name}`,
body: `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Ort / Standort</label>
<div style="display:flex;gap:var(--space-2)">
<input type="text" id="listing-ort" class="form-control"
placeholder="z.B. München"
value="${_esc(existing?.ort_name || '')}">
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
title="GPS-Standort ermitteln">
${UI.icon('crosshair')}
</button>
</div>
<input type="hidden" id="listing-lat" value="${existing?.lat || ''}">
<input type="hidden" id="listing-lon" value="${existing?.lon || ''}">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln.
Nur der Ortsname wird für andere sichtbar nicht dein genauer Standort.
</div>
</div>
<div class="form-group">
<label class="form-label">Suchradius</label>
<select id="listing-radius" class="form-control">
<option value="5" ${(existing?.radius_km||10)===5 ? 'selected' : ''}>5 km</option>
<option value="10" ${(existing?.radius_km||10)===10 ? 'selected' : ''}>10 km</option>
<option value="25" ${(existing?.radius_km||10)===25 ? 'selected' : ''}>25 km</option>
<option value="50" ${(existing?.radius_km||10)===50 ? 'selected' : ''}>50 km</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Beschreibung (optional)</label>
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400"
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${_esc(existing?.beschreibung || '')}</textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="listing-cancel-btn">Abbrechen</button>
<button class="btn btn-primary" id="listing-save-btn">
${UI.icon('floppy-disk')} Speichern
</button>
`,
});
// GPS-Button
document.getElementById('listing-gps-btn').addEventListener('click', async () => {
const gpsBtn = document.getElementById('listing-gps-btn');
UI.setLoading(gpsBtn, true);
try {
const pos = await API.getLocation();
document.getElementById('listing-lat').value = pos.lat;
document.getElementById('listing-lon').value = pos.lon;
// Reverse-Geocoding für Ortsname
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
{ cache: 'no-store' }
);
const geoData = await rev.json();
const a = geoData.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) document.getElementById('listing-ort').value = ort;
} catch {}
UI.toast.success('Standort ermittelt.');
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(gpsBtn, false);
}
});
document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('listing-save-btn').addEventListener('click', async () => {
const btn = document.getElementById('listing-save-btn');
const lat = parseFloat(document.getElementById('listing-lat').value);
const lon = parseFloat(document.getElementById('listing-lon').value);
const ort = document.getElementById('listing-ort').value.trim();
const rad = parseInt(document.getElementById('listing-radius').value, 10);
const desc = document.getElementById('listing-beschreibung').value.trim();
if (!lat || !lon) {
UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
return;
}
await UI.asyncButton(btn, async () => {
await API.put('/playdate/listing', {
dog_id: dog.id,
lat,
lon,
ort_name: ort || null,
radius_km: rad,
beschreibung: desc || null,
});
UI.modal.close();
UI.toast.success('Inserat gespeichert!');
onSaved?.();
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: ANFRAGEN
// ------------------------------------------------------------------
async function _renderRequests(el) {
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
try {
const data = await API.get('/playdate/requests');
const incoming = data.incoming || [];
const outgoing = data.outgoing || [];
// Badge aktualisieren
const pendingCount = incoming.filter(r => r.status === 'pending').length;
const badge = document.getElementById('playdate-req-badge');
if (badge) {
badge.textContent = pendingCount;
badge.style.display = pendingCount > 0 ? '' : 'none';
}
if (incoming.length === 0 && outgoing.length === 0) {
el.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch keine Anfragen',
text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
});
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
${incoming.length > 0 ? `
<div>
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Eingehende Anfragen</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${incoming.map(r => _incomingCard(r)).join('')}
</div>
</div>` : ''}
${outgoing.length > 0 ? `
<div>
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${outgoing.map(r => _outgoingCard(r)).join('')}
</div>
</div>` : ''}
</div>
`;
// Button-Events (Accept/Decline)
el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const reqId = parseInt(btn.dataset.reqId, 10);
const status = btn.dataset.status;
await UI.asyncButton(btn, async () => {
const result = await API.patch(`/playdate/requests/${reqId}`, { status });
if (status === 'accepted' && result.conversation_id) {
UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
} else {
UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
}
await _renderRequests(el);
}, { errorMsg: null });
});
});
// Chat-Buttons
el.querySelectorAll('.req-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
App.navigate('chat', true);
});
});
} catch (err) {
el.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
}
}
function _incomingCard(r) {
const isPending = r.status === 'pending';
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? _esc(r.alter) + ' · ' : ''}
von ${_esc(r.from_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
line-height:1.5">
"${_esc(r.nachricht)}"
</div>` : ''}
${isPending ? `
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen
</button>
<button class="btn btn-ghost btn-sm req-decline-btn"
data-req-id="${r.id}" data-status="declined">
${UI.icon('x')} Ablehnen
</button>
</div>` : `
${r.status === 'accepted' ? `
<button class="btn btn-ghost btn-sm req-chat-btn">
${UI.icon('chat-circle-dots')} Zum Chat
</button>` : ''}
`}
</div>
`;
}
function _outgoingCard(r) {
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
von ${_esc(r.to_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
"${_esc(r.nachricht)}"
</p>` : ''}
${r.status === 'accepted' ? `
<button class="btn btn-ghost btn-sm req-chat-btn">
${UI.icon('chat-circle-dots')} Chat öffnen
</button>` : ''}
</div>
`;
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,190 @@
/* ============================================================
BAN YARO Tierfutter-Rückrufe
Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter.
============================================================ */
window.Page_recalls = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _recalls = [];
let _query = '';
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_query = '';
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_recalls = [];
_query = '';
await _render();
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<!-- Warnbanner -->
<div style="background:#fef2f2;border:1px solid #fca5a5;border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);margin-bottom:var(--space-4);
display:flex;align-items:flex-start;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="color:#dc2626;flex-shrink:0;margin-top:2px">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
<p style="margin:0;font-size:var(--text-sm);color:#991b1b;line-height:1.5">
<strong>Hinweis:</strong> Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer
bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.
</p>
</div>
<!-- Suchfeld -->
<div style="position:relative;margin-bottom:var(--space-4)">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);pointer-events:none">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input type="search" id="recalls-search" placeholder="Produkt, Gefahr oder Herkunft suchen…"
value="${UI.escape(_query)}"
style="width:100%;padding:var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3)*2 + 1.2rem);
border:1px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);background:var(--c-surface);color:var(--c-text);
box-sizing:border-box">
</div>
<!-- Ergebnis-Liste -->
<div id="recalls-list">${UI.skeleton(4)}</div>
`;
// Suchfeld-Handler
_container.querySelector('#recalls-search').addEventListener('input', (e) => {
_query = e.target.value.trim();
_renderList();
});
await _loadRecalls();
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadRecalls() {
try {
const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls';
_recalls = await API.get(url);
} catch {
_container.querySelector('#recalls-list').innerHTML = UI.emptyState({
icon: 'warning-circle',
title: 'Rückrufe konnten nicht geladen werden',
text: 'Bitte versuche es später erneut.',
});
return;
}
_renderList();
}
// ----------------------------------------------------------
// LISTE RENDERN
// ----------------------------------------------------------
function _renderList() {
const listEl = _container.querySelector('#recalls-list');
if (!listEl) return;
const filtered = _query
? _recalls.filter(r => {
const q = _query.toLowerCase();
return (r.titel || '').toLowerCase().includes(q)
|| (r.produkt || '').toLowerCase().includes(q)
|| (r.gefahr || '').toLowerCase().includes(q)
|| (r.herkunft || '').toLowerCase().includes(q);
})
: _recalls;
if (!filtered.length) {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
listEl.innerHTML = UI.emptyState({
icon: 'check-circle',
title: 'Aktuell keine Rückrufe',
text: `Letzte Prüfung: ${today}`,
});
return;
}
listEl.innerHTML = filtered.map(r => _cardHtml(r)).join('');
}
// ----------------------------------------------------------
// EINZELNE KARTE
// ----------------------------------------------------------
function _cardHtml(r) {
const datum = r.datum
? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
const meta = [
r.herkunft ? `<span>${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}</span>` : '',
datum ? `<span>${UI.icon('calendar-blank')} ${datum}</span>` : '',
r.quelle ? `<span style="text-transform:uppercase;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.escape(r.quelle)}</span>` : '',
].filter(Boolean).join('<span style="color:var(--c-border)"> · </span>');
const linkHtml = r.url
? `<a href="${UI.escape(r.url)}" target="_blank" rel="noopener"
style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);
color:var(--c-primary);text-decoration:none;margin-top:var(--space-1)">
${UI.icon('arrow-square-out')} Details auf RASFF
</a>`
: '';
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-left:4px solid #dc2626;border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);margin-bottom:var(--space-3)">
<!-- Titel -->
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="color:#dc2626;flex-shrink:0;margin-top:2px">
<use href="/icons/phosphor.svg#warning-octagon"></use>
</svg>
<strong style="font-size:var(--text-base);color:var(--c-text);line-height:1.4">
${UI.escape(r.produkt || r.titel)}
</strong>
</div>
<!-- Gefahr -->
${r.gefahr ? `
<p style="margin:0 0 var(--space-2) 0;font-size:var(--text-sm);color:var(--c-text-muted);
padding-left:calc(var(--space-2) + 1.2rem)">
${UI.escape(r.gefahr)}
</p>` : ''}
<!-- Meta-Zeile -->
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-muted);
padding-left:calc(var(--space-2) + 1.2rem)">
${meta}
</div>
<!-- Link -->
${linkHtml ? `<div style="padding-left:calc(var(--space-2) + 1.2rem)">${linkHtml}</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => {
_closeModal();
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
// Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
API.post(`/streak/${body.dog_id}/ping`).then(streak => {
if (!streak) return;
if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
} else if (streak.current_streak > 1) {
setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
}
}).catch(() => {});
if (resp.ist_top) {
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
} else {

View file

@ -463,6 +463,8 @@ window.Page_welcome = (() => {
`).join('')}
</div>
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
<div class="wc-grid">
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
@ -497,9 +499,85 @@ window.Page_welcome = (() => {
_updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
}).catch(() => { /* Skeleton bleibt sichtbar */ });
// Streak-Widget asynchron laden
_loadStreakWidget(dog.id);
}
}
// ----------------------------------------------------------
// STREAK-WIDGET
// ----------------------------------------------------------
async function _loadStreakWidget(dogId) {
const slot = _container.querySelector('#wc-streak-widget');
if (!slot) return;
let streak;
try {
streak = await API.get(`/streak/${dogId}`);
} catch { return; }
if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return;
slot.innerHTML = _streakWidgetHTML(streak);
slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => {
const modalEl = UI.modal.open({
title: '🔥 Trainings-Bestenliste',
body: '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Wird geladen…</p>',
});
let board;
try { board = await API.get('/streak/leaderboard'); } catch { board = []; }
const bodyEl = modalEl?.querySelector('.modal-body');
if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board);
});
}
function _streakWidgetHTML(s) {
const cur = s.current_streak || 0;
const best = s.longest_streak || 0;
return `
<div class="wc-streak-card">
<div class="wc-streak-flame-wrap">
<span class="wc-streak-flame">🔥</span>
<span class="wc-streak-number">${cur}</span>
</div>
<div class="wc-streak-info">
<div class="wc-streak-label">Tage in Folge trainiert</div>
<div class="wc-streak-best">Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}</div>
</div>
<button class="wc-streak-lb-btn" id="wc-streak-leaderboard-btn" title="Bestenliste">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
</button>
</div>`;
}
function _leaderboardHTML(rows) {
if (!rows || !rows.length) {
return '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Einträge.</p>';
}
const medals = ['🥇', '🥈', '🥉'];
return `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${rows.map((r, i) => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-subtle)">
<span style="font-size:1.4rem;width:28px;text-align:center;flex-shrink:0">${medals[i] || (i + 1) + '.'}</span>
${r.foto_url
? `<img src="${UI.escape(r.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0" alt="">`
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary-subtle);flex-shrink:0"></div>`}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${UI.escape(r.dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<span style="font-size:1.1rem">🔥</span>
<span style="font-weight:var(--weight-bold);font-size:var(--text-base);color:var(--c-primary)">${r.current_streak}</span>
</div>
</div>
`).join('')}
</div>`;
}
function _updateHeroFromDash(dash, dog) {
const heroBox = _container.querySelector('#wc-hero-box');
if (!heroBox) return;

View file

@ -255,6 +255,15 @@ window.Page_wiki = (() => {
<option value="">Alle Gruppen</option>
</select>
</div>
<div style="padding:0 0 var(--space-3)">
<button class="btn btn-secondary w-full" id="wiki-rasse-erkennen-btn"
style="font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Welche Rasse ist das? Foto analysieren
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="wiki-rasse-foto-input" style="display:none">
</div>
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
@ -264,6 +273,9 @@ window.Page_wiki = (() => {
// Load initial batch (also populates gruppen)
await _loadBreeds(el, true);
// Rassen-Erkennung per KI
_bindWikiRasseErkennung(el);
// Search handler with debounce
let _searchTimer;
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
@ -1265,6 +1277,130 @@ window.Page_wiki = (() => {
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Wiki-Tab)
// ----------------------------------------------------------
function _bindWikiRasseErkennung(el) {
const btn = el.querySelector('#wiki-rasse-erkennen-btn');
const fileInput = el.querySelector('#wiki-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
if (!_appState.user) {
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
return;
}
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast('Bild zu groß (max. 5 MB).', 'danger');
return;
}
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert das Bild…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origHtml;
_showWikiRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origHtml;
UI.toast(e.message || 'Fehler bei der Rassen-Erkennung.', 'danger');
}
});
}
function _showWikiRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p style="color:var(--c-text-secondary)">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
${r.wiki_slug ? `
<div style="margin-top:var(--space-3)">
<button class="btn btn-${isTop ? 'primary' : 'secondary'} btn-sm w-full"
data-action="wiki" data-slug="${_esc(r.wiki_slug)}">
Im Wiki nachschlagen
</button>
</div>` : ''}
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="wiki-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('wiki-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _openBreedDetail(btn.dataset.slug), 300);
});
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------