Compare commits

..

27 commits

Author SHA1 Message Date
40de0f38aa Feature: Tierarzt-Bewertungen — Sterne-Rating pro Praxis mit Detail-Modal (SW by-v700) 2026-05-04 21:02:49 +02:00
c5030024b0 Feature: Hunde-Buch — druckbare HTML-Tagebuchansicht als PDF (SW by-v700)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 21:01:54 +02:00
20a4936397 Feature: Ban Yaro Wrapped + Jahrestags- und Monatsrückblick (SW by-v699)
- GET /api/dogs/{id}/wrapped?year= aggregiert km, Gassi-Tage, Fotos,
  Lieblingsmonat/-aktivität, Training, Gesundheit, Wetter-Stats aus SQLite
- Frontend: Wrapped-Fullscreen-Modal in dog-profile.js — 5 Cards mit
  Swipe/Klick-Navigation, Dots, ESC-Taste, Copy-to-Clipboard auf Share-Card
- Scheduler: _job_anniversary_reminders (täglich 09:00) sendet Push wenn
  heute ein Tagebucheintrag von vor 1+ Jahren existiert
- Scheduler: _job_monthly_recap (1. des Monats 10:00) sendet Vormonat-
  Zusammenfassung (km, Einträge, Training) per Push an alle User
- Beide Jobs im Status-Report-Log und Scheduler-Start-Log vermerkt
- SW by-v699, APP_VER 699
2026-05-04 20:54:12 +02:00
0fdc32eaf4 Feature: Hunde-Persönlichkeitstest + Kilometer-Lebenswerk-Badge (SW by-v698)
- personality.js: 10-Fragen-Quiz mit 4 Typen (Abenteurer/Entdecker/Kuschler/Denker), Ergebnis-Speicherung in localStorage, Share-Funktion
- achievements.py: neue Badge-Kategorie km_lebenswerk (Bronze 100 km bis Platin 5000 km)
- settings.js: Lifetime-km-Balken mit Meilenstein-Markierungen bei 100/500/1000/5000 km
- app.js + index.html: personality-Seite registriert
2026-05-04 20:52:51 +02:00
a4e97348ed Feature: Schnell-Gassi-Log + Hunde-Visitenkarte mit QR-Code (SW by-v698)
- Worlds-FAB: neuer 'Schnell-Gassi' Button im Gassi-Chip — öffnet schlankes
  Bottom-Sheet mit Dauer-Toggle (15/30/45/60 min), auto-Wetter aus Cache,
  postet direkt als Tagebucheintrag typ='gassi' ohne GPS-Tracking
- dog-profile.js: 'Visitenkarte teilen' Button öffnet Modal mit gestalteter
  Karte (Hundefoto, Name, Rasse/Alter, Wohnort) + QR-Code via qrserver.com,
  Link-kopieren und native Web-Share-API
2026-05-04 20:52:11 +02:00
6e4bf25581 Feature: Hundeernährungs-Feature — Kalorien-Rechner, Futter-Guide, Giftliste, KI-Berater (SW by-v698) 2026-05-04 20:51:45 +02:00
b1d9fb4f54 Feature: Wetter-Verbesserung im Tagebuch — Auto-Wetter, Chip-Fix, Detail-Fix (SW by-v695)
- diary.js: Weather-Chip in der Liste nutzt jetzt temp_c (korrekter Feldname)
- diary.js: Detail-View zeigt "emoji temp · X km/h Wind · Y% Regen" (precip_prob statt Luftfeuchtigkeit)
- diary.js: Bei neuem Eintrag ohne GPS → Wetter wird via GPS-API vorgeholt und als weather_json mitgesendet
- diary.py: DiaryCreate-Modell um weather_json-Feld erweitert; client-geliefertes Wetter wird gespeichert wenn kein GPS-basiertes Wetter verfügbar
- SW by-v695, APP_VER 695
2026-05-04 20:30:06 +02:00
6152d6bf0e Feature: Meine Wetterrekorde Sektion auf Wetter-Seite (SW by-v694)
- Backend: GET /api/weather/records — liest diary-Einträge mit weather_json
  und berechnet Kältester/Heißester Gassi, Stürmischster Tag, Regentage
- Frontend: #wttr-records 2×2 Grid-Karten unterhalb Hunde-Wetter
  (nur für eingeloggte User mit ≥3 Tagebucheinträgen mit Wetterdaten)
- SW-Version auf by-v694 erhöht, APP_VER auf 694
2026-05-04 20:28:06 +02:00
d081029618 Feature: Wetter-Tapferkeits-, Jahreszeiten- und Schnee-Badges (SW by-v693)
Drei neue Badge-Kategorien in achievements.py:
- wetter_tapfer: Diary-Einträge bei Regen/Kälte/Wind (precip>60, temp<2, wind>50)
- jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (max 4)
- schnee_held: Diary-Einträge bei Schnee (weathercode 71-77)

Beide Funktionen check_and_award und my_achievements erweitert.
2026-05-04 20:26:03 +02:00
6bf088df56 Feature: Goldene Gassi-Stunde — täglicher Push mit bestem Wetterfenster (SW by-v693)
- Scheduler-Job täglich 07:00: berechnet bestes 2h-Fenster via Open-Meteo
- Score-System (max. 10 Pkt): Temperatur, Niederschlag, Wind, Tageszeit
- User-Fallback auf letzten bekannten Standort (push_subscriptions.last_lat/lon) oder München
- Nur Push wenn score >= 3 (kein sinnloser Push bei schlechtem Wetter)
- DB-Migration: users.gassi_stunde_push (Boolean, default 0)
- settings.js: Toggle "Goldene Gassi-Stunde täglich" in App-Einstellungen
- PATCH /api/profile + auth.py /me: gassi_stunde_push Feld
2026-05-04 20:22:02 +02:00
af1508c0de Feature: Fell-Typ Einstellung am Hundeprofil — personalisierte Wetter-Hinweise (SW by-v693)
- DB-Migration: dogs.fell_typ (kurz|mittel|lang|drahtaar|doppel|nackt)
- Hund-Profil Formular: Dropdown "Felltyp" mit 6 Optionen, wird via PATCH /api/dogs/{id} gespeichert
- Wetter: _dogWeatherLabel(d, felltyp) mit fell-spezifischen Hitze-/Kälteschwellen
- Wetter: Fell-spezifische Hinweise (doppel + Hitze, nackt + Kälte, kurz + Frost)
2026-05-04 20:21:02 +02:00
471633867c Feature: Wetter — Gassi-Score, Schnüffel-Index, Hunde-Alter-Hinweis (SW by-v692)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:18:38 +02:00
759979ffce Feature: Stündliche Niederschlagswahrscheinlichkeit auf Wetter-Seite (SW by-v690)
- Backend: Open-Meteo Forecast-Request um hourly precipitation_probability,
  precipitation und weathercode erweitert; stündliche Daten werden pro Tag
  gruppiert und im API-Response unter "hourly" je Tag ausgeliefert
- Frontend: Neue _renderRainTimeline()-Funktion rendert horizontale
  Balken-Zeitskala für alle 24 Stunden des gewählten Tages; bei "Heute"
  wird automatisch zur aktuellen Stunde gescrollt und "jetzt" hervorgehoben;
  Farb-Gradient von hellgrau (<10%) bis dunkelblau (≥75%)
- SW/APP_VER/CSS auf 690 gebumpt
2026-05-04 20:06:30 +02:00
84e6bfdd82 Feature: Wiki Photo-Gallery mit Thumbnails + Lightbox, alle Fotos einer Rasse anklickbar (SW by-v664) 2026-05-03 21:23:41 +02:00
1d1171e5f2 Fix: Hilfe-? inline neben 'Dein Plan für heute' statt absolut oben rechts (SW by-v663) 2026-05-03 21:13:10 +02:00
0413483692 Feature: Moderation-Kacheln klickbar — direkter Sprung in Tab (SW by-v662) 2026-05-03 21:07:30 +02:00
4cd2a33ca2 Fix: Wiki-Foto-Moderation — 404 = bereits bearbeitet, Liste statt Fehlermeldung (SW by-v661) 2026-05-03 21:02:13 +02:00
e2cbbde7f2 Fix: Wiki-Foto-Freigabe 500 — foto_url fehlte im SELECT auf wiki_rassen 2026-05-03 20:56:24 +02:00
7b8ee7e2b1 Fix: Welten-Config — DB-Load bei jedem Login-Wechsel, einmalige localStorage→DB-Migration (SW by-v660) 2026-05-03 20:54:11 +02:00
71a13d695e Fix: Welten-Config Endpoint-Pfad doppeltes /profile/ entfernt — /api/profile/world-config korrekt (SW by-v659) 2026-05-03 20:47:31 +02:00
eca8d1455d Fix: Nach Login direkt in HUND-Welt landen statt Welcome-Seite (SW by-v658) 2026-05-03 20:41:20 +02:00
3344de27bb Feature: Welten-Wechsel per Mausrad auf Desktop (SW by-v657) 2026-05-03 20:36:19 +02:00
5b73443d0a Fix: iOS-Warnung bei Routenstart — Display wach lassen, GPS stoppt sonst (SW by-v656) 2026-05-03 20:34:00 +02:00
ccb92254b6 Fix: Routenaufzeichnung — Stopp-Button braucht Long-Press (1.8s), DIM-Timer 10s → 5s (SW by-v655)
Verhindert versehentliches Stoppen durch Hosentaschen-Druck: Stopp-Button reagiert
nur auf 1.8s Gedrückt-Halten mit Fill-Animation, Einzeltap tut nichts. DIM-Schutz-
Overlay greift jetzt nach 5s statt 10s.
2026-05-03 20:24:14 +02:00
9103c7950f Feature: Generische Seiten-Hilfe (UI.pageInfo), POI Multi-Select, Tagessprüche-DB (SW by-v654)
- UI.pageInfo(): generische Hilfe-Funktion — erstes Öffnen zeigt Info-Banner, danach ? Button oben rechts; CSS-Klassen pinfo-*
- Übungen-Seite nutzt UI.pageInfo() als erstes Beispiel
- Karte POI: Mehrfachauswahl (außer Giftköder), Kombi-Typen entfernt, type als comma-separated im Backend
- daily_quotes Tabelle in DB (346 Einträge via import_quotes.py importiert)
- GET /widget/quote — deterministischer Tagesspruch (wechselt täglich)
2026-05-03 20:10:01 +02:00
1fdba57365 Feature: UX-Fixes — Zahnrad weg, POI-Kombi-Typen, exp-fab-Position, Welten-Config in DB (SW by-v653)
- worlds-settings Zahnrad komplett entfernt (war auf Mobile sichtbar, auf Desktop schon hidden)
- exp-fab: bottom jetzt calc(--nav-bottom-height + --safe-bottom + --space-2) — kein Overlap mit worlds-back auf iPhone
- Karte POI: neue Typen bank, bank_kotbeutel, bank_kotbeutel_abfall, kotbeutel_abfall (Backend + Frontend)
- Welten-Chip-Config: GET/PUT /profile/world-config, Spalte users.world_config TEXT (Migration), Sync bei Init + Speichern
2026-05-03 19:50:04 +02:00
f0b5e6e89b Fix: Desktop-Welten-Labels — größer (13px), heller, Text-Schatten, Pill-Hintergrund aktiv, SW by-v652 2026-05-03 11:12:54 +02:00
36 changed files with 5509 additions and 226 deletions

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,)
).fetchone()

View file

@ -540,6 +540,9 @@ def _migrate(conn_factory):
("pflege_tipps", "fell_pflege_art", "TEXT"),
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
# Tagebuch-Medien: Bildmaße für Querformat-Filter
("diary_media", "img_width", "INTEGER"),
("diary_media", "img_height", "INTEGER"),
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
("diary", "weather_json", "TEXT"),
("diary", "poi_json", "TEXT"),
@ -568,6 +571,11 @@ def _migrate(conn_factory):
# Passwort-Zurücksetzen
("users", "password_reset_token", "TEXT"),
("users", "password_reset_expires", "TEXT"),
# Fell-Typ für personalisierte Wetter-Hinweise
("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt
# Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz
("tieraerzte", "avg_rating", "REAL DEFAULT 0"),
("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -1923,6 +1931,44 @@ def _migrate(conn_factory):
)
""")
# Welten-Chip-Konfiguration pro User
existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'world_config' not in existing_u:
conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT")
# Tagessprüche-Pool
conn.executescript("""
CREATE TABLE IF NOT EXISTS daily_quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
autor TEXT,
kategorie TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie);
""")
# Goldene Gassi-Stunde: User-Einstellung
existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'gassi_stunde_push' not in existing_u:
conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0")
logger.info("Migration: users.gassi_stunde_push bereit.")
# Futter-Profil
conn.executescript("""
CREATE TABLE IF NOT EXISTS futter_profil (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE,
futter_typ TEXT,
marke TEXT,
kcal_tag INTEGER,
portionen INTEGER DEFAULT 2,
notizen TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
logger.info("Migration: futter_profil bereit.")
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
@ -1940,3 +1986,75 @@ def _migrate(conn_factory):
);
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
""")
# ---- Tierarzt-Bewertungen ----
conn.executescript("""
CREATE TABLE IF NOT EXISTS tierarzt_bewertungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5),
wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5),
freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5),
kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5),
text TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(tierarzt_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt
ON tierarzt_bewertungen(tierarzt_id);
""")
# ---- Feature: Foto-Challenge der Woche ----
conn.executescript("""
CREATE TABLE IF NOT EXISTS foto_challenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thema TEXT NOT NULL,
beschreibung TEXT,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS challenge_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
foto_url TEXT NOT NULL,
caption TEXT,
votes INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(challenge_id, user_id)
);
CREATE TABLE IF NOT EXISTS challenge_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(submission_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal
ON challenge_submissions(challenge_id, created_at DESC);
""")
logger.info("Migration: Foto-Challenge-Tabellen bereit.")
# ---- Feature: Gassi-Zeiten-Pool ----
conn.executescript("""
CREATE TABLE IF NOT EXISTS gassi_zeiten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
wochentage TEXT NOT NULL,
uhrzeit TEXT NOT NULL,
ort_name TEXT,
lat REAL,
lon REAL,
radius_m INTEGER DEFAULT 500,
notiz TEXT,
aktiv INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user
ON gassi_zeiten(user_id, aktiv);
""")
logger.info("Migration: Gassi-Zeiten-Tabelle bereit.")

View file

@ -6,9 +6,10 @@ import os
import html
import logging
from collections import deque
import httpx
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
@ -43,10 +44,43 @@ logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Startup / Shutdown
# ------------------------------------------------------------------
def _backfill_image_sizes():
"""Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
import io
from database import db
from media_utils import get_image_size
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
with db() as conn:
rows = conn.execute(
"SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
).fetchall()
if not rows:
return
logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
updated = 0
for row in rows:
# url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
rel = row["url"].removeprefix("/media/")
path = os.path.join(MEDIA_DIR, rel)
try:
with open(path, "rb") as f:
data = f.read()
size = get_image_size(data)
if size:
with db() as conn:
conn.execute(
"UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
(size[0], size[1], row["id"])
)
updated += 1
except Exception:
pass
logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
_backfill_image_sizes()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
@ -198,6 +232,7 @@ from routes.adoption import router as adoption_router
from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
from routes.ernaehrung import router as ernaehrung_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
# ------------------------------------------------------------------
@ -285,6 +321,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@app.get("/stats/script.js")
async def umami_script_proxy():
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get("https://umami.motocamp.de/script.js")
return Response(content=r.content, media_type="application/javascript",
headers={"Cache-Control": "public, max-age=86400"})
@app.post("/stats/api/send")
async def umami_send_proxy(request: Request):
body = await request.body()
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
"https://umami.motocamp.de/api/send",
content=body,
headers={"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent", "")},
)
return Response(content=r.content, status_code=r.status_code,
media_type="application/json")
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")

View file

@ -92,6 +92,59 @@ CATEGORIES = [
("gold", 10, "Wiki-Fotograf"),
],
},
{
"id": "wetter_tapfer",
"name": "Wetter-Tapferkeit",
"emoji": "⛈️",
"metrik": "wetter_tapfer_score",
"einheit": " Eintrag/Einträge",
"stufen": [
("bronze", 1, "Regentrotzdem"),
("silber", 5, "Wettertrotzer"),
("gold", 15, "Allwetter-Held"),
("platin", 30, "Hunde-Wetterheld"),
],
},
{
"id": "jahreszeiten",
"name": "Jahreszeiten-Erkunder",
"emoji": "🍃",
"metrik": "jahreszeiten_score",
"einheit": " Jahreszeit(en)",
"stufen": [
("bronze", 1, "Frühlings-Erkunder"),
("silber", 2, "Sommer-Genießer"),
("gold", 3, "Herbst-Schnüffler"),
("platin", 4, "Alle-Jahreszeiten"),
],
},
{
"id": "schnee_held",
"name": "Schneeheld",
"emoji": "❄️",
"metrik": "schnee_eintraege",
"einheit": " Eintrag/Einträge",
"stufen": [
("bronze", 1, "Erster Schnee"),
("silber", 5, "Schneehund"),
("gold", 15, "Schneeheld"),
("platin", 30, "Schneewolf"),
],
},
{
"id": "km_lebenswerk",
"name": "Kilometer-Lebenswerk",
"emoji": "🐾",
"metrik": "gesamt_km_lebenswerk",
"einheit": " km",
"icon": "path",
"stufen": [
("bronze", 100, "100-km-Club"),
("silber", 500, "500-km-Wanderer"),
("gold", 1000, "Tausend-km-Held"),
("platin", 5000, "Ultraläufer"),
],
},
]
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
@ -150,12 +203,48 @@ def check_and_award(user_id: int, conn):
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
# Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter
wetter_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.user_id = ?
AND d.weather_json IS NOT NULL
AND (
CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
)
""", (user_id,)).fetchone()
# Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen
jahreszeiten_row = conn.execute("""
SELECT
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
AS jahreszeiten_score
FROM (SELECT 1)
""", (user_id, user_id, user_id, user_id)).fetchone()
# Schnee: Diary-Einträge bei Schnee (weathercode 71-77)
schnee_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary
WHERE user_id = ?
AND weather_json IS NOT NULL
AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
""", (user_id,)).fetchone()
metrics = {
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
"wiki_fotos": stats["wiki_fotos"] if stats else 0,
"wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
"jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
"schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
"gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
}
earned = {r["badge_id"] for r in
@ -211,6 +300,38 @@ async def my_achievements(user=Depends(get_current_user)):
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
).fetchone()
# Wetter-Tapferkeit
wetter_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.user_id = ?
AND d.weather_json IS NOT NULL
AND (
CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
)
""", (uid,)).fetchone()
# Jahreszeiten
jahreszeiten_row = conn.execute("""
SELECT
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
AS jahreszeiten_score
FROM (SELECT 1)
""", (uid, uid, uid, uid)).fetchone()
# Schnee-Einträge
schnee_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary
WHERE user_id = ?
AND weather_json IS NOT NULL
AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
""", (uid,)).fetchone()
earned_rows = conn.execute(
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
).fetchall()
@ -235,6 +356,10 @@ async def my_achievements(user=Depends(get_current_user)):
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
"wiki_fotos": stats["wiki_fotos"] if stats else 0,
"wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
"jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
"schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
"gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
}
# Kategorien mit aktuellem Tier + Fortschritt aufbauen

View file

