Compare commits

...

6 commits

Author SHA1 Message Date
bda61a0e40 Feature: Trauer-Feature, Futter-Verträglichkeit, Multi-Hund-Fixes, Wetter-Ort (Sprint 47)
- dog-profile.js: Verstorben-Button, Gedenkseite, KI-Abschiedstext
- database.py: futter_eintraege/reaktionen, route_dogs, exercise_progress.dog_id
- routes/ernaehrung.py: Futter-Verträglichkeit mit 20 Reaktionstypen + Analyse
- routes/routen.py: route_dogs Many-to-Many, Routen editierbar
- routes/training.py: exercise_progress per dog_id
- routes/ki.py: /ki/abschied Trauer-KI
- weather.py: Nominatim Ortsname parallel geladen
- ui.js: dogChip/bindDogChip, visualViewport-Modal
- api.js: gedenken, gedenkseite, futter-Methoden, route_dogs
- worlds.js: Ortsname im Wetter-Chip
- uebungen.js: _progressLoaded-Flag, dog-spezifischer Fortschritt
- trainingsplaene.js: dog_id Unterstützung
- diary.js/health.js: P-Badge Cleanup
- map.js: Wetter-Ort-Anzeige entfernt
- wetter.js: Ort in Wetter-Detail
2026-05-11 19:28:38 +02:00
1ce802c8dc Fix: Ernährung Hund-spezifisch, Erinnerungen in Settings, Übung des Tages per Hund (SW by-v872)
- ernaehrung.js: onDogChange setzt activeTab zurück, Hund klar sichtbar
- settings.js: Erinnerungen-Sektion lädt verstorbene Hunde + öffnet Gedenkseite
- dogs.py: GET /dogs/verstorben Endpoint (korrekte Route-Reihenfolge vor /{dog_id})
- dogs.py: Übung des Tages filtert jetzt nach dog_id statt user_id (sitzt-Übungen korrekt ausgeschlossen)
- Routen zeigen verstorbene Hunde korrekt als Teilnehmer (route_dogs ohne verstorben-Filter)
2026-05-11 19:25:00 +02:00
265d3d4fe2 Fix: HUND-Welt Avatar-Klick setzt activeDog korrekt vor dog-profile Navigation (SW by-v856) 2026-05-11 17:30:26 +02:00
79fa5684b9 Feature+Fix: Referral-Admin, Pro-Gates, Karten-Layer, onDogChange, Staging-Media (SW by-v855)
Features:
- Admin: Referral-Tab (Virality Factor, Top-Werber, letzte Einladungen)
- Karte: Regenradar (RainViewer, zoom→7, color=4), Temperatur-Layer (OWM) mit Zahlen-Grid + Legende
- Wetter-Chip: Umschwung-Warnung bei ≥40%-Sprung in Niederschlagswahrscheinlichkeit
- Freundschaftsanfragen: Accept/Decline direkt in Notifications (kein Pro nötig)
- Freunde-Seite für Standard-User freigeschaltet

Pro-Gates:
- KI-Trainer, Routenvorschläge, Regenradar, Temperatur-Layer jetzt Pro-Feature
- Pro-Badge (P) auf Chips für Admins/Mods in allen Welten + Welten-einrichten
- Oranger Banner auf Pro-Seiten für Admin/Mod/Manager

Bugfixes:
- onDogChange: uebungen.js (Cache leeren + _render), trainingsplaene.js (war leer)
- robots.txt vereinfacht (nur Disallow, kein Allow-Durcheinander)
- Hintergrund-Foto: Querformat-Filter korrigiert (kein Fallback auf Hochformat)
- Staging Media: FileResponse mit korrektem MIME-Type, no-cache statt immutable
- Staging Docker: MEDIA_DIR=/data/media + /prod-media:ro Fallback-Handler
- Staging-Fix: Bild-Upload auf zweitem Hund (war Read-only file system)
2026-05-11 17:23:29 +02:00
2f021f54c2 Fix: Hund-Limit Standard-User (max 1, Pro required für weitere), nacho_sarah → Pro (SW by-v834) 2026-05-10 13:00:39 +02:00
70af387147 Feature: User-Feedback, Regen-Uhrzeit im Wetter-Chip, Admin-Karten klickbar (SW by-v833)
- Feedback-Modal im Settings (Kategorie + Text → E-Mail an support@banyaro.app)
- Wetter-Chip (Karte + Gassi-Score): zeigt nächste Regenstunde ab ≥20% Wahrscheinlichkeit
- Gassi-Score-Chip: zweizeilige Wetter-Info, linksbündig, volle Chipbreite
- Admin-Übersicht: Stat-Karten anklickbar → navigiert direkt zum jeweiligen Tab
- ui.js: visualViewport-Listener hebt Modal über Tastatur (alle Modals)
- api.js: Pydantic v2 Array-Detail korrekt als Fehlermeldung extrahiert
- map.js: Wetter-Fallback über watchPosition wenn getCurrentPosition scheitert
- Update-Loop-Fix: index.html ?v= synchron mit APP_VER halten (alle 4 Stellen)
2026-05-10 12:52:55 +02:00
33 changed files with 2576 additions and 273 deletions

View file

