Session 2026-04-21: SEO, Wiki-Anreicherung, Training, Lober

SEO & Crawler:
- robots.txt, llms.txt, sitemap.xml (508 Seiten bei Google)
- SSR-Seiten: /info, /wiki/rassen, /wiki/rasse/{slug}, /knigge
- Open Graph, JSON-LD, Breadcrumbs in index.html

Navigation:
- Training unter "Mein Hund", Wissen collapsible
- Welcome-Seite und Landing-Page auf 5-Gruppen-Struktur

Wiki:
- KI-Anreicherung (Claude API): beschreibung, vorkommen_de, Steckbrief
- "So einen hab ich" / Züchter-Verzeichnis
- Scheduler: 50 Rassen beim Start, 20/Nacht

Training:
- Session-Logging (Erfolgsquote, Stimmung, Zufriedenheit)
- Virtueller KI-Trainer (6h-Cache)
- Trainingskalender (Habit-Tracker)
- Top-Training → automatischer Tagebucheintrag
- Gamification ohne Druck: Badges, Streak, Stats

Fortschritts-Lober:
- Jeden Montag 09:00: Claude schreibt Lob-Text pro Hund
- Push + Karte im Tagebuch

Monitoring:
- 4× täglich Status-Mail mit Scheduler-Status + Wiki-Fortschritt
This commit is contained in:
rene 2026-04-21 19:38:20 +02:00
parent 65d1cf6c7f
commit 180de32e57
22 changed files with 4351 additions and 189 deletions

View file

@ -46,6 +46,55 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock)
---
## Implementierungsstand (aktuell: 2026-04-21, SW by-v279, APP_VER 267)
### Fertig implementiert ✅
#### SEO & Auffindbarkeit
- `robots.txt`, `llms.txt` (für KI-Crawler), dynamische `sitemap.xml`
- Server-gerenderte Seiten (für Google/Crawler): `/info`, `/wiki/rassen`, `/wiki/rasse/{slug}`, `/knigge`
- Open Graph + Twitter Card + JSON-LD in index.html
- Breadcrumb-Structured-Data auf Rassen-Seiten
- Google Search Console eingerichtet, 508 Seiten indexiert
#### Navigation
- Bottom-Nav: Karte · Routen · + · Tagebuch · Forum
- Sidebar: Mein Hund (Tagebuch, Gesundheit, Übungen, Pläne) / Entdecken / Soziales / Community / Wissen (collapsible)
- Welcome-Seite und Landing-Page auf gleiche 5-Gruppen-Struktur abgestimmt
#### Wiki-Rassen
- 500+ Rassen aus Wikidata/TheDogAPI
- KI-Anreicherung via Claude API: Charakter, Vorkommen DE, technische Daten (Scheduler: 02:30 täglich, 20 Rassen)
- "So einen hab ich" / "Interessiert mich" Community-Feature
- Züchter-Verzeichnis (community-basiert, mit Moderation)
- SSR-Detailseiten mit OG-Tags für jede Rasse
#### Training
- Session-Logging: Wiederholungen, Erfolgsquote (0-100%), Hundestimmung, Zufriedenheit (1-5)
- Automatischer Tagebucheintrag bei Top-Training (≥80% + ≥4★)
- Virtueller KI-Trainer: analysiert letzte 20 Sessions, 6h-gecacht
- Trainingskalender (Habit-Tracker-Style, aktueller + letzter Monat)
- Gamification ohne Druck: Badges, Stats-Banner, nur vorwärts zählende Streaks
- Stats-API: Gesamteinheiten, Ø Erfolg, Streak, beste Übung
#### Fortschritts-Lober
- Jeden Montag 09:00: Claude API schreibt 2-3 Sätze Lob für die Vorwoche
- Basiert auf: Tagebuch, Training, Gesundheitseinträge, Wochen-Gesamtzahl
- Nur Lob, kein Rat, kein Druck — spricht über den Hund, nicht den Besitzer
- Push-Notification + Karte im Tagebuch (dezent, wegklickbar)
#### Hintergrund-Scheduler
- Gesundheits-Reminder (08:00)
- Giftköder-Archiv (03:00)
- Wetter-Alert (07:30)
- Meilenstein-Check (00:05)
- Event-Import VDH (So 02:00)
- Wiki KI-Anreicherung (02:30 täglich + Startup)
- Wöchentlicher Lober (Mo 09:00)
- 4× täglicher Status-Report per Mail (07:00 / 13:00 / 19:00 / 01:00)
---
## Feature-Roadmap
### Phase 1 — MVP (Version 1.0)

View file