@ -9,7 +9,7 @@ from auth import get_current_user, require_admin
import ki as KI
import httpx
import weather as weather_mod
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
from timeutils import safe_client_time
logger = logging.getLogger(__name__)
@ -30,6 +30,7 @@ class DiaryCreate(BaseModel):
location_name: Optional[str] = None
is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
class DiaryUpdate(BaseModel):
@ -350,6 +351,19 @@ async def create_diary(dog_id: int, data: DiaryCreate,
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
elif data.weather_json:
# Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern
try:
json.loads(data.weather_json) # Validierung
with db() as conn:
conn.execute(
"UPDATE diary SET weather_json=? WHERE id=?",
(data.weather_json, entry_id)
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
except Exception as exc:
logger.warning("Client-weather_json ungültig: %s", exc)
return _entry_dict(entry, dogs_map, media_map)
@ -692,10 +706,12 @@ async def upload_media(dog_id: int, entry_id: int,
media_url = f"/media/diary/{filename}"
# EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
# Bildmaße + EXIF-GPS (nur bei Bilddateien)
exif_gps = None
img_size = None
if media_type == "image":
exif_gps = extract_gps_from_exif(raw_data)
img_size = get_image_size(raw_data)
with db() as conn:
# sort_order = nächste freie Position
@ -706,8 +722,9 @@ async def upload_media(dog_id: int, entry_id: int,
# Erstes Item eines Eintrags wird automatisch Cover
is_cover = 1 if max_order == -1 else 0
conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
(entry_id, media_url, media_type, max_order + 1, is_cover)
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)",
(entry_id, media_url, media_type, max_order + 1, is_cover,
img_size[0] if img_size else None, img_size[1] if img_size else None)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",

View file

@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Hund nicht gefunden.")
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
# Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
photos = conn.execute(
"""SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id
WHERE d.dog_id=? AND dm.media_type='image'
ORDER BY d.datum DESC LIMIT 100""",
AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
# Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
if not photos:
photos = conn.execute(
"""SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id
WHERE d.dog_id=? AND dm.media_type='image'
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
random_photo = None
if photos:
import datetime as _dt2
day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
chosen_url = photos[day_num % len(photos)]["url"]
tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
chosen_url = photos[tick % len(photos)]["url"]
random_photo = {
"url": chosen_url,
"preview_url": preview_url_from(chosen_url),
@ -294,6 +305,463 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
}
@router.get("/{dog_id}/wrapped")
async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)):
"""Jahresrückblick ('Wrapped') für einen Hund."""
import json as _json
from datetime import date as _date
if year is None:
year = _date.today().year
with db() as conn:
dog = conn.execute(
"SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?",
(dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
# km gelaufen (eigene Routen des Users)
gesamt_km_row = conn.execute(
"SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
"WHERE user_id=? AND strftime('%Y', created_at)=?",
(user["id"], str(year))
).fetchone()
gesamt_km = gesamt_km_row["km"] or 0.0
# Gassi-Tage (Distinct Datum in Diary)
gassi_tage = conn.execute(
"SELECT COUNT(DISTINCT datum) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=?",
(dog_id, str(year))
).fetchone()["n"]
# Gesamte Einträge
eintraege_gesamt = conn.execute(
"SELECT COUNT(*) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=?",
(dog_id, str(year))
).fetchone()["n"]
# Fotos gesamt
fotos_gesamt = conn.execute(
"SELECT COUNT(*) AS n FROM diary_media dm "
"JOIN diary d ON d.id=dm.diary_id "
"WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'",
(dog_id, str(year))
).fetchone()["n"]
# Beste Route (längste distanz)
beste_route_row = conn.execute(
"SELECT MAX(distanz_km) AS km FROM routes "
"WHERE user_id=? AND strftime('%Y', created_at)=?",
(user["id"], str(year))
).fetchone()
beste_route = beste_route_row["km"] or 0.0
# Lieblingsmonat (meiste diary-Einträge)
monat_rows = conn.execute(
"SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=? "
"GROUP BY monat ORDER BY n DESC LIMIT 1",
(dog_id, str(year))
).fetchone()
lieblings_monat = None
if monat_rows:
_MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
try:
lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1]
except Exception:
pass
# Lieblingsaktivität (häufigster typ)
typ_row = conn.execute(
"SELECT typ, COUNT(*) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=? "
"GROUP BY typ ORDER BY n DESC LIMIT 1",
(dog_id, str(year))
).fetchone()
lieblings_aktivitaet = typ_row["typ"] if typ_row else None
# Training-Sessions
training_sessions = conn.execute(
"SELECT COUNT(*) AS n FROM training_sessions "
"WHERE dog_id=? AND strftime('%Y', created_at)=?",
(dog_id, str(year))
).fetchone()["n"]
# Gesundheits-Einträge
gesundheit_eintraege = conn.execute(
"SELECT COUNT(*) AS n FROM health "
"WHERE dog_id=? AND strftime('%Y', datum)=?",
(dog_id, str(year))
).fetchone()["n"]
# Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json
wetter_kalt = 0
wetter_warm = 0
wetter_rows = conn.execute(
"SELECT weather_json FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL",
(dog_id, str(year))
).fetchall()
for wr in wetter_rows:
try:
wj = _json.loads(wr["weather_json"])
temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
if temp is not None:
if float(temp) < 5:
wetter_kalt += 1
elif float(temp) > 25:
wetter_warm += 1
except Exception:
pass
return {
"dog_id": dog_id,
"dog_name": dog["name"],
"year": year,
"gesamt_km": gesamt_km,
"gassi_tage": gassi_tage,
"eintraege_gesamt": eintraege_gesamt,
"fotos_gesamt": fotos_gesamt,
"beste_route": beste_route,
"lieblings_monat": lieblings_monat,
"lieblings_aktivitaet": lieblings_aktivitaet,
"training_sessions": training_sessions,
"gesundheit_eintraege": gesundheit_eintraege,
"wetter_kalt": wetter_kalt,
"wetter_warm": wetter_warm,
}
@router.get("/{dog_id}/buch")
async def get_hunde_buch(
dog_id: int,
jahr: int = None,
limit: int = 50,
nur_fotos: bool = False,
nur_meilensteine: bool = False,
user=Depends(get_current_user),
):
"""Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege."""
import json as _json
from datetime import date as _date
from fastapi.responses import HTMLResponse
from html import escape as _esc
with db() as conn:
dog = conn.execute(
"SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?",
(dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
dog = dict(dog)
# --- Eintraege laden ---
conditions = ["(d.dog_id=? OR dd.dog_id=?)"]
params: list = [dog_id, dog_id]
if jahr:
conditions.append("strftime('%Y', d.datum) = ?")
params.append(str(jahr))
if nur_meilensteine:
conditions.append("d.is_milestone = 1")
where = " AND ".join(conditions)
rows = conn.execute(
f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags,
d.gps_lat, d.gps_lon, d.location_name, d.weather_json,
d.is_milestone,
(SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id AND dm.media_type='image'
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE {where}
AND d.datum IS NOT NULL
ORDER BY d.datum ASC""",
params
).fetchall()
rows = [dict(r) for r in rows]
# Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus
if nur_fotos:
rows = [r for r in rows if r.get("cover_url")]
else:
# Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit
with_photo = [r for r in rows if r.get("cover_url")]
milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")]
rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")]
rows = with_photo + milestones + rest
rows.sort(key=lambda r: r["datum"] or "")
rows = rows[:limit]
# --- Hund-Alter berechnen ---
alter_str = ""
if dog.get("geburtstag"):
try:
geb = _date.fromisoformat(dog["geburtstag"])
heute = _date.today()
jahre = (heute - geb).days // 365
alter_str = f"{jahre} Jahre"
except Exception:
pass
# --- HTML bauen ---
dog_name = _esc(dog["name"] or "Mein Hund")
rasse_str = _esc(dog.get("rasse") or "")
jahr_str = str(jahr) if jahr else "Alle Jahre"
foto_url = dog.get("foto_url") or ""
cover_img = (
f'<img src="{_esc(foto_url)}" alt="{dog_name}" '
f'style="border-radius:50%;width:200px;height:200px;object-fit:cover;'
f'border:4px solid #b97c2a;margin-bottom:24px;" onerror="this.style.display=\'none\'">'
if foto_url else
f'<div style="width:200px;height:200px;border-radius:50%;background:#f0e8d8;'
f'display:flex;align-items:center;justify-content:center;font-size:5rem;'
f'margin:0 auto 24px">&#x1F43E;</div>'
)
subtitle_parts = [p for p in [rasse_str, alter_str] if p]
subtitle = " &middot; ".join(subtitle_parts)
_MONATE = ["Januar","Februar","März","April","Mai","Juni",
"Juli","August","September","Oktober","November","Dezember"]
def _fmt_datum(iso: str) -> str:
try:
d = _date.fromisoformat(iso)
return f"{d.day}. {_MONATE[d.month - 1]} {d.year}"
except Exception:
return iso or ""
def _wetter_chip(wj_str: str) -> str:
if not wj_str:
return ""
try:
wj = _json.loads(wj_str)
temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
if temp is None:
return ""
temp_i = int(float(temp))
emoji = "&#x2600;&#xFE0F;" if temp_i > 20 else ("&#x1F327;&#xFE0F;" if temp_i < 10 else "&#x26C5;")
return f'<span class="chip">{emoji} {temp_i}&deg;C</span>'
except Exception:
return ""
entries_html = ""
for e in rows:
milestone_class = "milestone" if e.get("is_milestone") else ""
datum_fmt = _fmt_datum(e.get("datum") or "")
titel = _esc(e.get("titel") or "")
text_raw = e.get("text") or ""
text = _esc(text_raw).replace("\n", "<br>")
wetter = _wetter_chip(e.get("weather_json") or "")
loc = _esc(e.get("location_name") or "")
cover = e.get("cover_url") or ""
foto_html = ""
if cover:
foto_html = (
f'<div class="entry-photo">'
f'<img src="{_esc(cover)}" alt="" loading="lazy">'
f'</div>'
)
loc_html = f'<span class="chip">&#x1F4CD; {loc}</span>' if loc else ""
chips_html = f'<div class="chips">{wetter}{loc_html}</div>' if (wetter or loc_html) else ""
titel_html = f'<div class="entry-title">{titel}</div>' if titel else ""
text_html = f'<div class="entry-text">{text}</div>' if text_raw else ""
entries_html += f"""
<div class="entry {milestone_class}">
{foto_html}
<div class="entry-date">{datum_fmt}</div>
{titel_html}
{text_html}
{chips_html}
</div>
"""
anzahl = len(rows)
html_page = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Buch &mdash; {dog_name}</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; }}
body {{
font-family: Georgia, 'Times New Roman', serif;
max-width: 800px;
margin: 0 auto;
padding: 0 20px 60px;
color: #2c2c2c;
background: #fff;
line-height: 1.6;
}}
/* Druck-Button */
.print-btn {{
position: fixed;
top: 20px;
right: 20px;
background: #b97c2a;
color: #fff;
border: none;
border-radius: 50px;
padding: 12px 24px;
font-size: 1rem;
font-family: system-ui, sans-serif;
cursor: pointer;
box-shadow: 0 4px 16px rgba(185,124,42,0.35);
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
}}
.print-btn:hover {{ background: #a06820; }}
/* Titelseite */
.cover {{
text-align: center;
padding: 80px 40px 100px;
min-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}}
.cover h1 {{
font-size: 3em;
color: #b97c2a;
margin: 0 0 12px;
line-height: 1.2;
}}
.cover .subtitle {{
font-size: 1.15em;
color: #666;
margin-bottom: 8px;
}}
.cover .year-label {{
font-size: 1em;
color: #aaa;
margin-bottom: 32px;
}}
.cover .stat-line {{
font-size: 0.95em;
color: #888;
font-family: system-ui, sans-serif;
margin-top: 24px;
}}
/* Eintraege */
.entry {{
padding: 32px 0;
border-bottom: 1px solid #e8e0d0;
break-inside: avoid;
}}
.entry:last-child {{ border-bottom: none; }}
.entry-photo {{ margin-bottom: 20px; }}
.entry-photo img {{
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 10px;
display: block;
}}
.entry-date {{
color: #999;
font-size: 0.85em;
font-family: system-ui, sans-serif;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}}
.entry-title {{
font-size: 1.45em;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 10px;
line-height: 1.3;
}}
.entry-text {{
font-size: 1em;
line-height: 1.75;
color: #333;
}}
/* Meilenstein */
.milestone {{
border-left: 4px solid #b97c2a;
padding-left: 24px;
}}
.milestone .entry-title::before {{
content: "\\2605 ";
color: #b97c2a;
}}
/* Chips */
.chips {{
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
.chip {{
background: #f5f0e8;
border-radius: 50px;
padding: 4px 12px;
font-size: 0.82em;
font-family: system-ui, sans-serif;
color: #666;
}}
/* Drucken */
@media print {{
.print-btn {{ display: none !important; }}
.cover {{ page-break-after: always; }}
.entry {{ break-inside: avoid; }}
body {{ padding: 0; }}
@page {{ margin: 2cm; }}
}}
</style>
</head>
<body>
<button class="print-btn" onclick="window.print()">
&#x1F5A8; Drucken / Als PDF speichern
</button>
<div class="cover">
{cover_img}
<h1>{dog_name}</h1>
{'<div class="subtitle">' + subtitle + '</div>' if subtitle else ''}
<div class="year-label">{jahr_str}</div>
<div class="stat-line">{anzahl} Eintr&auml;ge</div>
</div>
{entries_html}
</body>
</html>"""
return HTMLResponse(content=html_page)
@router.get("/{dog_id}")
async def get_dog(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
@ -622,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
}
# ------------------------------------------------------------------
# LEBENS-TIMELINE
# ------------------------------------------------------------------
@router.get("/{dog_id}/timeline")
async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
"""Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen."""
import json as _json
with db() as conn:
dog = conn.execute(
"SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?",
(dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
events = []
with db() as conn:
# --- Tagebuch ---
diary_rows = conn.execute(
"""SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone,
dm.url AS foto_url
FROM diary d
LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0
WHERE d.dog_id=?
ORDER BY d.datum ASC, d.id ASC""",
(dog_id,)
).fetchall()
for i, r in enumerate(diary_rows):
events.append({
"datum": r["datum"],
"kategorie": "tagebuch",
"titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()),
"typ": r["typ"],
"is_first": i == 0,
"is_milestone": bool(r["is_milestone"]),
"foto_url": r["foto_url"],
"ref_id": r["id"],
})
# --- Gesundheit ---
health_rows = conn.execute(
"""SELECT id, datum, bezeichnung, typ
FROM health
WHERE dog_id=?
ORDER BY datum ASC, id ASC""",
(dog_id,)
).fetchall()
typ_seen = {}
for r in health_rows:
t = r["typ"]
is_first = t not in typ_seen
if is_first:
typ_seen[t] = True
events.append({
"datum": r["datum"],
"kategorie": "gesundheit",
"titel": r["bezeichnung"],
"typ": t,
"is_first": is_first,
"is_milestone": False,
"foto_url": None,
"ref_id": r["id"],
})
# --- Training-Sessions ---
ts_rows = conn.execute(
"""SELECT id, datum, exercise_name, erfolgsquote, ist_top
FROM training_sessions
WHERE dog_id=? AND user_id=?
ORDER BY datum ASC, id ASC""",
(dog_id, user["id"])
).fetchall()
ts_first = True
ts_best = None
ts_best_score = -1
for r in ts_rows:
if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score:
ts_best_score = r["erfolgsquote"]
ts_best = r
for i, r in enumerate(ts_rows):
is_first = (i == 0)
is_best = ts_best and r["id"] == ts_best["id"] and i > 0
events.append({
"datum": r["datum"],
"kategorie": "training",
"titel": r["exercise_name"],
"typ": "training",
"is_first": is_first,
"is_milestone": bool(r["ist_top"]) or is_best,
"foto_url": None,
"ref_id": r["id"],
})
# --- Routen ---
route_rows = conn.execute(
"""SELECT id, name, distanz_km,
date(created_at) AS datum
FROM routes
WHERE user_id=?
ORDER BY created_at ASC""",
(user["id"],)
).fetchall()
route_first = True
route_longest = None
route_max_km = -1
for r in route_rows:
km = r["distanz_km"] or 0
if km > route_max_km:
route_max_km = km
route_longest = r
for i, r in enumerate(route_rows):
is_first = (i == 0)
is_longest = route_longest and r["id"] == route_longest["id"] and i > 0
events.append({
"datum": r["datum"],
"kategorie": "route",
"titel": r["name"],
"typ": "route",
"is_first": is_first,
"is_milestone": is_longest,
"foto_url": None,
"ref_id": r["id"],
"distanz_km": r["distanz_km"],
})
# Geburtstag des Hundes als erster Eintrag
if dog["geburtstag"]:
events.append({
"datum": dog["geburtstag"],
"kategorie": "meilenstein",
"titel": f"{dog['name']} wird geboren",
"typ": "geburtstag",
"is_first": True,
"is_milestone": True,
"foto_url": None,
"ref_id": None,
})
# Chronologisch sortieren
events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"]))
return {
"dog_name": dog["name"],
"geburtstag": dog["geburtstag"],
"events": events,
}

View file

@ -0,0 +1,145 @@
"""BAN YARO — Ernährungs-Routes"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
import ki as ki_module
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class FutterProfilUpdate(BaseModel):
futter_typ: Optional[str] = None # trocken|nass|barf|mix
marke: Optional[str] = None
kcal_tag: Optional[int] = None
portionen: Optional[int] = None
notizen: Optional[str] = None
class KiBeratungRequest(BaseModel):
frage: str
dog_name: Optional[str] = None
rasse: Optional[str] = None
alter: Optional[str] = None
gewicht: Optional[float] = None
aktiv: Optional[bool] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Zugriffsprüfung
# ------------------------------------------------------------------
def _check_dog_access(conn, dog_id: int, user_id: int):
row = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not row:
raise HTTPException(404, "Hund nicht gefunden.")
# ------------------------------------------------------------------
# GET /dogs/{dog_id}/ernaehrung
# ------------------------------------------------------------------
@router.get("/{dog_id}/ernaehrung")
async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
row = conn.execute(
"SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
).fetchone()
if not row:
return {}
return dict(row)
# ------------------------------------------------------------------
# PUT /dogs/{dog_id}/ernaehrung
# ------------------------------------------------------------------
@router.put("/{dog_id}/ernaehrung")
async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
existing = conn.execute(
"SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,)
).fetchone()
if existing:
conn.execute("""
UPDATE futter_profil
SET futter_typ=COALESCE(?, futter_typ),
marke=COALESCE(?, marke),
kcal_tag=COALESCE(?, kcal_tag),
portionen=COALESCE(?, portionen),
notizen=COALESCE(?, notizen),
updated_at=datetime('now')
WHERE dog_id=?
""", (body.futter_typ, body.marke, body.kcal_tag,
body.portionen, body.notizen, dog_id))
else:
conn.execute("""
INSERT INTO futter_profil
(dog_id, futter_typ, marke, kcal_tag, portionen, notizen)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, body.futter_typ, body.marke, body.kcal_tag,
body.portionen or 2, body.notizen))
row = conn.execute(
"SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /dogs/{dog_id}/ernaehrung/ki-beratung
# ------------------------------------------------------------------
@router.post("/{dog_id}/ernaehrung/ki-beratung")
async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
request: Request,
user=Depends(get_current_user)):
if not body.frage or len(body.frage.strip()) < 3:
raise HTTPException(400, "Bitte stelle eine Frage.")
if len(body.frage) > 800:
raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).")
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
dog_name = body.dog_name or "unbekannt"
rasse = body.rasse or "unbekannt"
alter = body.alter or "unbekannt"
gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt"
aktiv_str = "aktiv" if body.aktiv else "normal aktiv"
system = (
"Du bist Ernährungsberater für Hunde. "
"Antworte immer auf Deutsch, kurz und praktisch. "
"Keine unnötigen Füllsätze. "
"Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. "
"Stelle keine medizinischen Diagnosen."
)
prompt = (
f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, "
f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n"
f"Frage: {body.frage.strip()}\n\n"
"Antworte konkret und praktisch, maximal 200 Wörter."
)
try:
antwort = await ki_module.complete(
prompt=prompt,
system=system,
max_tokens=500,
requires_premium=False,
user_id=user["id"],
)
return {"antwort": antwort}
except ki_module.KIUnavailableError as e:
raise HTTPException(503, str(e))
except Exception:
raise HTTPException(500, "KI momentan nicht verfügbar.")

View file

@ -279,8 +279,9 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
'giftkoeder', # Giftköder (exklusiv, kein Kombi)
'kotbeutel', # Kotbeutelspender
'bank', # Sitzbank
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
@ -289,7 +290,8 @@ ALLOWED_TYPES = {
@router.post('/user-poi')
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
if body.type not in ALLOWED_TYPES:
types = [t.strip() for t in body.type.split(',') if t.strip()]
if not types or any(t not in ALLOWED_TYPES for t in types):
raise HTTPException(400, 'Ungültiger Typ')
with db() as conn:
row = conn.execute("""

View file

@ -26,6 +26,7 @@ class ProfileUpdate(BaseModel):
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None
def _load_user(user_id: int) -> dict:
@ -113,3 +114,28 @@ async def upload_avatar(
)
return {"avatar_url": avatar_url}
# ----------------------------------------------------------
# GET /profile/world-config — Welten-Chip-Konfiguration laden
# PUT /profile/world-config — Welten-Chip-Konfiguration speichern
# ----------------------------------------------------------
import json as _json
@router.get('/world-config')
async def get_world_config(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone()
cfg = row['world_config'] if row and row['world_config'] else None
return {"config": _json.loads(cfg) if cfg else None}
class WorldConfigIn(BaseModel):
config: dict
@router.put('/world-config')
async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
with db() as conn:
conn.execute("UPDATE users SET world_config=? WHERE id=?",
(_json.dumps(body.config), user['id']))
return {"status": "ok"}

View file

@ -27,6 +27,14 @@ class TierarztCreate(BaseModel):
osm_id: Optional[str] = None
class BewertungCreate(BaseModel):
gesamt: int
wartezeit: Optional[int] = None
freundlichkeit: Optional[int] = None
kompetenz: Optional[int] = None
text: Optional[str] = None
class TierarztUpdate(BaseModel):
name: Optional[str] = None
strasse: Optional[str] = None
@ -220,3 +228,109 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate,
)
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# BEWERTUNGEN
# ------------------------------------------------------------------
def _refresh_vet_rating(conn, tierarzt_id: int):
"""Aktualisiert avg_rating und anz_bewertungen in tieraerzte."""
row = conn.execute(
"""SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg
FROM tierarzt_bewertungen WHERE tierarzt_id=?""",
(tierarzt_id,)
).fetchone()
n = row["n"] or 0
avg = row["avg"] or 0.0
conn.execute(
"UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?",
(round(avg, 1), n, tierarzt_id)
)
@router.post("/{tierarzt_id}/bewertung", status_code=201)
async def create_bewertung(tierarzt_id: int, data: BewertungCreate,
user=Depends(get_current_user)):
"""Bewertung abgeben (1×pro User+Tierarzt, UPSERT)."""
if not (1 <= data.gesamt <= 5):
raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.")
for field in ("wartezeit", "freundlichkeit", "kompetenz"):
val = getattr(data, field)
if val is not None and not (1 <= val <= 5):
raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.")
text = (data.text or "").strip()[:500] or None
with db() as conn:
vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
if not vet:
raise HTTPException(404, "Tierarzt nicht gefunden.")
conn.execute(
"""INSERT INTO tierarzt_bewertungen
(tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET
gesamt=excluded.gesamt,
wartezeit=excluded.wartezeit,
freundlichkeit=excluded.freundlichkeit,
kompetenz=excluded.kompetenz,
text=excluded.text,
created_at=datetime('now')""",
(tierarzt_id, user["id"], data.gesamt, data.wartezeit,
data.freundlichkeit, data.kompetenz, text)
)
_refresh_vet_rating(conn, tierarzt_id)
row = conn.execute(
"SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)
).fetchone()
return dict(row)
@router.get("/{tierarzt_id}/bewertungen")
async def list_bewertungen(tierarzt_id: int):
"""Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte."""
with db() as conn:
vet = conn.execute(
"SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?",
(tierarzt_id,)
).fetchone()
if not vet:
raise HTTPException(404, "Tierarzt nicht gefunden.")
# Stern-Verteilung
verteilung = {}
for star in range(1, 6):
r = conn.execute(
"SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?",
(tierarzt_id, star)
).fetchone()
verteilung[str(star)] = r["n"]
# Letzte 5 Kommentare
kommentare = conn.execute(
"""SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at
FROM tierarzt_bewertungen
WHERE tierarzt_id=? AND text IS NOT NULL AND text != ''
ORDER BY created_at DESC LIMIT 5""",
(tierarzt_id,)
).fetchall()
return {
"avg_rating": vet["avg_rating"] or 0,
"anz_bewertungen": vet["anz_bewertungen"] or 0,
"verteilung": verteilung,
"kommentare": [dict(k) for k in kommentare],
}
@router.get("/{tierarzt_id}/meine-bewertung")
async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)):
"""Eigene Bewertung für einen Tierarzt (oder null)."""
with db() as conn:
row = conn.execute(
"SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?",
(tierarzt_id, user["id"])
).fetchone()
return dict(row) if row else None

View file

@ -3,9 +3,11 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
import json
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
from auth import get_current_user
from database import db
router = APIRouter()
@ -31,3 +33,57 @@ async def get_weather_forecast(
return await weather_module.get_forecast(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')
@router.get('/records')
async def weather_records(user=Depends(get_current_user)):
"""Persönliche Wetterrekorde aus diary-Einträgen mit weather_json."""
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT d.datum, d.weather_json, d.titel
FROM diary d
WHERE d.user_id = ? AND d.weather_json IS NOT NULL
ORDER BY d.datum ASC
""", (uid,)).fetchall()
if not rows:
return {"records": None}
entries = []
for r in rows:
try:
w = json.loads(r["weather_json"])
entries.append({
"datum": r["datum"],
"titel": r["titel"],
"temp_c": w.get("temp_c"),
"wind_kmh": w.get("wind_kmh"),
"precip_prob": w.get("precip_prob"),
"desc": w.get("desc", ""),
"weathercode": w.get("weathercode"),
})
except Exception:
pass
if not entries:
return {"records": None}
temps = [e for e in entries if e["temp_c"] is not None]
winds = [e for e in entries if e["wind_kmh"] is not None]
records = {}
if temps:
kaeltester = min(temps, key=lambda e: e["temp_c"])
heissester = max(temps, key=lambda e: e["temp_c"])
records["kaeltester"] = kaeltester
records["heissester"] = heissester
if winds:
stuermischster = max(winds, key=lambda e: e["wind_kmh"])
records["stuermischster"] = stuermischster
regen_count = sum(1 for e in entries if (e.get("precip_prob") or 0) > 60)
records["regen_eintraege"] = regen_count
records["gesamt_eintraege"] = len(entries)
return {"records": records}

View file

@ -1,13 +1,33 @@
"""BAN YARO — Widget-Snapshot Endpoint"""
"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
import json, random
from fastapi import APIRouter, Depends
from datetime import date
from fastapi import APIRouter, Depends, Query
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
@router.get("/quote")
async def daily_quote(kategorie: Optional[str] = Query(None)):
"""Liefert einen deterministischen Tagesspruch (wechselt täglich)."""
day_num = (date.today() - date(2026, 1, 1)).days
with db() as conn:
if kategorie:
rows = conn.execute(
"SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?",
(kategorie,)
).fetchall()
else:
rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall()
if not rows:
return {"quote": None}
q = rows[day_num % len(rows)]
return {"quote": dict(q)}
@router.get("/snapshot")
async def widget_snapshot(user=Depends(get_current_user)):
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""

View file

@ -414,7 +414,7 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
raise HTTPException(404, "Einreichung nicht gefunden.")
rasse = conn.execute(
"SELECT id, external_id, slug FROM wiki_rassen WHERE id=?",
"SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?",
(sub["rasse_id"],)
).fetchone()

View file

@ -156,8 +156,32 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 07:00 Uhr — Goldene Gassi-Stunde
_scheduler.add_job(
_job_golden_gassi_hour,
CronTrigger(hour=7, minute=0),
id="golden_gassi_hour",
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren)
_scheduler.add_job(
_job_anniversary_reminders,
CronTrigger(hour=9, minute=0),
id="anniversary_reminders",
replace_existing=True,
misfire_grace_time=3600,
)
# 1. des Monats 10:00 — Monatlicher Rückblick per Push
_scheduler.add_job(
_job_monthly_recap,
CronTrigger(day=1, hour=10, minute=0),
id="monthly_recap",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -881,6 +905,9 @@ async def _job_status_report():
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
"golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)",
"anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)",
"monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)",
}
job_rows_html = ""
job_rows_txt = ""
@ -1288,3 +1315,329 @@ async def _job_recurring_expenses():
except Exception as e:
logger.error(f"Daueraufträge-Job Fehler: {e}")
_log_job("recurring_expenses", "error", str(e))
# ------------------------------------------------------------------
# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr)
# ------------------------------------------------------------------
async def _job_golden_gassi_hour():
"""
Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1)
das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification.
Score-Logik pro Stunde (max. 10 Punkte):
- Temperatur 1020°C +3
- Temperatur 510°C +1
- Niederschlagswahrsch. <20% +3, <40% +1
- Windgeschwindigkeit <20 km/h +2, <30 km/h +1
- Stunden 0719 Uhr (Tageslicht) +2
Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden).
"""
import httpx
from datetime import date as _date
logger.info("Goldene-Gassi-Stunde Job läuft")
# Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription
with db() as conn:
users = conn.execute("""
SELECT DISTINCT u.id AS user_id,
ps.last_lat, ps.last_lon
FROM users u
JOIN push_subscriptions ps ON ps.user_id = u.id
WHERE u.gassi_stunde_push = 1
""").fetchall()
users = [dict(u) for u in users]
logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.")
if not users:
_log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv")
return
sent_total = 0
for u in users:
lat = u["last_lat"] or 48.1351 # Fallback: München
lon = u["last_lon"] or 11.5820
try:
hourly = await _fetch_hourly_weather(lat, lon)
except Exception as e:
logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}")
continue
if not hourly:
continue
best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly)
if best_score < 3:
# Heute kein gutes Wetterfenster → kein Push
logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})")
continue
hour_end = (best_start + 2) % 24
temp_str = f"{best_temp:.0f}°C" if best_temp is not None else ""
wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else (
f"{best_wind:.0f} km/h Wind" if best_wind is not None else "")
body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00{hour_end:02d}:00 Uhr",
f"· {temp_str}"]
if wind_str:
body_parts.append(f"· {wind_str}")
sent = send_push_to_user(u["user_id"], {
"type": "golden_gassi_hour",
"title": "☀️ Goldene Gassi-Stunde heute!",
"body": " ".join(body_parts),
"data": {"page": "wetter"},
"tag": f"gassi-{_date.today()}",
})
sent_total += sent
logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']}{best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}")
logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.")
_log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User")
# ------------------------------------------------------------------
# JOB: Jahrestags-Erinnerungen (täglich 09:00)
# ------------------------------------------------------------------
async def _job_anniversary_reminders():
"""Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push."""
today = datetime.now(tz=_TZ)
today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr
logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
with db() as conn:
entries = conn.execute("""
SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
(SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
FROM diary d
WHERE strftime('%m-%d', d.datum) = ?
AND d.datum < date('now')
AND d.titel IS NOT NULL
AND d.is_milestone = 0
""", (today_md,)).fetchall()
sent_total = 0
for e in entries:
try:
jahre = today.year - int(e['datum'][:4])
if jahre < 1:
continue
jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren"
send_push_to_user(e['user_id'], {
'type': 'anniversary_reminder',
'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}',
'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund',
'data': {'page': 'diary'},
'tag': f'anniversary-{e["id"]}-{today.year}',
})
sent_total += 1
except Exception as ex:
logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}")
logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.")
_log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen")
# ------------------------------------------------------------------
# JOB: Monatlicher Rückblick (1. des Monats 10:00)
# ------------------------------------------------------------------
async def _job_monthly_recap():
"""Sendet jedem User am 1. des Monats einen Rückblick des Vormonats."""
today = datetime.now(tz=_TZ)
first_this = today.replace(day=1)
last_month_end = first_this - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
year_str = last_month_start.strftime('%Y')
month_str = last_month_start.strftime('%m')
month_label = last_month_start.strftime('%B %Y')
logger.info(f"Monatlicher Rückblick Job läuft für {month_label}")
with db() as conn:
# Alle User mit mindestens einem Hund
users = conn.execute(
"SELECT DISTINCT user_id FROM dogs"
).fetchall()
sent_total = 0
for u in users:
user_id = u["user_id"]
try:
with db() as conn:
# Hunde des Users
dog_rows = conn.execute(
"SELECT id, name FROM dogs WHERE user_id=?", (user_id,)
).fetchall()
if not dog_rows:
continue
dog_ids = [d["id"] for d in dog_rows]
placeholders = ','.join('?' * len(dog_ids))
# km (Routen des Users im Vormonat)
km_row = conn.execute(
"SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
"WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
(user_id, year_str, month_str)
).fetchone()
gesamt_km = km_row["km"] or 0.0
# Tagebucheinträge
eintraege = conn.execute(
f"SELECT COUNT(*) AS n FROM diary "
f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?",
(*dog_ids, year_str, month_str)
).fetchone()["n"]
# Training-Sessions
training = conn.execute(
f"SELECT COUNT(*) AS n FROM training_sessions "
f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
(*dog_ids, year_str, month_str)
).fetchone()["n"]
# Lieblingsfoto (erstes Foto im Vormonat)
foto_row = conn.execute(
f"SELECT dm.url FROM diary_media dm "
f"JOIN diary d ON d.id=dm.diary_id "
f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' "
f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? "
f"ORDER BY d.datum ASC LIMIT 1",
(*dog_ids, year_str, month_str)
).fetchone()
foto_url = foto_row["url"] if foto_row else None
# Nur senden wenn mindestens eine Aktivität vorhanden
if eintraege == 0 and training == 0 and gesamt_km == 0:
continue
dog_name = dog_rows[0]["name"]
parts = []
if gesamt_km > 0:
parts.append(f"{gesamt_km} km gelaufen")
if eintraege > 0:
parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge")
if training > 0:
parts.append(f"{training} Training-Sessions")
body_text = " · ".join(parts)
send_push_to_user(user_id, {
'type': 'monthly_recap',
'title': f'📅 {month_label}: Rückblick für {dog_name}',
'body': body_text,
'data': {'page': 'diary'},
'tag': f'monthly-recap-{year_str}-{month_str}',
})
sent_total += 1
except Exception as ex:
logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}")
logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.")
_log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
import httpx
from datetime import date as _date
today = _date.today().isoformat()
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={lat}&longitude={lon}"
"&hourly=temperature_2m,precipitation_probability,windspeed_10m"
"&timezone=Europe%2FBerlin&forecast_days=1"
)
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(url)
resp.raise_for_status()
raw = resp.json()
hourly = raw.get("hourly", {})
times = hourly.get("time", [])
temps = hourly.get("temperature_2m", [])
precips = hourly.get("precipitation_probability", [])
winds = hourly.get("windspeed_10m", [])
result = []
for i, ts in enumerate(times):
if not ts.startswith(today):
continue
hour = int(ts[11:13])
result.append({
"hour": hour,
"temp": temps[i] if i < len(temps) else None,
"precip": precips[i] if i < len(precips) else None,
"wind": winds[i] if i < len(winds) else None,
})
return result
def _score_hour(h: dict) -> int:
"""Berechnet Gassi-Score für eine einzelne Stunde (010 Punkte)."""
score = 0
temp = h.get("temp")
precip = h.get("precip")
wind = h.get("wind")
hour = h.get("hour", 12)
# Temperatur
if temp is not None:
if 10 <= temp <= 20:
score += 3
elif 5 <= temp < 10 or 20 < temp <= 25:
score += 1
# Niederschlagswahrscheinlichkeit
if precip is not None:
if precip < 20:
score += 3
elif precip < 40:
score += 1
# Wind
if wind is not None:
if wind < 20:
score += 2
elif wind < 30:
score += 1
# Tageslicht (0719 Uhr)
if 7 <= hour <= 19:
score += 2
return score
def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]:
"""
Findet das beste aufeinanderfolgende 2h-Fenster.
Gibt (start_hour, total_score, avg_temp, avg_wind) zurück.
"""
best_start = 8
best_score = -1
best_temp = None
best_wind = None
for i in range(len(hourly) - 1):
h1 = hourly[i]
h2 = hourly[i + 1]
combined = _score_hour(h1) + _score_hour(h2)
if combined > best_score:
best_score = combined
best_start = h1["hour"]
# Durchschnittswerte für Anzeige
temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None]
winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None]
best_temp = sum(temps) / len(temps) if temps else None
best_wind = sum(winds) / len(winds) if winds else None
return best_start, best_score, best_temp, best_wind

View file

@ -5571,6 +5571,139 @@ html.modal-open {
border-radius: 0;
}
/* ── Wiki Gallery ────────────────────────────────────────── */
.wiki-gallery-wrap {
position: relative;
margin-bottom: var(--space-3);
}
.wiki-gallery-main {
width: 100%;
height: 240px;
object-fit: cover;
object-position: center top;
border-radius: var(--radius-lg);
display: block;
}
.wiki-gallery-strip {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding: var(--space-2) 0 0;
scrollbar-width: none;
}
.wiki-gallery-strip::-webkit-scrollbar { display: none; }
.wiki-gallery-thumb {
flex-shrink: 0;
width: 64px; height: 64px;
border-radius: var(--radius-md);
overflow: hidden;
border: 2px solid transparent;
padding: 0;
background: none;
cursor: pointer;
position: relative;
transition: border-color .15s;
}
.wiki-gallery-thumb.active { border-color: var(--c-primary); }
.wiki-gallery-thumb img {
width: 100%; height: 100%; object-fit: cover;
}
.wiki-gallery-thumb-label {
position: absolute;
bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,.55);
color: #fff;
font-size: 8px;
padding: 2px 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.wiki-gallery-expand {
position: absolute;
top: var(--space-2);
right: var(--space-2);
width: 34px; height: 34px;
border-radius: 50%;
background: rgba(0,0,0,.45);
border: none;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
transition: background .15s;
}
.wiki-gallery-expand:hover { background: rgba(0,0,0,.65); }
/* ── Wiki Lightbox ───────────────────────────────────────── */
#wiki-lightbox {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.wlb-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,.88);
backdrop-filter: blur(6px);
}
.wlb-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
max-width: min(92vw, 680px);
width: 100%;
gap: var(--space-2);
}
.wlb-img {
width: 100%;
max-height: 72vh;
object-fit: contain;
border-radius: var(--radius-lg);
}
.wlb-close {
position: absolute;
top: -44px;
right: 0;
background: rgba(255,255,255,.12);
border: none;
color: #fff;
width: 36px; height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.wlb-prev, .wlb-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,.12);
border: none;
color: #fff;
width: 40px; height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
transition: background .15s;
}
.wlb-prev { left: -48px; }
.wlb-next { right: -48px; }
.wlb-prev:hover, .wlb-next:hover { background: rgba(255,255,255,.25); }
.wlb-caption { color: rgba(255,255,255,.75); font-size: var(--text-sm); }
.wlb-counter { color: rgba(255,255,255,.45); font-size: var(--text-xs); }
/* Steckbrief-Grid */
.wiki-steckbrief-grid {
display: grid;
@ -6550,6 +6683,97 @@ html.modal-open {
/* ============================================================
HELP TOOLTIP
============================================================ */
/* ============================================================
PAGE INFO generische Seiten-Hilfe (UI.pageInfo)
============================================================ */
.pinfo-trigger-inline {
width: 26px; height: 26px;
border-radius: 50%;
background: var(--c-surface-2);
border: 1px solid var(--c-border-light);
color: var(--c-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background .15s, color .15s;
}
.pinfo-trigger-inline:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); }
.pinfo-banner {
margin: var(--space-3) var(--space-4) 0;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--c-surface-2);
border-left: 3px solid var(--c-primary);
font-size: var(--text-sm);
}
.pinfo-banner-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; }
.pinfo-banner-title {
flex: 1;
font-weight: var(--weight-semibold);
color: var(--c-text);
}
.pinfo-banner-close {
background: none; border: none; cursor: pointer;
color: var(--c-text-muted); padding: 2px;
}
.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; }
.pinfo-banner-more {
background: none; border: none; cursor: pointer;
color: var(--c-primary);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
padding: 0;
display: flex;
align-items: center;
gap: 4px;
margin-top: var(--space-2);
}
/* MODAL BODY */
.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); }
.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; }
.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); }
.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); }
.pinfo-step {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.pinfo-step-icon {
width: 32px; height: 32px;
border-radius: var(--radius-md);
background: var(--c-primary-subtle, rgba(196,132,58,.12));
color: var(--c-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; }
.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; }
.pinfo-tip {
display: flex;
gap: var(--space-2);
align-items: flex-start;
padding: var(--space-3);
background: rgba(196,132,58,.08);
border-radius: var(--radius-md);
color: var(--c-text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; }
.by-help-btn {
display: inline-flex;
align-items: center;
@ -6954,7 +7178,7 @@ svg.empty-state-icon {
/* FAB */
.exp-fab {
position: fixed;
bottom: calc(var(--nav-height, 64px) + var(--space-4));
bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2));
right: var(--space-4);
z-index: 100;
width: 52px;
@ -7600,10 +7824,28 @@ svg.empty-state-icon {
.wlabel.active { opacity: 1; }
@media (min-width: 768px) {
#world-labels { gap: 48px; font-size: 11px; }
.wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; }
.wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); }
.wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); }
#world-labels {
gap: 40px;
top: calc(env(safe-area-inset-top, 0px) + 18px);
}
.wlabel {
font-size: 13px;
letter-spacing: 0.18em;
opacity: 0.55;
padding: 6px 14px;
border-radius: 20px;
text-shadow: 0 1px 6px rgba(0,0,0,0.7);
transition: opacity 0.18s, background 0.18s;
}
.wlabel:hover {
opacity: 0.85;
background: rgba(255, 255, 255, 0.12);
}
.wlabel.active {
opacity: 1;
background: rgba(255, 255, 255, 0.18);
text-shadow: 0 1px 8px rgba(0,0,0,0.5);
}
}
/* Settings-Button */

View file

@ -9,7 +9,6 @@
<link rel="canonical" href="https://banyaro.app/">
<!-- Preconnect: externe Hosts frühzeitig verbinden -->
<link rel="preconnect" href="https://umami.motocamp.de">
<link rel="preconnect" href="https://tile.openstreetmap.org" crossorigin>
<link rel="dns-prefetch" href="https://tile.openstreetmap.org">
@ -76,6 +75,7 @@
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Ban Yaro">
@ -93,9 +93,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=651">
<link rel="stylesheet" href="/css/layout.css?v=651">
<link rel="stylesheet" href="/css/components.css?v=651">
<link rel="stylesheet" href="/css/design-system.css?v=700">
<link rel="stylesheet" href="/css/layout.css?v=700">
<link rel="stylesheet" href="/css/components.css?v=700">
</head>
<body>
@ -499,6 +499,18 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-ernaehrung">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-personality">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-reise">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
@ -539,9 +551,6 @@
<span class="wlabel" data-w="1">HUND</span>
<span class="wlabel" data-w="2">WELT</span>
</div>
<button id="worlds-settings" aria-label="Einstellungen">
<svg class="ph-icon" style="width:20px;height:20px"><use href="/icons/phosphor.svg#gear"></use></svg>
</button>
<div id="worlds-track">
<div class="world-panel" id="wp-jetzt"><div id="wj-content"></div></div>
<div class="world-panel" id="wp-hund"><div id="wh-content"></div></div>
@ -565,12 +574,12 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=651"></script>
<script src="/js/worlds.js?v=700"></script>
<!-- Feature-Seiten werden lazy geladen -->
<!-- Umami Analytics (self-hosted, cookiefrei, DSGVO-konform) -->
<script defer src="https://umami.motocamp.de/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f"></script>
<script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script>
<!-- Offline-Banner Logik -->

View file

@ -212,6 +212,9 @@ const API = (() => {
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
myFavorite() { return get('/tieraerzte/my-favorite'); },
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); },
meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); },
bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); },
};
// ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -76,6 +76,9 @@ const App = (() => {
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
wetter: { title: 'Wetter', module: null },
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
personality: { title: 'Persönlichkeitstest', module: null },
reise: { title: 'Reise mit Hund', module: null },
};
// ----------------------------------------------------------

View file

@ -868,9 +868,9 @@ window.Page_diary = (() => {
if (e.weather_json) {
try {
const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json;
const temp = w?.temperature_2m ?? w?.temp_c;
const temp = w?.temp_c ?? w?.temperature_2m;
if (temp != null) {
metaParts.push(`<span>${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°</span>`);
metaParts.push(`<span class="diary-meta-weather">${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°</span>`);
}
} catch (_) {}
}
@ -1073,15 +1073,14 @@ window.Page_diary = (() => {
if (entry.weather_json) {
try {
const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json;
const temp = w?.temperature_2m ?? w?.temp_c;
const temp = w?.temp_c ?? w?.temperature_2m;
if (w && temp != null) {
const feels = w.apparent_temperature ?? w.feels_like_c;
const wind = w.wind_speed_10m ?? w.wind_kmh;
const wind = w.wind_kmh ?? w.wind_speed_10m;
const precip = w.precip_prob;
const parts = [
`${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`,
feels != null ? `gefühlt ${Math.round(feels)}°` : null,
wind != null ? `💨 ${Math.round(wind)} km/h` : null,
w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null,
`${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`,
wind != null ? `${Math.round(wind)} km/h Wind` : null,
precip != null ? `${precip}% Regen` : null,
].filter(Boolean).join(' · ');
metaItems.push(`<span class="diary-detail-meta-item">${parts}</span>`);
}
@ -1728,6 +1727,16 @@ window.Page_diary = (() => {
});
await UI.asyncButton(submitBtn, async () => {
// Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort
let _clientWeather = null;
if (!isEdit && _locLat == null) {
try {
const pos = await API.getLocation();
const wd = await API.weather.get(pos.lat, pos.lon);
if (wd && wd.temp_c != null) _clientWeather = JSON.stringify(wd);
} catch (_) { /* GPS oder Wetter nicht verfügbar → kein Problem */ }
}
const payload = {
datum: fd.datum || null,
typ: fd.typ,
@ -1739,6 +1748,7 @@ window.Page_diary = (() => {
gps_lon: _locLon,
location_name: _locName,
client_time: API.clientNow(),
weather_json: _clientWeather,
};
async function _uploadNewFiles(entryId) {

View file

@ -195,9 +195,27 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
Hundepass
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-vcard-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Visitenkarte teilen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-wrapped-btn"
style="background:linear-gradient(135deg,#1a1a2e,#16213e);color:#e8c96e;
border-color:transparent;font-weight:700">
Jahresrückblick ${new Date().getFullYear()}
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-buch-btn"
style="background:linear-gradient(135deg,#5c3a10,#7a4f1a);color:#f5e4c0;
border-color:transparent;font-weight:700">
📖 Hunde-Buch erstellen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-timeline-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#timeline"></use></svg>
Lebens-Timeline 🐾
</button>` : ''}
</div>
</div>
@ -264,6 +282,22 @@ window.Page_dog_profile = (() => {
_showPassportModal(dog);
});
document.getElementById('dp-vcard-btn')?.addEventListener('click', () => {
_showVcardModal(dog);
});
document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => {
_showWrappedModal(dog);
});
document.getElementById('dp-buch-btn')?.addEventListener('click', () => {
_showBuchModal(dog);
});
document.getElementById('dp-timeline-btn')?.addEventListener('click', () => {
_showTimelineModal(dog);
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@ -750,6 +784,138 @@ window.Page_dog_profile = (() => {
// ----------------------------------------------------------
// TEILEN
// ----------------------------------------------------------
// ----------------------------------------------------------
// HUNDE-VISITENKARTE MIT QR-CODE
// ----------------------------------------------------------
function _showVcardModal(dog) {
const passportUrl = `https://banyaro.app/hund/${dog.id}`;
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`;
const user = _appState?.user;
const ownerName = user?.name || '';
const wohnort = user?.wohnort || '';
// Alter errechnen
let alterStr = '';
if (dog.geburtstag) {
const birth = new Date(dog.geburtstag + 'T00:00:00');
const now = new Date();
const years = now.getFullYear() - birth.getFullYear()
- (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
alterStr = years < 1
? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate`
: years === 1 ? '1 Jahr' : `${years} Jahre`;
}
const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · ');
const cardHtml = `
<div id="dp-vcard-canvas" style="
background:linear-gradient(135deg,#0f1a2b 0%,#1a2a45 60%,#0d2137 100%);
border-radius:20px;padding:24px 20px;color:white;
font-family:system-ui,-apple-system,sans-serif;
position:relative;overflow:hidden;max-width:340px;margin:0 auto;
box-shadow:0 8px 32px rgba(0,0,0,0.4)">
<!-- Deko-Kreis -->
<div style="position:absolute;top:-30px;right:-30px;width:120px;height:120px;
border-radius:50%;background:rgba(196,132,58,0.12);pointer-events:none"></div>
<div style="position:absolute;bottom:-40px;left:-20px;width:100px;height:100px;
border-radius:50%;background:rgba(196,132,58,0.07);pointer-events:none"></div>
<!-- Header -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
${dog.foto_url
? `<img src="${_esc(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,0.6);flex-shrink:0">`
: `<div style="width:52px;height:52px;border-radius:50%;background:rgba(196,132,58,0.2);
display:flex;align-items:center;justify-content:center;font-size:1.6rem;
flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`}
<div>
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${_esc(dog.name)}</div>
${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${_esc(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${_esc(wohnort)}</div>` : ''}
</div>
</div>
<!-- Divider -->
<div style="height:1px;background:rgba(255,255,255,0.1);margin-bottom:16px"></div>
<!-- Owner + QR -->
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px">
<div style="flex:1;min-width:0">
${ownerName ? `<div style="font-size:0.7rem;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Besitzer</div>
<div style="font-size:0.9rem;font-weight:600;color:rgba(255,255,255,0.85)">${_esc(ownerName)}</div>` : ''}
<div style="font-size:0.65rem;color:rgba(255,255,255,0.35);margin-top:8px">banyaro.app</div>
</div>
<div style="flex-shrink:0;text-align:center">
<img id="dp-vcard-qr" src="${_esc(qrUrl)}"
style="width:80px;height:80px;border-radius:10px;display:block"
alt="QR-Code">
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35);margin-top:4px">Profil öffnen</div>
</div>
</div>
</div>
`;
UI.modal.open({
title: 'Visitenkarte',
body: `
<div style="margin-bottom:var(--space-4)">${cardHtml}</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);text-align:center;margin-bottom:0">
QR-Code auf NFC-Tag oder Anhänger kleben jeder kann das Profil von ${_esc(dog.name)} sofort öffnen.
</p>
`,
footer: `
<button class="btn btn-secondary" id="dp-vcard-copy-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
Link kopieren
</button>
<button class="btn btn-primary" id="dp-vcard-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
Teilen
</button>
`,
});
// Link kopieren
document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(passportUrl);
UI.toast.success('Link kopiert!');
} catch {
const inp = document.createElement('input');
inp.value = passportUrl;
document.body.appendChild(inp);
inp.select();
document.execCommand('copy');
inp.remove();
UI.toast.success('Link kopiert!');
}
});
// Native Share API
document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => {
if (navigator.share) {
try {
await navigator.share({
title: `${dog.name} auf Ban Yaro`,
text: `Schau dir das Profil von ${dog.name} an!`,
url: passportUrl,
});
} catch {}
} else {
// Fallback: kopieren
try {
await navigator.clipboard.writeText(passportUrl);
UI.toast.success('Link kopiert!');
} catch {
UI.toast.error('Teilen nicht verfügbar.');
}
}
});
}
async function _showShareModal(dog) {
UI.modal.open({
title: `${_esc(dog.name)} teilen`,
@ -970,6 +1136,23 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div class="form-group">
<label class="form-label">
Felltyp
<span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
</label>
<select class="form-control" name="fell_typ">
<option value="" ${!dog?.fell_typ ? 'selected' : ''}> nicht angegeben </option>
<option value="kurz" ${dog?.fell_typ === 'kurz' ? 'selected' : ''}>Kurzhaar (Labrador, Boxer)</option>
<option value="mittel" ${dog?.fell_typ === 'mittel' ? 'selected' : ''}>Mittellang (Spaniel, Husky)</option>
<option value="lang" ${dog?.fell_typ === 'lang' ? 'selected' : ''}>Langhaar (Collie, Berner Senne)</option>
<option value="drahtaar" ${dog?.fell_typ === 'drahtaar' ? 'selected' : ''}>Drahthaar (Terrier, Schnauzer)</option>
<option value="doppel" ${dog?.fell_typ === 'doppel' ? 'selected' : ''}>Doppeltes Unterfell (Husky, Malamute, Samojede)</option>
<option value="nackt" ${dog?.fell_typ === 'nackt' ? 'selected' : ''}>Nackthund (Chinese Crested, Xolo)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">
Bio / Steckbrief
@ -1136,6 +1319,7 @@ window.Page_dog_profile = (() => {
chip_nr: fd.chip_nr || null,
bio: fd.bio || null,
is_public: 'is_public' in fd,
fell_typ: fd.fell_typ || null,
};
let saved;
@ -1786,6 +1970,422 @@ window.Page_dog_profile = (() => {
}
}
// ----------------------------------------------------------
// JAHRESRÜCKBLICK — WRAPPED
// ----------------------------------------------------------
async function _showWrappedModal(dog) {
const year = new Date().getFullYear();
let data = null;
try {
data = await API.get(`/dogs/${dog.id}/wrapped?year=${year}`);
} catch (e) {
UI.toast.error('Rückblick konnte nicht geladen werden.');
return;
}
const name = _esc(data.dog_name);
const km = data.gesamt_km || 0;
const konfetti = km > 100;
const _TYPEN = {
eintrag: 'Tagebuch', gassi: 'Gassi', training: 'Training',
tierarzt: 'Tierarzt', freizeit: 'Freizeit', milestone: 'Meilenstein',
};
const aktivitaet = data.lieblings_aktivitaet
? (_TYPEN[data.lieblings_aktivitaet] || data.lieblings_aktivitaet)
: null;
const stadtpark = km > 0 ? Math.round(km / 1.5) : 0;
const schneeheld = data.wetter_kalt >= 10;
const pfotalalarm = data.wetter_warm >= 10;
const _card = (content) =>
`<div style="min-height:320px;display:flex;flex-direction:column;
align-items:center;justify-content:center;text-align:center;
padding:32px 24px;gap:16px">${content}</div>`;
const cards = [
_card(`
<div style="font-size:3rem">🐾</div>
<div style="font-size:1.6rem;font-weight:800;color:#e8c96e;line-height:1.2">
Dein Jahr mit ${name}
</div>
<div style="font-size:1rem;color:#b8b0a0;font-weight:500">${year} in Zahlen</div>
`),
_card(`
<div style="font-size:2.5rem">👟</div>
<div style="font-size:3rem;font-weight:900;color:#e8c96e">${km} km</div>
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">zusammen gelaufen</div>
${stadtpark > 0 ? `<div style="font-size:0.85rem;color:#888;margin-top:4px">= ${stadtpark}× um den Stadtpark</div>` : ''}
${konfetti ? `<div style="font-size:1.5rem;margin-top:8px">🎉 Über 100 km!</div>` : ''}
`),
_card(`
<div style="font-size:2.5rem">📔</div>
<div style="font-size:3rem;font-weight:900;color:#e8c96e">${data.eintraege_gesamt}</div>
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">Tagebucheinträge</div>
${data.fotos_gesamt > 0 ? `<div style="font-size:1.1rem;color:#a0c890;font-weight:700;margin-top:4px">📷 ${data.fotos_gesamt} Fotos</div>` : ''}
${data.gassi_tage > 0 ? `<div style="font-size:0.9rem;color:#888;margin-top:4px">🐾 ${data.gassi_tage} aktive Tage</div>` : ''}
${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${_esc(data.lieblings_monat)}</div>` : ''}
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${_esc(aktivitaet)}</div>` : ''}
`),
_card(`
<div style="font-size:2rem">🌡</div>
<div style="font-size:1.2rem;font-weight:700;color:#d0c8b8;margin-bottom:8px">Wetter-Tapferkeit</div>
<div style="display:flex;gap:32px;justify-content:center;flex-wrap:wrap">
<div><div style="font-size:2rem"></div>
<div style="font-size:2rem;font-weight:900;color:#8ecfef">${data.wetter_kalt}</div>
<div style="font-size:0.8rem;color:#888">kalte Tage</div></div>
<div><div style="font-size:2rem"></div>
<div style="font-size:2rem;font-weight:900;color:#f0b040">${data.wetter_warm}</div>
<div style="font-size:0.8rem;color:#888">heiße Tage</div></div>
</div>
${schneeheld ? `<div style="margin-top:12px;background:#1a3a5c;border-radius:8px;padding:6px 16px;font-size:0.9rem;font-weight:700;color:#8ecfef">❄️ Schneeheld!</div>` : ''}
${pfotalalarm ? `<div style="margin-top:12px;background:#3a2000;border-radius:8px;padding:6px 16px;font-size:0.9rem;font-weight:700;color:#f0b040">🔥 Pfoten-Alarm!</div>` : ''}
${data.training_sessions > 0 ? `<div style="margin-top:12px;font-size:0.85rem;color:#a0c890">🏋️ ${data.training_sessions} Training-Sessions</div>` : ''}
`),
_card(`
<div style="font-size:2.5rem">🐾</div>
<div style="font-size:1.3rem;font-weight:800;color:#e8c96e">Was für ein Jahr!</div>
<div style="font-size:0.95rem;color:#b8b0a0;line-height:1.5;max-width:280px">
${name} und du ein unschlagbares Team.<br>${year} war unvergesslich.
</div>
<button id="dp-wrapped-copy-btn" style="
margin-top:12px;background:#e8c96e;color:#1a1a2e;font-weight:800;
border:none;border-radius:8px;padding:10px 20px;cursor:pointer;font-size:1rem">
📋 Text kopieren
</button>
`),
];
let currentCard = 0;
const totalCards = cards.length;
const renderDots = () => Array.from({ length: totalCards }, (_, i) =>
`<div style="width:8px;height:8px;border-radius:50%;background:${i === currentCard ? '#e8c96e' : '#444'};transition:background .3s"></div>`
).join('');
const modalEl = document.createElement('div');
modalEl.style.cssText = 'position:fixed;inset:0;z-index:9999;background:#0d0d1a;display:flex;flex-direction:column;overflow:hidden;';
modalEl.innerHTML = `
<div style="display:flex;justify-content:flex-end;padding:16px 20px 0">
<button id="dp-wrapped-close" style="background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:36px;height:36px;font-size:1.2rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center">×</button>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
</div>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
`;
document.body.appendChild(modalEl);
const cardContainer = modalEl.querySelector('#dp-wrapped-card-container');
const dotsEl = modalEl.querySelector('#dp-wrapped-dots');
const prevBtn = modalEl.querySelector('#dp-wrapped-prev');
const nextBtn = modalEl.querySelector('#dp-wrapped-next');
const updateCard = () => {
cardContainer.innerHTML = cards[currentCard];
dotsEl.innerHTML = renderDots();
prevBtn.style.display = currentCard > 0 ? 'flex' : 'none';
nextBtn.style.display = currentCard < totalCards - 1 ? 'flex' : 'none';
if (currentCard === totalCards - 1) {
cardContainer.querySelector('#dp-wrapped-copy-btn')?.addEventListener('click', async () => {
const shareText = `🐾 ${name} & ich — Jahresrückblick ${year}\n`
+ (km > 0 ? `👟 ${km} km gelaufen\n` : '')
+ (data.eintraege_gesamt > 0 ? `📔 ${data.eintraege_gesamt} Tagebucheinträge\n` : '')
+ (data.fotos_gesamt > 0 ? `📷 ${data.fotos_gesamt} Fotos\n` : '')
+ (data.training_sessions > 0 ? `🏋️ ${data.training_sessions} Training-Sessions\n` : '')
+ `\nbanyaro.app`;
try {
await navigator.clipboard.writeText(shareText);
UI.toast.success('Text kopiert!');
} catch {
UI.toast.error('Kopieren fehlgeschlagen.');
}
});
}
};
prevBtn.addEventListener('click', () => { if (currentCard > 0) { currentCard--; updateCard(); } });
nextBtn.addEventListener('click', () => { if (currentCard < totalCards - 1) { currentCard++; updateCard(); } });
modalEl.querySelector('#dp-wrapped-close').addEventListener('click', () => modalEl.remove());
let touchStartX = 0;
modalEl.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; }, { passive: true });
modalEl.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 50) {
if (dx < 0 && currentCard < totalCards - 1) { currentCard++; updateCard(); }
if (dx > 0 && currentCard > 0) { currentCard--; updateCard(); }
}
});
const onKey = e => { if (e.key === 'Escape') { modalEl.remove(); document.removeEventListener('keydown', onKey); } };
document.addEventListener('keydown', onKey);
}
// ----------------------------------------------------------
// HUNDE-BUCH
// ----------------------------------------------------------
function _showBuchModal(dog) {
const currentYear = new Date().getFullYear();
let selectedJahr = String(currentYear);
let nurFotos = false;
let nurMeilensteine = false;
const modalEl = document.createElement('div');
modalEl.style.cssText = `
position:fixed;inset:0;z-index:9999;
background:rgba(0,0,0,0.55);
display:flex;align-items:center;justify-content:center;padding:16px;
`;
const renderModal = () => {
const years = [String(currentYear - 1), String(currentYear), 'alle'];
const yearBtns = years.map(y => {
const active = selectedJahr === y
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
const label = y === 'alle' ? 'Alle' : y;
return `<button onclick="window._buchSetJahr('${y}')" style="
border:1px solid;border-radius:8px;padding:8px 16px;
font-size:0.9rem;cursor:pointer;font-family:inherit;
${active}
">${label}</button>`;
}).join('');
const togStyle = (active) =>
active
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
modalEl.innerHTML = `
<div style="
background:#fff;border-radius:16px;padding:32px 24px;
max-width:420px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.2);
font-family:system-ui,sans-serif;
">
<div style="font-size:1.4rem;font-weight:700;margin-bottom:4px">📖 Hunde-Buch erstellen</div>
<div style="font-size:0.9rem;color:#888;margin-bottom:24px">
Eine druckbare Ansicht der schönsten Einträge.<br>Im Browser als PDF speichern.
</div>
<div style="margin-bottom:20px">
<div style="font-weight:600;margin-bottom:10px;color:#555;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em">Jahrgang</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">${yearBtns}</div>
</div>
<div style="margin-bottom:20px;display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button onclick="window._buchToggleFotos()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurFotos)}
">${nurFotos ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Einträge mit Fotos</span>
</label>
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button onclick="window._buchToggleMeilensteine()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurMeilensteine)}
">${nurMeilensteine ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Meilensteine</span>
</label>
</div>
<div style="display:flex;gap:10px">
<button onclick="window._buchOpen()" style="
flex:1;background:#7a4f1a;color:#f5e4c0;border:none;border-radius:10px;
padding:14px;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit;
">📖 Buch öffnen</button>
<button onclick="window._buchClose()" style="
background:#f0f0f0;color:#555;border:none;border-radius:10px;
padding:14px 18px;font-size:1rem;cursor:pointer;font-family:inherit;
"></button>
</div>
</div>
`;
};
window._buchSetJahr = (y) => { selectedJahr = y; renderModal(); };
window._buchToggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
window._buchToggleMeilensteine = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
window._buchClose = () => {
modalEl.remove();
delete window._buchSetJahr;
delete window._buchToggleFotos;
delete window._buchToggleMeilensteine;
delete window._buchOpen;
delete window._buchClose;
};
window._buchOpen = () => {
const params = new URLSearchParams();
if (selectedJahr !== 'alle') params.set('jahr', selectedJahr);
if (nurFotos) params.set('nur_fotos', 'true');
if (nurMeilensteine) params.set('nur_meilensteine', 'true');
const url = `/api/dogs/${dog.id}/buch?${params.toString()}`;
window.open(url, '_blank');
};
renderModal();
document.body.appendChild(modalEl);
modalEl.addEventListener('click', e => { if (e.target === modalEl) window._buchClose(); });
const onKey = e => {
if (e.key === 'Escape') { window._buchClose(); document.removeEventListener('keydown', onKey); }
};
document.addEventListener('keydown', onKey);
}
// ----------------------------------------------------------
// LEBENS-TIMELINE
// ----------------------------------------------------------
async function _showTimelineModal(dog) {
UI.modal.open({
title: `Lebens-Timeline — ${_esc(dog.name)}`,
body: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
size: 'large',
});
let data;
try {
data = await API.get(`/dogs/${dog.id}/timeline`);
} catch (e) {
const b = document.getElementById('dp-timeline-body');
if (b) b.innerHTML = `<p style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
return;
}
const wrap = document.getElementById('dp-timeline-body');
if (!wrap) return;
const events = data.events || [];
if (!events.length) {
wrap.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-6)">
Noch keine Einträge vorhanden. Beginne dein Tagebuch oder trage Gesundheitsdaten ein.
</p>`;
return;
}
const _KAT = {
meilenstein: { color: '#8b5cf6', icon: 'star', label: 'Meilenstein' },
tagebuch: { color: 'var(--c-primary)', icon: 'book-open', label: 'Tagebuch' },
gesundheit: { color: '#ef4444', icon: 'heartbeat', label: 'Gesundheit' },
training: { color: '#22c55e', icon: 'target', label: 'Training' },
route: { color: '#3b82f6', icon: 'path', label: 'Route' },
};
const _fmtDate = d => {
if (!d) return '';
try {
const p = d.substring(0, 10).split('-');
return `${p[2]}.${p[1]}.${p[0]}`;
} catch { return d; }
};
let lastYear = null;
let html = '<div class="tl-wrap">';
for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) {
html += `<div class="tl-year">${_esc(year)}</div>`;
lastYear = year;
}
const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
const big = ev.is_milestone;
let label = _esc(ev.titel);
if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`;
if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`;
if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`;
if (ev.typ === 'geburtstag') label = `🎂 ${label}`;
const dotSize = big ? '18px' : '12px';
const dotBorder = big ? `3px solid ${kat.color}` : `2px solid ${kat.color}`;
const dotML = big ? '6px' : '9px';
html += `
<div class="tl-item${big ? ' tl-item--big' : ''}">
<div class="tl-dot" style="width:${dotSize};height:${dotSize};
background:${kat.color};border:${dotBorder};
margin-left:${dotML};
box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
<div class="tl-card">
${big && ev.foto_url ? `
<div class="tl-foto" style="background-image:url(${_esc(ev.foto_url)})"></div>` : ''}
<div class="tl-meta">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg>
${_esc(kat.label)}
</span>
<span class="tl-date">${_fmtDate(ev.datum)}</span>
</div>
<div class="tl-title${big ? ' tl-title--big' : ''}">${label}</div>
${ev.distanz_km ? `<div class="tl-sub">${ev.distanz_km} km</div>` : ''}
</div>
</div>`;
}
html += '</div>';
html += `
<style>
.tl-wrap { padding:var(--space-2) 0;position:relative; }
.tl-wrap::before {
content:'';position:absolute;left:15px;top:0;bottom:0;width:2px;
background:var(--c-border);border-radius:1px;
}
.tl-year {
padding:var(--space-2) 0 var(--space-2) 48px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.08em;
}
.tl-item {
display:flex;align-items:flex-start;gap:var(--space-3);
margin-bottom:var(--space-3);position:relative;
}
.tl-dot {
flex-shrink:0;border-radius:50%;margin-top:4px;z-index:1;position:relative;
}
.tl-card {
flex:1;min-width:0;background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);overflow:hidden;
}
.tl-item--big .tl-card { border-left:3px solid var(--c-primary); }
.tl-foto {
width:100%;height:120px;background-size:cover;background-position:center;
border-radius:var(--radius-sm);margin-bottom:var(--space-2);
}
.tl-meta {
display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap;
}
.tl-badge {
display:inline-flex;align-items:center;gap:3px;
padding:2px 8px;border-radius:var(--radius-full);
font-size:var(--text-xs);font-weight:var(--weight-semibold);
}
.tl-date { font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto; }
.tl-title { font-size:var(--text-sm);color:var(--c-text);font-weight:var(--weight-medium); }
.tl-title--big { font-weight:var(--weight-semibold);font-size:var(--text-base); }
.tl-sub { font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px; }
</style>`;
wrap.innerHTML = html;
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -0,0 +1,603 @@
/* ============================================================
BAN YARO Ernährung
Tabs: Kalorien-Rechner | Futter-Guide | Giftliste | KI-Berater
============================================================ */
window.Page_ernaehrung = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'rechner';
let _profil = {};
const TABS = [
{ key: 'rechner', label: 'Kalorien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>' },
{ key: 'guide', label: 'Futter-Guide', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
{ key: 'gift', label: 'Giftliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>' },
{ key: 'ki', label: 'KI-Berater', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>' },
];
// ------------------------------------------------------------------
// Escape helper
// ------------------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ------------------------------------------------------------------
// LIFECYCLE
// ------------------------------------------------------------------
async function init(container, appState, params) {
_container = container;
_appState = appState;
if (params?.tab && TABS.some(t => t.key === params.tab)) {
_activeTab = params.tab;
}
await _render();
}
async function refresh() {
await _render();
}
async function onDogChange() {
_profil = {};
await _render();
}
// ------------------------------------------------------------------
// RENDER
// ------------------------------------------------------------------
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bowl-food"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}
// Profil laden
const dog = _appState.activeDog;
try {
_profil = await API.get(`/dogs/${dog.id}/ernaehrung`);
} catch (_) {
_profil = {};
}
_container.innerHTML = `
<div class="by-tabs" id="ern-tabs"></div>
<div id="ern-tab-content"></div>
`;
_renderTabBar();
_renderTab();
}
// ------------------------------------------------------------------
// TAB-BAR
// ------------------------------------------------------------------
function _renderTabBar() {
const el = _container.querySelector('#ern-tabs');
if (!el) return;
el.innerHTML = TABS.map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>`).join('');
el.querySelectorAll('.by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderTab();
});
});
}
function _renderTab() {
const el = _container.querySelector('#ern-tab-content');
if (!el) return;
switch (_activeTab) {
case 'rechner': _renderRechner(el); break;
case 'guide': _renderGuide(el); break;
case 'gift': _renderGift(el); break;
case 'ki': _renderKi(el); break;
}
}
// ------------------------------------------------------------------
// TAB 1: KALORIEN-RECHNER
// ------------------------------------------------------------------
function _renderRechner(el) {
const dog = _appState.activeDog;
// Auto-Werte aus Hundeprofil
const gewichtDefault = dog?.gewicht || '';
const alterDefault = dog?.alter || '';
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Berechne den täglichen Kalorienbedarf deines Hundes.
</p>
<div class="by-form-group">
<label class="by-label">Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
class="by-input" value="${_esc(gewichtDefault)}" placeholder="z. B. 15">
</div>
<div class="by-form-group">
<label class="by-label">Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
class="by-input" value="${_esc(alterDefault)}" placeholder="z. B. 3">
</div>
<div class="by-form-group">
<label class="by-label">Aktivität</label>
<select id="ern-aktivitaet" class="by-select">
<option value="gering">Gering (Couch-Hund)</option>
<option value="normal" selected>Normal</option>
<option value="aktiv">Aktiv</option>
<option value="sport">Sehr aktiv (Sport)</option>
</select>
</div>
<div class="by-form-group">
<label class="by-label">Kastriert</label>
<div style="display:flex;gap:var(--space-3)">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="ja"> Ja
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="nein" checked> Nein
</label>
</div>
</div>
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>
Berechnen
</button>
<div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div>
<!-- Profil speichern -->
<div id="ern-profil-speichern" style="display:none;margin-top:var(--space-4)">
<h4 style="font-size:var(--text-sm);margin-bottom:var(--space-2)">Profil speichern</h4>
<div style="display:grid;gap:var(--space-3)">
<div class="by-form-group" style="margin:0">
<label class="by-label">Futter-Typ</label>
<select id="ern-prof-typ" class="by-select">
<option value="">-- wählen --</option>
<option value="trocken"${_profil.futter_typ === 'trocken' ? ' selected' : ''}>Trockenfutter</option>
<option value="nass"${_profil.futter_typ === 'nass' ? ' selected' : ''}>Nassfutter</option>
<option value="barf"${_profil.futter_typ === 'barf' ? ' selected' : ''}>BARF</option>
<option value="mix"${_profil.futter_typ === 'mix' ? ' selected' : ''}>Mix</option>
</select>
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Marke / Produkt</label>
<input id="ern-prof-marke" type="text" class="by-input"
value="${_esc(_profil.marke)}" placeholder="z. B. Royal Canin">
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Portionen pro Tag</label>
<input id="ern-prof-portionen" type="number" min="1" max="6"
class="by-input" value="${_profil.portionen || 2}">
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Notizen</label>
<textarea id="ern-prof-notizen" class="by-input" rows="2"
placeholder="Besonderheiten, Allergien...">${_esc(_profil.notizen)}</textarea>
</div>
<button class="btn btn-secondary" id="ern-prof-save-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
Profil speichern
</button>
</div>
</div>
</div>
`;
el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
}
function _berechne(el) {
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const aktivitaet = el.querySelector('#ern-aktivitaet').value;
const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja';
if (!gewicht || gewicht < 0.5) {
UI.toast.warning('Bitte ein gültiges Gewicht eingeben.');
return;
}
const rer = 70 * Math.pow(gewicht, 0.75);
const faktoren = {
gering: { intakt: 1.2, kastriert: 1.0 },
normal: { intakt: 1.6, kastriert: 1.4 },
aktiv: { intakt: 1.8, kastriert: 1.6 },
sport: { intakt: 2.1, kastriert: 1.9 },
};
const kcal = Math.round(rer * faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt']);
// Umrechnung in Futtermengen
const trocken = Math.round(kcal / 3.5); // ~350 kcal/100g
const nass = Math.round(kcal / 0.85); // ~85 kcal/100g
const barf = Math.round(kcal / 1.5); // ~150 kcal/100g
const kcalFormatted = kcal.toLocaleString('de-DE');
const resultEl = el.querySelector('#ern-rechner-result');
resultEl.style.display = '';
resultEl.innerHTML = `
<div style="text-align:center;padding:var(--space-4);
background:var(--c-primary);color:#fff;
border-radius:var(--radius-lg);margin-bottom:var(--space-4)">
<div style="font-size:var(--text-2xl);font-weight:700">ca. ${kcalFormatted} kcal</div>
<div style="font-size:var(--text-sm);opacity:0.85">pro Tag</div>
</div>
<div style="display:grid;gap:var(--space-3)">
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🌾 Trockenfutter</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~350 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${trocken} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
</div>
</div>
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥫 Nassfutter</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~85 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${nass} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
</div>
</div>
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥩 BARF</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
(~150 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${barf} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
= ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
</div>
</div>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
Richtwert nach Nationaler Forschungsratsformel (NRC). Immer den Körperzustand beobachten.
</p>
`;
// Profil-Speichern einblenden und kcal vorbelegen
const profilSection = el.querySelector('#ern-profil-speichern');
profilSection.style.display = '';
// kcal für Speichern merken
profilSection.dataset.kcal = kcal;
el.querySelector('#ern-prof-save-btn').onclick = () => _speichereProfil(el, kcal);
}
async function _speichereProfil(el, kcal) {
const dog = _appState.activeDog;
const futter_typ = el.querySelector('#ern-prof-typ').value || null;
const marke = el.querySelector('#ern-prof-marke').value.trim() || null;
const portionen = parseInt(el.querySelector('#ern-prof-portionen').value) || 2;
const notizen = el.querySelector('#ern-prof-notizen').value.trim() || null;
const btn = el.querySelector('#ern-prof-save-btn');
await UI.asyncButton(btn, async () => {
try {
_profil = await API.put(`/dogs/${dog.id}/ernaehrung`, {
futter_typ, marke, kcal_tag: kcal, portionen, notizen,
});
UI.toast.success('Profil gespeichert.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
}
});
}
// ------------------------------------------------------------------
// TAB 2: FUTTER-GUIDE
// ------------------------------------------------------------------
function _renderGuide(el) {
const cards = [
{
id: 'barf',
emoji: '🥩',
titel: 'BARF (Rohfütterung)',
inhalt: `
<p><strong>Zusammensetzung:</strong> 70 % Muskelfleisch, 10 % rohe Knochen, 10 % Organe, 10 % Gemüse & Obst</p>
<p><strong>Vorteile:</strong> Naturnahste Ernährungsform, glänzendes Fell, weniger Kot, keine Zusatzstoffe</p>
<p><strong>Risiken:</strong> Keimbelastung durch rohes Fleisch, Calcium-Phosphor-Balance muss stimmen, zeitaufwändig und teurer</p>
<p><strong>Tipp:</strong> Niemals BARF und Trockenfutter in derselben Mahlzeit mischen unterschiedliche Verdauungszeiten können zu Problemen führen.</p>
`,
},
{
id: 'nass',
emoji: '🥫',
titel: 'Nassfutter',
inhalt: `
<p><strong>Zusammensetzung:</strong> 7080 % Wasseranteil, meist höherer Fleischanteil als Trockenfutter</p>
<p><strong>Vorteile:</strong> Hunde trinken automatisch mehr (gut für die Niere), schmackhafter, gut für wählerische Hunde</p>
<p><strong>Worauf achten:</strong> Erste Zutat auf der Liste = Fleisch (nicht Tierische Nebenerzeugnisse"), kein Zucker, kein Karamell</p>
<p><strong>Zähne:</strong> Schlechter für die Zahngesundheit als Trockenfutter öfter Zähne putzen oder Kauartikel geben.</p>
`,
},
{
id: 'trocken',
emoji: '🌾',
titel: 'Trockenfutter',
inhalt: `
<p><strong>Zusammensetzung:</strong> 610 % Wasser, ca. 350400 kcal/100 g, konzentrierte Nährstoffe</p>
<p><strong>Gute Zutaten:</strong> Benanntes Fleisch an erster Stelle (Huhn, Lachs), mind. 40 % Tierprotein, kein Getreide als Hauptzutat</p>
<p><strong>Schlechte Zutaten:</strong> „Getreide" als erste Zutat, Zucker, Karamell, Konservierungsstoffe E320 / E321</p>
<p><strong>Wichtig:</strong> Immer frisches Wasser bereitstellen Trockenfutter enthält kaum Feuchtigkeit.</p>
`,
},
];
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Klicke auf eine Karte für Details.
</p>
${cards.map(c => `
<div class="ern-guide-card" data-id="${c.id}"
style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);margin-bottom:var(--space-3);
overflow:hidden;cursor:pointer">
<div class="ern-guide-head"
style="display:flex;align-items:center;justify-content:space-between;
padding:var(--space-3) var(--space-4)">
<span style="font-weight:600;font-size:var(--text-base)">
${c.emoji} ${c.titel}
</span>
<svg class="ph-icon ern-guide-chevron" aria-hidden="true"
style="transition:transform .2s">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</div>
<div class="ern-guide-body"
style="display:none;padding:0 var(--space-4) var(--space-3);
font-size:var(--text-sm);color:var(--c-text-secondary);
line-height:1.6">
${c.inhalt}
</div>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.ern-guide-card').forEach(card => {
card.querySelector('.ern-guide-head').addEventListener('click', () => {
const body = card.querySelector('.ern-guide-body');
const chevron = card.querySelector('.ern-guide-chevron');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
chevron.style.transform = open ? '' : 'rotate(180deg)';
});
});
}
// ------------------------------------------------------------------
// TAB 3: GIFTLISTE
// ------------------------------------------------------------------
function _renderGift(el) {
const items = [
{ emoji: '🍫', name: 'Schokolade', grund: 'Theobromin → Herzrasen, Krämpfe, kann tödlich sein' },
{ emoji: '🍇', name: 'Trauben & Rosinen', grund: 'Nierenversagen — auch kleinste Mengen gefährlich' },
{ emoji: '🧅', name: 'Zwiebeln & Knoblauch', grund: 'Zerstören rote Blutkörperchen → Anämie' },
{ emoji: '🥑', name: 'Avocado', grund: 'Persin → Erbrechen, Durchfall, Atemnot' },
{ emoji: '🌰', name: 'Macadamia-Nüsse', grund: 'Lähmungserscheinungen, Zittern, Erbrechen' },
{ emoji: '🍬', name: 'Xylitol (Süßstoff)', grund: 'Schwere Leberschäden, Unterzucker — oft in Kaugummi' },
{ emoji: '🥛', name: 'Milch & Milchprodukte', grund: 'Laktose-Intoleranz bei vielen Hunden → Durchfall' },
{ emoji: '🦴', name: 'Gekochte Knochen', grund: 'Splitter → innere Verletzungen, Darmverschluss' },
{ emoji: '☕', name: 'Koffein (Kaffee, Tee)', grund: 'Herzrasen, Zittern, Nervensystem' },
{ emoji: '🧂', name: 'Salz', grund: 'Natriumvergiftung → Erbrechen, Krämpfe' },
];
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-4);
font-size:var(--text-sm)">
<strong> Notfall-Tierarzt:</strong> Bei Verdacht auf Vergiftung sofort zum Tierarzt.
Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
</div>
<div style="display:grid;gap:var(--space-2)">
${items.map(item => `
<div style="background:#fff5f5;border:1px solid #fed7d7;
border-radius:var(--radius-md);padding:var(--space-3)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:1.4rem">${item.emoji}</span>
<div>
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.name)}</div>
<div style="font-size:var(--text-xs);color:#c53030">${_esc(item.grund)}</div>
</div>
</div>
</div>
`).join('')}
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-4)">
Diese Liste ist nicht vollständig. Im Zweifel gilt: lieber weglassen.
</p>
</div>
`;
}
// ------------------------------------------------------------------
// TAB 4: KI-FUTTERBERATER
// ------------------------------------------------------------------
function _renderKi(el) {
const dog = _appState.activeDog;
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<div style="background:var(--c-surface-2,var(--c-surface));border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-4);
font-size:var(--text-sm);color:var(--c-text-secondary);
border:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
Der KI-Futterberater beantwortet Ernährungsfragen für
<strong>${_esc(dog?.name || 'deinen Hund')}</strong>.
Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
</div>
<!-- Vorschläge -->
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
${[
'Welches Futter empfiehlst du für meine Rasse?',
'Wie oft soll ich meinen Hund füttern?',
'Ist Getreide im Futter schlecht?',
'Welche Leckerlis sind gesund?',
].map(q => `
<button class="btn btn-sm btn-secondary ern-ki-vorschlag"
data-q="${_esc(q)}"
style="font-size:var(--text-xs)">${_esc(q)}</button>
`).join('')}
</div>
<!-- Chat-Verlauf -->
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
<!-- Eingabe -->
<div style="display:flex;gap:var(--space-2)">
<textarea id="ern-ki-frage" class="by-input" rows="2"
placeholder="Deine Frage zur Ernährung..."
style="flex:1;resize:vertical"></textarea>
<button class="btn btn-primary" id="ern-ki-send-btn"
style="align-self:flex-end">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
</button>
</div>
</div>
`;
// Vorschläge
el.querySelectorAll('.ern-ki-vorschlag').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelector('#ern-ki-frage').value = btn.dataset.q;
el.querySelector('#ern-ki-frage').focus();
});
});
// Senden
el.querySelector('#ern-ki-send-btn').addEventListener('click', () => _kiSenden(el));
el.querySelector('#ern-ki-frage').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) _kiSenden(el);
});
}
async function _kiSenden(el) {
const dog = _appState.activeDog;
const frageEl = el.querySelector('#ern-ki-frage');
const frage = frageEl.value.trim();
if (!frage) {
UI.toast.warning('Bitte eine Frage eingeben.');
return;
}
const chatEl = el.querySelector('#ern-ki-chat');
const sendBtn = el.querySelector('#ern-ki-send-btn');
// Userfrage anzeigen
chatEl.insertAdjacentHTML('beforeend', `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-2)">
<div style="background:var(--c-primary);color:#fff;border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);max-width:80%;font-size:var(--text-sm)">
${_esc(frage)}
</div>
</div>
`);
frageEl.value = '';
// KI-Antwort Placeholder
const placeholderId = `ern-ki-placeholder-${Date.now()}`;
chatEl.insertAdjacentHTML('beforeend', `
<div id="${placeholderId}" style="margin-bottom:var(--space-3)">
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);color:var(--c-text-muted)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#circle-notch"></use></svg>
Denke nach
</div>
</div>
`);
chatEl.scrollTop = chatEl.scrollHeight;
await UI.asyncButton(sendBtn, async () => {
let antwort = '';
try {
const result = await API.post(`/dogs/${dog.id}/ernaehrung/ki-beratung`, {
frage,
dog_name: dog?.name || null,
rasse: dog?.rasse || null,
alter: dog?.alter != null ? String(dog.alter) : null,
gewicht: dog?.gewicht || null,
aktiv: false,
});
antwort = result.antwort || 'Keine Antwort erhalten.';
} catch (err) {
if (err.status === 503) {
antwort = 'Die KI ist momentan nicht verfügbar. Bitte später versuchen.';
} else {
antwort = 'Fehler bei der KI-Anfrage. Bitte später erneut versuchen.';
}
}
const antwortHtml = _esc(antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-1) 0">')
.replace(/\n/g, '<br>');
const placeholder = document.getElementById(placeholderId);
if (placeholder) {
placeholder.innerHTML = `
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);line-height:1.6;max-width:90%">
<p style="margin:0">${antwortHtml}</p>
</div>
`;
}
chatEl.scrollTop = chatEl.scrollHeight;
});
}
// ------------------------------------------------------------------
// PUBLIC API
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -941,14 +941,30 @@ window.Page_health = (() => {
_openNoteModal('health', id, label, null);
});
});
// Praxis öffnen
// Praxis öffnen → Detail-Modal mit Bewertungen
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
el.addEventListener('click', () => {
const id = parseInt(el.dataset.praxisId);
const p = _praxen.find(x => x.id === id);
if (p) _showPraxisDetail(p);
});
});
// Praxis bearbeiten
content.querySelectorAll('[data-action="edit-praxis"]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.praxisId);
const p = _praxen.find(x => x.id === id);
if (p) _showPraxForm(p);
});
});
// Bewertung abgeben
content.querySelectorAll('[data-action="bewerten"]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.praxisId);
const p = _praxen.find(x => x.id === id);
if (p) _showBewertungModal(p);
});
});
// Dokument löschen
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
btn.addEventListener('click', async () => {
@ -1642,6 +1658,14 @@ window.Page_health = (() => {
const renderCard = p => {
const isFav = _favoritVet?.id === p.id || p.is_favorite;
const hasRating = p.anz_bewertungen > 0;
const stars = hasRating ? _renderStarsReadonly(p.avg_rating) : '';
const ratingHtml = hasRating
? `<div style="display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-1);font-size:var(--text-sm)">
${stars}
<span style="color:var(--c-text-secondary)">${p.avg_rating.toFixed(1)} (${p.anz_bewertungen} Bew.)</span>
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">Noch keine Bewertungen</div>`;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
@ -1660,6 +1684,7 @@ window.Page_health = (() => {
<svg class="ph-icon" aria-hidden="true" style="font-size:0.9em"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
</div>` : ''}
${ratingHtml}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? `
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
@ -1671,6 +1696,14 @@ window.Page_health = (() => {
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm btn-secondary"
data-action="bewerten" data-praxis-id="${p.id}"
title="Bewertung abgeben"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Bewerten
</button>
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
@ -1681,6 +1714,13 @@ window.Page_health = (() => {
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
<button class="btn btn-sm btn-secondary"
data-action="edit-praxis" data-praxis-id="${p.id}"
title="Praxis bearbeiten"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>
</div>
</div>
</div>
@ -1716,6 +1756,226 @@ window.Page_health = (() => {
`;
}
// ----------------------------------------------------------
// PRAXEN — Sterne-Hilfs-Funktionen
// ----------------------------------------------------------
/** Rendert 5 Sterne (readonly, filled bis `rating`). */
function _renderStarsReadonly(rating) {
const full = Math.round(rating);
return Array.from({ length: 5 }, (_, i) => {
const filled = i < full;
return `<span aria-hidden="true" style="font-size:1em;color:${filled ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'}">&#9733;</span>`;
}).join('');
}
/** Rendert 5 klickbare Sterne mit data-val. */
function _renderStarsInput(name, current) {
return `<div class="bew-stars" data-name="${name}" role="group" aria-label="Bewertung ${name}"
style="display:flex;gap:2px;cursor:pointer">
${Array.from({ length: 5 }, (_, i) => {
const val = i + 1;
const filled = current >= val;
return `<span class="bew-star" data-val="${val}"
style="font-size:1.6rem;color:${filled ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'};
transition:color .1s">&#9733;</span>`;
}).join('')}
</div>`;
}
// ----------------------------------------------------------
// PRAXEN — Detail-Modal (Bewertungen anzeigen)
// ----------------------------------------------------------
async function _showPraxisDetail(praxis) {
// Erst mit Lade-Spinner öffnen, dann Daten laden
UI.modal.open({
title: _esc(praxis.name),
body: `<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon spin" aria-hidden="true" style="font-size:2rem">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="detail-bewerten-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Jetzt bewerten
</button>`,
});
document.getElementById('detail-bewerten-btn')
?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); });
let data;
try {
data = await API.tieraerzte.bewertungen(praxis.id);
} catch {
UI.modal.open({ title: praxis.name, body: '<p>Bewertungen konnten nicht geladen werden.</p>' });
return;
}
const { avg_rating, anz_bewertungen, verteilung, kommentare } = data;
// Balkendiagramm
const balken = [5, 4, 3, 2, 1].map(s => {
const n = verteilung[String(s)] || 0;
const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0;
return `<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm)">
<span style="min-width:1.2em;text-align:right">${s}</span>
<span aria-hidden="true" style="color:var(--c-warning,#f59e0b);font-size:.9em">&#9733;</span>
<div style="flex:1;height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:var(--c-warning,#f59e0b);border-radius:4px"></div>
</div>
<span style="min-width:2em;color:var(--c-text-secondary)">${n}</span>
</div>`;
}).join('');
const kommentarHtml = kommentare.length
? kommentare.map(k => `
<div style="padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-1)">
${_renderStarsReadonly(k.gesamt)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
${k.created_at ? k.created_at.slice(0, 10) : ''}
</span>
</div>
${k.wartezeit || k.freundlichkeit || k.kompetenz ? `
<div style="display:flex;gap:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">
${k.wartezeit ? `<span>Wartezeit: ${_renderStarsReadonly(k.wartezeit)}</span>` : ''}
${k.freundlichkeit ? `<span>Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}</span>` : ''}
${k.kompetenz ? `<span>Kompetenz: ${_renderStarsReadonly(k.kompetenz)}</span>` : ''}
</div>` : ''}
<p style="margin:0;font-size:var(--text-sm)">${_esc(k.text || '')}</p>
</div>`).join('')
: `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Kommentare.</p>`;
const bewBody = anz_bewertungen === 0
? `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-4) 0">
Noch keine Bewertungen sei der Erste!
</p>`
: `
<div style="display:flex;align-items:center;gap:var(--space-4);margin-bottom:var(--space-4)">
<div style="text-align:center">
<div style="font-size:3rem;font-weight:700;line-height:1">${avg_rating.toFixed(1)}</div>
<div>${_renderStarsReadonly(avg_rating)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}</div>
</div>
<div style="flex:1">${balken}</div>
</div>
<div>${kommentarHtml}</div>`;
// Modal-Body aktualisieren (ohne Modal neu zu öffnen)
const modalBody = document.querySelector('.modal-body');
if (modalBody) modalBody.innerHTML = bewBody;
}
// ----------------------------------------------------------
// PRAXEN — Bewertungs-Modal
// ----------------------------------------------------------
async function _showBewertungModal(praxis) {
// Ggf. bestehende Bewertung laden
let existing = null;
try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ }
const cur = existing || {};
const body = `
<form id="bew-form">
<div class="form-group">
<label class="form-label" style="font-weight:600">Gesamteindruck *</label>
${_renderStarsInput('gesamt', cur.gesamt || 0)}
<input type="hidden" name="gesamt" id="bew-gesamt" value="${cur.gesamt || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Wartezeit</label>
${_renderStarsInput('wartezeit', cur.wartezeit || 0)}
<input type="hidden" name="wartezeit" id="bew-wartezeit" value="${cur.wartezeit || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Freundlichkeit</label>
${_renderStarsInput('freundlichkeit', cur.freundlichkeit || 0)}
<input type="hidden" name="freundlichkeit" id="bew-freundlichkeit" value="${cur.freundlichkeit || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Kompetenz</label>
${_renderStarsInput('kompetenz', cur.kompetenz || 0)}
<input type="hidden" name="kompetenz" id="bew-kompetenz" value="${cur.kompetenz || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Kommentar <span style="font-weight:400;color:var(--c-text-muted)">(optional, anonym)</span></label>
<textarea class="form-control" name="text" maxlength="500" rows="3"
placeholder="Deine Erfahrungen mit dieser Praxis…">${_esc(cur.text || '')}</textarea>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:right">max. 500 Zeichen</div>
</div>
</form>`;
UI.modal.open({
title: `${_esc(praxis.name)} bewerten`,
body,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="bew-submit-btn" form="bew-form">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
${existing ? 'Bewertung aktualisieren' : 'Bewertung abgeben'}
</button>`,
});
// Sterne-Interaktion
document.querySelectorAll('.bew-stars').forEach(group => {
const name = group.dataset.name;
const hidden = document.getElementById(`bew-${name}`);
const stars = group.querySelectorAll('.bew-star');
const paint = val => {
stars.forEach(s => {
s.style.color = parseInt(s.dataset.val) <= val
? 'var(--c-warning,#f59e0b)' : 'var(--c-border)';
});
};
stars.forEach(s => {
s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val)));
s.addEventListener('mouseleave', () => paint(parseInt(hidden.value)));
s.addEventListener('click', () => {
hidden.value = s.dataset.val;
paint(parseInt(s.dataset.val));
});
});
paint(parseInt(hidden.value));
});
// Submit
document.getElementById('bew-submit-btn').addEventListener('click', async (e) => {
e.preventDefault();
const form = document.getElementById('bew-form');
const gesamt = parseInt(document.getElementById('bew-gesamt').value);
if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; }
const payload = { gesamt };
const wz = parseInt(document.getElementById('bew-wartezeit').value);
const fr = parseInt(document.getElementById('bew-freundlichkeit').value);
const ko = parseInt(document.getElementById('bew-kompetenz').value);
if (wz) payload.wartezeit = wz;
if (fr) payload.freundlichkeit = fr;
if (ko) payload.kompetenz = ko;
const txt = form.querySelector('textarea[name="text"]').value.trim();
if (txt) payload.text = txt;
await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => {
const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload);
// _praxen-Cache aktualisieren
_praxen = _praxen.map(p =>
p.id === praxis.id
? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen }
: p
);
UI.modal.close();
UI.toast.success('Bewertung gespeichert.');
_renderTab();
});
});
}
// ----------------------------------------------------------
// PRAXEN — Formular (Neu / Bearbeiten)
// ----------------------------------------------------------

View file

@ -838,10 +838,12 @@ window.Page_map = (() => {
_tempMarker = null;
}
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
@ -855,18 +857,18 @@ window.Page_map = (() => {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
let _selectedType = 'giftkoeder';
let _selectedTypes = new Set(['giftkoeder']);
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
<label class="form-label">Typ auswählen</label>
<label class="form-label">Typ auswählen <span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">(Mehrfachauswahl möglich)</span></label>
<div class="poi-type-grid">
${PIN_TYPES.map(p => `
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
data-type="${p.type}" style="--pt-color:${p.color}">
data-type="${p.type}" data-excl="${p.exclusive ? '1' : ''}" style="--pt-color:${p.color}">
<span class="poi-type-icon">${p.icon}</span>
<span class="poi-type-label">${p.label}</span>
</button>
@ -892,9 +894,21 @@ window.Page_map = (() => {
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
const btn = e.target.closest('.poi-type-btn');
if (!btn) return;
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
_selectedType = btn.dataset.type;
const t = btn.dataset.type;
if (btn.dataset.excl) {
_selectedTypes = new Set([t]);
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t));
} else {
if (_selectedTypes.has('giftkoeder')) {
_selectedTypes.delete('giftkoeder');
document.querySelector('[data-excl="1"]')?.classList.remove('selected');
}
if (_selectedTypes.has(t)) {
if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); }
} else {
_selectedTypes.add(t); btn.classList.add('selected');
}
}
});
document.getElementById('poi-cancel')?.addEventListener('click', () => {
@ -905,8 +919,9 @@ window.Page_map = (() => {
document.getElementById('poi-save')?.addEventListener('click', async () => {
const name = document.getElementById('poi-name').value.trim() || null;
const notiz = document.getElementById('poi-notiz').value.trim() || null;
const type = [..._selectedTypes].join(',');
UI.modal.close();
await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz });
await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz });
_exitPlacementMode();
});
}

View file

@ -88,45 +88,35 @@ window.Page_moderation = (() => {
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
function _switchTab(tabId) {
_tab = tabId;
_container.querySelectorAll('#mod-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
}
async function _renderStats(el) {
const s = await API.get('/moderation/stats');
el.innerHTML = `
<div class="adm-stats-grid">
${_statCard('warning',
'Offene Meldungen',
s.open_reports,
s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
${_statCard('image',
'Fotos ausstehend',
s.pending_fotos,
s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('skull',
'Gesperrte User',
s.banned_users,
s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
${_statCard('storefront',
'Züchter ausstehend',
s.pending_zuchter,
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('clock',
'POI-Korrekturen',
s.pending_poi_edits ?? 0,
(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'forum')}
${_statCard('image', 'Fotos ausstehend', s.pending_fotos, s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'fotos')}
${_statCard('skull', 'Gesperrte User', s.banned_users, s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'user')}
${_statCard('storefront','Züchter ausstehend',s.pending_zuchter, s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'user')}
${_statCard('clock', 'POI-Korrekturen', s.pending_poi_edits ?? 0,(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'poi-edits')}
</div>
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6">
${UI.icon('info')}
Das Moderations-Panel zeigt dir alle ausstehenden Aufgaben auf einen Blick.
Verwende die Tabs oben für Details zu Fotos, Usern und Forum-Meldungen.
</p>
</div>
`;
el.querySelectorAll('.mod-stat-card[data-tab]').forEach(card => {
card.addEventListener('click', () => _switchTab(card.dataset.tab));
});
}
function _statCard(icon, label, value, color) {
function _statCard(icon, label, value, color, tab) {
const clickable = tab ? `data-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer;transition:box-shadow .15s,transform .15s" onmouseenter="this.style.boxShadow='var(--shadow-md)';this.style.transform='translateY(-2px)'" onmouseleave="this.style.boxShadow='';this.style.transform=''"` : `style="padding:var(--space-4);text-align:center"`;
return `
<div class="card" style="padding:var(--space-4);text-align:center">
<div class="card mod-stat-card" ${clickable}>
<svg class="ph-icon" style="width:24px;height:24px;color:${color};
margin-bottom:var(--space-2)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
@ -135,6 +125,7 @@ window.Page_moderation = (() => {
color:var(--c-text)">${value ?? '—'}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:2px">${label}</div>
${tab ? `<div style="font-size:10px;color:var(--c-primary);margin-top:var(--space-2);opacity:.7">${UI.icon('arrow-right')} öffnen</div>` : ''}
</div>
`;
}
@ -217,7 +208,16 @@ window.Page_moderation = (() => {
await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' });
UI.toast('Foto freigegeben.', 'success');
await _loadFotos(el);
} catch (e) { UI.toast(e.message, 'danger'); btn.disabled = false; btn.textContent = '✓ Freigeben'; }
} catch (e) {
if (e.status === 404) {
UI.toast('Bereits bearbeitet — Liste aktualisiert.', 'info');
await _loadFotos(el);
} else {
UI.toast(e.message, 'danger');
btn.disabled = false;
btn.textContent = '✓ Freigeben';
}
}
});
});

View file

@ -0,0 +1,480 @@
/* ============================================================
BAN YARO Hunde-Persönlichkeitstest
10 Fragen, 4 Typen: Abenteurer / Entdecker / Kuschler / Denker
============================================================ */
window.Page_personality = (() => {
let _container = null;
let _appState = null;
let _current = 0; // Aktuelle Frage (0-basiert)
let _scores = { A:0, B:0, C:0, D:0 };
let _answers = []; // Gewählte Typen je Frage
const LS_KEY = 'banyaro_personality_result';
// ----------------------------------------------------------
// FRAGEN
// ----------------------------------------------------------
const FRAGEN = [
{ frage: "Wie reagiert dein Hund auf neue Orte?",
antworten: [
{ text: "Stürmt sofort los — alles erkunden!", typ: 'A' },
{ text: "Schaut erst vorsichtig, dann neugierig", typ: 'B' },
{ text: "Bleibt lieber bei mir in der Nähe", typ: 'C' },
{ text: "Analysiert die Lage gründlich", typ: 'D' },
]},
{ frage: "Was macht dein Hund am liebsten?",
antworten: [
{ text: "Rennen, Ball, endlos spielen", typ: 'A' },
{ text: "Schnüffeln und die Welt erkunden", typ: 'B' },
{ text: "Kuscheln auf dem Sofa", typ: 'C' },
{ text: "Tricks lernen und Aufgaben lösen", typ: 'D' },
]},
{ frage: "Wie verhält er sich mit anderen Hunden?",
antworten: [
{ text: "Spielt sofort und wild mit", typ: 'A' },
{ text: "Friendly, aber wählerisch", typ: 'B' },
{ text: "Lieber zu zweit als in der Gruppe", typ: 'C' },
{ text: "Beobachtet erstmal genau", typ: 'D' },
]},
{ frage: "Wie reagiert er auf Kommandos?",
antworten: [
{ text: "Macht alles — wenn er Lust hat 😅", typ: 'A' },
{ text: "Gut, aber manchmal abgelenkt", typ: 'B' },
{ text: "Sehr zuverlässig, will gefallen", typ: 'C' },
{ text: "Präzise und fokussiert", typ: 'D' },
]},
{ frage: "Was passiert wenn du heimkommst?",
antworten: [
{ text: "Explosiver Freudentanz!", typ: 'A' },
{ text: "Wedelt freudig, bleibt aber cool", typ: 'B' },
{ text: "Kuschelt sich sofort an dich", typ: 'C' },
{ text: "Bringt dir sein Lieblingsspielzeug", typ: 'D' },
]},
{ frage: "Wie ist er bei Geräuschen/Gewitter?",
antworten: [
{ text: "Interessiert sich dafür oder ignoriert es", typ: 'A' },
{ text: "Schaut kurz, dann weiter", typ: 'B' },
{ text: "Sucht Schutz bei dir", typ: 'C' },
{ text: "Analysiert die Situation", typ: 'D' },
]},
{ frage: "Sein Verhältnis zu Kindern?",
antworten: [
{ text: "Liebt das wilde Spielen!", typ: 'A' },
{ text: "Gut, aber auf seine Art", typ: 'B' },
{ text: "Sanft und geduldig", typ: 'C' },
{ text: "Vorsichtig, aber freundlich", typ: 'D' },
]},
{ frage: "Was macht er alleine zu Hause?",
antworten: [
{ text: "Schläft oder spielt mit Spielzeug", typ: 'A' },
{ text: "Schaut aus dem Fenster", typ: 'B' },
{ text: "Wartet sehnsüchtig auf dich", typ: 'C' },
{ text: "Sucht sich Beschäftigung", typ: 'D' },
]},
{ frage: "Beim Gassigehen:",
antworten: [
{ text: "Zieht an der Leine — immer vorwärts!", typ: 'A' },
{ text: "Läuft locker aber entdeckungsfreudig", typ: 'B' },
{ text: "Bleibt gerne neben dir", typ: 'C' },
{ text: "Systematisches Schnüffeln", typ: 'D' },
]},
{ frage: "Was sagt er über dich aus?",
antworten: [
{ text: "Mein Mensch hält mit mir mit!", typ: 'A' },
{ text: "Gibt mir Freiheit und Abenteuer", typ: 'B' },
{ text: "Mein bester Freund", typ: 'C' },
{ text: "Versteht mich wirklich", typ: 'D' },
]},
];
// ----------------------------------------------------------
// TYPEN
// ----------------------------------------------------------
const TYPEN = {
A: {
key: 'A',
emoji: '🏔️',
name: 'Der Abenteurer',
desc: 'Immer vorwärts, immer mehr! Dein Hund lebt im Augenblick und liebt das Unbekannte.',
staerken: ['Energiegeladen', 'Mutig', 'Lebensfroh'],
aktivitaeten: [
{ label: 'Routen', page: 'routes' },
{ label: 'Karte', page: 'map' },
{ label: 'Training', page: 'uebungen' },
],
aktivitaetLabels: ['Agility', 'Canicross', 'Lange Wanderungen', 'Nasenarbeit'],
rassen: ['Husky', 'Malinois', 'Border Collie'],
color: '#f97316',
bg: 'linear-gradient(135deg, #f97316, #ea580c)',
},
B: {
key: 'B',
emoji: '🌍',
name: 'Der Entdecker',
desc: 'Neugierig auf alles, aber mit Köpfchen. Dein Hund ist der perfekte Begleiter für jede Situation.',
staerken: ['Anpassungsfähig', 'Sozial', 'Ausgeglichen'],
aktivitaeten: [
{ label: 'Karte', page: 'map' },
{ label: 'Events', page: 'events' },
{ label: 'Routen', page: 'routes' },
],
aktivitaetLabels: ['Mantrailing', 'Dummy-Training', 'Gassi-Treffen'],
rassen: ['Labrador', 'Golden Retriever', 'Beagle'],
color: '#0ea5e9',
bg: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
},
C: {
key: 'C',
emoji: '🥰',
name: 'Der Kuschler',
desc: 'Verbundenheit über alles. Dein Hund liebt Menschen mehr als alles andere.',
staerken: ['Loyal', 'Einfühlsam', 'Zuverlässig'],
aktivitaeten: [
{ label: 'Tagebuch', page: 'diary' },
{ label: 'Training', page: 'uebungen' },
{ label: 'Gesundheit', page: 'health' },
],
aktivitaetLabels: ['Trick-Training', 'Therapy-Dog-Ausbildung', 'Ruhige Spaziergänge'],
rassen: ['Cavalier KCS', 'Bichon Frisé', 'Mops'],
color: '#ec4899',
bg: 'linear-gradient(135deg, #ec4899, #db2777)',
},
D: {
key: 'D',
emoji: '🧠',
name: 'Der Denker',
desc: 'Stratege mit Seele. Dein Hund denkt bevor er handelt — und ist dabei brillant.',
staerken: ['Intelligent', 'Fokussiert', 'Lernbegeistert'],
aktivitaeten: [
{ label: 'Übungen', page: 'uebungen' },
{ label: 'Training', page: 'trainingsplaene' },
{ label: 'Wiki', page: 'wiki' },
],
aktivitaetLabels: ['Zieltraining', 'Geruchsarbeit', 'Rally Obedience', 'Intelligenzspielzeug'],
rassen: ['Poodle', 'Schäferhund', 'Rottweiler'],
color: '#8b5cf6',
bg: 'linear-gradient(135deg, #8b5cf6, #7c3aed)',
},
};
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
function init(container, appState) {
_container = container;
_appState = appState;
_renderPage();
}
function refresh() {}
function onDogChange() {}
// ----------------------------------------------------------
// RENDER EINSTIEG
// ----------------------------------------------------------
function _renderPage() {
// Gespeichertes Ergebnis aus localStorage?
const saved = _loadResult();
if (saved) {
_renderResult(TYPEN[saved.typ], saved.scores, true);
} else {
_renderIntro();
}
}
// ----------------------------------------------------------
// INTRO
// ----------------------------------------------------------
function _renderIntro() {
_container.innerHTML = `
<div class="page-body page-container">
<div style="text-align:center;padding:var(--space-6) var(--space-4) var(--space-4)">
<div style="font-size:3.5rem;margin-bottom:var(--space-3)">🐾</div>
<h1 style="font-size:var(--text-xl);font-weight:800;margin-bottom:var(--space-2)">
Hunde-Persönlichkeitstest
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);max-width:320px;margin:0 auto var(--space-6)">
10 Fragen finde heraus welcher der 4 Persönlichkeitstypen deinen Hund am besten beschreibt!
</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);max-width:340px;margin:0 auto var(--space-6)">
${Object.values(TYPEN).map(t => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3);text-align:center">
<div style="font-size:1.8rem">${t.emoji}</div>
<div style="font-size:var(--text-xs);font-weight:600;margin-top:4px;color:${t.color}">${t.name}</div>
</div>`).join('')}
</div>
<button class="btn btn-primary" style="padding:14px 40px;font-size:1rem;font-weight:700;border-radius:999px"
id="quiz-start-btn">Quiz starten</button>
</div>
</div>`;
document.getElementById('quiz-start-btn').addEventListener('click', _startQuiz);
}
// ----------------------------------------------------------
// QUIZ STARTEN
// ----------------------------------------------------------
function _startQuiz() {
_current = 0;
_scores = { A:0, B:0, C:0, D:0 };
_answers = [];
_renderQuestion();
}
// ----------------------------------------------------------
// FRAGE RENDERN
// ----------------------------------------------------------
function _renderQuestion() {
const q = FRAGEN[_current];
const pct = Math.round((_current / FRAGEN.length) * 100);
_container.innerHTML = `
<div class="page-body page-container">
<!-- Fortschritt -->
<div style="padding:var(--space-4) var(--space-4) 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
Frage ${_current + 1} von ${FRAGEN.length}
</span>
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary)">${pct}%</span>
</div>
<div style="height:6px;background:var(--c-border);border-radius:3px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--c-primary);border-radius:3px;transition:width .4s"></div>
</div>
</div>
<!-- Frage -->
<div style="padding:var(--space-5) var(--space-4) var(--space-3)">
<h2 style="font-size:var(--text-lg);font-weight:700;line-height:1.4;margin:0">${q.frage}</h2>
</div>
<!-- Antworten -->
<div style="padding:0 var(--space-4) var(--space-6);display:flex;flex-direction:column;gap:var(--space-3)">
${q.antworten.map((a, i) => `
<button class="quiz-answer-btn" data-typ="${a.typ}"
style="text-align:left;padding:var(--space-4);background:var(--c-surface);
border:2px solid var(--c-border);border-radius:var(--radius-lg);
font-size:var(--text-sm);cursor:pointer;transition:all .15s;
color:var(--c-text);line-height:1.4;
display:flex;align-items:center;gap:var(--space-3)">
<span style="width:28px;height:28px;border-radius:50%;background:var(--c-bg);
border:1.5px solid var(--c-border);display:flex;align-items:center;justify-content:center;
font-size:11px;font-weight:700;color:var(--c-text-secondary);flex-shrink:0">
${String.fromCharCode(65 + i)}
</span>
<span>${a.text}</span>
</button>`).join('')}
</div>
</div>`;
_container.querySelectorAll('.quiz-answer-btn').forEach(btn => {
btn.addEventListener('click', () => _answerQuestion(btn.dataset.typ));
btn.addEventListener('mouseenter', () => {
btn.style.borderColor = 'var(--c-primary)';
btn.style.background = 'var(--c-primary-subtle, rgba(var(--c-primary-rgb,59,130,246),.08))';
});
btn.addEventListener('mouseleave', () => {
if (!btn.classList.contains('selected')) {
btn.style.borderColor = 'var(--c-border)';
btn.style.background = 'var(--c-surface)';
}
});
});
}
// ----------------------------------------------------------
// ANTWORT VERARBEITEN
// ----------------------------------------------------------
function _answerQuestion(typ) {
_scores[typ]++;
_answers.push(typ);
_current++;
if (_current < FRAGEN.length) {
// Kurze Animation — zeige Auswahl kurz grün
_renderQuestion();
} else {
_calcAndShowResult();
}
}
// ----------------------------------------------------------
// AUSWERTUNG
// ----------------------------------------------------------
function _calcAndShowResult() {
// Mehrheits-Typ finden; bei Gleichstand letzter bestimmender Typ
let maxScore = 0;
let winner = _answers[_answers.length - 1]; // Fallback: letzte Antwort
for (const [typ, score] of Object.entries(_scores)) {
if (score > maxScore) {
maxScore = score;
winner = typ;
}
}
// Bei Gleichstand: letzter in _answers der einen der Max-Score-Typen hat
const maxTypes = Object.entries(_scores)
.filter(([, s]) => s === maxScore)
.map(([t]) => t);
if (maxTypes.length > 1) {
for (let i = _answers.length - 1; i >= 0; i--) {
if (maxTypes.includes(_answers[i])) { winner = _answers[i]; break; }
}
}
_saveResult(winner, _scores);
_renderResult(TYPEN[winner], _scores, false);
}
// ----------------------------------------------------------
// ERGEBNIS RENDERN
// ----------------------------------------------------------
function _renderResult(typ, scores, fromStorage) {
const dogName = _appState?.activeDog?.name || 'dein Hund';
const shareText = `${dogName} ist ${typ.name} ${typ.emoji} — macht den Test auf ban.yaro.de!`;
const scoreBars = Object.entries(scores)
.sort(([,a],[,b]) => b - a)
.map(([t, s]) => {
const tp = TYPEN[t];
const pct = Math.round((s / FRAGEN.length) * 100);
return `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:1rem;width:24px;text-align:center">${tp.emoji}</span>
<div style="flex:1">
<div style="height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${tp.color};border-radius:4px;transition:width .6s"></div>
</div>
</div>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);width:28px;text-align:right">${s}/${FRAGEN.length}</span>
</div>`;
}).join('');
_container.innerHTML = `
<div class="page-body page-container">
<!-- Hero -->
<div style="background:${typ.bg};padding:var(--space-8) var(--space-4) var(--space-6);
text-align:center;color:#fff;border-radius:0 0 var(--radius-xl) var(--radius-xl);
margin-bottom:var(--space-5)">
<div style="font-size:4rem;margin-bottom:var(--space-2)">${typ.emoji}</div>
<div style="font-size:var(--text-xs);font-weight:600;opacity:.8;text-transform:uppercase;letter-spacing:.1em;
margin-bottom:var(--space-1)">Persönlichkeitstyp</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-3)">${typ.name}</h1>
<p style="font-size:var(--text-sm);opacity:.9;max-width:300px;margin:0 auto;line-height:1.5">${typ.desc}</p>
</div>
<!-- Stärken -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Stärken</div>
<div style="padding:var(--space-3) var(--space-4);display:flex;flex-wrap:wrap;gap:8px">
${typ.staerken.map(s => `
<span style="background:${typ.color}22;color:${typ.color};padding:5px 14px;
border-radius:999px;font-size:var(--text-xs);font-weight:600">${s}</span>`).join('')}
</div>
</div>
<!-- Empfohlene Aktivitäten -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Empfohlene Aktivitäten</div>
<div style="padding:var(--space-3) var(--space-4)">
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:var(--space-3)">
${typ.aktivitaetLabels.map(a => `
<span style="background:var(--c-surface);border:1px solid var(--c-border);
padding:5px 14px;border-radius:999px;font-size:var(--text-xs)">${a}</span>`).join('')}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${typ.aktivitaeten.map(a => `
<button class="btn btn-secondary" style="font-size:var(--text-xs);padding:6px 14px;border-radius:999px"
onclick="App.navigate('${a.page}')">${a.label} </button>`).join('')}
</div>
</div>
</div>
<!-- Bekannte Rassen -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Typische Rassen</div>
<div style="padding:var(--space-3) var(--space-4);display:flex;flex-wrap:wrap;gap:8px">
${typ.rassen.map(r => `
<span style="background:var(--c-bg);border:1px solid var(--c-border);
padding:5px 14px;border-radius:999px;font-size:var(--text-xs)">${r}</span>`).join('')}
</div>
</div>
<!-- Punkteverteilung -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Dein Profil</div>
<div style="padding:var(--space-4)">${scoreBars}</div>
</div>
<!-- Teilen + Nochmal -->
<div style="padding:0 var(--space-4) var(--space-8);display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-secondary" id="quiz-share-btn"
style="padding:14px;font-size:var(--text-sm);border-radius:var(--radius-lg);
display:flex;align-items:center;justify-content:center;gap:8px">
<svg style="width:18px;height:18px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
<polyline points="16 6 12 2 8 6"/>
<line x1="12" y1="2" x2="12" y2="15"/>
</svg>
Ergebnis teilen
</button>
<button class="btn btn-primary" id="quiz-restart-btn"
style="padding:14px;font-size:var(--text-sm);border-radius:var(--radius-lg)">
Nochmal machen
</button>
</div>
</div>`;
// Share
document.getElementById('quiz-share-btn')?.addEventListener('click', async () => {
if (navigator.share) {
try {
await navigator.share({ text: shareText, url: 'https://ban.yaro.de' });
} catch {}
} else {
await navigator.clipboard.writeText(shareText);
UI.toast.success('In die Zwischenablage kopiert!');
}
});
// Neustart
document.getElementById('quiz-restart-btn')?.addEventListener('click', () => {
localStorage.removeItem(LS_KEY);
_startQuiz();
});
}
// ----------------------------------------------------------
// LOCALSTORAGE
// ----------------------------------------------------------
function _saveResult(typ, scores) {
try {
localStorage.setItem(LS_KEY, JSON.stringify({ typ, scores, ts: Date.now() }));
} catch {}
}
function _loadResult() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch { return null; }
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -695,10 +695,60 @@ window.Page_routes = (() => {
_recActive = true;
_recTrack = []; _recDistKm = 0; _recStartTime = Date.now();
// iOS-Hinweis: Display muss wach bleiben
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
const banner = document.createElement('div');
banner.style.cssText = 'position:absolute;top:0;left:0;right:0;z-index:960;' +
'background:rgba(30,30,30,0.92);color:#fff;font-size:13px;line-height:1.4;' +
'padding:10px 14px;display:flex;align-items:flex-start;gap:10px;' +
'border-bottom:1px solid rgba(255,255,255,0.1)';
banner.innerHTML = `
<svg style="width:18px;height:18px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use></svg>
<span><strong>Display wach lassen!</strong> Auf iPhone stoppt die GPS-Aufzeichnung, wenn das Display ausgeht Helligkeit hochsetzen oder Bildschirm nicht sperren.</span>`;
document.getElementById('rk-rec-map-wrap')?.appendChild(banner);
setTimeout(() => banner.remove(), 9000);
}
const ctrl = document.getElementById('rk-rec-ctrl');
ctrl.innerHTML = `<button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;">
${UI.icon('path')} Stopp & Speichern</button>`;
ctrl.querySelector('#rk-rec-stopbtn').addEventListener('click', () => _stopRecInOvl(true));
ctrl.innerHTML = `
<button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;position:relative;overflow:hidden;touch-action:none;user-select:none;">
<span id="rk-stop-label" style="position:relative;z-index:1;display:flex;align-items:center;gap:6px;pointer-events:none">
${UI.icon('path')} Gedrückt halten zum Stoppen
</span>
<div id="rk-stop-fill" style="position:absolute;inset:0;background:rgba(0,0,0,0.35);transform:scaleX(0);transform-origin:left center;transition:transform 0.05s linear;pointer-events:none"></div>
</button>`;
// Long-Press 1.8s zum Stoppen
let _stopTimer = null, _stopTick = null;
const btn = ctrl.querySelector('#rk-rec-stopbtn');
const fill = ctrl.querySelector('#rk-stop-fill');
const startHold = () => {
if (_stopTimer) return;
const DURATION = 1800;
const start = Date.now();
_stopTick = setInterval(() => {
const p = Math.min((Date.now() - start) / DURATION, 1);
fill.style.transition = 'none';
fill.style.transform = `scaleX(${p})`;
}, 30);
_stopTimer = setTimeout(() => {
clearInterval(_stopTick); _stopTick = null; _stopTimer = null;
fill.style.transform = 'scaleX(1)';
_stopRecInOvl(true);
}, DURATION);
};
const cancelHold = () => {
if (!_stopTimer && !_stopTick) return;
clearTimeout(_stopTimer); clearInterval(_stopTick);
_stopTimer = null; _stopTick = null;
fill.style.transition = 'transform 0.25s ease';
fill.style.transform = 'scaleX(0)';
};
btn.addEventListener('pointerdown', e => { e.preventDefault(); startHold(); });
btn.addEventListener('pointerup', cancelHold);
btn.addEventListener('pointerleave', cancelHold);
btn.addEventListener('pointercancel', cancelHold);
document.getElementById('rk-rec-stats-bar').style.display = '';
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
@ -789,7 +839,7 @@ window.Page_routes = (() => {
dim.style.display = 'flex';
_recDimmed = true;
}
}, 10000);
}, 5000);
}
async function _stopRecInOvl(save) {

View file

@ -229,6 +229,7 @@ window.Page_settings = (() => {
</div>
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
<div id="settings-lifetime-km" style="border-top:1px solid var(--c-border)"></div>
</div>
<!-- Züchter-Profil Slot -->
@ -313,7 +314,7 @@ window.Page_settings = (() => {
</div>
<!-- KI-Notiz-Assistent -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="flex:1">
<div style="font-weight:500">KI-Notiz-Assistent</div>
@ -336,6 +337,30 @@ window.Page_settings = (() => {
</label>
</div>
<!-- Goldene Gassi-Stunde -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#paw-print"></use></svg>
<div style="flex:1">
<div style="font-weight:500">Goldene Gassi-Stunde täglich</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Täglich um 07:00 Uhr: bestes Wetterfenster für den Gassi-Gang
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-gassi-stunde"
style="opacity:0;width:0;height:0;position:absolute"
${u.gassi_stunde_push ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:${u.gassi_stunde_push ? 'var(--c-primary)' : 'var(--c-border)'};transition:.2s"
id="toggle-gassi-stunde-track"></span>
<span id="toggle-gassi-stunde-thumb"
style="position:absolute;top:2px;left:${u.gassi_stunde_push ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
</div>
</div>
@ -418,10 +443,88 @@ window.Page_settings = (() => {
: `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">🔥 Noch kein Streak — heute aktiv werden!</span>`;
}
// Lifetime-km Balken mit Meilenstein-Markierungen
const lifetimeEl = document.getElementById('settings-lifetime-km');
if (lifetimeEl) {
const km = s.total_km ?? 0;
const MILESTONES = [
{ km: 100, label: '100', badge: '100-km-Club', color: '#cd7f32' },
{ km: 500, label: '500', badge: '500-km-Wanderer', color: '#94a3b8' },
{ km: 1000, label: '1k', badge: 'Tausend-km-Held', color: '#f59e0b' },
{ km: 5000, label: '5k', badge: 'Ultraläufer', color: '#cbd5e1' },
];
const maxKm = 5000;
const pct = Math.min(km / maxKm * 100, 100);
const nextM = MILESTONES.find(m => km < m.km);
const reachedM = MILESTONES.filter(m => km >= m.km);
const lastBadge = reachedM.length ? reachedM[reachedM.length - 1] : null;
const markers = MILESTONES.map(m => {
const pos = (m.km / maxKm * 100).toFixed(1);
const reached = km >= m.km;
return `<div title="${m.badge}" style="position:absolute;left:${pos}%;top:-4px;transform:translateX(-50%);
width:12px;height:12px;border-radius:50%;border:2px solid ${m.color};
background:${reached ? m.color : 'var(--c-bg)'};z-index:2">
</div>
<div style="position:absolute;left:${pos}%;top:12px;transform:translateX(-50%);
font-size:9px;color:${reached ? m.color : 'var(--c-text-muted)'};font-weight:600;
white-space:nowrap">${m.label}</div>`;
}).join('');
lifetimeEl.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0;
display:flex;justify-content:space-between;align-items:center">
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Lebenswerk-km</span>
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-primary)">${km} km</span>
</div>
<div style="padding:var(--space-3) var(--space-4) var(--space-4)">
<div style="position:relative;height:8px;background:var(--c-border);border-radius:4px;
overflow:visible;margin-bottom:22px">
<div style="position:absolute;left:0;top:0;height:100%;width:${pct}%;
background:linear-gradient(90deg,#10b981,#0ea5e9);
border-radius:4px;z-index:1;transition:width .6s"></div>
${markers}
</div>
${nextM
? `<div style="font-size:11px;color:var(--c-text-muted)">
Noch <strong>${(nextM.km - km).toLocaleString('de-DE')}&nbsp;km</strong>
bis <span style="color:${nextM.color};font-weight:600">${nextM.badge}</span>
</div>`
: `<div style="font-size:11px;color:var(--c-primary);font-weight:600">
Ultraläufer-Legende erreicht! 🏆
</div>`}
</div>`;
}
if (badgesEl && a.categories) {
// SVG-Schild für jede Kategorie
const shield = (color, dark, emoji, opacity = 1) => `
<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
// Foto-Hintergründe für bestimmte Badge-Kategorien
const _BADGE_PHOTOS = {
'schnee_held': '/img/banyaro/winter_schnee.webp',
'jahreszeiten': '/img/banyaro/herbst_bach.webp',
'wetter_tapfer': '/img/banyaro/fruehling_playdate.webp',
};
// SVG-Schild für jede Kategorie (mit optionalem Foto-Hintergrund)
const shield = (color, dark, emoji, opacity = 1, catId = '') => {
const photo = _BADGE_PHOTOS[catId];
const clipId = `clip_${catId || Math.random().toString(36).slice(2)}`;
const path = 'M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z';
if (photo && opacity === 1) {
return `<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 8px rgba(0,0,0,.4))">
<defs>
<clipPath id="${clipId}"><path d="${path}"/></clipPath>
</defs>
<image href="${photo}" x="0" y="0" width="60" height="72"
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>
<path d="${path}" fill="rgba(0,0,0,0.28)"/>
<path d="${path}" fill="none" stroke="rgba(255,255,255,.4)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
}
return `<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 6px ${color}66)">
<defs>
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
@ -429,13 +532,12 @@ window.Page_settings = (() => {
<stop offset="100%" stop-color="${dark}"/>
</linearGradient>
</defs>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<path d="${path}" fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="${path}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
};
badgesEl.innerHTML = (a.categories || []).map(cat => {
const cur = cat.current_tier;
@ -450,8 +552,8 @@ window.Page_settings = (() => {
// Aktuelles Schild
const shieldSvg = cur
? shield(cur.color, cur.dark, cat.emoji)
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5);
? shield(cur.color, cur.dark, cat.emoji, 1, cat.id)
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5, cat.id);
// Fortschrittsbalken
const progressBar = nxt ? `
@ -785,6 +887,25 @@ window.Page_settings = (() => {
}
});
document.getElementById('toggle-gassi-stunde')?.addEventListener('change', async e => {
const enabled = e.target.checked;
const track = document.getElementById('toggle-gassi-stunde-track');
const thumb = document.getElementById('toggle-gassi-stunde-thumb');
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
try {
await API.patch('/profile', { gassi_stunde_push: enabled ? 1 : 0 });
_appState.user.gassi_stunde_push = enabled ? 1 : 0;
UI.toast.success(enabled ? 'Goldene Gassi-Stunde aktiviert.' : 'Goldene Gassi-Stunde deaktiviert.');
} catch (err) {
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
// Revert UI
e.target.checked = !enabled;
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
}
});
_loadReferral();
_loadBreederCard();
}
@ -1551,9 +1672,9 @@ window.Page_settings = (() => {
_offerPushNotifications();
}
// Nach Login: Welcome-Seite oder Profil anlegen
// Nach Login: Direkt in HUND-Welt oder Profil anlegen
if (_appState.activeDog) {
App.navigate('welcome');
window.Worlds?.show(1);
} else {
App.navigate('dog-profile');
}

View file

@ -34,6 +34,7 @@ window.Page_uebungen = (() => {
// ----------------------------------------------------------
// STATS STATE
// ----------------------------------------------------------
let _helpHandle = null; // Rückgabe von UI.pageInfo — für inline Trigger-Button
let _statsData = null; // cached stats from /api/training/stats
let _badgesData = null; // cached badges from /api/achievements
let _exercisesByTab = {}; // aus API geladen
@ -476,6 +477,18 @@ window.Page_uebungen = (() => {
if (_VALID_TABS.has(mapped)) _activeTab = mapped;
}
_render();
_helpHandle = UI.pageInfo(_container, {
pageId: 'uebungen',
title: 'Übungsbibliothek',
icon: 'graduation-cap',
intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.',
steps: [
{ icon: 'list-checks', title: 'Stand erfassen', text: 'Klicke auf "Stand erfassen" um schnell für alle Übungen einzutragen, was euer aktueller Stand ist.' },
{ icon: 'flag', title: 'Übung üben', text: 'Tippe auf eine Übung, um die Anleitung zu lesen. Mit den Fortschritts-Icons (Flagge → Trophäe) trackst du, wie weit ihr seid.' },
{ icon: 'star', title: 'KI-Trainer', text: 'Im Tab "KI-Trainer" analysiert unsere KI deinen Trainingsstand und gibt personalisierte Empfehlungen.' },
],
tip: 'Regelmäßiges Training stärkt die Bindung — auch 5 Minuten täglich machen einen großen Unterschied!',
});
// Übungen aus DB laden (parallel mit Progress)
if (!_exercisesLoaded) {
@ -735,10 +748,14 @@ window.Page_uebungen = (() => {
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
Dein Plan für heute
</span>
<span id="ueb-help-anchor" style="margin-left:auto"></span>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${cards.join('')}
</div>`;
if (_helpHandle) {
document.getElementById('ueb-help-anchor')?.appendChild(_helpHandle.makeTriggerBtn());
}
el.querySelectorAll('.ueb-trainer-btn').forEach(btn => {
btn.addEventListener('click', () => {

View file

@ -60,6 +60,7 @@ window.Page_wetter = (() => {
let _data = null;
let _selDay = 0;
let _loading = false;
let _recordsLoaded = false;
// ----------------------------------------------------------
// INIT
@ -77,6 +78,7 @@ window.Page_wetter = (() => {
// ----------------------------------------------------------
async function refresh() {
_selDay = 0;
_recordsLoaded = false;
_renderShell();
_tryAutoLocate();
}
@ -187,9 +189,18 @@ window.Page_wetter = (() => {
style="margin-bottom:var(--space-4)">
</div>
<!-- Niederschlagswahrscheinlichkeit Zeitskala -->
<div id="wttr-rain" class="section-card"
style="margin-bottom:var(--space-4)">
</div>
<!-- Hunde-Wetter -->
<div id="wttr-dog" class="section-card">
</div>
<!-- Meine Wetterrekorde -->
<div id="wttr-records">
</div>
`;
// Strip-Klick-Events
@ -198,12 +209,15 @@ window.Page_wetter = (() => {
_selDay = parseInt(card.dataset.wttrDay);
_updateStrip();
_renderDetail();
_renderRainTimeline();
_renderDog();
});
});
_renderDetail();
_renderRainTimeline();
_renderDog();
_loadRecords();
}
// ----------------------------------------------------------
@ -318,6 +332,9 @@ window.Page_wetter = (() => {
</div>
</div>
<!-- Gassi-Score -->
${_gassiScoreBadge(d)}
<!-- Sonnenaufgang / -untergang -->
${sunriseStr && sunsetStr ? `
<div style="margin-bottom:var(--space-4)">
@ -380,6 +397,137 @@ window.Page_wetter = (() => {
`;
}
// ----------------------------------------------------------
// NIEDERSCHLAGS-ZEITSKALA (stündlich)
// ----------------------------------------------------------
function _renderRainTimeline() {
const el = _container.querySelector('#wttr-rain');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const hourly = d.hourly || [];
// Filtere auf Stunden mit Daten, die eine Niederschlagswahrscheinlichkeit haben
const entries = hourly.filter(h => h.precip_prob != null);
if (!entries.length) { el.style.display = 'none'; return; }
el.style.display = '';
// Für "Heute" (Tag 0): ab jetzt, sonst alle 24h
const now = new Date();
const nowMin = now.getHours() * 60 + now.getMinutes();
let slots = entries;
if (_selDay === 0) {
// Zeige ab der aktuellen Stunde (und die letzten 2h als Kontext)
const pastCutoff = now.getHours() - 2;
slots = entries.filter(h => {
const hHour = parseInt(h.hour.split(':')[0]);
return hHour >= pastCutoff;
});
// Falls nichts übrig bleibt, zeige alles
if (!slots.length) slots = entries;
}
// Max probability für Skalierung (mindestens 30 damit die Balken sichtbar sind)
const maxProb = Math.max(30, ...slots.map(h => h.precip_prob ?? 0));
// Farb-Funktion: blau basierend auf Wahrscheinlichkeit
function _rainColor(prob) {
if (prob < 10) return 'rgba(148,163,184,0.4)'; // grau, kaum Regen
if (prob < 25) return 'rgba(147,197,253,0.65)'; // hellblau
if (prob < 50) return 'rgba(96,165,250,0.8)'; // blau
if (prob < 75) return 'rgba(59,130,246,0.9)'; // kräftig blau
return 'rgba(29,78,216,1)'; // dunkelblau
}
// Aktuell aktiver Slot (nur bei Heute)
const currentHour = now.getHours();
const bars = slots.map(h => {
const prob = h.precip_prob ?? 0;
const hHour = parseInt(h.hour.split(':')[0]);
const isNow = _selDay === 0 && hHour === currentHour;
const barH = Math.max(2, Math.round((prob / 100) * 56)); // max 56px Balkenhöhe
const color = _rainColor(prob);
const labelHour = h.hour.substring(0, 2); // 'HH'
return `
<div style="display:flex;flex-direction:column;align-items:center;
gap:2px;min-width:38px;flex-shrink:0;position:relative">
<!-- Prozentzahl oben (nur bei 20%) -->
<div style="font-size:9px;color:var(--c-text-secondary);
height:13px;line-height:13px;font-weight:600">
${prob >= 20 ? prob + '%' : ''}
</div>
<!-- Balken-Container mit fixer Höhe -->
<div style="height:56px;display:flex;align-items:flex-end;width:100%">
<div style="
width:100%;
height:${barH}px;
background:${color};
border-radius:3px 3px 0 0;
transition:height .3s;
${isNow ? 'box-shadow:0 0 0 1.5px var(--c-primary);border-radius:3px 3px 0 0;' : ''}
"></div>
</div>
<!-- Stunden-Label unten -->
<div style="font-size:9px;font-weight:${isNow ? '700' : '400'};
color:${isNow ? 'var(--c-primary)' : 'var(--c-text-secondary)'};
line-height:1">
${isNow ? 'jetzt' : labelHour + 'h'}
</div>
</div>
`;
}).join('');
// Gibt es überhaupt nennenswerten Niederschlag?
const hasRain = slots.some(h => (h.precip_prob ?? 0) >= 10);
const titleColor = hasRain ? '#60A5FA' : 'var(--c-text-secondary)';
const titleIcon = hasRain ? 'cloud-rain' : 'cloud';
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:${titleColor}">
<use href="/icons/phosphor.svg#${titleIcon}"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:700">
Niederschlagswahrscheinlichkeit
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto">
${_selDay === 0 ? 'heute' : _esc(d.date ? new Date(d.date + 'T12:00').toLocaleDateString('de', {weekday:'short', day:'numeric', month:'short'}) : '')}
</span>
</div>
<!-- Baseline -->
<div style="position:relative">
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;
scrollbar-width:none;padding-bottom:2px">
<div style="display:flex;gap:3px;min-width:max-content;padding:0 2px">
${bars}
</div>
</div>
<!-- 0%-Linie -->
<div style="height:1px;background:var(--c-border);margin-top:2px"></div>
</div>
${!hasRain ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-2);text-align:center">
Kein Regen erwartet
</div>` : ''}
`;
// Scroll zum aktuellen Slot wenn Heute
if (_selDay === 0) {
requestAnimationFrame(() => {
const wrap = el.querySelector('div[style*="overflow-x"]');
if (!wrap) return;
const nowIdx = slots.findIndex(h => parseInt(h.hour.split(':')[0]) === currentHour);
if (nowIdx > 2) {
wrap.scrollLeft = (nowIdx - 2) * 41; // ca. 38px + 3px gap
}
});
}
}
// ----------------------------------------------------------
// HUNDE-WETTER
// ----------------------------------------------------------
@ -390,10 +538,23 @@ window.Page_wetter = (() => {
if (!d) return;
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
margin-bottom:var(--space-4)">
const felltyp = (_appState?.activeDog ?? _appState?.dogs?.[0])?.fell_typ || null;
const _wl = _dogWeatherLabel(d, felltyp);
let html = `
<div style="border-radius:var(--radius);padding:var(--space-4);
background:${_wl.color}18;border:1px solid ${_wl.color}44;
margin-bottom:var(--space-4);text-align:center">
<div style="font-size:2rem;line-height:1;margin-bottom:4px">${_wl.emoji}</div>
<div style="font-weight:800;font-size:var(--text-lg);color:${_wl.color};line-height:1.2">
${_esc(_wl.label)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">
${_esc(_wl.sub)}
</div>
</div>
<h3 style="font-size:var(--text-base);font-weight:700;margin-bottom:var(--space-4)">
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Hunde-Wetter
Hunde-Hinweise
</h3>`;
// Asphalt-Temperatur
@ -500,6 +661,45 @@ window.Page_wetter = (() => {
`;
}
// Fell-spezifische Hinweise
if (felltyp) {
const tempNow = d.temp_max ?? 20;
let fellHint = null;
if (felltyp === 'doppel' && tempNow > 20) {
fellHint = { icon: 'thermometer-hot', color: '#F97316',
text: 'Doppeltes Fell — heute besonders auf Überhitzung achten.' };
} else if (felltyp === 'nackt' && tempNow < 15) {
fellHint = { icon: 'coat-hanger', color: '#60A5FA',
text: 'Nackthund braucht heute eine Hundejacke oder einen -pullover.' };
} else if (felltyp === 'kurz' && tempNow < 5) {
fellHint = { icon: 'snowflake', color: '#38BDF8',
text: 'Kurzhaar friert schnell — Hundemantel empfohlen.' };
}
if (fellHint) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${fellHint.color}1a;border:1px solid ${fellHint.color}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:${fellHint.color}">
<use href="/icons/phosphor.svg#${fellHint.icon}"></use>
</svg>
<div style="font-size:var(--text-sm)">${_esc(fellHint.text)}</div>
</div>
`;
}
}
// Schnüffel-Index + Hunde-Alter Chips
const ageYears = _dogAgeYears();
html += _dogAgeChip(ageYears);
html += `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
${_schnueffelChip(d)}
</div>
`;
// Wenn keine Hunde-Daten vorhanden
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
@ -513,6 +713,160 @@ window.Page_wetter = (() => {
el.innerHTML = html;
}
// ----------------------------------------------------------
// GASSI-SCORE (110)
// ----------------------------------------------------------
function _gassiScore(d) {
let score = 10;
const temp = d.temp_max ?? 20;
const precip = d.precip_prob ?? 0;
const wind = d.windspeed_max ?? 0;
const asphalt = d.asphalt_temp ?? 0;
// Temperatur (ideal: 1020°C)
if (temp > 30) score -= 3;
else if (temp > 25) score -= 1;
else if (temp < 0) score -= 3;
else if (temp < 5) score -= 1;
// Regen
if (precip > 70) score -= 3;
else if (precip > 40) score -= 2;
else if (precip > 20) score -= 1;
// Wind
if (wind > 60) score -= 2;
else if (wind > 40) score -= 1;
// Asphalt
if (asphalt > 55) score -= 2;
else if (asphalt > 45) score -= 1;
// Gewitter
if (d.thunderstorm) score -= 3;
return Math.max(1, Math.min(10, score));
}
function _gassiScoreBadge(d) {
const score = _gassiScore(d);
let color, text;
if (score >= 8) {
color = '#10B981';
text = 'Toller Gassi-Tag!';
} else if (score >= 5) {
color = '#F59E0B';
text = 'Geht so';
} else {
color = '#EF4444';
text = 'Lieber drinbleiben';
}
return `
<div style="display:flex;align-items:center;justify-content:center;
gap:var(--space-3);margin-bottom:var(--space-4);
padding:var(--space-3) var(--space-4);
border-radius:999px;
background:${color}1a;border:1.5px solid ${color}55">
<span style="font-size:var(--text-xs);font-weight:700;
color:var(--c-text-secondary);white-space:nowrap">🐾 Gassi-Score</span>
<span style="font-size:var(--text-2xl);font-weight:900;color:${color};line-height:1">
${score}
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">/&nbsp;10</span>
<span style="font-size:var(--text-xs);font-weight:600;color:${color};
white-space:nowrap"> ${_esc(text)}</span>
</div>
`;
}
// ----------------------------------------------------------
// SCHNÜFFEL-INDEX
// ----------------------------------------------------------
function _schnueffelIndex(d) {
const temp = d.temp_max ?? 20;
const precip = d.precip_prob ?? 0;
// Feuchtigkeit aus precip_prob ableiten
const feucht = precip > 60 ? 'feucht' : precip > 30 ? 'leicht-feucht' : 'trocken';
if (feucht === 'feucht' && temp >= 10 && temp <= 18)
return { label:'Exzellent 👃', color:'#10B981' };
if (feucht === 'feucht' && temp > 10 && temp <= 22)
return { label:'Sehr gut 👃', color:'#34D399' };
if (temp < 5)
return { label:'Gut (kalte Luft trägt Gerüche)', color:'#60A5FA' };
if (feucht === 'leicht-feucht' && temp >= 10 && temp <= 22)
return { label:'Gut 👃', color:'#4CAF50' };
if (feucht === 'trocken' && temp > 25)
return { label:'Schwach', color:'#94A3B8' };
return { label:'Mittel', color:'#F59E0B' };
}
function _schnueffelChip(d) {
const s = _schnueffelIndex(d);
return `
<span style="display:inline-flex;align-items:center;gap:4px;
font-size:var(--text-xs);border-radius:999px;
padding:3px 10px;background:${s.color}22;
border:1px solid ${s.color}55;color:${s.color};font-weight:600">
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#nose"></use></svg>
Schnüffel: ${_esc(s.label)}
</span>
`;
}
// ----------------------------------------------------------
// HUNDE-ALTER aus appState
// ----------------------------------------------------------
function _dogAgeYears() {
try {
const dog = _appState?.activeDog || _appState?.dog || _appState?.active_dog;
if (!dog) return null;
const geb = dog.geburtsdatum || dog.birthdate;
if (!geb) return null;
const birth = new Date(geb);
if (isNaN(birth)) return null;
const now = new Date();
let age = now.getFullYear() - birth.getFullYear();
const m = now.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
return age < 0 ? 0 : age;
} catch { return null; }
}
function _dogAgeChip(ageYears) {
if (ageYears === null) return '';
if (ageYears < 1) {
return `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#f59e0b1a;border:1px solid #f59e0b55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:#F59E0B"><use href="/icons/phosphor.svg#baby"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Welpe</strong> kurze Spaziergänge, max. 15 Min bei Hitze.
Gelenke und Pfoten besonders schonen.
</div>
</div>
`;
}
if (ageYears >= 8) {
return `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#6b7280 1a;border:1px solid #6b728055;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:#9CA3AF"><use href="/icons/phosphor.svg#person-simple-walk"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Seniorhund</strong> Hitze und Kälte vermeiden, kurze Runden bevorzugen.
Auf Gelenkbeschwerden achten.
</div>
</div>
`;
}
return '';
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN — Wetter
// ----------------------------------------------------------
@ -557,6 +911,58 @@ window.Page_wetter = (() => {
return '#F44336'; // level 4+
}
function _dogWeatherLabel(d, felltyp) {
const temp = d.temp_max ?? 20;
const tempMin = d.temp_min ?? temp;
const precip = d.precip_prob ?? 0;
const wind = d.windspeed_max ?? 0;
const asphalt = d.asphalt_temp ?? 0;
const wcode = d.weathercode ?? 0;
const isSnow = wcode >= 71 && wcode <= 77;
// Fell-spezifische Temperaturschwellen
const heatLimit = {
kurz: 25, mittel: 27, lang: 22, drahtaar: 26, doppel: 30, nackt: 20
}[felltyp] ?? 28;
const coldLimit = {
kurz: 8, mittel: 5, lang: 3, drahtaar: 5, doppel: -5, nackt: 15
}[felltyp] ?? 5;
if (d.thunderstorm)
return { label:'Gewitterangst-Wetter', sub:'Angsthasen lieber zu Hause lassen', emoji:'⛈️', color:'#7C3AED' };
if (isSnow && temp < 3)
return { label:'Schnee-Toben-Wetter', sub:'Pudel im Schnee — der Klassiker', emoji:'❄️', color:'#38BDF8' };
if (isSnow)
return { label:'Matschpfoten-Wetter', sub:'Pfoten nach der Runde gut abtrocknen', emoji:'🌨️', color:'#60A5FA' };
if (tempMin < 0 && precip < 30)
return { label:'Kristallklare Nasenluft', sub:'Kalt aber herrlich — Schnüffeln auf Maximum', emoji:'🌡️', color:'#60A5FA' };
if (temp < coldLimit && precip > 50)
return { label:'Kuschelwetter', sub:'Kurze Runde, dann ab auf das Sofa', emoji:'🏠', color:'#6B7280' };
if (temp < coldLimit)
return { label:'Fellkuschelwetter', sub:'Frisch und klar — ideal für aktive Rassen', emoji:'🧣', color:'#93C5FD' };
if (temp > heatLimit && asphalt > 50)
return { label:'Pfoten-Alarm!', sub:'Asphalt zu heiß — früh morgens oder abends raus', emoji:'🔥', color:'#EF4444' };
if (temp > heatLimit)
return { label:'Schwimm-Wetter', sub:'Bach oder See suchen — Hunde überhitzen schnell', emoji:'🏊', color:'#F97316' };
if (precip > 70 && temp < 15)
return { label:'Nass-Hund-Wetter', sub:'Handtuch bereit? Der Geruch kommt garantiert', emoji:'💧', color:'#3B82F6' };
if (precip > 70)
return { label:'Warm-Dusch-Wetter', sub:'Wer braucht noch ein Bad — der Regen übernimmt', emoji:'🌧️', color:'#60A5FA' };
if (precip > 30 && temp >= 10 && temp <= 20)
return { label:'Schnüffel-Wetter', sub:'Feuchte Luft = Nasenarbeit pur — Gerüche lieben das', emoji:'👃', color:'#34D399' };
if (wind > 50)
return { label:'Sturmfrisur-Wetter', sub:'Fell in alle Richtungen — Leine gut festhalten', emoji:'🌬️', color:'#A78BFA' };
if (wind > 30 && temp >= 15)
return { label:'Ohren-im-Wind-Wetter', sub:'Optimal für Hunde mit Schlappohren', emoji:'💨', color:'#A78BFA' };
if (precip > 30 && precip <= 70)
return { label:'Gassiregen-Wetter', sub:'Leichte Jacke, kurze Runde — Hund findet es gut', emoji:'🌦️', color:'#60A5FA' };
if (temp >= 18 && temp <= 26 && precip < 20)
return { label:'Perfektes Gassi-Wetter',sub:'Heute müssen alle Routen genossen werden', emoji:'🐾', color:'#10B981' };
if (temp >= 10 && temp < 18 && precip < 30)
return { label:'Klassisches Hunde-Wetter', sub:'Nicht zu warm, nicht zu kalt — Vierbeiner-Paradies', emoji:'🐕', color:'#4CAF50' };
return { label:'Gutes Hunde-Wetter', sub:'Raus mit dem Hund!', emoji:'🐶', color:'#10B981' };
}
function _tickLevel(risk) {
const r = (risk || '').toLowerCase();
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
@ -573,6 +979,104 @@ window.Page_wetter = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// MEINE WETTERREKORDE
// ----------------------------------------------------------
async function _loadRecords() {
// Nur wenn User eingeloggt
if (!_appState?.user) return;
// Nur einmal pro Seitenaufruf laden
if (_recordsLoaded) return;
_recordsLoaded = true;
try {
const res = await API.get('/weather/records');
_renderRecords(res?.records || null);
} catch {
// Stumm scheitern — Rekorde sind ein Nice-to-have
}
}
function _fmtDate(datum) {
if (!datum) return '';
try {
return new Date(datum + 'T12:00').toLocaleDateString('de', {
day: 'numeric', month: 'short', year: 'numeric'
});
} catch { return datum; }
}
function _recordCard(emoji, title, value, subtitle, color) {
return `
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);
border-radius:var(--radius);padding:var(--space-3) var(--space-3);
display:flex;flex-direction:column;gap:2px">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
display:flex;align-items:center;gap:4px;font-weight:600">
<span>${emoji}</span>
<span>${_esc(title)}</span>
</div>
<div style="font-size:var(--text-xl);font-weight:800;color:${color};line-height:1.1">
${_esc(value)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(subtitle)}
</div>
</div>
`;
}
function _renderRecords(records) {
const el = _container.querySelector('#wttr-records');
if (!el) return;
// Mindestens 3 Einträge nötig
if (!records || (records.gesamt_eintraege || 0) < 3) {
el.innerHTML = '';
return;
}
const cards = [];
if (records.kaeltester) {
const e = records.kaeltester;
const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum);
cards.push(_recordCard('🥶', 'Kältester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#60A5FA'));
}
if (records.heissester) {
const e = records.heissester;
const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum);
cards.push(_recordCard('🔥', 'Heißester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#EF4444'));
}
if (records.stuermischster) {
const e = records.stuermischster;
const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum);
cards.push(_recordCard('🌬️', 'Stürmischster Tag', `${Math.round(e.wind_kmh)} km/h`, sub, '#A78BFA'));
}
const regenCount = records.regen_eintraege || 0;
const gesamt = records.gesamt_eintraege || 0;
cards.push(_recordCard('💧', 'Regentage', `${regenCount} Einträge`, `von ${gesamt} Tagebucheinträgen`, '#3B82F6'));
el.innerHTML = `
<div style="margin-top:var(--space-5)">
<h3 style="font-size:var(--text-base);font-weight:700;
margin-bottom:var(--space-3);
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)">
<use href="/icons/phosphor.svg#trophy"></use>
</svg>
Meine Wetterrekorde
</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
${cards.join('')}
</div>
</div>
`;
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------

View file

@ -730,36 +730,34 @@ window.Page_wiki = (() => {
: '';
const _dogSvgLg = _DOG_SILHOUETTE.replace('width="48" height="48"', 'width="56" height="56"');
const photoHtml = rasse.foto_url
? `<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';this.parentElement.nextElementSibling.style.display='flex'">
</div>
<div class="wiki-detail-photo-placeholder" style="display:none">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>`
// Alle Fotos: Hauptbild zuerst, dann Community-Fotos
const allFotos = [];
if (rasse.foto_url) allFotos.push({ foto_url: rasse.foto_url, user_name: null });
(rasse.user_fotos || []).forEach(f => allFotos.push(f));
const photoHtml = allFotos.length
? `<div class="wiki-gallery-wrap">
<img class="wiki-detail-photo wiki-gallery-main" id="wiki-main-photo"
src="${_esc(allFotos[0].foto_url)}" alt="${_esc(rasse.name)}"
onerror="this.style.display='none';document.getElementById('wiki-photo-fallback').style.display='flex'">
<div id="wiki-photo-fallback" class="wiki-detail-photo-placeholder" style="display:none">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>
${allFotos.length > 1 ? `
<div class="wiki-gallery-strip" id="wiki-gallery-strip">
${allFotos.map((f, i) => `
<button class="wiki-gallery-thumb${i === 0 ? ' active' : ''}" data-idx="${i}"
aria-label="Foto ${i + 1}">
<img src="${_esc(f.foto_url)}" alt="" loading="lazy">
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${_esc(f.user_name)}</span>` : ''}
</button>`).join('')}
</div>` : ''}
<button class="wiki-gallery-expand" id="wiki-gallery-expand" aria-label="Vollbild">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrows-out"></use></svg>
</button>
</div>`
: `<div class="wiki-detail-photo-placeholder">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>`;
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
const userFotosHtml = (rasse.user_fotos || []).length
? `<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
font-weight:700;text-transform:uppercase;letter-spacing:.05em;
margin-bottom:var(--space-2)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Community-Fotos</div>
<div style="display:flex;gap:var(--space-2);overflow-x:auto;padding-bottom:4px">
${rasse.user_fotos.map(f => `
<div style="flex-shrink:0">
<img src="${_esc(f.foto_url)}" alt="${_esc(f.user_name)}"
style="height:80px;width:80px;object-fit:cover;
border-radius:var(--radius-md);cursor:pointer"
onclick="document.querySelector('.wiki-detail-photo')?.setAttribute('src','${_esc(f.foto_url)}')">
<div style="font-size:9px;color:var(--c-text-muted);text-align:center;
margin-top:2px;max-width:80px;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap">von ${_esc(f.user_name)}</div>
</div>
`).join('')}
</div>
</div>`
: '';
const userFotosHtml = '';
const body = `
${/* 1. Hero */ ''}
@ -851,6 +849,65 @@ window.Page_wiki = (() => {
document.getElementById('wiki-zuchter-placeholder')?.remove();
});
// Gallery-Thumbnails + Lightbox
const mainImg = document.getElementById('wiki-main-photo');
const strip = document.getElementById('wiki-gallery-strip');
if (strip && mainImg) {
strip.querySelectorAll('.wiki-gallery-thumb').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
mainImg.src = allFotos[idx].foto_url;
mainImg.style.display = '';
document.getElementById('wiki-photo-fallback').style.display = 'none';
strip.querySelectorAll('.wiki-gallery-thumb').forEach(b => b.classList.toggle('active', b === btn));
});
});
}
document.getElementById('wiki-gallery-expand')?.addEventListener('click', () => {
const src = mainImg?.src || allFotos[0]?.foto_url;
if (!src) return;
let curIdx = allFotos.findIndex(f => f.foto_url && src.endsWith(f.foto_url.split('/').pop()));
if (curIdx < 0) curIdx = 0;
function _lbOpen(idx) {
const f = allFotos[idx];
const lb = document.getElementById('wiki-lightbox');
lb.querySelector('.wlb-img').src = f.foto_url;
lb.querySelector('.wlb-caption').textContent = f.user_name ? `Foto von ${f.user_name}` : rasse.name;
lb.querySelector('.wlb-counter').textContent = `${idx + 1} / ${allFotos.length}`;
lb.querySelector('.wlb-prev').style.display = allFotos.length > 1 ? '' : 'none';
lb.querySelector('.wlb-next').style.display = allFotos.length > 1 ? '' : 'none';
curIdx = idx;
}
const lb = document.createElement('div');
lb.id = 'wiki-lightbox';
lb.innerHTML = `
<div class="wlb-backdrop"></div>
<div class="wlb-content">
<button class="wlb-close" aria-label="Schließen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
<button class="wlb-prev" aria-label="Zurück"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-left"></use></svg></button>
<img class="wlb-img" src="" alt="">
<button class="wlb-next" aria-label="Weiter"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-right"></use></svg></button>
<div class="wlb-caption"></div>
<div class="wlb-counter"></div>
</div>`;
document.body.appendChild(lb);
_lbOpen(curIdx);
const close = () => lb.remove();
lb.querySelector('.wlb-close').addEventListener('click', close);
lb.querySelector('.wlb-backdrop').addEventListener('click', close);
lb.querySelector('.wlb-prev').addEventListener('click', () => _lbOpen((curIdx - 1 + allFotos.length) % allFotos.length));
lb.querySelector('.wlb-next').addEventListener('click', () => _lbOpen((curIdx + 1) % allFotos.length));
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
if (e.key === 'ArrowLeft') lb.querySelector('.wlb-prev').click();
if (e.key === 'ArrowRight') lb.querySelector('.wlb-next').click();
});
});
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);

View file

@ -280,6 +280,75 @@ const UI = (() => {
// Alias für ältere Aufrufe
const escHtml = escape;
// ----------------------------------------------------------
// PAGE INFO — generische Seiten-Hilfe
// config: { pageId, title, icon?, intro, steps?: [{icon,title,text}], tip? }
// Erstes Öffnen: expandierter Banner. Danach: kleines ? im Header.
// ----------------------------------------------------------
function pageInfo(container, config) {
const seenKey = 'help_seen_' + config.pageId;
const seen = !!localStorage.getItem(seenKey);
function _buildSteps() {
if (!config.steps?.length) return '';
return config.steps.map(s => `
<div class="pinfo-step">
${s.icon ? `<span class="pinfo-step-icon">${_svgIcon(s.icon)}</span>` : ''}
<div>
${s.title ? `<div class="pinfo-step-title">${s.title}</div>` : ''}
<div class="pinfo-step-text">${s.text}</div>
</div>
</div>`).join('');
}
function _openModal() {
modal.open({
title: `${_svgIcon(config.icon || 'question')} ${config.title}`,
body: `
<div class="pinfo-modal">
<p class="pinfo-intro">${config.intro}</p>
${config.steps?.length ? `<div class="pinfo-steps">${_buildSteps()}</div>` : ''}
${config.tip ? `<div class="pinfo-tip">${_svgIcon('lightbulb')} ${config.tip}</div>` : ''}
</div>`,
});
}
// Kein automatischer absolut-positionierter Trigger mehr.
// Aufrufer kann openModal() nutzen und den Button selbst platzieren.
// Banner beim ersten Besuch
if (!seen) {
localStorage.setItem(seenKey, '1');
const banner = document.createElement('div');
banner.className = 'pinfo-banner';
banner.innerHTML = `
<div class="pinfo-banner-head">
<span class="pinfo-banner-icon">${_svgIcon(config.icon || 'info')}</span>
<span class="pinfo-banner-title">${config.title}</span>
<button class="pinfo-banner-close" aria-label="Schließen">${_svgIcon('x')}</button>
</div>
<div class="pinfo-banner-intro">${config.intro}</div>
${config.steps?.length ? `<div class="pinfo-steps pinfo-steps--compact">${_buildSteps()}</div>` : ''}
<button class="pinfo-banner-more">Mehr erfahren ${_svgIcon('arrow-right')}</button>
`;
banner.querySelector('.pinfo-banner-close').addEventListener('click', () => banner.remove());
banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); });
container.insertAdjacentElement('afterbegin', banner);
}
// Inline-Trigger-Button (für Aufrufer zum Einbetten)
function makeTriggerBtn() {
const btn = document.createElement('button');
btn.className = 'pinfo-trigger-inline';
btn.setAttribute('aria-label', 'Hilfe');
btn.innerHTML = _svgIcon('question');
btn.addEventListener('click', _openModal);
return btn;
}
return { openModal: _openModal, makeTriggerBtn };
}
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
@ -915,7 +984,7 @@ const UI = (() => {
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
escape, escHtml, help,
escape, escHtml, help, pageInfo,
saveToAlbum,
loadLeaflet,
leafletMarker,

View file

@ -49,9 +49,6 @@ window.Worlds = (() => {
_setupButtons();
_goTo(_cur, false);
show();
// Welten parallel rendern
_renderJetzt();
_renderHund();
}
function show(worldIdx) {
@ -66,14 +63,19 @@ window.Worlds = (() => {
if (worldIdx != null) _goTo(worldIdx, false);
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
// Nach Login/Logout neu rendern
// Nach Login/Logout: Config aus DB laden, dann rendern
const currentUserId = _state?.user?.id ?? null;
if (currentUserId !== _lastUserId) {
_lastUserId = currentUserId;
if (currentUserId) {
_loadConfigFromServer().then(() => { _renderJetzt(); _renderHund(); });
} else {
_cfgCache = null;
_renderJetzt();
_renderHund();
}
}
}
function hide() {
const ov = document.getElementById('worlds-overlay');
@ -125,6 +127,21 @@ window.Worlds = (() => {
_goTo(next, true);
if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
});
// Mausrad-Navigation (Desktop)
let _wheelCooldown = false;
track.addEventListener('wheel', e => {
e.preventDefault();
if (_wheelCooldown) return;
const next = e.deltaX > 30 || e.deltaY > 30 ? Math.min(2, _cur + 1)
: e.deltaX < -30 || e.deltaY < -30 ? Math.max(0, _cur - 1)
: _cur;
if (next === _cur) return;
_wheelCooldown = true;
setTimeout(() => { _wheelCooldown = false; }, 500);
_goTo(next, true);
if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
}, { passive: false });
}
function _goTo(idx, animated) {
@ -148,18 +165,28 @@ window.Worlds = (() => {
document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur));
}
function _fabOptions() {
const worldNames = ['jetzt', 'hund', 'welt'];
const chips = _chipsForWorld(worldNames[_cur]);
const opts = [];
for (const chip of chips) {
if (chip.fab) for (const o of chip.fab) { if (opts.length < 6) opts.push(o); }
}
return opts;
}
function _updateFab() {
const fab = document.getElementById('worlds-fab');
if (!fab) return;
const icons = ['note-pencil', 'paw-print', 'warning'];
const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden'];
fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`);
fab.title = titles[_cur];
const opts = _fabOptions();
if (!opts.length) { fab.style.display = 'none'; return; }
fab.style.display = '';
fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`);
fab.title = 'Schnellaktion';
}
function _setupButtons() {
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
document.getElementById('worlds-settings')?.addEventListener('click', () => navigateTo('settings'));
document.getElementById('worlds-back')?.addEventListener('click', () => show());
document.querySelectorAll('.wdot').forEach((dot, i) => {
dot.style.pointerEvents = 'auto';
@ -179,21 +206,13 @@ window.Worlds = (() => {
}
function _openFab() {
const isWelt = _cur === 2;
const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar
const options = _fabOptions();
if (!options.length) return;
const options = isWelt ? [
{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' },
{ icon:'dog', color:'#3B82F6', label:'Verlorenen Hund melden', sub:'Hilf beim Wiederfinden', page:'lost' },
{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' },
] : [
{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' },
{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen',sub:'Übung absolviert', page:'uebungen' },
{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' },
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' },
];
const meldenPages = new Set(['poison','lost','recalls','map']);
const meldenCount = options.filter(o => meldenPages.has(o.page)).length;
const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?';
// Overlay erstellen
const ov = document.createElement('div');
ov.id = 'fab-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end';
@ -203,9 +222,7 @@ window.Worlds = (() => {
padding:20px 16px calc(env(safe-area-inset-bottom,16px) + 16px);
box-shadow:0 -8px 32px rgba(0,0,0,0.2)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div style="font-size:var(--text-base);font-weight:700">
${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'}
</div>
<div style="font-size:var(--text-base);font-weight:700">${title}</div>
<button id="fab-close" style="background:var(--c-border);border:none;border-radius:50%;
width:28px;height:28px;cursor:pointer;display:flex;align-items:center;justify-content:center">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
@ -244,6 +261,10 @@ window.Worlds = (() => {
_close();
const page = btn.dataset.page;
const action = btn.dataset.action;
if (action === 'quickGassi') {
_openQuickGassi();
return;
}
navigateTo(page);
if (action === 'openNew') {
setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
@ -252,40 +273,183 @@ window.Worlds = (() => {
});
}
// ── SCHNELL-GASSI ─────────────────────────────────────────────
async function _openQuickGassi() {
const dog = _dogs[_dogIdx] || null;
if (!dog) {
UI.toast?.error('Kein Hund gefunden. Bitte zuerst ein Profil anlegen.');
navigateTo('dog-profile');
return;
}
// Wetter aus Cache holen (kein Wait nötig)
let weatherData = null;
try {
const wc = _wLoad('weather');
if (wc?.data) weatherData = wc.data;
} catch {}
let selectedMin = 30;
const durations = [15, 30, 45, 60];
const ov = document.createElement('div');
ov.id = 'quick-gassi-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:400;display:flex;flex-direction:column;justify-content:flex-end';
const weatherLine = weatherData
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:6px">
🌡 ${Math.round(weatherData.temp_c)}° · ${_esc(weatherData.desc?.split(' ')[0] || '')}
</div>` : '';
ov.innerHTML = `
<div id="qg-backdrop" style="position:absolute;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(3px)"></div>
<div style="position:relative;z-index:1;background:var(--c-bg);border-radius:24px 24px 0 0;
padding:24px 16px calc(env(safe-area-inset-bottom,16px) + 20px);
box-shadow:0 -8px 32px rgba(0,0,0,0.25)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div>
<div style="font-size:var(--text-base);font-weight:700">🐾 Schnell-Gassi</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${_esc(dog.name)} · ohne GPS
</div>
${weatherLine}
</div>
<button id="qg-close" style="background:var(--c-border);border:none;border-radius:50%;
width:32px;height:32px;cursor:pointer;display:flex;align-items:center;justify-content:center">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div style="font-size:var(--text-sm);font-weight:600;margin-bottom:10px">Dauer</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:24px">
${durations.map(d => `
<button class="qg-dur" data-min="${d}"
style="padding:12px 6px;border-radius:12px;border:2px solid ${d === selectedMin ? 'var(--c-primary)' : 'var(--c-border)'};
background:${d === selectedMin ? 'var(--c-primary-subtle)' : 'var(--c-bg-card)'};
cursor:pointer;font-weight:700;font-size:var(--text-sm);
color:${d === selectedMin ? 'var(--c-primary)' : 'var(--c-text)'}">
${d} min
</button>
`).join('')}
</div>
<button id="qg-submit" style="width:100%;padding:16px;border-radius:14px;
background:var(--c-primary);color:white;border:none;cursor:pointer;
font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;justify-content:center;gap:8px">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg>
Eintragen
</button>
</div>
`;
document.body.appendChild(ov);
const _close = () => ov.remove();
ov.querySelector('#qg-backdrop').addEventListener('click', _close);
ov.querySelector('#qg-close').addEventListener('click', _close);
// Dauer-Toggle
ov.querySelectorAll('.qg-dur').forEach(btn => {
btn.addEventListener('click', () => {
selectedMin = parseInt(btn.dataset.min);
ov.querySelectorAll('.qg-dur').forEach(b => {
const active = parseInt(b.dataset.min) === selectedMin;
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-bg-card)';
b.style.color = active ? 'var(--c-primary)' : 'var(--c-text)';
});
});
});
// Eintragen
ov.querySelector('#qg-submit').addEventListener('click', async () => {
const submitBtn = ov.querySelector('#qg-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird eingetragen…';
try {
const payload = {
typ: 'gassi',
titel: 'Schnell-Gassi 🐾',
text: `Kurze Runde, ${selectedMin} Minuten`,
};
if (weatherData) {
payload.weather_json = JSON.stringify(weatherData);
}
await API.post(`/dogs/${dog.id}/diary`, payload);
_close();
UI.toast?.success(`Gassi eingetragen! ${selectedMin} min 🐾`);
// Streak-Cache invalidieren
try { localStorage.removeItem('w3_streak_' + dog.id); } catch {}
// JETZT-Welt neu rendern für aktuellen Streak
setTimeout(() => _renderJetzt(), 300);
} catch (err) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg> Eintragen';
UI.toast?.error('Fehler beim Eintragen. Bitte erneut versuchen.');
}
});
}
// ── CHIP-KONFIGURATION ──────────────────────────────────────
// Alle verfügbaren Chips mit Metadaten
const _ALL_CHIPS = [
{ icon:'note-pencil', label:'Notizblock', page:'notes' },
{ icon:'currency-eur', label:'Ausgaben', page:'expenses' },
{ icon:'note-pencil', label:'Notizblock', page:'notes',
fab:[{ icon:'note-pencil', color:'#10B981', label:'Neue Notiz', sub:'Schnellnotiz erstellen', page:'notes', action:'openNew' }] },
{ icon:'currency-eur', label:'Ausgaben', page:'expenses',
fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] },
{ icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' },
{ icon:'handshake', label:'Playdate', page:'playdate' },
{ icon:'handshake', label:'Playdate', page:'playdate',
fab:[{ icon:'handshake', color:'#F59E0B', label:'Playdate anfragen', sub:'Treffen mit anderen Hunden', page:'playdate', action:'openNew' }] },
{ icon:'chat-circle-dots', label:'Nachrichten', page:'chat' },
{ icon:'sun', label:'Wetter', page:'wetter' },
{ icon:'book-open', label:'Tagebuch', page:'diary' },
{ icon:'heartbeat', label:'Gesundheit', page:'health' },
{ icon:'target', label:'Übungen', page:'uebungen' },
{ icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'},
{ icon:'heart', label:'Adoption', page:'adoption' },
{ icon:'house-line', label:'Sitting', page:'sitting' },
{ icon:'book-open', label:'Tagebuch', page:'diary',
fab:[{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }] },
{ icon:'heartbeat', label:'Gesundheit', page:'health',
fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' },
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] },
{ icon:'target', label:'Übungen', page:'uebungen',
fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] },
{ icon:'list-checks', label:'Trainings-\npläne', page:'trainingsplaene',
fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] },
{ icon:'heart', label:'Adoption', page:'adoption',
fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] },
{ icon:'house-line', label:'Sitting', page:'sitting',
fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] },
{ icon:'books', label:'Wiki', page:'wiki' },
{ icon:'scales', label:'Wurfbörse', page:'wurfboerse' },
{ icon:'map-trifold', label:'Karte', page:'map' },
{ icon:'push-pin', label:'Forum', page:'forum' },
{ icon:'users', label:'Freunde', page:'friends' },
{ icon:'paw-print', label:'Gassi', page:'walks' },
{ icon:'skull', label:'Giftköder', page:'poison' },
{ icon:'warning-circle', label:'Rückrufe', page:'recalls' },
{ icon:'dog', label:'Verlorene', page:'lost' },
{ icon:'path', label:'Routen', page:'routes' },
{ icon:'calendar-dots', label:'Events', page:'events' },
{ icon:'map-trifold', label:'Karte', page:'map',
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
{ icon:'push-pin', label:'Forum', page:'forum',
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
{ icon:'users', label:'Freunde', page:'friends',
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
{ icon:'paw-print', label:'Gassi', page:'walks',
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
{ icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] },
{ icon:'skull', label:'Giftköder', page:'poison',
fab:[{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }] },
{ icon:'warning-circle', label:'Rückrufe', page:'recalls',
fab:[{ icon:'warning-circle', color:'#EF4444', label:'Rückruf melden', sub:'Produkt oder Futter', page:'recalls', action:'openNew' }] },
{ icon:'dog', label:'Verlorene', page:'lost',
fab:[{ icon:'dog', color:'#3B82F6', label:'Verlorenen melden', sub:'Hilf beim Wiederfinden', page:'lost' }] },
{ icon:'path', label:'Routen', page:'routes',
fab:[{ icon:'path', color:'#10B981', label:'Route aufzeichnen', sub:'GPS-Tracking starten', page:'routes', action:'openNew' }] },
{ icon:'calendar-dots', label:'Events', page:'events',
fab:[{ icon:'calendar-dots', color:'#06B6D4', label:'Event erstellen', sub:'Veranstaltung ankündigen', page:'events', action:'openNew' }] },
{ icon:'sparkle', label:'Jobs', page:'jobs' },
{ icon:'book-open', label:'Knigge', page:'knigge' },
{ icon:'film-slate', label:'Filme', page:'movies' },
{ icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' },
{ icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' },
{ icon:'sparkle', label:'Social', page:'social', role:'social' },
{ icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder',
fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] },
{ icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder',
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] },
{ icon:'sparkle', label:'Social', page:'social', role:'social',
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
];
@ -296,12 +460,40 @@ window.Worlds = (() => {
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
};
function _getConfig() {
try { return JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; }
catch { return _DEFAULT_CONFIG; }
// _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default
let _cfgCache = null;
async function _loadConfigFromServer() {
try {
const res = await API.get('/profile/world-config');
if (res?.config) {
_cfgCache = res.config;
try { localStorage.setItem('world_chips', JSON.stringify(_cfgCache)); } catch {}
return;
}
// Noch nichts in DB: lokale Config hochladen (einmalige Migration)
const local = (() => { try { return JSON.parse(localStorage.getItem('world_chips') || 'null'); } catch { return null; } })();
if (local) {
_cfgCache = local;
API.put('/profile/world-config', { config: local }).catch(() => {});
return;
}
} catch {}
// Fallback: localStorage → Default
try { _cfgCache = JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; }
catch { _cfgCache = _DEFAULT_CONFIG; }
}
function _getConfig() {
return _cfgCache || _DEFAULT_CONFIG;
}
function _saveConfig(cfg) {
_cfgCache = cfg;
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
if (_state?.user) {
API.put('/profile/world-config', { config: cfg }).catch(() => {});
}
}
function _chipMeta(page) {
return _ALL_CHIPS.find(c => c.page === page) || null;
@ -574,18 +766,13 @@ window.Worlds = (() => {
async function _loadDailyImage(dog) {
if (!dog) return null;
const todayKey = 'bg_' + new Date().toISOString().slice(0, 10);
const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10);
const cached = _wLoad(todayKey);
if (cached?.data) return cached.data;
try {
const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`);
const entries = r.data?.entries || r.data || [];
const withPhotos = entries.filter(e => (e.foto_urls?.length || e.foto_url));
if (!withPhotos.length) { const u = dog.foto_url || null; if(u) _wSave(todayKey, u); return u; }
const day = Math.floor(Date.now() / 86400000);
const entry = withPhotos[day % withPhotos.length];
const url = (entry.foto_urls?.[0] || entry.foto_url);
_wSave(todayKey, url);
const dash = await API.dogs.welcomeDashboard(dog.id);
const url = dash?.random_photo?.url || dog.foto_url || null;
if (url) _wSave(todayKey, url);
return url;
} catch { return dog.foto_url || null; }
}
@ -625,10 +812,11 @@ window.Worlds = (() => {
const user = _state?.user;
el.innerHTML = _skeleton(3);
const [weatherRes, dogsRes, alertsRes] = await Promise.allSettled([
const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([
_getCachedWeather(),
user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }),
user ? _getNearbyAlerts() : Promise.resolve([]),
user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }),
]);
const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 };
@ -637,6 +825,7 @@ window.Worlds = (() => {
const dogList = dogsObj.data || [];
const dog = dogList[0] || null;
const alertList = alertsRes.value || [];
const totalKm = achRes.value?.data?.stats?.total_km ?? null;
const isOffline = weatherObj.fromCache && dogsObj.fromCache;
const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0);
@ -712,7 +901,7 @@ window.Worlds = (() => {
<div class="world-info-title">
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
</div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}</div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div>
</div>
${user ? userAvatarHtml : ''}
</div>

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v651';
const CACHE_VERSION = 'by-v700';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
@ -202,7 +202,8 @@ self.addEventListener('fetch', event => {
.then(resp => {
if (resp.ok) {
_cacheMark(url.pathname);
caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
const toCache = resp.clone();
caches.open(CACHE_API).then(c => c.put(event.request, toCache));
}
return resp;
})

View file

@ -222,6 +222,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
"sunrise,sunset"
"&hourly=precipitation_probability,precipitation,weathercode"
"&timezone=auto&forecast_days=7"
)
pollen_url = (
@ -245,6 +246,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
raw = forecast_resp.json()
daily = raw.get('daily', {})
hourly_fc = raw.get('hourly', {})
timezone = raw.get('timezone', 'auto')
dates = daily.get('time', [])
@ -261,6 +263,24 @@ async def get_forecast(lat: float, lon: float) -> dict:
sunrises = daily.get('sunrise', [])
sunsets = daily.get('sunset', [])
# --- Hourly precipitation data grouped by day ---
hourly_times = hourly_fc.get('time', [])
hourly_pp = hourly_fc.get('precipitation_probability', [])
hourly_precip = hourly_fc.get('precipitation', [])
hourly_wcode = hourly_fc.get('weathercode', [])
# Build: date_str → list of {hour, precip_prob, precip, weathercode}
_hourly_by_day: dict = {}
for idx, ts_str in enumerate(hourly_times):
day_str = ts_str[:10] # 'YYYY-MM-DD'
hour_str = ts_str[11:16] # 'HH:MM'
entry = {
'hour': hour_str,
'precip_prob': hourly_pp[idx] if idx < len(hourly_pp) else None,
'precip': hourly_precip[idx] if idx < len(hourly_precip) else None,
'weathercode': int(hourly_wcode[idx]) if idx < len(hourly_wcode) and hourly_wcode[idx] is not None else None,
}
_hourly_by_day.setdefault(day_str, []).append(entry)
# --- Pollen (optional) ---
pollen_daily: dict | None = None
if not isinstance(pollen_resp, Exception):
@ -361,6 +381,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
'zecken': zecken,
'thunderstorm': wcode in {95, 96, 99},
'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0),
'hourly': _hourly_by_day.get(date_str, []),
})
result = {'timezone': timezone, 'days': days}

348
scripts/dog_quotes.json Normal file
View file

@ -0,0 +1,348 @@
[
{ "text": "Der Hund ist des Menschen bester Freund.", "autor": "Sprichwort", "kategorie": "allgemein" },
{ "text": "Ein treuer Hund ist besser als ein falscher Freund.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde haben alle guten Eigenschaften des Menschen, ohne gleichzeitig seine Fehler zu besitzen.", "autor": "Friedrich II.", "kategorie": "weisheit" },
{ "text": "Dem Hunde, wenn er gut erzogen, wird selbst ein weiser Mann gewogen.", "autor": "Johann Wolfgang von Goethe", "kategorie": "training" },
{ "text": "Die Treue eines Hundes ist ein kostbares Geschenk, das nicht minder bindende moralische Verpflichtungen auferlegt als die Freundschaft eines Menschen.", "autor": "Konrad Lorenz", "kategorie": "treue" },
{ "text": "Wenn du einen verhungerten Hund aufliest und machst ihn satt, dann wird er dich nicht beißen. Das ist der Grundunterschied zwischen Hund und Mensch.", "autor": "Mark Twain", "kategorie": "weisheit" },
{ "text": "Woran sollte man sich von der endlosen Verstellung, Falschheit und Heimtücke der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen schauen kann?", "autor": "Arthur Schopenhauer", "kategorie": "weisheit" },
{ "text": "Ein Leben ohne Hund ist ein Irrtum.", "autor": "Carl Zuckmayer", "kategorie": "allgemein" },
{ "text": "Man kann auch ohne Hund leben, aber es lohnt sich nicht.", "autor": "Heinz Rühmann", "kategorie": "allgemein" },
{ "text": "Ein Hund ist das Einzige auf der Welt, das dich mehr liebt als sich selbst.", "autor": "Josh Billings", "kategorie": "liebe" },
{ "text": "In den Augen meines Hundes liegt mein ganzes Glück, all mein Inneres, Krankes, Wundes heilt in seinem Blick.", "autor": "Friederike Kempner", "kategorie": "liebe" },
{ "text": "Hunde sind nicht unser ganzes Leben, aber sie machen unser Leben ganz.", "autor": "Roger Caras", "kategorie": "liebe" },
{ "text": "Mein kleiner Hund — ein Herzschlag an meinen Füßen.", "autor": "Edith Wharton", "kategorie": "liebe" },
{ "text": "Der Hund ist ein Ehrenmann. Ich hoffe, in seinen Himmel zu kommen, nicht in den des Menschen.", "autor": "Mark Twain", "kategorie": "humor" },
{ "text": "Wenn du dich einsam fühlst, kaufe dir keinen Freund — leih dir einen Hund.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Ein Hund, der bellt, ist mehr wert als ein Mensch, der lügt.", "autor": "Henry de Montherlant", "kategorie": "weisheit" },
{ "text": "Ich hatte lieber meinen Hund bellen hören als einen Mann schwören, er liebe mich.", "autor": "William Shakespeare", "kategorie": "weisheit" },
{ "text": "Hunde sind die Führer des Planeten. Wenn du zwei Lebewesen siehst, von denen eins einen Haufen macht und das andere ihn aufhebt — wer ist wohl der Chef?", "autor": "Jerry Seinfeld", "kategorie": "humor" },
{ "text": "Ein Hund bringt dir Stöcke, weil er denkt, du mochtest ihn so sehr, dass du ihn sofort weggeworfen hast.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde lehren uns eine Menge über das Leben: Treue, Ausdauer und vor dem Hinlegen dreimal im Kreis zu drehen.", "autor": "Robert Benchley", "kategorie": "humor" },
{ "text": "Ich arbeite hart, damit mein Hund ein besseres Leben führen kann.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wer sagt, dass Geld nicht glücklich macht, hat noch nie ein weinendes Kind gesehen, dem gerade ein Welpe geschenkt wurde.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Das Schönste, was ein Mensch besitzen kann, ist die Liebe eines Hundes.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund ist der einzige Freund, den du kaufen kannst.", "autor": "Sprichwort", "kategorie": "allgemein" },
{ "text": "Hundehaare sind das ultimative Accessoire für jede Kleidung.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wenn dein Hund übergewichtig ist, dann warst du nicht genug an der frischen Luft.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Gassi gehen ist keine Pflicht — es ist ein Abenteuer.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein müder Hund ist ein guter Hund.", "autor": "Sprichwort", "kategorie": "training" },
{ "text": "Hunde haben mehr Liebe zu verschenken als die meisten Menschen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Kein Psychiater der Welt kann einem Menschen so helfen wie ein Hund.", "autor": "Bern Williams", "kategorie": "liebe" },
{ "text": "Den eigenen Fokus auf erwünschtes Verhalten zu richten anstatt auf das, was nicht klappt, ist nicht nur für die Hundeerziehung ein großartiger Ansatz.", "autor": "Christina Hanf", "kategorie": "training" },
{ "text": "Die Menschen erwarten zu viel von ihrem Hund und zu wenig von sich selbst.", "autor": "Bob Bailey", "kategorie": "training" },
{ "text": "Gewalt bewirkt niemals etwas Gutes, weder bei Menschen noch bei Hunden.", "autor": "Turid Rugaas", "kategorie": "training" },
{ "text": "Training bedeutet, Situationen zu schaffen, in denen der Hund viel richtig machen kann.", "autor": "Gudrun Scholz", "kategorie": "training" },
{ "text": "Hunde erziehen heißt, sich selbst zu erziehen.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein gut erzogener Hund macht seinem Besitzer Ehre.", "autor": "Sprichwort", "kategorie": "training" },
{ "text": "Lass schlafende Hunde liegen.", "autor": "Sprichwort", "kategorie": "weisheit" },
{ "text": "Wenn ein alter Hund bellt, soll man hinausschauen.", "autor": "Sprichwort", "kategorie": "weisheit" },
{ "text": "Der Hund, der viel bellt, beißt selten.", "autor": "Sprichwort", "kategorie": "weisheit" },
{ "text": "Beißt dich ein Hund, zeig ihm nicht die Wunde.", "autor": "Sprichwort", "kategorie": "weisheit" },
{ "text": "Mit Hunden und Kindern kommt man immer gut an.", "autor": "Sprichwort", "kategorie": "allgemein" },
{ "text": "Streichle einen Hund und er gehört dir fürs Leben.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund vergisst keine Freundlichkeit.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde kennen keine Vorurteile.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund liebt dich, egal wie du aussiehst oder was du hast.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde sind Meister darin, im Augenblick zu leben.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wenn Menschen so treu wären wie Hunde, wäre die Welt eine bessere.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Der Blick eines Hundes kann mehr sagen als tausend Worte.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Jeder Mensch verdient einen Hund. Umgekehrt ist auch wahr: Jeder Hund verdient einen guten Menschen.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Ein Hund weiß nicht, was Einsamkeit bedeutet — er ist immer bei dir.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde lieben bedingungslos — etwas, das wir Menschen erst lernen müssen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Das Leben ist zu kurz für schlechtes Hundefutter.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Wer täglich mit seinem Hund spaziert, lebt länger und glücklicher.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Bewegung ist das beste Medikament — für Hund und Mensch.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein Hund an der Leine hält auch sein Herrchen fit.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Laufen mit dem Hund ist die schönste Form des Sports.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hundebesitzer gehen Gassi. Hunde hingegen gehen auf eine Geruchssafari.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Immer wenn du beim Gassi gehen auf dein Handy schaust, verpasst du ein Abenteuer mit deinem Hund.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Die beste Therapie hat vier Pfoten und einen Schwanz.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde bringen das Beste in uns zum Vorschein.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Wer Hunde hat, hat selten Feinde.", "autor": "Sprichwort", "kategorie": "allgemein" },
{ "text": "Hunde verstehen uns oft besser als wir uns selbst.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund hat kein Ego — dafür hat er alles andere.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "The fidelity of a dog is a precious gift demanding no less binding moral responsibilities than the friendship of a human being.", "autor": "Konrad Lorenz", "kategorie": "treue" },
{ "text": "Dogs are not our whole life, but they make our lives whole.", "autor": "Roger Caras", "kategorie": "liebe" },
{ "text": "A dog is the only thing on earth that loves you more than he loves himself.", "autor": "Josh Billings", "kategorie": "liebe" },
{ "text": "Happiness is a warm puppy.", "autor": "Charles M. Schulz", "kategorie": "liebe" },
{ "text": "The better I get to know men, the more I find myself loving dogs.", "autor": "Charles de Gaulle", "kategorie": "humor" },
{ "text": "No matter how little money and how few possessions you own, having a dog makes you rich.", "autor": "Louis Sabin", "kategorie": "liebe" },
{ "text": "My goal in life is to be as good of a person as my dog already thinks I am.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Dogs have a way of finding the people who need them.", "autor": "Thom Jones", "kategorie": "liebe" },
{ "text": "A dog will teach you unconditional love. If you can have that in your life, things won't be too bad.", "autor": "Robert Wagner", "kategorie": "liebe" },
{ "text": "Ein Hund ist ein Teil unseres Lebens. Für unseren Hund sind wir sein ganzes Leben.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde geben mehr als sie nehmen — das können die wenigsten von sich behaupten.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Der größte Fehler beim Hundetraining ist, die Geduld zu vergessen.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Belohnung schafft Vertrauen — Strafe schafft Angst.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Positive Verstärkung ist der Schlüssel zu einer glücklichen Hund-Mensch-Beziehung.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein Hund, der Freude am Lernen hat, ist ein Hund, der Freude am Leben hat.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Konsequenz bedeutet nicht Strenge — es bedeutet Verlässlichkeit.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein Hund braucht Grenzen, so wie ein Kind Grenzen braucht — aus Liebe, nicht aus Macht.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Hunde folgen nicht den Gesetzen der Logik — sie folgen den Gesetzen des Herzens.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wenn du deinen Hund liebst, zeig es — er wartet den ganzen Tag darauf.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Kein Haus ist groß genug für einen Hund und seine Haare.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Früher hatten wir ein schönes Sofa. Jetzt hat unser Hund ein schönes Sofa.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund und ich haben eine Vereinbarung: Ich tue so, als würde ich das Essen nicht mitessen wollen, und er tut so, als würde er es glauben.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hundeliebe ist die reinste Form der Liebe — sie fragt nichts und gibt alles.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund ist der einzige Zeuge, der keine Erwartungen an dich stellt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Mit einem Hund an deiner Seite bist du nie wirklich allein.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Der Schwanz eines Hundes lügt nie.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde wissen genau, wann du traurig bist — und sie entscheiden sich trotzdem, bei dir zu bleiben.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Wenn ein Hund dir vertraut, hat er dir sein Herzstück gegeben.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde mögen alle Menschen, die sie kennen — bis auf den Tierarzt. Und selbst den vergeben sie.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Gib deinem Hund heute ein extra Leckerli — er hat es sich verdient, einfach indem er da ist.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund ist das lebende Beispiel dafür, dass man mit nichts als Liebe reich sein kann.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde bellen, die Karawane zieht weiter.", "autor": "Sprichwort", "kategorie": "weisheit" },
{ "text": "Wer einmal einen Hund gehabt hat, kann sich ein Leben ohne nicht mehr vorstellen.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hundehaare auf dem Sofa sind kein Problem — sie sind ein Zeichen von Liebe.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund ist kein Tier — er ist ein Familienmitglied.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Das Einzige, was ein Hund von dir will, ist deine Zeit.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde lachen auch — nur mit dem Schwanz.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund trauert nie um eine verpasste Chance — er schafft sofort die nächste.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde leben im Moment. Das sollten wir auch.", "autor": "Cesar Millan", "kategorie": "weisheit" },
{ "text": "Dein Hund braucht kein Geschenk zu deinem Geburtstag — für ihn ist jeder Tag mit dir ein Fest.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund riecht deine Stimmung, bevor du sie selbst bemerkst.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde kennen keine schlechten Tage — sie kennen nur schlechte Momente, die mit einem Kuss besser werden.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Jedem Hundebesitzer passiert es einmal: Er redet mit seinem Hund als wäre er ein Mensch. Dem Hund ist das egal — er hört einfach zu.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde sind die einzigen Wesen, die dich für das lieben, was du bist, nicht für das, was du hast.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein gut erzogener Hund ist das Ergebnis eines gut erzogenen Besitzers.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Im Frühling, wenn die Erde sich erneuert, beginnt auch der Hund sein Fell zu verlieren — liberal.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Treu wie ein Hund — das ist kein Vergleich, das ist ein Kompliment.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Wer einen Hund streichelt, streichelt auch sich selbst.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hunde senken den Blutdruck — zumindest bis sie auf dem weißen Teppich kotzen.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Studien zeigen: Hundebesitzer haben ein gesünderes Herz. Kein Wunder — ihr Herz wird täglich trainiert.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Gassi gehen bei Regen ist kein Spaß — aber dein Hund findet es toll.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Der einzige Fehler, den ein Hund macht, ist, zu früh zu sterben.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde machen keine Politik. Deswegen sind sie klüger als wir.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Haus wird zum Zuhause, wenn ein Hund darin wohnt.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Der Hund ist der älteste Freund des Menschen — und noch immer der beste.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Wer einen Hund hat, hat keinen Grund zu klagen — höchstens über Hundehaare.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Manche Menschen verdienen nicht den Hund, den sie haben.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein guter Mensch behandelt seinen Hund wie einen Freund.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Wo ein Hund ist, ist Freude.", "autor": "Sprichwort", "kategorie": "allgemein" },
{ "text": "Ein Hund weiß nicht, was Gleichgültigkeit ist.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde erkennen gute Menschen auf Anhieb.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Vertraue dem Urteil deines Hundes über andere Menschen.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund, der dir ohne Hintergedanken folgt, ist ein Geschenk.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hundeerziehung ist Beziehungspflege.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Wenn du deinen Hund gut kennst, kennt er dich noch besser.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Das schönste Geräusch am Morgen: der Schwanz deines Hundes, der wedelt, weil du aufgewacht bist.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund redet nicht — und sagt doch alles.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde haben keine Worte für Enttäuschung — nur für Freude.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Für deinen Hund bist du der Beste — immer, ohne Ausnahme.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Der Hund ist das einzige Tier, das den Menschen als seinen Gott betrachtet.", "autor": "George Bernard Shaw", "kategorie": "weisheit" },
{ "text": "Bis es keinen Hund in meinem Leben gibt, wird mein Herz nie ganz gefüllt sein.", "autor": "Roger Caras", "kategorie": "liebe" },
{ "text": "Hunde sind die Philosophen des Alltags — sie leben, ohne zu grübeln.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Der weise Hund macht einen Bogen um den Stock.", "autor": "Sprichwort", "kategorie": "weisheit" },
{ "text": "Hunde laufen auf vier Beinen und tragen dennoch die ganze Welt auf dem Rücken ihrer Besitzer.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund ist der Beweis, dass die Natur manchmal Vollkommenes erschafft.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde haben die Weisheit, den Menschen dort zu treffen, wo er steht.", "autor": "Orhan Pamuk", "kategorie": "weisheit" },
{ "text": "Für meinen Hund bin ich kein Held, kein Versager — ich bin einfach sein Zuhause.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Der Hund ist das Symbol der Loyalität, weil er sie nicht kennt — er lebt sie.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund bellte einmal einen Dieb an — und Menschen nennen ihn seitdem treu.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Wer mit Hunden liegt, steht mit Flöhen auf.", "autor": "Sprichwort", "kategorie": "humor" },
{ "text": "Zwei Dinge machen das Leben leichter: ein guter Kaffee und ein treuer Hund.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Der Hund liebt die Wahrheit — er ist das ehrlichste Lebewesen auf dem Planeten.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Es gibt keine schlechten Hunde. Nur schlechte Erziehung.", "autor": "Cesar Millan", "kategorie": "training" },
{ "text": "Ein Hund ist glücklich, wenn er beschäftigt ist und weiß, was von ihm erwartet wird.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Lob kostet nichts und bedeutet dem Hund alles.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Strafe zeigt dem Hund nur, was er nicht soll — Belohnung zeigt ihm, was er soll.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein entspannter Hund ist ein trainierter Hund.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Jede Rasse hat ihre Eigenheiten — der gute Hundetrainer kennt sie alle.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Wenn du willst, dass dein Hund gehorcht, musst du zuerst lernen zu verstehen.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Training ist Dialog, nicht Monolog.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein gelangweilter Hund ist ein Problemhund.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Konsequenz beim Training bedeutet: Heute gilt dasselbe wie gestern.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Der beste Hundetrainer ist der, dem der Hund freiwillig folgt.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Hunde lernen durch Wiederholung und Belohnung — nicht durch Zwang.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Körpersprache ist alles im Hundetraining — was du sagst, ist weniger wichtig als wie du es sagst.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein Hund verzeiht Fehler schneller als jeder Mensch.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde halten keine Groll — sie lieben weiter, egal was war.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Treue ist keine Eigenschaft — sie ist eine Entscheidung. Hunde haben sie nie bereut.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund folgt dir in die Dunkelheit, ohne zu zögern.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Die Treue eines Hundes beschämt uns manchmal, weil wir so selten dasselbe bieten.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund, der wartet, zeigt mehr Liebe als Worte ausdrücken können.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Der japanische Hund Hachiko wartete neun Jahre auf seinen Herrn. Frage dich, wie lange du warten würdest.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Kein anderes Wesen auf der Welt ist so treu wie ein Hund.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde wählen ihre Besitzer nicht aus — und lieben sie trotzdem.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund bleibt bei dir, wenn alle anderen gehen.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Wenn dein Hund dir seinen Bauch zeigt, schenkt er dir das größte Vertrauen, das ein Tier geben kann.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde lieben ohne zu vergleichen — das können Menschen selten.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Der Atem deines Hundes im Nacken bedeutet: Du bist nicht allein.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Nichts heilt einen schlechten Tag schneller als ein Hund, der dir entgegenläuft.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde sind das liebste Geheimnis glücklicher Menschen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund macht Liebe sichtbar.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Wenn du einen Hund streichelst, streichelt er gleichzeitig deine Seele.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hundeaugen lügen nicht — dort siehst du reine Liebe.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Die Liebe eines Hundes ist keine Anhänglichkeit — sie ist Hingabe.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund liebt dich an deinen schlechtesten Tagen am meisten.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Mit einem Hund zu schlafen ist die wärmste Art, eine Nacht zu verbringen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Wenn Hunde beten könnten, würden sie für ihre Menschen beten.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund braucht nichts außer dir — und das ist sein Reichtum.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Liebe eines Hundes zu verdienen ist die einfachste und gleichzeitig die schönste Sache der Welt.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Wer noch nie von einem Hund geliebt wurde, kennt ein ganzes Stück Glück nicht.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde wissen, dass Liebe keine Bedingungen braucht.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hundewelpen sind die beste Anti-Depressions-Therapie der Welt — leider nicht von der Krankenkasse erstattet.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund hat mir beigebracht, ruhig zu sein. Schade, dass er selbst nie ruhig ist.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Manche sagen: Es ist nur ein Hund. Diese Menschen haben nie einen Hund gehabt.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Der einzige Nachteil eines Hundes: Er lebt zu kurz.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Wenn Hunde reden könnten, würden sie uns sagen, was wir schon immer wissen wollten: dass wir gut genug sind.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde schlafen 18 Stunden am Tag und sehen dabei entspannter aus als ich nach 8 Stunden Schlaf.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund hat fünf Betten und schläft trotzdem auf meinen Füßen.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund frisst alles außer dem, was im Napf ist.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund hat mehr Freunde auf Instagram als ich — und postet nie selbst.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde kennen kein Montag-Gefühl.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Kein Hund schaut je auf die Uhr — höchstens zur Futterzeit.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Der Traumjob wäre, bezahlt zu werden, während der Hund auf dem Schoß liegt.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hundebesitzer kennen zwei Arten von Böden: sauber und nach dem Gassi.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund ist offiziell der Einzige, der mich ohne Make-up schön findet.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund ist der beste Grund, früh aufzustehen.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde zeigen uns, wie schön das Einfache ist: Spaziergang, Leckerli, Kuscheln.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Kein Hund hat je Krieg geführt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde verbringen kein Leben damit zu bereuen. Sie leben einfach.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Die Weisheit des Hundes liegt darin, dass er nie zweifelt, ob er genug tut.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund hat keine Vergangenheit, die er bereut, und keine Zukunft, die er fürchtet.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde leben nicht nach Regeln — sie leben nach Instinkten. Das ist manchmal weiser.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "In der Stille eines Hundes liegt mehr Weisheit als in manchem langen Gespräch.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund versteht mehr von Empathie als jeder Therapeut.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde haben das, was wir suchen: Zufriedenheit ohne Grund.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Die wahre Weisheit des Hundes: Er braucht nichts zu beweisen.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund sieht in dir keinen Status, kein Einkommen, keine Vergangenheit — nur dich.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde wählen Menschen nicht nach Äußerlichkeiten — das sollten wir auch nicht.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Achte darauf, wie dein Hund Menschen begegnet — er spürt, was du nicht siehst.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wenn dein Hund dir nicht traut, solltest du in dich gehen.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Der Hund ist das beste Argument für die Existenz von Güte in der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde spiegeln wider, wer wir wirklich sind.", "autor": "Cesar Millan", "kategorie": "weisheit" },
{ "text": "Wenn ich an die Treulosigkeit der Menschen denke, lobe ich meinen Hund.", "autor": "Friedrich II.", "kategorie": "treue" },
{ "text": "Ein Hund wird nie sagen: Heute habe ich keine Lust, dein Freund zu sein.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde verlassen uns nie — wir verlassen sie.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund hat kein Gedächtnis für Groll — nur für Zuneigung.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Wenn ein Hund dir treu ist, hat er eine Entscheidung für sein Leben getroffen.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Treue ist nicht, was ein Hund tut — es ist, wer er ist.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund trägt kein Netz aus Erwartungen — nur das Netz der Zuneigung.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Wenn Menschen so wären wie Hunde — treu, ehrlich, freudig — wäre die Welt ein besserer Ort.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde zeigen dir täglich, dass Loyalität keine Leistung ist, sondern ein Charakter.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde liegen zu Füßen der Helden und der Hilflosen gleichermaßen.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Tägliche Bewegung mit dem Hund ist der günstigste Arztbesuch, den du machen kannst.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein Hund hält dich körperlich fit — ob du willst oder nicht.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Zehn Minuten mit einem Hund spielen senkt Stress nachweislich.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hundebesitzer haben im Durchschnitt einen niedrigeren Blutdruck. Kein Wunder.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein Hund motiviert dich, das Sofa zu verlassen — jeden Tag, bei jedem Wetter.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Wer täglich Gassi geht, braucht kein Fitnessstudio.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hunde halten uns jung — zumindest im Herzen.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein Hund an der Seite hilft gegen Depressionen besser als viele Tabletten.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Streicheln ist gut für den Menschen und noch besser für den Hund.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hunde sind die natürlichste Form der Stresstherapie.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Mit einem Hund draußen zu sein bedeutet: frische Luft, Bewegung, Freude — dreifacher Gewinn.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein gesunder Hund ist ein aktiver Hund — und ein aktiver Hund braucht einen aktiven Menschen.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hunde sorgen dafür, dass wir uns bewegen — das ist ihr heimliches Gesundheitsprogramm für uns.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Der Hund kennt das Geheimnis des Glücks: Das Beste ist immer das Jetzt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde fragen nicht, was das Leben bedeutet. Sie leben es einfach.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wenn du nicht weißt, wohin — folge deinem Hund.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund zeigt dir jeden Tag: Kleine Dinge sind die wichtigsten.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde tanzen nicht, wenn du ihnen von deinen Erfolgen erzählst — sie tanzen, wenn du heimkommst.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Das Gebell eines Hundes ist die ehrlichste Meinung der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund hat keine Agenda außer Liebe.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde suchen nicht nach dem Sinn des Lebens — sie finden ihn im Spaziergang.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund macht dich vollständig — auch wenn du das erst merkst, wenn er weg ist.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Im Trauerfall ist ein Hund oft der einzige Trost, der nicht nach Worten sucht.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde lieben jeden Tag so, als wäre es der schönste ihres Lebens.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Für einen Hund ist Heimkommen das größte Ereignis des Tages — täglich.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund jubelt bei deiner Ankunft mehr als dein Telefon bei einem Like.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde misstrauen keinem ohne Grund. Lerne davon.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Kein Hund hat je einen Krieg begonnen oder ein Tier beleidigt ohne Grund.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein treuer Hund ist ein Spiegel des Guten in der Welt.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Liebe, die nicht urteilt — das ist Hundeliebe.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Wir verdienen unsere Hunde nicht — wir werden von ihnen gesegnet.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde bringen Licht in die dunkelsten Ecken unserer Tage.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Jeder Hund hat ein Talent: Er macht sein Zuhause glücklicher.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Ein Hund bereichert das Leben, ohne etwas von dir zu fordern.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Das einzige Problem mit Hunden ist, dass sie zu kurz leben und zu vollständig lieben.", "autor": "Agnes Sligh Turnbull", "kategorie": "liebe" },
{ "text": "Until one has loved an animal, a part of one's soul remains unawakened.", "autor": "Anatole France", "kategorie": "liebe" },
{ "text": "Dogs are how people would be if the important stuff is all that mattered to us.", "autor": "Ashly Lorenzana", "kategorie": "weisheit" },
{ "text": "Ich bin kein Hundenarr. Ich habe einfach einen Hund, der eine eigene Meinung hat, und ich respektiere das.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund weiß nichts von Montagen — er weiß nur, dass du da bist.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde erfinden sich jeden Tag neu — mit demselben Wedeln.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Ein Hund zu sein bedeutet: vollständig glücklich sein mit dem, was ist.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wer einmal die Liebe eines Hundes erlebt hat, zweifelt nie mehr an gutem Leben.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Kein Geld der Welt kann die Freude eines Hundes kaufen — nur verdienen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde machen keine großen Versprechen. Sie halten einfach kleine.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "In meinem Haus regiert der Hund — ich bin nur der Butler.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wenn mein Hund spricht, spreche ich auch. Unsere Unterhaltungen sind die besten.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde täuschen nicht vor, glücklich zu sein. Wenn sie es zeigen, meinen sie es.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund zeigt dir: Dankbarkeit kann man auch ohne Worte ausdrücken.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wenn der Hund wedelt, ist der Tag gerettet.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde sind Experten darin, das Beste im Menschen zu sehen.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Eine Pfote auf dem Schoß ist mehr wert als tausend Worte des Trostes.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Wer Hunde nicht mag, hat sie entweder nie richtig kennengelernt oder das Herz zu weit verschlossen.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Ein Hund ist der beste Gesprächspartner: Er hört immer zu und widerspricht nie.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde schnarchen lauter als manche Menschen. Und trotzdem stört es mich nicht.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Welpe ist wie ein Sonnenschein, den du im Haus trägst.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Mit einem alten Hund zu leben ist ein Privileg — er hat viel zu erzählen, wenn man zuhört.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde altern mit Würde. Wir sollten es ihnen gleichtun.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein alter Hund hat mehr gelernt als ein junger — aber er braucht mehr Pausen.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Kein Hundeblick ist so tief wie der eines alten Hundes, der sein Leben hinter sich hat.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde wachsen mit uns auf — und lassen uns nie ganz los.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund ist von Geburt bis Tod dein treuer Gefährte — das ist der reinste Pakt der Welt.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Den Charakter eines Menschen erkennt man daran, wie er seinen Hund behandelt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wer gut zu Hunden ist, ist gut zu Menschen — das Gegenteil gilt leider auch.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund erzählt dir nicht, was er fühlt — er zeigt es dir.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Respekt und Vertrauen — das sind die Grundlagen jeder Mensch-Hund-Beziehung.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Hundeerziehung beginnt am ersten Tag und endet nie — sie wird nur besser.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein Hund der niemals Nein sagt, hat nie gelernt Grenzen zu respektieren.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Hunde lieben Routinen — sie machen das Unbekannte beherrschbar.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein Hund, dem man Vertrauen schenkt, enttäuscht selten.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Hunde brauchen keine langen Befehle — ein Wort und ein klares Signal genügen.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ein glücklicher Hund hat genug Bewegung, genug Aufgaben und genug Liebe.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Wer mit seinem Hund Tricks übt, übt gleichzeitig Geduld und Ausdauer.", "autor": "Unbekannt", "kategorie": "training" },
{ "text": "Ich sage nicht, dass mein Hund klüger ist als du — aber er hat das schon gedacht.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wenn mein Hund Telefonkonferenzen hätte, würden sie pünktlich enden.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund ist der einzige, der glücklich ist, wenn du wieder von der Toilette kommst.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ich bin nicht süchtig nach meinem Hund. Ich brauche ihn nur täglich.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund lässt mich nie ausreden — das mag ich an ihm.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Sie sagten, ein Hund sei viel Arbeit. Sie hatten recht. Die Freude ist noch mehr.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde machen das Leben besser — das ist keine Meinung, das ist Tatsache.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Kein Selfie gelingt so gut wie das mit dem Hund.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wer einen Hund hat, braucht keinen Wecker — und kein Sofa für sich allein.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde sind die perfekten Mitbewohner: still, wenn gewünscht, laut, wenn nötig.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Ein Hund ist das einzige Wesen, das dich liebt, obwohl es weiß, dass du seine Spielzeuge wegschmeißt.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Hunde riechen die Lüge — Menschen riechen Parfum.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund braucht dich nicht zu verstehen — er muss dich nur fühlen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde wissen, wann du weinst. Und sie kommen trotzdem — oder gerade dann.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund ist das beste Argument gegen Einsamkeit.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Wer Hunde versteht, versteht ein Stück mehr von der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Hunde haben keine Maske. Das ist ihr größtes Geschenk an uns.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund fragt nicht nach deiner Geschichte — er schreibt mit dir die nächsten Seiten.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde machen keine Witze auf Kosten anderer. Lehre von ihnen.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund zu haben bedeutet: täglich Dankbarkeit erfahren.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Niemand begrüßt dich aufrichtiger als dein Hund.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund gibt dir immer eine zweite Chance — und eine dritte und vierte.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde machen Fehler wie alle — aber sie entschuldigen sich täglich mit ihrem Blick.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund mit gebrochenem Bein hat mehr Stolz als mancher Mensch.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Der mutigste Beschützer wiegt manchmal nur fünf Kilo.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde fürchten sich manchmal — aber sie überwinden es für dich.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Ein Hund kämpft nie für Macht — nur für seine Menschen.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Hunde haben keine Karriere, kein Konto, keine Ambitionen — und sind glücklicher als die meisten.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Das Geheimnis eines langen Lebens: jeden Tag Gassi gehen.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Frische Luft, Bewegung, ein Hund an der Seite — besser geht Therapie nicht.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Wer regelmäßig mit dem Hund läuft, braucht keinen Motivationscoach.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Hunde halten uns davon ab, den ganzen Tag zu sitzen — das ist ihre heimliche Mission.", "autor": "Unbekannt", "kategorie": "gesundheit" },
{ "text": "Ein Hundekiss mag nicht hygienisch sein — aber er heilt trotzdem.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wer mit Hunden lebt, hat ein wärmeres Zuhause — buchstäblich.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Hunde zeigen uns täglich: Das Schönste am Tag ist der Anfang und das Ende — weil du da bist.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund braucht weder Smartphone noch Social Media — er lebt das echte Leben.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Wenn ich meinen Hund anschaue, verstehe ich, was Unschuld bedeutet.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund sieht in dir nie das Schlechteste — nur das Beste.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde sind die ehrlichsten Kritiker: Wenn sie fressen, schmeckt es.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Mein Hund gibt mir täglich einen Grund zu lächeln — ohne es zu wissen.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Alles, was du brauchst, ist Liebe — und ein Hund, der das auch so sieht.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Ein Hund macht das Schweigen gemeinsam — das ist echter Frieden.", "autor": "Unbekannt", "kategorie": "liebe" },
{ "text": "Hunde haben das perfekte Leben: schlafen, fressen, spielen, lieben. Kein Stress.", "autor": "Unbekannt", "kategorie": "humor" },
{ "text": "Wer einmal einem Hund beim Schlafen zugesehen hat, weiß, was echte Entspannung ist.", "autor": "Unbekannt", "kategorie": "weisheit" },
{ "text": "Ein Hund zu retten bedeutet, zwei Leben zu retten — seins und deins.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Kaufe nicht, wenn du adoptieren kannst — du gewinnst mehr, als du weißt.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Ein Tierheim-Hund weiß, was Verlassenheit ist — und liebt dich trotzdem, sobald du da bist.", "autor": "Unbekannt", "kategorie": "treue" },
{ "text": "Jeder Hund verdient ein Zuhause. Jeder Mensch verdient einen Hund.", "autor": "Unbekannt", "kategorie": "allgemein" },
{ "text": "Die beste Zeit, einen Hund zu adoptieren, war gestern. Die zweitbeste ist heute.", "autor": "Unbekannt", "kategorie": "allgemein" }
]

24
scripts/import_quotes.py Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Importiert dog_quotes.json in die daily_quotes-Tabelle."""
import json, sqlite3, sys
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / 'backend' / 'banyaro.db'
JSON_PATH = Path(__file__).parent / 'dog_quotes.json'
quotes = json.loads(JSON_PATH.read_text())
conn = sqlite3.connect(DB_PATH)
existing = conn.execute("SELECT COUNT(*) FROM daily_quotes").fetchone()[0]
if existing > 0:
print(f"{existing} Einträge bereits vorhanden — überspringe Import.")
print("Zum Neuimport: DELETE FROM daily_quotes; zuerst ausführen.")
sys.exit(0)
conn.executemany(
"INSERT INTO daily_quotes (text, autor, kategorie) VALUES (?, ?, ?)",
[(q['text'], q.get('autor'), q.get('kategorie')) for q in quotes]
)
conn.commit()
print(f"{len(quotes)} Tagessprüche importiert.")
conn.close()