@ -189,6 +189,13 @@ def init_db():
); );
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id); CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
CREATE TABLE IF NOT EXISTS route_dogs (
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
PRIMARY KEY (route_id, dog_id)
);
CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id);
CREATE TABLE IF NOT EXISTS exercise_progress ( CREATE TABLE IF NOT EXISTS exercise_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -1974,6 +1981,38 @@ def _migrate(conn_factory):
""") """)
logger.info("Migration: futter_profil bereit.") logger.info("Migration: futter_profil bereit.")
# Futter-Einträge & Reaktionen (Verträglichkeits-Tracking)
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS futter_eintraege (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
datum TEXT NOT NULL,
uhrzeit TEXT NOT NULL,
futter_name TEXT NOT NULL,
futter_typ TEXT NOT NULL DEFAULT 'trockenfutter',
menge_g INTEGER,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC);
CREATE TABLE IF NOT EXISTS futter_reaktionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
datum TEXT NOT NULL,
uhrzeit TEXT NOT NULL,
reaktion_typ TEXT NOT NULL,
intensitaet INTEGER NOT NULL DEFAULT 3,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC);
""")
logger.info("Migration: futter_eintraege + futter_reaktionen bereit.")
except Exception as e:
logger.warning(f"Migration futter_eintraege/reaktionen: {e}")
# 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 (
@ -2104,6 +2143,85 @@ def _migrate(conn_factory):
except Exception: except Exception:
pass # Spalte existiert bereits pass # Spalte existiert bereits
# exercise_progress + training_plan_progress: dog_id ergänzen
existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()]
if 'dog_id' not in existing_ep:
try:
# Neue Tabelle mit dog_id erstellen
conn.execute("""
CREATE TABLE exercise_progress_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
exercise_id TEXT NOT NULL,
status TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(dog_id, exercise_id)
)
""")
# Bestehende Daten migrieren: dog_id = erster Hund des Users
conn.execute("""
INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at)
SELECT ep.user_id,
(SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1),
ep.exercise_id, ep.status, ep.updated_at
FROM exercise_progress ep
""")
conn.execute("DROP TABLE exercise_progress")
conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress")
conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)")
logger.info("Migration: exercise_progress.dog_id hinzugefügt.")
except Exception as e:
logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}")
existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()]
if 'dog_id' not in existing_tp:
try:
conn.execute("""
CREATE TABLE training_plan_progress_new (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
item_key TEXT NOT NULL,
checked INTEGER NOT NULL DEFAULT 1,
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (dog_id, item_key)
)
""")
conn.execute("""
INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at)
SELECT tp.user_id,
(SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1),
tp.item_key, tp.checked, tp.checked_at
FROM training_plan_progress tp
""")
conn.execute("DROP TABLE training_plan_progress")
conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress")
logger.info("Migration: training_plan_progress.dog_id hinzugefügt.")
except Exception as e:
logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}")
# verstorben_am: Hund als verstorben markierbar
try:
conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT")
logger.info("Migration: dogs.verstorben_am hinzugefügt.")
except Exception:
pass
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
try:
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
if existing == 0:
conn.execute("""
INSERT OR IGNORE INTO route_dogs (route_id, dog_id)
SELECT r.id, d.id
FROM routes r
JOIN dogs d ON d.user_id = r.user_id
""")
logger.info("Migration: route_dogs mit bestehenden Routen befüllt.")
except Exception as e:
logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
def _seed_help_articles(conn): def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""

View file

@ -179,6 +179,9 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
response = await call_next(request) response = await call_next(request)
if request.url.path.startswith('/media/'): if request.url.path.startswith('/media/'):
if os.getenv('STAGING') == 'true':
response.headers['Cache-Control'] = 'no-cache'
else:
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return response return response
@ -250,6 +253,7 @@ from routes.ernaehrung import router as ernaehrung_router
from routes.challenges import router as challenges_router from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_router from routes.help import router as help_router
from routes.feedback import router as feedback_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"])
@ -312,6 +316,7 @@ app.include_router(ernaehrung_router, prefix="/api/dogs", tag
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"]) app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -339,9 +344,39 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") 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_VER = "826" # muss mit APP_VER in app.js übereinstimmen STAGING = os.getenv("STAGING", "false").lower() == "true"
PROD_MEDIA_DIR = "/prod-media"
_MIME_MAP = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4",
".webm": "video/webm", ".pdf": "application/pdf",
}
if STAGING and os.path.isdir(PROD_MEDIA_DIR):
# Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only)
from fastapi.responses import FileResponse as _FileResponse
def _media_response(filepath: str):
ext = os.path.splitext(filepath)[1].lower()
mt = _MIME_MAP.get(ext, "application/octet-stream")
return _FileResponse(filepath, media_type=mt)
@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
async def serve_media_staging(path: str):
staging_file = os.path.join(MEDIA_DIR, path)
if os.path.isfile(staging_file):
return _media_response(staging_file)
prod_file = os.path.join(PROD_MEDIA_DIR, path)
if os.path.isfile(prod_file):
return _media_response(prod_file)
from fastapi import HTTPException as _HE
raise _HE(404, "Media not found")
else:
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "872" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -583,6 +583,48 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
# ------------------------------------------------------------------
# GET /api/admin/referrals — User-wirbt-User Top 100
# ------------------------------------------------------------------
@router.get("/referrals")
async def referral_stats(user=Depends(require_mod)):
with db() as conn:
# Top-Werber mit Anzahl
top = conn.execute("""
SELECT r.id, r.name, r.email,
COUNT(u.id) AS invited_count,
r.created_at AS member_since
FROM users u
JOIN users r ON r.id = u.referred_by
GROUP BY r.id
ORDER BY invited_count DESC
LIMIT 100
""").fetchall()
# Alle Einladungen (für Detail-Ansicht)
invites = conn.execute("""
SELECT u.id, u.name, u.email, u.created_at,
r.id AS referrer_id, r.name AS referrer_name
FROM users u
JOIN users r ON r.id = u.referred_by
ORDER BY u.created_at DESC
LIMIT 500
""").fetchall()
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
total_referred = conn.execute(
"SELECT COUNT(*) FROM users WHERE referred_by IS NOT NULL"
).fetchone()[0]
return {
"top_referrers": [dict(r) for r in top],
"recent_invites": [dict(r) for r in invites],
"total_users": total_users,
"total_referred": total_referred,
"viral_factor": round(total_referred / max(total_users - total_referred, 1), 2),
}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time) # GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user, has_pro_access
from routes.push import send_push_to_user from routes.push import send_push_to_user
from media_utils import safe_media_path, preview_url_from from media_utils import safe_media_path, preview_url_from
@ -41,7 +41,7 @@ class DogUpdate(BaseModel):
async def list_dogs(user=Depends(get_current_user)): async def list_dogs(user=Depends(get_current_user)):
with db() as conn: with db() as conn:
own = conn.execute( own = conn.execute(
"SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? ORDER BY id", "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? AND (verstorben_am IS NULL) ORDER BY id",
(user["id"],) (user["id"],)
).fetchall() ).fetchall()
shared = conn.execute( shared = conn.execute(
@ -131,6 +131,14 @@ def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]:
@router.post("") @router.post("")
async def create_dog(data: DogCreate, user=Depends(get_current_user)): async def create_dog(data: DogCreate, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
existing = conn.execute(
"SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],)
).fetchone()[0]
if existing >= 1 and not has_pro_access(user):
raise HTTPException(
status_code=403,
detail="Mehrere Hunde sind ein Pro-Feature. Upgrade auf Ban Yaro Pro, um weitere Hunde anzulegen."
)
conn.execute( conn.execute(
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht, """INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
gewicht_kg, chip_nr, bio, is_public) gewicht_kg, chip_nr, bio, is_public)
@ -180,8 +188,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
if not dog: if not dog:
raise HTTPException(404, "Hund nicht gefunden.") raise HTTPException(404, "Hund nicht gefunden.")
# Zufälliges Foto aus den letzten 100 Tagebuchbildern # Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
# 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
@ -190,12 +197,13 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", 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) # Fallback: Bilder ohne Dimensionsdaten (vor dem Backfill hochgeladen)
if not photos: if not photos:
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 NULL
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,) (dog_id,)
).fetchall() ).fetchall()
@ -247,15 +255,16 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
day_num = (_dt.date.today() - _dt.date(2024, 1, 1)).days day_num = (_dt.date.today() - _dt.date(2024, 1, 1)).days
# Versuche JOIN (funktioniert wenn js_exercise_id-Spalte vorhanden) # Versuche JOIN (funktioniert wenn js_exercise_id-Spalte vorhanden)
# Nur Übungen des aktiven Hundes, 'sitzt' ausschließen
try: try:
joined = conn.execute( joined = conn.execute(
"""SELECT ep.exercise_id, te.name, te.kategorie AS kategorie_raw, """SELECT ep.exercise_id, te.name, te.kategorie AS kategorie_raw,
te.schwierigkeit, te.js_exercise_id te.schwierigkeit, te.js_exercise_id
FROM exercise_progress ep FROM exercise_progress ep
JOIN training_exercises te ON te.js_exercise_id = ep.exercise_id JOIN training_exercises te ON te.js_exercise_id = ep.exercise_id
WHERE ep.user_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens') WHERE ep.dog_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens')
ORDER BY ep.updated_at ASC LIMIT 50""", ORDER BY ep.updated_at ASC LIMIT 50""",
(user["id"],) (dog_id,)
).fetchall() ).fetchall()
except Exception: except Exception:
joined = [] joined = []
@ -280,9 +289,9 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
) )
raw = conn.execute( raw = conn.execute(
"""SELECT exercise_id FROM exercise_progress """SELECT exercise_id FROM exercise_progress
WHERE user_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens') WHERE dog_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens')
ORDER BY updated_at ASC LIMIT 50""", ORDER BY updated_at ASC LIMIT 50""",
(user["id"],) (dog_id,)
).fetchall() ).fetchall()
valid = [r["exercise_id"] for r in raw valid = [r["exercise_id"] for r in raw
if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)] if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)]
@ -771,6 +780,21 @@ async def get_hunde_buch(
return HTMLResponse(content=html_page) return HTMLResponse(content=html_page)
# ------------------------------------------------------------------
# GET /api/dogs/verstorben — Alle verstorbenen Hunde des Users
# ------------------------------------------------------------------
@router.get("/verstorben")
async def get_verstorbene_hunde(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT id, name, rasse, foto_url, verstorben_am, geburtstag
FROM dogs WHERE user_id=? AND verstorben_am IS NOT NULL
ORDER BY verstorben_am DESC""",
(user["id"],)
).fetchall()
return [dict(r) for r in rows]
@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:
@ -1200,14 +1224,15 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
"ref_id": r["id"], "ref_id": r["id"],
}) })
# --- Routen --- # --- Routen (nur Routen wo dieser Hund mitgegangen ist) ---
route_rows = conn.execute( route_rows = conn.execute(
"""SELECT id, name, distanz_km, """SELECT r.id, r.name, r.distanz_km,
date(created_at) AS datum date(r.created_at) AS datum
FROM routes FROM routes r
WHERE user_id=? JOIN route_dogs rd ON rd.route_id = r.id AND rd.dog_id = ?
ORDER BY created_at ASC""", WHERE r.user_id = ?
(user["id"],) ORDER BY r.created_at ASC""",
(dog_id, user["id"])
).fetchall() ).fetchall()
route_first = True route_first = True
@ -1255,3 +1280,108 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
"geburtstag": dog["geburtstag"], "geburtstag": dog["geburtstag"],
"events": events, "events": events,
} }
# ------------------------------------------------------------------
# POST /api/dogs/{id}/gedenken — Hund als verstorben markieren
# ------------------------------------------------------------------
class GedenkenData(BaseModel):
verstorben_am: str # YYYY-MM-DD
@router.post("/{dog_id}/gedenken")
async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):
with db() as conn:
updated = conn.execute(
"UPDATE dogs SET verstorben_am=? WHERE id=? AND user_id=?",
(data.verstorben_am, dog_id, user["id"])
).rowcount
if not updated:
raise HTTPException(404, "Hund nicht gefunden.")
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/dogs/{id}/gedenkseite — Memorial-Daten
# ------------------------------------------------------------------
@router.get("/{dog_id}/gedenkseite")
async def get_gedenkseite(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404)
dog = dict(dog)
# Statistiken
km_total = conn.execute(
"SELECT COALESCE(ROUND(SUM(distanz_km),1),0) AS km FROM routes r "
"JOIN route_dogs rd ON rd.route_id=r.id WHERE rd.dog_id=?", (dog_id,)
).fetchone()["km"]
diary_count = conn.execute(
"SELECT COUNT(*) FROM diary WHERE dog_id=?", (dog_id,)
).fetchone()[0]
media_count = conn.execute(
"SELECT COUNT(*) FROM diary_media dm JOIN diary d ON d.id=dm.diary_id "
"WHERE d.dog_id=? AND dm.media_type='image'", (dog_id,)
).fetchone()[0]
training_count = conn.execute(
"SELECT COUNT(*) FROM training_sessions WHERE dog_id=?", (dog_id,)
).fetchone()[0]
# Letzter Tagebucheintrag
last_entry = conn.execute(
"SELECT titel, datum FROM diary WHERE dog_id=? ORDER BY datum DESC LIMIT 1",
(dog_id,)
).fetchone()
# Erste und letzte Aufnahme
first_entry = conn.execute(
"SELECT datum FROM diary WHERE dog_id=? ORDER BY datum ASC LIMIT 1",
(dog_id,)
).fetchone()
# Letzte 6 Fotos für Galerie
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'
AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
ORDER BY d.datum DESC, dm.id DESC LIMIT 6""",
(dog_id,)
).fetchall()
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, dm.id DESC LIMIT 6""",
(dog_id,)
).fetchall()
# Gemeinsame Zeit berechnen
joined = dog.get("geburtstag") or (first_entry["datum"] if first_entry else None)
passed = dog.get("verstorben_am")
gemeinsam_tage = None
if joined and passed:
try:
from datetime import date as _date
d1 = _date.fromisoformat(joined)
d2 = _date.fromisoformat(passed)
gemeinsam_tage = (d2 - d1).days
except Exception:
pass
return {
"dog": dog,
"km_total": km_total,
"diary_count": diary_count,
"media_count": media_count,
"training_count": training_count,
"last_entry": dict(last_entry) if last_entry else None,
"gemeinsam_tage": gemeinsam_tage,
"photos": [r["url"] for r in photos],
}

View file

@ -143,3 +143,305 @@ async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
raise HTTPException(503, str(e)) raise HTTPException(503, str(e))
except Exception: except Exception:
raise HTTPException(500, "KI momentan nicht verfügbar.") raise HTTPException(500, "KI momentan nicht verfügbar.")
# ==================================================================
# FUTTER-VERTRÄGLICHKEIT
# ==================================================================
REAKTION_TYPEN = {
# Positiv
"verdauung_gut": {"label": "Gute Verdauung", "kategorie": "positiv", "fenster_h": 8},
"energie_hoch": {"label": "Viel Energie", "kategorie": "positiv", "fenster_h": 12},
"fell_glaenzend": {"label": "Glänzendes Fell", "kategorie": "positiv", "fenster_h": 336}, # 2 Wochen
# Gastrointestinal
"erbrechen": {"label": "Erbrechen", "kategorie": "gastro_negativ", "fenster_h": 6},
"durchfall": {"label": "Durchfall", "kategorie": "gastro_negativ", "fenster_h": 8},
"blaehungen": {"label": "Blähungen", "kategorie": "gastro_negativ", "fenster_h": 6},
"weicher_stuhl": {"label": "Weicher Stuhl", "kategorie": "gastro_negativ", "fenster_h": 8},
"appetitlosigkeit":{"label": "Appetitlosigkeit", "kategorie": "gastro_negativ", "fenster_h": 12},
# Haut & Fell
"juckreiz": {"label": "Juckreiz / Kratzen", "kategorie": "haut_negativ", "fenster_h": 72},
"haarausfall": {"label": "Haarausfall", "kategorie": "haut_negativ", "fenster_h": 336},
"stumpfes_fell": {"label": "Stumpfes Fell", "kategorie": "haut_negativ", "fenster_h": 336},
"schuppenbildung":{"label": "Schuppenbildung", "kategorie": "haut_negativ", "fenster_h": 168},
"roetungen": {"label": "Hautrötungen / Entzündung", "kategorie": "haut_negativ", "fenster_h": 72},
"pfotenlecken": {"label": "Pfoten lecken (chronisch)", "kategorie": "haut_negativ", "fenster_h": 168},
"ohrentzuendung": {"label": "Ohrentzündung", "kategorie": "haut_negativ", "fenster_h": 168},
"fettiges_fell": {"label": "Fettiges Fell / Seborrhö", "kategorie": "haut_negativ", "fenster_h": 336},
# Allgemeinbefinden
"schlappheit": {"label": "Schlappheit / Apathie", "kategorie": "allgemein_negativ", "fenster_h": 12},
"nervositaet": {"label": "Nervosität / Unruhe", "kategorie": "allgemein_negativ", "fenster_h": 12},
"viel_trinken": {"label": "Ungewöhnlich viel trinken", "kategorie": "allgemein_negativ", "fenster_h": 24},
"sonstiges": {"label": "Sonstiges", "kategorie": "sonstiges", "fenster_h": 24},
}
_POSITIV_KAT = {"positiv"}
_NEGATIV_KAT = {"gastro_negativ", "haut_negativ", "allgemein_negativ"}
_HAUT_HINWEIS = "Haut- & Fell-Symptome wie {label} entwickeln sich typischerweise über Wochen. Mindestens 46 Wochen Beobachtung empfohlen — auch nach einem Futterwechsel dauert eine Besserung 26 Wochen."
_GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb weniger Stunden auf. Wenn sie häufig wiederkehren, ist ein Tierarztbesuch empfohlen."
class FutterEintragCreate(BaseModel):
datum: str
uhrzeit: str
futter_name: str
futter_typ: Optional[str] = "trockenfutter"
menge_g: Optional[int] = None
notiz: Optional[str] = None
class ReaktionCreate(BaseModel):
datum: str
uhrzeit: str
reaktion_typ: str
intensitaet: Optional[int] = 3
notiz: Optional[str] = None
# ------------------------------------------------------------------
# POST /dogs/{dog_id}/futter
# ------------------------------------------------------------------
@router.post("/{dog_id}/futter")
async def create_futter_eintrag(dog_id: int, body: FutterEintragCreate,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
cur = conn.execute("""
INSERT INTO futter_eintraege
(dog_id, datum, uhrzeit, futter_name, futter_typ, menge_g, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (dog_id, body.datum, body.uhrzeit, body.futter_name,
body.futter_typ or "trockenfutter", body.menge_g, body.notiz))
row = conn.execute(
"SELECT * FROM futter_eintraege WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /dogs/{dog_id}/futter
# ------------------------------------------------------------------
@router.get("/{dog_id}/futter")
async def list_futter_eintraege(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
rows = conn.execute("""
SELECT * FROM futter_eintraege
WHERE dog_id=?
ORDER BY datum DESC, uhrzeit DESC
LIMIT 50
""", (dog_id,)).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# DELETE /dogs/{dog_id}/futter/{entry_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/futter/{entry_id}")
async def delete_futter_eintrag(dog_id: int, entry_id: int,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
result = conn.execute(
"DELETE FROM futter_eintraege WHERE id=? AND dog_id=?",
(entry_id, dog_id)
)
if result.rowcount == 0:
raise HTTPException(404, "Eintrag nicht gefunden.")
return {"ok": True}
# ------------------------------------------------------------------
# POST /dogs/{dog_id}/futter/reaktion
# ------------------------------------------------------------------
@router.post("/{dog_id}/futter/reaktion")
async def create_reaktion(dog_id: int, body: ReaktionCreate,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
cur = conn.execute("""
INSERT INTO futter_reaktionen
(dog_id, datum, uhrzeit, reaktion_typ, intensitaet, notiz)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, body.datum, body.uhrzeit, body.reaktion_typ,
body.intensitaet or 3, body.notiz))
row = conn.execute(
"SELECT * FROM futter_reaktionen WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /dogs/{dog_id}/futter/reaktionen
# ------------------------------------------------------------------
@router.get("/{dog_id}/futter/reaktionen")
async def list_reaktionen(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
rows = conn.execute("""
SELECT * FROM futter_reaktionen
WHERE dog_id=?
ORDER BY datum DESC, uhrzeit DESC
LIMIT 50
""", (dog_id,)).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# DELETE /dogs/{dog_id}/futter/reaktion/{react_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/futter/reaktion/{react_id}")
async def delete_reaktion(dog_id: int, react_id: int,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
result = conn.execute(
"DELETE FROM futter_reaktionen WHERE id=? AND dog_id=?",
(react_id, dog_id)
)
if result.rowcount == 0:
raise HTTPException(404, "Reaktion nicht gefunden.")
return {"ok": True}
# ------------------------------------------------------------------
# GET /dogs/{dog_id}/futter/analyse
# ------------------------------------------------------------------
@router.get("/{dog_id}/futter/analyse")
async def futter_analyse(dog_id: int, user=Depends(get_current_user)):
from datetime import datetime
with db() as conn:
_check_dog_access(conn, dog_id, user["id"])
eintraege = conn.execute(
"SELECT * FROM futter_eintraege WHERE dog_id=? ORDER BY datum, uhrzeit",
(dog_id,)
).fetchall()
reaktionen = conn.execute(
"SELECT * FROM futter_reaktionen WHERE dog_id=? ORDER BY datum, uhrzeit",
(dog_id,)
).fetchall()
def parse_ts(datum, uhrzeit):
try:
return datetime.fromisoformat(f"{datum}T{uhrzeit}")
except Exception:
return None
# futter_name → {typ, mahlzeiten, positiv, negativ, kategorien: {kat: count}}
futter_stats: dict = {}
for e in eintraege:
name = e["futter_name"]
if name not in futter_stats:
futter_stats[name] = {
"name": name,
"typ": e["futter_typ"],
"mahlzeiten": 0,
"positiv": 0,
"negativ": 0,
"kategorien": {},
}
futter_stats[name]["mahlzeiten"] += 1
for r in reaktionen:
r_ts = parse_ts(r["datum"], r["uhrzeit"])
if not r_ts:
continue
r_typ = r["reaktion_typ"]
meta = REAKTION_TYPEN.get(r_typ, {"kategorie": "sonstiges", "fenster_h": 24})
kat = meta["kategorie"]
fenster = meta["fenster_h"]
# Mindestfenster 1h, maximales Fenster wie angegeben
min_h = 1
for e in eintraege:
e_ts = parse_ts(e["datum"], e["uhrzeit"])
if not e_ts:
continue
diff = (r_ts - e_ts).total_seconds() / 3600
if min_h <= diff <= fenster:
name = e["futter_name"]
if name not in futter_stats:
continue
if kat in _POSITIV_KAT:
futter_stats[name]["positiv"] += 1
elif kat in _NEGATIV_KAT:
futter_stats[name]["negativ"] += 1
# Kategorie-Zähler
futter_stats[name]["kategorien"][kat] = \
futter_stats[name]["kategorien"].get(kat, 0) + 1
result_futter = []
for stats in futter_stats.values():
positiv = stats["positiv"]
negativ = stats["negativ"]
total = positiv + negativ
if total == 0:
score = 50
status = "neu"
else:
raw = (positiv - negativ * 2) / max(1, total)
# raw liegt zwischen -2 und 1 → normieren auf 0-100
score = int(max(0, min(100, (raw + 2) / 3 * 100)))
if score >= 60:
status = "gut"
elif score >= 30:
status = "neutral"
else:
status = "problematisch"
result_futter.append({
"name": stats["name"],
"typ": stats["typ"],
"mahlzeiten": stats["mahlzeiten"],
"positiv": positiv,
"negativ": negativ,
"score": score,
"status": status,
"kategorien": stats["kategorien"],
})
# Sortierung: problematisch → neutral → gut → neu, dann nach Score
ORDER = {"problematisch": 0, "neutral": 1, "gut": 2, "neu": 3}
result_futter.sort(key=lambda x: (ORDER.get(x["status"], 9), -x["score"]))
# Hinweis ableiten: erstes problematisches Futter mit Haut/Gastro-Symptomen
hinweis = None
for f in result_futter:
if f["status"] != "problematisch":
continue
kats = f["kategorien"]
if kats.get("haut_negativ", 0) > 0:
# Häufigstes Haut-Symptom finden
haut_rxn = [
r["reaktion_typ"] for r in reaktionen
if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "haut_negativ"
]
label = REAKTION_TYPEN.get(haut_rxn[0], {}).get("label", "Haut-Symptome") if haut_rxn else "Haut-Symptome"
hinweis = _HAUT_HINWEIS.format(label=label)
break
if kats.get("gastro_negativ", 0) > 0:
gastro_rxn = [
r["reaktion_typ"] for r in reaktionen
if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "gastro_negativ"
]
label = REAKTION_TYPEN.get(gastro_rxn[0], {}).get("label", "Magen-Darm-Symptome") if gastro_rxn else "Magen-Darm-Symptome"
hinweis = _GASTRO_HINWEIS.format(label=label)
break
# Kategorien-Übersicht über alle Reaktionen
kategorien_gesamt: dict = {}
for r in reaktionen:
kat = REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie", "sonstiges")
kategorien_gesamt[kat] = kategorien_gesamt.get(kat, 0) + 1
return {
"eintraege_count": len(eintraege),
"reaktionen_count": len(reaktionen),
"futter": result_futter,
"kategorien": kategorien_gesamt,
"hinweis": hinweis,
}

View file

@ -0,0 +1,56 @@
"""
BAN YARO User-Feedback per E-Mail an support@banyaro.app
"""
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from auth import get_current_user
from mailer import send_email, email_html
router = APIRouter()
SUPPORT_MAIL = "support@banyaro.app"
KATEGORIEN = {"bug": "🐛 Bug / Fehler", "idee": "💡 Idee / Wunsch", "lob": "🎉 Lob", "sonstiges": "💬 Sonstiges"}
class FeedbackIn(BaseModel):
kategorie: Literal["bug", "idee", "lob", "sonstiges"]
text: Annotated[str, Field(min_length=5, max_length=2000)]
@router.post("")
async def submit_feedback(
payload: FeedbackIn,
user=Depends(get_current_user),
):
kat_label = KATEGORIEN.get(payload.kategorie, payload.kategorie)
username = user.get("name", "?")
email = user.get("email", "")
tier = user.get("subscription_tier", "standard")
subject = f"[Feedback] {kat_label} von @{username}"
body = f"""
<p style="margin:0 0 16px">
Neues Feedback aus der App:
</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600;width:120px">Kategorie</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{kat_label}</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">User</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">@{username} ({email})</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">Tier</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{tier}</td></tr>
</table>
<div style="background:#fdf6ef;border-left:4px solid #C4843A;padding:14px 16px;
border-radius:0 8px 8px 0;white-space:pre-wrap;font-size:14px;line-height:1.6">
{payload.text}
</div>"""
plain = f"Feedback [{kat_label}] von @{username} ({email})\n\n{payload.text}"
await send_email(SUPPORT_MAIL, subject, email_html(body), plain)
return {"ok": True}

View file

@ -172,6 +172,20 @@ async def send_request(target_id: int, user=Depends(get_current_user)):
return {"ok": True} return {"ok": True}
@router.get("/pending")
async def pending_requests(user=Depends(get_current_user)):
"""Eingehende Freundschaftsanfragen — kein Pro nötig, für Notification-Accept."""
with db() as conn:
rows = conn.execute("""
SELECT f.id, u.name AS requester_name, u.avatar_url
FROM friendships f
JOIN users u ON u.id = f.requester_id
WHERE f.addressee_id=? AND f.status='pending'
ORDER BY f.created_at DESC
""", (user["id"],)).fetchall()
return [dict(r) for r in rows]
@router.post("/{friendship_id}/accept") @router.post("/{friendship_id}/accept")
async def accept_request(friendship_id: int, user=Depends(get_current_user)): async def accept_request(friendship_id: int, user=Depends(get_current_user)):
uid = user["id"] uid = user["id"]

View file

@ -55,7 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
prompt=prompt, prompt=prompt,
system=system, system=system,
max_tokens=600, max_tokens=600,
requires_premium=False, requires_premium=True,
user_id=user["id"], user_id=user["id"],
) )
return {"antwort": result} return {"antwort": result}
@ -361,3 +361,65 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
"hinweis": parsed.get("hinweis") or None, "hinweis": parsed.get("hinweis") or None,
"verbleibende_anfragen": remaining_after, "verbleibende_anfragen": remaining_after,
} }
# ------------------------------------------------------------------
# POST /ki/abschied — Persönlicher Abschiedstext für verstorbenen Hund
# ------------------------------------------------------------------
class AbschiedRequest(BaseModel):
dog_id: int
name: str
rasse: Optional[str] = None
km_total: Optional[float] = None
diary_count: Optional[int] = None
gemeinsam_tage: Optional[int] = None
last_entry_titel: Optional[str] = None
@router.post("/abschied")
async def ki_abschied(req: AbschiedRequest, request: Request,
user=Depends(get_current_user)):
"""Persönlicher Abschiedstext — einmalig generiert, DB-gecacht."""
with db() as conn:
cached = conn.execute(
"SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=9999 AND mode='abschied'",
(req.dog_id,)
).fetchone()
if cached:
return {"text": cached["content"], "cached": True}
name = req.name.strip()[:40]
rasse = req.rasse or ""
km = f"{req.km_total:.0f} km" if req.km_total else None
tage = f"{req.gemeinsam_tage} gemeinsame Tage" if req.gemeinsam_tage else None
eintr = f"{req.diary_count} Tagebucheinträge" if req.diary_count else None
stats_str = ", ".join(filter(None, [km, tage, eintr]))
rasse_str = f" ({rasse})" if rasse else ""
system = (
"Du bist ein einfühlsamer Begleiter für Menschen in Trauer um ihren Hund. "
"Schreibe warmherzig, persönlich und respektvoll auf Deutsch. "
"Keine Floskeln, kein Kitsch — echte Wärme. "
"Erwähne die Statistiken natürlich eingebunden."
)
prompt = (
f"{name}{rasse_str} ist über die Regenbogenbrücke gegangen. "
f"Schreibe einen kurzen, persönlichen Abschiedstext (ca. 80100 Wörter) "
f"der die Verbundenheit würdigt. "
f"Statistiken: {stats_str or 'nicht bekannt'}. "
f"Sei warm, nicht sentimental überladen. Schließe mit einem hoffnungsvollen Gedanken."
)
try:
text = await ki_module.complete(
system=system, prompt=prompt, max_tokens=300,
requires_premium=False, user_id=user["id"],
)
with db() as conn:
conn.execute(
"INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,9999,'abschied',?)",
(req.dog_id, text)
)
return {"text": text, "cached": False}
except Exception as e:
raise HTTPException(503, str(e))

View file

@ -58,6 +58,7 @@ class RouteCreate(BaseModel):
is_public: Optional[bool] = False is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None client_time: Optional[str] = None
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel): class RouteUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
@ -69,6 +70,9 @@ class RouteUpdate(BaseModel):
is_public: Optional[bool] = None is_public: Optional[bool] = None
hunde_tauglichkeit: Optional[str] = None hunde_tauglichkeit: Optional[str] = None
class RouteDogs(BaseModel):
dog_ids: List[int]
def _simplify_track(track: list, max_pts: int = 40) -> list: def _simplify_track(track: list, max_pts: int = 40) -> list:
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau.""" """Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
@ -168,7 +172,26 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
int(data.is_public) if data.is_public is not None else 1, int(data.is_public) if data.is_public is not None else 1,
data.hunde_tauglichkeit, is_valid, ct, data.hunde_tauglichkeit, is_valid, ct,
)) ))
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() route_id = cur.lastrowid
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
# Hunde zuordnen — entweder explizit oder alle Hunde des Users
dog_ids = data.dog_ids or []
if not dog_ids:
# Fallback: alle Hunde des Users
all_dogs = conn.execute(
"SELECT id FROM dogs WHERE user_id=?", (user['id'],)
).fetchall()
dog_ids = [d['id'] for d in all_dogs]
for did in dog_ids:
try:
conn.execute(
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)",
(route_id, did)
)
except Exception:
pass
update_streak(user['id'], conn) update_streak(user['id'], conn)
check_and_award(user['id'], conn) check_and_award(user['id'], conn)
result = _parse(row) result = _parse(row)
@ -195,6 +218,10 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
if not ORS_API_KEY: if not ORS_API_KEY:
raise HTTPException(503, "ORS nicht konfiguriert") raise HTTPException(503, "ORS nicht konfiguriert")
from auth import has_pro_access
if not has_pro_access(user):
raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.")
is_privileged = ( is_privileged = (
user.get("rolle") in ("admin", "moderator") or user.get("rolle") in ("admin", "moderator") or
user.get("is_moderator") or user.get("is_moderator") or
@ -315,7 +342,12 @@ async def get_route(route_id: int):
).fetchone() ).fetchone()
if not row: if not row:
raise HTTPException(404, "Route nicht gefunden.") raise HTTPException(404, "Route nicht gefunden.")
return _parse(row) dog_rows = conn.execute(
"SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,)
).fetchall()
result = _parse(row)
result['dog_ids'] = [r['dog_id'] for r in dog_rows]
return result
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -342,6 +374,26 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
return _parse(row) return _parse(row)
# ------------------------------------------------------------------
# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren
# ------------------------------------------------------------------
@router.patch("/{route_id}/dogs")
async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,))
for did in data.dog_ids:
conn.execute(
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)",
(route_id, did)
)
return {"ok": True}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz) # PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -85,12 +85,19 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ProgressUpdate(BaseModel): class ProgressUpdate(BaseModel):
exercise_id: str exercise_id: str
status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt status: Optional[str] = None
dog_id: Optional[int] = None
@router.get("/progress") @router.get("/progress")
async def get_progress(user=Depends(get_current_user)): async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"] uid = user["id"]
with db() as conn: with db() as conn:
if dog_id:
rows = conn.execute(
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?",
(dog_id,)
).fetchall()
else:
rows = conn.execute( rows = conn.execute(
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
(uid,) (uid,)
@ -101,10 +108,18 @@ async def get_progress(user=Depends(get_current_user)):
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
uid = user["id"] uid = user["id"]
with db() as conn: with db() as conn:
if body.dog_id:
conn.execute("""
INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status)
VALUES (?,?,?,?)
ON CONFLICT(dog_id, exercise_id) DO UPDATE
SET status=excluded.status, updated_at=datetime('now')
""", (uid, body.dog_id, body.exercise_id, body.status))
else:
conn.execute(""" conn.execute("""
INSERT INTO exercise_progress (user_id, exercise_id, status) INSERT INTO exercise_progress (user_id, exercise_id, status)
VALUES (?,?,?) VALUES (?,?,?)
ON CONFLICT(user_id, exercise_id) DO UPDATE ON CONFLICT(dog_id, exercise_id) DO UPDATE
SET status=excluded.status, updated_at=datetime('now') SET status=excluded.status, updated_at=datetime('now')
""", (uid, body.exercise_id, body.status)) """, (uid, body.exercise_id, body.status))
return {"ok": True} return {"ok": True}
@ -115,11 +130,18 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
class PlanProgress(BaseModel): class PlanProgress(BaseModel):
item_key: str item_key: str
checked: bool checked: bool
dog_id: Optional[int] = None
@router.get("/plan-progress") @router.get("/plan-progress")
async def get_plan_progress(user=Depends(get_current_user)): async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"] uid = user["id"]
with db() as conn: with db() as conn:
if dog_id:
rows = conn.execute(
"SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?",
(dog_id,)
).fetchall()
else:
rows = conn.execute( rows = conn.execute(
"SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
(uid,) (uid,)
@ -132,13 +154,13 @@ async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user
with db() as conn: with db() as conn:
if body.checked: if body.checked:
conn.execute(""" conn.execute("""
INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked) INSERT OR REPLACE INTO training_plan_progress (user_id, dog_id, item_key, checked)
VALUES (?,?,1) VALUES (?,?,?,1)
""", (uid, body.item_key)) """, (uid, body.dog_id, body.item_key))
else: else:
conn.execute( conn.execute(
"DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?", "DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?",
(uid, body.item_key) (body.dog_id, body.item_key)
) )
return {"ok": True} return {"ok": True}
@ -149,9 +171,15 @@ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus /
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen'] TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
@router.get("/suggestions") @router.get("/suggestions")
async def get_suggestions(user=Depends(get_current_user)): async def get_suggestions(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"] uid = user["id"]
with db() as conn: with db() as conn:
if dog_id:
rows = conn.execute(
"SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?",
(dog_id,)
).fetchall()
else:
rows = conn.execute( rows = conn.execute(
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
(uid,) (uid,)

View file

@ -3,6 +3,7 @@ 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 os
import json 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
@ -11,6 +12,34 @@ from database import db
router = APIRouter() router = APIRouter()
OWM_API_KEY = os.getenv("OPENWEATHERMAP_KEY", "")
ALLOWED_OWM_LAYERS = {"temp_new", "clouds_new", "wind_new", "pressure_new", "precipitation_new"}
@router.get('/radar-tiles')
async def radar_tile_config(user=Depends(get_current_user)):
"""Regenradar-Tile-Config (RainViewer)."""
return {"provider": "rainviewer"}
@router.get('/layer-tiles')
async def layer_tile_config(
layer: str = "temp_new",
user=Depends(get_current_user),
):
"""OWM-Tile-URL für Wetter-Layer (Key bleibt server-seitig)."""
if layer not in ALLOWED_OWM_LAYERS:
raise HTTPException(400, f"Unbekannter Layer. Erlaubt: {', '.join(ALLOWED_OWM_LAYERS)}")
if not OWM_API_KEY:
raise HTTPException(503, "OWM nicht konfiguriert.")
return {
"url": f"https://tile.openweathermap.org/map/{layer}/{{z}}/{{x}}/{{y}}.png?appid={OWM_API_KEY}",
"maxNativeZoom": 18,
"opacity": 0.6,
}
@router.get('') @router.get('')
async def get_weather( async def get_weather(

View file

@ -3184,6 +3184,16 @@ html.modal-open {
color: #fff; color: #fff;
font-size: 1rem; font-size: 1rem;
} }
#map-radar-btn.active {
background: #1d4ed8;
border-color: #1d4ed8;
color: #fff;
}
#map-temp-btn.active {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.map-fab:disabled { opacity: 0.5; cursor: default; } .map-fab:disabled { opacity: 0.5; cursor: default; }
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; } .map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
@keyframes fab-spin { to { transform: rotate(360deg); } } @keyframes fab-spin { to { transform: rotate(360deg); } }

View file

@ -101,9 +101,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=826"> <link rel="stylesheet" href="/css/design-system.css?v=872">
<link rel="stylesheet" href="/css/layout.css?v=826"> <link rel="stylesheet" href="/css/layout.css?v=872">
<link rel="stylesheet" href="/css/components.css?v=826"> <link rel="stylesheet" href="/css/components.css?v=872">
</head> </head>
<body> <body>
@ -583,10 +583,10 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=826"></script> <script src="/js/api.js?v=872"></script>
<script src="/js/ui.js?v=826"></script> <script src="/js/ui.js?v=872"></script>
<script src="/js/app.js?v=826"></script> <script src="/js/app.js?v=872"></script>
<script src="/js/worlds.js?v=826"></script> <script src="/js/worlds.js?v=872"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -58,7 +58,10 @@ const API = (() => {
try { data = await response.json(); } catch { data = null; } try { data = await response.json(); } catch { data = null; }
if (!response.ok) { if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`; const _d = data?.detail;
const message = (typeof _d === 'string' ? _d
: Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe')
: null) || data?.message || `Fehler ${response.status}`;
const isSwOffline = response.status === 503 && message.startsWith('Offline'); const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
@ -139,6 +142,15 @@ const API = (() => {
deletePhoto(id) { return del(`/dogs/${id}/photo`); }, deletePhoto(id) { return del(`/dogs/${id}/photo`); },
getSkills(id) { return get(`/dogs/${id}/skills`); }, getSkills(id) { return get(`/dogs/${id}/skills`); },
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); }, welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); },
gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); },
futterList(id) { return get(`/dogs/${id}/futter`); },
futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); },
futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); },
reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); },
reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); },
reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); },
futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -284,6 +296,7 @@ const API = (() => {
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); }, rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); }, walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
reverse(id) { return post(`/routes/${id}/reverse`, {}); }, reverse(id) { return post(`/routes/${id}/reverse`, {}); },
updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); },
addPhoto(id, file) { addPhoto(id, file) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
@ -297,11 +310,11 @@ const API = (() => {
// TRAINING & ÜBUNGSFORTSCHRITT // TRAINING & ÜBUNGSFORTSCHRITT
// ---------------------------------------------------------- // ----------------------------------------------------------
const training = { const training = {
getProgress() { return get('/training/progress'); }, getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); },
setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); }, setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); },
getSuggestions() { return get('/training/suggestions'); }, getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); },
getPlanProgress() { return get('/training/plan-progress'); }, getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); },
setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); }, setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); },
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); }, getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
}; };
@ -461,6 +474,7 @@ const API = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
const friends = { const friends = {
list() { return get('/friends/'); }, list() { return get('/friends/'); },
pending() { return get('/friends/pending'); },
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); }, search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
activity() { return get('/friends/activity'); }, activity() { return get('/friends/activity'); },
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); }, sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },

View file

@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '872'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen
if (location.search.includes('_t=')) history.replaceState(null, '', '/'); if (location.search.includes('_t=')) history.replaceState(null, '', '/');
@ -57,7 +57,7 @@ const App = (() => {
'erste-hilfe': { title: 'Erste Hilfe', module: null }, 'erste-hilfe': { title: 'Erste Hilfe', module: null },
settings: { title: 'Einstellungen', module: null }, settings: { title: 'Einstellungen', module: null },
lost: { title: 'Verlorener Hund', module: null }, lost: { title: 'Verlorener Hund', module: null },
friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true }, friends: { title: 'Freunde', module: null, requiresAuth: true },
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true }, chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
social: { title: 'Social Media', module: null, requiresAuth: true }, social: { title: 'Social Media', module: null, requiresAuth: true },
admin: { title: 'Admin', module: null, requiresAuth: true }, admin: { title: 'Admin', module: null, requiresAuth: true },
@ -198,6 +198,26 @@ const App = (() => {
return; return;
} }
// Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init()
const pageEl = document.getElementById(`page-${pageId}`);
if (pageEl) {
pageEl.querySelector('#pro-role-banner')?.remove();
if (page.requiresPro && state.user) {
const t = state.user.subscription_tier || 'standard';
const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) &&
(state.user.rolle === 'admin' || state.user.rolle === 'moderator' ||
state.user.is_moderator || state.user.is_social_media);
if (isRoleBased) {
const banner = document.createElement('div');
banner.id = 'pro-role-banner';
banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;';
banner.innerHTML = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 256 256" fill="currentColor"><path d="M236.8,188.09,149.35,36.22a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"/></svg>
Pro-Feature Standard-User sehen diese Seite nicht`;
pageEl.insertBefore(banner, pageEl.firstChild);
}
}
}
if (page.module) { if (page.module) {
const hasParams = params && Object.keys(params).length > 0; const hasParams = params && Object.keys(params).length > 0;
if (hasParams) { if (hasParams) {
@ -885,6 +905,12 @@ const App = (() => {
if (!dog || dog.id === state.activeDog?.id) return; if (!dog || dog.id === state.activeDog?.id) return;
state.activeDog = dog; state.activeDog = dog;
localStorage.setItem('by_active_dog', String(dogId)); localStorage.setItem('by_active_dog', String(dogId));
// SW-Cache für hund-spezifische Daten invalidieren
navigator.serviceWorker?.controller?.postMessage({
type: 'INVALIDATE_CACHE',
paths: ['/api/training/progress', '/api/training/plan-progress',
'/api/training/suggestions', `/api/dogs/${dogId}/welcome-dashboard`],
});
_renderDogSwitcher(); _renderDogSwitcher();
_notifyDogChange(); _notifyDogChange();
} }

View file

@ -25,6 +25,7 @@ window.Page_admin = (() => {
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
]; ];
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -161,6 +162,7 @@ window.Page_admin = (() => {
case 'bewerbungen': await _renderBewerbungen(el); break; case 'bewerbungen': await _renderBewerbungen(el); break;
case 'hilfe': await _renderHilfe(el); break; case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -534,22 +536,22 @@ window.Page_admin = (() => {
}; };
el.innerHTML = ` el.innerHTML = `
<div class="adm-stats-grid"> <div class="adm-stats-grid" id="adm-overview-grid">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')} ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')} ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')} ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')} ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')} ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')}
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')} ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')}
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')} ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')}
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')} ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')} ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')}
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')} ${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')} ${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')} ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')}
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')} ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
</div> </div>
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
@ -705,11 +707,19 @@ window.Page_admin = (() => {
</p> </p>
</div> </div>
`; `;
el.querySelector('#adm-overview-grid')?.addEventListener('click', e => {
const card = e.target.closest('[data-adm-tab]');
if (!card) return;
const tab = card.dataset.admTab;
_container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click();
});
} }
function _statCard(icon, label, value, color) { function _statCard(icon, label, value, color, tab = null) {
const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
return ` return `
<div class="card" style="padding:var(--space-4);text-align:center"> <div class="card" ${clickable}>
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)" <svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true"> aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use> <use href="/icons/phosphor.svg#${icon}"></use>
@ -3336,6 +3346,79 @@ window.Page_admin = (() => {
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// ------------------------------------------------------------------
// TAB: REFERRALS
// ------------------------------------------------------------------
async function _renderReferrals(el) {
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade…</div>`;
let d;
try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger)">Fehler beim Laden.</div>`; return; }
const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0;
const topRows = d.top_referrers.map((r, i) => `
<tr>
<td style="padding:8px 10px;color:var(--c-text-muted);font-weight:600">${i + 1}</td>
<td style="padding:8px 10px;font-weight:600">${_esc(r.name)}</td>
<td style="padding:8px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.email)}</td>
<td style="padding:8px 10px;text-align:right">
<span style="font-size:var(--text-lg);font-weight:800;color:var(--c-primary)">${r.invited_count}</span>
</td>
</tr>`).join('');
const recentRows = d.recent_invites.slice(0, 50).map(r => `
<tr>
<td style="padding:6px 10px;font-weight:500">${_esc(r.name)}</td>
<td style="padding:6px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.referrer_name)}</td>
<td style="padding:6px 10px;color:var(--c-text-muted);font-size:var(--text-xs)">${(r.created_at || '').slice(0, 10)}</td>
</tr>`).join('');
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${d.total_referred}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Geworbene User</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success)">${pct}%</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Anteil geworbener User</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-warning)">${d.viral_factor}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Virality Factor</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
<div class="by-card-section-header">Top Werber</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
<th style="padding:8px 10px;text-align:left">#</th>
<th style="padding:8px 10px;text-align:left">Name</th>
<th style="padding:8px 10px;text-align:left">E-Mail</th>
<th style="padding:8px 10px;text-align:right">Eingeladen</th>
</tr></thead>
<tbody>${topRows || '<tr><td colspan="4" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Empfehlungen</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="card" style="overflow:hidden">
<div class="by-card-section-header">Zuletzt geworbene User</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
<th style="padding:6px 10px;text-align:left">User</th>
<th style="padding:6px 10px;text-align:left">Geworben von</th>
<th style="padding:6px 10px;text-align:left">Datum</th>
</tr></thead>
<tbody>${recentRows || '<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Daten</td></tr>'}</tbody>
</table>
</div>
</div>`;
}
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };
})(); })();

View file

@ -149,12 +149,6 @@ window.Page_diary = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
async function refresh() { async function refresh() {
if (!_appState.activeDog) return; if (!_appState.activeDog) return;
// Mehrere Hunde → Picker zeigen (User kann Hund wählen)
if (_appState.dogs.length > 1) {
_renderDogPicker();
return;
}
// Einzelner Hund → Diary direkt neu laden
_offset = 0; _offset = 0;
_entries = []; _entries = [];
_totalStats = null; _totalStats = null;
@ -194,59 +188,15 @@ window.Page_diary = (() => {
return; return;
} }
if (_appState.dogs.length > 1) {
_renderDogPicker();
} else {
await _renderDiary(); await _renderDiary();
} }
}
// ----------------------------------------------------------
// HUNDE-PICKER — Einstiegsseite bei mehreren Hunden
// ----------------------------------------------------------
function _renderDogPicker() {
const activeDogId = _appState.activeDog?.id;
const cards = _appState.dogs.map(dog => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}">`
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
<div class="diary-picker-av">${av}</div>
<div class="diary-picker-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${UI.escape(dog.rasse)}</div>` : ''}
</div>`;
}).join('');
_container.innerHTML = `
<div class="diary-picker-wrap">
<p class="diary-picker-hint">Wessen Tagebuch?</p>
<div class="diary-picker-grid">${cards}</div>
</div>`;
_container.querySelectorAll('.diary-picker-card').forEach(el => {
el.addEventListener('click', async () => {
const id = parseInt(el.dataset.dogId);
if (id === _appState.activeDog?.id) {
// Bereits aktiver Hund → direkt Diary laden
_offset = 0; _entries = [];
await _renderDiary();
} else {
App.setActiveDog(id);
// onDogChange() → _renderDiary() via _notifyDogChange()
}
});
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// DIARY-ANSICHT — Timeline mit Einträgen // DIARY-ANSICHT — Timeline mit Einträgen
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _renderDiary() { async function _renderDiary() {
_container.innerHTML = ` _container.innerHTML = `
${UI.dogChip(_appState)}
<div class="by-toolbar diary-toolbar"> <div class="by-toolbar diary-toolbar">
<div class="diary-search-wrap" id="diary-search-wrap"> <div class="diary-search-wrap" id="diary-search-wrap">
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> <svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
@ -274,6 +224,7 @@ window.Page_diary = (() => {
</svg> </svg>
</button> </button>
`; `;
UI.bindDogChip(_container, _appState);
_container.querySelector('#diary-milestone-filter') _container.querySelector('#diary-milestone-filter')
?.addEventListener('click', async () => { ?.addEventListener('click', async () => {

View file

@ -1060,6 +1060,12 @@ window.Page_dog_profile = (() => {
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button> <button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
<div style="display:flex;gap:var(--space-2)"> <div style="display:flex;gap:var(--space-2)">
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button> <button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
<button type="button" id="dp-gedenken-btn"
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:none;background:#1a1a1a;color:#C4843A;
font-size:var(--text-sm);font-weight:600;cursor:pointer">
Verstorben
</button>
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button> <button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
</div> </div>
</div> </div>
@ -1279,6 +1285,11 @@ window.Page_dog_profile = (() => {
document.getElementById('dp-form-cancel') document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close); ?.addEventListener('click', UI.modal.close);
document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => {
UI.modal.close();
_openGedenkenFlow(dog);
});
document.getElementById('dp-delete-btn')?.addEventListener('click', async () => { document.getElementById('dp-delete-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
title : `${dog.name} löschen?`, title : `${dog.name} löschen?`,
@ -2414,6 +2425,178 @@ window.Page_dog_profile = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC
// ---------------------------------------------------------- // ----------------------------------------------------------
// ----------------------------------------------------------
// GEDENKEN-FLOW
// ----------------------------------------------------------
async function _openGedenkenFlow(dog) {
// Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe
UI.modal.open({
title: `Abschied von ${dog.name}`,
body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary);opacity:0.7" aria-hidden="true">
<use href="/icons/phosphor.svg#heart"></use>
</svg>
<p style="color:var(--c-text-secondary);margin:var(--space-3) 0 var(--space-4);line-height:1.6">
${dog.name} hinterlässt eine riesige Lücke.<br>
Die gemeinsamen Erinnerungen bleiben für immer.
</p>
</div>
<form id="gedenken-form">
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Datum des Abschieds
</label>
<input type="date" id="gedenken-datum" name="datum"
value="${new Date().toISOString().slice(0,10)}"
max="${new Date().toISOString().slice(0,10)}"
style="width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</form>`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Gedenkseite erstellen
</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>`,
});
document.getElementById('gedenken-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('gedenken-save-btn');
const datum = document.getElementById('gedenken-datum').value;
await UI.asyncButton(btn, async () => {
await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum });
// Aus aktiver Hundeliste entfernen
_appState.dogs = _appState.dogs.filter(d => d.id !== dog.id);
_appState.activeDog = _appState.dogs[0] || null;
UI.modal.close();
// Gedenkseite öffnen
await _openGedenkseite(dog.id, dog.name);
await _render();
});
});
}
async function _openGedenkseite(dogId, dogName) {
UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: `
<div style="text-align:center;padding:var(--space-4)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite">
<use href="/icons/phosphor.svg#spinner"></use>
</svg>
</div>` });
let data;
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
catch { UI.modal.close(); return; }
const d = data;
const av = d.dog.foto_url
? `<img src="${UI.escape(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
const photoGrid = d.photos.length ? `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
${d.photos.map(url => `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
</div>` : '';
const statsHtml = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
</div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
</div>` : ''}
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
</div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
</div>` : ''}
</div>`;
// Trauer-Support-Texte
const supportHtml = `
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
<div style="font-weight:700;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>
Für dich in dieser Zeit
</div>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein.
Lass dich trauern so lange du brauchst. Die Erinnerungen bleiben immer bei dir.
</p>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.8">
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Sprich mit Freunden oder der Familie über ${d.dog.name} Geschichten lebendig halten hilft.
</div>
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
Das Tagebuch bleibt erhalten es ist ein kostbares Stück gemeinsamer Geschichte.
</div>
<div style="display:flex;align-items:flex-start;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
</div>
</div>
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Persönlichen Abschiedstext erstellen
</button>
</div>`;
const modal = UI.modal.open({
title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`,
body: `
<div style="text-align:center;margin-bottom:var(--space-4)">
${av}
<div style="font-size:var(--text-xl);font-weight:800;margin-top:var(--space-3)">${UI.escape(d.dog.name)}</div>
${d.dog.rasse ? `<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${UI.escape(d.dog.rasse)}</div>` : ''}
${d.dog.verstorben_am ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:4px">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#rainbow"></use></svg>
Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')}
</div>` : ''}
</div>
${photoGrid}
${statsHtml}
${supportHtml}`,
});
document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('gedenk-ki-btn');
await UI.asyncButton(btn, async () => {
const result = await API.post('/ki/abschied', {
dog_id: dogId,
name: d.dog.name,
rasse: d.dog.rasse,
km_total: d.km_total,
diary_count: d.diary_count,
gemeinsam_tage: d.gemeinsam_tage,
});
const wrap = document.getElementById('gedenk-ki-wrap');
if (wrap) wrap.innerHTML = `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-4);
font-size:var(--text-sm);line-height:1.7;color:var(--c-text);font-style:italic">
"${UI.escape(result.text)}"
</div>`;
});
});
}
return { init, refresh, onDogChange, addNew: _openCreateModal }; return { init, refresh, onDogChange, addNew: _openCreateModal };
})(); })();

View file

@ -15,6 +15,7 @@ window.Page_ernaehrung = (() => {
{ key: 'guide', label: 'Futter-Guide', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></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: '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>' }, { key: 'ki', label: 'KI-Berater', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>' },
{ key: 'vertraeglichkeit', label: 'Verträglichkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>' },
]; ];
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -47,6 +48,7 @@ window.Page_ernaehrung = (() => {
async function onDogChange() { async function onDogChange() {
_profil = {}; _profil = {};
_activeTab = 'rechner'; // Tab zurücksetzen damit neuer Hund frisch startet
await _render(); await _render();
} }
@ -73,10 +75,12 @@ window.Page_ernaehrung = (() => {
} }
_container.innerHTML = ` _container.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
<div class="by-tabs" id="ern-tabs"></div> <div class="by-tabs" id="ern-tabs"></div>
<div id="ern-tab-content"></div> <div id="ern-tab-content"></div>
`; `;
UI.bindDogChip(_container, _appState);
_renderTabBar(); _renderTabBar();
_renderTab(); _renderTab();
} }
@ -110,6 +114,7 @@ window.Page_ernaehrung = (() => {
case 'guide': _renderGuide(el); break; case 'guide': _renderGuide(el); break;
case 'gift': _renderGift(el); break; case 'gift': _renderGift(el); break;
case 'ki': _renderKi(el); break; case 'ki': _renderKi(el); break;
case 'vertraeglichkeit': _renderVertraeglichkeit(el); break;
} }
} }
@ -630,6 +635,531 @@ window.Page_ernaehrung = (() => {
}); });
} }
// ------------------------------------------------------------------
// TAB 5: VERTRÄGLICHKEIT
// ------------------------------------------------------------------
async function _renderVertraeglichkeit(el) {
const dog = _appState?.activeDog;
if (!dog) { el.innerHTML = ''; return; }
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<!-- Schnell-Erfassung -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-bottom:var(--space-4)">
<button class="btn btn-primary" id="vert-btn-futter">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bowl-food"></use></svg>
Futter erfassen
</button>
<button class="btn btn-secondary" id="vert-btn-reaktion">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>
Reaktion erfassen
</button>
</div>
<!-- Info-Banner Haut/Fell -->
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-3);
margin-bottom:var(--space-4);font-size:var(--text-xs);
color:var(--c-text-secondary);line-height:1.5">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary);margin-top:1px">
<use href="/icons/phosphor.svg#info"></use>
</svg>
<span>Haut- &amp; Fellsymptome zeigen sich erst nach Wochen trage regelmäßig ein um Muster zu erkennen.</span>
</div>
<!-- Analyse -->
<div id="vert-analyse" style="margin-bottom:var(--space-5)">
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#circle-notch"></use></svg>
Lade Analyse
</div>
</div>
<!-- Verlauf -->
<div id="vert-verlauf"></div>
</div>
`;
el.querySelector('#vert-btn-futter').addEventListener('click', () => _openFutterModal(el, dog));
el.querySelector('#vert-btn-reaktion').addEventListener('click', () => _openReaktionModal(el, dog));
await _loadAnalyse(el, dog);
await _loadVerlauf(el, dog);
}
function _todayStr() {
return new Date().toISOString().slice(0, 10);
}
function _nowTimeStr() {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function _openFutterModal(el, dog) {
const id = `fm-${Date.now()}`;
const body = `
<form id="${id}">
<div class="by-form-group">
<label class="by-label">Datum</label>
<input type="date" name="datum" class="form-control by-input" value="${_todayStr()}" required>
</div>
<div class="by-form-group">
<label class="by-label">Uhrzeit</label>
<input type="time" name="uhrzeit" class="form-control by-input" value="${_nowTimeStr()}" required>
</div>
<div class="by-form-group">
<label class="by-label">Futter-Name</label>
<input type="text" name="futter_name" class="form-control by-input"
list="vert-futter-datalist" placeholder="z. B. Wolfsblut Adult" required>
<datalist id="vert-futter-datalist"></datalist>
</div>
<div class="by-form-group">
<label class="by-label">Futter-Typ</label>
<select name="futter_typ" class="form-control by-select">
<option value="trockenfutter">Trockenfutter</option>
<option value="nassfutter">Nassfutter</option>
<option value="barf">BARF</option>
<option value="snack">Snack</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="by-form-group">
<label class="by-label">Menge (g, optional)</label>
<input type="number" name="menge_g" class="form-control by-input" min="1" placeholder="">
</div>
<div class="by-form-group">
<label class="by-label">Notiz (optional)</label>
<textarea name="notiz" class="form-control by-input" rows="2" placeholder=""></textarea>
</div>
</form>
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="vert-futter-save-btn" form="${id}">Speichern</button>
`;
UI.modal.open({ title: 'Futter erfassen', body, footer });
// Datalist mit bekannten Futter-Namen füllen
API.dogs.futterList(dog.id).then(list => {
const dl = document.getElementById('vert-futter-datalist');
if (!dl) return;
const names = [...new Set((list || []).map(e => e.futter_name))];
dl.innerHTML = names.map(n => `<option value="${_esc(n)}">`).join('');
}).catch(() => {});
setTimeout(() => {
const saveBtn = document.getElementById('vert-futter-save-btn');
if (!saveBtn) return;
saveBtn.addEventListener('click', async (ev) => {
ev.preventDefault();
const form = document.getElementById(id);
if (!form) return;
const fd = new FormData(form);
const data = {
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit'),
futter_name: (fd.get('futter_name') || '').trim(),
futter_typ: fd.get('futter_typ') || 'trockenfutter',
menge_g: fd.get('menge_g') ? parseInt(fd.get('menge_g')) : null,
notiz: (fd.get('notiz') || '').trim() || null,
};
if (!data.futter_name) { UI.toast.warning('Bitte Futter-Name angeben.'); return; }
await UI.asyncButton(saveBtn, async () => {
try {
await API.dogs.futterCreate(dog.id, data);
UI.modal.close();
UI.toast.success('Futter gespeichert.');
const tabEl = _container.querySelector('#ern-tab-content');
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
}
});
});
}, 50);
}
function _openReaktionModal(el, dog) {
const id = `rm-${Date.now()}`;
const body = `
<form id="${id}">
<div class="by-form-group">
<label class="by-label">Datum</label>
<input type="date" name="datum" class="form-control by-input" value="${_todayStr()}" required>
</div>
<div class="by-form-group">
<label class="by-label">Uhrzeit</label>
<input type="time" name="uhrzeit" class="form-control by-input" value="${_nowTimeStr()}" required>
</div>
<div class="by-form-group">
<label class="by-label">Reaktion</label>
<select name="reaktion_typ" class="form-control by-select" required>
<optgroup label="✓ Positiv">
<option value="fell_glaenzend">Glänzendes Fell</option>
<option value="verdauung_gut">Gute Verdauung</option>
<option value="energie_hoch">Viel Energie</option>
</optgroup>
<optgroup label="Magen &amp; Darm">
<option value="erbrechen">Erbrechen</option>
<option value="durchfall">Durchfall</option>
<option value="blaehungen">Blähungen</option>
<option value="weicher_stuhl">Weicher Stuhl</option>
<option value="appetitlosigkeit">Appetitlosigkeit</option>
</optgroup>
<optgroup label="Haut &amp; Fell">
<option value="juckreiz">Juckreiz / Kratzen</option>
<option value="haarausfall">Haarausfall</option>
<option value="stumpfes_fell">Stumpfes Fell</option>
<option value="schuppenbildung">Schuppenbildung</option>
<option value="roetungen">Hautrötungen / Entzündung</option>
<option value="pfotenlecken">Pfoten lecken (chronisch)</option>
<option value="ohrentzuendung">Ohrentzündung</option>
<option value="fettiges_fell">Fettiges Fell / Seborrhö</option>
</optgroup>
<optgroup label="Allgemeinbefinden">
<option value="schlappheit">Schlappheit / Apathie</option>
<option value="nervositaet">Nervosität / Unruhe</option>
<option value="viel_trinken">Ungewöhnlich viel trinken</option>
<option value="sonstiges">Sonstiges</option>
</optgroup>
</select>
</div>
<div class="by-form-group">
<label class="by-label">Intensität (15)</label>
<div class="vert-stern-gruppe" style="display:flex;gap:6px;flex-wrap:wrap">
${[1,2,3,4,5].map(n => `
<button type="button" class="vert-stern${n <= 3 ? ' active' : ''}"
data-val="${n}"
style="width:40px;height:40px;border-radius:8px;
border:1.5px solid var(--c-border);
background:${n <= 3 ? 'var(--c-primary)' : 'var(--c-bg-card)'};
color:${n <= 3 ? '#fff' : 'var(--c-text-secondary)'};
font-weight:700;cursor:pointer;">${n}</button>
`).join('')}
</div>
<input type="hidden" name="intensitaet" value="3">
</div>
<div class="by-form-group">
<label class="by-label">Notiz (optional)</label>
<textarea name="notiz" class="form-control by-input" rows="2" placeholder=""></textarea>
</div>
</form>
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="vert-reaktion-save-btn" form="${id}">Speichern</button>
`;
UI.modal.open({ title: 'Reaktion erfassen', body, footer });
setTimeout(() => {
// Stern-Buttons
document.querySelectorAll('.vert-stern').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.val);
const form = document.getElementById(id);
if (form) form.querySelector('[name=intensitaet]').value = val;
document.querySelectorAll('.vert-stern').forEach(b => {
const v = parseInt(b.dataset.val);
b.style.background = v <= val ? 'var(--c-primary)' : 'var(--c-bg-card)';
b.style.color = v <= val ? '#fff' : 'var(--c-text-secondary)';
});
});
});
const saveBtn = document.getElementById('vert-reaktion-save-btn');
if (!saveBtn) return;
saveBtn.addEventListener('click', async (ev) => {
ev.preventDefault();
const form = document.getElementById(id);
if (!form) return;
const fd = new FormData(form);
const data = {
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit'),
reaktion_typ: fd.get('reaktion_typ'),
intensitaet: parseInt(fd.get('intensitaet')) || 3,
notiz: (fd.get('notiz') || '').trim() || null,
};
await UI.asyncButton(saveBtn, async () => {
try {
await API.dogs.reaktionCreate(dog.id, data);
UI.modal.close();
UI.toast.success('Reaktion gespeichert.');
const tabEl = _container.querySelector('#ern-tab-content');
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
}
});
});
}, 50);
}
async function _loadAnalyse(el, dog) {
const analyseEl = el.querySelector('#vert-analyse');
if (!analyseEl) return;
let data;
try {
data = await API.dogs.futterAnalyse(dog.id);
} catch (_) {
analyseEl.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Analyse nicht verfügbar.</p>`;
return;
}
if (!data.futter || data.futter.length === 0) {
analyseEl.innerHTML = `
<div style="text-align:center;padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">
<svg class="ph-icon" style="width:2rem;height:2rem;margin-bottom:8px;display:block;margin-inline:auto" aria-hidden="true">
<use href="/icons/phosphor.svg#chart-bar"></use>
</svg>
Noch keine Einträge. Erfasse Futter und Reaktionen um die Analyse zu sehen.
</div>
`;
return;
}
const STATUS_CFG = {
gut: { label: 'Gut verträglich', color: 'var(--c-success,#22c55e)', bg: 'rgba(34,197,94,0.12)' },
neutral: { label: 'Neutral', color: 'var(--c-warning,#f59e0b)', bg: 'rgba(245,158,11,0.12)' },
problematisch:{ label: 'Problematisch', color: 'var(--c-danger,#ef4444)', bg: 'rgba(239,68,68,0.12)' },
neu: { label: 'Zu wenig Daten', color: 'var(--c-text-muted)', bg: 'var(--c-surface)' },
};
const TYP_LABELS = {
trockenfutter: 'Trockenfutter', nassfutter: 'Nassfutter',
barf: 'BARF', snack: 'Snack', sonstiges: 'Sonstiges',
};
const KAT_LABELS = {
gastro_negativ: 'Magen & Darm',
haut_negativ: 'Haut & Fell',
allgemein_negativ: 'Allgemein',
positiv: 'Positiv',
sonstiges: 'Sonstiges',
};
const hinweisHtml = data.hinweis ? `
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
background:rgba(245,158,11,0.12);border:1px solid var(--c-warning,#f59e0b);
border-radius:var(--radius-md);padding:var(--space-3);
margin-bottom:var(--space-3);font-size:var(--text-xs);
color:var(--c-text);line-height:1.5">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-warning,#f59e0b);margin-top:1px">
<use href="/icons/phosphor.svg#warning-circle"></use>
</svg>
<span>${_esc(data.hinweis)}</span>
</div>
` : '';
analyseEl.innerHTML = `
<h4 style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3);color:var(--c-text)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chart-bar"></use></svg>
Verträglichkeits-Analyse
<span style="font-weight:400;color:var(--c-text-muted);font-size:var(--text-xs)">
(${data.eintraege_count} Mahlzeiten, ${data.reaktionen_count} Reaktionen)
</span>
</h4>
${hinweisHtml}
<div style="display:grid;gap:var(--space-2)">
${data.futter.map(f => {
const cfg = STATUS_CFG[f.status] || STATUS_CFG.neu;
// Symptom-Kategorien des Futters als Chips
const katChips = Object.entries(f.kategorien || {})
.filter(([kat]) => kat !== 'positiv')
.map(([kat, cnt]) => {
const isHaut = kat === 'haut_negativ';
const isGastro = kat === 'gastro_negativ';
const chipColor = isHaut ? 'var(--c-warning,#f59e0b)' :
isGastro ? 'var(--c-danger,#ef4444)' :
'var(--c-text-muted)';
return `<span style="font-size:10px;font-weight:600;padding:2px 6px;
border-radius:999px;border:1px solid ${chipColor};
color:${chipColor};white-space:nowrap">
${_esc(KAT_LABELS[kat] || kat)} ×${cnt}
</span>`;
}).join('');
return `
<div style="background:${cfg.bg};border:1px solid ${cfg.color};
border-radius:var(--radius-md);padding:var(--space-3);
display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)">
<div style="min-width:0;flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(f.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(TYP_LABELS[f.typ] || f.typ)} &middot; ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
${f.status !== 'neu' ? `&middot; <span style="color:var(--c-success,#22c55e)">+${f.positiv}</span> / <span style="color:var(--c-danger,#ef4444)">-${f.negativ}</span>` : ''}
</div>
${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''}
</div>
<span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700;
color:${cfg.color};white-space:nowrap">
${_esc(cfg.label)}
</span>
</div>
`;
}).join('')}
</div>
`;
}
async function _loadVerlauf(el, dog) {
const verlaufEl = el.querySelector('#vert-verlauf');
if (!verlaufEl) return;
let eintraege = [], reaktionen = [];
try {
[eintraege, reaktionen] = await Promise.all([
API.dogs.futterList(dog.id),
API.dogs.reaktionList(dog.id),
]);
} catch (_) { return; }
// Letzten 10 Futter + 5 Reaktionen, gemischt chronologisch
const items = [
...(eintraege || []).slice(0, 10).map(e => ({ ...e, _art: 'futter' })),
...(reaktionen || []).slice(0, 5).map(r => ({ ...r, _art: 'reaktion' })),
].sort((a, b) => {
const ta = `${a.datum}T${a.uhrzeit}`;
const tb = `${b.datum}T${b.uhrzeit}`;
return tb.localeCompare(ta);
});
if (items.length === 0) {
verlaufEl.innerHTML = '';
return;
}
const REAK_LABELS = {
// Positiv
verdauung_gut: 'Gute Verdauung',
energie_hoch: 'Viel Energie',
fell_glaenzend: 'Glänzendes Fell',
// Gastro
erbrechen: 'Erbrechen',
durchfall: 'Durchfall',
blaehungen: 'Blähungen',
weicher_stuhl: 'Weicher Stuhl',
appetitlosigkeit: 'Appetitlosigkeit',
// Haut & Fell
juckreiz: 'Juckreiz / Kratzen',
haarausfall: 'Haarausfall',
stumpfes_fell: 'Stumpfes Fell',
schuppenbildung: 'Schuppenbildung',
roetungen: 'Hautrötungen / Entzündung',
pfotenlecken: 'Pfoten lecken (chronisch)',
ohrentzuendung: 'Ohrentzündung',
fettiges_fell: 'Fettiges Fell / Seborrhö',
// Allgemein
schlappheit: 'Schlappheit / Apathie',
nervositaet: 'Nervosität / Unruhe',
viel_trinken: 'Ungewöhnlich viel trinken',
sonstiges: 'Sonstiges',
};
const NEGATIV_TYPEN = new Set([
'erbrechen','durchfall','blaehungen','weicher_stuhl','appetitlosigkeit',
'juckreiz','haarausfall','stumpfes_fell','schuppenbildung','roetungen',
'pfotenlecken','ohrentzuendung','fettiges_fell',
'schlappheit','nervositaet','viel_trinken',
]);
const POSITIV_TYPEN = new Set(['verdauung_gut','energie_hoch','fell_glaenzend']);
verlaufEl.innerHTML = `
<h4 style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-2);color:var(--c-text)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock-counter-clockwise"></use></svg>
Verlauf
</h4>
<div style="display:grid;gap:var(--space-2)">
${items.map(item => {
if (item._art === 'futter') {
return `
<div data-futter-id="${item.id}"
style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bowl-food"></use>
</svg>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.futter_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(item.datum)} ${_esc(item.uhrzeit)}
${item.menge_g ? ` &middot; ${item.menge_g} g` : ''}
</div>
</div>
<button class="btn-icon vert-del-futter" data-id="${item.id}"
style="flex-shrink:0;background:none;border:none;cursor:pointer;padding:4px;
color:var(--c-text-muted)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
`;
} else {
const isNeg = NEGATIV_TYPEN.has(item.reaktion_typ);
const isPos = POSITIV_TYPEN.has(item.reaktion_typ);
const col = isNeg ? 'var(--c-danger,#ef4444)' : isPos ? 'var(--c-success,#22c55e)' : 'var(--c-text-muted)';
return `
<div data-reaktion-id="${item.id}"
style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:${col}">
<use href="/icons/phosphor.svg#heartbeat"></use>
</svg>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm);color:${col}">
${_esc(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
<span style="font-weight:400;color:var(--c-text-muted)">(${item.intensitaet}/5)</span>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(item.datum)} ${_esc(item.uhrzeit)}
</div>
</div>
<button class="btn-icon vert-del-reaktion" data-id="${item.id}"
style="flex-shrink:0;background:none;border:none;cursor:pointer;padding:4px;
color:var(--c-text-muted)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
`;
}
}).join('')}
</div>
`;
// Löschen-Buttons
verlaufEl.querySelectorAll('.vert-del-futter').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Eintrag löschen?')) return;
try {
await API.dogs.futterDelete(dog.id, parseInt(btn.dataset.id));
UI.toast.success('Eintrag gelöscht.');
const tabEl = _container.querySelector('#ern-tab-content');
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
}
});
});
verlaufEl.querySelectorAll('.vert-del-reaktion').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Reaktion löschen?')) return;
try {
await API.dogs.reaktionDelete(dog.id, parseInt(btn.dataset.id));
UI.toast.success('Reaktion gelöscht.');
const tabEl = _container.querySelector('#ern-tab-content');
if (tabEl) await _loadAnalyse(tabEl, dog), await _loadVerlauf(tabEl, dog);
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
}
});
});
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// PUBLIC API // PUBLIC API
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View file

@ -50,10 +50,6 @@ window.Page_health = (() => {
async function refresh() { async function refresh() {
if (!_appState.activeDog) return; if (!_appState.activeDog) return;
if (_appState.dogs.length > 1) {
_renderDogPicker();
return;
}
_data = {}; _data = {};
await _renderHealth(); await _renderHealth();
} }
@ -81,53 +77,8 @@ window.Page_health = (() => {
return; return;
} }
if (_appState.dogs.length > 1) {
_renderDogPicker();
} else {
await _renderHealth(); await _renderHealth();
} }
}
// ----------------------------------------------------------
// HUNDE-PICKER
// ----------------------------------------------------------
function _renderDogPicker() {
const activeDogId = _appState.activeDog?.id;
const cards = _appState.dogs.map(dog => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
<div class="diary-picker-av">${av}</div>
<div class="diary-picker-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${_esc(dog.rasse)}</div>` : ''}
</div>`;
}).join('');
_container.innerHTML = `
<div class="diary-picker-wrap">
<p class="diary-picker-hint">Wessen Gesundheitsakte?</p>
<div class="diary-picker-grid">${cards}</div>
</div>`;
_container.querySelectorAll('.diary-picker-card').forEach(el => {
el.addEventListener('click', async () => {
const id = parseInt(el.dataset.dogId);
if (id === _appState.activeDog?.id) {
// Bereits aktiver Hund → direkt Health laden
_data = {};
await _renderHealth();
} else {
App.setActiveDog(id);
// onDogChange() → _renderHealth() via _notifyDogChange()
}
});
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// HEALTH-ANSICHT — Tabs mit Einträgen // HEALTH-ANSICHT — Tabs mit Einträgen
@ -147,6 +98,7 @@ window.Page_health = (() => {
</button> </button>
</div>`; </div>`;
_container.innerHTML = ` _container.innerHTML = `
${UI.dogChip(_appState)}
<div class="by-toolbar health-header"> <div class="by-toolbar health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn"> <button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung ${UI.icon('star')} KI-Zusammenfassung
@ -164,6 +116,7 @@ window.Page_health = (() => {
`; `;
_renderTabBar(); _renderTabBar();
UI.bindDogChip(_container, _appState);
_container.querySelector('#health-ki-btn') _container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary); .addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn') _container.querySelector('#health-ki-tierarzt-btn')

View file

@ -11,6 +11,7 @@ window.Page_map = (() => {
let _map = null; let _map = null;
let _leafletLoaded = false; let _leafletLoaded = false;
let _userPos = null; let _userPos = null;
let _weatherLoaded = false;
let _placingMarker = false; let _placingMarker = false;
let _tempMarker = null; let _tempMarker = null;
@ -147,6 +148,7 @@ window.Page_map = (() => {
_userPos = pos; _userPos = pos;
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; } if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 }); _map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
_weatherLoaded = true;
_loadWeather(pos.lat, pos.lon); _loadWeather(pos.lat, pos.lon);
}).catch(() => { }).catch(() => {
const btn = document.getElementById('map-locate-btn'); const btn = document.getElementById('map-locate-btn');
@ -201,6 +203,8 @@ window.Page_map = (() => {
<div class="map-fabs"> <div class="map-fabs">
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button> <button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button> <button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div> </div>
@ -283,6 +287,186 @@ window.Page_map = (() => {
}); });
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar);
document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp);
}
// ----------------------------------------------------------
// REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.)
// ----------------------------------------------------------
let _radarLayer = null;
let _radarActive = false;
let _radarTimer = null;
let _tempLayer = null;
let _tempActive = false;
let _tempMarkers = [];
let _tempDebounce = null;
async function _toggleRadar() {
if (!App.hasPro(_appState?.user)) {
UI.toast.info('Regenradar ist ein Pro-Feature.');
return;
}
const btn = document.getElementById('map-radar-btn');
if (_radarActive) {
_radarActive = false;
if (_radarLayer) { _map.removeLayer(_radarLayer); _radarLayer = null; }
clearInterval(_radarTimer);
btn?.classList.remove('active');
return;
}
_radarActive = true;
btn?.classList.add('active');
if (_map && _map.getZoom() > 7) _map.setZoom(7);
await _loadRadar();
_radarTimer = setInterval(_loadRadar, 5 * 60 * 1000);
}
async function _toggleTemp() {
if (!App.hasPro(_appState?.user)) {
UI.toast.info('Temperatur-Layer ist ein Pro-Feature.');
return;
}
const btn = document.getElementById('map-temp-btn');
if (_tempActive) {
_tempActive = false;
if (_tempLayer) { _map.removeLayer(_tempLayer); _tempLayer = null; }
_tempMarkers.forEach(m => _map.removeLayer(m));
_tempMarkers = [];
clearTimeout(_tempDebounce);
_map.off('moveend zoomend', _debounceTempLabels);
document.getElementById('map-temp-legend')?.remove();
btn?.classList.remove('active');
return;
}
_tempActive = true;
btn?.classList.add('active');
try {
const cfg = await API.get('/weather/layer-tiles?layer=temp_new');
_tempLayer = window.L.tileLayer(cfg.url, {
opacity: 1.0,
tileSize: 256,
zIndex: 290,
maxNativeZoom: cfg.maxNativeZoom ?? 18,
maxZoom: 18,
attribution: 'Temp © <a href="https://openweathermap.org">OpenWeatherMap</a>',
}).addTo(_map);
_showTempLegend();
_map.on('moveend zoomend', _debounceTempLabels);
await _loadTempLabels();
} catch {
_tempActive = false;
btn?.classList.remove('active');
UI.toast.error('Temperatur-Layer nicht verfügbar.');
}
}
function _debounceTempLabels() {
clearTimeout(_tempDebounce);
_tempDebounce = setTimeout(_loadTempLabels, 600);
}
function _tempColor(t) {
if (t <= -10) return '#0033cc';
if (t <= 0) return '#0099ff';
if (t <= 10) return '#00cc88';
if (t <= 15) return '#88cc00';
if (t <= 20) return '#ffcc00';
if (t <= 25) return '#ff8800';
if (t <= 30) return '#ff3300';
return '#990000';
}
async function _loadTempLabels() {
if (!_tempActive || !_map) return;
const bounds = _map.getBounds();
const n = bounds.getNorth(), s = bounds.getSouth();
const e = bounds.getEast(), w = bounds.getWest();
// 3×3 Raster
const rows = 3, cols = 3;
const points = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const lat = s + (n - s) * (r + 0.5) / rows;
const lon = w + (e - w) * (c + 0.5) / cols;
points.push([lat, lon]);
}
}
const results = await Promise.all(points.map(([lat, lon]) =>
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}&current=temperature_2m&timezone=auto`, { cache: 'no-store' })
.then(r => r.json())
.then(d => ({ lat, lon, t: d.current?.temperature_2m }))
.catch(() => null)
));
// Alte Marker entfernen
_tempMarkers.forEach(m => _map.removeLayer(m));
_tempMarkers = [];
results.filter(Boolean).forEach(({ lat, lon, t }) => {
if (t == null) return;
const temp = Math.round(t);
const color = _tempColor(temp);
const icon = window.L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;font-size:12px;font-weight:800;
padding:2px 6px;border-radius:10px;white-space:nowrap;
box-shadow:0 1px 4px rgba(0,0,0,0.5);text-shadow:0 1px 2px rgba(0,0,0,0.4)">${temp}°</div>`,
iconSize: null,
iconAnchor: [20, 10],
});
const m = window.L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false });
m.addTo(_map);
_tempMarkers.push(m);
});
}
function _showTempLegend() {
const existing = document.getElementById('map-temp-legend');
if (existing) return;
const steps = [
{ c: '#0000cc', v: '20°' }, { c: '#0055ff', v: '10°' },
{ c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' },
{ c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' },
{ c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' },
{ c: '#990000', v: '35°' },
];
const gradient = steps.map(s => s.c).join(',');
const labels = steps.map(s =>
`<span style="flex:1;text-align:center;font-size:9px;color:#fff;text-shadow:0 0 3px #000">${s.v}</span>`
).join('');
const el = document.createElement('div');
el.id = 'map-temp-legend';
el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%);
z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px;
min-width:220px;pointer-events:none`;
el.innerHTML = `
<div style="height:10px;border-radius:3px;background:linear-gradient(to right,${gradient});margin-bottom:2px"></div>
<div style="display:flex">${labels}</div>`;
document.getElementById('central-map')?.appendChild(el);
}
async function _loadRadar() {
if (!_radarActive || !_map) return;
try {
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
const data = await resp.json();
const frames = [...(data.radar?.past || []), ...(data.radar?.nowcast || [])];
if (!frames.length) return;
const latest = frames[frames.length - 1].path;
const url = `https://tilecache.rainviewer.com${latest}/256/{z}/{x}/{y}/4/1_1.png`;
if (_radarLayer) _map.removeLayer(_radarLayer);
_radarLayer = window.L.tileLayer(url, {
opacity: 0.7,
tileSize: 256,
zIndex: 300,
maxNativeZoom: 7,
maxZoom: 18,
attribution: 'Radar © <a href="https://rainviewer.com">RainViewer</a>',
}).addTo(_map);
} catch { /* still */ }
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -373,6 +557,7 @@ window.Page_map = (() => {
pos => { pos => {
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords; const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
_userPos = { lat, lon }; _userPos = { lat, lon };
if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); }
if (_locationMarker) { if (_locationMarker) {
_locationMarker.setLatLng([lat, lon]); _locationMarker.setLatLng([lat, lon]);
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc); _locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
@ -1503,11 +1688,34 @@ window.Page_map = (() => {
} }
function _showRecSaveModal(track, distKm, dauMin) { function _showRecSaveModal(track, distKm, dauMin) {
const dogs = _appState?.dogs || [];
const activeDogId = _appState?.activeDog?.id;
const dogPickerHtml = dogs.length > 1 ? `
<div class="form-group">
<label class="form-label">Welche Hunde waren dabei?</label>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${dogs.map(d => {
const checked = d.id === activeDogId;
const av = d.foto_url
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
style="display:none" class="rec-dog-cb">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('')}
</div>
</div>` : '';
const body = ` const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)"> <p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p> </p>
<form id="rec-save-form" autocomplete="off"> <form id="rec-save-form" autocomplete="off">
${dogPickerHtml}
<div class="form-group"> <div class="form-group">
<label class="form-label">Name der Route *</label> <label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name" <input class="form-control" type="text" name="name"
@ -1587,10 +1795,23 @@ window.Page_map = (() => {
if (_recMarker) { _recMarker.remove(); _recMarker = null; } if (_recMarker) { _recMarker.remove(); _recMarker = null; }
}); });
// Hund-Checkbox Toggle-Styling
document.querySelectorAll('.rec-dog-cb').forEach(cb => {
const label = cb.closest('label');
const update = () => {
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
label.style.color = cb.checked ? 'var(--c-primary)' : '';
};
update();
cb.addEventListener('change', update);
});
document.getElementById('rec-save-form')?.addEventListener('submit', async e => { document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = document.querySelector('[form="rec-save-form"][type="submit"]'); const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
const fd = UI.formData(e.target); const fd = UI.formData(e.target);
const dogIds = [...document.querySelectorAll('.rec-dog-cb:checked')].map(c => parseInt(c.value));
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const saved = await API.routes.create({ const saved = await API.routes.create({
name: fd.name?.trim(), name: fd.name?.trim(),
@ -1604,6 +1825,7 @@ window.Page_map = (() => {
leine_empfohlen: 'leine_empfohlen' in fd, leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd, is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
dog_ids: dogIds.length ? dogIds : null,
}); });
UI.modal.close(); UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
@ -1628,13 +1850,18 @@ window.Page_map = (() => {
const w = await API.weather.get(lat, lon); const w = await API.weather.get(lat, lon);
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : ''; const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '';
const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`; const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : ''; const regen = w.precip_prob != null
? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
: '';
const warning = w.rain_warning_time
? ` · <span style="color:#f59e0b;font-weight:700">⚠ ab ${w.rain_warning_time}</span>`
: '';
let zecken = ''; let zecken = '';
if (w.zecken_warnung) { if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E'; const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`; zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
} }
info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${zecken}`; info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`;
info.classList.remove('map-weather-chip--hidden'); info.classList.remove('map-weather-chip--hidden');
sep.classList.remove('map-weather-chip--hidden'); sep.classList.remove('map-weather-chip--hidden');
} catch { /* still */ } } catch { /* still */ }

View file

@ -168,7 +168,7 @@ window.Page_notifications = (() => {
? App.callModule('poison', 'openDetail', { id: d.id }) ? App.callModule('poison', 'openDetail', { id: d.id })
: App.navigate('poison'); : App.navigate('poison');
break; break;
case 'friend_request': App.navigate('friends'); break; case 'friend_request': _openFriendRequestModal(); break;
case 'health_reminder':App.navigate('health'); break; case 'health_reminder':App.navigate('health'); break;
case 'milestone': App.navigate('diary'); break; case 'milestone': App.navigate('diary'); break;
default: default:
@ -379,5 +379,55 @@ window.Page_notifications = (() => {
document.head.appendChild(style); document.head.appendChild(style);
} }
async function _openFriendRequestModal() {
let requests;
try { requests = await API.friends.pending(); } catch { requests = []; }
if (!requests.length) {
UI.toast.info('Keine offenen Freundschaftsanfragen.');
return;
}
const items = requests.map(r => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) 0;
border-bottom:1px solid var(--c-border)">
<div style="width:36px;height:36px;border-radius:50%;overflow:hidden;flex-shrink:0;
background:var(--c-surface-2);display:flex;align-items:center;justify-content:center">
${r.avatar_url
? `<img src="${UI.escape(r.avatar_url)}" style="width:100%;height:100%;object-fit:cover">`
: `<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#user"></use></svg>`}
</div>
<div style="flex:1;font-weight:600">${UI.escape(r.requester_name)}</div>
<button class="btn btn-primary" style="padding:6px 14px;font-size:var(--text-sm)"
data-accept="${r.id}">Annehmen</button>
<button class="btn btn-secondary" style="padding:6px 10px;font-size:var(--text-sm)"
data-decline="${r.id}"></button>
</div>`).join('');
UI.modal.open({
title: 'Freundschaftsanfragen',
body: `<div>${items}</div>`,
});
document.querySelectorAll('[data-accept]').forEach(btn => {
btn.addEventListener('click', async () => {
await UI.asyncButton(btn, async () => {
await API.friends.accept(parseInt(btn.dataset.accept));
UI.modal.close();
UI.toast.success('Freundschaft angenommen!');
});
});
});
document.querySelectorAll('[data-decline]').forEach(btn => {
btn.addEventListener('click', async () => {
await UI.asyncButton(btn, async () => {
await API.friends.decline(parseInt(btn.dataset.decline));
UI.modal.close();
UI.toast.info('Anfrage abgelehnt.');
});
});
});
}
return { init }; return { init };
})(); })();

View file

@ -320,7 +320,17 @@ window.Page_routes = (() => {
if (actRow) actRow.style.display = 'none'; if (actRow) actRow.style.display = 'none';
const filterPanel = document.getElementById('rk-filter-panel'); const filterPanel = document.getElementById('rk-filter-panel');
if (filterPanel) filterPanel.style.display = 'none'; if (filterPanel) filterPanel.style.display = 'none';
if (!App.hasPro(_appState?.user)) {
document.getElementById('rk-list')?.replaceChildren();
const gate = document.createElement('div');
gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)';
gate.innerHTML = `<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
<div style="font-size:var(--text-sm)">Routenvorschläge sind ein Pro-Feature.</div>`;
document.getElementById('rk-list')?.appendChild(gate);
} else {
_renderSuggestTab(); _renderSuggestTab();
}
} else { } else {
if (searchRow) searchRow.style.display = ''; if (searchRow) searchRow.style.display = '';
if (actRow) actRow.style.display = ''; if (actRow) actRow.style.display = '';
@ -2146,6 +2156,7 @@ window.Page_routes = (() => {
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')} ${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')}
${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''} ${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''}
${_actionBtn('rd-reverse', 'path', 'Umkehren')} ${_actionBtn('rd-reverse', 'path', 'Umkehren')}
${(_appState?.dogs?.length > 0) ? _actionBtn('rd-dogs', 'dog', 'Hunde') : ''}
${_actionBtn('rd-del', 'trash', 'Löschen', true)} ${_actionBtn('rd-del', 'trash', 'Löschen', true)}
</div>` : ''; </div>` : '';
@ -2234,6 +2245,9 @@ window.Page_routes = (() => {
} catch (err) { UI.toast.error(err.message); } } catch (err) { UI.toast.error(err.message); }
}); });
// Hunde bearbeiten
document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route));
// Löschen // Löschen
document.getElementById('rd-del')?.addEventListener('click', async () => { document.getElementById('rd-del')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
@ -2294,6 +2308,70 @@ window.Page_routes = (() => {
} }
} }
// ----------------------------------------------------------
// Hunde einer Route bearbeiten
// ----------------------------------------------------------
function _openEditDogsModal(route) {
const dogs = _appState?.dogs || [];
if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; }
const currentIds = new Set(route.dog_ids || []);
const dogRows = dogs.map(d => {
const checked = currentIds.has(d.id);
const av = d.foto_url
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>`;
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
border:1.5px solid ${checked ? 'var(--c-primary)' : 'var(--c-border)'};
border-radius:100px;cursor:pointer;
background:${checked ? 'var(--c-primary-subtle)' : ''};
color:${checked ? 'var(--c-primary)' : ''};
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
style="display:none" class="rd-dog-cb">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('');
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Welche Hunde waren bei dieser Route dabei?
</p>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)" id="rd-dogs-picker">
${dogRows}
</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rd-dogs-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="rd-dogs-save">${UI.icon('floppy-disk')} Speichern</button>
`;
UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer });
// Checkbox-Pill Styling
document.querySelectorAll('.rd-dog-cb').forEach(cb => {
const label = cb.closest('label');
cb.addEventListener('change', () => {
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
label.style.color = cb.checked ? 'var(--c-primary)' : '';
});
});
document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-dogs-save')?.addEventListener('click', async () => {
const btn = document.getElementById('rd-dogs-save');
await UI.asyncButton(btn, async () => {
const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value));
await API.routes.updateDogs(route.id, dogIds);
route.dog_ids = dogIds;
UI.modal.close();
UI.toast.success('Hunde aktualisiert.');
});
});
}
// Richtungspfeile gleichmäßig entlang des Tracks platzieren // Richtungspfeile gleichmäßig entlang des Tracks platzieren
function _addRouteArrows(map, track, color = '#fff') { function _addRouteArrows(map, track, color = '#fff') {
if (track.length < 2) return; if (track.length < 2) return;

View file

@ -245,6 +245,7 @@ window.Page_settings = (() => {
<span>Hunde-Profile</span> <span>Hunde-Profile</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span> <span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div> </div>
<div id="settings-erinnerungen-wrap"></div>
<div class="sidebar-item" id="settings-push-btn" <div class="sidebar-item" id="settings-push-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)"> style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
@ -269,6 +270,12 @@ window.Page_settings = (() => {
<span>Hilfe &amp; FAQ</span> <span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span> <span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div> </div>
<div class="sidebar-item" id="settings-feedback-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-dots"></use></svg>
<span>Feedback geben</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? ` ${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4); <div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
background:rgba(196,132,58,0.1);border-radius:var(--radius-md); background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
@ -438,6 +445,38 @@ window.Page_settings = (() => {
</div> </div>
`; `;
// Verstorbene Hunde in Erinnerungen-Sektion laden
API.get('/dogs/verstorben').then(dogs => {
const el = document.getElementById('settings-erinnerungen-wrap');
if (!el || !dogs.length) return;
el.innerHTML = dogs.map(d => {
const av = d.foto_url
? `<img src="${_esc(d.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
</div>`;
const jahr = d.verstorben_am ? d.verstorben_am.slice(0, 4) : '';
return `
<div class="sidebar-item settings-erinnerung-btn" data-dog-id="${d.id}" data-dog-name="${_esc(d.name)}"
style="padding:var(--space-3) var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
${av}
<div style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:0">
<span style="font-weight:600;font-size:var(--text-sm)">${_esc(d.name)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"><use href="/icons/phosphor.svg#heart-break"></use></svg>
Erinnerungen${jahr ? ' · ' + jahr : ''}
</span>
</div>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>`;
}).join('');
el.querySelectorAll('.settings-erinnerung-btn').forEach(btn => {
btn.addEventListener('click', () => _openGedenkseite(
parseInt(btn.dataset.dogId), btn.dataset.dogName
));
});
}).catch(() => {});
// Achievements laden (Streak + Stats + Badges) // Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => { API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body'); const statsEl = document.getElementById('settings-stats-body');
@ -790,6 +829,52 @@ window.Page_settings = (() => {
App.navigate('hilfe'); App.navigate('hilfe');
}); });
document.getElementById('settings-feedback-btn')?.addEventListener('click', () => {
const sel = (id) => document.getElementById(id);
const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box';
UI.modal.open({
title: 'Feedback geben',
body: `
<form id="feedback-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Kategorie</label>
<select id="feedback-kat" name="kategorie" style="${inputStyle}">
<option value="idee">💡 Idee / Wunsch</option>
<option value="bug">🐛 Bug / Fehler</option>
<option value="lob">🎉 Lob</option>
<option value="sonstiges">💬 Sonstiges</option>
</select>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Deine Nachricht</label>
<textarea id="feedback-text" name="text" rows="5" maxlength="2000"
placeholder="Was möchtest du uns mitteilen?"
style="${inputStyle};resize:vertical"></textarea>
</div>
</form>
`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="feedback-form" id="feedback-submit-btn" class="btn btn-primary" style="width:100%">Absenden</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
sel('feedback-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = sel('feedback-submit-btn');
const kat = sel('feedback-kat')?.value;
const text = sel('feedback-text')?.value?.trim();
if (!text) { UI.toast.error('Bitte schreib etwas.'); return; }
await UI.asyncButton(btn, async () => {
await API.post('/feedback', { kategorie: kat, text });
UI.modal.close?.();
UI.toast.success('Vielen Dank für dein Feedback!');
});
});
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => { document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
title : 'Abmelden?', title : 'Abmelden?',
@ -1404,6 +1489,80 @@ window.Page_settings = (() => {
} catch { el.innerHTML = ''; } } catch { el.innerHTML = ''; }
} }
// ----------------------------------------------------------
// GEDENKSEITE — für verstorbene Hunde
// ----------------------------------------------------------
async function _openGedenkseite(dogId, dogName) {
UI.modal.open({ title: `Erinnerungen an ${_esc(dogName)}`, body: `
<div style="text-align:center;padding:var(--space-4)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner"></use>
</svg>
</div>` });
let data;
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
catch { UI.modal.close(); return; }
const d = data;
const av = d.dog.foto_url
? `<img src="${_esc(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
const photoGrid = d.photos?.length ? `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
${d.photos.map(url => `<img src="${_esc(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
</div>` : '';
const statsHtml = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
</div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
</div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
</div>` : ''}
</div>`;
const passed = d.dog.verstorben_am;
const passedStr = passed
? new Date(passed).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
UI.modal.open({
title: `Erinnerungen an ${_esc(d.dog.name)}`,
body: `
<div style="text-align:center;margin-bottom:var(--space-4)">
${av}
<div style="margin-top:var(--space-3);font-size:var(--text-lg);font-weight:700">${_esc(d.dog.name)}</div>
${passedStr ? `<div style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:4px">
<svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#heart-break"></use></svg>
${passedStr}
</div>` : ''}
</div>
${statsHtml}
${photoGrid}
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
Der Schmerz über den Verlust eines Hundes ist real und tief. Lass dich trauern die Erinnerungen bleiben immer bei dir.
</p>
</div>
${d.ki_abschied ? `<div style="font-style:italic;font-size:var(--text-sm);color:var(--c-text-secondary);
line-height:1.7;padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-border)">
"${_esc(d.ki_abschied)}"
</div>` : ''}
`,
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung // NICHT EINGELOGGT — Login / Registrierung
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -40,7 +40,8 @@ window.Page_trainingsplaene = (() => {
} }
function _lsKey(planId, goalIdx) { function _lsKey(planId, goalIdx) {
return `tp_${planId}_${goalIdx}`; const dogId = _dogId() || 'x';
return `tp_d${dogId}_${planId}_${goalIdx}`;
} }
function _saveGoal(key, checked) { function _saveGoal(key, checked) {
@ -537,6 +538,8 @@ window.Page_trainingsplaene = (() => {
// BIND EVENTS // BIND EVENTS
// ---------------------------------------------------------- // ----------------------------------------------------------
function _bindEvents() { function _bindEvents() {
UI.bindDogChip(_container, _appState);
// Notiz-Button // Notiz-Button
const dogId = _dogId(); const dogId = _dogId();
_container.querySelector('#tp-note-btn')?.addEventListener('click', e => { _container.querySelector('#tp-note-btn')?.addEventListener('click', e => {
@ -612,8 +615,9 @@ window.Page_trainingsplaene = (() => {
: `Erwachsener Hund ${_activeAdultTab}`; : `Erwachsener Hund ${_activeAdultTab}`;
_container.innerHTML = ` _container.innerHTML = `
<div style="padding-bottom:var(--space-8)"> <div style="padding:var(--space-4) var(--space-4) var(--space-8)">
<div style="display:flex;align-items:center;justify-content:space-between;margin:var(--space-4) 0 var(--space-4)"> ${UI.dogChip(_appState)}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0"> <h2 style="font-size:var(--text-lg);font-weight:700;margin:0">
${_icon('clipboard-text')} Trainingspläne ${_icon('clipboard-text')} Trainingspläne
</h2> </h2>
@ -862,7 +866,9 @@ window.Page_trainingsplaene = (() => {
} }
function refresh() {} function refresh() {}
function onDogChange() {} function onDogChange() {
_render();
}
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

View file

@ -75,6 +75,7 @@ window.Page_uebungen = (() => {
// In-memory cache (loaded from API on init) // In-memory cache (loaded from API on init)
let _progressCache = {}; // key → statusId let _progressCache = {}; // key → statusId
let _progressLoaded = false;
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend} let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
function _progressKey(tab, name) { function _progressKey(tab, name) {
@ -83,17 +84,13 @@ window.Page_uebungen = (() => {
function _getStatus(tab, name) { function _getStatus(tab, name) {
const k = _progressKey(tab, name); const k = _progressKey(tab, name);
// Fallback to localStorage while API loads return _progressCache[k] ?? null;
return _progressCache[k] !== undefined
? _progressCache[k]
: localStorage.getItem(_statusKey(tab, name)) || null;
} }
function _setStatus(tab, name, statusId) { function _setStatus(tab, name, statusId) {
const k = _progressKey(tab, name); const k = _progressKey(tab, name);
_progressCache[k] = statusId; _progressCache[k] = statusId;
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync API.training.setProgress(k, statusId, _dogId()).catch(() => {});
API.training.setProgress(k, statusId).catch(() => {});
} }
function _nextStatus(currentId) { function _nextStatus(currentId) {
@ -504,28 +501,19 @@ window.Page_uebungen = (() => {
_scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' }; _scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' };
} }
// Progress vom Server laden // Progress vom Server laden (hund-spezifisch)
API.training.getProgress().then(rows => { const _did = _dogId();
_progressLoaded = false;
API.training.getProgress(_did)
.then(rows => {
_progressCache = {};
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
// localStorage-Daten migrieren falls noch nicht im Backend _progressLoaded = true;
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => { _renderContent();
const parts = lsKey.replace('ub_status_', '').split('_'); }).catch(() => { _progressLoaded = true; _renderContent(); });
const tab = parts[0];
const name = parts.slice(1).join('_');
const apiKey = `${tab}_${name}`;
if (_progressCache[apiKey] === undefined) {
const val = localStorage.getItem(lsKey);
if (val) {
_progressCache[apiKey] = val;
API.training.setProgress(apiKey, val).catch(() => {});
}
}
});
_renderContent(); // Re-render with loaded progress
}).catch(() => {});
// Empfehlungen laden // Empfehlungen laden
API.training.getSuggestions().then(suggestions => { API.training.getSuggestions(_did).then(suggestions => {
if (suggestions.length) _showSuggestions(suggestions); if (suggestions.length) _showSuggestions(suggestions);
}).catch(() => {}); }).catch(() => {});
@ -555,6 +543,10 @@ window.Page_uebungen = (() => {
function onDogChange() { function onDogChange() {
_statsData = null; _statsData = null;
_badgesData = null; _badgesData = null;
_progressCache = {};
_progressLoaded = false;
_exerciseStats = {};
_render();
_loadStatsAndBadges(); _loadStatsAndBadges();
_loadVirtualTrainer(); _loadVirtualTrainer();
} }
@ -565,6 +557,7 @@ window.Page_uebungen = (() => {
function _render() { function _render() {
_container.innerHTML = ` _container.innerHTML = `
<div id="ueb-wrap"> <div id="ueb-wrap">
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
<div style="padding:var(--space-3) var(--space-4) var(--space-2)"> <div style="padding:var(--space-3) var(--space-4) var(--space-2)">
<table style="width:100%;border-collapse:collapse"> <table style="width:100%;border-collapse:collapse">
<tr> <tr>
@ -601,6 +594,7 @@ window.Page_uebungen = (() => {
<div id="ueb-content"></div> <div id="ueb-content"></div>
</div> </div>
`; `;
UI.bindDogChip(_container, _appState);
_container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal); _container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal);
_container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2)); _container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2));
_container.querySelector('#ueb-search')?.addEventListener('input', e => { _container.querySelector('#ueb-search')?.addEventListener('input', e => {
@ -610,7 +604,12 @@ window.Page_uebungen = (() => {
_renderContent(); _renderContent();
}); });
_bindTabs(); _bindTabs();
if (_progressLoaded) {
_renderContent(); _renderContent();
} else {
const el = _container.querySelector('#ueb-content');
if (el) el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)"><svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg></div>`;
}
_renderStatsBanner(); _renderStatsBanner();
} }
@ -779,7 +778,18 @@ window.Page_uebungen = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// SCHNELL-SETUP: Stand aller Übungen erfassen // SCHNELL-SETUP: Stand aller Übungen erfassen
// ---------------------------------------------------------- // ----------------------------------------------------------
function _openQuickSetupModal() { async function _openQuickSetupModal() {
// Sicherstellen dass Progress geladen ist bevor das Modal öffnet
if (!_progressLoaded) {
const did = _dogId();
try {
const rows = await API.training.getProgress(did);
_progressCache = {};
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
_progressLoaded = true;
_renderContent();
} catch { _progressLoaded = true; }
}
const ALL = [ const ALL = [
{ group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS }, { group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS },
{ group: 'Tricks', tab: 'tricks', items: TRICKS }, { group: 'Tricks', tab: 'tricks', items: TRICKS },
@ -880,11 +890,8 @@ window.Page_uebungen = (() => {
// Alle geänderten Status speichern // Alle geänderten Status speichern
const parts = Object.entries(pending).map(([key, val]) => { const parts = Object.entries(pending).map(([key, val]) => {
const [tab, ...rest] = key.split('_');
const name = rest.join('_').replace(/_/g, ' ');
_progressCache[key] = val || null; _progressCache[key] = val || null;
localStorage.setItem(`ub_status_${key}`, val || ''); return API.training.setProgress(key, val || null, _dogId());
return API.training.setProgress(key, val || null);
}); });
await Promise.allSettled(parts); await Promise.allSettled(parts);
@ -1004,7 +1011,17 @@ window.Page_uebungen = (() => {
break; break;
} }
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break; case 'ki-trainer':
if (!App.hasPro(_appState?.user)) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
<div style="font-size:var(--text-sm)">Der KI-Trainer ist ein Pro-Feature.</div>
</div>`;
} else {
el.innerHTML = _renderKiTrainer();
}
break;
} }
_bindAccordions(); _bindAccordions();
_bindStatusButtons(); _bindStatusButtons();

View file

@ -397,7 +397,9 @@ window.Page_wetter = (() => {
: 0; : 0;
} }
const locName = _data.location_name ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">${_esc(_data.location_name)}</div>` : '';
el.innerHTML = ` el.innerHTML = `
${locName}
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)"> <div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
${_wmoIcon(d.weathercode, '3.5rem')} ${_wmoIcon(d.weathercode, '3.5rem')}
<div> <div>

View file

@ -79,15 +79,34 @@ const UI = (() => {
document.getElementById('modal-container').appendChild(overlay); document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open'); document.documentElement.classList.add('modal-open');
_current = { overlay, onClose };
// Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
let _vvCleanup = null;
const vv = window.visualViewport;
if (vv) {
const adjust = () => {
const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
overlay.style.paddingBottom = (kb + 16) + 'px';
};
vv.addEventListener('resize', adjust);
vv.addEventListener('scroll', adjust);
_vvCleanup = () => {
vv.removeEventListener('resize', adjust);
vv.removeEventListener('scroll', adjust);
overlay.style.paddingBottom = '';
};
}
_current = { overlay, onClose, _vvCleanup };
return overlay.querySelector('.modal'); return overlay.querySelector('.modal');
} }
function close() { function close() {
if (!_current) return; if (!_current) return;
const { onClose } = _current; const { onClose, _vvCleanup } = _current;
onClose?.(); onClose?.();
_vvCleanup?.();
_current.overlay.remove(); _current.overlay.remove();
document.documentElement.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open');
_current = null; _current = null;
@ -976,6 +995,30 @@ const UI = (() => {
_load(); _load();
} }
function dogChip(appState) {
const dog = appState?.activeDog;
const dogs = appState?.dogs || [];
if (!dog) return '';
const av = dog.foto_url
? `<img src="${escape(dog.foto_url)}" style="width:22px;height:22px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
const sw = dogs.length > 1
? `<svg class="ph-icon" style="width:13px;height:13px;color:var(--c-text-muted);margin-left:2px"><use href="/icons/phosphor.svg#arrows-left-right"></use></svg>` : '';
return `<div class="by-dog-chip" data-dog-chip style="display:inline-flex;align-items:center;gap:6px;
padding:4px 10px 4px 6px;background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:100px;font-size:var(--text-xs);font-weight:600;color:var(--c-text);
${dogs.length > 1 ? 'cursor:pointer' : ''};max-width:fit-content">${av}<span>${escape(dog.name)}</span>${sw}</div>`;
}
function bindDogChip(container, appState) {
if ((appState?.dogs?.length || 0) < 2) return;
container.querySelector('[data-dog-chip]')?.addEventListener('click', () => {
const dogs = appState.dogs;
const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0];
if (next) App.setActiveDog(next.id);
});
}
// Öffentliche API // Öffentliche API
return { return {
toast, modal, toast, modal,
@ -990,6 +1033,10 @@ const UI = (() => {
leafletMarker, leafletMarker,
locationPicker, locationPicker,
ratingStars, ratingStars,
dogChip,
bindDogChip,
dogChip,
bindDogChip,
}; };
})(); })();

View file

@ -346,7 +346,8 @@ window.Worlds = (() => {
<div class="w3-section-label">${worldLabels[w]}</div> <div class="w3-section-label">${worldLabels[w]}</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px"> <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
${chips.map(c => ` ${chips.map(c => `
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}"> <button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>` : ''}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)"> <svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#${c.icon}"></use> <use href="/icons/phosphor.svg#${c.icon}"></use>
</svg> </svg>
@ -523,7 +524,7 @@ window.Worlds = (() => {
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] }, fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
{ icon:'push-pin', label:'Forum', page:'forum', { icon:'push-pin', label:'Forum', page:'forum',
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] }, fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
{ icon:'users', label:'Freunde', page:'friends', pro: true, { icon:'users', label:'Freunde', page:'friends',
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] }, fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true, { icon:'paw-print', label:'Gassi', page:'walks', pro: true,
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' }, fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
@ -760,6 +761,7 @@ window.Worlds = (() => {
<use href="/icons/phosphor.svg#lock-simple"></use> <use href="/icons/phosphor.svg#lock-simple"></use>
</svg> </svg>
</div>`} </div>`}
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0"> <svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
<use href="/icons/phosphor.svg#${c.icon}"></use> <use href="/icons/phosphor.svg#${c.icon}"></use>
</svg> </svg>
@ -969,10 +971,24 @@ window.Worlds = (() => {
// ── CHIP-HELPER ────────────────────────────────────────────── // ── CHIP-HELPER ──────────────────────────────────────────────
function _chip(icon, label, page, locked = false) { function _isRoleBasedPro() {
const u = _state?.user;
if (!u) return false;
const t = u.subscription_tier || 'standard';
if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false;
return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media;
}
function _chip(icon, label, page, locked = false, proBadge = false) {
const style = locked ? 'opacity:0.25;cursor:default;' : ''; const style = locked ? 'opacity:0.25;cursor:default;' : '';
const badge = proBadge
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>`
: '';
return ` return `
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`} style="${style}"> <div class="world-chip" ${locked ? '' : `data-wnav="${page}"`}
style="${style}position:relative">
${badge}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem"> <svg class="ph-icon" style="width:1.4rem;height:1.4rem">
<use href="/icons/phosphor.svg#${icon}"></use> <use href="/icons/phosphor.svg#${icon}"></use>
</svg> </svg>
@ -1097,7 +1113,11 @@ window.Worlds = (() => {
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span> <span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''} ${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div> </div>
${w ? `<span style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:1px;white-space:nowrap">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</span>` : ''} ${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
${w.location_name ? `<div style="color:rgba(255,255,255,0.5)">${w.location_name}</div>` : ''}
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
</div>` : ''}
</div> </div>
</div> </div>
</div> </div>
@ -1119,7 +1139,7 @@ window.Worlds = (() => {
<div class="world-bottom"> <div class="world-bottom">
<div class="world-section-label">Deine Bereiche</div> <div class="world-section-label">Deine Bereiche</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${features.map(f => _chip(f.icon, f.label, f.page)).join('')} ${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro())).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="impressum">Impressum</span> <span data-wnav="impressum">Impressum</span>
@ -1410,7 +1430,7 @@ window.Worlds = (() => {
` : ''} ` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div> <div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span> <span data-wnav="gruender">Die 100 Gründer</span>
@ -1418,8 +1438,12 @@ window.Worlds = (() => {
</div> </div>
`; `;
// Avatar → Hundeprofil // Avatar → Hundeprofil (aktiven Hund auf den angezeigten setzen)
el.querySelector('#wh-avatar')?.addEventListener('click', () => navigateTo('dog-profile')); el.querySelector('#wh-avatar')?.addEventListener('click', () => {
const shown = _dogs[_dogIdx];
if (shown && shown.id !== _state?.activeDog?.id) App.setActiveDog(shown.id);
navigateTo('dog-profile');
});
// Chips // Chips
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
// Name → nächster Hund // Name → nächster Hund
@ -1580,7 +1604,7 @@ window.Worlds = (() => {
<div class="world-bottom"> <div class="world-bottom">
<div class="world-section-label">Die Welt da draußen</div> <div class="world-section-label">Die Welt da draußen</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span> <span data-wnav="datenschutz">Datenschutz</span>

View file

@ -1,12 +1,4 @@
User-agent: * User-agent: *
Allow: /
Allow: /info
Allow: /wiki/rassen
Allow: /wiki/rasse/
Allow: /hund/
Allow: /breeder/
Allow: /wurfboerse
Allow: /knigge
Disallow: /api/ Disallow: /api/
Disallow: /ausweis/ Disallow: /ausweis/
Disallow: /teilen/ Disallow: /teilen/

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v826'; const CACHE_VERSION = 'by-v872';
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
@ -123,6 +123,7 @@ const _CACHEABLE_GET = [
/^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen /^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen
/^\/api\/training\/exercises/, /^\/api\/training\/exercises/,
/^\/api\/training\/progress/, /^\/api\/training\/progress/,
/^\/api\/training\/plan-progress/,
/^\/api\/wiki\/rassen/, /^\/api\/wiki\/rassen/,
/^\/api\/dogs\/\d+\/diary\/stats/, /^\/api\/dogs\/\d+\/diary\/stats/,
// Drei Welten — offline-fähig // Drei Welten — offline-fähig
@ -137,13 +138,17 @@ function _isCacheableGet(pathname) {
// Cache-TTL: stabile Daten länger, dynamische kürzer // Cache-TTL: stabile Daten länger, dynamische kürzer
const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/]; const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/];
const _SHORT_GET = [/^\/api\/training\/progress/, /^\/api\/training\/plan-progress/];
const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde
const _TTL_SHORT = 30 * 1000; // 30 Sekunden (Fortschritte, hund-spezifisch)
const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten
const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart) const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart)
function _cacheTTL(pathname) { function _cacheTTL(pathname) {
return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _TTL_DEFAULT; if (_STABLE_GET.some(re => re.test(pathname))) return _TTL_STABLE;
if (_SHORT_GET.some(re => re.test(pathname))) return _TTL_SHORT;
return _TTL_DEFAULT;
} }
function _cacheStale(pathname) { function _cacheStale(pathname) {
const ts = _cacheTs.get(pathname); const ts = _cacheTs.get(pathname);
@ -359,6 +364,12 @@ self.addEventListener('message', event => {
self.skipWaiting(); self.skipWaiting();
return; return;
} }
if (event.data?.type === 'INVALIDATE_CACHE') {
// Cache-Timestamps für angegebene Pfade zurücksetzen → nächster Request geht ans Netz
const paths = event.data.paths || [];
paths.forEach(p => _cacheTs.delete(p));
return;
}
if (event.data?.type === 'PROCESS_QUEUE') { if (event.data?.type === 'PROCESS_QUEUE') {
event.waitUntil(_processQueue()); event.waitUntil(_processQueue());
return; return;

View file

@ -4,6 +4,7 @@ BAN YARO — Wetter via Open-Meteo
- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache - get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache
""" """
import asyncio
import time import time
import logging import logging
import httpx import httpx
@ -58,15 +59,36 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
f"?latitude={lat}&longitude={lon}" f"?latitude={lat}&longitude={lon}"
"&current=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day" "&current=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
"&daily=precipitation_probability_max,uv_index_max" "&daily=precipitation_probability_max,uv_index_max"
"&hourly=precipitation_probability"
"&timezone=Europe%2FBerlin&forecast_days=1" "&timezone=Europe%2FBerlin&forecast_days=1"
) )
async with httpx.AsyncClient(timeout=8.0) as client: async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(url) resp, geo_resp = await asyncio.gather(
client.get(url),
client.get(
f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10",
headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"}
),
return_exceptions=True,
)
resp.raise_for_status() resp.raise_for_status()
raw = resp.json() raw = resp.json()
# Ortsname aus Reverse-Geocoding
location_name = None
try:
if not isinstance(geo_resp, Exception) and geo_resp.status_code == 200:
geo = geo_resp.json()
addr = geo.get("address", {})
location_name = (addr.get("city") or addr.get("town") or
addr.get("village") or addr.get("municipality") or
addr.get("county") or geo.get("name"))
except Exception:
pass
cur = raw.get('current', {}) cur = raw.get('current', {})
daily = raw.get('daily', {}) daily = raw.get('daily', {})
hourly = raw.get('hourly', {})
temp = cur.get('temperature_2m') temp = cur.get('temperature_2m')
feels_like = cur.get('apparent_temperature') feels_like = cur.get('apparent_temperature')
@ -85,6 +107,39 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
if temp is not None and temp > 7.0 and 3 <= month <= 10: if temp is not None and temp > 7.0 and 3 <= month <= 10:
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig') zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
# Nächste Regenstunde + Umschwung-Warnung
next_rain_time = None
rain_warning_time = None # Stunde mit ≥40%-Sprung gegenüber Vorststunde
already_raining = wcode >= 51
now_h = datetime.now().hour
h_times = hourly.get('time', [])
h_precip = hourly.get('precipitation_probability', [])
# Index-Liste nur für Stunden im Fenster now_h+1 … now_h+12
window = []
for t, p in zip(h_times, h_precip):
try:
entry_h = int(t[11:13])
except Exception:
continue
if entry_h <= now_h or entry_h > now_h + 12:
continue
window.append((entry_h, p if p is not None else 0))
if not already_raining:
for entry_h, p in window:
if next_rain_time is None and p >= 20:
next_rain_time = f"{entry_h:02d}:00"
break
# Umschwung: Sprung ≥40% von einer Stunde zur nächsten
prev_p = wcode >= 51 and 100 or (h_precip[now_h] if now_h < len(h_precip) else 0) or 0
for entry_h, p in window:
if p - prev_p >= 40:
rain_warning_time = f"{entry_h:02d}:00"
break
prev_p = p
data = { data = {
'temp_c': temp, 'temp_c': temp,
'feels_like_c': feels_like, 'feels_like_c': feels_like,
@ -96,6 +151,9 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
'uv_index': uv, 'uv_index': uv,
'is_day': bool(is_day), 'is_day': bool(is_day),
'zecken_warnung': zecken, 'zecken_warnung': zecken,
'next_rain_time': next_rain_time,
'rain_warning_time': rain_warning_time,
'location_name': location_name,
} }
_location_cache[key] = (now, data) _location_cache[key] = (now, data)
return data return data
@ -235,9 +293,23 @@ async def get_forecast(lat: float, lon: float) -> dict:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
forecast_task = client.get(forecast_url) forecast_task = client.get(forecast_url)
pollen_task = client.get(pollen_url) pollen_task = client.get(pollen_url)
forecast_resp, pollen_resp = await asyncio.gather( geo_task = client.get(
forecast_task, pollen_task, return_exceptions=True f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10",
headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"}
) )
forecast_resp, pollen_resp, geo_resp_fc = await asyncio.gather(
forecast_task, pollen_task, geo_task, return_exceptions=True
)
location_name_fc = None
try:
if not isinstance(geo_resp_fc, Exception) and geo_resp_fc.status_code == 200:
addr = geo_resp_fc.json().get("address", {})
location_name_fc = (addr.get("city") or addr.get("town") or
addr.get("village") or addr.get("municipality") or
addr.get("county"))
except Exception:
pass
# --- Forecast (required) --- # --- Forecast (required) ---
if isinstance(forecast_resp, Exception): if isinstance(forecast_resp, Exception):
@ -384,7 +456,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
'hourly': _hourly_by_day.get(date_str, []), 'hourly': _hourly_by_day.get(date_str, []),
}) })
result = {'timezone': timezone, 'days': days} result = {'timezone': timezone, 'days': days, 'location_name': location_name_fc}
_forecast_cache[key] = (now, result) _forecast_cache[key] = (now, result)
_log_forecast(round(lat, 1), round(lon, 1), days) _log_forecast(round(lat, 1), round(lon, 1), days)
return result return result

View file

@ -12,7 +12,7 @@ services:
- .env - .env
environment: environment:
- DB_PATH=/data/banyaro.db - DB_PATH=/data/banyaro.db
- MEDIA_DIR=/prod-media - MEDIA_DIR=/data/media
- STAGING=true - STAGING=true
- KI_MODE=cloud - KI_MODE=cloud
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0