@ -910,3 +910,102 @@ def _migrate(conn_factory):
logger.info("Migration: real_name Spalte hinzugefügt, %d User migriert.", len(rows))
except Exception:
pass
# Wiki: Rassen-Anreicherung
for col, typedef in [
("beschreibung", "TEXT"),
("vorkommen_de", "TEXT"),
("wikipedia_url_de","TEXT"),
("ki_enriched", "INTEGER DEFAULT 0"),
]:
try:
conn.execute(f"ALTER TABLE wiki_rassen ADD COLUMN {col} {typedef}")
except Exception:
pass
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.")
# Wiki: Züchter-Verzeichnis
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_zuchter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rasse_slug TEXT NOT NULL,
name TEXT NOT NULL,
zwingername TEXT,
ort TEXT,
plz TEXT,
bundesland TEXT,
vdh_mitglied INTEGER DEFAULT 0,
website TEXT,
telefon TEXT,
beschreibung TEXT,
verified INTEGER DEFAULT 0,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wiki_zuchter_rasse ON wiki_zuchter(rasse_slug, verified);
""")
logger.info("Migration: wiki_zuchter Tabelle bereit.")
# Wiki: Rasse-Interesse ("So einen hab ich" / "Interessiert mich")
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_breed_interest (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rasse_slug TEXT NOT NULL,
typ TEXT NOT NULL DEFAULT 'hat',
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, rasse_slug)
);
CREATE INDEX IF NOT EXISTS idx_wbi_rasse ON wiki_breed_interest(rasse_slug, typ);
CREATE INDEX IF NOT EXISTS idx_wbi_user ON wiki_breed_interest(user_id);
""")
logger.info("Migration: wiki_breed_interest Tabelle bereit.")
# Training: Session-Protokoll
conn.executescript("""
CREATE TABLE IF NOT EXISTS training_sessions (
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,
exercise_id TEXT NOT NULL,
exercise_name TEXT NOT NULL,
datum TEXT NOT NULL DEFAULT (date('now')),
wiederholungen INTEGER DEFAULT 1,
erfolgsquote INTEGER DEFAULT 50,
hund_stimmung TEXT DEFAULT 'aufmerksam',
zufriedenheit INTEGER DEFAULT 3,
notiz TEXT,
ist_top INTEGER DEFAULT 0,
diary_entry_id INTEGER REFERENCES diary(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ts_user_dog ON training_sessions(user_id, dog_id, datum DESC);
CREATE INDEX IF NOT EXISTS idx_ts_exercise ON training_sessions(exercise_id, user_id);
""")
logger.info("Migration: training_sessions Tabelle bereit.")
# Training: KI-Feedback-Cache
conn.executescript("""
CREATE TABLE IF NOT EXISTS training_ki_cache (
dog_id INTEGER PRIMARY KEY,
feedback TEXT NOT NULL,
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
logger.info("Migration: training_ki_cache Tabelle bereit.")
# Fortschritts-Lober: wöchentliche Lob-Karten
conn.executescript("""
CREATE TABLE IF NOT EXISTS weekly_praise (
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,
week_key TEXT NOT NULL, -- ISO-Woche, z.B. "2026-W17"
praise_text TEXT NOT NULL,
stats_json TEXT, -- JSON der gesammelten Stats (für Debugging)
generated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(dog_id, week_key)
);
CREATE INDEX IF NOT EXISTS idx_wp_dog_week ON weekly_praise(dog_id, week_key DESC);
""")
logger.info("Migration: weekly_praise Tabelle bereit.")

View file

@ -100,6 +100,7 @@ from routes.sitting_access import router as sitting_access_router
from routes.stats import router as stats_router
from routes.achievements import router as achievements_router
from routes.training import router as training_router
from routes.praise import router as praise_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -137,6 +138,7 @@ app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["
app.include_router(stats_router, prefix="/api/stats", tags=["Stats"])
app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"])
app.include_router(training_router, prefix="/api/training", tags=["Training"])
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
# ------------------------------------------------------------------
@ -165,6 +167,439 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
@app.get("/llms.txt")
async def llms():
return FileResponse(f"{STATIC_DIR}/llms.txt", media_type="text/plain")
@app.get("/sitemap.xml")
async def sitemap():
from fastapi.responses import Response
from database import db as _db
from datetime import date
today = date.today().isoformat()
urls = [
("https://banyaro.app/", "weekly", "1.0"),
("https://banyaro.app/info", "monthly", "0.9"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.8"),
]
try:
with _db() as conn:
rassen = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500"
).fetchall()
if rassen:
urls.append(("https://banyaro.app/wiki/rassen", "weekly", "0.8"))
for r in rassen:
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
events = conn.execute(
"SELECT id FROM events WHERE datum >= date('now') LIMIT 200"
).fetchall()
for e in events:
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5"))
except Exception:
pass
entries = "\n".join(
f""" <url>
<loc>{loc}</loc>
<lastmod>{today}</lastmod>
<changefreq>{freq}</changefreq>
<priority>{prio}</priority>
</url>"""
for loc, freq, prio in urls
)
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{entries}
</urlset>"""
return Response(content=xml, media_type="application/xml")
@app.get("/info")
async def info_page():
return FileResponse(f"{STATIC_DIR}/landing.html", headers={"Cache-Control": "max-age=3600"})
# ------------------------------------------------------------------
# SEO: Server-gerenderete Wiki-Rassen-Übersicht /wiki/rassen
# ------------------------------------------------------------------
@app.get("/wiki/rassen")
async def wiki_rassen_page():
from fastapi.responses import HTMLResponse
from database import db as _db
with _db() as conn:
rows = conn.execute(
"""SELECT name, slug, gruppe, groesse, aktivitaet, foto_url, kinder_geeignet, wohnung_geeignet
FROM wiki_rassen WHERE slug IS NOT NULL
ORDER BY name ASC"""
).fetchall()
rassen = [dict(r) for r in rows]
total = len(rassen)
groessen_map = {"klein": "Klein", "mittel": "Mittel", "gross": "Groß", "sehr_gross": "Sehr groß"}
aktivitaet_map = {"niedrig": "Niedrig", "mittel": "Mittel", "hoch": "Hoch", "sehr_hoch": "Sehr hoch"}
cards = ""
for r in rassen:
foto = r["foto_url"] or ""
img = f'<img src="{foto}" alt="{r["name"]}" loading="lazy">' if foto else '<div class="breed-placeholder">🐕</div>'
groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "")
kinder = "✓ Kinder" if r.get("kinder_geeignet") else ""
wohnung = "✓ Wohnung" if r.get("wohnung_geeignet") else ""
tags = " ".join(f'<span class="tag">{t}</span>' for t in [groesse, kinder, wohnung] if t)
cards += f"""<a href="/wiki/rasse/{r['slug']}" class="breed-card">
{img}
<div class="breed-info">
<h2>{r['name']}</h2>
<p class="gruppe">{r.get('gruppe') or ''}</p>
<div class="tags">{tags}</div>
</div>
</a>\n"""
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunderassen-Wiki {total} Rassen im Überblick | Ban Yaro</title>
<meta name="description" content="Alle {total} Hunderassen im Überblick: Charakter, Größe, Aktivität, Eignung für Familien und Wohnungen. Das Hunderassen-Wiki von Ban Yaro.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/wiki/rassen">
<meta property="og:title" content="Hunderassen-Wiki — {total} Rassen | Ban Yaro">
<meta property="og:description" content="Alle {total} Hunderassen im Überblick: Charakter, Größe, Aktivität, Eignung für Familien und Wohnungen.">
<meta property="og:url" content="https://banyaro.app/wiki/rassen">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<script type="application/ld+json">
{{"@context":"https://schema.org","@type":"CollectionPage","name":"Hunderassen-Wiki","description":"Übersicht über {total} Hunderassen mit Charakter, Größe, Aktivität und Eignungsprofil.","url":"https://banyaro.app/wiki/rassen","publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}}}}
</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a}}
header{{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2rem 1.5rem;text-align:center}}
header h1{{font-size:clamp(1.4rem,4vw,2rem);font-weight:700;margin-bottom:.4rem}}
header p{{opacity:.9;font-size:.95rem}}
nav{{background:#fff;border-bottom:1px solid #e8ddd0;padding:.6rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap}}
nav a{{color:#555;text-decoration:none;font-size:.9rem;font-weight:500}}
nav a:hover{{color:#C4843A}}
nav .brand{{font-weight:800;margin-right:auto;color:#C4843A}}
.container{{max-width:1100px;margin:0 auto;padding:2rem 1.5rem}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1.25rem}}
.breed-card{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;overflow:hidden;text-decoration:none;color:#1a1a1a;transition:box-shadow .15s,transform .15s;display:flex;flex-direction:column}}
.breed-card:hover{{box-shadow:0 4px 20px rgba(0,0,0,.1);transform:translateY(-2px)}}
.breed-card img{{width:100%;height:140px;object-fit:cover}}
.breed-placeholder{{width:100%;height:140px;background:#f0e8dc;display:flex;align-items:center;justify-content:center;font-size:3rem}}
.breed-info{{padding:.85rem 1rem;flex:1}}
.breed-info h2{{font-size:.95rem;font-weight:700;margin-bottom:.2rem}}
.gruppe{{font-size:.78rem;color:#888;margin-bottom:.4rem}}
.tags{{display:flex;flex-wrap:wrap;gap:.3rem}}
.tag{{background:#f5e6d3;color:#a86e2e;font-size:.7rem;font-weight:600;padding:.15rem .5rem;border-radius:999px}}
footer{{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:3rem}}
footer a{{color:#C4843A}}
</style>
</head>
<body>
<header>
<h1>Hunderassen-Wiki</h1>
<p>{total} Rassen Charakter, Eignung, Pflege auf einen Blick</p>
</header>
<nav>
<span class="brand">Ban Yaro</span>
<a href="/info">Über die App</a>
<a href="/knigge">Knigge</a>
<a href="/" style="background:#C4843A;color:#fff;padding:.35rem 1rem;border-radius:999px;font-weight:700">App öffnen</a>
</nav>
<div class="container">
<div class="grid">
{cards}
</div>
</div>
<footer>
<strong style="color:#fff">Ban Yaro</strong> Die Hunde-Plattform · <a href="https://banyaro.app">banyaro.app</a>
</footer>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
# ------------------------------------------------------------------
# SEO: Server-gerenderete Wiki-Rassen-Detailseite /wiki/rasse/{slug}
# ------------------------------------------------------------------
@app.get("/wiki/rasse/{slug}")
async def wiki_rasse_page(slug: str):
from fastapi.responses import HTMLResponse
from database import db as _db
def esc(s):
if not s: return ""
return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;")
with _db() as conn:
rasse = conn.execute("SELECT * FROM wiki_rassen WHERE slug=?", (slug,)).fetchone()
if not rasse:
return HTMLResponse("<html><body><h1>Rasse nicht gefunden</h1><a href='/wiki/rassen'>Alle Rassen</a></body></html>", status_code=404)
berichte = conn.execute(
"""SELECT wb.titel, wb.text, wb.created_at, u.name AS autor
FROM wiki_berichte wb JOIN users u ON u.id=wb.user_id
WHERE wb.rasse=? ORDER BY wb.created_at DESC LIMIT 20""",
(slug,)
).fetchall()
r = dict(rasse)
try:
dogs_count = conn.execute(
"SELECT COUNT(DISTINCT d.user_id) FROM dogs d WHERE LOWER(d.rasse) LIKE ?",
(f"%{r.get('name','').lower()}%",)
).fetchone()[0]
except Exception:
dogs_count = 0
try:
zuchter_count = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
(slug,)
).fetchone()[0]
except Exception:
zuchter_count = 0
berichte = [dict(b) for b in berichte]
berichte_count = len(berichte)
groessen_map = {"klein":"Klein","mittel":"Mittel","gross":"Groß","sehr_gross":"Sehr groß"}
aktivitaet_map = {"niedrig":"Niedrig","mittel":"Mittel","hoch":"Hoch","sehr_hoch":"Sehr hoch"}
erfahrung_map = {"anfaenger":"Anfänger geeignet","fortgeschritten":"Für Erfahrene","experte":"Nur für Experten"}
groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "")
aktivitaet = aktivitaet_map.get(r.get("aktivitaet") or "", r.get("aktivitaet") or "")
erfahrung = erfahrung_map.get(r.get("erfahrung") or "", r.get("erfahrung") or "")
kinder = "Ja" if r.get("kinder_geeignet") else "Bedingt"
wohnung = "Ja" if r.get("wohnung_geeignet") else "Besser Haus mit Garten"
gewicht = ""
if r.get("gewicht_min_kg") and r.get("gewicht_max_kg"):
gewicht = f"{r['gewicht_min_kg']}{r['gewicht_max_kg']} kg"
elif r.get("gewicht_max_kg"):
gewicht = f"bis {r['gewicht_max_kg']} kg"
foto_html = f'<img src="{esc(r["foto_url"])}" alt="{esc(r["name"])}" class="breed-photo">' if r.get("foto_url") else '<div class="breed-photo-placeholder">🐕</div>'
facts = [
("Gruppe / FCI", esc(r.get("gruppe") or "")),
("Herkunft", esc(r.get("herkunft") or "")),
("Größe", groesse),
("Gewicht", gewicht),
("Lebensdauer", esc(r.get("lebensdauer") or "")),
("Aktivitätslevel", aktivitaet),
("Für Anfänger", erfahrung),
("Kinder geeignet", kinder),
("Wohnungsgeeignet", wohnung),
("Ursprüngliche Aufgabe", esc(r.get("bred_for") or "")),
]
facts_html = "".join(
f'<div class="fact-row"><span class="fact-label">{label}</span><span class="fact-value">{val}</span></div>'
for label, val in facts if val
)
temperament_html = ""
if r.get("temperament"):
tags = [t.strip() for t in str(r["temperament"]).split(",") if t.strip()]
temperament_html = (
'<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem">'
+ "".join(
f'<span style="background:#f5e6d3;color:#a86e2e;padding:.2rem .7rem;border-radius:999px;font-size:.8rem">{esc(t)}</span>'
for t in tags
)
+ '</div>'
)
berichte_html = ""
if berichte:
for b in berichte:
datum = b.get("created_at","")[:10] if b.get("created_at") else ""
berichte_html += f"""<div class="bericht">
<div class="bericht-meta">{esc(b.get('autor',''))} · {datum}</div>
<h3 class="bericht-titel">{esc(b.get('titel',''))}</h3>
<p class="bericht-text">{esc(b.get('text',''))}</p>
</div>"""
beschreibung_html = ""
if r.get("beschreibung"):
beschreibung_html = (
'<section>'
'<h2>Charakter &amp; Wesen</h2>'
f'<p style="font-size:.95rem;color:#444;line-height:1.7">{esc(r["beschreibung"])}</p>'
'</section>'
)
vorkommen_html = ""
if r.get("vorkommen_de"):
vorkommen_html = (
'<section>'
'<h2>Vorkommen in Deutschland</h2>'
f'<p style="font-size:.9rem;color:#555;line-height:1.65">{esc(r["vorkommen_de"])}</p>'
'</section>'
)
stats_html = (
'<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1.5rem;font-size:.85rem;color:#888">'
f'<span>&#x1F415; {dogs_count} Nutzer haben diesen Hund</span>'
f'<span>&#x1F3C6; {zuchter_count} Z&uuml;chter eingetragen</span>'
f'<span>&#x1F4AC; {berichte_count} Community-Berichte</span>'
'</div>'
)
name = esc(r.get("name",""))
gruppe = esc(r.get("gruppe") or "")
herkunft = esc(r.get("herkunft") or "")
temp_str = esc(r.get("temperament") or "")
beschr_str = esc(r.get("beschreibung") or "")
if beschr_str:
desc = f"{name}{beschr_str[:160]}".strip().rstrip(".")
else:
desc = f"{name} — Hunderasse aus {herkunft}. Größe: {groesse}. Aktivität: {aktivitaet}. {temp_str[:120] if temp_str else ''}".strip().rstrip(".")
json_ld = f"""{{
"@context":"https://schema.org",
"@type":"Article",
"headline":"{name} — Rasse-Profil",
"description":"{desc}",
"url":"https://banyaro.app/wiki/rasse/{slug}",
"inLanguage":"de",
"publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}},
"mainEntityOfPage":{{"@type":"WebPage","@id":"https://banyaro.app/wiki/rasse/{slug}"}}
}}"""
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name} Hunderasse Profil | Ban Yaro Wiki</title>
<meta name="description" content="{desc[:160]}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/wiki/rasse/{slug}">
<meta property="og:type" content="article">
<meta property="og:title" content="{name} — Hunderasse | Ban Yaro">
<meta property="og:description" content="{desc[:200]}">
<meta property="og:url" content="https://banyaro.app/wiki/rasse/{slug}">
<meta property="og:image" content="{esc(r.get('foto_url') or 'https://banyaro.app/icons/icon-512.png')}">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{name} — Hunderasse | Ban Yaro">
<meta name="twitter:description" content="{desc[:200]}">
<meta name="twitter:image" content="{esc(r.get('foto_url') or 'https://banyaro.app/icons/icon-512.png')}">
<script type="application/ld+json">{json_ld}</script>
<script type="application/ld+json">
{{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
{{"@type":"ListItem","position":1,"name":"Ban Yaro","item":"https://banyaro.app"}},
{{"@type":"ListItem","position":2,"name":"Hunderassen-Wiki","item":"https://banyaro.app/wiki/rassen"}},
{{"@type":"ListItem","position":3,"name":"{name}","item":"https://banyaro.app/wiki/rasse/{slug}"}}
]}}
</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a;line-height:1.6}}
a{{color:#C4843A;text-decoration:none}}
a:hover{{text-decoration:underline}}
nav{{background:#fff;border-bottom:1px solid #e8ddd0;padding:.7rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:10}}
nav .brand{{font-weight:800;color:#C4843A;margin-right:auto}}
nav a{{font-size:.9rem;font-weight:500;color:#555}}
nav a:hover{{color:#C4843A;text-decoration:none}}
.container{{max-width:860px;margin:0 auto;padding:2rem 1.5rem}}
.hero{{display:flex;gap:2rem;align-items:flex-start;margin-bottom:2.5rem;flex-wrap:wrap}}
.breed-photo{{width:200px;height:200px;border-radius:16px;object-fit:cover;border:3px solid #e8ddd0;flex-shrink:0}}
.breed-photo-placeholder{{width:200px;height:200px;border-radius:16px;background:#f0e8dc;display:flex;align-items:center;justify-content:center;font-size:5rem;flex-shrink:0}}
.hero-info h1{{font-size:clamp(1.5rem,4vw,2.2rem);font-weight:800;margin-bottom:.3rem}}
.hero-info .gruppe{{color:#888;font-size:.95rem;margin-bottom:1rem}}
.temp-tags{{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}}
.temp-tag{{background:#f5e6d3;color:#a86e2e;font-size:.8rem;font-weight:600;padding:.25rem .7rem;border-radius:999px}}
.cta{{display:inline-block;background:#C4843A;color:#fff;font-weight:700;padding:.65rem 1.5rem;border-radius:999px;font-size:.9rem;margin-top:.75rem}}
.cta:hover{{background:#a86e2e;text-decoration:none}}
section{{margin-bottom:2.5rem}}
h2{{font-size:1.15rem;font-weight:700;color:#C4843A;margin-bottom:1rem;padding-bottom:.4rem;border-bottom:2px solid #f0e8dc;text-transform:uppercase;letter-spacing:.04em;font-size:.85rem}}
.facts{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;overflow:hidden}}
.fact-row{{display:flex;padding:.7rem 1.25rem;border-bottom:1px solid #f5f0eb}}
.fact-row:last-child{{border-bottom:none}}
.fact-label{{width:180px;font-size:.85rem;color:#888;font-weight:500;flex-shrink:0}}
.fact-value{{font-size:.9rem;font-weight:600;color:#1a1a1a}}
.bericht{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;padding:1.25rem;margin-bottom:1rem}}
.bericht-meta{{font-size:.78rem;color:#aaa;margin-bottom:.35rem}}
.bericht-titel{{font-size:1rem;font-weight:700;margin-bottom:.4rem}}
.bericht-text{{font-size:.9rem;color:#444;line-height:1.6}}
.breadcrumb{{font-size:.82rem;color:#aaa;margin-bottom:1.5rem}}
.breadcrumb a{{color:#C4843A}}
footer{{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:3rem}}
footer a{{color:#C4843A}}
@media(max-width:500px){{.breed-photo,.breed-photo-placeholder{{width:140px;height:140px;font-size:3.5rem}}.fact-label{{width:140px}}}}
</style>
</head>
<body>
<nav>
<span class="brand">Ban Yaro</span>
<a href="/wiki/rassen">Alle Rassen</a>
<a href="/knigge">Knigge</a>
<a href="/info">Über die App</a>
<a href="/" class="cta" style="padding:.35rem 1rem;font-size:.82rem">App öffnen</a>
</nav>
<div class="container">
<div class="breadcrumb">
<a href="/wiki/rassen">Hunderassen-Wiki</a> {name}
</div>
<div class="hero">
{foto_html}
<div class="hero-info">
<h1>{name}</h1>
{'<p class="gruppe">' + gruppe + '</p>' if gruppe else ''}
{temperament_html}
<a href="/" class="cta">In der App öffnen</a>
</div>
</div>
{beschreibung_html}
{vorkommen_html}
<section>
<h2>Steckbrief</h2>
<div class="facts">{facts_html}</div>
</section>
{stats_html}
{'<section><h2>Erfahrungsberichte der Community (' + str(berichte_count) + ')</h2>' + berichte_html + '</section>' if berichte else ''}
<section>
<h2>In der App</h2>
<p style="font-size:.9rem;color:#555;margin-bottom:.75rem">
Ban Yaro ist die kostenlose Hunde-App für Deutschland, Österreich und die Schweiz.
Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community und mehr DSGVO-konform, ohne App Store.
</p>
<a href="/" class="cta">Kostenlos starten</a>
</section>
</div>
<footer>
<strong style="color:#fff">Ban Yaro</strong> Hunderassen-Wiki ·
<a href="/wiki/rassen">Alle Rassen</a> ·
<a href="https://banyaro.app">banyaro.app</a>
</footer>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
@app.get("/favicon.ico")
async def favicon():
return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
@ -193,12 +628,45 @@ async def share_target(request: Request):
# Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig)
@app.get("/hund/{dog_id}")
async def public_dog_page(dog_id: int):
from database import db as _db
_og_name = "Hunde-Profil"
_og_desc = "Hunde-Profil auf Ban Yaro — der deutschen Hunde-Plattform"
_og_img = "https://banyaro.app/icons/icon-512.png"
try:
with _db() as conn:
_dog = conn.execute(
"SELECT name, rasse, foto_url, bio FROM dogs WHERE id=? AND is_public=1",
(dog_id,)
).fetchone()
if _dog:
_og_name = _dog["name"]
_rasse = f" · {_dog['rasse']}" if _dog.get("rasse") else ""
_og_desc = f"{_dog['name']}{_rasse} — Profil auf Ban Yaro"
if _dog.get("bio"):
_og_desc = f"{_dog['name']}{_rasse}: {str(_dog['bio'])[:120]}"
if _dog.get("foto_url"):
_og_img = f"https://banyaro.app{_dog['foto_url']}"
except Exception:
pass
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Profil BAN YARO</title>
<title>{_og_name} Ban Yaro</title>
<meta name="description" content="{_og_desc}">
<meta name="robots" content="noindex">
<meta property="og:type" content="profile">
<meta property="og:title" content="{_og_name} — Ban Yaro">
<meta property="og:description" content="{_og_desc}">
<meta property="og:url" content="https://banyaro.app/hund/{dog_id}">
<meta property="og:image" content="{_og_img}">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{_og_name} — Ban Yaro">
<meta name="twitter:image" content="{_og_img}">
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/components.css">
<style>
@ -688,6 +1156,133 @@ async def ausweis_page(dog_id: int, request: Request):
return HTMLResponse(html)
# ------------------------------------------------------------------
# SEO: Hunde-Knigge als server-gerenderete Seite /knigge
# ------------------------------------------------------------------
@app.get("/knigge")
async def knigge_page():
from fastapi.responses import HTMLResponse
begegnungen = [
("Fremder Hund", "Kurze Leine, ruhig bleiben. Hunde schnüffeln lassen wenn beide entspannt sind. Bei Eskalation: weglenken, Richtung wechseln. Freilaufende Hunde auf angeleine Hunde zulaufen lassen ist unhöflich — der angeleine Hund kann in Stress (Leinenfrust) geraten."),
("Kinder", "Hund nie unbeaufsichtigt mit fremden Kindern lassen. Kind fragen ob es streicheln darf. Hund seitlich positionieren, nicht zwischen Kind und Weg. Kinder sollten keinen direkten Augenkontakt mit unbekannten Hunden halten."),
("Radfahrer", "Hund rechtzeitig an die Seite nehmen. Fahrräder können für manche Hunde bedrohlich wirken. Frühzeitig Abstand gewinnen, ruhig bleiben, Hund bei sich halten."),
("Jogger", "Kurze Leine, Abstand halten, Hund nicht anspringen lassen. Jogger bewegen sich schnell — das kann Jagdinstinkt auslösen."),
("ÖPNV (Bus & Bahn)", "In Deutschland gilt im ÖPNV grundsätzlich Maulkorbpflicht für Hunde. Kleine Hunde in Transportbox fahren kostenlos oder zum Kindertarif. Große Hunde brauchen in den meisten Städten einen eigenen Fahrschein. Regeln variieren je nach Verkehrsverbund."),
("Supermarkt & Geschäfte", "Es gilt das Hausrecht des Betreibers. Ein 'Hunde willkommen'-Schild ist eine explizite Einladung. Im Zweifel immer fragen. Außen anbinden ist nur kurzzeitig und mit Sichtkontakt akzeptabel."),
("Restaurant", "Im Außenbereich erlauben viele Restaurants Hunde — aber Hausrecht gilt. Wenn ein anderer Gast Angst hat, ist Kompromissbereitschaft ein Zeichen guter Hundehaltung. Im Zweifelsfall Personal entscheiden lassen."),
("Spielplätze", "Hunde sind auf Kinderspielplätzen generell verboten (§ 2 Abs. 4 BImSchG und Gemeindesatzungen). Gilt auch für gut erzogene Hunde. Abstand halten, auch beim Vorbeigehen."),
]
szenarien = [
("Muss Kot immer aufgesammelt werden?", "Ja — auch im Gebüsch abseits des Weges. Kinder spielen überall, und Parasiten wie Spulwurm können für Menschen gefährlich sein. Bußgelder für liegengelassenen Hundekot liegen je nach Stadt zwischen 50 € und 500 €."),
("Leinenpflicht ohne Schild?", "Leinenpflicht ist Ländersache. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften und öffentlichen Grünanlagen. Im Zweifel anleinen und die Gemeindesatzung prüfen."),
("Hund frei laufen trotz angeleintem Hund?", "Nein. Freilaufende Hunde auf angeleine Hunde zuzulassen ist unhöflich und kann den angeleinten Hund in Stress versetzen (Leinenfrust). Immer erst anleinen und fragen ob ein Treffen gewünscht ist."),
("Haftung bei Beißvorfall?", "Hundehalter haften verschuldensunabhängig für Schäden durch ihren Hund (§ 833 BGB). Eine Hunde-Haftpflichtversicherung ist in vielen Bundesländern Pflicht und in jedem Fall empfehlenswert."),
]
begs_html = "".join(
f'<div class="item"><h3>{titel}</h3><p>{text}</p></div>'
for titel, text in begegnungen
)
szen_html = "".join(
f'<div class="item"><h3>{frage}</h3><p>{antwort}</p></div>'
for frage, antwort in szenarien
)
html = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Knigge Regeln für Hundebesitzer | Ban Yaro</title>
<meta name="description" content="Der Hunde-Knigge: Begegnungen mit fremden Hunden, Kindern, Radfahrern, Regeln im ÖPNV, Leinenpflicht, Haftung — alles was Hundebesitzer wissen müssen.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/knigge">
<meta property="og:type" content="article">
<meta property="og:title" content="Hunde-Knigge — Regeln für Hundebesitzer | Ban Yaro">
<meta property="og:description" content="Begegnungen mit fremden Hunden, Kindern, Radfahrern, Regeln im ÖPNV, Leinenpflicht, Haftung — alles was Hundebesitzer wissen müssen.">
<meta property="og:url" content="https://banyaro.app/knigge">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[
{"@type":"Question","name":"Muss Hundekot immer aufgesammelt werden?","acceptedAnswer":{"@type":"Answer","text":"Ja — auch im Gebüsch abseits des Weges. Bußgelder liegen je nach Stadt zwischen 50 € und 500 €."}},
{"@type":"Question","name":"Gilt Leinenpflicht ohne Schild?","acceptedAnswer":{"@type":"Answer","text":"Leinenpflicht ist Ländersache. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften. Im Zweifel anleinen."}},
{"@type":"Question","name":"Darf mein Hund auf einen angeleinten Hund zulaufen?","acceptedAnswer":{"@type":"Answer","text":"Nein. Freilaufende Hunde auf angeleine Hunde zulaufen lassen ist unhöflich und verursacht Leinenfrust."}},
{"@type":"Question","name":"Wer haftet bei einem Beißvorfall?","acceptedAnswer":{"@type":"Answer","text":"Hundehalter haften verschuldensunabhängig für Schäden durch ihren Hund (§ 833 BGB). Eine Haftpflichtversicherung ist in vielen Bundesländern Pflicht."}}
]}
</script>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
{"@type":"ListItem","position":1,"name":"Ban Yaro","item":"https://banyaro.app"},
{"@type":"ListItem","position":2,"name":"Hunde-Knigge","item":"https://banyaro.app/knigge"}
]}
</script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a;line-height:1.65}
a{color:#C4843A;text-decoration:none}
a:hover{text-decoration:underline}
header{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2.5rem 1.5rem;text-align:center}
header h1{font-size:clamp(1.5rem,4vw,2.2rem);font-weight:800;margin-bottom:.5rem}
header p{opacity:.92;max-width:560px;margin:0 auto}
nav{background:#fff;border-bottom:1px solid #e8ddd0;padding:.65rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:10}
nav .brand{font-weight:800;color:#C4843A;margin-right:auto}
nav a{font-size:.9rem;font-weight:500;color:#555}
nav a:hover{color:#C4843A;text-decoration:none}
.container{max-width:820px;margin:0 auto;padding:2.5rem 1.5rem}
section{margin-bottom:3rem}
h2{font-size:.82rem;font-weight:700;color:#C4843A;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1.25rem;padding-bottom:.4rem;border-bottom:2px solid #f0e8dc}
.item{background:#fff;border:1px solid #e8ddd0;border-radius:12px;padding:1.25rem 1.5rem;margin-bottom:.9rem}
.item h3{font-size:1rem;font-weight:700;margin-bottom:.4rem;color:#1a1a1a}
.item p{font-size:.9rem;color:#444;line-height:1.65}
.cta-box{background:linear-gradient(135deg,#f5e6d3,#fdf6ef);border:1px solid #e8c99a;border-radius:14px;padding:1.75rem;text-align:center;margin-top:2.5rem}
.cta-box h3{font-size:1.1rem;font-weight:700;margin-bottom:.5rem}
.cta-box p{font-size:.9rem;color:#555;margin-bottom:1rem}
.cta-btn{display:inline-block;background:#C4843A;color:#fff;font-weight:700;padding:.65rem 1.75rem;border-radius:999px;font-size:.95rem}
.cta-btn:hover{background:#a86e2e;text-decoration:none}
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
footer a{color:#C4843A}
</style>
</head>
<body>
<header>
<h1>Hunde-Knigge</h1>
<p>Regeln, Tipps und häufige Fragen für Hundebesitzer im Alltag, im ÖPNV und in der Community</p>
</header>
<nav>
<span class="brand">Ban Yaro</span>
<a href="/wiki/rassen">Rassen-Wiki</a>
<a href="/info">Über die App</a>
<a href="/" style="background:#C4843A;color:#fff;padding:.35rem 1rem;border-radius:999px;font-weight:700;font-size:.85rem;text-decoration:none">App öffnen</a>
</nav>
<div class="container">
<section>
<h2>Begegnungen im Alltag</h2>
""" + begs_html + """
</section>
<section>
<h2>Häufige Fragen &amp; Regeln</h2>
""" + szen_html + """
</section>
<div class="cta-box">
<h3>Mehr Tipps in der Ban Yaro App</h3>
<p>Community-Abstimmungen zu kniffligen Situationen, KI-Situationsberater und alle Hunde-Funktionen kostenlos nutzen.</p>
<a href="/" class="cta-btn">Kostenlos starten</a>
</div>
</div>
<footer>
<strong style="color:#fff">Ban Yaro</strong> Hunde-Knigge ·
<a href="/wiki/rassen">Rassen-Wiki</a> ·
<a href="https://banyaro.app">banyaro.app</a>
</footer>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=7200"})
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):

View file

@ -86,6 +86,9 @@ class UserPatch(BaseModel):
is_banned: Optional[int] = None
ban_reason: Optional[str] = None
class WikiEnrichBody(BaseModel):
limit: int = 10
class ThreadAdminPatch(BaseModel):
is_pinned: Optional[int] = None
is_locked: Optional[int] = None
@ -550,3 +553,33 @@ async def get_analytics(user=Depends(require_mod)):
"pageviews": r_pv.json(),
"top_pages": _to_list(r_pages),
}
# ------------------------------------------------------------------
# POST /api/admin/wiki/enrich — KI-Rassen-Anreicherung anstoßen
# ------------------------------------------------------------------
@router.post("/wiki/enrich")
async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
from scraper.breed_enricher import enrich_breeds
limit = max(1, min(data.limit, 100))
enriched = await enrich_breeds(limit)
with db() as conn:
remaining = conn.execute(
"SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=0"
).fetchone()[0]
return {"enriched": enriched, "remaining": remaining}
# ------------------------------------------------------------------
# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod)
# ------------------------------------------------------------------
@router.delete("/wiki/zuchter/{zuchter_id}", status_code=204)
async def admin_delete_zuchter(zuchter_id: int, user=Depends(require_mod)):
with db() as conn:
row = conn.execute(
"SELECT id FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
conn.execute("DELETE FROM wiki_zuchter WHERE id=?", (zuchter_id,))
_audit(conn, user, "wiki_zuchter_delete", f"zuchter:{zuchter_id}")

35
backend/routes/praise.py Normal file
View file

@ -0,0 +1,35 @@
"""BAN YARO — Fortschritts-Lober"""
from fastapi import APIRouter, Depends, Query
from auth import get_current_user
from database import db
from datetime import date
router = APIRouter()
def _current_week_key():
d = date.today()
return f"{d.isocalendar()[0]}-W{d.isocalendar()[1]:02d}"
@router.get("/current")
async def get_current_praise(dog_id: int = Query(...), user=Depends(get_current_user)):
"""Gibt den Lob-Text der aktuellen Woche zurück."""
week_key = _current_week_key()
with db() as conn:
# Sicherheitscheck: Hund gehört dem User?
dog = conn.execute(
"""SELECT d.id, d.name FROM dogs d
LEFT JOIN dog_shares ds ON ds.dog_id=d.id AND ds.shared_with_id=?
WHERE d.id=? AND (d.user_id=? OR ds.id IS NOT NULL)""",
(user["id"], dog_id, user["id"])
).fetchone()
if not dog:
return {"praise": None}
row = conn.execute(
"SELECT praise_text, generated_at FROM weekly_praise WHERE dog_id=? AND week_key=?",
(dog_id, week_key)
).fetchone()
if not row:
return {"praise": None, "week_key": week_key}
return {"praise": row["praise_text"], "week_key": week_key, "generated_at": row["generated_at"]}

View file

@ -1,8 +1,10 @@
"""BAN YARO — Übungs- & Trainingsfortschritt"""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import datetime
import ki
from database import db
from auth import get_current_user
@ -154,3 +156,459 @@ async def get_suggestions(user=Depends(get_current_user)):
})
return suggestions[:2] # max 2 Empfehlungen
# ------------------------------------------------------------------
# Training: Session-Protokoll
# ------------------------------------------------------------------
STIMMUNGS_LABELS = {
"aufmerksam": "aufmerksam",
"muede": "müde",
"abgelenkt": "abgelenkt",
"super": "super motiviert",
}
TRAINING_BADGES = [
("training_first", 1, "Erste Trainingseinheit",
"Ihr habt gemeinsam die erste Einheit abgeschlossen \U0001f43e"),
("training_5", 5, "5 Einheiten",
"5 Trainingseinheiten \u2014 ihr seid dabei!"),
("training_10", 10, "10 Einheiten",
"10 Einheiten! {name} macht gro\u00dfartige Fortschritte."),
("training_25", 25, "25 Einheiten",
"25 Einheiten \u2014 eine echte Trainingspartnerschaft!"),
("training_50", 50, "50 Einheiten",
"50 Einheiten! {name} und du seid ein echtes Team."),
("training_top_5", 5, "5 Top-Trainings",
"5 Top-Trainings \u2014 {name} ist ein Schnellerner!"),
]
def _check_badges(conn, user_id: int, dog_name: str) -> list:
"""Prüft und vergibt Trainings-Badges. Gibt neu verdiente Badges zurück."""
total = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE user_id=?",
(user_id,)
).fetchone()[0]
top_count = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND ist_top=1",
(user_id,)
).fetchone()[0]
existing = {
r[0] for r in conn.execute(
"SELECT badge_id FROM user_badges WHERE user_id=?", (user_id,)
).fetchall()
}
new_badges = []
for badge_id, threshold, title, desc_tpl in TRAINING_BADGES:
if badge_id in existing:
continue
count = top_count if badge_id == "training_top_5" else total
if count >= threshold:
desc = desc_tpl.format(name=dog_name)
conn.execute(
"INSERT OR IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)",
(user_id, badge_id)
)
new_badges.append({"id": badge_id, "title": title, "desc": desc})
return new_badges
class SessionCreate(BaseModel):
dog_id: int
exercise_id: str
exercise_name: str
datum: Optional[str] = None
wiederholungen: int = 1
erfolgsquote: int = 50
hund_stimmung: str = "aufmerksam"
zufriedenheit: int = 3
notiz: Optional[str] = None
tagebuch_eintrag: bool = False
@router.post("/sessions")
async def log_session(body: SessionCreate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Hund-Zugehörigkeit prüfen
dog = conn.execute(
"SELECT id, name FROM dogs WHERE id=? AND user_id=?",
(body.dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
dog_name = dog["name"]
datum = body.datum or datetime.date.today().isoformat()
ist_top = int(body.erfolgsquote >= 80 and body.zufriedenheit >= 4)
cur = conn.execute(
"""
INSERT INTO training_sessions
(user_id, dog_id, exercise_id, exercise_name, datum,
wiederholungen, erfolgsquote, hund_stimmung, zufriedenheit,
notiz, ist_top)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""",
(uid, body.dog_id, body.exercise_id, body.exercise_name, datum,
body.wiederholungen, body.erfolgsquote, body.hund_stimmung,
body.zufriedenheit, body.notiz, ist_top)
)
session_id = cur.lastrowid
# Badges prüfen
new_badges = _check_badges(conn, uid, dog_name)
# Tagebucheintrag erstellen?
diary_entry_id = None
if body.tagebuch_eintrag or ist_top:
stimmung_label = STIMMUNGS_LABELS.get(body.hund_stimmung, body.hund_stimmung)
if ist_top:
titel = f"\U0001f3af {body.exercise_name} \u2014 Top-Training!"
else:
titel = f"\U0001f3af Training: {body.exercise_name}"
text_parts = [
f"{body.wiederholungen} Wiederholungen \u00b7 "
f"Erfolgsquote: {body.erfolgsquote}% \u00b7 "
f"Stimmung: {stimmung_label}"
]
if body.notiz:
text_parts.append(f"\n\n{body.notiz}")
eintrag_text = "".join(text_parts)
diary_cur = conn.execute(
"""
INSERT INTO diary (dog_id, datum, typ, titel, text)
VALUES (?,?,?,?,?)
""",
(body.dog_id, datum, "training", titel, eintrag_text)
)
diary_entry_id = diary_cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(diary_entry_id, body.dog_id)
)
conn.execute(
"UPDATE training_sessions SET diary_entry_id=? WHERE id=?",
(diary_entry_id, session_id)
)
session = {
"id": session_id,
"user_id": uid,
"dog_id": body.dog_id,
"exercise_id": body.exercise_id,
"exercise_name": body.exercise_name,
"datum": datum,
"wiederholungen": body.wiederholungen,
"erfolgsquote": body.erfolgsquote,
"hund_stimmung": body.hund_stimmung,
"zufriedenheit": body.zufriedenheit,
"notiz": body.notiz,
"ist_top": bool(ist_top),
"diary_entry_id": diary_entry_id,
}
return {
"session": session,
"ist_top": bool(ist_top),
"badges": new_badges,
"diary_entry_id": diary_entry_id,
}
@router.get("/sessions")
async def get_sessions(
dog_id: int,
limit: int = 50,
offset: int = 0,
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.")
rows = conn.execute(
"""
SELECT * FROM training_sessions
WHERE user_id=? AND dog_id=?
ORDER BY datum DESC, created_at DESC
LIMIT ? OFFSET ?
""",
(uid, dog_id, limit, offset)
).fetchall()
return [dict(r) for r in rows]
@router.get("/calendar")
async def get_calendar(
dog_id: int,
year: Optional[int] = None,
month: Optional[int] = None,
user=Depends(get_current_user)
):
uid = user["id"]
today = datetime.date.today()
year = year or today.year
month = month or today.month
month_str = f"{year:04d}-{month:02d}"
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.")
rows = conn.execute(
"""
SELECT datum,
COUNT(*) AS count,
MAX(ist_top) AS top
FROM training_sessions
WHERE user_id=? AND dog_id=?
AND datum LIKE ?
GROUP BY datum
""",
(uid, dog_id, month_str + "-%")
).fetchall()
days = {
r["datum"]: {"count": r["count"], "top": bool(r["top"])}
for r in rows
}
return {"days": days}
@router.get("/stats")
async def get_stats(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.")
total_sessions = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()[0]
total_exercises = conn.execute(
"SELECT COUNT(DISTINCT exercise_id) FROM training_sessions WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()[0]
avg_row = conn.execute(
"SELECT AVG(erfolgsquote) FROM training_sessions WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
avg_erfolgsquote = round(avg_row[0], 1) if avg_row[0] is not None else 0.0
best_row = conn.execute(
"""
SELECT exercise_name, AVG(erfolgsquote) AS avg_e
FROM training_sessions
WHERE user_id=? AND dog_id=?
GROUP BY exercise_id
ORDER BY avg_e DESC
LIMIT 1
""",
(uid, dog_id)
).fetchone()
best_exercise = (
{"name": best_row["exercise_name"], "avg_erfolg": round(best_row["avg_e"], 1)}
if best_row else None
)
training_days = conn.execute(
"SELECT COUNT(DISTINCT datum) FROM training_sessions WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()[0]
top_sessions = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND ist_top=1",
(uid, dog_id)
).fetchone()[0]
today = datetime.date.today()
week_start = (today - datetime.timedelta(days=today.weekday())).isoformat()
this_week = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND datum >= ?",
(uid, dog_id, week_start)
).fetchone()[0]
month_start = today.replace(day=1).isoformat()
this_month = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND datum >= ?",
(uid, dog_id, month_start)
).fetchone()[0]
# Streak: aufeinanderfolgende Tage bis heute
day_rows = conn.execute(
"""
SELECT DISTINCT datum FROM training_sessions
WHERE user_id=? AND dog_id=?
ORDER BY datum DESC
""",
(uid, dog_id)
).fetchall()
streak_days = 0
check = today
for row in day_rows:
d = datetime.date.fromisoformat(row["datum"])
if d == check:
streak_days += 1
check -= datetime.timedelta(days=1)
else:
break
return {
"total_sessions": total_sessions,
"total_exercises": total_exercises,
"avg_erfolgsquote": avg_erfolgsquote,
"best_exercise": best_exercise,
"training_days": training_days,
"top_sessions": top_sessions,
"this_week": this_week,
"this_month": this_month,
"streak_days": streak_days,
}
class KiFeedbackRequest(BaseModel):
dog_id: int
@router.post("/ki-feedback")
async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
dog = conn.execute(
"SELECT id, name, rasse, geburtstag FROM dogs WHERE id=? AND user_id=?",
(body.dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
# Cache prüfen
cache_row = conn.execute(
"SELECT feedback, generated_at FROM training_ki_cache WHERE dog_id=?",
(body.dog_id,)
).fetchone()
if cache_row:
generated_at = datetime.datetime.fromisoformat(cache_row["generated_at"])
age_hours = (datetime.datetime.utcnow() - generated_at).total_seconds() / 3600
# Neue Sessions seit letzter Generierung?
new_since = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND created_at > ?",
(body.dog_id, cache_row["generated_at"])
).fetchone()[0]
if age_hours < 6 and new_since == 0:
return {"feedback": cache_row["feedback"], "cached": True}
# Hund-Alter berechnen
alter_str = "unbekannt"
if dog["geburtstag"]:
try:
geb = datetime.date.fromisoformat(dog["geburtstag"])
alter_jahre = (datetime.date.today() - geb).days // 365
alter_str = f"{alter_jahre} Jahre"
except Exception:
pass
# Letzte 20 Sessions laden
sessions = conn.execute(
"""
SELECT datum, exercise_name, wiederholungen, erfolgsquote,
hund_stimmung, zufriedenheit, notiz, ist_top
FROM training_sessions
WHERE dog_id=?
ORDER BY datum DESC, created_at DESC
LIMIT 20
""",
(body.dog_id,)
).fetchall()
# Übungsfortschritt laden
progress_rows = conn.execute(
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
(uid,)
).fetchall()
# Prompt zusammenbauen
dog_name = dog["name"]
dog_rasse = dog["rasse"] or "unbekannter Rasse"
sessions_text = "\n".join(
f" {r['datum']}: {r['exercise_name']}, "
f"{r['wiederholungen']}x, {r['erfolgsquote']}% Erfolg, "
f"Stimmung: {STIMMUNGS_LABELS.get(r['hund_stimmung'], r['hund_stimmung'])}, "
f"Zufriedenheit: {r['zufriedenheit']}/5"
+ (f", Notiz: {r['notiz']}" if r["notiz"] else "")
+ (" [TOP]" if r["ist_top"] else "")
for r in sessions
) or " (noch keine Sessions)"
progress_text = "\n".join(
f" {r['exercise_id']}: {r['status']}"
for r in progress_rows
) or " (kein Fortschritt erfasst)"
prompt = (
f"Hund: {dog_name}, {dog_rasse}, {alter_str}\n"
f"Letzte Sessions:\n{sessions_text}\n"
f"Übungsfortschritt:\n{progress_text}\n\n"
"Antworte mit maximal 3 kurzen Abschnitten:\n"
"1. Was gut läuft (1-2 Sätze, immer positiv beginnen)\n"
"2. Konkrete Empfehlung (1-2 Sätze, spezifisch auf die Daten bezogen)\n"
"3. Kleiner motivierender Abschluss (1 Satz)\n\n"
"Sprich über den Hund, nicht über den Besitzer. Kein Druck, keine Forderungen."
)
system = (
"Du bist ein einfühlsamer, positiver Hundetrainer. "
"Analysiere die Trainingshistorie und gib kurzes, motivierendes Feedback auf Deutsch."
)
try:
feedback_text = await ki.complete(
prompt,
system=system,
max_tokens=400,
requires_premium=False,
user_is_premium=user.get("is_premium", False),
)
except (ki.KIUnavailableError, ki.KIPremiumRequired) as e:
raise HTTPException(503, str(e))
# Cache speichern
with db() as conn:
conn.execute(
"""
INSERT INTO training_ki_cache (dog_id, feedback, generated_at)
VALUES (?,?,datetime('now'))
ON CONFLICT(dog_id) DO UPDATE
SET feedback=excluded.feedback, generated_at=excluded.generated_at
""",
(body.dog_id, feedback_text)
)
return {"feedback": feedback_text, "cached": False}

View file

@ -7,7 +7,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel
from database import db
from auth import get_current_user
from auth import get_current_user, get_current_user_optional
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -437,3 +437,241 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
pass
return {"ok": True}
# ------------------------------------------------------------------
# Hilfsfunktion: Stats für eine Rasse zusammenstellen
# ------------------------------------------------------------------
def _build_stats(conn, rasse_slug: str, user_id=None) -> dict:
dogs_count = conn.execute(
"SELECT COUNT(DISTINCT user_id) FROM dogs WHERE LOWER(rasse) = LOWER(?)",
(rasse_slug,),
).fetchone()[0]
hat_count = conn.execute(
"SELECT COUNT(*) FROM wiki_breed_interest WHERE rasse_slug=? AND typ='hat'",
(rasse_slug,),
).fetchone()[0]
will_count = conn.execute(
"SELECT COUNT(*) FROM wiki_breed_interest WHERE rasse_slug=? AND typ='will'",
(rasse_slug,),
).fetchone()[0]
zuchter_count = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
(rasse_slug,),
).fetchone()[0]
berichte_count = conn.execute(
"SELECT COUNT(*) FROM wiki_berichte WHERE rasse=?",
(rasse_slug,),
).fetchone()[0]
user_interest = None
if user_id:
row = conn.execute(
"SELECT typ FROM wiki_breed_interest WHERE user_id=? AND rasse_slug=?",
(user_id, rasse_slug),
).fetchone()
if row:
user_interest = row["typ"]
return {
"dogs_count": dogs_count,
"hat_count": hat_count,
"will_count": will_count,
"zuchter_count": zuchter_count,
"berichte_count":berichte_count,
"user_interest": user_interest,
}
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug}/stats
# ------------------------------------------------------------------
@router.get("/rassen/{slug}/stats")
async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)):
with db() as conn:
rasse = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug=?", (slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
return _build_stats(conn, slug, user["id"] if user else None)
# ------------------------------------------------------------------
# Schemas für Interesse und Züchter
# ------------------------------------------------------------------
class InteresseCreate(BaseModel):
typ: str # "hat" oder "will"
class ZuchterCreate(BaseModel):
rasse_slug: str
name: str
zwingername: str = ""
ort: str = ""
plz: str = ""
bundesland: str = ""
vdh_mitglied: int = 0
website: str = ""
telefon: str = ""
beschreibung: str = ""
# ------------------------------------------------------------------
# POST /api/wiki/rassen/{slug}/interesse
# ------------------------------------------------------------------
@router.post("/rassen/{slug}/interesse")
async def set_interesse(slug: str, data: InteresseCreate, user=Depends(get_current_user)):
if data.typ not in ("hat", "will"):
raise HTTPException(400, "typ muss 'hat' oder 'will' sein.")
with db() as conn:
rasse = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug=?", (slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
conn.execute(
"""INSERT INTO wiki_breed_interest (user_id, rasse_slug, typ)
VALUES (?, ?, ?)
ON CONFLICT(user_id, rasse_slug) DO UPDATE SET typ=excluded.typ""",
(user["id"], slug, data.typ),
)
return _build_stats(conn, slug, user["id"])
# ------------------------------------------------------------------
# DELETE /api/wiki/rassen/{slug}/interesse
# ------------------------------------------------------------------
@router.delete("/rassen/{slug}/interesse")
async def delete_interesse(slug: str, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"DELETE FROM wiki_breed_interest WHERE user_id=? AND rasse_slug=?",
(user["id"], slug),
)
return _build_stats(conn, slug, user["id"])
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug}/zuchter
# ------------------------------------------------------------------
@router.get("/rassen/{slug}/zuchter")
async def get_zuchter_fuer_rasse(
slug: str,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
with db() as conn:
rows = conn.execute(
"""SELECT z.id, z.rasse_slug, z.name, z.zwingername, z.ort, z.plz,
z.bundesland, z.vdh_mitglied, z.website, z.telefon,
z.beschreibung, z.created_at
FROM wiki_zuchter z
WHERE z.rasse_slug=? AND z.verified=1
ORDER BY z.bundesland ASC, z.ort ASC
LIMIT ? OFFSET ?""",
(slug, limit, offset),
).fetchall()
total = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
(slug,),
).fetchone()[0]
return {"zuchter": [dict(r) for r in rows], "total": total}
# ------------------------------------------------------------------
# POST /api/wiki/zuchter — Züchter einreichen
# ------------------------------------------------------------------
@router.post("/zuchter", status_code=201)
async def create_zuchter(data: ZuchterCreate, user=Depends(get_current_user)):
if not data.name.strip():
raise HTTPException(400, "Name darf nicht leer sein.")
with db() as conn:
rasse = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug=?", (data.rasse_slug,)
).fetchone()
if not rasse:
raise HTTPException(400, "Ungültige Rasse.")
cur = conn.execute(
"""INSERT INTO wiki_zuchter
(rasse_slug, name, zwingername, ort, plz, bundesland,
vdh_mitglied, website, telefon, beschreibung, verified, user_id)
VALUES (?,?,?,?,?,?,?,?,?,?,0,?)""",
(
data.rasse_slug, data.name.strip(),
data.zwingername.strip() or None,
data.ort.strip() or None,
data.plz.strip() or None,
data.bundesland.strip() or None,
data.vdh_mitglied,
data.website.strip() or None,
data.telefon.strip() or None,
data.beschreibung.strip() or None,
user["id"],
),
)
row = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/wiki/zuchter/{id} — eigene Einreichung löschen
# ------------------------------------------------------------------
@router.delete("/zuchter/{zuchter_id}")
async def delete_zuchter(zuchter_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id, user_id FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
is_admin = user.get("rolle") == "admin" or user.get("is_moderator")
if row["user_id"] != user["id"] and not is_admin:
raise HTTPException(403, "Nicht erlaubt.")
conn.execute("DELETE FROM wiki_zuchter WHERE id=?", (zuchter_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/wiki/zuchter/pending — unverifizierte Einreichungen (Mod/Admin)
# ------------------------------------------------------------------
@router.get("/zuchter/pending")
async def list_zuchter_pending(user=Depends(get_current_user)):
if not (user.get("is_moderator") or user.get("rolle") in ("admin", "moderator")):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
"""SELECT z.*, u.name AS user_name
FROM wiki_zuchter z
LEFT JOIN users u ON u.id = z.user_id
WHERE z.verified=0
ORDER BY z.created_at ASC""",
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/wiki/zuchter/{id}/verify — Züchter freigeben (Mod/Admin)
# ------------------------------------------------------------------
@router.patch("/zuchter/{zuchter_id}/verify")
async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
if not (user.get("is_moderator") or user.get("rolle") in ("admin", "moderator")):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
row = conn.execute(
"SELECT id FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
conn.execute(
"UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,)
)
result = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)
).fetchone()
return dict(result)

View file

@ -19,6 +19,9 @@ logger = logging.getLogger(__name__)
_scheduler = AsyncIOScheduler(timezone="Europe/Berlin")
# In-Memory Job-Protokoll: {job_id: {"last_run": datetime, "result": str, "status": "ok"|"error"}}
_job_log: dict = {}
def start():
_scheduler.add_job(
@ -87,8 +90,41 @@ def start():
id="seed_wikidata_startup",
replace_existing=True,
)
# Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen
_scheduler.add_job(
_job_wiki_enrich,
CronTrigger(hour=2, minute=30),
id="wiki_enrich_nightly",
replace_existing=True,
misfire_grace_time=3600,
)
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
_scheduler.add_job(
_job_weekly_praise,
CronTrigger(day_of_week='mon', hour=9, minute=0),
id="weekly_praise",
replace_existing=True,
misfire_grace_time=3600,
)
# 4× täglich Status-Report per Mail (07:00, 13:00, 19:00, 01:00)
for _h in [7, 13, 19, 1]:
_scheduler.add_job(
_job_status_report,
CronTrigger(hour=_h, minute=0),
id=f"status_report_{_h:02d}",
replace_existing=True,
misfire_grace_time=1800,
)
# Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern
_scheduler.add_job(
_job_wiki_enrich_startup,
'date',
run_date=datetime.now(tz=_TZ) + timedelta(seconds=90),
id="wiki_enrich_startup",
replace_existing=True,
)
_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 beim 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 beim Start, Wiki-KI-Anreicherung 02:30.")
def stop():
@ -150,6 +186,7 @@ async def _job_health_reminders():
logger.info(f"Reminder Push: user={r['user_id']} entry={r['id']} delta={delta}d")
logger.info(f"Health-Reminder Job fertig — {len(rows)} Einträge, {sent_total} Push gesendet.")
_log_job("health_reminders", "ok", f"{len(rows)} Einträge, {sent_total} Push")
# ------------------------------------------------------------------
@ -176,6 +213,7 @@ async def _job_poison_archive():
AND expires_at < ?
""", (now,))
count = result.rowcount
_log_job("poison_archive", "ok", f"{count} Meldungen archiviert")
if count:
logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.")
@ -202,6 +240,7 @@ async def _job_weather_alert():
thunderstorm = summary["thunderstorm"]
if max_temp >= 28:
_log_job("weather_alert", "ok", f"Hitze-Push: {max_temp:.0f}°C")
sent = send_push_to_all({
"type": "weather_heat",
"title": "☀️ Heißer Asphalt heute",
@ -222,6 +261,7 @@ async def _job_weather_alert():
return
logger.info("Wetter-Alert: Keine Warnung nötig heute.")
_log_job("weather_alert", "ok", "Keine Warnung")
# ------------------------------------------------------------------
@ -301,6 +341,7 @@ async def _job_milestone_check():
created_total += 1
logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.")
_log_job("milestone_check", "ok", f"{created_total} Meilensteine erstellt")
# ------------------------------------------------------------------
@ -348,6 +389,7 @@ async def _job_import_events():
logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}")
logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).")
_log_job("import_events", "ok", f"{imported} neue von {len(events)} Events")
# ------------------------------------------------------------------
@ -390,8 +432,10 @@ async def _job_seed_wikidata_breeds():
from scraper.wikipedia_photos import fetch_wikipedia_photos
wp_count = await fetch_wikipedia_photos()
logger.info(f"Wikipedia photo fetch done: {wp_count} Fotos")
_log_job("seed_wikidata_startup", "ok", f"{count} Rassen, {mirrored}+{wp_count} Fotos")
except Exception as e:
logger.error(f"Wikidata-Seed: Fehler: {e}")
_log_job("seed_wikidata_startup", "error", str(e))
# ------------------------------------------------------------------
@ -575,6 +619,359 @@ async def _job_prewarm_cities():
await _send_progress("abgeschlossen ✓", cities_done, total_cities)
# ------------------------------------------------------------------
# Hilfsfunktion: Job-Protokoll aktualisieren
# ------------------------------------------------------------------
def _log_job(job_id: str, status: str, result: str):
_job_log[job_id] = {
"last_run": datetime.now(tz=_TZ),
"status": status,
"result": result,
}
# ------------------------------------------------------------------
# JOB: KI-Anreicherung der Rassen-Daten (nächtlich)
# ------------------------------------------------------------------
async def _job_wiki_enrich():
"""Reichert 20 noch nicht angereicherte Rassen mit KI-Daten an."""
try:
from scraper.breed_enricher import enrich_breeds
enriched = await enrich_breeds(limit=20)
msg = f"{enriched} Rassen angereichert"
logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.")
_log_job("wiki_enrich_nightly", "ok", msg)
except Exception as e:
logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}")
_log_job("wiki_enrich_nightly", "error", str(e))
async def _job_wiki_enrich_startup():
"""Beim Start: erste 50 Rassen sofort anreichern."""
try:
from scraper.breed_enricher import enrich_breeds
enriched = await enrich_breeds(limit=50)
msg = f"{enriched} Rassen angereichert (Startup)"
logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.")
_log_job("wiki_enrich_startup", "ok", msg)
except Exception as e:
logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}")
_log_job("wiki_enrich_startup", "error", str(e))
# ------------------------------------------------------------------
# Hilfsfunktion: Lob-Text für einen Hund generieren
# ------------------------------------------------------------------
async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
"""Generiert einen Lob-Text für einen Hund basierend auf der letzten Woche."""
from ki import complete, KIUnavailableError
import json as _json
from datetime import date, timedelta
since = (date.today() - timedelta(days=7)).isoformat()
name = dog["name"]
rasse = dog.get("rasse") or "Hund"
stats = {}
try:
with db() as conn:
stats["diary"] = conn.execute("SELECT COUNT(*) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
stats["training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
stats["top_training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=? AND ist_top=1", (dog["id"], since)).fetchone()[0]
stats["health"] = conn.execute("SELECT COUNT(*) FROM health WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
stats["days_active"] = conn.execute(
"SELECT COUNT(DISTINCT datum) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)
).fetchone()[0]
# Wie viele Wochen ist der User dabei?
first = conn.execute("SELECT MIN(datum) FROM diary WHERE dog_id=?", (dog["id"],)).fetchone()[0]
if first:
weeks_total = max(1, (date.today() - date.fromisoformat(first)).days // 7)
else:
weeks_total = 1
stats["weeks_total"] = weeks_total
except Exception:
pass
# Prompt aufbauen
aktivitaet_parts = []
if stats.get("diary", 0):
aktivitaet_parts.append(f"{stats['diary']} Tagebuch-Eintr\u00e4ge")
if stats.get("training", 0):
t = f"{stats['training']} Trainingseinheiten"
if stats.get("top_training", 0):
t += f" (davon {stats['top_training']} Top-Training)"
aktivitaet_parts.append(t)
if stats.get("health", 0):
aktivitaet_parts.append(f"{stats['health']} Gesundheitseintr\u00e4ge")
if not aktivitaet_parts:
aktivitaet_text = "Diese Woche war ruhig \u2014 keine erfassten Aktivit\u00e4ten."
else:
aktivitaet_text = ", ".join(aktivitaet_parts)
prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche.
Hund: {name} ({rasse})
Letzte 7 Tage: {aktivitaet_text}
Dabei seit: {stats.get('weeks_total', 1)} Wochen
Regeln (unbedingt einhalten):
- Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen
- Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer
- Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c)
- Maximal 3 kurze S\u00e4tze
- Warm, pers\u00f6nlich, keine Floskeln
- Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen"""
try:
text = await complete(
prompt,
system="Du schreibst kurze, warme Lob-Nachrichten f\u00fcr Hundebesitzer. Nur Lob, keine Ratschl\u00e4ge.",
max_tokens=150,
)
return text.strip()
except Exception:
# Fallback wenn KI nicht verfügbar
if aktivitaet_parts:
return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e"
else:
return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e"
# ------------------------------------------------------------------
# JOB: Wöchentlicher Fortschritts-Lober
# ------------------------------------------------------------------
async def _job_weekly_praise():
"""Jeden Montag: Lob-Text f\u00fcr alle aktiven Hunde generieren + Push senden."""
from datetime import date
import json as _json
today = date.today()
d = today.isocalendar()
week_key = f"{d[0]}-W{d[1]:02d}"
logger.info(f"Weekly Praise Job startet f\u00fcr Woche {week_key}")
# Alle Hunde laden, für die noch kein Lob diese Woche existiert
with db() as conn:
dogs = conn.execute("""
SELECT d.id, d.name, d.rasse, d.user_id, d.foto_url
FROM dogs d
WHERE NOT EXISTS (
SELECT 1 FROM weekly_praise wp
WHERE wp.dog_id=d.id AND wp.week_key=?
)
ORDER BY d.id
""", (week_key,)).fetchall()
dogs = [dict(d) for d in dogs]
logger.info(f"Weekly Praise: {len(dogs)} Hunde ohne Lob diese Woche.")
import asyncio
generated = 0
for dog in dogs:
try:
praise = await _generate_praise_for_dog(dog, dog["user_id"])
with db() as conn:
conn.execute("""
INSERT OR IGNORE INTO weekly_praise (user_id, dog_id, week_key, praise_text)
VALUES (?,?,?,?)
""", (dog["user_id"], dog["id"], week_key, praise))
# Push-Notification — erste 100 Zeichen als Preview
preview = praise[:100] + "\u2026" if len(praise) > 100 else praise
send_push_to_user(dog["user_id"], {
"type": "weekly_praise",
"title": f"\U0001f43e R\u00fcckblick f\u00fcr {dog['name']}",
"body": preview,
"data": {"page": "diary"},
"tag": f"weekly-praise-{dog['id']}-{week_key}",
})
generated += 1
await asyncio.sleep(2) # Rate limiting für KI
except Exception as e:
logger.error(f"Weekly Praise: Fehler f\u00fcr Hund {dog['id']}: {e}")
logger.info(f"Weekly Praise Job fertig \u2014 {generated}/{len(dogs)} Lob-Texte generiert.")
_log_job("weekly_praise", "ok", f"{generated} Lob-Texte f\u00fcr KW {d[1]}")
# ------------------------------------------------------------------
# JOB: Status-Report per Mail (4× täglich)
# ------------------------------------------------------------------
async def _job_status_report():
"""Sendet einen HTML-Status-Report an ADMIN_EMAIL."""
import os
from mailer import send_email
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
logger.info("Status-Report: ADMIN_EMAIL nicht gesetzt, übersprungen.")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
# --- DB-Metriken abrufen ---
metrics = {}
try:
with db() as conn:
# Rassen-Anreicherung
metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0]
metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0]
metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0]
metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0]
# Züchter
try:
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
metrics["zuchter_verified"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=1").fetchone()[0]
except Exception:
metrics["zuchter_pending"] = metrics["zuchter_verified"] = 0
# Community
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
metrics["lost_active"] = conn.execute("SELECT COUNT(*) FROM lost WHERE gefunden=0").fetchone()[0]
# Wiki-Interesse
try:
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
metrics["interesse_will"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='will'").fetchone()[0]
except Exception:
metrics["interesse_hat"] = metrics["interesse_will"] = 0
except Exception as e:
logger.error(f"Status-Report: DB-Fehler: {e}")
return
# --- Wiki-Fortschritt berechnen ---
total = metrics["rassen_total"] or 1
enriched = metrics["rassen_enriched"]
pct = round(enriched / total * 100)
remaining = total - enriched
nights_left = (remaining + 19) // 20 # bei 20/Nacht
bar_filled = round(pct / 5)
progress_bar = "" * bar_filled + "" * (20 - bar_filled)
# --- Job-Log-Tabelle ---
job_labels = {
"health_reminders": "Gesundheits-Erinnerungen",
"poison_archive": "Giftköder-Archiv",
"weather_alert": "Wetter-Alert",
"milestone_check": "Meilenstein-Check",
"import_events": "Event-Import (VDH)",
"wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)",
"wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)",
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
}
job_rows_html = ""
job_rows_txt = ""
for jid, label in job_labels.items():
log = _job_log.get(jid)
if log:
ts = log["last_run"].strftime("%d.%m. %H:%M")
status = "" if log["status"] == "ok" else ""
result = log["result"]
color = "#16a34a" if log["status"] == "ok" else "#dc2626"
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;font-family:monospace;font-size:12px">{ts}</td><td style="padding:5px 10px;color:{color}">{status} {result}</td></tr>'
job_rows_txt += f" {status} {label}: {ts}{result}\n"
else:
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;color:#aaa" colspan="2">— noch nicht gelaufen</td></tr>'
job_rows_txt += f"{label}: noch nicht gelaufen\n"
html = f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<!-- Header -->
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro Status-Report</div>
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
</div>
<!-- Wiki-Fortschritt -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Wiki KI-Anreicherung</div>
<div style="font-family:monospace;font-size:13px;background:#fdf6ef;border-radius:8px;padding:12px 14px;line-height:1.8">
<span style="color:#555">{progress_bar}</span> <strong>{pct}%</strong><br>
Angereichert: <strong>{enriched}</strong> / {total}<br>
Verbleibend: <strong>{remaining}</strong> Rassen (~{nights_left} Nächte)<br>
📷 Mit lokalem Foto: <strong>{metrics['rassen_mit_foto']}</strong><br>
📝 Mit Beschreibung: <strong>{metrics['rassen_mit_desc']}</strong>
</div>
</div>
<!-- Scheduler-Status -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
{job_rows_html}
</table>
</div>
<!-- Community-Metriken -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
("Nutzer",metrics["users"]),
("Hunde",metrics["dogs"]),
("Tagebuch-Einträge",metrics["diary_entries"]),
("Aktive Giftköder",metrics["poison_active"]),
("Vermisste Hunde",metrics["lost_active"]),
("'So einen hab ich'",metrics["interesse_hat"]),
("'Interessiert mich'",metrics["interesse_will"]),
("Züchter (pending)",metrics["zuchter_pending"]),
])}
</div>
</div>
<!-- Footer -->
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
Ban Yaro · banyaro.app · Nächster Report in ~6h
</div>
</div>
</body>
</html>"""
plain = f"""Ban Yaro Status-Report — {now_str}
=== Wiki KI-Anreicherung ===
{progress_bar} {pct}%
Angereichert: {enriched}/{total}
Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht)
Mit Foto: {metrics['rassen_mit_foto']}
Mit Beschreibung: {metrics['rassen_mit_desc']}
=== Scheduler-Jobs ===
{job_rows_txt}
=== Community ===
Nutzer: {metrics['users']}
Hunde: {metrics['dogs']}
Tagebuch-Einträge: {metrics['diary_entries']}
Aktive Giftköder: {metrics['poison_active']}
Vermisste Hunde: {metrics['lost_active']}
'So einen hab ich': {metrics['interesse_hat']}
'Interessiert mich': {metrics['interesse_will']}
Züchter (pending): {metrics['zuchter_pending']}
"""
try:
await send_email(admin, f"Ban Yaro Status {now_str}", html, plain)
logger.info(f"Status-Report gesendet an {admin}.")
except Exception as e:
logger.error(f"Status-Report: Mail-Fehler: {e}")
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,

View file

@ -0,0 +1,168 @@
"""
BAN YARO Rassen-Anreicherung via KI
Nutzt ki.complete() um fehlende Rassen-Daten (Beschreibung, Vorkommen, etc.)
per Claude API anzureichern und in wiki_rassen zurückzuschreiben.
"""
import asyncio
import json
import logging
import re
import sys
import os
# Pfad zum Backend-Verzeichnis sicherstellen (beim direkten Aufruf)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import db
from ki import complete, KIUnavailableError
logger = logging.getLogger(__name__)
_SYSTEM = "Du bist ein Hunde-Experte."
_PROMPT_TEMPLATE = '''\
Gib mir strukturierte Informationen über die Hunderasse "{name}" (Herkunft: {herkunft}) auf Deutsch.
Antworte NUR mit einem JSON-Objekt, keine Erklärung darum.
Format:
{{
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse",
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist",
"groesse": "klein|mittel|gross|sehr_gross",
"gewicht_min_kg": Zahl_oder_null,
"gewicht_max_kg": Zahl_oder_null,
"lebensdauer": "X-Y Jahre oder null",
"aktivitaet": "niedrig|mittel|hoch|sehr_hoch",
"erfahrung": "anfaenger|fortgeschritten|experte",
"kinder_geeignet": true_oder_false,
"wohnung_geeignet": true_oder_false,
"temperament": "kommagetrennte Eigenschaftsliste auf Deutsch, z.B. freundlich, verspielt, loyal"
}}
'''
# Felder die direkt in wiki_rassen geschrieben werden (wenn nicht null)
_DIRECT_FIELDS = {
"beschreibung", "vorkommen_de",
"groesse", "gewicht_min_kg", "gewicht_max_kg",
"lebensdauer", "aktivitaet", "erfahrung",
"kinder_geeignet", "wohnung_geeignet", "temperament",
}
def _parse_json(raw: str) -> dict:
"""JSON aus KI-Antwort extrahieren — toleriert ```json ... ``` Wrapper."""
# Versuche direkt
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# Suche nach ```json ... ``` Block
match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Suche nach erstem { ... } Block
match = re.search(r"\{[\s\S]+\}", raw)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}")
async def enrich_breeds(limit: int = 10) -> int:
"""
Reichert bis zu `limit` Rassen an, bei denen ki_enriched = 0.
Returns:
Anzahl erfolgreich angereicherter Rassen.
"""
with db() as conn:
rassen = conn.execute(
"""SELECT id, name, slug, herkunft FROM wiki_rassen
WHERE ki_enriched = 0
ORDER BY name ASC
LIMIT ?""",
(limit,),
).fetchall()
if not rassen:
logger.info("Keine Rassen zur Anreicherung gefunden (alle ki_enriched=1).")
return 0
enriched_count = 0
for rasse in rassen:
name = rasse["name"]
herkunft = rasse["herkunft"] or "unbekannt"
rasse_id = rasse["id"]
prompt = _PROMPT_TEMPLATE.format(name=name, herkunft=herkunft)
try:
raw = await complete(
prompt,
system=_SYSTEM,
max_tokens=600,
requires_premium=False,
)
except KIUnavailableError as e:
logger.warning("KI nicht verfügbar, Anreicherung abgebrochen: %s", e)
break
except Exception as e:
logger.error("Fehler bei KI-Anfrage für %s: %s", name, e)
await asyncio.sleep(2)
continue
try:
data = _parse_json(raw)
except ValueError as e:
logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(2)
continue
# Nur bekannte Felder mit nicht-None-Wert übernehmen
updates = {
k: v for k, v in data.items()
if k in _DIRECT_FIELDS and v is not None
}
updates["ki_enriched"] = 1
cols = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [rasse_id]
try:
with db() as conn:
conn.execute(
f"UPDATE wiki_rassen SET {cols} WHERE id=?",
values,
)
logger.info("Rasse angereichert: %s (%d Felder)", name, len(updates) - 1)
enriched_count += 1
except Exception as e:
logger.error("DB-Update fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(2)
return enriched_count
if __name__ == "__main__":
import argparse
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="Rassen-Anreicherung via KI")
parser.add_argument("--limit", type=int, default=10, help="Anzahl Rassen (default: 10)")
args = parser.parse_args()
count = asyncio.run(enrich_breeds(args.limit))
print(f"Angereichert: {count} Rassen")

View file

@ -4824,6 +4824,79 @@ html.modal-open {
color: var(--c-text-secondary);
}
/* Detail Hero (neues Layout) */
.wiki-detail-hero-photo-wrap {
width: 100%;
max-height: 240px;
overflow: hidden;
border-radius: var(--radius-lg);
margin-bottom: var(--space-3);
background: var(--c-surface-2);
}
.wiki-detail-hero-photo-wrap .wiki-detail-photo {
width: 100%;
height: 240px;
object-fit: cover;
object-position: center top;
margin-bottom: 0;
border-radius: 0;
}
/* Steckbrief-Grid */
.wiki-steckbrief-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--c-border-light);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: var(--space-4);
}
.wiki-steckbrief-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
background: var(--c-surface);
}
.wiki-steckbrief-label {
font-size: var(--text-xs);
color: var(--c-text-muted);
font-weight: var(--weight-medium);
}
.wiki-steckbrief-value {
font-size: var(--text-sm);
color: var(--c-text);
font-weight: var(--weight-semibold);
}
/* Interesse-Section */
.wiki-interesse-section {
background: var(--c-surface-2);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.wiki-interesse-btn {
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
color: var(--c-text);
font-size: var(--text-sm);
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.wiki-interesse-btn:hover {
border-color: var(--c-primary);
color: var(--c-primary);
}
/* Züchter-Karten */
.wiki-zuchter-card {
transition: background 0.15s;
}
.hdm-vote-rasse {
font-size: var(--text-xs);
color: var(--c-text-secondary);

View file

@ -393,6 +393,48 @@
margin-top: var(--space-2);
}
.sidebar-section-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
cursor: pointer;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: var(--space-3) var(--space-3) var(--space-1);
margin-top: var(--space-2);
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.sidebar-section-toggle:hover { color: var(--c-text); }
.sidebar-section-toggle .wissen-caret {
transition: transform 0.2s ease;
flex-shrink: 0;
}
.sidebar-section-toggle[aria-expanded="true"] .wissen-caret {
transform: rotate(90deg);
}
.sidebar-section-body {
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.sidebar-section-body.open {
max-height: 300px;
opacity: 1;
}
.sidebar-item {
display: flex;
align-items: center;

View file

@ -4,7 +4,64 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#C4843A">
<meta name="description" content="Ban Yaro — Die Hunde-Plattform. Alles rund um deinen Hund.">
<meta name="description" content="Ban Yaro — Die kostenlose Hunde-App für Deutschland. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. DSGVO-konform, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Tagebuch, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundesitting, Hunde Wiki, Hunderassen, PWA Hunde, DSGVO Hunde App">
<link rel="canonical" href="https://banyaro.app/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
<meta property="og:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. Kostenlos, DSGVO-konform, ohne App Store.">
<meta property="og:url" content="https://banyaro.app/">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
<meta name="twitter:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community. Kostenlos, DSGVO-konform.">
<meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "MobileApplication",
"name": "Ban Yaro",
"alternateName": "Ban Yaro — Die Hunde-Plattform",
"description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App. Digitales Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting und mehr — DSGVO-konform, ohne App Store.",
"url": "https://banyaro.app",
"applicationCategory": "LifestyleApplication",
"applicationSubCategory": "PetApplication",
"operatingSystem": "iOS, Android, Web",
"inLanguage": "de",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
"publisher": {
"@type": "Organization",
"name": "Ban Yaro",
"url": "https://banyaro.app"
},
"featureList": [
"Digitales Hunde-Tagebuch mit Fotos und GPS",
"Digitaler Impfpass und Gesundheitsakte",
"Giftköder-Alarm mit Push-Benachrichtigungen",
"Gassi-Community und GPS-Routen",
"Hundesitting-Vermittlung mit 8% Provision",
"NFC-Halsband-Tags",
"Hunde-Wiki mit Rassendatenbank",
"Verlorener Hund Alarm",
"Forum für Hundebesitzer",
"Offline-Modus via Service Worker"
],
"areaServed": ["DE", "AT", "CH"]
}
</script>
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
@ -61,6 +118,13 @@
<div class="sidebar-item" data-page="health">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Gesundheit
</div>
<div class="sidebar-item" data-page="uebungen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg> Übungen
</div>
<div class="sidebar-item" data-page="trainingsplaene">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne
</div>
<span class="sidebar-section-label">Entdecken</span>
<div class="sidebar-item" data-page="map">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Karte
@ -105,26 +169,23 @@
<span class="sidebar-item-badge" id="lost-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Training</span>
<div class="sidebar-item" data-page="uebungen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg> Übungen
</div>
<div class="sidebar-item" data-page="trainingsplaene">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne
</div>
<span class="sidebar-section-label">Wissen</span>
<div class="sidebar-item" data-page="wiki">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
</div>
<div class="sidebar-item" data-page="knigge">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg> Knigge
</div>
<div class="sidebar-item" data-page="movies">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme
</div>
<div class="sidebar-item" data-page="erste-hilfe">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
<button class="sidebar-section-toggle" id="wissen-toggle" aria-expanded="false">
<span>Wissen</span>
<svg class="ph-icon wissen-caret" aria-hidden="true"><use href="/icons/phosphor.svg#caret-right"></use></svg>
</button>
<div class="sidebar-section-body" id="wissen-body">
<div class="sidebar-item" data-page="wiki">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
</div>
<div class="sidebar-item" data-page="knigge">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg> Knigge
</div>
<div class="sidebar-item" data-page="movies">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme
</div>
<div class="sidebar-item" data-page="erste-hilfe">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
</div>
</div>
<div class="sidebar-item" data-page="admin" id="sidebar-admin"

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '262'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '267'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -103,6 +103,7 @@ const App = (() => {
state.page = pageId;
UI.scrollTop();
_expandWissenIfActive(pageId);
// Seiten-Modul lazy laden (einmalig)
_loadPage(pageId, params);
@ -309,6 +310,12 @@ const App = (() => {
return;
}
// Wissen-Toggle aufklappen/zuklappen
if (e.target.closest('#wissen-toggle')) {
_toggleWissen();
return;
}
// Sidebar-Item auf Mobile → schließen nach Navigation
if (e.target.closest('#sidebar .sidebar-item')) {
_closeSidebar();
@ -346,6 +353,22 @@ const App = (() => {
document.getElementById('sidebar-backdrop')?.classList.remove('visible');
}
const _WISSEN_PAGES = new Set(['wiki', 'knigge', 'movies', 'erste-hilfe']);
function _toggleWissen(force) {
const toggle = document.getElementById('wissen-toggle');
const body = document.getElementById('wissen-body');
if (!toggle || !body) return;
const open = force !== undefined ? force : toggle.getAttribute('aria-expanded') !== 'true';
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
body.classList.toggle('open', open);
try { localStorage.setItem('by_wissen_open', open ? '1' : '0'); } catch (_) {}
}
function _expandWissenIfActive(page) {
if (_WISSEN_PAGES.has(page)) _toggleWissen(true);
}
// ----------------------------------------------------------
// SCHNELL-HINZUFÜGEN (+ Button)
// ----------------------------------------------------------
@ -744,6 +767,12 @@ const App = (() => {
}
_bindNavigation();
// Wissen-Sektion: gespeicherten Zustand wiederherstellen
try {
if (localStorage.getItem('by_wissen_open') === '1') _toggleWissen(true);
} catch (_) {}
await _checkAuth();
// Einladungslink /teilen/{token} → direkt annehmen

View file

@ -220,6 +220,62 @@ window.Page_diary = (() => {
await _load();
_renderList();
_loadPraise();
}
// ----------------------------------------------------------
// FORTSCHRITTS-LOBER
// ----------------------------------------------------------
async function _loadPraise() {
const dog = _appState.activeDog;
if (!dog) return;
const existing = _container.querySelector('#diary-praise-card');
if (existing) existing.remove();
let data;
try {
const r = await fetch(`/api/praise/current?dog_id=${dog.id}`, {credentials: 'include'});
data = r.ok ? await r.json() : null;
} catch (_) { return; }
if (!data?.praise) return;
const card = document.createElement('div');
card.id = 'diary-praise-card';
card.style.cssText = `
margin: var(--space-3) var(--space-4) 0;
background: linear-gradient(135deg, var(--c-primary-subtle), #fdf6ef);
border: 1px solid var(--c-primary-light, #e8c99a);
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
display: flex; gap: var(--space-3); align-items: flex-start;
`;
card.innerHTML = `
<div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div>
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)">
Rückblick der Woche
</div>
<p style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.6;margin:0">${data.praise}</p>
</div>
<button id="diary-praise-close"
style="background:none;border:none;cursor:pointer;padding:2px;
color:var(--c-text-muted);flex-shrink:0;line-height:1;font-size:1.1rem"
aria-label="Schließen">×</button>
`;
const list = _container.querySelector('#diary-list');
if (list) _container.insertBefore(card, list);
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
card.style.opacity = '0';
card.style.transition = 'opacity .2s';
setTimeout(() => card.remove(), 200);
});
}
// ----------------------------------------------------------

View file

@ -11,6 +11,18 @@ window.Page_trainingsplaene = (() => {
let _activePlan = 'welpe'; // welpe | junior | erwachsen
let _activeAdultTab = 'grundkurs'; // grundkurs | aufbaukurs
// ----------------------------------------------------------
// API HELPERS
// ----------------------------------------------------------
function _dogId() {
return window.App?.state?.activeDogId || null;
}
async function _apiGet(url) {
const r = await fetch(url, {credentials: 'include'});
return r.ok ? r.json() : null;
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
@ -590,9 +602,151 @@ window.Page_trainingsplaene = (() => {
</h2>
${_renderPlanSelector()}
${planContent}
<!-- Trainingskalender -->
<div id="tp-calendar-section" style="margin-top:var(--space-6)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
${_icon('calendar')} Trainingskalender
</h3>
<div id="tp-calendar-wrap" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);text-align:center;padding:var(--space-4)">
Lade Kalender
</div>
</div>
</div>
</div>`;
_bindEvents();
_loadCalendar();
}
// ----------------------------------------------------------
// TRAININGSKALENDER
// ----------------------------------------------------------
async function _loadCalendar() {
const dogId = _dogId();
const wrap = _container.querySelector('#tp-calendar-wrap');
if (!wrap) return;
if (!dogId) {
wrap.innerHTML = `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);text-align:center;padding:var(--space-3)">Kein Hund ausgewählt.</p>`;
return;
}
const now = new Date();
const thisYear = now.getFullYear();
const thisMon = now.getMonth() + 1; // 1-based
// Letzter Monat
const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevYear = prevDate.getFullYear();
const prevMon = prevDate.getMonth() + 1;
const [dataCurr, dataPrev] = await Promise.all([
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${thisYear}&month=${thisMon}`),
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${prevYear}&month=${prevMon}`),
]);
wrap.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-4)">
${_renderCalendarMonth(prevYear, prevMon, dataPrev?.days || {})}
${_renderCalendarMonth(thisYear, thisMon, dataCurr?.days || {})}
</div>
<!-- Legende -->
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-top:var(--space-2);
font-size:var(--text-xs);color:var(--c-text-secondary);align-items:center">
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;border:1.5px solid var(--c-border);display:inline-block"></span>
kein Training
</span>
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary-subtle);border:1.5px solid var(--c-primary);display:inline-block"></span>
Training
</span>
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary);display:inline-block"></span>
Top-Training
</span>
</div>
`;
}
function _renderCalendarMonth(year, month, days) {
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
const WDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
const firstDay = new Date(year, month - 1, 1);
// Monday = 0 offset: getDay() returns 0=Sun, so we shift
let startOffset = firstDay.getDay(); // 0=Sun
startOffset = startOffset === 0 ? 6 : startOffset - 1; // Mon=0 … Sun=6
const daysInMonth = new Date(year, month, 0).getDate();
// Header Wochentage
const wdayHeader = WDAYS.map(d =>
`<div style="text-align:center;font-size:10px;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);padding-bottom:4px">${d}</div>`
).join('');
// Leere Slots am Anfang
let cells = '';
for (let i = 0; i < startOffset; i++) {
cells += `<div></div>`;
}
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const dayData = days[dateStr];
const isToday = dateStr === todayStr;
let bg = 'transparent';
let border = '1.5px solid var(--c-border)';
let color = 'var(--c-text-secondary)';
let title = '';
if (dayData) {
if (dayData.top) {
bg = 'var(--c-primary)';
border = '1.5px solid var(--c-primary)';
color = '#fff';
title = `Top-Training! ${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
} else {
bg = 'var(--c-primary-subtle)';
border = '1.5px solid var(--c-primary)';
color = 'var(--c-primary)';
title = `${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
}
}
const todayRing = isToday ? 'box-shadow:0 0 0 2px var(--c-primary);' : '';
cells += `
<div title="${title}"
style="aspect-ratio:1;border-radius:4px;background:${bg};border:${border};
display:flex;align-items:center;justify-content:center;
font-size:10px;font-weight:${dayData ? 'var(--weight-semibold)' : '400'};
color:${color};${todayRing}cursor:default">
${d}
</div>
`;
}
return `
<div style="flex:1;min-width:220px">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
${_esc(MONTHS[month - 1])} ${year}
</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:3px">
${wdayHeader}
${cells}
</div>
</div>
`;
}
// ----------------------------------------------------------

View file

@ -9,6 +9,34 @@ window.Page_uebungen = (() => {
let _appState = null;
let _activeTab = 'grundkommandos';
// ----------------------------------------------------------
// API HELPERS
// ----------------------------------------------------------
function _dogId() {
return window.App?.state?.activeDogId || null;
}
async function _apiPost(url, body) {
const r = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
return r.ok ? r.json() : null;
}
async function _apiGet(url) {
const r = await fetch(url, {credentials: 'include'});
return r.ok ? r.json() : null;
}
// ----------------------------------------------------------
// STATS STATE
// ----------------------------------------------------------
let _statsData = null; // cached stats from /api/training/stats
let _badgesData = null; // cached badges from /api/achievements
// ----------------------------------------------------------
// DATEN
// ----------------------------------------------------------
@ -387,10 +415,29 @@ window.Page_uebungen = (() => {
API.training.getSuggestions().then(suggestions => {
if (suggestions.length) _showSuggestions(suggestions);
}).catch(() => {});
// Stats + Badges laden
_loadStatsAndBadges();
}
async function _loadStatsAndBadges() {
const dogId = _dogId();
if (!dogId) return;
const [stats, achievements] = await Promise.all([
_apiGet(`/api/training/stats?dog_id=${dogId}`),
_apiGet('/api/achievements'),
]);
_statsData = stats;
_badgesData = achievements;
_renderStatsBanner();
}
function refresh() {}
function onDogChange() {}
function onDogChange() {
_statsData = null;
_badgesData = null;
_loadStatsAndBadges();
}
// ----------------------------------------------------------
// HAUPT-RENDER
@ -399,12 +446,64 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
${_renderTabs()}
<div id="ueb-stats-banner" style="padding:0 var(--space-4);margin-bottom:var(--space-2)"></div>
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
<div id="ueb-content"></div>
</div>
`;
_bindTabs();
_renderContent();
_renderStatsBanner();
}
// ----------------------------------------------------------
// STATS BANNER
// ----------------------------------------------------------
function _renderStatsBanner() {
const el = _container && _container.querySelector('#ueb-stats-banner');
if (!el) return;
if (!_statsData || !_statsData.total_sessions) { el.innerHTML = ''; return; }
const s = _statsData;
const streakHtml = (s.streak_days >= 2)
? ` &nbsp;·&nbsp; ${s.streak_days}-Tage-Streak 🔥`
: '';
const avgHtml = s.avg_erfolgsquote != null
? ` &nbsp;·&nbsp; Ø ${Math.round(s.avg_erfolgsquote)}% Erfolg`
: '';
// Training-Badges filtern
let badgesHtml = '';
if (_badgesData && Array.isArray(_badgesData.user_badges)) {
const trainingBadges = _badgesData.user_badges.filter(b => b.badge_id && b.badge_id.startsWith('training_'));
if (trainingBadges.length) {
const visible = trainingBadges.slice(0, 3);
const rest = trainingBadges.length - visible.length;
const pills = visible.map(b => `
<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 8px;
border-radius:var(--radius-full,999px);
background:var(--c-primary-subtle);color:var(--c-primary);
font-size:var(--text-xs);font-weight:var(--weight-semibold)">
${b.icon || '🏅'} ${_esc(b.name || b.badge_id)}
</span>
`).join('');
const more = rest > 0
? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">+${rest} weitere</span>`
: '';
badgesHtml = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-2)">${pills}${more}</div>`;
}
}
el.innerHTML = `
<div style="background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${s.total_sessions} Einheit${s.total_sessions !== 1 ? 'en' : ''}
</span>${avgHtml}${streakHtml}
${badgesHtml}
</div>
`;
}
function _renderTabs() {
@ -492,7 +591,8 @@ window.Page_uebungen = (() => {
}
_bindAccordions();
_bindStatusButtons();
if (_activeTab === 'ki-trainer') _bindKiTrainer();
_bindLogButtons();
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
}
// ----------------------------------------------------------
@ -523,6 +623,21 @@ window.Page_uebungen = (() => {
${_esc(u.name)}
</span>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<!-- Log-Button -->
<button class="ueb-log-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="Trainingseinheit loggen"
style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
color:var(--c-primary);cursor:pointer;padding:2px 7px;
display:flex;align-items:center;gap:3px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#plus"></use>
</svg>
Einheit
</button>
<!-- Status-Button -->
<button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}"
@ -652,143 +767,432 @@ window.Page_uebungen = (() => {
}
// ----------------------------------------------------------
// KI-TRAINER
// LOG SESSION MODAL
// ----------------------------------------------------------
function _bindLogButtons() {
_container.querySelectorAll('.ueb-log-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_openLogModal(btn.dataset.tab, btn.dataset.name);
});
});
}
function _openLogModal(tab, exerciseName) {
// Build the modal HTML
const modalId = 'ueb-log-modal';
const formId = 'ueb-log-form';
// Remove existing if present
document.getElementById(modalId)?.remove();
const overlay = document.createElement('div');
overlay.id = modalId;
overlay.style.cssText = `
position:fixed;inset:0;z-index:9999;
display:flex;align-items:flex-end;justify-content:center;
background:rgba(0,0,0,0.45);
`;
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:92vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<!-- Handle -->
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
margin:0 0 var(--space-4);text-align:center">
Einheit loggen: ${_esc(exerciseName)}
</h3>
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Wiederholungen -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Wiederholungen</label>
<div style="display:flex;align-items:center;gap:var(--space-3);justify-content:center">
<button type="button" id="ueb-rep-minus"
style="width:36px;height:36px;border-radius:50%;border:1.5px solid var(--c-border);
background:var(--c-surface-2);font-size:1.2rem;cursor:pointer;
display:flex;align-items:center;justify-content:center;color:var(--c-text)"></button>
<span id="ueb-rep-val" style="font-size:var(--text-xl);font-weight:700;color:var(--c-text);min-width:32px;text-align:center">5</span>
<button type="button" id="ueb-rep-plus"
style="width:36px;height:36px;border-radius:50%;border:1.5px solid var(--c-border);
background:var(--c-surface-2);font-size:1.2rem;cursor:pointer;
display:flex;align-items:center;justify-content:center;color:var(--c-text)">+</button>
</div>
</div>
<!-- Wie lief's -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Wie lief's?</label>
<div style="display:flex;gap:var(--space-2);justify-content:center;flex-wrap:wrap">
${[['😓','0'],['😐','25'],['🙂','50'],['😊','75'],['🎉','100']].map(([emoji, val]) => `
<button type="button" class="ueb-erfolg-btn"
data-val="${val}"
style="font-size:1.5rem;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;transition:all 0.15s"
title="${val}%">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Stimmung des Hundes -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div style="display:flex;gap:var(--space-2);justify-content:center;flex-wrap:wrap">
${[['🎯','aufmerksam'],['😴','müde'],['🌪️','abgelenkt'],['⚡','super']].map(([emoji, val]) => `
<button type="button" class="ueb-stimmung-btn"
data-val="${val}"
style="display:flex;flex-direction:column;align-items:center;gap:2px;
font-size:1.2rem;padding:var(--space-2);min-width:60px;
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;transition:all 0.15s">
${emoji}
<span style="font-size:9px;color:var(--c-text-secondary)">${_esc(val)}</span>
</button>
`).join('')}
</div>
</div>
<!-- Zufriedenheit -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Wie zufrieden bist du?</label>
<div style="display:flex;gap:var(--space-2);justify-content:center">
${[1,2,3,4,5].map(n => `
<button type="button" class="ueb-stern-btn"
data-val="${n}"
style="font-size:1.5rem;background:none;border:none;cursor:pointer;
padding:2px;opacity:0.35;transition:opacity 0.15s"></button>
`).join('')}
</div>
</div>
<!-- Notiz -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="ueb-log-notiz" rows="2" placeholder="Optional: Was ist aufgefallen?"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;resize:none;
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
</div>
<!-- Meilenstein-Checkbox (initially hidden) -->
<label id="ueb-log-milestone-wrap" hidden
style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
padding:var(--space-3);background:var(--c-primary-subtle);
border:1px solid var(--c-primary-light);border-radius:var(--radius-md)">
<input type="checkbox" id="ueb-log-milestone"
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
📖 Als Meilenstein ins Tagebuch eintragen
</span>
</label>
</form>
<!-- Footer Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
<button id="ueb-log-cancel"
style="flex:1;padding:var(--space-3);border:1.5px solid var(--c-border);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);cursor:pointer;color:var(--c-text)">
Abbrechen
</button>
<button id="ueb-log-save" form="${formId}"
class="btn btn-primary" style="flex:2"
type="button">
Einheit speichern
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// State
let wiederholungen = 5;
let erfolgsquote = null; // must be selected
let stimmung = null;
let zufriedenheit = null;
// Close helpers
function _closeModal() { overlay.remove(); }
overlay.addEventListener('click', e => { if (e.target === overlay) _closeModal(); });
overlay.querySelector('#ueb-log-cancel').addEventListener('click', _closeModal);
// Stepper
const repVal = overlay.querySelector('#ueb-rep-val');
overlay.querySelector('#ueb-rep-minus').addEventListener('click', () => {
if (wiederholungen > 1) { wiederholungen--; repVal.textContent = wiederholungen; }
});
overlay.querySelector('#ueb-rep-plus').addEventListener('click', () => {
wiederholungen++;
repVal.textContent = wiederholungen;
});
// Erfolg-Buttons
overlay.querySelectorAll('.ueb-erfolg-btn').forEach(btn => {
btn.addEventListener('click', () => {
erfolgsquote = parseInt(btn.dataset.val, 10);
overlay.querySelectorAll('.ueb-erfolg-btn').forEach(b => {
b.style.background = 'var(--c-surface-2)';
b.style.borderColor = 'var(--c-border)';
b.style.transform = '';
});
btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)';
btn.style.transform = 'scale(1.15)';
_checkMilestoneVisibility();
});
});
// Stimmung-Buttons
overlay.querySelectorAll('.ueb-stimmung-btn').forEach(btn => {
btn.addEventListener('click', () => {
stimmung = btn.dataset.val;
overlay.querySelectorAll('.ueb-stimmung-btn').forEach(b => {
b.style.background = 'var(--c-surface-2)';
b.style.borderColor = 'var(--c-border)';
});
btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)';
});
});
// Stern-Buttons
overlay.querySelectorAll('.ueb-stern-btn').forEach(btn => {
btn.addEventListener('click', () => {
zufriedenheit = parseInt(btn.dataset.val, 10);
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
});
_checkMilestoneVisibility();
});
});
function _checkMilestoneVisibility() {
const wrap = overlay.querySelector('#ueb-log-milestone-wrap');
if (!wrap) return;
const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4;
wrap.hidden = !show;
}
// Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId();
if (!dogId) { UI.toast('Kein Hund ausgewählt.', 'warning'); return; }
if (erfolgsquote === null) { UI.toast('Bitte wähle aus, wie es gelaufen ist.', 'warning'); return; }
const saveBtn = overlay.querySelector('#ueb-log-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichern…';
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
const today = new Date().toISOString().slice(0, 10);
const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden &&
overlay.querySelector('#ueb-log-milestone').checked;
const body = {
dog_id: dogId,
exercise_id: exerciseId,
exercise_name: exerciseName,
datum: today,
wiederholungen: wiederholungen,
erfolgsquote: erfolgsquote,
hund_stimmung: stimmung || null,
zufriedenheit: zufriedenheit || null,
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
tagebuch_eintrag: tagebuch,
};
try {
const resp = await _apiPost('/api/training/sessions', body);
_closeModal();
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
if (resp.ist_top) {
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
} else {
UI.toast.success('Einheit gespeichert!');
}
if (resp.badges && resp.badges.length) {
resp.badges.forEach((badge, idx) => {
setTimeout(() => {
UI.toast.success(`🏅 Neues Abzeichen: "${badge.name || badge}"!`);
}, 1000 * (idx + 1));
});
}
if (resp.diary_entry_id) {
setTimeout(() => {
UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
}
// Stats-Banner aktualisieren
_statsData = null;
_loadStatsAndBadges();
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = 'Einheit speichern';
UI.toast.error('Speichern fehlgeschlagen.');
}
});
}
// ----------------------------------------------------------
// KI-TRAINER (neu: hundebasiertes Feedback)
// ----------------------------------------------------------
function _renderKiTrainer() {
return `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
const dogId = _dogId();
if (!dogId) {
return `
<div style="padding:var(--space-6) var(--space-4);text-align:center;color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">Wähle einen Hund aus um den KI-Trainer zu nutzen.</p>
</div>
`;
}
<!-- Intro -->
<div class="card" style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<svg class="ph-icon" style="width:24px;height:24px;flex-shrink:0;color:var(--c-primary);margin-top:2px" aria-hidden="true">
return `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)" id="ki-trainer-panel">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="width:48px;height:48px;border-radius:50%;background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg class="ph-icon" style="width:26px;height:26px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<div>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">
KI-Hundetrainer
</h3>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.5">
Beschreibe ein konkretes Problem oder Verhalten deines Hundes
du bekommst individuelle Trainingstipps.
</p>
</div>
<div>
<div style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">
KI-Trainer
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Personalisiertes Feedback basierend auf deinen Trainingseinheiten
</div>
</div>
</div>
<!-- Eingabe -->
<div class="card" style="padding:var(--space-4)">
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
Rasse &amp; Alter (optional)
</label>
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<input id="ki-rasse" type="text" placeholder="z.B. Labrador"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
background:var(--c-surface);color:var(--c-text);font-family:inherit">
<input id="ki-alter" type="text" placeholder="z.B. 2 Jahre"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
background:var(--c-surface);color:var(--c-text);font-family:inherit">
</div>
<!-- Lade-Spinner -->
<div id="ki-loading" style="text-align:center;padding:var(--space-6);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:32px;height:32px;animation:spin 1s linear infinite;margin-bottom:var(--space-2)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">Lade KI-Feedback</p>
</div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
Problem beschreiben *
</label>
<textarea id="ki-problem" rows="4"
placeholder="z.B. Mein Hund bellt bei jedem Klingeln an der Tür und lässt sich kaum beruhigen. Er springt Besucher an und ist sehr aufgedreht..."
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;resize:vertical;
background:var(--c-surface);color:var(--c-text);line-height:1.5;
min-height:100px"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:var(--space-3)">
<span id="ki-char-count" style="font-size:var(--text-xs);color:var(--c-text-muted)">0 / 1000</span>
<button id="ki-submit" class="btn btn-primary">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
Tipps holen
<!-- Kein Sessions-Hinweis -->
<div id="ki-no-sessions" hidden
style="text-align:center;padding:var(--space-6) var(--space-4);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
<use href="/icons/phosphor.svg#clipboard-text"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">
Logge deine erste Trainingseinheit um KI-Feedback zu erhalten.
</p>
</div>
<!-- Feedback-Card -->
<div id="ki-feedback-card" hidden>
<div class="card" style="border-left:3px solid var(--c-primary);background:var(--c-surface)">
<div id="ki-feedback-text"
style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;white-space:pre-wrap"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:var(--space-3)">
<span id="ki-feedback-meta" style="font-size:var(--text-xs);color:var(--c-text-muted)"></span>
<button id="ki-regenerate"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-3);
border-radius:var(--radius-md);border:1px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;color:var(--c-text-secondary)">
Neu generieren
</button>
</div>
</div>
<!-- Antwort -->
<div id="ki-result" hidden></div>
<!-- Hinweis -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
KI-Tipps ersetzen keinen professionellen Hundetrainer. Bei Aggression oder starker Angst
wende dich an einen zertifizierten Trainer vor Ort.
KI-Tipps ersetzen keinen professionellen Hundetrainer.
</p>
</div>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
</style>
`;
}
function _bindKiTrainer() {
const textarea = _container.querySelector('#ki-problem');
const charCount = _container.querySelector('#ki-char-count');
const submitBtn = _container.querySelector('#ki-submit');
const result = _container.querySelector('#ki-result');
if (!textarea || !submitBtn) return;
async function _loadKiTrainerFeedback(forceRefresh) {
const loading = _container.querySelector('#ki-loading');
const noSessions = _container.querySelector('#ki-no-sessions');
const feedbackCard = _container.querySelector('#ki-feedback-card');
const feedbackText = _container.querySelector('#ki-feedback-text');
const feedbackMeta = _container.querySelector('#ki-feedback-meta');
const regenBtn = _container.querySelector('#ki-regenerate');
if (!loading) return; // not on ki-trainer tab
textarea.addEventListener('input', () => {
charCount.textContent = `${textarea.value.length} / 1000`;
});
const dogId = _dogId();
if (!dogId) return;
submitBtn.addEventListener('click', async () => {
const problem = textarea.value.trim();
if (problem.length < 10) {
UI.toast('Bitte beschreibe das Problem etwas genauer.', 'warning');
// Show loading
loading.hidden = false;
noSessions.hidden = true;
feedbackCard.hidden = true;
// Check if there are any sessions
const stats = _statsData || await _apiGet(`/api/training/stats?dog_id=${dogId}`);
if (!stats || !stats.total_sessions) {
loading.hidden = true;
noSessions.hidden = false;
return;
}
try {
const resp = await _apiPost('/api/training/ki-feedback', { dog_id: dogId });
loading.hidden = true;
if (!resp || !resp.feedback) {
noSessions.hidden = false;
return;
}
const rasse = _container.querySelector('#ki-rasse')?.value.trim() || null;
const alter = _container.querySelector('#ki-alter')?.value.trim() || null;
submitBtn.disabled = true;
submitBtn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> Denke nach…`;
result.hidden = true;
result.innerHTML = '';
try {
const resp = await API.post('/ki/training', { problem, rasse, alter });
const text = resp.antwort || '';
// Render with simple markdown-like formatting (text already escaped by API)
const safeText = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = safeText
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/^(\d+)\. (.+)$/gm, '<li><strong>$1.</strong> $2</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
result.innerHTML = `
<div class="card" style="border-left:3px solid var(--c-primary)">
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<svg class="ph-icon" style="color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
Empfehlung des KI-Trainers
</span>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7">
<p>${html}</p>
</div>
</div>
`;
result.hidden = false;
} catch (err) {
UI.toast(err.message || 'KI momentan nicht verfügbar.', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg> Tipps holen`;
feedbackText.textContent = resp.feedback;
const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert';
const sessionInfo = stats.total_sessions
? ` · Basiert auf ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}`
: '';
feedbackMeta.textContent = `Aktualisiert alle 6 Stunden · ${cachedInfo}${sessionInfo}`;
if (regenBtn) {
regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren';
regenBtn.style.opacity = resp.cached ? '0.6' : '1';
}
});
feedbackCard.hidden = false;
} catch (err) {
loading.hidden = true;
noSessions.hidden = false;
}
// Bind regenerate button
if (regenBtn) {
regenBtn.addEventListener('click', async () => {
regenBtn.disabled = true;
regenBtn.textContent = 'Generiere…';
loading.hidden = false;
feedbackCard.hidden = true;
await _loadKiTrainerFeedback(true);
regenBtn.disabled = false;
}, { once: true });
}
}
function _bindAccordions() {

View file

@ -70,27 +70,41 @@ window.Page_welcome = (() => {
<!-- Features: Mein Hund -->
${_featureCard('Mein Hund', [
['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten', 'diary'],
['syringe', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente', 'health'],
['target', 'Training', 'Übungen, Pläne und KI-Trainer', 'uebungen'],
['books', 'Wiki & Wissen', 'Rassen, Ernährung, Erste Hilfe', 'wiki'],
])}
<!-- Features: Community -->
${_featureCard('Community', [
['users', 'Freunde', 'Verbinde dich mit anderen Hundebesitzern', 'friends'],
['chat-circle-dots', 'Nachrichten', 'Private Chats mit deinen Freunden', 'chat'],
['push-pin', 'Forum', 'Diskussionen, Tipps und Austausch', 'forum'],
['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern', 'walks'],
['house-line', 'Sitting', 'Dogsitter finden oder selbst anbieten', 'sitting'],
['magnifying-glass', 'Verlorene Hunde','Hilf gesuchte Hunde zu finden', 'lost'],
['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten', 'diary'],
['first-aid', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente', 'health'],
['target', 'Übungen', 'Trainingsübungen mit KI-Unterstützung', 'uebungen'],
['clipboard-text', 'Trainingspläne', 'Strukturierte Pläne für jedes Lernziel', 'trainingsplaene'],
])}
<!-- Features: Entdecken -->
${_featureCard('Entdecken', [
['map-trifold', 'Karte & Routen', 'Hundefreundliche Orte und Spazierwege', 'map'],
['calendar-dots', 'Events', 'Veranstaltungen in deiner Nähe', 'events'],
['warning-octagon','Giftköder-Alarm', 'Community-Warnungen in deiner Nähe', 'poison'],
['map-trifold', 'Karte', 'Orte, Routen und Meldungen in der Nähe', 'map'],
['path', 'Routen', 'GPS-Routen aufzeichnen und bewerten', 'routes'],
['calendar-dots', 'Events', 'Turniere und Veranstaltungen', 'events'],
])}
<!-- Features: Soziales -->
${_featureCard('Soziales', [
['users', 'Freunde', 'Verbinde dich mit anderen Hundebesitzern', 'friends'],
['chat-circle-dots', 'Nachrichten', 'Private Chats mit deinen Freunden', 'chat'],
['bell', 'Aktuelles', 'Benachrichtigungen und Neuigkeiten', 'notifications'],
])}
<!-- Features: Community -->
${_featureCard('Community', [
['warning-octagon', 'Giftköder-Alarm', 'Warnungen sofort melden und empfangen', 'poison'],
['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern', 'walks'],
['house-line', 'Sitting', 'Sitter finden oder selbst anbieten', 'sitting'],
['push-pin', 'Forum', 'Diskussionen, Tipps und Austausch', 'forum'],
['magnifying-glass', 'Verlorene Hunde', 'Hilf vermisste Hunde zu finden', 'lost'],
])}
<!-- Features: Wissen -->
${_featureCard('Wissen', [
['books', 'Wiki', 'Rassendatenbank, Gesundheits-Wiki, Quiz', 'wiki'],
['handshake', 'Knigge', 'Regeln, Begegnungen, Leinenpflicht', 'knigge'],
['film-slate', 'Filme', 'Stirbt der Hund? Die wichtigste Frage', 'movies'],
['first-aid', 'Erste Hilfe','Notfallratgeber für häufige Situationen', 'erste-hilfe'],
])}
<!-- App installieren -->

View file

@ -393,6 +393,313 @@ window.Page_wiki = (() => {
`;
}
// ----------------------------------------------------------
// API-Funktionen: Interesse / Stats / Züchter
// ----------------------------------------------------------
async function _fetchStats(slug) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/stats`, { credentials: 'include' });
return r.ok ? r.json() : null;
}
async function _setInteresse(slug, typ) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/interesse`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ typ }),
});
return r.ok ? r.json() : null;
}
async function _deleteInteresse(slug) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/interesse`, {
method: 'DELETE', credentials: 'include',
});
return r.ok ? r.json() : null;
}
async function _fetchZuchter(slug) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/zuchter`);
return r.ok ? r.json() : [];
}
async function _submitZuchter(data) {
const r = await fetch('/api/wiki/zuchter', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return r.ok;
}
// ----------------------------------------------------------
// Render-Helfer: Steckbrief-Grid
// ----------------------------------------------------------
function _renderSteckbriefGrid(rasse) {
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
? `${rasse.gewicht_min_kg}&ndash;${rasse.gewicht_max_kg} kg`
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '&mdash;');
const kinderLabel = rasse.kinder_geeignet === true
? `<span style="color:var(--c-success)">&#10003; Ja</span>`
: rasse.kinder_geeignet === false
? `<span style="color:var(--c-warning)">&#9889; Bedingt</span>`
: '&mdash;';
const wohnungLabel = rasse.wohnung_geeignet
? `<span style="color:var(--c-success)">&#10003; Ja</span>`
: `<span style="color:var(--c-text-secondary)">&#10007; Besser Garten</span>`;
const rows = [
['Größe', _groesseLabel(rasse.groesse) || '&mdash;'],
['Gewicht', gewicht],
['Lebensdauer', _esc(rasse.lebensdauer) || '&mdash;'],
['Aktivität', _aktivLabel(rasse.aktivitaet) || '&mdash;'],
['Eignung', _erfahrungLabel(rasse.erfahrung) || '&mdash;'],
['Kinder', kinderLabel],
['Wohnung', wohnungLabel],
['FCI-Gruppe', _esc(rasse.gruppe) || '&mdash;'],
];
return `
<div class="wiki-steckbrief-grid">
${rows.map(([label, val]) => `
<div class="wiki-steckbrief-item">
<span class="wiki-steckbrief-label">${label}</span>
<span class="wiki-steckbrief-value">${val}</span>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// Render-Helfer: Interesse-Section (Social)
// ----------------------------------------------------------
function _renderInteresseSection(stats, slug) {
const hatCount = stats?.dogs_count ?? '';
const willCount = stats?.will_count ?? '';
const interest = stats?.user_interest ?? null;
const isLoggedIn = !!_appState.user;
const hatActive = interest === 'hat';
const willActive = interest === 'will';
const hatStyle = hatActive ? `background:var(--c-primary);color:#fff;border-color:var(--c-primary)` : '';
const willStyle = willActive ? `background:var(--c-primary);color:#fff;border-color:var(--c-primary)` : '';
return `
<div class="wiki-detail-section wiki-interesse-section" id="wiki-interesse-section">
<div class="wiki-detail-label">In der Community</div>
<div class="wiki-interesse-counts" style="display:flex;gap:var(--space-4);margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)">
<span id="wiki-hat-count">&#128021; <strong>${hatCount}</strong> haben diesen Hund</span>
<span id="wiki-will-count">&#10084;&#65039; <strong>${willCount}</strong> m&ouml;chten ihn</span>
</div>
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm wiki-interesse-btn" id="wiki-btn-hat"
style="flex:1;${hatStyle}"
data-slug="${_esc(slug)}" data-typ="hat">
${isLoggedIn ? '' : '&#128274; '}Ich hab einen
</button>
<button class="btn btn-sm wiki-interesse-btn" id="wiki-btn-will"
style="flex:1;${willStyle}"
data-slug="${_esc(slug)}" data-typ="will">
${isLoggedIn ? '' : '&#128274; '}Ich will einen
</button>
</div>
</div>
`;
}
function _bindInteresseButtons(slug) {
document.querySelectorAll('.wiki-interesse-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!_appState.user) {
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
return;
}
const typ = btn.dataset.typ;
const hatBtn = document.getElementById('wiki-btn-hat');
const willBtn = document.getElementById('wiki-btn-will');
// Determine current state
const isActive = btn.style.background.includes('var(--c-primary)') || btn.style.backgroundColor;
const currentActive = (hatBtn?.style.background || '').includes('var(--c-primary)') ? 'hat'
: (willBtn?.style.background || '').includes('var(--c-primary)') ? 'will' : null;
// Optimistic disable
btn.disabled = true;
try {
if (currentActive === typ) {
await _deleteInteresse(slug);
} else {
await _setInteresse(slug, typ);
}
// Reload stats and re-render counts + button states
const stats = await _fetchStats(slug);
if (stats) {
const hatCount = stats.dogs_count ?? '';
const willCount = stats.will_count ?? '';
const interest = stats.user_interest ?? null;
const hatEl = document.getElementById('wiki-hat-count');
const willEl = document.getElementById('wiki-will-count');
if (hatEl) hatEl.innerHTML = `&#128021; <strong>${hatCount}</strong> haben diesen Hund`;
if (willEl) willEl.innerHTML = `&#10084;&#65039; <strong>${willCount}</strong> m&ouml;chten ihn`;
const activeStyle = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`;
if (hatBtn) { hatBtn.removeAttribute('style'); if (interest === 'hat') hatBtn.style.cssText = activeStyle; }
if (willBtn) { willBtn.removeAttribute('style'); if (interest === 'will') willBtn.style.cssText = activeStyle; }
}
} catch {
UI.toast.error('Aktion fehlgeschlagen.');
}
btn.disabled = false;
});
});
}
// ----------------------------------------------------------
// Render-Helfer: Züchter-Sektion
// ----------------------------------------------------------
function _renderZuchterSection(zuchter, slug) {
const DE_BUNDESLAENDER = [
'Baden-Württemberg','Bayern','Berlin','Brandenburg','Bremen','Hamburg',
'Hessen','Mecklenburg-Vorpommern','Niedersachsen','Nordrhein-Westfalen',
'Rheinland-Pfalz','Saarland','Sachsen','Sachsen-Anhalt',
'Schleswig-Holstein','Thüringen',
];
const listHtml = zuchter.length === 0
? `<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">Noch keine Züchter eingetragen.</p>`
: zuchter.map(z => `
<div class="wiki-zuchter-card" style="padding:var(--space-3);border-radius:var(--radius-md);background:var(--c-surface-2);margin-bottom:var(--space-2)">
<div style="font-weight:var(--weight-semibold)">${_esc(z.name)}
${z.zwingername ? `<em style="font-weight:normal;color:var(--c-text-secondary)"> &bdquo;${_esc(z.zwingername)}&ldquo;</em>` : ''}
${z.vdh_mitglied ? `<span class="badge badge-sm" style="margin-left:var(--space-1);background:var(--c-primary);color:#fff">VDH</span>` : ''}
</div>
${(z.ort || z.bundesland) ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${[z.ort, z.bundesland].filter(Boolean).map(_esc).join(', ')}</div>` : ''}
${z.beschreibung ? `<p style="font-size:var(--text-sm);margin-top:var(--space-1)">${_esc(z.beschreibung)}</p>` : ''}
${z.website ? `<a href="${_esc(z.website)}" target="_blank" rel="noopener" style="font-size:var(--text-sm);color:var(--c-primary)">${_esc(z.website)}</a>` : ''}
</div>
`).join('');
const formHtml = _appState.user ? `
<div id="wiki-zuchter-form-wrap" style="display:none;margin-top:var(--space-3)">
<form id="wiki-zuchter-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required maxlength="100">
</div>
<div class="form-group">
<label class="form-label">Zwingername</label>
<input class="form-control" name="zwingername" maxlength="100">
</div>
<div class="form-group">
<label class="form-label">Ort</label>
<input class="form-control" name="ort" maxlength="80">
</div>
<div class="form-group">
<label class="form-label">PLZ</label>
<input class="form-control" name="plz" maxlength="10">
</div>
<div class="form-group">
<label class="form-label">Bundesland</label>
<select class="form-control" name="bundesland">
<option value=""> bitte wählen </option>
${DE_BUNDESLAENDER.map(bl => `<option value="${_esc(bl)}">${_esc(bl)}</option>`).join('')}
</select>
</div>
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url" maxlength="200" placeholder="https://…">
</div>
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Telefon</label>
<input class="form-control" name="telefon" type="tel" maxlength="30">
</div>
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Kurzbeschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3" maxlength="500"></textarea>
</div>
<div class="form-group" style="grid-column:1/-1;display:flex;align-items:center;gap:var(--space-2)">
<input type="checkbox" id="wiki-zuchter-vdh" name="vdh_mitglied" value="1" style="width:auto">
<label for="wiki-zuchter-vdh" style="margin:0;font-size:var(--text-sm)">VDH-Mitglied</label>
</div>
</div>
<div id="wiki-zuchter-success" style="display:none;color:var(--c-success);padding:var(--space-3);text-align:center;font-size:var(--text-sm)">
Vielen Dank! Dein Eintrag wird geprüft.
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-ghost flex-1" id="wiki-zuchter-cancel">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1" id="wiki-zuchter-submit">Eintragen</button>
</div>
</form>
</div>
<button class="btn btn-secondary btn-sm" id="wiki-zuchter-add-btn" style="margin-top:var(--space-3)">
+ Züchter eintragen
</button>
` : '';
return `
<div class="wiki-detail-section" id="wiki-zuchter-section">
<div class="wiki-detail-label">Züchter</div>
<div id="wiki-zuchter-list">${listHtml}</div>
${formHtml}
</div>
`;
}
function _bindZuchterForm(slug) {
const addBtn = document.getElementById('wiki-zuchter-add-btn');
const cancelBtn = document.getElementById('wiki-zuchter-cancel');
const formWrap = document.getElementById('wiki-zuchter-form-wrap');
const form = document.getElementById('wiki-zuchter-form');
addBtn?.addEventListener('click', () => {
formWrap.style.display = '';
addBtn.style.display = 'none';
});
cancelBtn?.addEventListener('click', () => {
formWrap.style.display = 'none';
addBtn.style.display = '';
});
form?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('wiki-zuchter-submit');
const fd = new FormData(form);
const data = {
rasse_slug: slug,
name: fd.get('name'),
zwingername: fd.get('zwingername') || null,
ort: fd.get('ort') || null,
plz: fd.get('plz') || null,
bundesland: fd.get('bundesland') || null,
vdh_mitglied: fd.get('vdh_mitglied') === '1',
website: fd.get('website') || null,
telefon: fd.get('telefon') || null,
beschreibung: fd.get('beschreibung') || null,
};
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…';
const ok = await _submitZuchter(data);
if (ok) {
form.reset();
document.getElementById('wiki-zuchter-success').style.display = '';
// Hide submit row
submitBtn.closest('div[style*="flex"]').style.display = 'none';
} else {
UI.toast.error('Fehler beim Einsenden. Bitte versuche es erneut.');
submitBtn.disabled = false;
submitBtn.textContent = 'Eintragen';
}
});
}
async function _openBreedDetail(slug) {
let rasse;
try {
@ -402,54 +709,69 @@ window.Page_wiki = (() => {
return;
}
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
// Temperament chips
const chips = rasse.temperament
? rasse.temperament.split(',').map(t => `<span class="wiki-trait-chip">${_esc(t.trim())}</span>`).join('')
: '';
// Stats row
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
? `${rasse.gewicht_min_kg}${rasse.gewicht_max_kg} kg`
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—');
const photoHtml = rasse.foto_url
? `<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.style.display='none'">`
? `<div class="wiki-detail-hero-photo-wrap">
<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.parentElement.style.display='none'">
</div>`
: '';
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
const body = `
${photoHtml}
<div class="wiki-detail-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(rasse.groesse)}">${_groesseLabel(rasse.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(rasse.aktivitaet)}">${_aktivLabel(rasse.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(rasse.erfahrung)}">${_erfahrungLabel(rasse.erfahrung)}</span>
${rasse.gruppe ? `<span class="badge">${_esc(rasse.gruppe)}</span>` : ''}
${/* 1. Hero */ ''}
<div class="wiki-detail-hero" style="text-align:center;margin-bottom:var(--space-4)">
${photoHtml}
<h1 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:var(--space-2) 0 var(--space-1)">${_esc(rasse.name)}</h1>
${rasse.herkunft ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(rasse.herkunft)}</div>` : ''}
${rasse.gruppe ? `<div style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:2px">${_esc(rasse.gruppe)}</div>` : ''}
</div>
${rasse.herkunft || rasse.bred_for ? `
<div class="wiki-detail-section">
${rasse.herkunft ? `<div class="wiki-detail-label">Herkunft</div><p>${_esc(rasse.herkunft)}</p>` : ''}
${rasse.bred_for ? `<div class="wiki-detail-label">Ursprüngliche Aufgabe</div><p>${_esc(rasse.bred_for)}</p>` : ''}
</div>` : ''}
${chips ? `
${/* 2. Charakter-Badges */ chips ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Charakter</div>
<div class="wiki-trait-chips">${chips}</div>
</div>` : ''}
<div class="wiki-stat-row">
<div class="wiki-stat-item">
<span class="wiki-stat-label">Gewicht</span>
<span class="wiki-stat-value">${gewicht}</span>
${/* 3. Beschreibung */ rasse.beschreibung ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Beschreibung</div>
<p style="line-height:1.6;color:var(--c-text)">${_esc(rasse.beschreibung)}</p>
</div>` : (rasse.bred_for ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Ursprüngliche Aufgabe</div>
<p style="line-height:1.6;color:var(--c-text)">${_esc(rasse.bred_for)}</p>
</div>` : '')}
${/* 4. Steckbrief */ _renderSteckbriefGrid(rasse)}
${/* 5. Vorkommen */ rasse.vorkommen_de ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Vorkommen in Deutschland</div>
<p style="line-height:1.6;color:var(--c-text)">${_esc(rasse.vorkommen_de)}</p>
</div>` : ''}
${/* 6. Interesse — wird async befüllt */ `
<div id="wiki-interesse-placeholder">
<div class="wiki-detail-section" style="opacity:0.5">
<div class="wiki-detail-label">In der Community</div>
<div style="height:60px;background:var(--c-surface-2);border-radius:var(--radius-md)"></div>
</div>
<div class="wiki-stat-item">
<span class="wiki-stat-label">Lebenserwartung</span>
<span class="wiki-stat-value">${_esc(rasse.lebensdauer || '—')}</span>
</div>`}
${/* 7. Züchter — wird async befüllt */ `
<div id="wiki-zuchter-placeholder">
<div class="wiki-detail-section" style="opacity:0.5">
<div class="wiki-detail-label">Züchter</div>
<div style="height:40px;background:var(--c-surface-2);border-radius:var(--radius-md)"></div>
</div>
</div>
<div class="wiki-fit-row">
<span>${UI.icon('house-line')} Wohnung: ${rasse.wohnung_geeignet ? UI.icon('check') : UI.icon('x')}</span>
<span>${UI.icon('users')} Kinder: ${rasse.kinder_geeignet ? UI.icon('check') : UI.icon('x')}</span>
</div>
</div>`}
${/* 8. Community-Berichte */ `
<div class="wiki-detail-section" id="wiki-berichte-section">
<div class="wiki-detail-label">Community-Berichte</div>
${berichteHtml}
@ -465,11 +787,30 @@ window.Page_wiki = (() => {
<button class="btn btn-ghost w-full" id="wiki-foto-submit-btn" style="font-size:var(--text-sm)">
${UI.icon('camera')} ${rasse.foto_url ? 'Besseres Foto vorschlagen' : 'Foto hinzufügen'}
</button>
</div>` : ''}
</div>` : ''}`}
`;
UI.modal.open({ title: _esc(rasse.name), body });
// Async: load stats + züchter in parallel
Promise.all([_fetchStats(slug), _fetchZuchter(slug)]).then(([stats, zuchter]) => {
const interessePlaceholder = document.getElementById('wiki-interesse-placeholder');
if (interessePlaceholder) {
interessePlaceholder.outerHTML = _renderInteresseSection(stats, slug);
_bindInteresseButtons(slug);
}
const zuchterPlaceholder = document.getElementById('wiki-zuchter-placeholder');
if (zuchterPlaceholder) {
zuchterPlaceholder.outerHTML = _renderZuchterSection(zuchter || [], slug);
_bindZuchterForm(slug);
}
}).catch(() => {
// Silently remove placeholders on error
document.getElementById('wiki-interesse-placeholder')?.remove();
document.getElementById('wiki-zuchter-placeholder')?.remove();
});
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);

717
backend/static/landing.html Normal file
View file

@ -0,0 +1,717 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ban Yaro — Die deutschsprachige Hunde-Plattform</title>
<meta name="description" content="Ban Yaro ist die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting — DSGVO-konform, ohne App Store.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/info">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
<meta property="og:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. Kostenlos, DSGVO-konform, ohne App Store.">
<meta property="og:url" content="https://banyaro.app/info">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform">
<meta name="twitter:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community. Kostenlos, DSGVO-konform.">
<meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "MobileApplication",
"name": "Ban Yaro",
"alternateName": "Ban Yaro — Die Hunde-Plattform",
"description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App. Digitales Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting und mehr — DSGVO-konform, ohne App Store.",
"url": "https://banyaro.app",
"applicationCategory": "LifestyleApplication",
"applicationSubCategory": "PetApplication",
"operatingSystem": "iOS, Android, Web",
"inLanguage": "de",
"availableOnDevice": "Smartphone, Tablet",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
"publisher": {
"@type": "Organization",
"name": "Ban Yaro",
"url": "https://banyaro.app"
},
"featureList": [
"Digitales Hunde-Tagebuch mit Fotos und GPS",
"Digitaler Impfpass und Gesundheitsakte",
"Giftköder-Alarm mit Push-Benachrichtigungen",
"Gassi-Community und GPS-Routen",
"Hundesitting-Vermittlung",
"NFC-Halsband-Tags",
"Hunde-Wiki mit Rassendatenbank",
"Verlorener Hund Alarm",
"Forum für Hundebesitzer",
"Offline-Modus via Service Worker"
],
"screenshot": "https://banyaro.app/icons/icon-512.png",
"softwareVersion": "2.0",
"datePublished": "2026-04-01",
"areaServed": ["DE", "AT", "CH"],
"audience": {
"@type": "Audience",
"audienceType": "Hundebesitzer, Hundeschulen, Tierärzte, Züchter"
}
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #C4843A;
--primary-dark: #a86e2e;
--primary-light: #f5e6d3;
--text: #1a1a1a;
--text-secondary: #555;
--text-muted: #888;
--bg: #FAF7F2;
--surface: #fff;
--border: #e8ddd0;
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 900px; margin: 0 auto; padding: 0 1.5rem; }
/* Header */
header {
background: linear-gradient(135deg, #C4843A 0%, #e8a857 100%);
color: white;
padding: 3rem 0 4rem;
text-align: center;
}
.header-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header-logo img {
width: 64px;
height: 64px;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0,0,0,.2);
}
.header-logo .logo-name {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.02em;
}
header h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
margin-bottom: 1rem;
line-height: 1.2;
}
header p {
font-size: 1.15rem;
opacity: 0.92;
max-width: 600px;
margin: 0 auto 2rem;
}
.header-badges {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
}
.badge {
background: rgba(255,255,255,.2);
border: 1px solid rgba(255,255,255,.4);
border-radius: 999px;
padding: 0.4rem 1rem;
font-size: 0.85rem;
font-weight: 600;
}
.cta-btn {
display: inline-block;
background: white;
color: var(--primary-dark);
font-weight: 700;
font-size: 1.1rem;
padding: 0.85rem 2.5rem;
border-radius: 999px;
margin-top: 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,.15);
transition: transform 0.15s, box-shadow 0.15s;
}
.cta-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 28px rgba(0,0,0,.2);
text-decoration: none;
}
/* Nav */
nav {
background: white;
border-bottom: 1px solid var(--border);
padding: 0.75rem 0;
position: sticky;
top: 0;
z-index: 10;
}
nav .container {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
nav a {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
}
nav a:hover { color: var(--primary); text-decoration: none; }
nav .nav-brand {
font-weight: 800;
color: var(--primary);
margin-right: auto;
font-size: 1rem;
}
/* Sections */
section { padding: 4rem 0; }
section:nth-child(even) { background: white; }
h2 {
font-size: clamp(1.4rem, 3vw, 2rem);
font-weight: 700;
color: var(--text);
margin-bottom: 0.5rem;
}
.section-intro {
color: var(--text-secondary);
font-size: 1.05rem;
margin-bottom: 2.5rem;
max-width: 600px;
}
/* Feature Groups */
.feature-group { margin-bottom: 2.5rem; }
.feature-group-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--primary-dark);
background: var(--primary-light);
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 999px;
margin-bottom: 1rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.feature-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
}
section:nth-child(even) .feature-card { background: var(--bg); }
.feature-icon {
font-size: 1.6rem;
flex-shrink: 0;
margin-top: 0.1rem;
}
.feature-card h3 {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 0.25rem;
color: var(--text);
}
.feature-card p {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.feature-tag {
display: inline-block;
background: var(--primary-light);
color: var(--primary-dark);
font-size: 0.68rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 999px;
margin-top: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Comparison Table */
.table-wrap { overflow-x: auto; margin-top: 2rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; min-width: 500px; }
th {
background: var(--primary);
color: white;
font-weight: 600;
padding: 0.75rem 1rem;
text-align: left;
}
th:first-child { border-radius: var(--radius) 0 0 0; }
th:last-child { border-radius: 0 var(--radius) 0 0; }
td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: var(--bg); }
.check { color: #16a34a; font-weight: 700; }
.cross { color: #dc2626; }
/* Pricing */
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.pricing-card {
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 2rem 1.5rem;
background: white;
}
.pricing-card.featured {
border-color: var(--primary);
position: relative;
}
.pricing-card.featured::before {
content: "Empfohlen";
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--primary);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 0.25rem 0.75rem;
border-radius: 999px;
}
.pricing-card h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.25rem; }
.pricing-price { font-size: 2rem; font-weight: 800; color: var(--primary); margin: 0.75rem 0; }
.pricing-price span { font-size: 1rem; font-weight: 400; color: var(--text-muted); }
.pricing-card ul { list-style: none; margin-top: 1rem; }
.pricing-card ul li { padding: 0.35rem 0; font-size: 0.9rem; color: var(--text-secondary); }
.pricing-card ul li::before { content: "✓ "; color: #16a34a; font-weight: 700; }
/* USP Strip */
.usp-strip {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 2rem;
}
.usp-item { display: flex; align-items: flex-start; gap: 0.75rem; }
.usp-icon { font-size: 1.5rem; flex-shrink: 0; margin-top: 0.1rem; }
.usp-item h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 0.2rem; }
.usp-item p { font-size: 0.85rem; color: var(--text-secondary); }
/* Footer */
footer {
background: #1a1a1a;
color: #aaa;
padding: 2.5rem 0;
text-align: center;
font-size: 0.85rem;
}
footer a { color: var(--primary); }
footer .footer-links { margin-top: 0.75rem; display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap; }
</style>
</head>
<body>
<header>
<div class="container">
<div class="header-logo">
<img src="/icons/icon-180.png" alt="Ban Yaro App Icon">
<span class="logo-name">Ban Yaro</span>
</div>
<h1>Die deutschsprachige Hunde-Plattform</h1>
<p>Alles rund um deinen Hund — von Welpe bis Opa. Kostenlos, DSGVO-konform, ohne App Store.</p>
<div class="header-badges">
<span class="badge">Kostenlos nutzbar</span>
<span class="badge">DSGVO-konform</span>
<span class="badge">Kein App Store nötig</span>
<span class="badge">Made in Germany</span>
<span class="badge">Offline-fähig</span>
</div>
<a href="/" class="cta-btn">Jetzt kostenlos starten</a>
</div>
</header>
<nav>
<div class="container">
<span class="nav-brand">Ban Yaro</span>
<a href="#funktionen">Funktionen</a>
<a href="#vergleich">Vergleich</a>
<a href="#preise">Preise</a>
<a href="#warum">Warum Ban Yaro?</a>
<a href="/wiki/rassen">Rassen-Wiki</a>
<a href="/knigge">Knigge</a>
<a href="/">App öffnen</a>
</div>
</nav>
<section id="funktionen">
<div class="container">
<h2>Alles für Hundebesitzer in einer App</h2>
<p class="section-intro">Ban Yaro vereint alle wichtigen Hunde-Tools — ohne Werbung, ohne Datenweitergabe an US-Konzerne, ohne monatliche Pflichtkosten.</p>
<div class="feature-group">
<div class="feature-group-label">Mein Hund</div>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon">📓</span>
<div><h3>Tagebuch</h3><p>Fotos, Videos, Texte und GPS-Orte — alle Momente mit deinem Hund. Kategorien wie Spaziergänge, Meilensteine, Lustiges.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">💉</span>
<div><h3>Gesundheit & Impfpass</h3><p>Impfungen, Tierarztbesuche, Medikamente digital verwalten. Automatische Erinnerungen per Push-Notification.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🎯</span>
<div><h3>Training & Übungen</h3><p>Tägliches Trainings-Tagebuch, Übungen und Pläne. KI-gestützte Mustererkennung — wann lernt dein Hund am besten?</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🏥</span>
<div><h3>Symptom-Checker</h3><p>KI-gestützte Ersteinschätzung: beobachten, Tierarzt oder Notfall? Orientierung wann es wirklich dringend ist.</p><span class="feature-tag">Plus</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">📄</span>
<div><h3>Digitaler Heimtierausweis</h3><p>Alle Gesundheitsdaten als druckbares Dokument — für Tierarzt, Tierpension oder Auslandsreise.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🆔</span>
<div><h3>NFC-Halsband-Tags</h3><p>Öffentliche Profilseite für jeden Hund. Finder kontaktiert dich anonym — ohne deine Nummer preiszugeben.</p><span class="feature-tag">Kostenlos + Shop</span></div>
</div>
</div>
</div>
<div class="feature-group">
<div class="feature-group-label">Entdecken</div>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon">🗺️</span>
<div><h3>Karte & Umgebung</h3><p>Interaktive Karte mit Giftköder-Meldungen, Gassi-Treffen, Routen und hundefreundlichen Orten in der Nähe.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🐾</span>
<div><h3>GPS-Routen</h3><p>Routen aufzeichnen, teilen und bewerten — Untergrund, Schatten, Leinenpflicht, Sicherheit.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">📅</span>
<div><h3>Events & Turniere</h3><p>Agility-Turniere, Hundeausstellungen und lokale Veranstaltungen in deiner Region.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">📍</span>
<div><h3>Hundefreundliche Orte</h3><p>Crowd-sourced Datenbank: Restaurants, Cafés, Parks, Geschäfte — mit echten Bewertungen von Hundebesitzern.</p><span class="feature-tag">Kostenlos</span></div>
</div>
</div>
</div>
<div class="feature-group">
<div class="feature-group-label">Community</div>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon">⚠️</span>
<div><h3>Giftköder-Alarm</h3><p>Meldungen mit GPS und Foto. Alle Nutzer im Umkreis bekommen sofort eine Push-Benachrichtigung.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🚨</span>
<div><h3>Verlorener Hund</h3><p>Sofortalarm für alle Nutzer in der Nähe — mit Foto, letzter GPS-Position und direktem Kontakt.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🐕</span>
<div><h3>Gassi-Treffen</h3><p>Spontane oder geplante Gassi-Treffen erstellen und finden. Mit Hunde-Profilen der Teilnehmer.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🏠</span>
<div><h3>Hundesitting</h3><p>Vertrauenswürdige Sitter finden — nur 8% Provision statt 20% bei Rover oder Pawshake.</p><span class="feature-tag">8% Provision</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">💬</span>
<div><h3>Forum</h3><p>Rassen-basierte Foren, KI-Zusammenfassungen langer Threads, Experten-Badge für Tierärzte und Trainer.</p><span class="feature-tag">Kostenlos</span></div>
</div>
</div>
</div>
<div class="feature-group">
<div class="feature-group-label">Wissen</div>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon">📚</span>
<div><h3><a href="/wiki/rassen">Hunde-Wiki</a></h3><p>Rassendatenbank mit Charakter, Gesundheit, Pflege. "Passt diese Rasse zu mir?" Quiz für angehende Hundebesitzer.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🤝</span>
<div><h3><a href="/knigge">Hunde-Knigge</a></h3><p>Begegnungen mit fremden Hunden, Kindern, Radfahrern. ÖPNV-Regeln, Leinenpflicht, Haftpflicht.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🎬</span>
<div><h3>Hundefilme</h3><p>Filmdatenbank mit der wichtigsten Frage: "Stirbt der Hund?" Nie wieder unvorbereitet in einen Film stolpern.</p><span class="feature-tag">Kostenlos</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🩹</span>
<div><h3>Erste Hilfe</h3><p>Notfallratgeber für häufige Situationen — Vergiftung, Wunden, Hitzschlag. Mit klaren Handlungsschritten.</p><span class="feature-tag">Kostenlos</span></div>
</div>
</div>
</div>
</div>
</section>
<section id="vergleich">
<div class="container">
<h2>Ban Yaro vs. Konkurrenz</h2>
<p class="section-intro">Andere Apps decken einzelne Bereiche ab — Ban Yaro vereint alles in einer DSGVO-konformen Plattform ohne US-Datenweitergabe.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Funktion</th>
<th>Ban Yaro</th>
<th>Dogorama</th>
<th>Tractive</th>
<th>PetDesk</th>
</tr>
</thead>
<tbody>
<tr>
<td>Kostenlos nutzbar</td>
<td class="check">✓ Ja</td>
<td>Begrenzt</td>
<td class="cross">✗ Abo</td>
<td class="cross">✗ Nein</td>
</tr>
<tr>
<td>DSGVO / EU-Hosting</td>
<td class="check">✓ Ja</td>
<td class="cross">✗ Nein</td>
<td>Teilweise</td>
<td class="cross">✗ USA</td>
</tr>
<tr>
<td>Kein App Store nötig</td>
<td class="check">✓ PWA</td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Giftköder-Alarm</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Digitaler Impfpass</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="check"></td>
</tr>
<tr>
<td>Gassi-Community</td>
<td class="check"></td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Hundesitting</td>
<td class="check">✓ (8%)</td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>NFC-Halsband-Tag</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Verlorener Hund Alarm</td>
<td class="check"></td>
<td class="check"></td>
<td class="check">✓ (GPS)</td>
<td class="cross"></td>
</tr>
<tr>
<td>Rassen-Wiki</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
<tr>
<td>Offline-Modus</td>
<td class="check"></td>
<td class="cross"></td>
<td class="cross"></td>
<td class="cross"></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section id="preise">
<div class="container">
<h2>Preise</h2>
<p class="section-intro">Ban Yaro ist kostenlos nutzbar — für immer. Ban Yaro Plus erweitert die Möglichkeiten für engagierte Hundebesitzer.</p>
<div class="pricing-grid">
<div class="pricing-card">
<h3>Kostenlos</h3>
<div class="pricing-price">0 € <span>/ Monat</span></div>
<ul>
<li>1 Hunde-Profil</li>
<li>Tagebuch (unbegrenzte Einträge)</li>
<li>Giftköder-Alarm</li>
<li>Verlorener Hund Alarm</li>
<li>Wiki &amp; Knigge</li>
<li>Forum &amp; Community</li>
<li>Gassi-Treffen &amp; Routen</li>
<li>NFC-Halsband-Profil</li>
<li>Heimtierausweis (Druck)</li>
</ul>
</div>
<div class="pricing-card featured">
<h3>Ban Yaro Plus</h3>
<div class="pricing-price">4,99 € <span>/ Monat</span></div>
<ul>
<li>Alles aus Kostenlos</li>
<li>Unbegrenzte Hunde-Profile</li>
<li>Tagebuch-Export (PDF/Fotobuch)</li>
<li>Jahresrückblick</li>
<li>Symptom-Checker unlimitiert</li>
<li>Futter-Barcode Scanner</li>
<li>EU-Reisepass Checkliste</li>
<li>KI-Erziehungsassistent</li>
<li>Smart Collar Integration</li>
</ul>
</div>
<div class="pricing-card">
<h3>NFC-Tags</h3>
<div class="pricing-price">ab 6 €</div>
<ul>
<li>Physisches NFC-Tag für Halsband</li>
<li>Scan → öffentliches Hunde-Profil</li>
<li>"Gefunden"-Benachrichtigung</li>
<li>Anonymer Kontakt ohne Telefon</li>
<li>Wetter- und kratzfest</li>
</ul>
</div>
</div>
</div>
</section>
<section id="warum">
<div class="container">
<h2>Warum Ban Yaro?</h2>
<p class="section-intro">Ban Yaro wurde von Hundebesitzern für Hundebesitzer entwickelt — mit einem klaren Standpunkt zu Datenschutz und Fairness.</p>
<div class="usp-strip">
<div class="usp-item">
<span class="usp-icon">🇩🇪</span>
<div>
<h3>Deutsche Plattform</h3>
<p>Hosting in Deutschland, deutschsprachiger Support, auf DACH-Nutzer zugeschnitten.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">🔒</span>
<div>
<h3>DSGVO-konform</h3>
<p>Keine Datenweitergabe an US-Konzerne. Cookielose Analytics (Umami). Transparente Datennutzung.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">📱</span>
<div>
<h3>Kein App Store</h3>
<p>Als Progressive Web App direkt über den Browser installierbar — auf iOS und Android. Sofort updatebar.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">📡</span>
<div>
<h3>Offline-fähig</h3>
<p>Service Worker sorgt dafür dass die App auch ohne Internet funktioniert — beim Gassi gehen in der Natur.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">💸</span>
<div>
<h3>Faire Provision</h3>
<p>Hundesitting nur 8% Provision — Rover und Pawshake nehmen 20%. Mehr Geld bleibt beim Sitter.</p>
</div>
</div>
<div class="usp-item">
<span class="usp-icon">🗺️</span>
<div>
<h3>OpenStreetMap</h3>
<p>Karten von OpenStreetMap statt Google — keine Tracking-Cookies, kein API-Lock-in, günstiger für alle.</p>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="container" style="text-align:center">
<h2>Jetzt kostenlos starten</h2>
<p class="section-intro" style="margin:1rem auto 2rem">Einfach banyaro.app im Browser öffnen und "Zum Homescreen hinzufügen" — fertig. Kein App Store, keine Kreditkarte.</p>
<a href="/" class="cta-btn" style="background:var(--primary);color:white;box-shadow:0 4px 20px rgba(196,132,58,.4)">Ban Yaro öffnen</a>
</div>
</section>
<footer>
<div class="container">
<p><strong style="color:white">Ban Yaro</strong> — Die deutschsprachige Hunde-Plattform</p>
<p style="margin-top:0.5rem">banyaro.app · banyaro.de · DSGVO-konform · Hosting in Deutschland</p>
<div class="footer-links">
<a href="/">App öffnen</a>
<a href="/info">Über Ban Yaro</a>
<a href="/api/wiki/rassen">Hunde-Rassen</a>
<a href="/api/events">Events</a>
</div>
</div>
</footer>
</body>
</html>

167
backend/static/llms.txt Normal file
View file

@ -0,0 +1,167 @@
# Ban Yaro — Die deutschsprachige Hunde-Plattform
# https://banyaro.app
# Letzte Aktualisierung: 2026-04-21
## Was ist Ban Yaro?
Ban Yaro (banyaro.app) ist eine deutschsprachige All-in-One Hunde-Plattform als Progressive Web App (PWA).
Kein App Store, kein US-Konzern, DSGVO-konform, selbst gehostet in Deutschland.
Das Kern-Versprechen: "Alles rund um deinen Hund — von Welpe bis Opa."
Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartphones installierbar
(iOS und Android) direkt über den Browser — ohne App Store.
## Zielgruppe
- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz)
- Hundeschulen und Hundetrainer
- Tierärzte und Praxen
- Züchter
## Funktionen (aktuell verfügbar)
### Hunde-Profil & Tagebuch
- Digitales Hunde-Profil (Name, Rasse, Geburtstag, Foto, Chip-Nummer)
- Tagebuch mit Fotos, Videos, GPS-Ort, Kategorien
- Öffentliches Profil für NFC-Halsband-Tags (banyaro.app/hund/{id})
- Mehrere Hunde pro Account verwaltbar
- Hunde-Profil teilen (Mitbesitzer, Hundesitter)
### Gesundheit & Impfpass
- Digitaler Impfpass (Impfungen, Entwurmungen, Tierarztbesuche)
- Medikamenten-Reminder per Push-Notification
- Gewichtsverlauf
- Symptom-Checker (KI-gestützt: beobachten / Tierarzt / Notfall)
- Tierarzt-Verzeichnis
- Printbarer Heimtierausweis (PDF)
### Giftköder-Alarm
- Giftköder-Meldungen mit GPS-Koordinaten und Foto
- Push-Benachrichtigung für alle Nutzer im konfigurierbaren Umkreis
- Interaktive Karte (OpenStreetMap/Leaflet)
- Automatisches Ablaufdatum nach 7 Tagen
### Sicherheit & Community-Alerts
- Verlorener Hund: Alert mit Foto und letzter GPS-Position
- Nearby-Alerts: Push-Benachrichtigungen für Ereignisse in der Nähe
### NFC-Halsband-Tags
- Jeder Hund hat eine öffentliche URL (ohne Login sichtbar)
- "Ich habe diesen Hund gefunden"-Button → Besitzer bekommt Push-Benachrichtigung
- Notfallkontakt ohne Telefonnummer preiszugeben
- Physische NFC-Tags erhältlich (Shop)
### Gassi-Community
- Gassi-Treffen erstellen und beitreten
- GPS-Routen aufzeichnen und teilen
- Routen bewerten (Untergrund, Schatten, Leinenpflicht)
- Beliebte Routen entdecken
### Hundesitting-Netzwerk
- Sitter-Profile mit Erfahrung und Bewertungen
- Buchungsanfragen und Kalender
- Nur 8% Provision (vs. 20% bei Rover/Pawshake)
- Bewertungen verifizierter Buchungen
### Forum
- Rassen-basierte Foren
- KI-Zusammenfassung langer Threads
- Experten-Badge (Tierarzt, Trainer)
### Hunde-Wiki (Wissensdatenbank)
- Rassen-Datenbank mit Charakter, Gesundheit, Pflege
- "Passt diese Rasse zu mir?" Quiz
- Gesundheits-Wiki (Zecken, Vergiftungen, Erste Hilfe)
### Hunde-Knigge
- Ratgeber für Begegnungen (fremder Hund, Kinder, Radfahrer)
- Regeln in ÖPNV und öffentlichen Orten
- Haftpflicht-Ratgeber
### Events & Kultur
- Agility-Turniere und Hundeausstellungen
- Hundefilme-Datenbank mit "Stirbt der Hund?"-Rubrik
- Veranstaltungskalender
### Hundefreundliche Orte
- Crowd-sourced Datenbank hundefreundlicher Orte
- Restaurants, Parks, Geschäfte
- Detaillierte Bewertungen
### Training
- Tägliches Trainings-Tagebuch
- Trainingsübungen und -pläne
- KI-gestützte Mustererkennung
## Technologie
- Progressive Web App (PWA) — installierbar ohne App Store
- Offline-fähig via Service Worker
- Backend: Python/FastAPI + SQLite
- Karten: Leaflet.js + OpenStreetMap (kein Google Maps)
- Hosting: Deutschland (DSGVO-konform)
- Analytics: Umami (cookieless, DSGVO-konform)
- KI-Integration: Lokale Sprachmodelle + Claude API
## Monetarisierung
**Kostenlos (immer):**
- Hunde-Profil (1 Hund)
- Giftköder-Alarm
- Verlorener Hund
- Wiki & Knigge
- Forum
- Grundfunktionen Tagebuch
**Ban Yaro Plus (ca. 4,99 €/Monat):**
- Unbegrenzte Hunde-Profile
- Tagebuch-Export als PDF/Printbook
- Jahresrückblick
- Symptom-Checker unlimitiert
- Futter-Barcode Scanner
- EU-Reisepass Checkliste
- KI-Erziehungsassistent
**Provisionen:**
- Hundesitting: 8% Provision
**Physische Produkte:**
- NFC-Halsband-Tags (ca. 6 €)
- Gedruckte Fotobücher (Partner)
## Vergleich mit Konkurrenz
| Funktion | Ban Yaro | Dogorama | PetDesk | Tractive |
|----------|----------|----------|---------|----------|
| Kostenlos nutzbar | Ja | Begrenzt | Nein | Nein |
| DSGVO / EU-Hosting | Ja | Nein | Nein | Teilweise |
| Giftköder-Alarm | Ja | Nein | Nein | Nein |
| Gassi-Community | Ja | Ja | Nein | Nein |
| Hundesitting | Ja | Nein | Nein | Nein |
| Digitaler Impfpass | Ja | Nein | Ja | Nein |
| NFC-Halsband-Tag | Ja | Nein | Nein | Nein |
| Offline-Modus | Ja | Nein | Nein | Nein |
| Kein App Store | Ja | Nein | Nein | Nein |
| Sitting-Provision | 8% | | | |
## Domains
- https://banyaro.app (primäre Domain)
- https://banyaro.de (Weiterleitung auf banyaro.app)
## Kontakt
Website: https://banyaro.app
E-Mail: Über das Kontaktformular in der App
## Öffentliche Daten-APIs (keine Authentifizierung nötig)
- GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen
- GET https://banyaro.app/api/wiki/rassen/{slug} — Details zu einer Rasse
- GET https://banyaro.app/api/events — Aktuelle Hundeevents
- GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen
- GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen
- GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel
- GET https://banyaro.app/api/movies/list — Hundefilme-Datenbank
- GET https://banyaro.app/api/stats — Community-Statistiken

32
backend/static/robots.txt Normal file
View file

@ -0,0 +1,32 @@
User-agent: *
Allow: /
Allow: /info
Allow: /hund/
Allow: /api/wiki/rassen
Allow: /api/wiki/rassen/
Allow: /api/events
Allow: /api/knigge/articles
Allow: /api/movies/list
Allow: /api/forum/
Allow: /api/lost
Allow: /api/poison
Allow: /api/stats
Disallow: /api/auth/
Disallow: /api/admin/
Disallow: /api/dogs/
Disallow: /api/diary/
Disallow: /api/health/
Disallow: /api/chat/
Disallow: /api/friends/
Disallow: /api/push/
Disallow: /api/widget/
Disallow: /api/notifications/
Disallow: /api/alerts/
Disallow: /api/ki/
Disallow: /api/import/
Disallow: /api/sitting-access/
Disallow: /ausweis/
Disallow: /teilen/
Disallow: /media/
Sitemap: https://banyaro.app/sitemap.xml

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v272';
const CACHE_VERSION = 'by-v279';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten