Compare commits
No commits in common. "40de0f38aa4731410fd263821c218ab9600ad9c7" and "15e2446ea72078388e255959b60d8b9aafb853ed" have entirely different histories.
40de0f38aa
...
15e2446ea7
36 changed files with 225 additions and 5508 deletions
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"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=?",
|
"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=?",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -540,9 +540,6 @@ def _migrate(conn_factory):
|
||||||
("pflege_tipps", "fell_pflege_art", "TEXT"),
|
("pflege_tipps", "fell_pflege_art", "TEXT"),
|
||||||
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
|
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
|
||||||
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
|
("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
|
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
|
||||||
("diary", "weather_json", "TEXT"),
|
("diary", "weather_json", "TEXT"),
|
||||||
("diary", "poi_json", "TEXT"),
|
("diary", "poi_json", "TEXT"),
|
||||||
|
|
@ -571,11 +568,6 @@ def _migrate(conn_factory):
|
||||||
# Passwort-Zurücksetzen
|
# Passwort-Zurücksetzen
|
||||||
("users", "password_reset_token", "TEXT"),
|
("users", "password_reset_token", "TEXT"),
|
||||||
("users", "password_reset_expires", "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:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
@ -1931,44 +1923,6 @@ 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)
|
# Wiederkehrende Ausgaben (Daueraufträge)
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
||||||
|
|
@ -1986,75 +1940,3 @@ def _migrate(conn_factory):
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
|
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.")
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ import os
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from brotli_asgi import BrotliMiddleware
|
from brotli_asgi import BrotliMiddleware
|
||||||
|
|
@ -44,43 +43,10 @@ logger = logging.getLogger(__name__)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Startup / Shutdown
|
# 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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Ban Yaro startet...")
|
logger.info("Ban Yaro startet...")
|
||||||
init_db()
|
init_db()
|
||||||
_backfill_image_sizes()
|
|
||||||
from routes.movies import seed_movies
|
from routes.movies import seed_movies
|
||||||
seed_movies()
|
seed_movies()
|
||||||
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
||||||
|
|
@ -110,7 +76,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
"img-src 'self' data: blob: https:; "
|
"img-src 'self' data: blob: https:; "
|
||||||
"connect-src 'self' https:; "
|
"connect-src 'self' https:; "
|
||||||
|
|
@ -232,7 +198,6 @@ from routes.adoption import router as adoption_router
|
||||||
from routes.health_docs import router as health_docs_router
|
from routes.health_docs import router as health_docs_router
|
||||||
from routes.passport import router as passport_router
|
from routes.passport import router as passport_router
|
||||||
from routes.playdate import router as playdate_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(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -291,7 +256,6 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
|
||||||
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
|
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(passport_router, prefix="/api/passport", tags=["Hundepass"])
|
||||||
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
|
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
|
||||||
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -321,27 +285,6 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
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")
|
@app.get("/robots.txt")
|
||||||
async def robots():
|
async def robots():
|
||||||
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
|
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
|
||||||
|
|
|
||||||
|
|
@ -92,59 +92,6 @@ CATEGORIES = [
|
||||||
("gold", 10, "Wiki-Fotograf"),
|
("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
|
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
|
||||||
|
|
@ -203,48 +150,12 @@ def check_and_award(user_id: int, conn):
|
||||||
"SELECT current_streak FROM users WHERE id=?", (user_id,)
|
"SELECT current_streak FROM users WHERE id=?", (user_id,)
|
||||||
).fetchone()
|
).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 = {
|
metrics = {
|
||||||
"total_km": stats["total_km"] if stats else 0,
|
"total_km": stats["total_km"] if stats else 0,
|
||||||
"routen": stats["routen"] if stats else 0,
|
"routen": stats["routen"] if stats else 0,
|
||||||
"pois": stats["pois"] if stats else 0,
|
"pois": stats["pois"] if stats else 0,
|
||||||
"streak": (streak_row["current_streak"] if streak_row else 0),
|
"streak": (streak_row["current_streak"] if streak_row else 0),
|
||||||
"wiki_fotos": stats["wiki_fotos"] if stats 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
|
earned = {r["badge_id"] for r in
|
||||||
|
|
@ -300,38 +211,6 @@ async def my_achievements(user=Depends(get_current_user)):
|
||||||
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
|
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
|
||||||
).fetchone()
|
).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(
|
earned_rows = conn.execute(
|
||||||
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
|
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -351,15 +230,11 @@ async def my_achievements(user=Depends(get_current_user)):
|
||||||
""", (stats["punkte"] if stats else 0,)).fetchone()
|
""", (stats["punkte"] if stats else 0,)).fetchone()
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
"total_km": stats["total_km"] if stats else 0,
|
"total_km": stats["total_km"] if stats else 0,
|
||||||
"routen": stats["routen"] if stats else 0,
|
"routen": stats["routen"] if stats else 0,
|
||||||
"pois": stats["pois"] if stats else 0,
|
"pois": stats["pois"] if stats else 0,
|
||||||
"streak": (streak_row["current_streak"] if streak_row else 0),
|
"streak": (streak_row["current_streak"] if streak_row else 0),
|
||||||
"wiki_fotos": stats["wiki_fotos"] if stats 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
|
# Kategorien mit aktuellem Tier + Fortschritt aufbauen
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from auth import get_current_user, require_admin
|
||||||
import ki as KI
|
import ki as KI
|
||||||
import httpx
|
import httpx
|
||||||
import weather as weather_mod
|
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, get_image_size
|
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from
|
||||||
from timeutils import safe_client_time
|
from timeutils import safe_client_time
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -30,7 +30,6 @@ class DiaryCreate(BaseModel):
|
||||||
location_name: Optional[str] = None
|
location_name: Optional[str] = None
|
||||||
is_milestone: bool = False
|
is_milestone: bool = False
|
||||||
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
|
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):
|
class DiaryUpdate(BaseModel):
|
||||||
|
|
@ -351,19 +350,6 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
||||||
)
|
)
|
||||||
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
|
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)
|
return _entry_dict(entry, dogs_map, media_map)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -706,12 +692,10 @@ async def upload_media(dog_id: int, entry_id: int,
|
||||||
|
|
||||||
media_url = f"/media/diary/{filename}"
|
media_url = f"/media/diary/{filename}"
|
||||||
|
|
||||||
# Bildmaße + EXIF-GPS (nur bei Bilddateien)
|
# EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
|
||||||
exif_gps = None
|
exif_gps = None
|
||||||
img_size = None
|
|
||||||
if media_type == "image":
|
if media_type == "image":
|
||||||
exif_gps = extract_gps_from_exif(raw_data)
|
exif_gps = extract_gps_from_exif(raw_data)
|
||||||
img_size = get_image_size(raw_data)
|
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
# sort_order = nächste freie Position
|
# sort_order = nächste freie Position
|
||||||
|
|
@ -722,9 +706,8 @@ async def upload_media(dog_id: int, entry_id: int,
|
||||||
# Erstes Item eines Eintrags wird automatisch Cover
|
# Erstes Item eines Eintrags wird automatisch Cover
|
||||||
is_cover = 1 if max_order == -1 else 0
|
is_cover = 1 if max_order == -1 else 0
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)",
|
"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,
|
(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(
|
new_id = conn.execute(
|
||||||
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
|
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
|
|
||||||
|
|
@ -181,29 +181,18 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
|
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
|
||||||
# Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
|
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
WHERE d.dog_id=? AND dm.media_type='image'
|
WHERE d.dog_id=? AND dm.media_type='image'
|
||||||
AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
|
ORDER BY d.datum DESC LIMIT 100""",
|
||||||
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).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
|
random_photo = None
|
||||||
if photos:
|
if photos:
|
||||||
import datetime as _dt2
|
import datetime as _dt2
|
||||||
tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
|
day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
|
||||||
chosen_url = photos[tick % len(photos)]["url"]
|
chosen_url = photos[day_num % len(photos)]["url"]
|
||||||
random_photo = {
|
random_photo = {
|
||||||
"url": chosen_url,
|
"url": chosen_url,
|
||||||
"preview_url": preview_url_from(chosen_url),
|
"preview_url": preview_url_from(chosen_url),
|
||||||
|
|
@ -305,463 +294,6 @@ 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">🐾</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
subtitle_parts = [p for p in [rasse_str, alter_str] if p]
|
|
||||||
subtitle = " · ".join(subtitle_parts)
|
|
||||||
|
|
||||||
_MONATE = ["Januar","Februar","März","April","Mai","Juni",
|
|
||||||
"Juli","August","September","Oktober","November","Dezember"]
|
|
||||||
|
|
||||||
def _fmt_datum(iso: str) -> str:
|
|
||||||
try:
|
|
||||||
d = _date.fromisoformat(iso)
|
|
||||||
return f"{d.day}. {_MONATE[d.month - 1]} {d.year}"
|
|
||||||
except Exception:
|
|
||||||
return iso or ""
|
|
||||||
|
|
||||||
def _wetter_chip(wj_str: str) -> str:
|
|
||||||
if not wj_str:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
wj = _json.loads(wj_str)
|
|
||||||
temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
|
|
||||||
if temp is None:
|
|
||||||
return ""
|
|
||||||
temp_i = int(float(temp))
|
|
||||||
emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅")
|
|
||||||
return f'<span class="chip">{emoji} {temp_i}°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">📍 {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 — {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()">
|
|
||||||
🖨 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äge</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{entries_html}
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
return HTMLResponse(content=html_page)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{dog_id}")
|
@router.get("/{dog_id}")
|
||||||
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
|
|
@ -1090,159 +622,3 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
|
||||||
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
|
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
|
||||||
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
|
"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,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
"""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.")
|
|
||||||
|
|
@ -279,9 +279,8 @@ class UserPoiIn(BaseModel):
|
||||||
|
|
||||||
ALLOWED_TYPES = {
|
ALLOWED_TYPES = {
|
||||||
'waste_basket', 'drinking_water', 'dog_park',
|
'waste_basket', 'drinking_water', 'dog_park',
|
||||||
'giftkoeder', # Giftköder (exklusiv, kein Kombi)
|
'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
|
||||||
'kotbeutel', # Kotbeutelspender
|
'kotbeutel', # Kotbeutelspender
|
||||||
'bank', # Sitzbank
|
|
||||||
'gefahr', # Allgemeine Gefahr / Hinweis
|
'gefahr', # Allgemeine Gefahr / Hinweis
|
||||||
'parkplatz', # Hundefreundlicher Parkplatz
|
'parkplatz', # Hundefreundlicher Parkplatz
|
||||||
'treffpunkt', # Treffpunkt für Hundehalter
|
'treffpunkt', # Treffpunkt für Hundehalter
|
||||||
|
|
@ -290,8 +289,7 @@ ALLOWED_TYPES = {
|
||||||
|
|
||||||
@router.post('/user-poi')
|
@router.post('/user-poi')
|
||||||
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
|
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
|
||||||
types = [t.strip() for t in body.type.split(',') if t.strip()]
|
if body.type not in ALLOWED_TYPES:
|
||||||
if not types or any(t not in ALLOWED_TYPES for t in types):
|
|
||||||
raise HTTPException(400, 'Ungültiger Typ')
|
raise HTTPException(400, 'Ungültiger Typ')
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute("""
|
row = conn.execute("""
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ class ProfileUpdate(BaseModel):
|
||||||
social_link: Optional[str] = None
|
social_link: Optional[str] = None
|
||||||
profil_sichtbarkeit: Optional[str] = None
|
profil_sichtbarkeit: Optional[str] = None
|
||||||
notes_ki_enabled: Optional[int] = None
|
notes_ki_enabled: Optional[int] = None
|
||||||
gassi_stunde_push: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_user(user_id: int) -> dict:
|
def _load_user(user_id: int) -> dict:
|
||||||
|
|
@ -114,28 +113,3 @@ async def upload_avatar(
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"avatar_url": avatar_url}
|
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"}
|
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,6 @@ class TierarztCreate(BaseModel):
|
||||||
osm_id: Optional[str] = None
|
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):
|
class TierarztUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
strasse: Optional[str] = None
|
strasse: Optional[str] = None
|
||||||
|
|
@ -228,109 +220,3 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate,
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
|
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
|
||||||
return dict(row)
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@ BAN YARO — Wetter-API
|
||||||
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from fastapi import APIRouter, Query, HTTPException, Depends
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
import weather as weather_module
|
import weather as weather_module
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import db
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -33,57 +31,3 @@ async def get_weather_forecast(
|
||||||
return await weather_module.get_forecast(lat, lon)
|
return await weather_module.get_forecast(lat, lon)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {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}
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,13 @@
|
||||||
"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
|
"""BAN YARO — Widget-Snapshot Endpoint"""
|
||||||
|
|
||||||
import json, random
|
import json, random
|
||||||
from datetime import date
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from typing import Optional
|
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/snapshot")
|
||||||
async def widget_snapshot(user=Depends(get_current_user)):
|
async def widget_snapshot(user=Depends(get_current_user)):
|
||||||
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""
|
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,7 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
|
||||||
raise HTTPException(404, "Einreichung nicht gefunden.")
|
raise HTTPException(404, "Einreichung nicht gefunden.")
|
||||||
|
|
||||||
rasse = conn.execute(
|
rasse = conn.execute(
|
||||||
"SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?",
|
"SELECT id, external_id, slug FROM wiki_rassen WHERE id=?",
|
||||||
(sub["rasse_id"],)
|
(sub["rasse_id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,32 +156,8 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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()
|
_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, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).")
|
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
|
||||||
|
|
||||||
|
|
||||||
def stop():
|
def stop():
|
||||||
|
|
@ -905,9 +881,6 @@ async def _job_status_report():
|
||||||
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
||||||
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
|
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
|
||||||
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08: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_html = ""
|
||||||
job_rows_txt = ""
|
job_rows_txt = ""
|
||||||
|
|
@ -1315,329 +1288,3 @@ async def _job_recurring_expenses():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Daueraufträge-Job Fehler: {e}")
|
logger.error(f"Daueraufträge-Job Fehler: {e}")
|
||||||
_log_job("recurring_expenses", "error", str(e))
|
_log_job("recurring_expenses", "error", str(e))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_golden_gassi_hour():
|
|
||||||
"""
|
|
||||||
Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1)
|
|
||||||
das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification.
|
|
||||||
|
|
||||||
Score-Logik pro Stunde (max. 10 Punkte):
|
|
||||||
- Temperatur 10–20°C → +3
|
|
||||||
- Temperatur 5–10°C → +1
|
|
||||||
- Niederschlagswahrsch. <20% → +3, <40% → +1
|
|
||||||
- Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1
|
|
||||||
- Stunden 07–19 Uhr (Tageslicht) → +2
|
|
||||||
Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden).
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
from datetime import date as _date
|
|
||||||
|
|
||||||
logger.info("Goldene-Gassi-Stunde Job läuft")
|
|
||||||
|
|
||||||
# Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription
|
|
||||||
with db() as conn:
|
|
||||||
users = conn.execute("""
|
|
||||||
SELECT DISTINCT u.id AS user_id,
|
|
||||||
ps.last_lat, ps.last_lon
|
|
||||||
FROM users u
|
|
||||||
JOIN push_subscriptions ps ON ps.user_id = u.id
|
|
||||||
WHERE u.gassi_stunde_push = 1
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
users = [dict(u) for u in users]
|
|
||||||
logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.")
|
|
||||||
|
|
||||||
if not users:
|
|
||||||
_log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv")
|
|
||||||
return
|
|
||||||
|
|
||||||
sent_total = 0
|
|
||||||
|
|
||||||
for u in users:
|
|
||||||
lat = u["last_lat"] or 48.1351 # Fallback: München
|
|
||||||
lon = u["last_lon"] or 11.5820
|
|
||||||
|
|
||||||
try:
|
|
||||||
hourly = await _fetch_hourly_weather(lat, lon)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not hourly:
|
|
||||||
continue
|
|
||||||
|
|
||||||
best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly)
|
|
||||||
|
|
||||||
if best_score < 3:
|
|
||||||
# Heute kein gutes Wetterfenster → kein Push
|
|
||||||
logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})")
|
|
||||||
continue
|
|
||||||
|
|
||||||
hour_end = (best_start + 2) % 24
|
|
||||||
temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–"
|
|
||||||
wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else (
|
|
||||||
f"{best_wind:.0f} km/h Wind" if best_wind is not None else "")
|
|
||||||
|
|
||||||
body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr",
|
|
||||||
f"· {temp_str}"]
|
|
||||||
if wind_str:
|
|
||||||
body_parts.append(f"· {wind_str}")
|
|
||||||
|
|
||||||
sent = send_push_to_user(u["user_id"], {
|
|
||||||
"type": "golden_gassi_hour",
|
|
||||||
"title": "☀️ Goldene Gassi-Stunde heute!",
|
|
||||||
"body": " ".join(body_parts),
|
|
||||||
"data": {"page": "wetter"},
|
|
||||||
"tag": f"gassi-{_date.today()}",
|
|
||||||
})
|
|
||||||
sent_total += sent
|
|
||||||
logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}")
|
|
||||||
|
|
||||||
logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.")
|
|
||||||
_log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: Jahrestags-Erinnerungen (täglich 09:00)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_anniversary_reminders():
|
|
||||||
"""Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push."""
|
|
||||||
today = datetime.now(tz=_TZ)
|
|
||||||
today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr
|
|
||||||
|
|
||||||
logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
entries = conn.execute("""
|
|
||||||
SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
|
|
||||||
(SELECT dm.url FROM diary_media dm
|
|
||||||
WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
|
|
||||||
FROM diary d
|
|
||||||
WHERE strftime('%m-%d', d.datum) = ?
|
|
||||||
AND d.datum < date('now')
|
|
||||||
AND d.titel IS NOT NULL
|
|
||||||
AND d.is_milestone = 0
|
|
||||||
""", (today_md,)).fetchall()
|
|
||||||
|
|
||||||
sent_total = 0
|
|
||||||
for e in entries:
|
|
||||||
try:
|
|
||||||
jahre = today.year - int(e['datum'][:4])
|
|
||||||
if jahre < 1:
|
|
||||||
continue
|
|
||||||
jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren"
|
|
||||||
send_push_to_user(e['user_id'], {
|
|
||||||
'type': 'anniversary_reminder',
|
|
||||||
'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}',
|
|
||||||
'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund',
|
|
||||||
'data': {'page': 'diary'},
|
|
||||||
'tag': f'anniversary-{e["id"]}-{today.year}',
|
|
||||||
})
|
|
||||||
sent_total += 1
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}")
|
|
||||||
|
|
||||||
logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.")
|
|
||||||
_log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: Monatlicher Rückblick (1. des Monats 10:00)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_monthly_recap():
|
|
||||||
"""Sendet jedem User am 1. des Monats einen Rückblick des Vormonats."""
|
|
||||||
today = datetime.now(tz=_TZ)
|
|
||||||
first_this = today.replace(day=1)
|
|
||||||
last_month_end = first_this - timedelta(days=1)
|
|
||||||
last_month_start = last_month_end.replace(day=1)
|
|
||||||
year_str = last_month_start.strftime('%Y')
|
|
||||||
month_str = last_month_start.strftime('%m')
|
|
||||||
month_label = last_month_start.strftime('%B %Y')
|
|
||||||
|
|
||||||
logger.info(f"Monatlicher Rückblick Job läuft für {month_label}")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
# Alle User mit mindestens einem Hund
|
|
||||||
users = conn.execute(
|
|
||||||
"SELECT DISTINCT user_id FROM dogs"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
sent_total = 0
|
|
||||||
for u in users:
|
|
||||||
user_id = u["user_id"]
|
|
||||||
try:
|
|
||||||
with db() as conn:
|
|
||||||
# Hunde des Users
|
|
||||||
dog_rows = conn.execute(
|
|
||||||
"SELECT id, name FROM dogs WHERE user_id=?", (user_id,)
|
|
||||||
).fetchall()
|
|
||||||
if not dog_rows:
|
|
||||||
continue
|
|
||||||
|
|
||||||
dog_ids = [d["id"] for d in dog_rows]
|
|
||||||
placeholders = ','.join('?' * len(dog_ids))
|
|
||||||
|
|
||||||
# km (Routen des Users im Vormonat)
|
|
||||||
km_row = conn.execute(
|
|
||||||
"SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
|
|
||||||
"WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
|
|
||||||
(user_id, year_str, month_str)
|
|
||||||
).fetchone()
|
|
||||||
gesamt_km = km_row["km"] or 0.0
|
|
||||||
|
|
||||||
# Tagebucheinträge
|
|
||||||
eintraege = conn.execute(
|
|
||||||
f"SELECT COUNT(*) AS n FROM diary "
|
|
||||||
f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?",
|
|
||||||
(*dog_ids, year_str, month_str)
|
|
||||||
).fetchone()["n"]
|
|
||||||
|
|
||||||
# Training-Sessions
|
|
||||||
training = conn.execute(
|
|
||||||
f"SELECT COUNT(*) AS n FROM training_sessions "
|
|
||||||
f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
|
|
||||||
(*dog_ids, year_str, month_str)
|
|
||||||
).fetchone()["n"]
|
|
||||||
|
|
||||||
# Lieblingsfoto (erstes Foto im Vormonat)
|
|
||||||
foto_row = conn.execute(
|
|
||||||
f"SELECT dm.url FROM diary_media dm "
|
|
||||||
f"JOIN diary d ON d.id=dm.diary_id "
|
|
||||||
f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' "
|
|
||||||
f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? "
|
|
||||||
f"ORDER BY d.datum ASC LIMIT 1",
|
|
||||||
(*dog_ids, year_str, month_str)
|
|
||||||
).fetchone()
|
|
||||||
foto_url = foto_row["url"] if foto_row else None
|
|
||||||
|
|
||||||
# Nur senden wenn mindestens eine Aktivität vorhanden
|
|
||||||
if eintraege == 0 and training == 0 and gesamt_km == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
dog_name = dog_rows[0]["name"]
|
|
||||||
parts = []
|
|
||||||
if gesamt_km > 0:
|
|
||||||
parts.append(f"{gesamt_km} km gelaufen")
|
|
||||||
if eintraege > 0:
|
|
||||||
parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge")
|
|
||||||
if training > 0:
|
|
||||||
parts.append(f"{training} Training-Sessions")
|
|
||||||
|
|
||||||
body_text = " · ".join(parts)
|
|
||||||
|
|
||||||
send_push_to_user(user_id, {
|
|
||||||
'type': 'monthly_recap',
|
|
||||||
'title': f'📅 {month_label}: Rückblick für {dog_name}',
|
|
||||||
'body': body_text,
|
|
||||||
'data': {'page': 'diary'},
|
|
||||||
'tag': f'monthly-recap-{year_str}-{month_str}',
|
|
||||||
})
|
|
||||||
sent_total += 1
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}")
|
|
||||||
|
|
||||||
logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.")
|
|
||||||
_log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
|
|
||||||
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
|
|
||||||
import httpx
|
|
||||||
from datetime import date as _date
|
|
||||||
|
|
||||||
today = _date.today().isoformat()
|
|
||||||
url = (
|
|
||||||
"https://api.open-meteo.com/v1/forecast"
|
|
||||||
f"?latitude={lat}&longitude={lon}"
|
|
||||||
"&hourly=temperature_2m,precipitation_probability,windspeed_10m"
|
|
||||||
"&timezone=Europe%2FBerlin&forecast_days=1"
|
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
raw = resp.json()
|
|
||||||
|
|
||||||
hourly = raw.get("hourly", {})
|
|
||||||
times = hourly.get("time", [])
|
|
||||||
temps = hourly.get("temperature_2m", [])
|
|
||||||
precips = hourly.get("precipitation_probability", [])
|
|
||||||
winds = hourly.get("windspeed_10m", [])
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for i, ts in enumerate(times):
|
|
||||||
if not ts.startswith(today):
|
|
||||||
continue
|
|
||||||
hour = int(ts[11:13])
|
|
||||||
result.append({
|
|
||||||
"hour": hour,
|
|
||||||
"temp": temps[i] if i < len(temps) else None,
|
|
||||||
"precip": precips[i] if i < len(precips) else None,
|
|
||||||
"wind": winds[i] if i < len(winds) else None,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _score_hour(h: dict) -> int:
|
|
||||||
"""Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte)."""
|
|
||||||
score = 0
|
|
||||||
temp = h.get("temp")
|
|
||||||
precip = h.get("precip")
|
|
||||||
wind = h.get("wind")
|
|
||||||
hour = h.get("hour", 12)
|
|
||||||
|
|
||||||
# Temperatur
|
|
||||||
if temp is not None:
|
|
||||||
if 10 <= temp <= 20:
|
|
||||||
score += 3
|
|
||||||
elif 5 <= temp < 10 or 20 < temp <= 25:
|
|
||||||
score += 1
|
|
||||||
|
|
||||||
# Niederschlagswahrscheinlichkeit
|
|
||||||
if precip is not None:
|
|
||||||
if precip < 20:
|
|
||||||
score += 3
|
|
||||||
elif precip < 40:
|
|
||||||
score += 1
|
|
||||||
|
|
||||||
# Wind
|
|
||||||
if wind is not None:
|
|
||||||
if wind < 20:
|
|
||||||
score += 2
|
|
||||||
elif wind < 30:
|
|
||||||
score += 1
|
|
||||||
|
|
||||||
# Tageslicht (07–19 Uhr)
|
|
||||||
if 7 <= hour <= 19:
|
|
||||||
score += 2
|
|
||||||
|
|
||||||
return score
|
|
||||||
|
|
||||||
|
|
||||||
def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]:
|
|
||||||
"""
|
|
||||||
Findet das beste aufeinanderfolgende 2h-Fenster.
|
|
||||||
Gibt (start_hour, total_score, avg_temp, avg_wind) zurück.
|
|
||||||
"""
|
|
||||||
best_start = 8
|
|
||||||
best_score = -1
|
|
||||||
best_temp = None
|
|
||||||
best_wind = None
|
|
||||||
|
|
||||||
for i in range(len(hourly) - 1):
|
|
||||||
h1 = hourly[i]
|
|
||||||
h2 = hourly[i + 1]
|
|
||||||
combined = _score_hour(h1) + _score_hour(h2)
|
|
||||||
if combined > best_score:
|
|
||||||
best_score = combined
|
|
||||||
best_start = h1["hour"]
|
|
||||||
# Durchschnittswerte für Anzeige
|
|
||||||
temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None]
|
|
||||||
winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None]
|
|
||||||
best_temp = sum(temps) / len(temps) if temps else None
|
|
||||||
best_wind = sum(winds) / len(winds) if winds else None
|
|
||||||
|
|
||||||
return best_start, best_score, best_temp, best_wind
|
|
||||||
|
|
|
||||||
|
|
@ -5571,139 +5571,6 @@ html.modal-open {
|
||||||
border-radius: 0;
|
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 */
|
/* Steckbrief-Grid */
|
||||||
.wiki-steckbrief-grid {
|
.wiki-steckbrief-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -6683,97 +6550,6 @@ html.modal-open {
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
HELP TOOLTIP
|
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 {
|
.by-help-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -7178,7 +6954,7 @@ svg.empty-state-icon {
|
||||||
/* FAB */
|
/* FAB */
|
||||||
.exp-fab {
|
.exp-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2));
|
bottom: calc(var(--nav-height, 64px) + var(--space-4));
|
||||||
right: var(--space-4);
|
right: var(--space-4);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
width: 52px;
|
width: 52px;
|
||||||
|
|
@ -7824,28 +7600,10 @@ svg.empty-state-icon {
|
||||||
.wlabel.active { opacity: 1; }
|
.wlabel.active { opacity: 1; }
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
#world-labels {
|
#world-labels { gap: 48px; font-size: 11px; }
|
||||||
gap: 40px;
|
.wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; }
|
||||||
top: calc(env(safe-area-inset-top, 0px) + 18px);
|
.wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); }
|
||||||
}
|
.wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); }
|
||||||
.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 */
|
/* Settings-Button */
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<link rel="canonical" href="https://banyaro.app/">
|
<link rel="canonical" href="https://banyaro.app/">
|
||||||
|
|
||||||
<!-- Preconnect: externe Hosts frühzeitig verbinden -->
|
<!-- 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="preconnect" href="https://tile.openstreetmap.org" crossorigin>
|
||||||
<link rel="dns-prefetch" href="https://tile.openstreetmap.org">
|
<link rel="dns-prefetch" href="https://tile.openstreetmap.org">
|
||||||
|
|
||||||
|
|
@ -75,7 +76,6 @@
|
||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
|
<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-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Ban Yaro">
|
<meta name="apple-mobile-web-app-title" content="Ban Yaro">
|
||||||
|
|
@ -93,9 +93,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=700">
|
<link rel="stylesheet" href="/css/design-system.css?v=651">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=700">
|
<link rel="stylesheet" href="/css/layout.css?v=651">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=700">
|
<link rel="stylesheet" href="/css/components.css?v=651">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -499,18 +499,6 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||||
|
|
@ -551,6 +539,9 @@
|
||||||
<span class="wlabel" data-w="1">HUND</span>
|
<span class="wlabel" data-w="1">HUND</span>
|
||||||
<span class="wlabel" data-w="2">WELT</span>
|
<span class="wlabel" data-w="2">WELT</span>
|
||||||
</div>
|
</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 id="worlds-track">
|
||||||
<div class="world-panel" id="wp-jetzt"><div id="wj-content"></div></div>
|
<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>
|
<div class="world-panel" id="wp-hund"><div id="wh-content"></div></div>
|
||||||
|
|
@ -574,12 +565,12 @@
|
||||||
<script src="/js/api.js?v=94"></script>
|
<script src="/js/api.js?v=94"></script>
|
||||||
<script src="/js/ui.js?v=94"></script>
|
<script src="/js/ui.js?v=94"></script>
|
||||||
<script src="/js/app.js?v=94"></script>
|
<script src="/js/app.js?v=94"></script>
|
||||||
<script src="/js/worlds.js?v=700"></script>
|
<script src="/js/worlds.js?v=651"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
<!-- Umami Analytics (self-hosted, cookiefrei, DSGVO-konform) -->
|
<!-- Umami Analytics (self-hosted, cookiefrei, DSGVO-konform) -->
|
||||||
<script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script>
|
<script defer src="https://umami.motocamp.de/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f"></script>
|
||||||
|
|
||||||
|
|
||||||
<!-- Offline-Banner Logik -->
|
<!-- Offline-Banner Logik -->
|
||||||
|
|
|
||||||
|
|
@ -212,9 +212,6 @@ const API = (() => {
|
||||||
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
||||||
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
||||||
toggleFavorite(id) { return post(`/tieraerzte/${id}/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); },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
|
|
@ -76,9 +76,6 @@ const App = (() => {
|
||||||
adoption: { title: 'Adoption', module: null },
|
adoption: { title: 'Adoption', module: null },
|
||||||
playdate: { title: 'Playdate', module: null, requiresAuth: true },
|
playdate: { title: 'Playdate', module: null, requiresAuth: true },
|
||||||
wetter: { title: 'Wetter', module: null },
|
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 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -868,9 +868,9 @@ window.Page_diary = (() => {
|
||||||
if (e.weather_json) {
|
if (e.weather_json) {
|
||||||
try {
|
try {
|
||||||
const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json;
|
const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json;
|
||||||
const temp = w?.temp_c ?? w?.temperature_2m;
|
const temp = w?.temperature_2m ?? w?.temp_c;
|
||||||
if (temp != null) {
|
if (temp != null) {
|
||||||
metaParts.push(`<span class="diary-meta-weather">${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°</span>`);
|
metaParts.push(`<span>${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°</span>`);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
@ -1073,14 +1073,15 @@ window.Page_diary = (() => {
|
||||||
if (entry.weather_json) {
|
if (entry.weather_json) {
|
||||||
try {
|
try {
|
||||||
const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json;
|
const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json;
|
||||||
const temp = w?.temp_c ?? w?.temperature_2m;
|
const temp = w?.temperature_2m ?? w?.temp_c;
|
||||||
if (w && temp != null) {
|
if (w && temp != null) {
|
||||||
const wind = w.wind_kmh ?? w.wind_speed_10m;
|
const feels = w.apparent_temperature ?? w.feels_like_c;
|
||||||
const precip = w.precip_prob;
|
const wind = w.wind_speed_10m ?? w.wind_kmh;
|
||||||
const parts = [
|
const parts = [
|
||||||
`${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`,
|
`${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`,
|
||||||
wind != null ? `${Math.round(wind)} km/h Wind` : null,
|
feels != null ? `gefühlt ${Math.round(feels)}°` : null,
|
||||||
precip != null ? `${precip}% Regen` : null,
|
wind != null ? `💨 ${Math.round(wind)} km/h` : null,
|
||||||
|
w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null,
|
||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
metaItems.push(`<span class="diary-detail-meta-item">${parts}</span>`);
|
metaItems.push(`<span class="diary-detail-meta-item">${parts}</span>`);
|
||||||
}
|
}
|
||||||
|
|
@ -1727,16 +1728,6 @@ window.Page_diary = (() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await UI.asyncButton(submitBtn, async () => {
|
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 = {
|
const payload = {
|
||||||
datum: fd.datum || null,
|
datum: fd.datum || null,
|
||||||
typ: fd.typ,
|
typ: fd.typ,
|
||||||
|
|
@ -1748,7 +1739,6 @@ window.Page_diary = (() => {
|
||||||
gps_lon: _locLon,
|
gps_lon: _locLon,
|
||||||
location_name: _locName,
|
location_name: _locName,
|
||||||
client_time: API.clientNow(),
|
client_time: API.clientNow(),
|
||||||
weather_json: _clientWeather,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function _uploadNewFiles(entryId) {
|
async function _uploadNewFiles(entryId) {
|
||||||
|
|
|
||||||
|
|
@ -195,27 +195,9 @@ window.Page_dog_profile = (() => {
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
|
||||||
Hundepass
|
Hundepass
|
||||||
</button>` : ''}
|
</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">
|
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
||||||
+ Weiteren Hund anlegen
|
+ Weiteren Hund anlegen
|
||||||
</button>` : ''}
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -282,22 +264,6 @@ window.Page_dog_profile = (() => {
|
||||||
_showPassportModal(dog);
|
_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.
|
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -784,138 +750,6 @@ window.Page_dog_profile = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// TEILEN
|
// 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) {
|
async function _showShareModal(dog) {
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: `${_esc(dog.name)} teilen`,
|
title: `${_esc(dog.name)} teilen`,
|
||||||
|
|
@ -1136,23 +970,6 @@ window.Page_dog_profile = (() => {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
Bio / Steckbrief
|
Bio / Steckbrief
|
||||||
|
|
@ -1319,7 +1136,6 @@ window.Page_dog_profile = (() => {
|
||||||
chip_nr: fd.chip_nr || null,
|
chip_nr: fd.chip_nr || null,
|
||||||
bio: fd.bio || null,
|
bio: fd.bio || null,
|
||||||
is_public: 'is_public' in fd,
|
is_public: 'is_public' in fd,
|
||||||
fell_typ: fd.fell_typ || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let saved;
|
let saved;
|
||||||
|
|
@ -1970,422 +1786,6 @@ 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
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,603 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// 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> 70–80 % 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> 6–10 % Wasser, ca. 350–400 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 };
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -941,30 +941,14 @@ window.Page_health = (() => {
|
||||||
_openNoteModal('health', id, label, null);
|
_openNoteModal('health', id, label, null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Praxis öffnen → Detail-Modal mit Bewertungen
|
// Praxis öffnen
|
||||||
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const id = parseInt(el.dataset.praxisId);
|
const id = parseInt(el.dataset.praxisId);
|
||||||
const p = _praxen.find(x => x.id === id);
|
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);
|
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
|
// Dokument löschen
|
||||||
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
|
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
|
@ -1658,14 +1642,6 @@ window.Page_health = (() => {
|
||||||
|
|
||||||
const renderCard = p => {
|
const renderCard = p => {
|
||||||
const isFav = _favoritVet?.id === p.id || p.is_favorite;
|
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 `
|
return `
|
||||||
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||||||
data-praxis-id="${p.id}" data-action="open-praxis">
|
data-praxis-id="${p.id}" data-action="open-praxis">
|
||||||
|
|
@ -1684,7 +1660,6 @@ window.Page_health = (() => {
|
||||||
<svg class="ph-icon" aria-hidden="true" style="font-size:0.9em"><use href="/icons/phosphor.svg#clock"></use></svg>
|
<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))}
|
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
${ratingHtml}
|
|
||||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
||||||
${p.telefon ? `
|
${p.telefon ? `
|
||||||
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
|
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
|
||||||
|
|
@ -1696,14 +1671,6 @@ window.Page_health = (() => {
|
||||||
onclick="event.stopPropagation()">
|
onclick="event.stopPropagation()">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
||||||
</a>` : ''}
|
</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'}"
|
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
|
||||||
data-action="toggle-fav" data-praxis-id="${p.id}"
|
data-action="toggle-fav" data-praxis-id="${p.id}"
|
||||||
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
|
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
|
||||||
|
|
@ -1714,13 +1681,6 @@ window.Page_health = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
|
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1756,226 +1716,6 @@ 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)'}">★</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">★</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">★</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)
|
// PRAXEN — Formular (Neu / Bearbeiten)
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -838,17 +838,15 @@ window.Page_map = (() => {
|
||||||
_tempMarker = null;
|
_tempMarker = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
|
|
||||||
const PIN_TYPES = [
|
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', exclusive: true },
|
{ 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: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
|
{ 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: '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: '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: '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' },
|
||||||
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
|
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
|
||||||
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
|
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
|
||||||
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function _confirmPlacement(latlng) {
|
function _confirmPlacement(latlng) {
|
||||||
|
|
@ -857,18 +855,18 @@ window.Page_map = (() => {
|
||||||
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
|
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
|
||||||
}).addTo(_map);
|
}).addTo(_map);
|
||||||
|
|
||||||
let _selectedTypes = new Set(['giftkoeder']);
|
let _selectedType = 'giftkoeder';
|
||||||
|
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
|
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
|
||||||
body: `
|
body: `
|
||||||
<form id="poi-form" class="flex flex-col gap-3">
|
<form id="poi-form" class="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<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>
|
<label class="form-label">Typ auswählen</label>
|
||||||
<div class="poi-type-grid">
|
<div class="poi-type-grid">
|
||||||
${PIN_TYPES.map(p => `
|
${PIN_TYPES.map(p => `
|
||||||
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
|
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
|
||||||
data-type="${p.type}" data-excl="${p.exclusive ? '1' : ''}" style="--pt-color:${p.color}">
|
data-type="${p.type}" style="--pt-color:${p.color}">
|
||||||
<span class="poi-type-icon">${p.icon}</span>
|
<span class="poi-type-icon">${p.icon}</span>
|
||||||
<span class="poi-type-label">${p.label}</span>
|
<span class="poi-type-label">${p.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -894,21 +892,9 @@ window.Page_map = (() => {
|
||||||
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
|
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
|
||||||
const btn = e.target.closest('.poi-type-btn');
|
const btn = e.target.closest('.poi-type-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const t = btn.dataset.type;
|
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected'));
|
||||||
if (btn.dataset.excl) {
|
btn.classList.add('selected');
|
||||||
_selectedTypes = new Set([t]);
|
_selectedType = btn.dataset.type;
|
||||||
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', () => {
|
document.getElementById('poi-cancel')?.addEventListener('click', () => {
|
||||||
|
|
@ -919,9 +905,8 @@ window.Page_map = (() => {
|
||||||
document.getElementById('poi-save')?.addEventListener('click', async () => {
|
document.getElementById('poi-save')?.addEventListener('click', async () => {
|
||||||
const name = document.getElementById('poi-name').value.trim() || null;
|
const name = document.getElementById('poi-name').value.trim() || null;
|
||||||
const notiz = document.getElementById('poi-notiz').value.trim() || null;
|
const notiz = document.getElementById('poi-notiz').value.trim() || null;
|
||||||
const type = [..._selectedTypes].join(',');
|
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz });
|
await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz });
|
||||||
_exitPlacementMode();
|
_exitPlacementMode();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,35 +88,45 @@ window.Page_moderation = (() => {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// TAB: ÜBERSICHT
|
// 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) {
|
async function _renderStats(el) {
|
||||||
const s = await API.get('/moderation/stats');
|
const s = await API.get('/moderation/stats');
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="adm-stats-grid">
|
<div class="adm-stats-grid">
|
||||||
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'forum')}
|
${_statCard('warning',
|
||||||
${_statCard('image', 'Fotos ausstehend', s.pending_fotos, s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'fotos')}
|
'Offene Meldungen',
|
||||||
${_statCard('skull', 'Gesperrte User', s.banned_users, s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'user')}
|
s.open_reports,
|
||||||
${_statCard('storefront','Züchter ausstehend',s.pending_zuchter, s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'user')}
|
s.open_reports > 0 ? 'var(--c-danger)' : '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)', 'poi-edits')}
|
${_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)')}
|
||||||
</div>
|
</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, tab) {
|
function _statCard(icon, label, value, color) {
|
||||||
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 `
|
return `
|
||||||
<div class="card mod-stat-card" ${clickable}>
|
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||||
<svg class="ph-icon" style="width:24px;height:24px;color:${color};
|
<svg class="ph-icon" style="width:24px;height:24px;color:${color};
|
||||||
margin-bottom:var(--space-2)" aria-hidden="true">
|
margin-bottom:var(--space-2)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
|
@ -125,7 +135,6 @@ window.Page_moderation = (() => {
|
||||||
color:var(--c-text)">${value ?? '—'}</div>
|
color:var(--c-text)">${value ?? '—'}</div>
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
margin-top:2px">${label}</div>
|
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -208,16 +217,7 @@ window.Page_moderation = (() => {
|
||||||
await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' });
|
await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' });
|
||||||
UI.toast('Foto freigegeben.', 'success');
|
UI.toast('Foto freigegeben.', 'success');
|
||||||
await _loadFotos(el);
|
await _loadFotos(el);
|
||||||
} catch (e) {
|
} catch (e) { UI.toast(e.message, 'danger'); btn.disabled = false; btn.textContent = '✓ Freigeben'; }
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
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 };
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -695,60 +695,10 @@ window.Page_routes = (() => {
|
||||||
_recActive = true;
|
_recActive = true;
|
||||||
_recTrack = []; _recDistKm = 0; _recStartTime = Date.now();
|
_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');
|
const ctrl = document.getElementById('rk-rec-ctrl');
|
||||||
ctrl.innerHTML = `
|
ctrl.innerHTML = `<button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;">
|
||||||
<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;">
|
${UI.icon('path')} Stopp & Speichern</button>`;
|
||||||
<span id="rk-stop-label" style="position:relative;z-index:1;display:flex;align-items:center;gap:6px;pointer-events:none">
|
ctrl.querySelector('#rk-rec-stopbtn').addEventListener('click', () => _stopRecInOvl(true));
|
||||||
${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 = '';
|
document.getElementById('rk-rec-stats-bar').style.display = '';
|
||||||
|
|
||||||
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
|
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
|
||||||
|
|
@ -839,7 +789,7 @@ window.Page_routes = (() => {
|
||||||
dim.style.display = 'flex';
|
dim.style.display = 'flex';
|
||||||
_recDimmed = true;
|
_recDimmed = true;
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _stopRecInOvl(save) {
|
async function _stopRecInOvl(save) {
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,6 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
|
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
|
||||||
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Züchter-Profil Slot -->
|
<!-- Züchter-Profil Slot -->
|
||||||
|
|
@ -314,7 +313,7 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KI-Notiz-Assistent -->
|
<!-- KI-Notiz-Assistent -->
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
<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#brain"></use></svg>
|
<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="flex:1">
|
||||||
<div style="font-weight:500">KI-Notiz-Assistent</div>
|
<div style="font-weight:500">KI-Notiz-Assistent</div>
|
||||||
|
|
@ -337,30 +336,6 @@ window.Page_settings = (() => {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -443,88 +418,10 @@ window.Page_settings = (() => {
|
||||||
: `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">🔥 Noch kein Streak — heute aktiv werden!</span>`;
|
: `<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')} 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) {
|
if (badgesEl && a.categories) {
|
||||||
// Foto-Hintergründe für bestimmte Badge-Kategorien
|
// SVG-Schild für jede Kategorie
|
||||||
const _BADGE_PHOTOS = {
|
const shield = (color, dark, emoji, opacity = 1) => `
|
||||||
'schnee_held': '/img/banyaro/winter_schnee.webp',
|
<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
|
||||||
'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)">
|
style="width:56px;height:56px;filter:drop-shadow(0 2px 6px ${color}66)">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
|
@ -532,12 +429,13 @@ window.Page_settings = (() => {
|
||||||
<stop offset="100%" stop-color="${dark}"/>
|
<stop offset="100%" stop-color="${dark}"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<path d="${path}" 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"
|
||||||
<path d="${path}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
|
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"/>
|
||||||
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
|
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
|
||||||
font-size="22" style="user-select:none">${emoji}</text>
|
font-size="22" style="user-select:none">${emoji}</text>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
};
|
|
||||||
|
|
||||||
badgesEl.innerHTML = (a.categories || []).map(cat => {
|
badgesEl.innerHTML = (a.categories || []).map(cat => {
|
||||||
const cur = cat.current_tier;
|
const cur = cat.current_tier;
|
||||||
|
|
@ -552,8 +450,8 @@ window.Page_settings = (() => {
|
||||||
|
|
||||||
// Aktuelles Schild
|
// Aktuelles Schild
|
||||||
const shieldSvg = cur
|
const shieldSvg = cur
|
||||||
? shield(cur.color, cur.dark, cat.emoji, 1, cat.id)
|
? shield(cur.color, cur.dark, cat.emoji)
|
||||||
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5, cat.id);
|
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5);
|
||||||
|
|
||||||
// Fortschrittsbalken
|
// Fortschrittsbalken
|
||||||
const progressBar = nxt ? `
|
const progressBar = nxt ? `
|
||||||
|
|
@ -887,25 +785,6 @@ 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();
|
_loadReferral();
|
||||||
_loadBreederCard();
|
_loadBreederCard();
|
||||||
}
|
}
|
||||||
|
|
@ -1672,9 +1551,9 @@ window.Page_settings = (() => {
|
||||||
_offerPushNotifications();
|
_offerPushNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nach Login: Direkt in HUND-Welt oder Profil anlegen
|
// Nach Login: Welcome-Seite oder Profil anlegen
|
||||||
if (_appState.activeDog) {
|
if (_appState.activeDog) {
|
||||||
window.Worlds?.show(1);
|
App.navigate('welcome');
|
||||||
} else {
|
} else {
|
||||||
App.navigate('dog-profile');
|
App.navigate('dog-profile');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ window.Page_uebungen = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// STATS STATE
|
// 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 _statsData = null; // cached stats from /api/training/stats
|
||||||
let _badgesData = null; // cached badges from /api/achievements
|
let _badgesData = null; // cached badges from /api/achievements
|
||||||
let _exercisesByTab = {}; // aus API geladen
|
let _exercisesByTab = {}; // aus API geladen
|
||||||
|
|
@ -477,18 +476,6 @@ window.Page_uebungen = (() => {
|
||||||
if (_VALID_TABS.has(mapped)) _activeTab = mapped;
|
if (_VALID_TABS.has(mapped)) _activeTab = mapped;
|
||||||
}
|
}
|
||||||
_render();
|
_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)
|
// Übungen aus DB laden (parallel mit Progress)
|
||||||
if (!_exercisesLoaded) {
|
if (!_exercisesLoaded) {
|
||||||
|
|
@ -748,14 +735,10 @@ window.Page_uebungen = (() => {
|
||||||
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
|
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||||
Dein Plan für heute
|
Dein Plan für heute
|
||||||
</span>
|
</span>
|
||||||
<span id="ueb-help-anchor" style="margin-left:auto"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||||
${cards.join('')}
|
${cards.join('')}
|
||||||
</div>`;
|
</div>`;
|
||||||
if (_helpHandle) {
|
|
||||||
document.getElementById('ueb-help-anchor')?.appendChild(_helpHandle.makeTriggerBtn());
|
|
||||||
}
|
|
||||||
|
|
||||||
el.querySelectorAll('.ueb-trainer-btn').forEach(btn => {
|
el.querySelectorAll('.ueb-trainer-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,11 @@ window.Page_wetter = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// MODUL-STATE
|
// MODUL-STATE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
let _container = null;
|
let _container = null;
|
||||||
let _appState = null;
|
let _appState = null;
|
||||||
let _data = null;
|
let _data = null;
|
||||||
let _selDay = 0;
|
let _selDay = 0;
|
||||||
let _loading = false;
|
let _loading = false;
|
||||||
let _recordsLoaded = false;
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// INIT
|
// INIT
|
||||||
|
|
@ -77,8 +76,7 @@ window.Page_wetter = (() => {
|
||||||
// REFRESH
|
// REFRESH
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
_selDay = 0;
|
_selDay = 0;
|
||||||
_recordsLoaded = false;
|
|
||||||
_renderShell();
|
_renderShell();
|
||||||
_tryAutoLocate();
|
_tryAutoLocate();
|
||||||
}
|
}
|
||||||
|
|
@ -189,18 +187,9 @@ window.Page_wetter = (() => {
|
||||||
style="margin-bottom:var(--space-4)">
|
style="margin-bottom:var(--space-4)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Niederschlagswahrscheinlichkeit Zeitskala -->
|
|
||||||
<div id="wttr-rain" class="section-card"
|
|
||||||
style="margin-bottom:var(--space-4)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hunde-Wetter -->
|
<!-- Hunde-Wetter -->
|
||||||
<div id="wttr-dog" class="section-card">
|
<div id="wttr-dog" class="section-card">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meine Wetterrekorde -->
|
|
||||||
<div id="wttr-records">
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Strip-Klick-Events
|
// Strip-Klick-Events
|
||||||
|
|
@ -209,15 +198,12 @@ window.Page_wetter = (() => {
|
||||||
_selDay = parseInt(card.dataset.wttrDay);
|
_selDay = parseInt(card.dataset.wttrDay);
|
||||||
_updateStrip();
|
_updateStrip();
|
||||||
_renderDetail();
|
_renderDetail();
|
||||||
_renderRainTimeline();
|
|
||||||
_renderDog();
|
_renderDog();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
_renderDetail();
|
_renderDetail();
|
||||||
_renderRainTimeline();
|
|
||||||
_renderDog();
|
_renderDog();
|
||||||
_loadRecords();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -332,9 +318,6 @@ window.Page_wetter = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gassi-Score -->
|
|
||||||
${_gassiScoreBadge(d)}
|
|
||||||
|
|
||||||
<!-- Sonnenaufgang / -untergang -->
|
<!-- Sonnenaufgang / -untergang -->
|
||||||
${sunriseStr && sunsetStr ? `
|
${sunriseStr && sunsetStr ? `
|
||||||
<div style="margin-bottom:var(--space-4)">
|
<div style="margin-bottom:var(--space-4)">
|
||||||
|
|
@ -397,137 +380,6 @@ 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
|
// HUNDE-WETTER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -538,24 +390,11 @@ window.Page_wetter = (() => {
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
|
|
||||||
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
|
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
|
||||||
const felltyp = (_appState?.activeDog ?? _appState?.dogs?.[0])?.fell_typ || null;
|
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
|
||||||
const _wl = _dogWeatherLabel(d, felltyp);
|
margin-bottom:var(--space-4)">
|
||||||
let html = `
|
<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>
|
||||||
<div style="border-radius:var(--radius);padding:var(--space-4);
|
Hunde-Wetter
|
||||||
background:${_wl.color}18;border:1px solid ${_wl.color}44;
|
</h3>`;
|
||||||
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-Hinweise
|
|
||||||
</h3>`;
|
|
||||||
|
|
||||||
// Asphalt-Temperatur
|
// Asphalt-Temperatur
|
||||||
if (d.asphalt_temp != null) {
|
if (d.asphalt_temp != null) {
|
||||||
|
|
@ -661,45 +500,6 @@ 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
|
// Wenn keine Hunde-Daten vorhanden
|
||||||
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
|
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
|
||||||
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
|
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
|
||||||
|
|
@ -713,160 +513,6 @@ window.Page_wetter = (() => {
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// GASSI-SCORE (1–10)
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
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: 10–20°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)">/ 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
|
// HILFSFUNKTIONEN — Wetter
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -911,58 +557,6 @@ window.Page_wetter = (() => {
|
||||||
return '#F44336'; // level 4+
|
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) {
|
function _tickLevel(risk) {
|
||||||
const r = (risk || '').toLowerCase();
|
const r = (risk || '').toLowerCase();
|
||||||
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
|
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
|
||||||
|
|
@ -979,104 +573,6 @@ window.Page_wetter = (() => {
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// 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
|
// PUBLIC API
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -730,34 +730,36 @@ window.Page_wiki = (() => {
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const _dogSvgLg = _DOG_SILHOUETTE.replace('width="48" height="48"', 'width="56" height="56"');
|
const _dogSvgLg = _DOG_SILHOUETTE.replace('width="48" height="48"', 'width="56" height="56"');
|
||||||
// Alle Fotos: Hauptbild zuerst, dann Community-Fotos
|
const photoHtml = rasse.foto_url
|
||||||
const allFotos = [];
|
? `<div class="wiki-detail-hero-photo-wrap">
|
||||||
if (rasse.foto_url) allFotos.push({ foto_url: rasse.foto_url, user_name: null });
|
<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}"
|
||||||
(rasse.user_fotos || []).forEach(f => allFotos.push(f));
|
onerror="this.parentElement.style.display='none';this.parentElement.nextElementSibling.style.display='flex'">
|
||||||
|
</div>
|
||||||
const photoHtml = allFotos.length
|
<div class="wiki-detail-photo-placeholder" style="display:none">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>`
|
||||||
? `<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>`;
|
: `<div class="wiki-detail-photo-placeholder">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>`;
|
||||||
|
|
||||||
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
|
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
|
||||||
const userFotosHtml = '';
|
|
||||||
|
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 body = `
|
const body = `
|
||||||
${/* 1. Hero */ ''}
|
${/* 1. Hero */ ''}
|
||||||
|
|
@ -849,65 +851,6 @@ window.Page_wiki = (() => {
|
||||||
document.getElementById('wiki-zuchter-placeholder')?.remove();
|
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', () => {
|
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
|
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
|
||||||
|
|
|
||||||
|
|
@ -280,75 +280,6 @@ const UI = (() => {
|
||||||
// Alias für ältere Aufrufe
|
// Alias für ältere Aufrufe
|
||||||
const escHtml = escape;
|
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
|
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -984,7 +915,7 @@ const UI = (() => {
|
||||||
emptyState, time,
|
emptyState, time,
|
||||||
setupPhotoPreview, scrollTop, skeleton,
|
setupPhotoPreview, scrollTop, skeleton,
|
||||||
icon: _svgIcon,
|
icon: _svgIcon,
|
||||||
escape, escHtml, help, pageInfo,
|
escape, escHtml, help,
|
||||||
saveToAlbum,
|
saveToAlbum,
|
||||||
loadLeaflet,
|
loadLeaflet,
|
||||||
leafletMarker,
|
leafletMarker,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ window.Worlds = (() => {
|
||||||
_setupButtons();
|
_setupButtons();
|
||||||
_goTo(_cur, false);
|
_goTo(_cur, false);
|
||||||
show();
|
show();
|
||||||
|
// Welten parallel rendern
|
||||||
|
_renderJetzt();
|
||||||
|
_renderHund();
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(worldIdx) {
|
function show(worldIdx) {
|
||||||
|
|
@ -63,17 +66,12 @@ window.Worlds = (() => {
|
||||||
if (worldIdx != null) _goTo(worldIdx, false);
|
if (worldIdx != null) _goTo(worldIdx, false);
|
||||||
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
|
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
|
||||||
|
|
||||||
// Nach Login/Logout: Config aus DB laden, dann rendern
|
// Nach Login/Logout neu rendern
|
||||||
const currentUserId = _state?.user?.id ?? null;
|
const currentUserId = _state?.user?.id ?? null;
|
||||||
if (currentUserId !== _lastUserId) {
|
if (currentUserId !== _lastUserId) {
|
||||||
_lastUserId = currentUserId;
|
_lastUserId = currentUserId;
|
||||||
if (currentUserId) {
|
_renderJetzt();
|
||||||
_loadConfigFromServer().then(() => { _renderJetzt(); _renderHund(); });
|
_renderHund();
|
||||||
} else {
|
|
||||||
_cfgCache = null;
|
|
||||||
_renderJetzt();
|
|
||||||
_renderHund();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,21 +125,6 @@ window.Worlds = (() => {
|
||||||
_goTo(next, true);
|
_goTo(next, true);
|
||||||
if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
|
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) {
|
function _goTo(idx, animated) {
|
||||||
|
|
@ -165,28 +148,18 @@ window.Worlds = (() => {
|
||||||
document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur));
|
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() {
|
function _updateFab() {
|
||||||
const fab = document.getElementById('worlds-fab');
|
const fab = document.getElementById('worlds-fab');
|
||||||
if (!fab) return;
|
if (!fab) return;
|
||||||
const opts = _fabOptions();
|
const icons = ['note-pencil', 'paw-print', 'warning'];
|
||||||
if (!opts.length) { fab.style.display = 'none'; return; }
|
const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden'];
|
||||||
fab.style.display = '';
|
fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`);
|
||||||
fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`);
|
fab.title = titles[_cur];
|
||||||
fab.title = 'Schnellaktion';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setupButtons() {
|
function _setupButtons() {
|
||||||
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
|
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
|
||||||
|
document.getElementById('worlds-settings')?.addEventListener('click', () => navigateTo('settings'));
|
||||||
document.getElementById('worlds-back')?.addEventListener('click', () => show());
|
document.getElementById('worlds-back')?.addEventListener('click', () => show());
|
||||||
document.querySelectorAll('.wdot').forEach((dot, i) => {
|
document.querySelectorAll('.wdot').forEach((dot, i) => {
|
||||||
dot.style.pointerEvents = 'auto';
|
dot.style.pointerEvents = 'auto';
|
||||||
|
|
@ -206,13 +179,21 @@ window.Worlds = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _openFab() {
|
function _openFab() {
|
||||||
const options = _fabOptions();
|
const isWelt = _cur === 2;
|
||||||
if (!options.length) return;
|
const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar
|
||||||
|
|
||||||
const meldenPages = new Set(['poison','lost','recalls','map']);
|
const options = isWelt ? [
|
||||||
const meldenCount = options.filter(o => meldenPages.has(o.page)).length;
|
{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' },
|
||||||
const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?';
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Overlay erstellen
|
||||||
const ov = document.createElement('div');
|
const ov = document.createElement('div');
|
||||||
ov.id = 'fab-overlay';
|
ov.id = 'fab-overlay';
|
||||||
ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end';
|
ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end';
|
||||||
|
|
@ -222,7 +203,9 @@ window.Worlds = (() => {
|
||||||
padding:20px 16px calc(env(safe-area-inset-bottom,16px) + 16px);
|
padding:20px 16px calc(env(safe-area-inset-bottom,16px) + 16px);
|
||||||
box-shadow:0 -8px 32px rgba(0,0,0,0.2)">
|
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="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||||
<div style="font-size:var(--text-base);font-weight:700">${title}</div>
|
<div style="font-size:var(--text-base);font-weight:700">
|
||||||
|
${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'}
|
||||||
|
</div>
|
||||||
<button id="fab-close" style="background:var(--c-border);border:none;border-radius:50%;
|
<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">
|
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>
|
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||||
|
|
@ -261,10 +244,6 @@ window.Worlds = (() => {
|
||||||
_close();
|
_close();
|
||||||
const page = btn.dataset.page;
|
const page = btn.dataset.page;
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
if (action === 'quickGassi') {
|
|
||||||
_openQuickGassi();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigateTo(page);
|
navigateTo(page);
|
||||||
if (action === 'openNew') {
|
if (action === 'openNew') {
|
||||||
setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
|
setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
|
||||||
|
|
@ -273,185 +252,42 @@ 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 ──────────────────────────────────────
|
// ── CHIP-KONFIGURATION ──────────────────────────────────────
|
||||||
// Alle verfügbaren Chips mit Metadaten
|
// Alle verfügbaren Chips mit Metadaten
|
||||||
|
|
||||||
const _ALL_CHIPS = [
|
const _ALL_CHIPS = [
|
||||||
{ icon:'note-pencil', label:'Notizblock', page:'notes',
|
{ 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' },
|
||||||
{ icon:'currency-eur', label:'Ausgaben', page:'expenses',
|
{ icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' },
|
||||||
fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] },
|
{ icon:'handshake', label:'Playdate', page:'playdate' },
|
||||||
{ icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' },
|
{ icon:'chat-circle-dots', label:'Nachrichten', page:'chat' },
|
||||||
{ icon:'handshake', label:'Playdate', page:'playdate',
|
{ icon:'sun', label:'Wetter', page:'wetter' },
|
||||||
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:'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' },
|
||||||
{ icon:'heartbeat', label:'Gesundheit', page:'health',
|
{ icon:'target', label:'Übungen', page:'uebungen' },
|
||||||
fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' },
|
{ icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'},
|
||||||
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] },
|
{ icon:'heart', label:'Adoption', page:'adoption' },
|
||||||
{ icon:'target', label:'Übungen', page:'uebungen',
|
{ icon:'house-line', label:'Sitting', page:'sitting' },
|
||||||
fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] },
|
{ icon:'books', label:'Wiki', page:'wiki' },
|
||||||
{ icon:'list-checks', label:'Trainings-\npläne', page:'trainingsplaene',
|
{ icon:'scales', label:'Wurfbörse', page:'wurfboerse' },
|
||||||
fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] },
|
{ icon:'map-trifold', label:'Karte', page:'map' },
|
||||||
{ icon:'heart', label:'Adoption', page:'adoption',
|
{ icon:'push-pin', label:'Forum', page:'forum' },
|
||||||
fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] },
|
{ icon:'users', label:'Freunde', page:'friends' },
|
||||||
{ icon:'house-line', label:'Sitting', page:'sitting',
|
{ icon:'paw-print', label:'Gassi', page:'walks' },
|
||||||
fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] },
|
{ icon:'skull', label:'Giftköder', page:'poison' },
|
||||||
{ icon:'books', label:'Wiki', page:'wiki' },
|
{ icon:'warning-circle', label:'Rückrufe', page:'recalls' },
|
||||||
{ icon:'scales', label:'Wurfbörse', page:'wurfboerse' },
|
{ icon:'dog', label:'Verlorene', page:'lost' },
|
||||||
{ icon:'map-trifold', label:'Karte', page:'map',
|
{ icon:'path', label:'Routen', page:'routes' },
|
||||||
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
|
{ icon:'calendar-dots', label:'Events', page:'events' },
|
||||||
{ icon:'push-pin', label:'Forum', page:'forum',
|
{ icon:'sparkle', label:'Jobs', page:'jobs' },
|
||||||
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
|
{ icon:'book-open', label:'Knigge', page:'knigge' },
|
||||||
{ icon:'users', label:'Freunde', page:'friends',
|
{ icon:'film-slate', label:'Filme', page:'movies' },
|
||||||
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
|
{ icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' },
|
||||||
{ icon:'paw-print', label:'Gassi', page:'walks',
|
{ icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' },
|
||||||
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
|
{ icon:'sparkle', label:'Social', page:'social', role:'social' },
|
||||||
{ icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] },
|
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
|
||||||
{ icon:'skull', label:'Giftköder', page:'poison',
|
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
|
||||||
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',
|
|
||||||
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' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const _DEFAULT_CONFIG = {
|
const _DEFAULT_CONFIG = {
|
||||||
|
|
@ -460,40 +296,12 @@ window.Worlds = (() => {
|
||||||
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
|
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// _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() {
|
function _getConfig() {
|
||||||
return _cfgCache || _DEFAULT_CONFIG;
|
try { return JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; }
|
||||||
|
catch { return _DEFAULT_CONFIG; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveConfig(cfg) {
|
function _saveConfig(cfg) {
|
||||||
_cfgCache = cfg;
|
|
||||||
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
|
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
|
||||||
if (_state?.user) {
|
|
||||||
API.put('/profile/world-config', { config: cfg }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function _chipMeta(page) {
|
function _chipMeta(page) {
|
||||||
return _ALL_CHIPS.find(c => c.page === page) || null;
|
return _ALL_CHIPS.find(c => c.page === page) || null;
|
||||||
|
|
@ -766,13 +574,18 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
async function _loadDailyImage(dog) {
|
async function _loadDailyImage(dog) {
|
||||||
if (!dog) return null;
|
if (!dog) return null;
|
||||||
const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10);
|
const todayKey = 'bg_' + new Date().toISOString().slice(0, 10);
|
||||||
const cached = _wLoad(todayKey);
|
const cached = _wLoad(todayKey);
|
||||||
if (cached?.data) return cached.data;
|
if (cached?.data) return cached.data;
|
||||||
try {
|
try {
|
||||||
const dash = await API.dogs.welcomeDashboard(dog.id);
|
const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`);
|
||||||
const url = dash?.random_photo?.url || dog.foto_url || null;
|
const entries = r.data?.entries || r.data || [];
|
||||||
if (url) _wSave(todayKey, url);
|
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);
|
||||||
return url;
|
return url;
|
||||||
} catch { return dog.foto_url || null; }
|
} catch { return dog.foto_url || null; }
|
||||||
}
|
}
|
||||||
|
|
@ -812,11 +625,10 @@ window.Worlds = (() => {
|
||||||
const user = _state?.user;
|
const user = _state?.user;
|
||||||
el.innerHTML = _skeleton(3);
|
el.innerHTML = _skeleton(3);
|
||||||
|
|
||||||
const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([
|
const [weatherRes, dogsRes, alertsRes] = await Promise.allSettled([
|
||||||
_getCachedWeather(),
|
_getCachedWeather(),
|
||||||
user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }),
|
user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }),
|
||||||
user ? _getNearbyAlerts() : Promise.resolve([]),
|
user ? _getNearbyAlerts() : Promise.resolve([]),
|
||||||
user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 };
|
const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 };
|
||||||
|
|
@ -825,7 +637,6 @@ window.Worlds = (() => {
|
||||||
const dogList = dogsObj.data || [];
|
const dogList = dogsObj.data || [];
|
||||||
const dog = dogList[0] || null;
|
const dog = dogList[0] || null;
|
||||||
const alertList = alertsRes.value || [];
|
const alertList = alertsRes.value || [];
|
||||||
const totalKm = achRes.value?.data?.stats?.total_km ?? null;
|
|
||||||
const isOffline = weatherObj.fromCache && dogsObj.fromCache;
|
const isOffline = weatherObj.fromCache && dogsObj.fromCache;
|
||||||
const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0);
|
const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0);
|
||||||
|
|
||||||
|
|
@ -901,7 +712,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-info-title">
|
<div class="world-info-title">
|
||||||
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
|
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div>
|
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
${user ? userAvatarHtml : ''}
|
${user ? userAvatarHtml : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v700';
|
const CACHE_VERSION = 'by-v651';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
@ -202,8 +202,7 @@ self.addEventListener('fetch', event => {
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
_cacheMark(url.pathname);
|
_cacheMark(url.pathname);
|
||||||
const toCache = resp.clone();
|
caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
|
||||||
caches.open(CACHE_API).then(c => c.put(event.request, toCache));
|
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,6 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
|
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
|
||||||
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
|
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
|
||||||
"sunrise,sunset"
|
"sunrise,sunset"
|
||||||
"&hourly=precipitation_probability,precipitation,weathercode"
|
|
||||||
"&timezone=auto&forecast_days=7"
|
"&timezone=auto&forecast_days=7"
|
||||||
)
|
)
|
||||||
pollen_url = (
|
pollen_url = (
|
||||||
|
|
@ -246,7 +245,6 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
raw = forecast_resp.json()
|
raw = forecast_resp.json()
|
||||||
|
|
||||||
daily = raw.get('daily', {})
|
daily = raw.get('daily', {})
|
||||||
hourly_fc = raw.get('hourly', {})
|
|
||||||
timezone = raw.get('timezone', 'auto')
|
timezone = raw.get('timezone', 'auto')
|
||||||
|
|
||||||
dates = daily.get('time', [])
|
dates = daily.get('time', [])
|
||||||
|
|
@ -263,24 +261,6 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
sunrises = daily.get('sunrise', [])
|
sunrises = daily.get('sunrise', [])
|
||||||
sunsets = daily.get('sunset', [])
|
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 (optional) ---
|
||||||
pollen_daily: dict | None = None
|
pollen_daily: dict | None = None
|
||||||
if not isinstance(pollen_resp, Exception):
|
if not isinstance(pollen_resp, Exception):
|
||||||
|
|
@ -381,7 +361,6 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
'zecken': zecken,
|
'zecken': zecken,
|
||||||
'thunderstorm': wcode in {95, 96, 99},
|
'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),
|
'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}
|
result = {'timezone': timezone, 'days': days}
|
||||||
|
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
[
|
|
||||||
{ "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" }
|
|
||||||
]
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
#!/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()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue