From 553e9e7854807c1aafca64491f55c36e4e927dd4 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 25 Apr 2026 20:44:46 +0200 Subject: [PATCH] Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tagebuch: - Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter) - 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker) - Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr) - Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area - 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste - EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API - NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien) - Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations Notiz-Feature: - Generische notes-Tabelle (parent_type + parent_id + meta_json) - 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse - KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil Icons & Design: - fill:currentColor Fix für welcome/onboarding/friends.js - --c-icon Variable, --c-text-muted Dark Mode aufgehellt - 15+ neue Phosphor-Icons aus lokaler Kopie - CSS Network-First im SW, Cache-Control-Middleware Infrastruktur: - Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen) - auth.py: notes_ki_enabled + is_social_media im User-Response --- .claude/scheduled_tasks.lock | 1 + PROJEKT.md | 46 +- backend/auth.py | 2 +- backend/database.py | 26 + backend/main.py | 20 + backend/media_utils.py | 30 + backend/routes/diary.py | 246 ++++++- backend/routes/import_data.py | 16 +- backend/routes/notes.py | 263 +++++++ backend/routes/profile.py | 1 + backend/scheduler.py | 84 +-- backend/static/css/components.css | 780 +++++++++++++++++--- backend/static/css/design-system.css | 6 +- backend/static/css/layout.css | 2 +- backend/static/icons/phosphor.svg | 15 + backend/static/index.html | 13 +- backend/static/js/api.js | 29 +- backend/static/js/app.js | 2 +- backend/static/js/pages/diary.js | 980 +++++++++++++++++++++---- backend/static/js/pages/dog-profile.js | 6 +- backend/static/js/pages/erste-hilfe.js | 103 ++- backend/static/js/pages/events.js | 77 +- backend/static/js/pages/friends.js | 2 +- backend/static/js/pages/health.js | 131 +++- backend/static/js/pages/notes.js | 693 +++++++++++++++++ backend/static/js/pages/onboarding.js | 6 +- backend/static/js/pages/routes.js | 60 ++ backend/static/js/pages/settings.js | 43 ++ backend/static/js/pages/sitting.js | 72 ++ backend/static/js/pages/uebungen.js | 264 ++++++- backend/static/js/pages/walks.js | 76 +- backend/static/js/pages/welcome.js | 4 +- backend/static/sw.js | 12 +- promotion/banyaro_hundeschulen.html | 410 +++++++++++ promotion/banyaro_tieraerzte.html | 407 ++++++++++ 35 files changed, 4558 insertions(+), 370 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 backend/routes/notes.py create mode 100644 backend/static/js/pages/notes.js create mode 100644 promotion/banyaro_hundeschulen.html create mode 100644 promotion/banyaro_tieraerzte.html diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f6fdb0d --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339} \ No newline at end of file diff --git a/PROJEKT.md b/PROJEKT.md index 246ea6b..da216fe 100644 --- a/PROJEKT.md +++ b/PROJEKT.md @@ -46,6 +46,50 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) --- +## Implementierungsstand (aktuell: 2026-04-25, SW by-v405, APP_VER 385) + +### Sprint 12+13 (2026-04-25) ✅ + +#### Tagebuch — Day-One-Redesign +- Listenansicht: Wochentag-Kürzel + große Tageszahl links, Titel fett, Textvorschau, Meta-Zeile (Zeit · Ort · Wetter), Thumbnail quadratisch rechts +- Vier Ansichten: Liste, Medien (3-Spalten-Mosaik), Kalender (mit Fotos auf Tagen), Karte (Leaflet alle GPS-Standorte) +- Kalender: Doppelpfeil-Sprungbuttons «/» zum nächsten Monat mit Einträgen +- Karten-Ansicht: Foto-Marker, Popup-Vorschau, Klick öffnet Eintrag direkt +- Detail-Ansicht: inline im Content-Bereich (nicht mehr als Fullscreen-Overlay), Sidebar bleibt sichtbar +- Detail: Hero-Foto (vollständig sichtbar, object-fit:contain), Thumbnails, 2-Spalten-Layout Desktop (Text + Karte/POI) +- Detail: Karte zeigt GPS-Position, POI-Liste darunter ("In der Nähe") +- Lightbox: Back-Button + Prev/Next in Bottom-Bar, Safe-Area für Querformat +- Stats-Leiste: Einträge/Medien/Tage (Gesamtzahlen vom Backend), View-Switcher, kompakt auf Mobile +- "Weitere laden" nur in Listenansicht sichtbar + +#### Tagebuch — Daten & Import +- EXIF-GPS-Extraktion beim Foto-Upload (Pillow), auto-Wetter+POI bei GPS aus EXIF +- Wetter (Open-Meteo Archive-API historisch): 106 Einträge retroaktiv angereichert +- POIs (osm_pois DB-Cache, 437k Einträge): 85 Einträge retroaktiv angereichert +- NoteStation-Import Fix: Fotos in diary_media statt altem media_url-Feld +- Migration: 80 importierte media_url-Einträge in diary_media (94 statt 15 Medien für Ban Yaro) +- Neue API-Endpoints: /diary/stats, /diary/calendar, /diary/locations + +#### Notiz-Feature +- Generische notes-Tabelle (parent_type + parent_id + meta_json) +- REST-API /api/notes mit GET/POST/PATCH/DELETE +- API.notes in api.js +- 📝-Button in: Übungen, Gesundheit, Tagebuch, Routen, Events, Gassi-Treffen, Sitting, Erste Hilfe +- Notizblock-Seite: Filter nach Rubrik, Suche, Sortierung, KI-Muster-Erkennung (abschaltbar) +- KI-Toggle in Einstellungen + +#### Design & Icons +- fill:currentColor Fix für SVGs ohne ph-icon-Klasse (welcome.js, onboarding.js, friends.js) +- --c-icon CSS-Variable, --c-text-muted in Dark Mode aufgehellt (#9A8878) +- 15+ neue Phosphor-Icons: note-pencil, images, caret-left/right/double, coffee, bed, tree, church, etc. +- Phosphor-Workflow: fill-Variante aus lokaler Kopie /icons/phosphor-icons/SVGs/fill/ + +#### Infrastruktur +- CSS Network-First im Service Worker (kein iOS-Caching-Problem mehr) +- Cache-Control-Middleware: versioned URLs immutable, andere no-cache +- Python open(w)-vor-read Bug dokumentiert (leert Datei) +- Scheduler: Wiki-Anreicherungs-Jobs entfernt (abgeschlossen) + ## Implementierungsstand (aktuell: 2026-04-25, SW by-v370, APP_VER 355) ### Sprint 11 (2026-04-25) ✅ @@ -222,7 +266,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) #### 1.2 Gesundheit & Impfpass - [ ] Impfungen, Entwurmungen, Tierarztbesuche digital - [ ] Medikamenten-Reminder (Push Notification) -- [ ] Gewichtsverlauf-Chart +- [x] Gewichtsverlauf-Chart ✅ - [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall) #### 1.3 Giftköder-Alarm diff --git a/backend/auth.py b/backend/auth.py index 9e01700..beedb65 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 4d656d1..e4c43ed 100644 --- a/backend/database.py +++ b/backend/database.py @@ -534,6 +534,13 @@ def _migrate(conn_factory): ("pflege_tipps", "fell_pflege_art", "TEXT"), # Wiki-Foto-Einreichungen: Bildrechte-Bestätigung ("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"), + # Tagebuch: Wetter + POI-Metadaten beim Eintrag + ("diary", "weather_json", "TEXT"), + ("diary", "poi_json", "TEXT"), + # Notizen: Ort + Label + KI-Assistent User-Setting + ("notes", "location_name", "TEXT"), + ("notes", "parent_label", "TEXT"), + ("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1131,3 +1138,22 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source); """) logger.info("Migration: ki_daily_calls.source bereit.") + + # Notizen: generische polymorphe Notiz-Tabelle + conn.executescript(""" + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + parent_type TEXT NOT NULL, + parent_id INTEGER NOT NULL, + text TEXT NOT NULL DEFAULT '', + meta_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_notes_parent + ON notes(parent_type, parent_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_notes_user + ON notes(user_id, created_at DESC); + """) + logger.info("Migration: notes Tabelle bereit.") diff --git a/backend/main.py b/backend/main.py index 4cfad18..d9e0d71 100644 --- a/backend/main.py +++ b/backend/main.py @@ -80,6 +80,24 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware): app.add_middleware(_UploadSizeMiddleware) +class _CacheControlMiddleware(BaseHTTPMiddleware): + """Setzt Cache-Control-Header für statische Assets. + CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum. + Versioned Assets (?v=…): immutable — URL ändert sich bei Updates. + """ + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + path = request.url.path + if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")): + if "v=" in str(request.url.query): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + +app.add_middleware(_CacheControlMiddleware) + + # ------------------------------------------------------------------ # API-Router registrieren (werden nach und nach hinzugefügt) # ------------------------------------------------------------------ @@ -122,6 +140,7 @@ from routes.praise import router as praise_router from routes.weather import router as weather_router from routes.social import router as social_router from routes.moderation import router as moderation_router +from routes.notes import router as notes_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -163,6 +182,7 @@ app.include_router(achievements_router, prefix="/api/achievements", tags= app.include_router(training_router, prefix="/api/training", tags=["Training"]) app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) +app.include_router(notes_router, prefix="/api/notes", tags=["Notes"]) # ------------------------------------------------------------------ diff --git a/backend/media_utils.py b/backend/media_utils.py index 4b70300..10f1f5f 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -117,6 +117,36 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]: pass +def extract_gps_from_exif(data: bytes) -> tuple | None: + """EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None.""" + try: + from PIL import Image + img = Image.open(io.BytesIO(data)) + exif = img._getexif() + if not exif: + return None + gps = exif.get(34853) # GPSInfo tag + if not gps: + return None + lat_dms = gps.get(2) + lon_dms = gps.get(4) + lat_ref = gps.get(1, 'N') + lon_ref = gps.get(3, 'E') + if not lat_dms or not lon_dms: + return None + + def dms(v): + return float(v[0]) + float(v[1]) / 60 + float(v[2]) / 3600 + + lat = dms(lat_dms) * (-1 if lat_ref == 'S' else 1) + lon = dms(lon_dms) * (-1 if lon_ref == 'W' else 1) + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + return None + return round(lat, 6), round(lon, 6) + except Exception: + return None + + def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]: """Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through.""" ext = os.path.splitext(filename or "")[1].lower() diff --git a/backend/routes/diary.py b/backend/routes/diary.py index b469bbe..e2ead8f 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,14 +1,17 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json, math +import os, uuid, json, math, logging from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional from database import db -from auth import get_current_user +from auth import get_current_user, require_admin import ki as KI import httpx -from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload +import weather as weather_mod +from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif + +logger = logging.getLogger(__name__) router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -142,6 +145,69 @@ def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict: return e +@router.get("/{dog_id}/diary/stats") +async def diary_stats(dog_id: int, user=Depends(get_current_user)): + """Gesamtstatistik für das Tagebuch (unabhängig von Pagination).""" + with db() as conn: + _can_read_dog(dog_id, user["id"], conn) + total = conn.execute( + "SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id " + "WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id) + ).fetchone()[0] + photos = conn.execute( + "SELECT COUNT(*) FROM diary_media dm " + "JOIN diary d ON d.id=dm.diary_id LEFT JOIN diary_dogs dd ON dd.diary_id=d.id " + "WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id) + ).fetchone()[0] + days = conn.execute( + "SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id " + "WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id) + ).fetchone()[0] + return {"entries": total, "photos": photos, "days": days} + + +@router.get("/{dog_id}/diary/calendar") +async def diary_calendar(dog_id: int, user=Depends(get_current_user)): + """Alle Einträge minimal für Kalenderansicht: id, datum, cover_url.""" + with db() as conn: + _can_read_dog(dog_id, user["id"], conn) + rows = conn.execute( + """SELECT DISTINCT d.id, d.datum, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id AND dm.media_type='image' + ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url + FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE (d.dog_id=? OR dd.dog_id=?) + AND d.datum IS NOT NULL + ORDER BY d.datum DESC""", + (dog_id, dog_id) + ).fetchall() + return [dict(r) for r in rows] + + +@router.get("/{dog_id}/diary/locations") +async def diary_locations(dog_id: int, user=Depends(get_current_user)): + """Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht.""" + with db() as conn: + _can_read_dog(dog_id, user["id"], conn) + rows = conn.execute( + """SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon, + d.location_name, d.weather_json, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id AND dm.media_type='image' + ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url, + (SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count + FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE (d.dog_id=? OR dd.dog_id=?) + AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL + ORDER BY d.datum DESC""", + (dog_id, dog_id) + ).fetchall() + return [dict(r) for r in rows] + + @router.get("/{dog_id}/diary") async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, q: Optional[str] = None, milestone: int = 0, @@ -226,10 +292,95 @@ async def create_diary(dog_id: int, data: DiaryCreate, _set_dog_ids(conn, entry["id"], all_dogs) dogs_map = _fetch_dog_ids(conn, [entry["id"]]) media_map = _fetch_media_items(conn, [entry["id"]]) + entry_id = entry["id"] + + # Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks) + if data.gps_lat is not None and data.gps_lon is not None: + weather_json = None + poi_json = None + + # Wetter holen + try: + wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon) + weather_json = json.dumps(wd) + except Exception as exc: + logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc) + + # POIs holen + try: + pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5) + if pois: + poi_json = json.dumps(pois) + except Exception as exc: + logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc) + + # In DB speichern und Entry aktualisieren + if weather_json is not None or poi_json is not None: + with db() as conn: + conn.execute( + "UPDATE diary SET weather_json=?, poi_json=? WHERE id=?", + (weather_json, poi_json, entry_id) + ) + entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() return _entry_dict(entry, dogs_map, media_map) +async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list: + """Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth).""" + results = [] + try: + async with httpx.AsyncClient(timeout=6) as client: + def _overpass_q(radius): + return ( + f'[out:json][timeout:6];' + f'(' + f' node["name"]["tourism"](around:{radius},{lat},{lon});' + f' node["name"]["historic"](around:{radius},{lat},{lon});' + f' node["name"]["leisure"](around:{radius},{lat},{lon});' + f' node["name"]["amenity"](around:{radius},{lat},{lon});' + f' node["name"]["shop"](around:{radius},{lat},{lon});' + f' way["name"]["tourism"](around:{radius},{lat},{lon});' + f' way["name"]["historic"](around:{radius},{lat},{lon});' + f' way["name"]["leisure"](around:{radius},{lat},{lon});' + f');' + f'out center;' + ) + ov = await client.post( + "https://overpass-api.de/api/interpreter", + data={"data": _overpass_q(800)}, + headers={"User-Agent": "BanYaro/1.0"}, + ) + elements = ov.json().get("elements", []) if ov.status_code == 200 else [] + if not elements: + ov2 = await client.post( + "https://overpass-api.de/api/interpreter", + data={"data": _overpass_q(2000)}, + headers={"User-Agent": "BanYaro/1.0"}, + ) + elements = ov2.json().get("elements", []) if ov2.status_code == 200 else [] + seen = set() + for el in elements: + n = el.get("tags", {}).get("name") + if not n or n.lower() in seen: + continue + seen.add(n.lower()) + elat = el.get("lat") or el.get("center", {}).get("lat") + elon = el.get("lon") or el.get("center", {}).get("lon") + if elat and elon: + km = _haversine_km(lat, lon, elat, elon) + typ = next((el["tags"].get(k) for k in + ["tourism", "historic", "leisure", "amenity", "shop"] + if el["tags"].get(k)), "place") + results.append({"name": n, "type": typ, + "distance_m": int(km * 1000)}) + if len(results) >= limit: + break + except Exception as exc: + logger.debug("_fetch_pois_for_coords Fehler: %s", exc) + return results[:limit] + + def _haversine_km(lat1, lon1, lat2, lon2) -> float: R = 6371 dlat = math.radians(lat2 - lat1) @@ -508,6 +659,11 @@ async def upload_media(dog_id: int, entry_id: int, media_url = f"/media/diary/{filename}" + # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien) + exif_gps = None + if media_type == "image": + exif_gps = extract_gps_from_exif(raw_data) + with db() as conn: # sort_order = nächste freie Position max_order = conn.execute( @@ -525,8 +681,38 @@ async def upload_media(dog_id: int, entry_id: int, (entry_id,) ).fetchone()["id"] - return {"id": new_id, "url": media_url, "media_type": media_type, + # GPS aus EXIF in den Eintrag schreiben, wenn noch keine Koordinaten vorhanden + gps_written = False + if exif_gps: + existing = conn.execute( + "SELECT gps_lat FROM diary WHERE id=?", (entry_id,) + ).fetchone() + if existing and existing["gps_lat"] is None: + conn.execute( + "UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?", + (exif_gps[0], exif_gps[1], entry_id) + ) + gps_written = True + + # Wetter + POI nachladen wenn GPS frisch gesetzt + if gps_written and exif_gps: + try: + wd = await weather_mod.get_weather_for_location(exif_gps[0], exif_gps[1]) + pois = await _fetch_pois_for_coords(exif_gps[0], exif_gps[1], limit=5) + with db() as conn: + conn.execute( + "UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?", + (json.dumps(wd) if wd else None, json.dumps(pois) if pois else None, entry_id) + ) + except Exception as e: + logger.warning("EXIF-GPS Wetter/POI Fehler: %s", e) + + resp = {"id": new_id, "url": media_url, "media_type": media_type, "sort_order": max_order + 1, "is_cover": is_cover} + if exif_gps: + resp["exif_lat"] = exif_gps[0] + resp["exif_lon"] = exif_gps[1] + return resp @router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204) @@ -587,3 +773,55 @@ async def set_cover_media(dog_id: int, entry_id: int, media_id: int, conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,)) conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,)) return {"ok": True} + + +# ------------------------------------------------------------------ +# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge +# ------------------------------------------------------------------ +@router.post("/admin/enrich-metadata", status_code=200) +async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)): + """Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach.""" + with db() as conn: + rows = conn.execute( + """SELECT id, gps_lat, gps_lon FROM diary + WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL + AND (weather_json IS NULL OR poi_json IS NULL) + LIMIT ?""", + (limit,) + ).fetchall() + + enriched = 0 + skipped = 0 + for row in rows: + entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"] + weather_json = None + poi_json = None + try: + wd = await weather_mod.get_weather_for_location(lat, lon) + weather_json = json.dumps(wd) + except Exception as e: + logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e) + try: + pois = await _fetch_pois_for_coords(lat, lon, limit=5) + if pois: + poi_json = json.dumps(pois) + except Exception as e: + logger.warning("enrich-metadata POI id=%s: %s", entry_id, e) + + if weather_json is not None or poi_json is not None: + with db() as conn: + conn.execute( + "UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?", + (weather_json, poi_json, entry_id) + ) + enriched += 1 + else: + skipped += 1 + + with db() as conn: + remaining = conn.execute( + """SELECT COUNT(*) FROM diary + WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)""" + ).fetchone()[0] + + return {"enriched": enriched, "skipped": skipped, "remaining": remaining} diff --git a/backend/routes/import_data.py b/backend/routes/import_data.py index 76c3099..8dbe887 100644 --- a/backend/routes/import_data.py +++ b/backend/routes/import_data.py @@ -155,9 +155,9 @@ async def import_notestation( (entry_id, dog_id), ) - # Erstes Bild speichern + # Anhänge in diary_media speichern (statt veraltetem media_url-Feld) attachments = note.get("attachment") or {} - media_url = None + first = True for att in attachments.values(): md5 = att.get("md5", "") mime = att.get("type", "image/jpeg") @@ -165,13 +165,11 @@ async def import_notestation( continue media_url = _save_image_from_zip(zf, md5, mime) if media_url: - break - - if media_url: - conn.execute( - "UPDATE diary SET media_url=? WHERE id=?", - (media_url, entry_id), - ) + conn.execute( + "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)", + (entry_id, media_url, "image", 0 if first else 1, 1 if first else 0), + ) + first = False imported += 1 diff --git a/backend/routes/notes.py b/backend/routes/notes.py new file mode 100644 index 0000000..a85a2a2 --- /dev/null +++ b/backend/routes/notes.py @@ -0,0 +1,263 @@ +"""BAN YARO — Notizen Routes""" + +import json +import logging +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional, Any, List +from database import db +from auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class NoteCreate(BaseModel): + text: str + meta_json: Optional[Any] = None + location_name: Optional[str] = None + parent_label: Optional[str] = None + + +class NoteUpdate(BaseModel): + text: Optional[str] = None + meta_json: Optional[Any] = None + location_name: Optional[str] = None + parent_label: Optional[str] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktionen +# ------------------------------------------------------------------ +def _serialize(row) -> dict: + d = dict(row) + if d.get("meta_json") and isinstance(d["meta_json"], str): + try: + d["meta_json"] = json.loads(d["meta_json"]) + except Exception: + pass + return d + + +# ------------------------------------------------------------------ +# GET /api/notes — Gesamt-Notizblock mit Filtern +# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität) +# WICHTIG: Diese Route muss VOR /{parent_type}/{parent_id} stehen! +# ------------------------------------------------------------------ +@router.get("") +async def list_all_notes_filtered( + parent_type: Optional[List[str]] = Query(default=None), + date_from: Optional[str] = Query(default=None), + date_to: Optional[str] = Query(default=None), + q: Optional[str] = Query(default=None), + sort: Optional[str] = Query(default="date_desc"), + user=Depends(get_current_user), +): + """Alle Notizen des Users mit optionalen Filtern.""" + conditions = ["user_id=?"] + params: list = [user["id"]] + + if parent_type: + placeholders = ",".join("?" * len(parent_type)) + conditions.append(f"parent_type IN ({placeholders})") + params.extend(parent_type) + + if date_from: + conditions.append("DATE(created_at) >= ?") + params.append(date_from) + + if date_to: + conditions.append("DATE(created_at) <= ?") + params.append(date_to) + + if q: + conditions.append("(text LIKE ? OR COALESCE(parent_label,'') LIKE ?)") + like = f"%{q}%" + params.extend([like, like]) + + where = " AND ".join(conditions) + + if sort == "rubrik": + order = "parent_type ASC, created_at DESC" + elif sort == "ort": + order = "CASE WHEN location_name IS NULL OR location_name='' THEN 1 ELSE 0 END ASC, location_name ASC, created_at DESC" + elif sort == "date_asc": + order = "created_at ASC" + else: + order = "created_at DESC" + + with db() as conn: + rows = conn.execute( + f"SELECT * FROM notes WHERE {where} ORDER BY {order}", + params + ).fetchall() + + return [_serialize(r) for r in rows] + + +@router.get("/all/0") +async def list_all_notes(user=Depends(get_current_user)): + """Alias für Rückwärtskompatibilität.""" + with db() as conn: + rows = conn.execute( + "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", + (user["id"],) + ).fetchall() + return [_serialize(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/notes/ki-analyse +# WICHTIG: Fixe Route MUSS vor /{parent_type}/{parent_id} stehen! +# ------------------------------------------------------------------ +@router.post("/ki-analyse") +async def ki_analyse(user=Depends(get_current_user)): + """KI analysiert die Notizen des Users und gibt Muster/Vorschläge zurück.""" + with db() as conn: + # User-Setting prüfen + setting = conn.execute( + "SELECT notes_ki_enabled FROM users WHERE id=?", + (user["id"],) + ).fetchone() + + if not setting or not setting["notes_ki_enabled"]: + raise HTTPException(403, "KI-Assistent ist deaktiviert.") + + with db() as conn: + rows = conn.execute( + """SELECT text, parent_type, parent_label, location_name, created_at + FROM notes + WHERE user_id=? + ORDER BY created_at DESC + LIMIT 50""", + (user["id"],) + ).fetchall() + + note_count = len(rows) + if note_count == 0: + return {"suggestions": "", "note_count": 0} + + notes_data = [dict(r) for r in rows] + + prompt = ( + "Du bist ein freundlicher Assistent für Hundebesitzer. " + "Analysiere diese Notizen und erkenne Muster (Gesundheit, Training, Verhalten, " + "Lieblingsrouten, saisonale Besonderheiten). " + "Gib 2-4 kurze, konkrete Vorschläge auf Deutsch. " + "Keine langen Texte, bullet points. " + f"Daten: {json.dumps(notes_data, ensure_ascii=False)}" + ) + + try: + import ki as ki_module + suggestions, _ = await ki_module.complete( + prompt, + requires_premium=False, + user_is_premium=False, + ) + except Exception as e: + logger.warning("KI-Analyse fehlgeschlagen: %s", e) + suggestions = "" + + return {"suggestions": suggestions, "note_count": note_count} + + +# ------------------------------------------------------------------ +# GET /api/notes/{parent_type}/{parent_id} +# parent_id kann ein Integer oder ein String-Schlüssel sein. +# SQLite ist dynamisch getypt — wir übergeben den Wert als Text. +# ------------------------------------------------------------------ +@router.get("/{parent_type}/{parent_id}") +async def list_notes(parent_type: str, parent_id: str, + user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute( + """SELECT * FROM notes + WHERE user_id=? AND parent_type=? AND CAST(parent_id AS TEXT)=? + ORDER BY created_at DESC""", + (user["id"], parent_type, parent_id) + ).fetchall() + return [_serialize(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/notes/{parent_type}/{parent_id} +# ------------------------------------------------------------------ +@router.post("/{parent_type}/{parent_id}", status_code=201) +async def create_note(parent_type: str, parent_id: str, data: NoteCreate, + user=Depends(get_current_user)): + if not data.text.strip(): + raise HTTPException(400, "Notiz darf nicht leer sein.") + + meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + with db() as conn: + conn.execute( + """INSERT INTO notes + (user_id, parent_type, parent_id, text, meta_json, + location_name, parent_label, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (user["id"], parent_type, parent_id, data.text.strip(), meta_str, + data.location_name, data.parent_label, now, now) + ) + row = conn.execute( + "SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1", + (user["id"], parent_type, parent_id) + ).fetchone() + return _serialize(row) + + +# ------------------------------------------------------------------ +# PATCH /api/notes/{id} +# ------------------------------------------------------------------ +@router.patch("/{note_id}") +async def update_note(note_id: int, data: NoteUpdate, + user=Depends(get_current_user)): + with db() as conn: + note = conn.execute( + "SELECT * FROM notes WHERE id=? AND user_id=?", (note_id, user["id"]) + ).fetchone() + if not note: + raise HTTPException(404, "Notiz nicht gefunden.") + + updates = {} + if data.text is not None: + if not data.text.strip(): + raise HTTPException(400, "Notiz darf nicht leer sein.") + updates["text"] = data.text.strip() + if data.meta_json is not None: + updates["meta_json"] = json.dumps(data.meta_json) + if data.location_name is not None: + updates["location_name"] = data.location_name + if data.parent_label is not None: + updates["parent_label"] = data.parent_label + + if not updates: + return _serialize(note) + + updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + set_clause = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [note_id] + conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values) + row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() + return _serialize(row) + + +# ------------------------------------------------------------------ +# DELETE /api/notes/{id} +# ------------------------------------------------------------------ +@router.delete("/{note_id}", status_code=204) +async def delete_note(note_id: int, user=Depends(get_current_user)): + with db() as conn: + note = conn.execute( + "SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user["id"]) + ).fetchone() + if not note: + raise HTTPException(404, "Notiz nicht gefunden.") + conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) + return None diff --git a/backend/routes/profile.py b/backend/routes/profile.py index a706949..08f403a 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -25,6 +25,7 @@ class ProfileUpdate(BaseModel): erfahrung: Optional[str] = None social_link: Optional[str] = None profil_sichtbarkeit: Optional[str] = None + notes_ki_enabled: Optional[int] = None def _load_user(user_id: int) -> dict: diff --git a/backend/scheduler.py b/backend/scheduler.py index c80a583..84d5072 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -90,14 +90,6 @@ def start(): id="seed_wikidata_startup", replace_existing=True, ) - # Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen - _scheduler.add_job( - _job_wiki_enrich, - CronTrigger(hour=2, minute=30), - id="wiki_enrich_nightly", - replace_existing=True, - misfire_grace_time=3600, - ) # Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober _scheduler.add_job( _job_weekly_praise, @@ -114,16 +106,8 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) - # Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern - _scheduler.add_job( - _job_wiki_enrich_startup, - 'date', - run_date=datetime.now(tz=_TZ) + timedelta(seconds=90), - id="wiki_enrich_startup", - replace_existing=True, - ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, Wiki-KI-Anreicherung 02:30.") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.") def stop(): @@ -629,35 +613,6 @@ def _log_job(job_id: str, status: str, result: str): } -# ------------------------------------------------------------------ -# JOB: KI-Anreicherung der Rassen-Daten (nächtlich) -# ------------------------------------------------------------------ -async def _job_wiki_enrich(): - """Reichert alle noch nicht angereicherten Rassen mit KI-Daten an.""" - try: - from scraper.breed_enricher import enrich_breeds - enriched = await enrich_breeds(limit=2000) - msg = f"{enriched} Rassen angereichert" - logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.") - _log_job("wiki_enrich_nightly", "ok", msg) - except Exception as e: - logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}") - _log_job("wiki_enrich_nightly", "error", str(e)) - - -async def _job_wiki_enrich_startup(): - """Beim Start: alle Rassen sofort anreichern.""" - try: - from scraper.breed_enricher import enrich_breeds - enriched = await enrich_breeds(limit=2000) - msg = f"{enriched} Rassen angereichert (Startup)" - logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.") - _log_job("wiki_enrich_startup", "ok", msg) - except Exception as e: - logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}") - _log_job("wiki_enrich_startup", "error", str(e)) - - # ------------------------------------------------------------------ # Hilfsfunktion: Lob-Text für einen Hund generieren # ------------------------------------------------------------------ @@ -815,12 +770,6 @@ async def _job_status_report(): metrics = {} try: with db() as conn: - # Rassen-Anreicherung - metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0] - metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0] - metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0] - metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0] - # Züchter try: metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0] @@ -848,16 +797,6 @@ async def _job_status_report(): logger.error(f"Status-Report: DB-Fehler: {e}") return - # --- Wiki-Fortschritt berechnen --- - total = metrics["rassen_total"] or 1 - enriched = metrics["rassen_enriched"] - pct = round(enriched / total * 100) - remaining = total - enriched - nights_left = (remaining + 19) // 20 # bei 20/Nacht - - bar_filled = round(pct / 5) - progress_bar = "█" * bar_filled + "░" * (20 - bar_filled) - # --- Job-Log-Tabelle --- job_labels = { "health_reminders": "Gesundheits-Erinnerungen", @@ -865,8 +804,6 @@ async def _job_status_report(): "weather_alert": "Wetter-Alert", "milestone_check": "Meilenstein-Check", "import_events": "Event-Import (VDH)", - "wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)", - "wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)", "seed_breeds_startup": "Rassen-Seed (TheDogAPI)", "seed_wikidata_startup":"Rassen-Seed (Wikidata)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", @@ -899,18 +836,6 @@ async def _job_status_report():
{now_str} Uhr
- -
-
Wiki KI-Anreicherung
-
- {progress_bar} {pct}%
- ✅ Angereichert: {enriched} / {total}
- ⏳ Verbleibend: {remaining} Rassen (~{nights_left} Nächte)
- 📷 Mit lokalem Foto: {metrics['rassen_mit_foto']}
- 📝 Mit Beschreibung: {metrics['rassen_mit_desc']} -
-
-
Scheduler-Jobs
@@ -947,13 +872,6 @@ async def _job_status_report(): plain = f"""Ban Yaro Status-Report — {now_str} -=== Wiki KI-Anreicherung === -{progress_bar} {pct}% -Angereichert: {enriched}/{total} -Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht) -Mit Foto: {metrics['rassen_mit_foto']} -Mit Beschreibung: {metrics['rassen_mit_desc']} - === Scheduler-Jobs === {job_rows_txt} === Community === diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 3102262..875b2f7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -963,82 +963,326 @@ html.modal-open { } /* ------------------------------------------------------------ - 12. TAGEBUCH + 12. TAGEBUCH — Day One Style ------------------------------------------------------------ */ -/* Monats-Trennlinie */ -.diary-month-header { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - text-transform: uppercase; - letter-spacing: 0.06em; - padding: var(--space-4) 0 var(--space-2); - border-bottom: 1px solid var(--c-border); - margin-bottom: var(--space-3); +/* Stats-Leiste */ +.diary-stats-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0; + padding: 8px 12px; + border-bottom: 1px solid var(--c-divider, var(--c-border)); + background: var(--c-surface); + flex-shrink: 0; } -.diary-month-header:first-child { - padding-top: 0; +.diary-stats-numbers { + display: flex; + gap: 0; + overflow-x: auto; + scrollbar-width: none; + flex: 1; + min-width: 0; +} +.diary-stats-numbers::-webkit-scrollbar { display: none; } +.diary-stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 0; + padding: 0 8px; + border-right: 1px solid var(--c-border); +} +.diary-stat:last-child { border-right: none; } +.diary-stat-num { + font-size: 18px; + font-weight: 700; + color: var(--c-text); + line-height: 1.2; + white-space: nowrap; +} +.diary-stat-label { + font-size: 9px; + color: var(--c-text-muted); + margin-top: 2px; + white-space: nowrap; + text-transform: uppercase; + letter-spacing: .04em; } -/* Eintragskarte */ +/* View-Switcher */ +.diary-view-switcher { + display: flex; + gap: 0; + flex-shrink: 0; + margin-left: 8px; +} +.diary-view-btn { + background: none; + border: none; + cursor: pointer; + padding: 5px 6px; + border-radius: 8px; + color: var(--c-text-secondary); + display: flex; + align-items: center; + transition: background .15s, color .15s; +} +.diary-view-btn:hover { background: var(--c-surface-2); color: var(--c-text); } +.diary-view-btn.active { color: var(--c-primary); background: var(--c-primary-subtle); } +.diary-view-btn .ph-icon { width: 18px; height: 18px; } +@media (min-width: 640px) { + .diary-stat { padding: 0 12px; } + .diary-stat-num { font-size: 20px; } + .diary-view-btn { padding: 6px 8px; } + .diary-view-btn .ph-icon { width: 20px; height: 20px; } +} + +/* Meta-Zeile in der Karte */ +.diary-meta-loc { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.diary-meta-dot { color: var(--c-text-muted); opacity: .5; } + +/* Medien-Mosaic */ +.diary-media-mosaic { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px; + padding: 2px; +} +.diary-mosaic-item { + aspect-ratio: 1; + overflow: hidden; + cursor: pointer; + position: relative; +} +.diary-mosaic-item img { + width: 100%; height: 100%; + object-fit: cover; + display: block; + transition: opacity .2s; +} +.diary-mosaic-item:hover img { opacity: .85; } + +/* Kalender-Ansicht */ +.diary-calendar { padding: 0 0 80px; } +.diary-cal-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 16px 8px; +} +.diary-cal-nav button { + background: none; border: none; cursor: pointer; + padding: 6px; border-radius: 8px; color: var(--c-text-muted); + display: flex; align-items: center; +} +.diary-cal-nav button:hover { background: var(--c-surface-2); } +.diary-cal-nav button .ph-icon { width: 20px; height: 20px; } +.diary-cal-month { font-size: 16px; font-weight: 600; color: var(--c-text); } +.diary-cal-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + padding: 0 8px 4px; + text-align: center; + font-size: 11px; + color: var(--c-text-muted); + font-weight: 500; +} +.diary-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 3px; + padding: 0 8px; +} +.diary-cal-cell { + aspect-ratio: 1; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + position: relative; + overflow: hidden; + font-size: 13px; + color: var(--c-text-secondary); +} +.diary-cal-cell.has-entry { + cursor: pointer; + color: var(--c-text); + font-weight: 600; +} +.diary-cal-cell.has-entry:active { opacity: .7; } +/* Oranger Punkt unter der Tageszahl — sichtbar auch ohne Foto */ +.diary-cal-cell.has-entry::after { + content: ''; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--c-primary); + flex-shrink: 0; +} +/* Foto als Hintergrund */ +.diary-cal-cell.has-entry img { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; + opacity: .4; + border-radius: 8px; +} +.diary-cal-cell.has-entry:hover img, +.diary-cal-cell.has-entry:active img { opacity: .6; } +/* Punkt ausblenden wenn Foto vorhanden (Foto reicht als Indikator) */ +.diary-cal-cell.has-entry:has(img)::after { display: none; } +.diary-cal-cell.today .diary-cal-day { + background: var(--c-primary); + color: var(--c-text-inverse); + border-radius: 50%; + width: 26px; height: 26px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; +} +.diary-cal-day { position: relative; z-index: 1; font-size: 13px; } + +/* Monats-Section */ +.diary-month-header { + font-size: 22px; + font-weight: 700; + color: var(--c-text); + padding: 12px 16px; + background: var(--c-surface-2, #f5f5f5); + margin: 0; + border-top: 1px solid var(--c-border); + border-bottom: none; + letter-spacing: -0.01em; +} +.diary-month-header:first-child { + border-top: none; + margin-top: 0; +} +@media (prefers-color-scheme: dark) { + .diary-month-header { background: var(--c-surface-2); } +} +[data-theme="dark"] .diary-month-header { background: var(--c-surface-2); } + +/* Monats-Eintrags-Container (umschließt alle Karten einer Section) */ +.diary-month-entries { + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); +} + +/* Eintragskarte — Day One Row-Style */ .diary-card { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - margin-bottom: var(--space-3); - overflow: hidden; - cursor: pointer; - transition: box-shadow var(--transition-fast), - transform var(--transition-fast); - box-shadow: var(--shadow-xs); + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + background: transparent; + border: none; + border-bottom: 1px solid var(--c-divider, var(--c-border)); + border-radius: 0; + margin-bottom: 0; + overflow: visible; + cursor: pointer; + transition: background var(--transition-fast); + box-shadow: none; -webkit-tap-highlight-color: transparent; } +.diary-card:last-child { + border-bottom: none; +} .diary-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-1px); + background: rgba(0,0,0,0.025); + box-shadow: none; + transform: none; } .diary-card:active { - transform: scale(0.99); + background: rgba(0,0,0,0.05); + transform: none; +} +[data-theme="dark"] .diary-card:hover { background: rgba(255,255,255,0.04); } +[data-theme="dark"] .diary-card:active { background: rgba(255,255,255,0.07); } + +/* Datum-Spalte links */ +.diary-card-date-col { + display: flex; + flex-direction: column; + align-items: center; + width: 44px; + flex-shrink: 0; + padding-top: 1px; +} +.diary-card-weekday { + font-size: 10px; + font-weight: 600; + color: var(--c-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + line-height: 1; + margin-bottom: 2px; +} +.diary-card-daynum { + font-size: 28px; + font-weight: 700; + color: var(--c-text); + line-height: 1; +} + +/* Meilenstein-Icon auf der Datum-Spalte */ +.diary-card-date-col .diary-milestone-icon { + font-size: 14px; + color: #c4a000; + margin-top: 4px; } /* Meilenstein-Hervorhebung */ .diary-card--milestone { - border-color: #d4a017; - border-width: 2px; - background: linear-gradient( - 135deg, - var(--c-surface) 0%, - color-mix(in srgb, #d4a017 8%, var(--c-surface)) 100% - ); + background: color-mix(in srgb, #d4a017 4%, transparent); +} +.diary-card--milestone .diary-card-daynum { + color: #b8860b; } /* Meilenstein-Badge innerhalb der Karte */ .diary-card-milestone-badge { - display: inline-flex; - align-items: center; - gap: 4px; - background: color-mix(in srgb, #d4a017 15%, transparent); - color: #8a6400; - font-weight: 600; - font-size: var(--text-xs); - padding: 2px var(--space-2); - border-radius: var(--radius-full); - margin-bottom: var(--space-2); + display: inline-flex; + align-items: center; + gap: 4px; + background: color-mix(in srgb, #d4a017 15%, transparent); + color: #8a6400; + font-weight: 600; + font-size: var(--text-xs); + padding: 2px var(--space-2); + border-radius: var(--radius-full); + margin-bottom: 4px; letter-spacing: 0.03em; } -/* Foto / Video oben */ +/* Foto / Thumbnail rechts — 72×72px */ .diary-card-photo { - width: 100%; - height: 180px; - overflow: hidden; + width: 72px; + height: 72px; + flex-shrink: 0; + border-radius: 8px; + overflow: hidden; + position: relative; + margin-top: 2px; } .diary-card-photo img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; + width: 100%; + height: 100%; + object-fit: cover; + display: block; } .diary-media-picker { display: flex; @@ -1165,7 +1409,7 @@ html.modal-open { border-radius: 50%; border: none; background: rgba(0,0,0,.50); - color: #9ca3af; + color: rgba(255,255,255,.55); font-size: 14px; cursor: pointer; display: flex; @@ -1177,7 +1421,7 @@ html.modal-open { transition: color .15s, background .15s; } .diary-cover-btn--active { - color: #f5c518; + color: var(--c-amber); background: rgba(0,0,0,.65); } .diary-cover-btn--form { @@ -1185,48 +1429,46 @@ html.modal-open { left: var(--space-1); } -/* Card Body */ +/* Card Body — mittlere Spalte */ .diary-card-body { - padding: var(--space-3) var(--space-4); + flex: 1; + min-width: 0; + padding: 0; } -/* Meta-Zeile: Typ + Datum */ -.diary-card-meta { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-1); -} -.diary-card-type { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--c-primary); - text-transform: uppercase; - letter-spacing: 0.04em; -} -.diary-card-date { - font-size: var(--text-xs); - color: var(--c-text-secondary); -} - -/* Titel */ +/* Titel in Karte */ .diary-card-title { - font-size: var(--text-base); - font-weight: var(--weight-semibold); - color: var(--c-text); - margin-bottom: var(--space-1); + font-size: 15px; + font-weight: 700; + color: var(--c-text); + margin: 0 0 3px; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } +/* Meta-Zeile: nur noch für Compat — im neuen Design nicht als flex-row genutzt */ +.diary-card-meta { + display: none; +} +.diary-card-type { display: none; } +.diary-card-date { display: none; } + /* Ort-Zeile in Karte */ .diary-card-location { display: flex; align-items: center; - gap: var(--space-1); - font-size: var(--text-sm); - color: var(--c-primary); - margin: 0 0 var(--space-1); + gap: 4px; + font-size: 12px; + color: var(--c-text-muted); + margin: 0 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.diary-card-location .ph-icon { flex-shrink: 0; } +.diary-card-location .ph-icon { flex-shrink: 0; width: 12px; height: 12px; } /* Ort in Detail-Ansicht */ .diary-detail-location { @@ -1292,12 +1534,12 @@ html.modal-open { /* Text-Vorschau */ .diary-card-text { - font-size: var(--text-sm); + font-size: 13px; color: var(--c-text-secondary); - line-height: 1.5; - margin: 0 0 var(--space-2); + line-height: 1.45; + margin: 0 0 4px; display: -webkit-box; - -webkit-line-clamp: 3; + -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -1310,6 +1552,62 @@ html.modal-open { margin-top: var(--space-1); } +/* Meta-Zeile unten in der Karte: Zeit · Ort · Wetter */ +.diary-card-meta-row { + font-size: 12px; + color: var(--c-text-muted); + line-height: 1.4; + margin-top: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Wetter-Badge in Karten-Meta */ +.diary-weather-badge { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: var(--text-xs); + color: var(--c-text-secondary); + white-space: nowrap; +} + +/* FAB — Floating Action Button */ +.diary-fab { + position: fixed; + bottom: calc(var(--nav-bottom-height, 64px) + env(safe-area-inset-bottom, 0px) + 16px); + right: 20px; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + border: none; + cursor: pointer; + box-shadow: 0 4px 16px rgba(196,132,58,.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + transition: transform .15s, box-shadow .15s; + -webkit-tap-highlight-color: transparent; +} +.diary-fab:hover { transform: scale(1.06); box-shadow: 0 6px 20px rgba(196,132,58,.5); } +.diary-fab:active { transform: scale(0.94); } + +/* POI-Chips in Karte und Detail */ +.diary-poi-chips, +.diary-detail-poi-chips { + font-size: var(--text-xs); + color: var(--c-text-muted); + line-height: 1.5; + margin: var(--space-1) 0 var(--space-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Detail-Ansicht */ .diary-detail-milestone-badge { display: inline-flex; @@ -1324,6 +1622,312 @@ html.modal-open { margin-bottom: var(--space-3); } +/* Detail-View: Hero-Bild */ +.diary-detail-hero { + width: 100%; + max-height: 80vh; + background: #000; + flex-shrink: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +@media (min-width: 768px) { + .diary-detail-hero { + max-width: 1100px; + margin: 0 auto; + border-radius: 0 0 12px 12px; + max-height: 60vh; + } +} +@media (min-width: 1200px) { + .diary-detail-hero { max-width: 1300px; } +} +.diary-detail-hero img { + width: 100%; + height: auto; + max-height: 80vh; + object-fit: contain; + display: block; + cursor: zoom-in; +} +.diary-detail-hero video { + width: 100%; + height: auto; + max-height: 80vh; + object-fit: contain; + display: block; + background: #000; +} + +/* Detail-View: inline im Content-Bereich (kein Overlay mehr) */ +.diary-detail-view-inner { + display: flex; + flex-direction: column; + min-height: calc(100vh - 120px); + background: var(--c-bg); +} + +/* Detail-View: Header-Bar */ +.diary-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; + min-height: 48px; +} +.diary-detail-back { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--c-primary); + font-size: 16px; + cursor: pointer; + padding: 4px 0; + font-weight: 500; +} +.diary-detail-date-center { + font-size: 14px; + font-weight: 600; + color: var(--c-text); + text-align: center; + flex: 1; + padding: 0 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.diary-detail-edit { + background: none; + border: none; + color: var(--c-primary); + cursor: pointer; + padding: 4px 0; + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + font-weight: 500; +} + +/* Detail-View: Body-Wrapper (text links, Karte rechts auf Desktop) */ +.diary-detail-body-wrap { + display: flex; + flex-direction: column; + flex: 1; + width: 100%; +} +@media (min-width: 768px) { + .diary-detail-body-wrap { + flex-direction: row; + align-items: flex-start; + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + box-sizing: border-box; + } +} +@media (min-width: 1200px) { + .diary-detail-body-wrap { max-width: 1300px; } +} + +/* Detail-View: Inhalt */ +.diary-detail-content { + padding: 24px 24px 60px; + flex: 1; + min-width: 0; +} +@media (max-width: 767px) { + .diary-detail-content { padding: 20px 16px 40px; } +} + +/* Detail-View: Karte + POI-Sektion */ +.diary-detail-map-wrap { + padding: 16px 16px 40px; + flex-shrink: 0; + width: 100%; +} +@media (min-width: 768px) { + .diary-detail-map-wrap { + width: 380px; + min-width: 300px; + max-width: 420px; + flex-shrink: 0; + padding: 24px 0 40px 32px; + position: sticky; + top: 60px; + align-self: flex-start; + } +} +.diary-detail-map { + width: 100%; + height: 200px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--c-border); + margin-bottom: 12px; +} +@media (min-width: 768px) { + .diary-detail-map { height: 280px; } +} + +/* POI-Liste */ +.diary-detail-poi-list { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: 12px; + overflow: hidden; +} +.diary-detail-poi-heading { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--c-text-muted); + padding: 10px 14px 8px; + border-bottom: 1px solid var(--c-border); +} +.diary-detail-poi-heading .ph-icon { width:14px;height:14px; } +.diary-detail-poi-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--c-border); + font-size: 13px; +} +.diary-detail-poi-row:last-child { border-bottom: none; } +.diary-detail-poi-icon { width:16px;height:16px;color:var(--c-primary);flex-shrink:0; } +.diary-detail-poi-name { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text); } +.diary-detail-poi-dist { font-size:12px;color:var(--c-text-muted);flex-shrink:0; } +.diary-detail-title { + font-size: 22px; + font-weight: 700; + color: var(--c-text); + margin: 0 0 16px; + line-height: 1.3; +} +.diary-detail-body { + font-size: 16px; + line-height: 1.7; + color: var(--c-text); + white-space: pre-wrap; + margin: 0 0 20px; +} +.diary-detail-divider { + border: none; + border-top: 1px solid var(--c-border); + margin: 20px 0; +} + +/* Detail-View: Meta-Bar unten */ +.diary-detail-meta-bar { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + font-size: 13px; + color: var(--c-text-muted); + margin-bottom: 16px; + align-items: center; +} +.diary-detail-meta-bar .ph-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} +.diary-detail-meta-item { + display: flex; + align-items: center; + gap: 5px; +} + +/* Detail-View: Thumbnail-Strip */ +.diary-detail-thumbs { + display: flex; + gap: 4px; + padding: 6px 16px; + overflow-x: auto; + background: rgba(0,0,0,.6); + flex-shrink: 0; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +@media (min-width: 768px) { + .diary-detail-thumbs { + max-width: 1100px; + margin: 0 auto; + border-radius: 0 0 8px 8px; + padding-left: 16px; + padding-right: 16px; + background: rgba(0,0,0,.75); + width: 100%; + box-sizing: border-box; + } +} +@media (min-width: 1200px) { + .diary-detail-thumbs { max-width: 1300px; } +} +.diary-detail-thumbs::-webkit-scrollbar { display: none; } +.diary-detail-thumb { + flex-shrink: 0; + width: 56px; + height: 56px; + border-radius: 6px; + overflow: hidden; + cursor: pointer; + border: 2px solid transparent; + box-sizing: border-box; + transition: border-color .15s, opacity .15s; + opacity: .7; +} +.diary-detail-thumb:hover { opacity: 1; } +.diary-detail-thumb--active { + border-color: var(--c-primary); + opacity: 1; +} +@media (min-width: 768px) { + .diary-detail-thumb { width: 72px; height: 72px; } +} + +/* Detail-View: Foto-Galerie horizontal */ +.diary-detail-gallery { + display: flex; + gap: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x mandatory; + margin: 0 -20px 20px; + padding: 0 20px; + scrollbar-width: none; +} +.diary-detail-gallery::-webkit-scrollbar { display: none; } +.diary-detail-gallery-item { + flex: 0 0 auto; + width: min(75vw, 280px); + height: 200px; + border-radius: var(--radius-md); + overflow: hidden; + scroll-snap-align: start; + cursor: zoom-in; +} +.diary-detail-gallery-item img, +.diary-detail-gallery-item video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + /* Leaflet-Attribution ausblenden */ .leaflet-control-attribution { display: none !important; } diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index dc1a0b2..5b6f1e0 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -44,6 +44,7 @@ --c-warning: #D4923A; --c-warning-subtle: #FDF3E3; --c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */ + --c-icon: #7A6A58; /* Standard-Icon-Farbe (= text-secondary im Light-Mode) */ --c-info: #4A7A9B; --c-info-subtle: #E8F2F8; @@ -137,8 +138,11 @@ --c-text: #F0EAE0; --c-text-secondary: #C0B0A0; - --c-text-muted: #806A58; + --c-text-muted: #9A8878; --c-text-inverse: #2A1F14; + --c-icon: #B0A090; + --c-amber: #C48820; + --c-success: #6A9E58; --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index ed396fb..65b62e3 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -202,7 +202,7 @@ justify-content: center; gap: 3px; cursor: pointer; - color: var(--c-text-muted); + color: var(--c-icon, var(--c-text-secondary)); transition: color var(--transition-fast); -webkit-tap-highlight-color: transparent; touch-action: manipulation; diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 7512a15..87a2ec2 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -30,6 +30,21 @@ + + + + + + + + + + + + + + + diff --git a/backend/static/index.html b/backend/static/index.html index 85f466e..8222594 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -88,9 +88,9 @@ - - - + + + @@ -124,6 +124,9 @@ + -
+ +
+
+
- + + + +
+ `; + bar.style.display = 'flex'; + + bar.querySelectorAll('.diary-view-btn').forEach(btn => { + btn.addEventListener('click', () => { + _currentView = btn.dataset.view; + _renderStatsBar(); + _renderCurrentView(); + }); + }); + } + + function _renderCurrentView() { + const content = _container.querySelector('#diary-view-content'); + const loadMore = _container.querySelector('#diary-load-more'); + if (!content) return; + // "Weitere laden" nur in der Listenansicht sinnvoll + if (loadMore) loadMore.style.display = 'none'; + if (_currentView === 'list') { + content.innerHTML = '
'; + _renderList(); + // Sichtbarkeit des "Weitere laden"-Buttons nach _load() steuern (bereits in _load()) + } else if (_currentView === 'media') { + _renderMediaGrid(content); + } else if (_currentView === 'calendar') { + _renderCalendarView(content); + } else if (_currentView === 'map') { + _renderMapView(content); + } + } + + async function _renderMapView(content) { + const dog = _appState.activeDog; + if (!dog) return; + + content.innerHTML = `
+
+ + Karte wird geladen… +
+
`; + + let locations; + try { + locations = await API.diary.locations(dog.id); + } catch (e) { + content.innerHTML = `

Standorte konnten nicht geladen werden.

`; + return; + } + + if (!locations.length) { + content.innerHTML = UI.emptyState({ icon: UI.icon('map-pin'), title: 'Keine Standorte', text: 'Füge GPS-Koordinaten zu Tagebucheinträgen hinzu.' }); + return; + } + + // Leaflet laden + if (!window.L) { + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = `/js/leaflet.js?v=${APP_VER}`; + s.onload = res; s.onerror = rej; + document.head.appendChild(s); + }); + } + + const mapEl = content.querySelector('#diary-map-view'); + if (!mapEl) return; + + // Bounds aus allen Punkten berechnen + const lats = locations.map(l => l.gps_lat); + const lons = locations.map(l => l.gps_lon); + const bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]]; + + const map = L.map(mapEl, { zoomControl: true, attributionControl: false }); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); + + // Marker für jeden Eintrag + locations.forEach(loc => { + const hasPhoto = !!loc.cover_url; + const dateStr = loc.datum ? new Date(loc.datum+'T12:00').toLocaleDateString('de-DE', {day:'numeric',month:'short',year:'numeric'}) : ''; + const title = UI.escape(loc.titel || loc.location_name || dateStr); + + const icon = L.divIcon({ + html: hasPhoto + ? `
+ +
` + : `
+ +
`, + iconSize: hasPhoto ? [44, 44] : [32, 32], + iconAnchor: hasPhoto ? [22, 22] : [16, 16], + className: '', + }); + + const marker = L.marker([loc.gps_lat, loc.gps_lon], { icon }); + marker.bindPopup(` +
+ ${hasPhoto ? `` : ''} +
${title}
+
${dateStr}
+ ${loc.media_count > 1 ? `
📷 ${loc.media_count} Medien
` : ''} +
→ Öffnen
+
`, { maxWidth: 200 }); + + marker.on('popupopen', () => { + setTimeout(() => { + document.querySelectorAll('.diary-map-popup').forEach(el => { + el.addEventListener('click', async () => { + map.closePopup(); + const id = parseInt(el.dataset.id); + // Eintrag aus _entries holen oder per API nachladen + if (!_entries.find(e => e.id === id)) { + try { + const fresh = await API.diary.get(_appState.activeDog.id, id); + _entries.unshift(fresh); + } catch { return; } + } + _openDetail(id); + }); + }); + }, 50); + }); + + marker.addTo(map); + }); + + // Karte auf alle Punkte zoomen + if (locations.length === 1) { + map.setView([locations[0].gps_lat, locations[0].gps_lon], 14); + } else { + map.fitBounds(bounds, { padding: [40, 40] }); + } + + setTimeout(() => map.invalidateSize(), 100); + } + + function _renderMediaGrid(content) { + const allMedia = []; + _entries.forEach(e => { + _allMedia(e).forEach(m => { + if (m.media_type === 'image') allMedia.push({ url: m.url, entryId: e.id, datum: e.datum }); + }); + }); + if (allMedia.length === 0) { + content.innerHTML = UI.emptyState({ icon: UI.icon('images'), title: 'Keine Medien', text: 'Füge Fotos oder Videos zu Tagebucheinträgen hinzu.' }); + return; + } + content.innerHTML = `
${ + allMedia.map(m => ` +
+ +
`).join('') + }
`; + content.querySelectorAll('.diary-mosaic-item').forEach(el => { + el.addEventListener('click', () => _openDetail(parseInt(el.dataset.entryId))); + }); + } + + async function _renderCalendarView(content) { + const dog = _appState.activeDog; + if (!dog) return; + + const today = new Date().toISOString().slice(0, 10); + const now = new Date(); + let year = now.getFullYear(), month = now.getMonth(); + + content.innerHTML = `
+ + Kalender wird geladen…
`; + + let byDate = {}; + try { + const all = await API.diary.calendar(dog.id); + if (!Array.isArray(all)) throw new Error('Keine Array-Antwort: ' + typeof all); + all.forEach(e => { if (e && e.datum) byDate[e.datum] = e; }); + } catch (err) { + content.innerHTML = `

Kalender-Fehler: ${UI.escape(String(err))}

`; + return; + } + // Debug: Anzahl geladener Einträge kurz anzeigen + const _total = Object.keys(byDate).length; + if (_total === 0) { + content.innerHTML = `

Keine Einträge mit Datum gefunden.

`; + return; + } + + const render = () => { + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const monthName = new Date(year, month).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + const monthPrefix = `${year}-${String(month+1).padStart(2,'0')}`; + const monthCount = Object.keys(byDate).filter(k => k.startsWith(monthPrefix)).length; + const DAYS = ['Mo','Di','Mi','Do','Fr','Sa','So']; + const offset = firstDay === 0 ? 6 : firstDay - 1; + const cells = []; + for (let i = 0; i < offset; i++) cells.push('
'); + for (let d = 1; d <= daysInMonth; d++) { + const key = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; + const entry = byDate[key]; + cells.push(`
+ ${entry?.cover_url ? `` : ''} + ${d} +
`); + } + + // Nächsten/vorherigen Monat MIT Einträgen finden für Sprungbuttons + const allMonths = [...new Set(Object.keys(byDate).map(k => k.slice(0,7)))].sort(); + const curM = monthPrefix; + const prevM = allMonths.filter(m => m < curM).at(-1) || null; + const nextM = allMonths.filter(m => m > curM)[0] || null; + + content.innerHTML = ` +
+
+
+ ${prevM ? `` : '
'} + +
+ ${monthName}${monthCount > 0 ? `${monthCount}` : ''} +
+ + ${nextM ? `` : '
'} +
+
+
${DAYS.map(n=>`
${n}
`).join('')}
+
${cells.join('')}
+
`; + }; + + // Event-Delegation auf content — überlebt innerHTML-Erneuerungen + content.addEventListener('click', async e => { + const navBtn = e.target.closest('.cal-nav-btn'); + if (navBtn) { + // Sprungbutton: direkt zu Monat mit Einträgen + if (navBtn.dataset.jump) { + const [y, m] = navBtn.dataset.jump.split('-').map(Number); + year = y; month = m - 1; + render(); + return; + } + const dir = parseInt(navBtn.dataset.dir); + month += dir; + if (month < 0) { month = 11; year--; } + if (month > 11) { month = 0; year++; } + render(); + return; + } + const cell = e.target.closest('.diary-cal-cell.has-entry'); + if (cell) { + const id = parseInt(cell.dataset.entryId); + if (!id) return; + if (!_entries.find(en => en.id === id)) { + try { + const fresh = await API.diary.get(dog.id, id); + _entries.unshift(fresh); + } catch { return; } + } + _openDetail(id); + } + }); + + render(); + } + async function _loadMore() { _offset += LIMIT; const btn = _container.querySelector('#diary-btn-more'); @@ -327,7 +730,7 @@ window.Page_diary = (() => { } // ---------------------------------------------------------- - // LISTE RENDERN — Timeline gruppiert nach Monat + // LISTE RENDERN — Timeline gruppiert nach Monat (Day One Style) // ---------------------------------------------------------- function _renderList() { const listEl = _container.querySelector('#diary-list'); @@ -357,7 +760,9 @@ window.Page_diary = (() => { groups.forEach((items, key) => { const monthLabel = key === 'unbekannt' ? 'Datum unbekannt' : _formatMonth(key); html += `
${monthLabel}
`; + html += `
`; html += items.map(e => _entryCard(e)).join(''); + html += `
`; }); listEl.innerHTML = html; @@ -367,64 +772,101 @@ window.Page_diary = (() => { const id = parseInt(card.dataset.entryId); card.addEventListener('click', () => _openDetail(id)); }); + listEl.querySelectorAll('[data-action="open-note"]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const id = parseInt(btn.dataset.entryId); + const label = btn.dataset.label || ''; + const location = btn.dataset.location || null; + _openNoteModal('diary', id, label, location || null); + }); + }); } // ---------------------------------------------------------- - // ENTRY CARD + // ENTRY CARD — Day One Row-Style // ---------------------------------------------------------- function _entryCard(e) { - const typ = TYPEN[e.typ] || TYPEN.eintrag; const isMile = e.is_milestone || e.typ === 'meilenstein'; - const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; - const tags = (e.tags || []).filter(t => t && t.trim()).slice(0, 4); const allMedia = _allMedia(e); const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null; const mediaCount = allMedia.length; - const photo = coverMedia - ? `
- ${coverMedia.media_type === 'video' - ? `
` - : `Foto`} - ${mediaCount > 1 ? `${mediaCount}` : ''} -
` + + // Thumbnail rechts (72×72) + let photoHtml = ''; + if (coverMedia) { + if (coverMedia.media_type === 'video') { + photoHtml = `
+
+ +
+ ${mediaCount > 1 ? `${mediaCount}` : ''} +
`; + } else { + photoHtml = `
+ Foto + ${mediaCount > 1 ? `${mediaCount}` : ''} +
`; + } + } + + // Vorschautext (max 2 Zeilen via CSS clamp) + const cleanedText = e.text ? _cleanText(e.text) : ''; + const textPreview = cleanedText + ? `

${UI.escape(cleanedText.slice(0, 160))}

` : ''; - const tagsHtml = tags.length - ? `
${tags.map(t => `${t}`).join('')}
` + // Meta-Zeile: Zeit · 📍 Ort · Wetter + const metaParts = []; + if (e.created_at) { + const t = _timeStr(e.created_at); + if (t) metaParts.push(`${t}`); + } + if (e.location_name) { + metaParts.push(`${UI.escape(e.location_name)}`); + } + if (e.weather_json) { + try { + const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json; + const temp = w?.temperature_2m ?? w?.temp_c; + if (temp != null) { + metaParts.push(`${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°`); + } + } catch (_) {} + } + const metaRow = metaParts.length + ? `
${metaParts.join(' · ')}
` : ''; - const locationHtml = e.location_name - ? `

${UI.escape(e.location_name)}

` + // Meilenstein-Icon auf der Datum-Spalte + const mileIcon = isMile + ? `` : ''; - const textPreview = e.text - ? `

${UI.escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}

` - : ''; - - // Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein') - const milestoneBadge = e.is_milestone - ? `
${UI.icon('calendar-dots')} Meilenstein
` - : ''; - - // Mehrere Hunde: kleine Avatare in der Karte - const dogAvatars = _dogAvatarRow(e.dog_ids || []); + // Titel oder Typ als Fallback + const typObj = TYPEN[e.typ] || TYPEN.eintrag; + const titleText = e.titel + ? `
${UI.escape(e.titel)}
` + : `
${typObj.label}
`; + const noteLabel = e.titel || e.datum || ''; return `
- ${photo} -
- ${milestoneBadge} -
- ${typ.icon} ${typ.label} - ${dateStr} -
- ${e.titel ? `
${UI.escape(e.titel)}
` : ''} - ${locationHtml} - ${textPreview} - ${tagsHtml} - ${dogAvatars} + +
+ ${_weekday(e.datum)} + ${_dayNum(e.datum)} + ${mileIcon}
+ +
+ ${titleText} + ${textPreview} + ${metaRow} +
+ + ${photoHtml}
`; } @@ -457,28 +899,38 @@ window.Page_diary = (() => { const render = () => { lb.innerHTML = ` -
- - ${photos.length > 1 - ? `${idx+1} / ${photos.length}` - : ''} -
-
+
+ +
+ + + ${photos.length > 1 ? `${idx+1} / ${photos.length}` : ''} + ${photos.length > 1 ? ` - - - ` : ''} +
` : '
'}
`; lb.querySelector('#lb-close').addEventListener('click', () => lb.remove()); @@ -490,7 +942,7 @@ window.Page_diary = (() => { } // ---------------------------------------------------------- - // DETAIL-ANSICHT — Fullscreen (DayOne-Stil) + // DETAIL-ANSICHT — Fullscreen Day One-Stil // ---------------------------------------------------------- function _openDetail(entryId) { const entry = _entries.find(e => e.id === entryId); @@ -502,6 +954,7 @@ window.Page_diary = (() => { const allMedia = _allMedia(entry); const dogIds = entry.dog_ids || [entry.dog_id]; + // Hunde-Chips (bei mehreren Hunden) const dogsHtml = dogIds.length > 1 ? `
${dogIds.map(did => { @@ -513,96 +966,217 @@ window.Page_diary = (() => { }).join('')}
` : ''; + // Detail-View im Content-Bereich rendern (gleiche Breite wie Liste/Kalender) + const content = _container.querySelector('#diary-view-content'); + if (!content) return; + // FAB + "Weitere laden" ausblenden während Detail offen + _container.querySelector('#diary-fab')?.style.setProperty('display','none'); + const _lm = _container.querySelector('#diary-load-more'); + if (_lm) _lm.style.display = 'none'; + const view = document.createElement('div'); view.id = 'diary-detail-view'; - view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column'; + view.className = 'diary-detail-view-inner'; - // Medien-HTML für Hero-Bereich - const _heroHtml = (m) => m.media_type === 'pdf' - ? ` { + if (m.media_type === 'pdf') { + return ` - + gap:12px;padding:40px 16px;background:var(--c-surface-2);text-decoration:none;color:var(--c-text);height:100%"> + ${UI.escape(m.url.split('/').pop())} PDF öffnen - ` - : m.media_type === 'video' - ? `` - : ``; + `; + } + if (m.media_type === 'video') { + return ``; + } + return ``; + }; - let mediaSection = ''; - if (allMedia.length === 1) { - mediaSection = `
${_heroHtml(allMedia[0])}
`; - } else if (allMedia.length > 1) { - mediaSection = ` -
${_heroHtml(allMedia[0])}
-
- ${allMedia.map((m, i) => ` -
- ${m.media_type === 'pdf' - ? `
` - : m.media_type === 'video' - ? `` - : ``} -
`).join('')} -
`; + // Hero-Sektion + let heroSection = ''; + if (allMedia.length >= 1) { + const thumbsHtml = allMedia.length > 1 + ? `
+ ${allMedia.map((m, i) => ` +
+ ${m.media_type === 'pdf' + ? `
` + : m.media_type === 'video' + ? `` + : ``} +
`).join('')} +
` + : ''; + heroSection = ` +
${_heroHtml(allMedia[0])}
+ ${thumbsHtml}`; } + // Datum lang formatiert: "Montag, 21. April 2026" + const datumLang = _formatDateLong(entry.datum); + + // Meta-Bar: Zeit · Ort · Wetter (korrekte Feldnamen aus Open-Meteo) + const metaItems = []; + if (entry.created_at) { + const t = _timeStr(entry.created_at); + if (t) metaItems.push(`${t}`); + } + if (entry.location_name) { + const locContent = entry.gps_lat + ? `${UI.escape(entry.location_name)}` + : UI.escape(entry.location_name); + metaItems.push(`${locContent}`); + } + if (entry.weather_json) { + try { + const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json; + const temp = w?.temperature_2m ?? w?.temp_c; + if (w && temp != null) { + const feels = w.apparent_temperature ?? w.feels_like_c; + const wind = w.wind_speed_10m ?? w.wind_kmh; + const parts = [ + `${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`, + feels != null ? `gefühlt ${Math.round(feels)}°` : null, + wind != null ? `💨 ${Math.round(wind)} km/h` : null, + w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null, + ].filter(Boolean).join(' · '); + metaItems.push(`${parts}`); + } + } catch (_) {} + } + const metaBar = metaItems.length + ? `
${metaItems.join('')}
` + : ''; + + // Tags + const tagsSection = tags.length + ? `
+ ${tags.map(t => `${t}`).join('')} +
` + : ''; + + // POI-Liste (wie Routen) + const POI_ICON = { restaurant:'fork-knife', cafe:'coffee', bar:'beer-bottle', + pharmacy:'first-aid', hospital:'first-aid', park:'tree', playground:'soccer-ball', + supermarket:'shopping-cart', shop:'shopping-bag', attraction:'star', + viewpoint:'binoculars', museum:'buildings', hotel:'bed', church:'church', + school:'graduation-cap', default:'map-pin' }; + let poiListHtml = ''; + if (entry.poi_json) { + try { + const pois = typeof entry.poi_json === 'string' ? JSON.parse(entry.poi_json) : entry.poi_json; + if (pois?.length) { + poiListHtml = `
+
+ + In der Nähe +
+ ${pois.map(p => { + const icon = POI_ICON[p.type] || POI_ICON.default; + const dist = p.distance_m < 1000 ? `${p.distance_m} m` : `${(p.distance_m/1000).toFixed(1)} km`; + return `
+ + ${UI.escape(p.name)} + ${dist} +
`; + }).join('')} +
`; + } + } catch (_) {} + } + + // Karte (wenn GPS vorhanden) — Platzhalter-Div, wird nach DOM-Insert befüllt + const hasGps = entry.gps_lat != null && entry.gps_lon != null; + const mapSection = hasGps + ? `
+
+ ${poiListHtml} +
` + : ''; + view.innerHTML = ` -
- + ${datumLang} ${!_appState?.activeDog?.is_guest - ? `` - : '
'} + ? `` + : '
'}
- ${mediaSection} + ${heroSection} -
- ${isMile ? `
${UI.icon('trophy')} Meilenstein
` : ''} - ${entry.titel ? `

${UI.escape(entry.titel)}

` : ''} -
- ${typ.icon} ${typ.label} - - ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} - +
+
+ ${isMile ? `
Meilenstein
` : ''} + ${entry.titel ? `

${UI.escape(entry.titel)}

` : ''} + ${metaBar} + ${dogsHtml} + ${entry.text + ? `

${UI.escape(_cleanText(entry.text))}

` + : ''} + ${metaItems.length || entry.text ? '
' : ''} +
+ ${typ.icon} ${typ.label} +
+ ${tagsSection}
- ${entry.location_name ? ` -
- - ${entry.gps_lat - ? `${UI.escape(entry.location_name)}` - : UI.escape(entry.location_name)} -
` : ''} - ${dogsHtml} - ${entry.text - ? `

${UI.escape(_cleanText(entry.text))}

` - : ''} - ${tags.length - ? `
- ${tags.map(t => `${t}`).join('')} -
` - : ''} + ${mapSection}
`; - document.body.appendChild(view); + // In Content-Bereich einsetzen statt als Fixed-Overlay + content.innerHTML = ''; + content.appendChild(view); + UI.scrollTop(); // Seite nach oben scrollen - // Zurück - view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove()); + // Leaflet-Karte initialisieren (wenn GPS vorhanden) + if (hasGps) { + setTimeout(async () => { + const mapEl = view.querySelector('#diary-dv-map'); + if (!mapEl) return; + if (!window.L) { + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = `/js/leaflet.js?v=${APP_VER}`; + s.onload = res; s.onerror = rej; + document.head.appendChild(s); + }); + } + const map = L.map(mapEl, { zoomControl: true, attributionControl: false }); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); + const svgIcon = L.divIcon({ + html: ` + + + `, + iconSize: [32, 32], iconAnchor: [16, 16], className: '', + }); + L.marker([entry.gps_lat, entry.gps_lon], { icon: svgIcon }).addTo(map); + map.setView([entry.gps_lat, entry.gps_lon], 15); + map.invalidateSize(); + }, 150); + } + + // Zurück → vorherige Ansicht wiederherstellen + const _closeDetail = () => { + _container.querySelector('#diary-fab')?.style.removeProperty('display'); + _renderCurrentView(); + _renderStatsBar(); + }; + view.querySelector('#diary-dv-back').addEventListener('click', _closeDetail); // Bearbeiten view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => { - view.remove(); + _container.querySelector('#diary-fab')?.style.removeProperty('display'); if (entry.location_name !== undefined || entry.gps_lat !== undefined) { _showForm(entry); } else { @@ -617,35 +1191,30 @@ window.Page_diary = (() => { // Foto in Hero → Lightbox const photoUrls = allMedia.filter(m => m.media_type !== 'video').map(m => m.url); - view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', e => { - const clickedIdx = parseInt(e.target.dataset.idx ?? 0); - const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1; + view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', ev => { + const clickedIdx = parseInt(ev.target.dataset.idx ?? 0); + const photoIdx = allMedia.slice(0, clickedIdx + 1).filter(m => m.media_type !== 'video').length - 1; _showLightbox(photoUrls, Math.max(0, photoIdx)); }); // Thumbnail-Strip → Hero wechseln - view.querySelector('#diary-dv-thumbs')?.addEventListener('click', e => { - const thumb = e.target.closest('[data-idx]'); + view.querySelector('#diary-dv-thumbs')?.addEventListener('click', ev => { + const thumb = ev.target.closest('[data-idx]'); if (!thumb) return; const i = parseInt(thumb.dataset.idx); const hero = view.querySelector('#diary-dv-hero'); if (hero) hero.innerHTML = _heroHtml(allMedia[i]); // Foto in neuem Hero → Lightbox - hero?.querySelector('img')?.addEventListener('click', ev => { - const clickedIdx = parseInt(ev.target.dataset.idx ?? i); - const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1; + hero?.querySelector('img')?.addEventListener('click', ev2 => { + const clickedIdx = parseInt(ev2.target.dataset.idx ?? i); + const photoIdx = allMedia.slice(0, clickedIdx + 1).filter(m => m.media_type !== 'video').length - 1; _showLightbox(photoUrls, Math.max(0, photoIdx)); }); // Aktive Markierung - view.querySelectorAll('#diary-dv-thumbs [data-idx]').forEach((t, j) => { - t.style.borderColor = j === i ? 'var(--c-primary)' : 'transparent'; + view.querySelectorAll('#diary-dv-thumbs .diary-detail-thumb').forEach((t, j) => { + t.classList.toggle('diary-detail-thumb--active', j === i); }); }); - - // Cover-Button: Stern-Icon auf aktiven Medien (optional, nur für eingeloggte) - if (!_appState?.activeDog?.is_guest && allMedia.some(m => m.id)) { - // Cover-Verwaltung über Edit-Dialog - } } // ---------------------------------------------------------- @@ -1122,12 +1691,16 @@ window.Page_diary = (() => { async function _uploadNewFiles(entryId) { let failCount = 0; const uploaded = []; + let exifGps = null; for (const file of _newFiles) { try { const formData = new FormData(); formData.append('file', file); const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData); uploaded.push(m); + if (m.exif_lat != null && m.exif_lon != null && !exifGps) { + exifGps = { lat: m.exif_lat, lon: m.exif_lon }; + } } catch { failCount++; } @@ -1135,17 +1708,23 @@ window.Page_diary = (() => { if (failCount > 0) { UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`); } - return uploaded; + if (exifGps) { + UI.toast.success(`📍 Standort aus Foto-GPS übernommen`); + } + return { uploaded, exifGps }; } if (isEdit) { const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload); if (_newFiles.length > 0) { - const uploaded = await _uploadNewFiles(entry.id); + const { uploaded, exifGps } = await _uploadNewFiles(entry.id); if (!updated.media_items) updated.media_items = []; updated.media_items.push(...uploaded); + if (exifGps && !updated.gps_lat) { + updated.gps_lat = exifGps.lat; + updated.gps_lon = exifGps.lon; + } } else { - // media_items aus dem aktuellen entry-State übernehmen (evtl. gelöscht via X-Button) updated.media_items = entry.media_items || updated.media_items || []; updated.media_url = entry.media_url ?? updated.media_url; } @@ -1154,8 +1733,12 @@ window.Page_diary = (() => { } else { const created = await API.diary.create(_appState.activeDog.id, payload); if (_newFiles.length > 0) { - const uploaded = await _uploadNewFiles(created.id); + const { uploaded, exifGps } = await _uploadNewFiles(created.id); created.media_items = uploaded; + if (exifGps && !created.gps_lat) { + created.gps_lat = exifGps.lat; + created.gps_lon = exifGps.lon; + } } _entries.unshift(created); UI.toast.success('Eintrag erstellt.'); @@ -1163,6 +1746,7 @@ window.Page_diary = (() => { UI.modal.close(); _renderList(); + _loadStats().then(() => _renderStatsBar()); }); }); } @@ -1176,6 +1760,7 @@ window.Page_diary = (() => { _entries = _entries.filter(e => e.id !== entryId); UI.modal.close(); _renderList(); + _loadStats().then(() => _renderStatsBar()); UI.toast.success('Eintrag gelöscht.'); } catch (err) { UI.toast.error(err.message || 'Fehler beim Löschen.'); @@ -1315,6 +1900,87 @@ window.Page_diary = (() => { }); } + // ---------------------------------------------------------- + // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + document.getElementById('by-note-modal')?.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'by-note-modal'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; + + overlay.innerHTML = ` +
+
+
+
Notiz
+
${UI.escape(parentLabel)}
+
+ +
+
+
+ +
+
+
+ + +
+
+ `; + + document.body.appendChild(overlay); + + const textarea = document.getElementById('by-note-text'); + const saveBtn = document.getElementById('by-note-save'); + const cancelBtn = document.getElementById('by-note-cancel'); + const closeBtn = document.getElementById('by-note-close'); + + let existingNoteId = null; + + try { + const existing = await API.notes.get(parentType, parentId); + if (existing?.id) { + existingNoteId = existing.id; + textarea.value = existing.text || ''; + } + } catch (_) { /* keine Notiz vorhanden — ok */ } + + setTimeout(() => textarea.focus(), 100); + + const _close = () => overlay.remove(); + closeBtn.addEventListener('click', _close); + cancelBtn.addEventListener('click', _close); + overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); + + document.getElementById('by-note-form').addEventListener('submit', async e => { + e.preventDefault(); + const text = textarea.value.trim(); + UI.setLoading(saveBtn, true); + try { + const payload = { text, parent_label: parentLabel, location_name: locationName }; + if (existingNoteId) { + await API.notes.update(existingNoteId, payload); + } else { + await API.notes.create(parentType, parentId, payload); + } + UI.toast.success('Notiz gespeichert.'); + _close(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Speichern.'); + UI.setLoading(saveBtn, false); + } + }); + } + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index e9ed5b4..1bd36f9 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -28,6 +28,10 @@ window.Page_dog_profile = (() => { if (e.target.closest('#profile-goto-login')) { App.navigate('settings'); } + if (e.target.closest('[data-action="goto-weight"]')) { + App.navigate('health', true, { tab: 'gewicht', openForm: true }); + return; + } }); await _render(); @@ -119,7 +123,7 @@ window.Page_dog_profile = (() => {
` : ''} ${dog.gewicht_kg ? ` -
+
Gewicht
${dog.gewicht_kg} kg
diff --git a/backend/static/js/pages/erste-hilfe.js b/backend/static/js/pages/erste-hilfe.js index 5e35644..5e9d262 100644 --- a/backend/static/js/pages/erste-hilfe.js +++ b/backend/static/js/pages/erste-hilfe.js @@ -186,7 +186,7 @@ window.Page_erste_hilfe = (() => { Bedeutung - Rosa, feuchtNormal + Rosa, feuchtNormal Blass / weißSchock, Blutverlust, Vergiftung Blau / grauSauerstoffmangel — NOTFALL GelbLeberprobleme @@ -239,6 +239,7 @@ window.Page_erste_hilfe = (() => { `; _bindTabs(); _bindAccordions(); + _bindNoteButtons(); _activateTab('lebensgefahr'); } @@ -340,6 +341,10 @@ window.Page_erste_hilfe = (() => { ${massnahmenHtml} ${warnHtml} ${e.extra || ''} +
+ +
`; @@ -382,6 +387,102 @@ window.Page_erste_hilfe = (() => { }); } + function _bindNoteButtons() { + _container.querySelectorAll('.eh-note-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const katId = btn.dataset.katId; + const titel = btn.dataset.titel; + const kat = KATEGORIEN.find(k => k.id === katId); + const label = kat ? `${kat.label} — ${titel}` : titel; + _openNoteModal('erste_hilfe', katId, label, null); + }); + }); + } + + // ---------------------------------------------------------------- + // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + document.getElementById('by-note-modal')?.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'by-note-modal'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; + + const _esc = s => s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; + + overlay.innerHTML = ` +
+
+
+
Notiz
+
${_esc(parentLabel)}
+
+ +
+
+
+ +
+
+
+ + +
+
+ `; + + document.body.appendChild(overlay); + + const textarea = document.getElementById('by-note-text'); + const saveBtn = document.getElementById('by-note-save'); + const cancelBtn = document.getElementById('by-note-cancel'); + const closeBtn = document.getElementById('by-note-close'); + + let existingNoteId = null; + + try { + const existing = await API.notes.get(parentType, parentId); + if (existing?.id) { + existingNoteId = existing.id; + textarea.value = existing.text || ''; + } + } catch (_) { /* keine Notiz vorhanden — ok */ } + + setTimeout(() => textarea.focus(), 100); + + const _close = () => overlay.remove(); + closeBtn.addEventListener('click', _close); + cancelBtn.addEventListener('click', _close); + overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); + + document.getElementById('by-note-form').addEventListener('submit', async e => { + e.preventDefault(); + const text = textarea.value.trim(); + UI.setLoading(saveBtn, true); + try { + const payload = { text, parent_label: parentLabel, location_name: locationName }; + if (existingNoteId) { + await API.notes.update(existingNoteId, payload); + } else { + await API.notes.create(parentType, parentId, payload); + } + UI.toast.success('Notiz gespeichert.'); + _close(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Speichern.'); + UI.setLoading(saveBtn, false); + } + }); + } + // ---------------------------------------------------------------- // PUBLIC // ---------------------------------------------------------------- diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js index 2d1dedf..7308eb2 100644 --- a/backend/static/js/pages/events.js +++ b/backend/static/js/pages/events.js @@ -226,7 +226,14 @@ window.Page_events = (() => {
` : ''} - ${isOwn ? `` : ''} +
+ ${isOwn ? `` : ''} + ${_state.user ? `` : ''} +
`; } @@ -268,7 +275,7 @@ window.Page_events = (() => { const popup = `
${UI.escape(ev.titel)}
- ${datum}
+ ${datum}
${ev.ort_name ? `📍 ${UI.escape(ev.ort_name)}
` : ''} ${ev.beschreibung ? `${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}
` : ''} { return; } + // Notiz-Button + const noteBtn = e.target.closest('.ev-note-btn'); + if (noteBtn) { + e.stopPropagation(); + _openNoteModal( + 'event', + parseInt(noteBtn.dataset.evNoteId), + noteBtn.dataset.evNoteLabel, + noteBtn.dataset.evNoteOrt || null + ); + return; + } + // Karten-Klick → Detail const card = e.target.closest('[data-ev-id]'); if (card) { _showDetail(parseInt(card.dataset.evId)); } } + // ---------------------------------------------------------- + // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + let existingNote = null; + try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} + + const ovl = document.createElement('div'); + ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; + ovl.innerHTML = ` +
+
+ + Notiz — ${UI.escape(parentLabel)} + +
+ +
+ + +
+
+ `; + document.body.appendChild(ovl); + + const close = () => ovl.remove(); + ovl.querySelector('#ev-note-close')?.addEventListener('click', close); + ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close); + ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); + + ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => { + const text = ovl.querySelector('#ev-note-text')?.value?.trim() || ''; + const payload = { text, parent_label: parentLabel, location_name: locationName || null }; + try { + if (existingNote?.id) { + await API.notes.update(existingNote.id, payload); + } else { + await API.notes.create(parentType, String(parentId), payload); + } + UI.toast.success('Notiz gespeichert.'); + close(); + } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } + }); + } + return { init, refresh, openNew, _openDetail: _showDetail }; })(); diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index 7e25d83..514f18a 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -44,7 +44,7 @@ window.Page_friends = (() => {
-
diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 5ffc78a..dced726 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -34,10 +34,17 @@ window.Page_health = (() => { // ---------------------------------------------------------- // LIFECYCLE // ---------------------------------------------------------- - async function init(container, appState) { + async function init(container, appState, params) { _container = container; _appState = appState; + if (params?.tab) { + const valid = _getTabs().some(t => t.key === params.tab); + if (valid) _activeTab = params.tab; + } await _render(); + if (params?.openForm) { + setTimeout(() => _showForm(null, _activeTab), 200); + } } async function refresh() { @@ -400,6 +407,10 @@ window.Page_health = (() => { Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + `; @@ -445,6 +456,10 @@ window.Page_health = (() => { ` : ''} ${e.diagnose ? `
Diagnose: ${_esc(e.diagnose)}
` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + `; @@ -493,6 +508,10 @@ window.Page_health = (() => { ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + `).join(''); @@ -726,6 +745,10 @@ window.Page_health = (() => { ${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + `; }).join(''); @@ -760,6 +783,10 @@ window.Page_health = (() => { ${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + `).join('')} @@ -797,6 +824,10 @@ window.Page_health = (() => { ${e.reaktion ? `
Reaktion: ${_esc(e.reaktion)}
` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + `).join(''); @@ -837,6 +868,10 @@ window.Page_health = (() => { ${count > 1 ? ` · ${count} Dateien` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} + ${count ? `
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf' @@ -874,6 +909,14 @@ window.Page_health = (() => { const entry = (_data[_activeTab] || []).find(e => e.id === id); if (entry) card.addEventListener('click', () => _openDetail(entry)); }); + content.querySelectorAll('[data-action="open-note"]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const id = parseInt(btn.dataset.entryId); + const label = btn.dataset.label || ''; + _openNoteModal('health', id, label, null); + }); + }); // Praxis öffnen content.querySelectorAll('[data-action="open-praxis"]').forEach(el => { el.addEventListener('click', () => { @@ -1166,6 +1209,9 @@ window.Page_health = (() => { if (!_data[t]) _data[t] = []; _data[t].unshift(saved); UI.toast.success('Eintrag erstellt.'); + if (t === 'gewicht' && saved.wert) { + _appState.activeDog.gewicht_kg = saved.wert; + } } // Multi-File-Upload @@ -1830,6 +1876,89 @@ window.Page_health = (() => { .replace(/"/g, '"'); } + // ---------------------------------------------------------- + // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + // Vorhandenes Modal entfernen falls noch offen + document.getElementById('by-note-modal')?.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'by-note-modal'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; + + overlay.innerHTML = ` +
+
+
+
Notiz
+
${_esc(parentLabel)}
+
+ +
+
+
+ +
+
+
+ + +
+
+ `; + + document.body.appendChild(overlay); + + const textarea = document.getElementById('by-note-text'); + const saveBtn = document.getElementById('by-note-save'); + const cancelBtn = document.getElementById('by-note-cancel'); + const closeBtn = document.getElementById('by-note-close'); + + let existingNoteId = null; + + // Vorhandene Notiz laden + try { + const existing = await API.notes.get(parentType, parentId); + if (existing?.id) { + existingNoteId = existing.id; + textarea.value = existing.text || ''; + } + } catch (_) { /* keine Notiz vorhanden — ok */ } + + setTimeout(() => textarea.focus(), 100); + + const _close = () => overlay.remove(); + closeBtn.addEventListener('click', _close); + cancelBtn.addEventListener('click', _close); + overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); + + document.getElementById('by-note-form').addEventListener('submit', async e => { + e.preventDefault(); + const text = textarea.value.trim(); + UI.setLoading(saveBtn, true); + try { + const payload = { text, parent_label: parentLabel, location_name: locationName }; + if (existingNoteId) { + await API.notes.update(existingNoteId, payload); + } else { + await API.notes.create(parentType, parentId, payload); + } + UI.toast.success('Notiz gespeichert.'); + _close(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Speichern.'); + UI.setLoading(saveBtn, false); + } + }); + } + return { init, refresh, openNew, onDogChange }; })(); diff --git a/backend/static/js/pages/notes.js b/backend/static/js/pages/notes.js new file mode 100644 index 0000000..97247df --- /dev/null +++ b/backend/static/js/pages/notes.js @@ -0,0 +1,693 @@ +/* ============================================================ + BAN YARO — Notizblock + Seiten-Modul: Alle Notizen mit Filter, Suche, Sortierung und KI-Analyse. + ============================================================ */ + +window.Page_notes = (() => { + + let _container = null; + let _appState = null; + let _notes = []; + + // Aktueller Filter-/Such-Zustand + let _filterType = ''; // '' = alle + let _sortMode = 'newest'; // newest | type | location + let _searchQ = ''; + let _searchTimer = null; + + // KI-Panel + let _kiOpen = false; + let _kiLoading = false; + let _kiSuggestions = null; + let _kiError = null; + + // ---------------------------------------------------------- + // Rubrik-Konfiguration + // ---------------------------------------------------------- + const RUBRIKEN = [ + { type: '', label: 'Alle', color: 'var(--c-text-muted)', icon: 'note' }, + { type: 'health', label: 'Gesundheit', color: '#e74c3c', icon: 'heart' }, + { type: 'diary', label: 'Tagebuch', color: '#C4843A', icon: 'book-open' }, + { type: 'training_session', label: 'Training', color: '#27ae60', icon: 'target' }, + { type: 'route', label: 'Routen', color: '#2980b9', icon: 'path' }, + { type: 'event', label: 'Events', color: '#8e44ad', icon: 'calendar' }, + { type: 'walk', label: 'Gassi-Treffen',color: '#f39c12', icon: 'paw-print' }, + { type: 'sitting', label: 'Sitting', color: '#16a085', icon: 'house-line' }, + { type: 'erste_hilfe', label: 'Erste Hilfe', color: '#c0392b', icon: 'first-aid' }, + ]; + + function _rubrik(type) { + return RUBRIKEN.find(r => r.type === type) || { type, label: type, color: 'var(--c-text-muted)', icon: 'note' }; + } + + // ---------------------------------------------------------- + // Hilfsfunktionen + // ---------------------------------------------------------- + function _esc(s) { + if (!s) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function _formatTime(isoStr) { + if (!isoStr) return ''; + try { + const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z')); + return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + } catch (_) { return ''; } + } + + function _dateGroup(isoStr) { + if (!isoStr) return 'Älteres'; + try { + const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z')); + const now = new Date(); + const diffDays = (now - d) / 86400000; + if (diffDays < 1 && d.getDate() === now.getDate()) return 'Heute'; + if (diffDays < 7) return 'Diese Woche'; + return 'Älteres'; + } catch (_) { return 'Älteres'; } + } + + function _truncate(str, max = 150) { + if (!str) return ''; + return str.length > max ? str.slice(0, max) + '…' : str; + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _load() { + const params = {}; + if (_filterType) params.parent_type = _filterType; + if (_sortMode !== 'newest') params.sort = _sortMode; + if (_searchQ) params.q = _searchQ; + return await API.notes.getAll(params); + } + + // ---------------------------------------------------------- + // Filter/Sortierung anwenden (client-seitig falls API alles zurückgibt) + // ---------------------------------------------------------- + function _applySort(list) { + const copy = [...list]; + if (_sortMode === 'newest') { + copy.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)); + } else if (_sortMode === 'type') { + copy.sort((a, b) => (a.parent_type || '').localeCompare(b.parent_type || '', 'de')); + } else if (_sortMode === 'location') { + copy.sort((a, b) => (a.location_name || '').localeCompare(b.location_name || '', 'de')); + } + return copy; + } + + // ---------------------------------------------------------- + // Rendern + // ---------------------------------------------------------- + function _render() { + const kiEnabled = _appState?.user?.notes_ki_enabled !== 0; + const sorted = _applySort(_notes); + + // Gruppen aufbauen + const groups = { 'Heute': [], 'Diese Woche': [], 'Älteres': [] }; + sorted.forEach(n => { + const g = _dateGroup(n.updated_at || n.created_at); + groups[g].push(n); + }); + + const groupHtml = Object.entries(groups) + .filter(([, items]) => items.length > 0) + .map(([label, items]) => ` +
+
${_esc(label)}
+ ${items.map(_noteCard).join('')} +
+ `).join(''); + + _container.innerHTML = ` +
+ + +
+

Notizblock

+ ${_notes.length} Notiz${_notes.length !== 1 ? 'en' : ''} +
+ + + ${kiEnabled ? _kiPanelHtml() : ''} + + +
+ ${RUBRIKEN.map(r => ` + + `).join('')} +
+ + +
+
+ + +
+
+ + + +
+
+ + +
+ ${sorted.length === 0 + ? UI.emptyState({ icon: 'note', title: 'Keine Notizen', text: 'Füge Notizen zu Trainingseinheiten oder anderen Einträgen hinzu.' }) + : groupHtml + } +
+ +
+ + + `; + + _bindEvents(); + } + + // ---------------------------------------------------------- + // KI-Panel HTML + // ---------------------------------------------------------- + function _kiPanelHtml() { + return ` +
+
+
+ + Muster-Analyse +
+ +
+ ${_kiOpen ? ` +
+ + ${_kiError ? `
${_esc(_kiError)}
` : ''} + ${_kiSuggestions ? ` +
+
    + ${_kiSuggestions.map(s => `
  • ${_esc(s)}
  • `).join('')} +
+
+ ` : ''} +
+ ` : ''} +
+ `; + } + + // ---------------------------------------------------------- + // Notiz-Karte HTML + // ---------------------------------------------------------- + function _noteCard(note) { + const rb = _rubrik(note.parent_type); + const meta = note.meta_json || {}; + + const microBadges = []; + if (meta.erfolgsquote) microBadges.push('🐾'.repeat(meta.erfolgsquote)); + if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung); + if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung); + + const hasLocation = !!note.location_name; + + return ` +
+ +
+ + + ${_esc(rb.label)} + + ${note.parent_label + ? `${_esc(note.parent_label)}` + : '' + } +
+ + +
+
+ + +

${_esc(_truncate(note.text))}

+ + + ${microBadges.length ? ` +
+ ${microBadges.map(b => `${_esc(b)}`).join('')} +
+ ` : ''} + + +
+ + ${_esc(_formatTime(note.updated_at || note.created_at))} + ${hasLocation ? ` ${_esc(note.location_name)}` : ''} +
+
+ `; + } + + // ---------------------------------------------------------- + // Event-Binding + // ---------------------------------------------------------- + function _bindEvents() { + + // Filter-Chips + _container.querySelectorAll('.notes-chip').forEach(btn => { + btn.addEventListener('click', () => { + _filterType = btn.dataset.type; + _reload(); + }); + }); + + // Sortierung + _container.querySelectorAll('.notes-sort-btn').forEach(btn => { + btn.addEventListener('click', () => { + _sortMode = btn.dataset.sort; + _render(); // nur neu rendern, keine API-Last + }); + }); + + // Suche (debounced) + const searchInput = _container.querySelector('#notes-search'); + if (searchInput) { + searchInput.addEventListener('input', () => { + clearTimeout(_searchTimer); + _searchTimer = setTimeout(() => { + _searchQ = searchInput.value.trim(); + _reload(); + }, 300); + }); + } + + // KI-Toggle + const kiToggle = _container.querySelector('#notes-ki-toggle'); + if (kiToggle) { + kiToggle.addEventListener('click', () => { + _kiOpen = !_kiOpen; + _render(); + }); + } + + // KI-Analyse-Button + const kiBtn = _container.querySelector('#notes-ki-analyse-btn'); + if (kiBtn) { + kiBtn.addEventListener('click', async () => { + _kiLoading = true; + _kiError = null; + _kiSuggestions = null; + _render(); + try { + const res = await API.notes.analyse(); + if (res && Array.isArray(res.suggestions)) { + _kiSuggestions = res.suggestions; + } else if (res && res.text) { + _kiSuggestions = res.text.split('\n').filter(Boolean); + } else { + _kiSuggestions = ['Keine Vorschläge verfügbar.']; + } + } catch (err) { + _kiError = err?.message || 'KI-Analyse nicht verfügbar.'; + } finally { + _kiLoading = false; + _render(); + } + }); + } + + // Edit-Buttons + _container.querySelectorAll('.notes-edit-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const note = _notes.find(n => n.id === parseInt(btn.dataset.id, 10)); + if (note) _openEditModal(note); + }); + }); + + // Delete-Buttons + _container.querySelectorAll('.notes-delete-btn').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const noteId = parseInt(btn.dataset.id, 10); + if (!window.confirm('Notiz wirklich löschen?')) return; + try { + await API.notes.delete(noteId); + _notes = _notes.filter(n => n.id !== noteId); + _render(); + UI.toast.success('Notiz gelöscht.'); + } catch (_) { + UI.toast.error('Löschen fehlgeschlagen.'); + } + }); + }); + } + + // ---------------------------------------------------------- + // Laden + Re-Render + // ---------------------------------------------------------- + async function _reload() { + _container.querySelector('.notes-list')?.classList.add('loading'); + try { + _notes = await _load(); + } catch (_) { + _notes = []; + } + _render(); + } + + // ---------------------------------------------------------- + // Edit-Modal (Bottom-Sheet Stil) + // ---------------------------------------------------------- + function _openEditModal(note) { + const meta = note.meta_json || {}; + const rb = _rubrik(note.parent_type); + + const modalId = 'notes-edit-modal'; + document.getElementById(modalId)?.remove(); + + const overlay = document.createElement('div'); + overlay.id = modalId; + overlay.style.cssText = ` + position:fixed;inset:0;z-index:9999; + display:flex;align-items:flex-end;justify-content:center; + background:rgba(0,0,0,0.45); + `; + + overlay.innerHTML = ` +
+ + +
+ + +
+ + ${_esc(rb.label)} + +

+ Notiz bearbeiten +

+
+ +
+ + +
+ + +
+ + ${note.parent_type === 'training_session' ? ` + +
+ +
+ ${[1,2,3,4,5].map(n => ` + + `).join('')} +
+
+ + +
+ +
+ ${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => ` + + `).join('')} +
+
+ + +
+ +
+ ${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => ` + + `).join('')} +
+
+ ` : ''} + +
+ + +
+ + + +
+
+ `; + + document.body.appendChild(overlay); + + let selErfolgsquote = meta.erfolgsquote || null; + let selUmgebung = meta.umgebung || null; + let selStimmung = meta.hund_stimmung || null; + + function _toggleBtn(group, val, getter, setter) { + overlay.querySelectorAll(`.notes-${group}`).forEach(b => { + const match = (group === 'pfote') + ? parseInt(b.dataset.val, 10) === val + : b.dataset.val === val; + b.style.background = match ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; + b.style.borderColor = match ? 'var(--c-primary)' : 'var(--c-border)'; + }); + } + + overlay.querySelectorAll('.notes-pfote').forEach(btn => { + btn.addEventListener('click', () => { + const v = parseInt(btn.dataset.val, 10); + selErfolgsquote = selErfolgsquote === v ? null : v; + _toggleBtn('pfote', selErfolgsquote, null, null); + }); + }); + + overlay.querySelectorAll('.notes-umgebung').forEach(btn => { + btn.addEventListener('click', () => { + selUmgebung = selUmgebung === btn.dataset.val ? null : btn.dataset.val; + _toggleBtn('umgebung', selUmgebung, null, null); + }); + }); + + overlay.querySelectorAll('.notes-stimmung').forEach(btn => { + btn.addEventListener('click', () => { + selStimmung = selStimmung === btn.dataset.val ? null : btn.dataset.val; + _toggleBtn('stimmung', selStimmung, null, null); + }); + }); + + function _close() { overlay.remove(); } + overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); + overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close); + + // Speichern + overlay.querySelector('#notes-edit-save').addEventListener('click', async () => { + const text = overlay.querySelector('#notes-edit-text').value.trim(); + if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; } + + const saveBtn = overlay.querySelector('#notes-edit-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Speichern…'; + + const metaObj = {}; + if (selErfolgsquote) metaObj.erfolgsquote = selErfolgsquote; + if (selUmgebung) metaObj.umgebung = selUmgebung; + if (selStimmung) metaObj.hund_stimmung = selStimmung; + + try { + const updated = await API.notes.update(note.id, { + text, + meta_json: Object.keys(metaObj).length > 0 ? metaObj : null, + }); + const idx = _notes.findIndex(n => n.id === note.id); + if (idx >= 0) _notes[idx] = updated; + _render(); + _close(); + UI.toast.success('Notiz aktualisiert.'); + } catch (_) { + saveBtn.disabled = false; + saveBtn.textContent = 'Speichern'; + UI.toast.error('Speichern fehlgeschlagen.'); + } + }); + + // Löschen + overlay.querySelector('#notes-edit-delete').addEventListener('click', async () => { + if (!window.confirm('Notiz wirklich löschen?')) return; + try { + await API.notes.delete(note.id); + _notes = _notes.filter(n => n.id !== note.id); + _render(); + _close(); + UI.toast.success('Notiz gelöscht.'); + } catch (_) { + UI.toast.error('Löschen fehlgeschlagen.'); + } + }); + } + + // ---------------------------------------------------------- + // INIT / REFRESH + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + + // Zustand zurücksetzen + _filterType = ''; + _sortMode = 'newest'; + _searchQ = ''; + _kiOpen = false; + _kiLoading = false; + _kiSuggestions = null; + _kiError = null; + _notes = []; + + _container.innerHTML = UI.skeleton(3); + + try { + _notes = await _load(); + } catch (_) { + _notes = []; + } + + _render(); + } + + async function refresh() { + if (!_container) return; + _container.innerHTML = UI.skeleton(3); + try { + _notes = await _load(); + } catch (_) { + _notes = []; + } + _render(); + } + + return { init, refresh }; + +})(); diff --git a/backend/static/js/pages/onboarding.js b/backend/static/js/pages/onboarding.js index 6cfda8b..d2beee7 100644 --- a/backend/static/js/pages/onboarding.js +++ b/backend/static/js/pages/onboarding.js @@ -126,7 +126,7 @@ window.Page_onboarding = (() => {
-
@@ -167,7 +167,7 @@ window.Page_onboarding = (() => {
-
@@ -262,7 +262,7 @@ window.Page_onboarding = (() => {
-
diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 8641995..f3296b1 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1805,6 +1805,7 @@ window.Page_routes = (() => { ${_actionBtn('rd-gpx', 'download-simple', 'GPX')} ${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')} ${_actionBtn('rd-navi', 'map-pin', 'Navi')} + ${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
${ownerRow} @@ -1920,6 +1921,12 @@ window.Page_routes = (() => { } catch (err) { UI.toast.error(err.message); } }); + // Notiz-Button + document.getElementById('rd-note')?.addEventListener('click', () => { + const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route'); + _openNoteModal('route', route.id, label, null); + }); + // Mini-Map let _detailMap = null; setTimeout(() => { @@ -2504,6 +2511,59 @@ window.Page_routes = (() => { }); } + // ---------------------------------------------------------- + // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + let existingNote = null; + try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} + + const ovl = document.createElement('div'); + ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; + ovl.innerHTML = ` +
+
+ + Notiz — ${UI.escape(parentLabel)} + +
+ +
+ + +
+
+ `; + document.body.appendChild(ovl); + + const close = () => ovl.remove(); + ovl.querySelector('#rk-note-close')?.addEventListener('click', close); + ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close); + ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); + + ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => { + const text = ovl.querySelector('#rk-note-text')?.value?.trim() || ''; + const payload = { text, parent_label: parentLabel, location_name: locationName || null }; + try { + if (existingNote?.id) { + await API.notes.update(existingNote.id, payload); + } else { + await API.notes.create(parentType, String(parentId), payload); + } + UI.toast.success('Notiz gespeichert.'); + close(); + } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } + }); + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index d4a45fd..0383496 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -266,6 +266,30 @@ window.Page_settings = (() => { + +
+ +
+
KI-Notiz-Assistent
+
+ Erkennt Muster in deinen Notizen und macht Vorschläge +
+
+ +
+ @@ -635,6 +659,25 @@ window.Page_settings = (() => { : 'Pocket-Modus deaktiviert.'); }); + document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => { + const enabled = e.target.checked; + const track = document.getElementById('toggle-notes-ki-track'); + const thumb = document.getElementById('toggle-notes-ki-thumb'); + if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = enabled ? '22px' : '2px'; + try { + await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 }); + _appState.user.notes_ki_enabled = enabled ? 1 : 0; + UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.'); + } catch (err) { + UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); + // Revert UI + e.target.checked = !enabled; + if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = !enabled ? '22px' : '2px'; + } + }); + _loadReferral(); } diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js index c825fa0..4df9fda 100644 --- a/backend/static/js/pages/sitting.js +++ b/backend/static/js/pages/sitting.js @@ -136,6 +136,12 @@ window.Page_sitting = (() => {
${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}
max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}
+ ${_state.user ? `` : ''}
`; @@ -704,6 +710,19 @@ window.Page_sitting = (() => { return; } + // Notiz-Button auf Sitter-Karte + const noteBtn = e.target.closest('.sit-note-btn'); + if (noteBtn) { + e.stopPropagation(); + _openNoteModal( + 'sitting', + parseInt(noteBtn.dataset.sitNoteId), + noteBtn.dataset.sitNoteLabel, + null + ); + return; + } + // Sitter-Karte const sitterCard = e.target.closest('[data-sit-id]'); if (sitterCard && !e.target.closest('button')) { @@ -741,6 +760,59 @@ window.Page_sitting = (() => { } catch (e) { UI.toast(e.message, 'error'); } } + // ---------------------------------------------------------- + // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + let existingNote = null; + try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} + + const ovl = document.createElement('div'); + ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; + ovl.innerHTML = ` +
+
+ + Notiz — ${UI.escape(parentLabel)} + +
+ +
+ + +
+
+ `; + document.body.appendChild(ovl); + + const close = () => ovl.remove(); + ovl.querySelector('#sit-note-close')?.addEventListener('click', close); + ovl.querySelector('#sit-note-cancel')?.addEventListener('click', close); + ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); + + ovl.querySelector('#sit-note-save')?.addEventListener('click', async () => { + const text = ovl.querySelector('#sit-note-text')?.value?.trim() || ''; + const payload = { text, parent_label: parentLabel, location_name: locationName || null }; + try { + if (existingNote?.id) { + await API.notes.update(existingNote.id, payload); + } else { + await API.notes.create(parentType, String(parentId), payload); + } + UI.toast.success('Notiz gespeichert.'); + close(); + } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } + }); + } + return { init, refresh }; })(); diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index 0450d1f..0d8e48a 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -895,6 +895,7 @@ window.Page_uebungen = (() => { _bindAccordions(); _bindStatusButtons(); _bindLogButtons(); + _bindNotizButtons(); if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback(); } @@ -965,6 +966,19 @@ window.Page_uebungen = (() => { Einheit ${_sessionStatsChip(_activeTab, u.name)} + + `).join('')} + + + + +
+ +
+ ${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji, val]) => ` + + `).join('')} +
+
+ + +
+ +
+ ${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji, val]) => ` + + `).join('')} +
+
+ + + + +
+ ${existingNote ? ` + + ` : ''} + + +
+ + `; + + document.body.appendChild(overlay); + + // State + let selectedErfolgsquote = currentErfolgsquote; + let selectedUmgebung = currentUmgebung; + let selectedStimmung = currentStimmung; + + // Pfoten-Buttons + overlay.querySelectorAll('.ueb-notiz-pfote').forEach(btn => { + btn.addEventListener('click', () => { + const val = parseInt(btn.dataset.val, 10); + selectedErfolgsquote = selectedErfolgsquote === val ? null : val; + overlay.querySelectorAll('.ueb-notiz-pfote').forEach(b => { + const active = parseInt(b.dataset.val, 10) === selectedErfolgsquote; + b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; + b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; + }); + }); + }); + + // Umgebung-Buttons + overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(btn => { + btn.addEventListener('click', () => { + selectedUmgebung = selectedUmgebung === btn.dataset.val ? null : btn.dataset.val; + overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(b => { + const active = b.dataset.val === selectedUmgebung; + b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; + b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; + }); + }); + }); + + // Stimmung-Buttons + overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(btn => { + btn.addEventListener('click', () => { + selectedStimmung = selectedStimmung === btn.dataset.val ? null : btn.dataset.val; + overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(b => { + const active = b.dataset.val === selectedStimmung; + b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; + b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; + }); + }); + }); + + function _closeNotizModal() { + overlay.remove(); + } + + overlay.addEventListener('click', e => { if (e.target === overlay) _closeNotizModal(); }); + overlay.querySelector('#ueb-notiz-cancel').addEventListener('click', _closeNotizModal); + + // Speichern + overlay.querySelector('#ueb-notiz-save').addEventListener('click', async () => { + const text = overlay.querySelector('#ueb-notiz-text').value.trim(); + if (!text) { UI.toast.warning('Bitte gib eine Notiz ein.'); return; } + + const saveBtn = overlay.querySelector('#ueb-notiz-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Speichern…'; + + const meta = {}; + if (selectedErfolgsquote) meta.erfolgsquote = selectedErfolgsquote; + if (selectedUmgebung) meta.umgebung = selectedUmgebung; + if (selectedStimmung) meta.hund_stimmung = selectedStimmung; + + const payload = { + text, + meta_json: Object.keys(meta).length > 0 ? meta : null, + }; + + try { + if (existingNote) { + await API.notes.update(existingNote.id, payload); + } else { + await API.notes.create('training_session', exerciseId, payload); + } + _closeNotizModal(); + UI.toast.success('Notiz gespeichert.'); + // Notiz-Button leicht hervorheben + if (triggerBtn) { + triggerBtn.style.borderColor = 'var(--c-primary)'; + triggerBtn.style.color = 'var(--c-primary)'; + } + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = existingNote ? 'Aktualisieren' : 'Speichern'; + UI.toast.error('Speichern fehlgeschlagen.'); + } + }); + + // Löschen + overlay.querySelector('#ueb-notiz-delete')?.addEventListener('click', async () => { + if (!existingNote) return; + try { + await API.notes.delete(existingNote.id); + _closeNotizModal(); + UI.toast.success('Notiz gelöscht.'); + if (triggerBtn) { + triggerBtn.style.borderColor = ''; + triggerBtn.style.color = ''; + } + } catch (_) { + UI.toast.error('Löschen fehlgeschlagen.'); + } + }); + } + function _openLogModal(tab, exerciseName, initialReps) { // Build the modal HTML const modalId = 'ueb-log-modal'; diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 34155a5..02a6bb6 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -192,6 +192,18 @@ window.Page_walks = (() => { el.querySelectorAll('.walks-card').forEach(card => { card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id))); }); + + el.querySelectorAll('.wk-note-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + _openNoteModal( + 'walk', + parseInt(btn.dataset.wkNoteId), + btn.dataset.wkNoteLabel, + btn.dataset.wkNoteOrt || null + ); + }); + }); } function _walkCardHTML(w) { @@ -217,7 +229,16 @@ window.Page_walks = (() => { ${isOwn ? 'Mein Treffen' : ''} -
+
+
+ ${_appState.user ? `` : ''} +
`; } @@ -964,6 +985,59 @@ window.Page_walks = (() => { }); } + // ---------------------------------------------------------- + // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) + // ---------------------------------------------------------- + async function _openNoteModal(parentType, parentId, parentLabel, locationName) { + let existingNote = null; + try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} + + const ovl = document.createElement('div'); + ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; + ovl.innerHTML = ` +
+
+ + Notiz — ${UI.escape(parentLabel)} + +
+ +
+ + +
+
+ `; + document.body.appendChild(ovl); + + const close = () => ovl.remove(); + ovl.querySelector('#wk-note-close')?.addEventListener('click', close); + ovl.querySelector('#wk-note-cancel')?.addEventListener('click', close); + ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); + + ovl.querySelector('#wk-note-save')?.addEventListener('click', async () => { + const text = ovl.querySelector('#wk-note-text')?.value?.trim() || ''; + const payload = { text, parent_label: parentLabel, location_name: locationName || null }; + try { + if (existingNote?.id) { + await API.notes.update(existingNote.id, payload); + } else { + await API.notes.create(parentType, String(parentId), payload); + } + UI.toast.success('Notiz gespeichert.'); + close(); + } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } + }); + } + return { init, refresh, onDogChange, openNew, openDetail: _openDetail }; })(); diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index 68f28b7..388172c 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -53,7 +53,7 @@ window.Page_welcome = (() => { style="width:36px;height:36px;border-radius:var(--radius-md); background:var(--c-primary);flex-shrink:0; display:flex;align-items:center;justify-content:center"> -
-
diff --git a/backend/static/sw.js b/backend/static/sw.js index 93bc520..836ef26 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,15 +3,15 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v370'; +const CACHE_VERSION = 'by-v405'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ - '/css/design-system.css', - '/css/layout.css', - '/css/components.css', + '/css/design-system.css?v=382', + '/css/layout.css?v=382', + '/css/components.css?v=382', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js', @@ -82,8 +82,8 @@ self.addEventListener('fetch', event => { return; } - // Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer) - if (url.pathname.startsWith('/js/pages/')) { + // CSS + Seiten-Module: immer Network-First — damit iOS nie veraltete CSS cached + if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')) { event.respondWith( fetch(event.request) .then(response => { diff --git a/promotion/banyaro_hundeschulen.html b/promotion/banyaro_hundeschulen.html new file mode 100644 index 0000000..6c637cf --- /dev/null +++ b/promotion/banyaro_hundeschulen.html @@ -0,0 +1,410 @@ + + + + +Banyaro für Hundeschulen + + + +
+ + +
+
+
Banyaro
+
Die App, die Ihre Trainingsstunden verlängert
+
+
+
Für Hundeschulen & Trainer
+
banyaro.app
+
+
+ + +
Damit das, was Sie in der Stunde aufbauen, zu Hause nicht verloren geht.
+
+ Die größte Hürde im Hundetraining ist nicht der Hund — es ist die Lücke zwischen den Stunden. + Banyaro gibt Ihren Kunden eine strukturierte Trainings-Begleitung an die Hand: Übungsbibliothek, + Trainingspläne, Tagebuch zum Festhalten von Fortschritten und ein Wiki mit fundiertem Wissen. + Das macht Ihre Arbeit nachhaltiger — und Ihre Kunden zufriedener. +
+ + +
+
+
Zielgruppe
+
    +
  • Welpen- und Junghundebesitzer
  • +
  • Ersthundehalter mit hohem Lernbedarf
  • +
  • Engagierte Halter ab ca. 30 Jahren
  • +
  • Wiedereinsteiger nach längerer Pause
  • +
  • Sportlich oder thematisch ambitionierte Teams
  • +
+
+
+
Nutzen für Ihre Schule
+
    +
  • Kunden trainieren strukturierter zwischen den Stunden
  • +
  • Sichtbare Fortschritte = höhere Kundenbindung
  • +
  • Weniger „Was war das nochmal?“-Rückfragen
  • +
  • Eigenes Profil = Sichtbarkeit für Neukunden
  • +
  • Kostenfreier Mehrwert, den Sie weitergeben können
  • +
+
+
+ + +
+
Was Banyaro bietet
+
+
+
Übungsbibliothek
+
Strukturierte Anleitungen mit Schritten und Hilfestellungen
+
+
+
Trainingspläne
+
Welpe, Grunderziehung, Leinenführung, Rückruf u. v. m.
+
+
+
Trainings-Tagebuch
+
Halter dokumentieren Fortschritt & Stimmung
+
+
+
Rassen-Wiki
+
Über 900 Rassen mit Charakter- & Erziehungs-Profilen
+
+
+
Erste Hilfe
+
Notfall-Anleitungen — wichtig für Outdoor-Training
+
+
+
Gassi & Treffen
+
Routen, Treffpunkte, hundefreundliche Orte
+
+
+
+ + +
+
+
Werden Sie Banyaro-Partnerschule
+
    +
  • Eigenes Schul-Profil in der App — auf der Karte verlinkt
  • +
  • QR-Code-Aufsteller für Theorieraum & Anmeldung (kostenfrei)
  • +
  • Empfehlung an Ihre Kunden über unseren Halter-Newsletter
  • +
  • Affiliate-Beteiligung sobald die App monetarisiert wird
  • +
+
+
+
[QR-Code
banyaro.app]
+
Direkt ausprobieren
+
+
+ + +
+
In 5 Minuten ausprobieren
+
+ Banyaro ist eine Web-App — keine Installation aus dem App-Store nötig. + Öffnen Sie einfach banyaro.app im Browser, legen Sie einen Testaccount an + und schauen Sie sich besonders die Übungsbibliothek und Trainingspläne aus Sicht Ihrer Kunden an. + Bei Interesse an einer Partnerschaft genügt eine kurze E-Mail — ich melde mich persönlich zurück. +
+
+ + + + +
+ + diff --git a/promotion/banyaro_tieraerzte.html b/promotion/banyaro_tieraerzte.html new file mode 100644 index 0000000..e7be51b --- /dev/null +++ b/promotion/banyaro_tieraerzte.html @@ -0,0 +1,407 @@ + + + + +Banyaro für Tierärzte + + + +
+ + +
+
+
Banyaro
+
Die App für ein gesundes Hundeleben
+
+
+
Für Tierärztinnen & Tierärzte
+
banyaro.app
+
+
+ + +
Eine digitale Gesundheitsakte, die Ihre Patientenbesitzer wirklich nutzen.
+
+ Banyaro unterstützt Hundehalter dabei, Symptome, Medikationen, Impftermine und Gewichtsverläufe konsequent zu dokumentieren — und versorgt sie zusätzlich mit fundiertem Wissen zu Erster Hilfe, Giftködern, Rassen und Ernährung. Für Sie als Tierarzt bedeutet das: besser informierte Halter, genauere Anamnesen, weniger Rückfragen. +
+ + +
+
+
Zielgruppe
+
    +
  • Engagierte Hundehalter ab ca. 30 Jahren
  • +
  • Familien mit Ersthund
  • +
  • Halter chronisch oder mehrfach erkrankter Tiere
  • +
  • Sicherheitsbewusste Gassigeher (Giftköder-Karte)
  • +
  • Züchter & Mehrhundehaushalte
  • +
+
+
+
Nutzen für Ihre Praxis
+
    +
  • Strukturiertere Anamnesen durch geführte Halter
  • +
  • Vollständige Impf- und Medikationshistorie
  • +
  • Frühere Vorstellung dank Symptom-Tracking
  • +
  • Entlastung bei Standardfragen (Erste Hilfe, Gift)
  • +
  • Mehrwert für Ihre Klienten — kostenlos
  • +
+
+
+ + +
+
Was Banyaro bietet
+
+
+
Gesundheitsakte
+
Impfungen, Medikamente, Befunde, Gewicht — exportierbar
+
+
+
Erste-Hilfe-Bereich
+
11 Notfall-Anleitungen, fachlich geprüft
+
+
+
Giftköder-Karte
+
Community-gepflegte Warnkarte mit Echtzeit-Meldungen
+
+
+
Rassen-Wiki
+
Über 900 Rassen mit Gesundheits-Profilen
+
+
+
Trainingsbereich
+
Übungsbibliothek & strukturierte Trainingspläne
+
+
+
Gassi-Funktionen
+
Routen, Treffpunkte, hundefreundliche Orte
+
+
+
+ + +
+
+
Werden Sie Banyaro-Partnerpraxis
+
    +
  • Eigenes Praxisprofil in der App — verlinkt aus der Karte
  • +
  • QR-Code-Aufsteller fürs Wartezimmer (kostenfrei zugesendet)
  • +
  • Erwähnung in unserem Halter-Newsletter als Partnerpraxis
  • +
  • Affiliate-Beteiligung sobald die App monetarisiert wird
  • +
+
+
+
[QR-Code
banyaro.app]
+
Direkt ausprobieren
+
+
+ + +
+
In 5 Minuten ausprobieren
+
+ Banyaro ist eine Web-App — keine Installation aus dem App-Store nötig. + Einfach im Browser banyaro.app öffnen, einen Testaccount anlegen und die Funktionen aus + Sicht Ihrer Patientenbesitzer erkunden. Bei Interesse an einer Partnerschaft genügt eine kurze E-Mail — + ich melde mich persönlich zurück. +
+
+ + + + +
+ +