From f0b5e6e89b259d99e9be52943a62c82a3db3eac7 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 11:12:54 +0200 Subject: [PATCH 01/27] =?UTF-8?q?Fix:=20Desktop-Welten-Labels=20=E2=80=94?= =?UTF-8?q?=20gr=C3=B6=C3=9Fer=20(13px),=20heller,=20Text-Schatten,=20Pill?= =?UTF-8?q?-Hintergrund=20aktiv,=20SW=20by-v652?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 26 ++++++++++++++++++++++---- backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 27cf0d9..3302268 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7600,10 +7600,28 @@ svg.empty-state-icon { .wlabel.active { opacity: 1; } @media (min-width: 768px) { - #world-labels { gap: 48px; font-size: 11px; } - .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; } - .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); } - .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); } + #world-labels { + gap: 40px; + top: calc(env(safe-area-inset-top, 0px) + 18px); + } + .wlabel { + font-size: 13px; + letter-spacing: 0.18em; + opacity: 0.55; + padding: 6px 14px; + border-radius: 20px; + text-shadow: 0 1px 6px rgba(0,0,0,0.7); + transition: opacity 0.18s, background 0.18s; + } + .wlabel:hover { + opacity: 0.85; + background: rgba(255, 255, 255, 0.12); + } + .wlabel.active { + opacity: 1; + background: rgba(255, 255, 255, 0.18); + text-shadow: 0 1px 8px rgba(0,0,0,0.5); + } } /* Settings-Button */ diff --git a/backend/static/index.html b/backend/static/index.html index cb75a8f..6086cbb 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a248787..8d5a013 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '652'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index e786916..67907d2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v651'; +const CACHE_VERSION = 'by-v652'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 1fdba57365d53da8d3e0439f648476164f49f26b Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 19:50:04 +0200 Subject: [PATCH 02/27] =?UTF-8?q?Feature:=20UX-Fixes=20=E2=80=94=20Zahnrad?= =?UTF-8?q?=20weg,=20POI-Kombi-Typen,=20exp-fab-Position,=20Welten-Config?= =?UTF-8?q?=20in=20DB=20(SW=20by-v653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worlds-settings Zahnrad komplett entfernt (war auf Mobile sichtbar, auf Desktop schon hidden) - exp-fab: bottom jetzt calc(--nav-bottom-height + --safe-bottom + --space-2) — kein Overlap mit worlds-back auf iPhone - Karte POI: neue Typen bank, bank_kotbeutel, bank_kotbeutel_abfall, kotbeutel_abfall (Backend + Frontend) - Welten-Chip-Config: GET/PUT /profile/world-config, Spalte users.world_config TEXT (Migration), Sync bei Init + Speichern --- backend/database.py | 5 +++++ backend/routes/osm.py | 14 +++++++++----- backend/routes/profile.py | 25 +++++++++++++++++++++++++ backend/static/css/components.css | 2 +- backend/static/index.html | 11 ++++------- backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 20 ++++++++++++-------- backend/static/js/worlds.js | 30 ++++++++++++++++++++++++++---- backend/static/sw.js | 2 +- 9 files changed, 84 insertions(+), 27 deletions(-) diff --git a/backend/database.py b/backend/database.py index eeb1add..078e7ae 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1923,6 +1923,11 @@ def _migrate(conn_factory): ) """) + # Welten-Chip-Konfiguration pro User + existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'world_config' not in existing_u: + conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( diff --git a/backend/routes/osm.py b/backend/routes/osm.py index e08742b..1f73f76 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -279,11 +279,15 @@ class UserPoiIn(BaseModel): ALLOWED_TYPES = { 'waste_basket', 'drinking_water', 'dog_park', - 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius) - 'kotbeutel', # Kotbeutelspender - 'gefahr', # Allgemeine Gefahr / Hinweis - 'parkplatz', # Hundefreundlicher Parkplatz - 'treffpunkt', # Treffpunkt für Hundehalter + 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius) + 'kotbeutel', # Kotbeutelspender + 'kotbeutel_abfall', # Kotbeutelspender + Mülleimer Kombi + 'bank', # Sitzbank + 'bank_kotbeutel', # Sitzbank + Kotbeutelspender + 'bank_kotbeutel_abfall', # Sitzbank + Kotbeutelspender + Mülleimer + 'gefahr', # Allgemeine Gefahr / Hinweis + 'parkplatz', # Hundefreundlicher Parkplatz + 'treffpunkt', # Treffpunkt für Hundehalter 'sonstiges', } diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 08f403a..5c07b99 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -113,3 +113,28 @@ async def upload_avatar( ) return {"avatar_url": avatar_url} + + +# ---------------------------------------------------------- +# GET /profile/world-config — Welten-Chip-Konfiguration laden +# PUT /profile/world-config — Welten-Chip-Konfiguration speichern +# ---------------------------------------------------------- +import json as _json + +@router.get('/profile/world-config') +async def get_world_config(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone() + cfg = row['world_config'] if row and row['world_config'] else None + return {"config": _json.loads(cfg) if cfg else None} + + +class WorldConfigIn(BaseModel): + config: dict + +@router.put('/profile/world-config') +async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)): + with db() as conn: + conn.execute("UPDATE users SET world_config=? WHERE id=?", + (_json.dumps(body.config), user['id'])) + return {"status": "ok"} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 3302268..564b011 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6954,7 +6954,7 @@ svg.empty-state-icon { /* FAB */ .exp-fab { position: fixed; - bottom: calc(var(--nav-height, 64px) + var(--space-4)); + bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2)); right: var(--space-4); z-index: 100; width: 52px; diff --git a/backend/static/index.html b/backend/static/index.html index 6086cbb..e052694 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -539,9 +539,6 @@ HUND WELT -
@@ -565,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 8d5a013..99cabf8 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '652'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '653'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index ded9a7d..594951e 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -839,14 +839,18 @@ window.Page_map = (() => { } const PIN_TYPES = [ - { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben - { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, - { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, - { type: 'drinking_water', icon: '', label: 'Wasserstelle', color: '#0EA5E9' }, - { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, - { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, - { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, - { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, + { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626' }, + { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, + { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, + { type: 'kotbeutel_abfall', icon: '', label: 'Kotbeutel + Mülleimer', color: '#5a8a6a' }, + { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' }, + { type: 'bank_kotbeutel', icon: '', label: 'Bank + Kotbeutel', color: '#7a6030' }, + { type: 'bank_kotbeutel_abfall', icon: '', label: 'Bank + Kotbeutel + Mülleimer', color: '#4a5a2a' }, + { type: 'drinking_water', icon: '', label: 'Wasserstelle', color: '#0EA5E9' }, + { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, + { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, + { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, + { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, ]; function _confirmPlacement(latlng) { diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 0fc7723..4c925f2 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -49,6 +49,8 @@ window.Worlds = (() => { _setupButtons(); _goTo(_cur, false); show(); + // Config aus DB laden (async, dann neu rendern wenn nötig) + await _loadConfigFromServer(); // Welten parallel rendern _renderJetzt(); _renderHund(); @@ -159,7 +161,6 @@ window.Worlds = (() => { function _setupButtons() { document.getElementById('worlds-fab')?.addEventListener('click', _openFab); - document.getElementById('worlds-settings')?.addEventListener('click', () => navigateTo('settings')); document.getElementById('worlds-back')?.addEventListener('click', () => show()); document.querySelectorAll('.wdot').forEach((dot, i) => { dot.style.pointerEvents = 'auto'; @@ -296,12 +297,33 @@ window.Worlds = (() => { welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'], }; - function _getConfig() { - try { return JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; } - catch { return _DEFAULT_CONFIG; } + // _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default + let _cfgCache = null; + + async function _loadConfigFromServer() { + try { + const res = await API.get('/profile/world-config'); + if (res?.config) { + _cfgCache = res.config; + try { localStorage.setItem('world_chips', JSON.stringify(_cfgCache)); } catch {} + return; + } + } catch {} + // Fallback: localStorage + try { _cfgCache = JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; } + catch { _cfgCache = _DEFAULT_CONFIG; } } + + function _getConfig() { + return _cfgCache || _DEFAULT_CONFIG; + } + function _saveConfig(cfg) { + _cfgCache = cfg; try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {} + if (_state?.user) { + API.put('/profile/world-config', { config: cfg }).catch(() => {}); + } } function _chipMeta(page) { return _ALL_CHIPS.find(c => c.page === page) || null; diff --git a/backend/static/sw.js b/backend/static/sw.js index 67907d2..71594d7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v652'; +const CACHE_VERSION = 'by-v653'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 9103c7950fb58a26e83af959c2f2523d139dadbf Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 20:10:01 +0200 Subject: [PATCH 03/27] =?UTF-8?q?Feature:=20Generische=20Seiten-Hilfe=20(U?= =?UTF-8?q?I.pageInfo),=20POI=20Multi-Select,=20Tagesspr=C3=BCche-DB=20(SW?= =?UTF-8?q?=20by-v654)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI.pageInfo(): generische Hilfe-Funktion — erstes Öffnen zeigt Info-Banner, danach ? Button oben rechts; CSS-Klassen pinfo-* - Übungen-Seite nutzt UI.pageInfo() als erstes Beispiel - Karte POI: Mehrfachauswahl (außer Giftköder), Kombi-Typen entfernt, type als comma-separated im Backend - daily_quotes Tabelle in DB (346 Einträge via import_quotes.py importiert) - GET /widget/quote — deterministischer Tagesspruch (wechselt täglich) --- backend/database.py | 12 + backend/routes/osm.py | 18 +- backend/routes/widget.py | 24 +- backend/static/css/components.css | 98 ++++++++ backend/static/index.html | 8 +- backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 49 ++-- backend/static/js/pages/uebungen.js | 12 + backend/static/js/ui.js | 64 ++++- backend/static/sw.js | 2 +- scripts/dog_quotes.json | 348 ++++++++++++++++++++++++++++ scripts/import_quotes.py | 24 ++ 12 files changed, 623 insertions(+), 38 deletions(-) create mode 100644 scripts/dog_quotes.json create mode 100644 scripts/import_quotes.py diff --git a/backend/database.py b/backend/database.py index 078e7ae..f20a924 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1928,6 +1928,18 @@ def _migrate(conn_factory): if 'world_config' not in existing_u: conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT") + # Tagessprüche-Pool + conn.executescript(""" + CREATE TABLE IF NOT EXISTS daily_quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + autor TEXT, + kategorie TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie); + """) + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 1f73f76..de4cb45 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -279,21 +279,19 @@ class UserPoiIn(BaseModel): ALLOWED_TYPES = { 'waste_basket', 'drinking_water', 'dog_park', - 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius) - 'kotbeutel', # Kotbeutelspender - 'kotbeutel_abfall', # Kotbeutelspender + Mülleimer Kombi - 'bank', # Sitzbank - 'bank_kotbeutel', # Sitzbank + Kotbeutelspender - 'bank_kotbeutel_abfall', # Sitzbank + Kotbeutelspender + Mülleimer - 'gefahr', # Allgemeine Gefahr / Hinweis - 'parkplatz', # Hundefreundlicher Parkplatz - 'treffpunkt', # Treffpunkt für Hundehalter + 'giftkoeder', # Giftköder (exklusiv, kein Kombi) + 'kotbeutel', # Kotbeutelspender + 'bank', # Sitzbank + 'gefahr', # Allgemeine Gefahr / Hinweis + 'parkplatz', # Hundefreundlicher Parkplatz + 'treffpunkt', # Treffpunkt für Hundehalter 'sonstiges', } @router.post('/user-poi') async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)): - if body.type not in ALLOWED_TYPES: + types = [t.strip() for t in body.type.split(',') if t.strip()] + if not types or any(t not in ALLOWED_TYPES for t in types): raise HTTPException(400, 'Ungültiger Typ') with db() as conn: row = conn.execute(""" diff --git a/backend/routes/widget.py b/backend/routes/widget.py index f5cc940..4af2473 100644 --- a/backend/routes/widget.py +++ b/backend/routes/widget.py @@ -1,13 +1,33 @@ -"""BAN YARO — Widget-Snapshot Endpoint""" +"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints""" import json, random -from fastapi import APIRouter, Depends +from datetime import date +from fastapi import APIRouter, Depends, Query +from typing import Optional from database import db from auth import get_current_user router = APIRouter() +@router.get("/quote") +async def daily_quote(kategorie: Optional[str] = Query(None)): + """Liefert einen deterministischen Tagesspruch (wechselt täglich).""" + day_num = (date.today() - date(2026, 1, 1)).days + with db() as conn: + if kategorie: + rows = conn.execute( + "SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?", + (kategorie,) + ).fetchall() + else: + rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall() + if not rows: + return {"quote": None} + q = rows[day_num % len(rows)] + return {"quote": dict(q)} + + @router.get("/snapshot") async def widget_snapshot(user=Depends(get_current_user)): """Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild.""" diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 564b011..ab8b70b 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6550,6 +6550,104 @@ html.modal-open { /* ============================================================ HELP TOOLTIP ============================================================ */ +/* ============================================================ + PAGE INFO — generische Seiten-Hilfe (UI.pageInfo) + ============================================================ */ +.pinfo-trigger { + position: absolute; + top: calc(env(safe-area-inset-top, 0px) + 10px); + right: var(--space-4); + width: 32px; height: 32px; + border-radius: 50%; + background: var(--c-surface-2); + border: 1px solid var(--c-border-light); + color: var(--c-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + flex-shrink: 0; + box-shadow: var(--shadow-sm); + transition: background .15s, color .15s; +} +.pinfo-trigger:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); } + +.pinfo-banner { + margin: var(--space-3) var(--space-4) 0; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + background: var(--c-surface-2); + border-left: 3px solid var(--c-primary); + font-size: var(--text-sm); +} +.pinfo-banner-head { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); +} +.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; } +.pinfo-banner-title { + flex: 1; + font-weight: var(--weight-semibold); + color: var(--c-text); +} +.pinfo-banner-close { + background: none; border: none; cursor: pointer; + color: var(--c-text-muted); padding: 2px; +} +.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; } +.pinfo-banner-more { + background: none; border: none; cursor: pointer; + color: var(--c-primary); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + padding: 0; + display: flex; + align-items: center; + gap: 4px; + margin-top: var(--space-2); +} + +/* MODAL BODY */ +.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); } +.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; } +.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); } +.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); } +.pinfo-step { + display: flex; + gap: var(--space-3); + align-items: flex-start; +} +.pinfo-step-icon { + width: 32px; height: 32px; + border-radius: var(--radius-md); + background: var(--c-primary-subtle, rgba(196,132,58,.12)); + color: var(--c-primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; } +.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; } +.pinfo-tip { + display: flex; + gap: var(--space-2); + align-items: flex-start; + padding: var(--space-3); + background: rgba(196,132,58,.08); + border-radius: var(--radius-md); + color: var(--c-text-secondary); + font-size: var(--text-sm); + line-height: 1.5; +} +.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; } + +/* Container braucht position:relative für den absoluten Trigger-Button */ +.page-body { position: relative; } + .by-help-btn { display: inline-flex; align-items: center; diff --git a/backend/static/index.html b/backend/static/index.html index e052694..63a0e9b 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -562,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 99cabf8..be0f3c2 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '653'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '654'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 594951e..fd15e1d 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -838,19 +838,17 @@ window.Page_map = (() => { _tempMarker = null; } + // Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv) const PIN_TYPES = [ - { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626' }, - { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, - { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, - { type: 'kotbeutel_abfall', icon: '', label: 'Kotbeutel + Mülleimer', color: '#5a8a6a' }, - { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' }, - { type: 'bank_kotbeutel', icon: '', label: 'Bank + Kotbeutel', color: '#7a6030' }, - { type: 'bank_kotbeutel_abfall', icon: '', label: 'Bank + Kotbeutel + Mülleimer', color: '#4a5a2a' }, - { type: 'drinking_water', icon: '', label: 'Wasserstelle', color: '#0EA5E9' }, - { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, - { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, - { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, - { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, + { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626', exclusive: true }, + { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, + { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, + { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' }, + { type: 'drinking_water',icon: '', label: 'Wasserstelle',color: '#0EA5E9' }, + { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, + { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, + { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, + { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, ]; function _confirmPlacement(latlng) { @@ -859,18 +857,18 @@ window.Page_map = (() => { radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6, }).addTo(_map); - let _selectedType = 'giftkoeder'; + let _selectedTypes = new Set(['giftkoeder']); UI.modal.open({ title: ' Marker setzen', body: `
- +
${PIN_TYPES.map(p => ` @@ -896,9 +894,21 @@ window.Page_map = (() => { document.querySelector('.poi-type-grid')?.addEventListener('click', e => { const btn = e.target.closest('.poi-type-btn'); if (!btn) return; - document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected')); - btn.classList.add('selected'); - _selectedType = btn.dataset.type; + const t = btn.dataset.type; + if (btn.dataset.excl) { + _selectedTypes = new Set([t]); + document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t)); + } else { + if (_selectedTypes.has('giftkoeder')) { + _selectedTypes.delete('giftkoeder'); + document.querySelector('[data-excl="1"]')?.classList.remove('selected'); + } + if (_selectedTypes.has(t)) { + if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); } + } else { + _selectedTypes.add(t); btn.classList.add('selected'); + } + } }); document.getElementById('poi-cancel')?.addEventListener('click', () => { @@ -909,8 +919,9 @@ window.Page_map = (() => { document.getElementById('poi-save')?.addEventListener('click', async () => { const name = document.getElementById('poi-name').value.trim() || null; const notiz = document.getElementById('poi-notiz').value.trim() || null; + const type = [..._selectedTypes].join(','); UI.modal.close(); - await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz }); + await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz }); _exitPlacementMode(); }); } diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index fed9bac..e102f92 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -476,6 +476,18 @@ window.Page_uebungen = (() => { if (_VALID_TABS.has(mapped)) _activeTab = mapped; } _render(); + UI.pageInfo(_container, { + pageId: 'uebungen', + title: 'Übungsbibliothek', + icon: 'graduation-cap', + intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.', + steps: [ + { icon: 'list-checks', title: 'Stand erfassen', text: 'Klicke auf "Stand erfassen" um schnell für alle Übungen einzutragen, was euer aktueller Stand ist.' }, + { icon: 'flag', title: 'Übung üben', text: 'Tippe auf eine Übung, um die Anleitung zu lesen. Mit den Fortschritts-Icons (Flagge → Trophäe) trackst du, wie weit ihr seid.' }, + { icon: 'star', title: 'KI-Trainer', text: 'Im Tab "KI-Trainer" analysiert unsere KI deinen Trainingsstand und gibt personalisierte Empfehlungen.' }, + ], + tip: 'Regelmäßiges Training stärkt die Bindung — auch 5 Minuten täglich machen einen großen Unterschied!', + }); // Übungen aus DB laden (parallel mit Progress) if (!_exercisesLoaded) { diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 867f1c9..eaf08b0 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -280,6 +280,68 @@ const UI = (() => { // Alias für ältere Aufrufe const escHtml = escape; + // ---------------------------------------------------------- + // PAGE INFO — generische Seiten-Hilfe + // config: { pageId, title, icon?, intro, steps?: [{icon,title,text}], tip? } + // Erstes Öffnen: expandierter Banner. Danach: kleines ? im Header. + // ---------------------------------------------------------- + function pageInfo(container, config) { + const seenKey = 'help_seen_' + config.pageId; + const seen = !!localStorage.getItem(seenKey); + + function _buildSteps() { + if (!config.steps?.length) return ''; + return config.steps.map(s => ` +
+ ${s.icon ? `${_svgIcon(s.icon)}` : ''} +
+ ${s.title ? `
${s.title}
` : ''} +
${s.text}
+
+
`).join(''); + } + + function _openModal() { + modal.open({ + title: `${_svgIcon(config.icon || 'question')} ${config.title}`, + body: ` +
+

${config.intro}

+ ${config.steps?.length ? `
${_buildSteps()}
` : ''} + ${config.tip ? `
${_svgIcon('lightbulb')} ${config.tip}
` : ''} +
`, + }); + } + + // Kleiner ? Button oben rechts — immer sichtbar + const headerBtn = document.createElement('button'); + headerBtn.className = 'pinfo-trigger'; + headerBtn.setAttribute('aria-label', 'Hilfe'); + headerBtn.innerHTML = _svgIcon('question'); + headerBtn.addEventListener('click', _openModal); + container.appendChild(headerBtn); + + // Banner beim ersten Besuch + if (!seen) { + localStorage.setItem(seenKey, '1'); + const banner = document.createElement('div'); + banner.className = 'pinfo-banner'; + banner.innerHTML = ` +
+ ${_svgIcon(config.icon || 'info')} + ${config.title} + +
+
${config.intro}
+ ${config.steps?.length ? `
${_buildSteps()}
` : ''} + + `; + banner.querySelector('.pinfo-banner-close').addEventListener('click', () => banner.remove()); + banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); }); + container.insertAdjacentElement('afterbegin', banner); + } + } + // ---------------------------------------------------------- // HELP TOOLTIP — inline ? Badge mit Klick-Tooltip // ---------------------------------------------------------- @@ -915,7 +977,7 @@ const UI = (() => { emptyState, time, setupPhotoPreview, scrollTop, skeleton, icon: _svgIcon, - escape, escHtml, help, + escape, escHtml, help, pageInfo, saveToAlbum, loadLeaflet, leafletMarker, diff --git a/backend/static/sw.js b/backend/static/sw.js index 71594d7..a159edb 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v653'; +const CACHE_VERSION = 'by-v654'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache diff --git a/scripts/dog_quotes.json b/scripts/dog_quotes.json new file mode 100644 index 0000000..7c3c8e2 --- /dev/null +++ b/scripts/dog_quotes.json @@ -0,0 +1,348 @@ +[ + { "text": "Der Hund ist des Menschen bester Freund.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Ein treuer Hund ist besser als ein falscher Freund.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde haben alle guten Eigenschaften des Menschen, ohne gleichzeitig seine Fehler zu besitzen.", "autor": "Friedrich II.", "kategorie": "weisheit" }, + { "text": "Dem Hunde, wenn er gut erzogen, wird selbst ein weiser Mann gewogen.", "autor": "Johann Wolfgang von Goethe", "kategorie": "training" }, + { "text": "Die Treue eines Hundes ist ein kostbares Geschenk, das nicht minder bindende moralische Verpflichtungen auferlegt als die Freundschaft eines Menschen.", "autor": "Konrad Lorenz", "kategorie": "treue" }, + { "text": "Wenn du einen verhungerten Hund aufliest und machst ihn satt, dann wird er dich nicht beißen. Das ist der Grundunterschied zwischen Hund und Mensch.", "autor": "Mark Twain", "kategorie": "weisheit" }, + { "text": "Woran sollte man sich von der endlosen Verstellung, Falschheit und Heimtücke der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen schauen kann?", "autor": "Arthur Schopenhauer", "kategorie": "weisheit" }, + { "text": "Ein Leben ohne Hund ist ein Irrtum.", "autor": "Carl Zuckmayer", "kategorie": "allgemein" }, + { "text": "Man kann auch ohne Hund leben, aber es lohnt sich nicht.", "autor": "Heinz Rühmann", "kategorie": "allgemein" }, + { "text": "Ein Hund ist das Einzige auf der Welt, das dich mehr liebt als sich selbst.", "autor": "Josh Billings", "kategorie": "liebe" }, + { "text": "In den Augen meines Hundes liegt mein ganzes Glück, all mein Inneres, Krankes, Wundes heilt in seinem Blick.", "autor": "Friederike Kempner", "kategorie": "liebe" }, + { "text": "Hunde sind nicht unser ganzes Leben, aber sie machen unser Leben ganz.", "autor": "Roger Caras", "kategorie": "liebe" }, + { "text": "Mein kleiner Hund — ein Herzschlag an meinen Füßen.", "autor": "Edith Wharton", "kategorie": "liebe" }, + { "text": "Der Hund ist ein Ehrenmann. Ich hoffe, in seinen Himmel zu kommen, nicht in den des Menschen.", "autor": "Mark Twain", "kategorie": "humor" }, + { "text": "Wenn du dich einsam fühlst, kaufe dir keinen Freund — leih dir einen Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund, der bellt, ist mehr wert als ein Mensch, der lügt.", "autor": "Henry de Montherlant", "kategorie": "weisheit" }, + { "text": "Ich hatte lieber meinen Hund bellen hören als einen Mann schwören, er liebe mich.", "autor": "William Shakespeare", "kategorie": "weisheit" }, + { "text": "Hunde sind die Führer des Planeten. Wenn du zwei Lebewesen siehst, von denen eins einen Haufen macht und das andere ihn aufhebt — wer ist wohl der Chef?", "autor": "Jerry Seinfeld", "kategorie": "humor" }, + { "text": "Ein Hund bringt dir Stöcke, weil er denkt, du mochtest ihn so sehr, dass du ihn sofort weggeworfen hast.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde lehren uns eine Menge über das Leben: Treue, Ausdauer und vor dem Hinlegen dreimal im Kreis zu drehen.", "autor": "Robert Benchley", "kategorie": "humor" }, + { "text": "Ich arbeite hart, damit mein Hund ein besseres Leben führen kann.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer sagt, dass Geld nicht glücklich macht, hat noch nie ein weinendes Kind gesehen, dem gerade ein Welpe geschenkt wurde.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Das Schönste, was ein Mensch besitzen kann, ist die Liebe eines Hundes.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist der einzige Freund, den du kaufen kannst.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Hundehaare sind das ultimative Accessoire für jede Kleidung.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wenn dein Hund übergewichtig ist, dann warst du nicht genug an der frischen Luft.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Gassi gehen ist keine Pflicht — es ist ein Abenteuer.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein müder Hund ist ein guter Hund.", "autor": "Sprichwort", "kategorie": "training" }, + { "text": "Hunde haben mehr Liebe zu verschenken als die meisten Menschen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Kein Psychiater der Welt kann einem Menschen so helfen wie ein Hund.", "autor": "Bern Williams", "kategorie": "liebe" }, + { "text": "Den eigenen Fokus auf erwünschtes Verhalten zu richten anstatt auf das, was nicht klappt, ist nicht nur für die Hundeerziehung ein großartiger Ansatz.", "autor": "Christina Hanf", "kategorie": "training" }, + { "text": "Die Menschen erwarten zu viel von ihrem Hund und zu wenig von sich selbst.", "autor": "Bob Bailey", "kategorie": "training" }, + { "text": "Gewalt bewirkt niemals etwas Gutes, weder bei Menschen noch bei Hunden.", "autor": "Turid Rugaas", "kategorie": "training" }, + { "text": "Training bedeutet, Situationen zu schaffen, in denen der Hund viel richtig machen kann.", "autor": "Gudrun Scholz", "kategorie": "training" }, + { "text": "Hunde erziehen heißt, sich selbst zu erziehen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein gut erzogener Hund macht seinem Besitzer Ehre.", "autor": "Sprichwort", "kategorie": "training" }, + { "text": "Lass schlafende Hunde liegen.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Wenn ein alter Hund bellt, soll man hinausschauen.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Der Hund, der viel bellt, beißt selten.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Beißt dich ein Hund, zeig ihm nicht die Wunde.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Mit Hunden und Kindern kommt man immer gut an.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Streichle einen Hund und er gehört dir fürs Leben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund vergisst keine Freundlichkeit.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde kennen keine Vorurteile.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund liebt dich, egal wie du aussiehst oder was du hast.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde sind Meister darin, im Augenblick zu leben.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn Menschen so treu wären wie Hunde, wäre die Welt eine bessere.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Der Blick eines Hundes kann mehr sagen als tausend Worte.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Jeder Mensch verdient einen Hund. Umgekehrt ist auch wahr: Jeder Hund verdient einen guten Menschen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund weiß nicht, was Einsamkeit bedeutet — er ist immer bei dir.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde lieben bedingungslos — etwas, das wir Menschen erst lernen müssen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Das Leben ist zu kurz für schlechtes Hundefutter.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Wer täglich mit seinem Hund spaziert, lebt länger und glücklicher.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Bewegung ist das beste Medikament — für Hund und Mensch.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund an der Leine hält auch sein Herrchen fit.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Laufen mit dem Hund ist die schönste Form des Sports.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hundebesitzer gehen Gassi. Hunde hingegen gehen auf eine Geruchssafari.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Immer wenn du beim Gassi gehen auf dein Handy schaust, verpasst du ein Abenteuer mit deinem Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Die beste Therapie hat vier Pfoten und einen Schwanz.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde bringen das Beste in uns zum Vorschein.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wer Hunde hat, hat selten Feinde.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Hunde verstehen uns oft besser als wir uns selbst.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund hat kein Ego — dafür hat er alles andere.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "The fidelity of a dog is a precious gift demanding no less binding moral responsibilities than the friendship of a human being.", "autor": "Konrad Lorenz", "kategorie": "treue" }, + { "text": "Dogs are not our whole life, but they make our lives whole.", "autor": "Roger Caras", "kategorie": "liebe" }, + { "text": "A dog is the only thing on earth that loves you more than he loves himself.", "autor": "Josh Billings", "kategorie": "liebe" }, + { "text": "Happiness is a warm puppy.", "autor": "Charles M. Schulz", "kategorie": "liebe" }, + { "text": "The better I get to know men, the more I find myself loving dogs.", "autor": "Charles de Gaulle", "kategorie": "humor" }, + { "text": "No matter how little money and how few possessions you own, having a dog makes you rich.", "autor": "Louis Sabin", "kategorie": "liebe" }, + { "text": "My goal in life is to be as good of a person as my dog already thinks I am.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Dogs have a way of finding the people who need them.", "autor": "Thom Jones", "kategorie": "liebe" }, + { "text": "A dog will teach you unconditional love. If you can have that in your life, things won't be too bad.", "autor": "Robert Wagner", "kategorie": "liebe" }, + { "text": "Ein Hund ist ein Teil unseres Lebens. Für unseren Hund sind wir sein ganzes Leben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde geben mehr als sie nehmen — das können die wenigsten von sich behaupten.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der größte Fehler beim Hundetraining ist, die Geduld zu vergessen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Belohnung schafft Vertrauen — Strafe schafft Angst.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Positive Verstärkung ist der Schlüssel zu einer glücklichen Hund-Mensch-Beziehung.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund, der Freude am Lernen hat, ist ein Hund, der Freude am Leben hat.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Konsequenz bedeutet nicht Strenge — es bedeutet Verlässlichkeit.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund braucht Grenzen, so wie ein Kind Grenzen braucht — aus Liebe, nicht aus Macht.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde folgen nicht den Gesetzen der Logik — sie folgen den Gesetzen des Herzens.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn du deinen Hund liebst, zeig es — er wartet den ganzen Tag darauf.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Kein Haus ist groß genug für einen Hund und seine Haare.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Früher hatten wir ein schönes Sofa. Jetzt hat unser Hund ein schönes Sofa.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund und ich haben eine Vereinbarung: Ich tue so, als würde ich das Essen nicht mitessen wollen, und er tut so, als würde er es glauben.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hundeliebe ist die reinste Form der Liebe — sie fragt nichts und gibt alles.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist der einzige Zeuge, der keine Erwartungen an dich stellt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Mit einem Hund an deiner Seite bist du nie wirklich allein.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Schwanz eines Hundes lügt nie.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde wissen genau, wann du traurig bist — und sie entscheiden sich trotzdem, bei dir zu bleiben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn ein Hund dir vertraut, hat er dir sein Herzstück gegeben.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde mögen alle Menschen, die sie kennen — bis auf den Tierarzt. Und selbst den vergeben sie.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Gib deinem Hund heute ein extra Leckerli — er hat es sich verdient, einfach indem er da ist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist das lebende Beispiel dafür, dass man mit nichts als Liebe reich sein kann.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde bellen, die Karawane zieht weiter.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Wer einmal einen Hund gehabt hat, kann sich ein Leben ohne nicht mehr vorstellen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hundehaare auf dem Sofa sind kein Problem — sie sind ein Zeichen von Liebe.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist kein Tier — er ist ein Familienmitglied.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Das Einzige, was ein Hund von dir will, ist deine Zeit.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde lachen auch — nur mit dem Schwanz.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund trauert nie um eine verpasste Chance — er schafft sofort die nächste.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde leben im Moment. Das sollten wir auch.", "autor": "Cesar Millan", "kategorie": "weisheit" }, + { "text": "Dein Hund braucht kein Geschenk zu deinem Geburtstag — für ihn ist jeder Tag mit dir ein Fest.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund riecht deine Stimmung, bevor du sie selbst bemerkst.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde kennen keine schlechten Tage — sie kennen nur schlechte Momente, die mit einem Kuss besser werden.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Jedem Hundebesitzer passiert es einmal: Er redet mit seinem Hund als wäre er ein Mensch. Dem Hund ist das egal — er hört einfach zu.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde sind die einzigen Wesen, die dich für das lieben, was du bist, nicht für das, was du hast.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein gut erzogener Hund ist das Ergebnis eines gut erzogenen Besitzers.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Im Frühling, wenn die Erde sich erneuert, beginnt auch der Hund sein Fell zu verlieren — liberal.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Treu wie ein Hund — das ist kein Vergleich, das ist ein Kompliment.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wer einen Hund streichelt, streichelt auch sich selbst.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde senken den Blutdruck — zumindest bis sie auf dem weißen Teppich kotzen.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Studien zeigen: Hundebesitzer haben ein gesünderes Herz. Kein Wunder — ihr Herz wird täglich trainiert.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Gassi gehen bei Regen ist kein Spaß — aber dein Hund findet es toll.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Der einzige Fehler, den ein Hund macht, ist, zu früh zu sterben.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde machen keine Politik. Deswegen sind sie klüger als wir.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Haus wird zum Zuhause, wenn ein Hund darin wohnt.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Der Hund ist der älteste Freund des Menschen — und noch immer der beste.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wer einen Hund hat, hat keinen Grund zu klagen — höchstens über Hundehaare.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Manche Menschen verdienen nicht den Hund, den sie haben.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein guter Mensch behandelt seinen Hund wie einen Freund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wo ein Hund ist, ist Freude.", "autor": "Sprichwort", "kategorie": "allgemein" }, + { "text": "Ein Hund weiß nicht, was Gleichgültigkeit ist.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde erkennen gute Menschen auf Anhieb.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Vertraue dem Urteil deines Hundes über andere Menschen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund, der dir ohne Hintergedanken folgt, ist ein Geschenk.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hundeerziehung ist Beziehungspflege.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Wenn du deinen Hund gut kennst, kennt er dich noch besser.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Das schönste Geräusch am Morgen: der Schwanz deines Hundes, der wedelt, weil du aufgewacht bist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund redet nicht — und sagt doch alles.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde haben keine Worte für Enttäuschung — nur für Freude.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Für deinen Hund bist du der Beste — immer, ohne Ausnahme.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Hund ist das einzige Tier, das den Menschen als seinen Gott betrachtet.", "autor": "George Bernard Shaw", "kategorie": "weisheit" }, + { "text": "Bis es keinen Hund in meinem Leben gibt, wird mein Herz nie ganz gefüllt sein.", "autor": "Roger Caras", "kategorie": "liebe" }, + { "text": "Hunde sind die Philosophen des Alltags — sie leben, ohne zu grübeln.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der weise Hund macht einen Bogen um den Stock.", "autor": "Sprichwort", "kategorie": "weisheit" }, + { "text": "Hunde laufen auf vier Beinen und tragen dennoch die ganze Welt auf dem Rücken ihrer Besitzer.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist der Beweis, dass die Natur manchmal Vollkommenes erschafft.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde haben die Weisheit, den Menschen dort zu treffen, wo er steht.", "autor": "Orhan Pamuk", "kategorie": "weisheit" }, + { "text": "Für meinen Hund bin ich kein Held, kein Versager — ich bin einfach sein Zuhause.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Hund ist das Symbol der Loyalität, weil er sie nicht kennt — er lebt sie.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund bellte einmal einen Dieb an — und Menschen nennen ihn seitdem treu.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wer mit Hunden liegt, steht mit Flöhen auf.", "autor": "Sprichwort", "kategorie": "humor" }, + { "text": "Zwei Dinge machen das Leben leichter: ein guter Kaffee und ein treuer Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Der Hund liebt die Wahrheit — er ist das ehrlichste Lebewesen auf dem Planeten.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Es gibt keine schlechten Hunde. Nur schlechte Erziehung.", "autor": "Cesar Millan", "kategorie": "training" }, + { "text": "Ein Hund ist glücklich, wenn er beschäftigt ist und weiß, was von ihm erwartet wird.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Lob kostet nichts und bedeutet dem Hund alles.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Strafe zeigt dem Hund nur, was er nicht soll — Belohnung zeigt ihm, was er soll.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein entspannter Hund ist ein trainierter Hund.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Jede Rasse hat ihre Eigenheiten — der gute Hundetrainer kennt sie alle.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Wenn du willst, dass dein Hund gehorcht, musst du zuerst lernen zu verstehen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Training ist Dialog, nicht Monolog.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein gelangweilter Hund ist ein Problemhund.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Konsequenz beim Training bedeutet: Heute gilt dasselbe wie gestern.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Der beste Hundetrainer ist der, dem der Hund freiwillig folgt.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde lernen durch Wiederholung und Belohnung — nicht durch Zwang.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Körpersprache ist alles im Hundetraining — was du sagst, ist weniger wichtig als wie du es sagst.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund verzeiht Fehler schneller als jeder Mensch.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde halten keine Groll — sie lieben weiter, egal was war.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Treue ist keine Eigenschaft — sie ist eine Entscheidung. Hunde haben sie nie bereut.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund folgt dir in die Dunkelheit, ohne zu zögern.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Die Treue eines Hundes beschämt uns manchmal, weil wir so selten dasselbe bieten.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund, der wartet, zeigt mehr Liebe als Worte ausdrücken können.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Der japanische Hund Hachiko wartete neun Jahre auf seinen Herrn. Frage dich, wie lange du warten würdest.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Kein anderes Wesen auf der Welt ist so treu wie ein Hund.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde wählen ihre Besitzer nicht aus — und lieben sie trotzdem.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund bleibt bei dir, wenn alle anderen gehen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn dein Hund dir seinen Bauch zeigt, schenkt er dir das größte Vertrauen, das ein Tier geben kann.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde lieben ohne zu vergleichen — das können Menschen selten.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Der Atem deines Hundes im Nacken bedeutet: Du bist nicht allein.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Nichts heilt einen schlechten Tag schneller als ein Hund, der dir entgegenläuft.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde sind das liebste Geheimnis glücklicher Menschen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund macht Liebe sichtbar.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wenn du einen Hund streichelst, streichelt er gleichzeitig deine Seele.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hundeaugen lügen nicht — dort siehst du reine Liebe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Die Liebe eines Hundes ist keine Anhänglichkeit — sie ist Hingabe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund liebt dich an deinen schlechtesten Tagen am meisten.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Mit einem Hund zu schlafen ist die wärmste Art, eine Nacht zu verbringen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wenn Hunde beten könnten, würden sie für ihre Menschen beten.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund braucht nichts außer dir — und das ist sein Reichtum.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Liebe eines Hundes zu verdienen ist die einfachste und gleichzeitig die schönste Sache der Welt.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wer noch nie von einem Hund geliebt wurde, kennt ein ganzes Stück Glück nicht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde wissen, dass Liebe keine Bedingungen braucht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hundewelpen sind die beste Anti-Depressions-Therapie der Welt — leider nicht von der Krankenkasse erstattet.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund hat mir beigebracht, ruhig zu sein. Schade, dass er selbst nie ruhig ist.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Manche sagen: Es ist nur ein Hund. Diese Menschen haben nie einen Hund gehabt.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Der einzige Nachteil eines Hundes: Er lebt zu kurz.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wenn Hunde reden könnten, würden sie uns sagen, was wir schon immer wissen wollten: dass wir gut genug sind.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde schlafen 18 Stunden am Tag und sehen dabei entspannter aus als ich nach 8 Stunden Schlaf.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund hat fünf Betten und schläft trotzdem auf meinen Füßen.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund frisst alles außer dem, was im Napf ist.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund hat mehr Freunde auf Instagram als ich — und postet nie selbst.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde kennen kein Montag-Gefühl.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Kein Hund schaut je auf die Uhr — höchstens zur Futterzeit.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Der Traumjob wäre, bezahlt zu werden, während der Hund auf dem Schoß liegt.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hundebesitzer kennen zwei Arten von Böden: sauber und nach dem Gassi.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund ist offiziell der Einzige, der mich ohne Make-up schön findet.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist der beste Grund, früh aufzustehen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde zeigen uns, wie schön das Einfache ist: Spaziergang, Leckerli, Kuscheln.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Kein Hund hat je Krieg geführt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde verbringen kein Leben damit zu bereuen. Sie leben einfach.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Die Weisheit des Hundes liegt darin, dass er nie zweifelt, ob er genug tut.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund hat keine Vergangenheit, die er bereut, und keine Zukunft, die er fürchtet.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde leben nicht nach Regeln — sie leben nach Instinkten. Das ist manchmal weiser.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "In der Stille eines Hundes liegt mehr Weisheit als in manchem langen Gespräch.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund versteht mehr von Empathie als jeder Therapeut.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde haben das, was wir suchen: Zufriedenheit ohne Grund.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Die wahre Weisheit des Hundes: Er braucht nichts zu beweisen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund sieht in dir keinen Status, kein Einkommen, keine Vergangenheit — nur dich.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde wählen Menschen nicht nach Äußerlichkeiten — das sollten wir auch nicht.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Achte darauf, wie dein Hund Menschen begegnet — er spürt, was du nicht siehst.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn dein Hund dir nicht traut, solltest du in dich gehen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der Hund ist das beste Argument für die Existenz von Güte in der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde spiegeln wider, wer wir wirklich sind.", "autor": "Cesar Millan", "kategorie": "weisheit" }, + { "text": "Wenn ich an die Treulosigkeit der Menschen denke, lobe ich meinen Hund.", "autor": "Friedrich II.", "kategorie": "treue" }, + { "text": "Ein Hund wird nie sagen: Heute habe ich keine Lust, dein Freund zu sein.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde verlassen uns nie — wir verlassen sie.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund hat kein Gedächtnis für Groll — nur für Zuneigung.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn ein Hund dir treu ist, hat er eine Entscheidung für sein Leben getroffen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Treue ist nicht, was ein Hund tut — es ist, wer er ist.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund trägt kein Netz aus Erwartungen — nur das Netz der Zuneigung.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Wenn Menschen so wären wie Hunde — treu, ehrlich, freudig — wäre die Welt ein besserer Ort.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde zeigen dir täglich, dass Loyalität keine Leistung ist, sondern ein Charakter.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde liegen zu Füßen der Helden und der Hilflosen gleichermaßen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Tägliche Bewegung mit dem Hund ist der günstigste Arztbesuch, den du machen kannst.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund hält dich körperlich fit — ob du willst oder nicht.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Zehn Minuten mit einem Hund spielen senkt Stress nachweislich.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hundebesitzer haben im Durchschnitt einen niedrigeren Blutdruck. Kein Wunder.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund motiviert dich, das Sofa zu verlassen — jeden Tag, bei jedem Wetter.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Wer täglich Gassi geht, braucht kein Fitnessstudio.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde halten uns jung — zumindest im Herzen.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hund an der Seite hilft gegen Depressionen besser als viele Tabletten.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Streicheln ist gut für den Menschen und noch besser für den Hund.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde sind die natürlichste Form der Stresstherapie.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Mit einem Hund draußen zu sein bedeutet: frische Luft, Bewegung, Freude — dreifacher Gewinn.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein gesunder Hund ist ein aktiver Hund — und ein aktiver Hund braucht einen aktiven Menschen.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde sorgen dafür, dass wir uns bewegen — das ist ihr heimliches Gesundheitsprogramm für uns.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Der Hund kennt das Geheimnis des Glücks: Das Beste ist immer das Jetzt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde fragen nicht, was das Leben bedeutet. Sie leben es einfach.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn du nicht weißt, wohin — folge deinem Hund.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund zeigt dir jeden Tag: Kleine Dinge sind die wichtigsten.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde tanzen nicht, wenn du ihnen von deinen Erfolgen erzählst — sie tanzen, wenn du heimkommst.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Das Gebell eines Hundes ist die ehrlichste Meinung der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund hat keine Agenda außer Liebe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde suchen nicht nach dem Sinn des Lebens — sie finden ihn im Spaziergang.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund macht dich vollständig — auch wenn du das erst merkst, wenn er weg ist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Im Trauerfall ist ein Hund oft der einzige Trost, der nicht nach Worten sucht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde lieben jeden Tag so, als wäre es der schönste ihres Lebens.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Für einen Hund ist Heimkommen das größte Ereignis des Tages — täglich.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund jubelt bei deiner Ankunft mehr als dein Telefon bei einem Like.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde misstrauen keinem ohne Grund. Lerne davon.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Kein Hund hat je einen Krieg begonnen oder ein Tier beleidigt ohne Grund.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein treuer Hund ist ein Spiegel des Guten in der Welt.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Liebe, die nicht urteilt — das ist Hundeliebe.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wir verdienen unsere Hunde nicht — wir werden von ihnen gesegnet.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde bringen Licht in die dunkelsten Ecken unserer Tage.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Jeder Hund hat ein Talent: Er macht sein Zuhause glücklicher.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund bereichert das Leben, ohne etwas von dir zu fordern.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Das einzige Problem mit Hunden ist, dass sie zu kurz leben und zu vollständig lieben.", "autor": "Agnes Sligh Turnbull", "kategorie": "liebe" }, + { "text": "Until one has loved an animal, a part of one's soul remains unawakened.", "autor": "Anatole France", "kategorie": "liebe" }, + { "text": "Dogs are how people would be if the important stuff is all that mattered to us.", "autor": "Ashly Lorenzana", "kategorie": "weisheit" }, + { "text": "Ich bin kein Hundenarr. Ich habe einfach einen Hund, der eine eigene Meinung hat, und ich respektiere das.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund weiß nichts von Montagen — er weiß nur, dass du da bist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde erfinden sich jeden Tag neu — mit demselben Wedeln.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund zu sein bedeutet: vollständig glücklich sein mit dem, was ist.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wer einmal die Liebe eines Hundes erlebt hat, zweifelt nie mehr an gutem Leben.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Kein Geld der Welt kann die Freude eines Hundes kaufen — nur verdienen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde machen keine großen Versprechen. Sie halten einfach kleine.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "In meinem Haus regiert der Hund — ich bin nur der Butler.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wenn mein Hund spricht, spreche ich auch. Unsere Unterhaltungen sind die besten.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde täuschen nicht vor, glücklich zu sein. Wenn sie es zeigen, meinen sie es.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund zeigt dir: Dankbarkeit kann man auch ohne Worte ausdrücken.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn der Hund wedelt, ist der Tag gerettet.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde sind Experten darin, das Beste im Menschen zu sehen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Eine Pfote auf dem Schoß ist mehr wert als tausend Worte des Trostes.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Wer Hunde nicht mag, hat sie entweder nie richtig kennengelernt oder das Herz zu weit verschlossen.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Hund ist der beste Gesprächspartner: Er hört immer zu und widerspricht nie.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde schnarchen lauter als manche Menschen. Und trotzdem stört es mich nicht.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Welpe ist wie ein Sonnenschein, den du im Haus trägst.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Mit einem alten Hund zu leben ist ein Privileg — er hat viel zu erzählen, wenn man zuhört.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde altern mit Würde. Wir sollten es ihnen gleichtun.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein alter Hund hat mehr gelernt als ein junger — aber er braucht mehr Pausen.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Kein Hundeblick ist so tief wie der eines alten Hundes, der sein Leben hinter sich hat.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde wachsen mit uns auf — und lassen uns nie ganz los.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund ist von Geburt bis Tod dein treuer Gefährte — das ist der reinste Pakt der Welt.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Den Charakter eines Menschen erkennt man daran, wie er seinen Hund behandelt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wer gut zu Hunden ist, ist gut zu Menschen — das Gegenteil gilt leider auch.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund erzählt dir nicht, was er fühlt — er zeigt es dir.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Respekt und Vertrauen — das sind die Grundlagen jeder Mensch-Hund-Beziehung.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hundeerziehung beginnt am ersten Tag und endet nie — sie wird nur besser.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund der niemals Nein sagt, hat nie gelernt Grenzen zu respektieren.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde lieben Routinen — sie machen das Unbekannte beherrschbar.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein Hund, dem man Vertrauen schenkt, enttäuscht selten.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Hunde brauchen keine langen Befehle — ein Wort und ein klares Signal genügen.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ein glücklicher Hund hat genug Bewegung, genug Aufgaben und genug Liebe.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Wer mit seinem Hund Tricks übt, übt gleichzeitig Geduld und Ausdauer.", "autor": "Unbekannt", "kategorie": "training" }, + { "text": "Ich sage nicht, dass mein Hund klüger ist als du — aber er hat das schon gedacht.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wenn mein Hund Telefonkonferenzen hätte, würden sie pünktlich enden.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist der einzige, der glücklich ist, wenn du wieder von der Toilette kommst.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ich bin nicht süchtig nach meinem Hund. Ich brauche ihn nur täglich.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund lässt mich nie ausreden — das mag ich an ihm.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Sie sagten, ein Hund sei viel Arbeit. Sie hatten recht. Die Freude ist noch mehr.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde machen das Leben besser — das ist keine Meinung, das ist Tatsache.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Kein Selfie gelingt so gut wie das mit dem Hund.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer einen Hund hat, braucht keinen Wecker — und kein Sofa für sich allein.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde sind die perfekten Mitbewohner: still, wenn gewünscht, laut, wenn nötig.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Ein Hund ist das einzige Wesen, das dich liebt, obwohl es weiß, dass du seine Spielzeuge wegschmeißt.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Hunde riechen die Lüge — Menschen riechen Parfum.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund braucht dich nicht zu verstehen — er muss dich nur fühlen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde wissen, wann du weinst. Und sie kommen trotzdem — oder gerade dann.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund ist das beste Argument gegen Einsamkeit.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Wer Hunde versteht, versteht ein Stück mehr von der Welt.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Hunde haben keine Maske. Das ist ihr größtes Geschenk an uns.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund fragt nicht nach deiner Geschichte — er schreibt mit dir die nächsten Seiten.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde machen keine Witze auf Kosten anderer. Lehre von ihnen.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund zu haben bedeutet: täglich Dankbarkeit erfahren.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Niemand begrüßt dich aufrichtiger als dein Hund.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund gibt dir immer eine zweite Chance — und eine dritte und vierte.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde machen Fehler wie alle — aber sie entschuldigen sich täglich mit ihrem Blick.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund mit gebrochenem Bein hat mehr Stolz als mancher Mensch.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Der mutigste Beschützer wiegt manchmal nur fünf Kilo.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde fürchten sich manchmal — aber sie überwinden es für dich.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Ein Hund kämpft nie für Macht — nur für seine Menschen.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Hunde haben keine Karriere, kein Konto, keine Ambitionen — und sind glücklicher als die meisten.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Das Geheimnis eines langen Lebens: jeden Tag Gassi gehen.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Frische Luft, Bewegung, ein Hund an der Seite — besser geht Therapie nicht.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Wer regelmäßig mit dem Hund läuft, braucht keinen Motivationscoach.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Hunde halten uns davon ab, den ganzen Tag zu sitzen — das ist ihre heimliche Mission.", "autor": "Unbekannt", "kategorie": "gesundheit" }, + { "text": "Ein Hundekiss mag nicht hygienisch sein — aber er heilt trotzdem.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer mit Hunden lebt, hat ein wärmeres Zuhause — buchstäblich.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Hunde zeigen uns täglich: Das Schönste am Tag ist der Anfang und das Ende — weil du da bist.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund braucht weder Smartphone noch Social Media — er lebt das echte Leben.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Wenn ich meinen Hund anschaue, verstehe ich, was Unschuld bedeutet.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund sieht in dir nie das Schlechteste — nur das Beste.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde sind die ehrlichsten Kritiker: Wenn sie fressen, schmeckt es.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Mein Hund gibt mir täglich einen Grund zu lächeln — ohne es zu wissen.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Alles, was du brauchst, ist Liebe — und ein Hund, der das auch so sieht.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Ein Hund macht das Schweigen gemeinsam — das ist echter Frieden.", "autor": "Unbekannt", "kategorie": "liebe" }, + { "text": "Hunde haben das perfekte Leben: schlafen, fressen, spielen, lieben. Kein Stress.", "autor": "Unbekannt", "kategorie": "humor" }, + { "text": "Wer einmal einem Hund beim Schlafen zugesehen hat, weiß, was echte Entspannung ist.", "autor": "Unbekannt", "kategorie": "weisheit" }, + { "text": "Ein Hund zu retten bedeutet, zwei Leben zu retten — seins und deins.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Kaufe nicht, wenn du adoptieren kannst — du gewinnst mehr, als du weißt.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Ein Tierheim-Hund weiß, was Verlassenheit ist — und liebt dich trotzdem, sobald du da bist.", "autor": "Unbekannt", "kategorie": "treue" }, + { "text": "Jeder Hund verdient ein Zuhause. Jeder Mensch verdient einen Hund.", "autor": "Unbekannt", "kategorie": "allgemein" }, + { "text": "Die beste Zeit, einen Hund zu adoptieren, war gestern. Die zweitbeste ist heute.", "autor": "Unbekannt", "kategorie": "allgemein" } +] diff --git a/scripts/import_quotes.py b/scripts/import_quotes.py new file mode 100644 index 0000000..91c38bc --- /dev/null +++ b/scripts/import_quotes.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Importiert dog_quotes.json in die daily_quotes-Tabelle.""" +import json, sqlite3, sys +from pathlib import Path + +DB_PATH = Path(__file__).parent.parent / 'backend' / 'banyaro.db' +JSON_PATH = Path(__file__).parent / 'dog_quotes.json' + +quotes = json.loads(JSON_PATH.read_text()) +conn = sqlite3.connect(DB_PATH) + +existing = conn.execute("SELECT COUNT(*) FROM daily_quotes").fetchone()[0] +if existing > 0: + print(f"{existing} Einträge bereits vorhanden — überspringe Import.") + print("Zum Neuimport: DELETE FROM daily_quotes; zuerst ausführen.") + sys.exit(0) + +conn.executemany( + "INSERT INTO daily_quotes (text, autor, kategorie) VALUES (?, ?, ?)", + [(q['text'], q.get('autor'), q.get('kategorie')) for q in quotes] +) +conn.commit() +print(f"{len(quotes)} Tagessprüche importiert.") +conn.close() From ccb92254b62e890db689f56139b22f2293de3d21 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 20:24:14 +0200 Subject: [PATCH 04/27] =?UTF-8?q?Fix:=20Routenaufzeichnung=20=E2=80=94=20S?= =?UTF-8?q?topp-Button=20braucht=20Long-Press=20(1.8s),=20DIM-Timer=2010s?= =?UTF-8?q?=20=E2=86=92=205s=20(SW=20by-v655)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verhindert versehentliches Stoppen durch Hosentaschen-Druck: Stopp-Button reagiert nur auf 1.8s Gedrückt-Halten mit Fill-Animation, Einzeltap tut nichts. DIM-Schutz- Overlay greift jetzt nach 5s statt 10s. --- backend/static/index.html | 8 +++--- backend/static/js/app.js | 2 +- backend/static/js/pages/routes.js | 43 ++++++++++++++++++++++++++++--- backend/static/sw.js | 2 +- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 63a0e9b..59f9fbe 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -562,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index be0f3c2..f9c8a41 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '654'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '655'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index bdc48c1..bac1664 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -696,9 +696,44 @@ window.Page_routes = (() => { _recTrack = []; _recDistKm = 0; _recStartTime = Date.now(); const ctrl = document.getElementById('rk-rec-ctrl'); - ctrl.innerHTML = ``; - ctrl.querySelector('#rk-rec-stopbtn').addEventListener('click', () => _stopRecInOvl(true)); + ctrl.innerHTML = ` + `; + + // Long-Press 1.8s zum Stoppen + let _stopTimer = null, _stopTick = null; + const btn = ctrl.querySelector('#rk-rec-stopbtn'); + const fill = ctrl.querySelector('#rk-stop-fill'); + const startHold = () => { + if (_stopTimer) return; + const DURATION = 1800; + const start = Date.now(); + _stopTick = setInterval(() => { + const p = Math.min((Date.now() - start) / DURATION, 1); + fill.style.transition = 'none'; + fill.style.transform = `scaleX(${p})`; + }, 30); + _stopTimer = setTimeout(() => { + clearInterval(_stopTick); _stopTick = null; _stopTimer = null; + fill.style.transform = 'scaleX(1)'; + _stopRecInOvl(true); + }, DURATION); + }; + const cancelHold = () => { + if (!_stopTimer && !_stopTick) return; + clearTimeout(_stopTimer); clearInterval(_stopTick); + _stopTimer = null; _stopTick = null; + fill.style.transition = 'transform 0.25s ease'; + fill.style.transform = 'scaleX(0)'; + }; + btn.addEventListener('pointerdown', e => { e.preventDefault(); startHold(); }); + btn.addEventListener('pointerup', cancelHold); + btn.addEventListener('pointerleave', cancelHold); + btn.addEventListener('pointercancel', cancelHold); document.getElementById('rk-rec-stats-bar').style.display = ''; _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap); @@ -789,7 +824,7 @@ window.Page_routes = (() => { dim.style.display = 'flex'; _recDimmed = true; } - }, 10000); + }, 5000); } async function _stopRecInOvl(save) { diff --git a/backend/static/sw.js b/backend/static/sw.js index a159edb..a216340 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v654'; +const CACHE_VERSION = 'by-v655'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 5b73443d0a62871ca0e450afd609f38560fc20a6 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 20:34:00 +0200 Subject: [PATCH 05/27] =?UTF-8?q?Fix:=20iOS-Warnung=20bei=20Routenstart=20?= =?UTF-8?q?=E2=80=94=20Display=20wach=20lassen,=20GPS=20stoppt=20sonst=20(?= =?UTF-8?q?SW=20by-v656)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/js/pages/routes.js | 15 +++++++++++++++ backend/static/sw.js | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 59f9fbe..a8f31e4 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -562,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f9c8a41..52903c9 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '655'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '656'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index bac1664..deb71ae 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -695,6 +695,21 @@ window.Page_routes = (() => { _recActive = true; _recTrack = []; _recDistKm = 0; _recStartTime = Date.now(); + // iOS-Hinweis: Display muss wach bleiben + if (/iPad|iPhone|iPod/.test(navigator.userAgent)) { + const banner = document.createElement('div'); + banner.style.cssText = 'position:absolute;top:0;left:0;right:0;z-index:960;' + + 'background:rgba(30,30,30,0.92);color:#fff;font-size:13px;line-height:1.4;' + + 'padding:10px 14px;display:flex;align-items:flex-start;gap:10px;' + + 'border-bottom:1px solid rgba(255,255,255,0.1)'; + banner.innerHTML = ` + + Display wach lassen! Auf iPhone stoppt die GPS-Aufzeichnung, wenn das Display ausgeht — Helligkeit hochsetzen oder Bildschirm nicht sperren.`; + document.getElementById('rk-rec-map-wrap')?.appendChild(banner); + setTimeout(() => banner.remove(), 9000); + } + const ctrl = document.getElementById('rk-rec-ctrl'); ctrl.innerHTML = `
${cards.join('')}
`; + if (_helpHandle) { + document.getElementById('ueb-help-anchor')?.appendChild(_helpHandle.makeTriggerBtn()); + } el.querySelectorAll('.ueb-trainer-btn').forEach(btn => { btn.addEventListener('click', () => { diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index eaf08b0..a4a550b 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -313,13 +313,8 @@ const UI = (() => { }); } - // Kleiner ? Button oben rechts — immer sichtbar - const headerBtn = document.createElement('button'); - headerBtn.className = 'pinfo-trigger'; - headerBtn.setAttribute('aria-label', 'Hilfe'); - headerBtn.innerHTML = _svgIcon('question'); - headerBtn.addEventListener('click', _openModal); - container.appendChild(headerBtn); + // Kein automatischer absolut-positionierter Trigger mehr. + // Aufrufer kann openModal() nutzen und den Button selbst platzieren. // Banner beim ersten Besuch if (!seen) { @@ -340,6 +335,18 @@ const UI = (() => { banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); }); container.insertAdjacentElement('afterbegin', banner); } + + // Inline-Trigger-Button (für Aufrufer zum Einbetten) + function makeTriggerBtn() { + const btn = document.createElement('button'); + btn.className = 'pinfo-trigger-inline'; + btn.setAttribute('aria-label', 'Hilfe'); + btn.innerHTML = _svgIcon('question'); + btn.addEventListener('click', _openModal); + return btn; + } + + return { openModal: _openModal, makeTriggerBtn }; } // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index eb7074f..92786e4 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v662'; +const CACHE_VERSION = 'by-v663'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 84e6bfdd82aa89bc25cb1f6175633d9a6cb65596 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 21:23:41 +0200 Subject: [PATCH 14/27] Feature: Wiki Photo-Gallery mit Thumbnails + Lightbox, alle Fotos einer Rasse anklickbar (SW by-v664) --- backend/static/css/components.css | 133 ++++++++++++++++++++++++++++++ backend/static/index.html | 8 +- backend/static/js/app.js | 2 +- backend/static/js/pages/wiki.js | 111 +++++++++++++++++++------ backend/static/sw.js | 2 +- 5 files changed, 223 insertions(+), 33 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 2ea6a50..c8de6a6 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -5571,6 +5571,139 @@ html.modal-open { border-radius: 0; } +/* ── Wiki Gallery ────────────────────────────────────────── */ +.wiki-gallery-wrap { + position: relative; + margin-bottom: var(--space-3); +} +.wiki-gallery-main { + width: 100%; + height: 240px; + object-fit: cover; + object-position: center top; + border-radius: var(--radius-lg); + display: block; +} +.wiki-gallery-strip { + display: flex; + gap: var(--space-2); + overflow-x: auto; + padding: var(--space-2) 0 0; + scrollbar-width: none; +} +.wiki-gallery-strip::-webkit-scrollbar { display: none; } +.wiki-gallery-thumb { + flex-shrink: 0; + width: 64px; height: 64px; + border-radius: var(--radius-md); + overflow: hidden; + border: 2px solid transparent; + padding: 0; + background: none; + cursor: pointer; + position: relative; + transition: border-color .15s; +} +.wiki-gallery-thumb.active { border-color: var(--c-primary); } +.wiki-gallery-thumb img { + width: 100%; height: 100%; object-fit: cover; +} +.wiki-gallery-thumb-label { + position: absolute; + bottom: 0; left: 0; right: 0; + background: rgba(0,0,0,.55); + color: #fff; + font-size: 8px; + padding: 2px 4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.wiki-gallery-expand { + position: absolute; + top: var(--space-2); + right: var(--space-2); + width: 34px; height: 34px; + border-radius: 50%; + background: rgba(0,0,0,.45); + border: none; + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: background .15s; +} +.wiki-gallery-expand:hover { background: rgba(0,0,0,.65); } + +/* ── Wiki Lightbox ───────────────────────────────────────── */ +#wiki-lightbox { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} +.wlb-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,.88); + backdrop-filter: blur(6px); +} +.wlb-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + max-width: min(92vw, 680px); + width: 100%; + gap: var(--space-2); +} +.wlb-img { + width: 100%; + max-height: 72vh; + object-fit: contain; + border-radius: var(--radius-lg); +} +.wlb-close { + position: absolute; + top: -44px; + right: 0; + background: rgba(255,255,255,.12); + border: none; + color: #fff; + width: 36px; height: 36px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.wlb-prev, .wlb-next { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(255,255,255,.12); + border: none; + color: #fff; + width: 40px; height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + transition: background .15s; +} +.wlb-prev { left: -48px; } +.wlb-next { right: -48px; } +.wlb-prev:hover, .wlb-next:hover { background: rgba(255,255,255,.25); } +.wlb-caption { color: rgba(255,255,255,.75); font-size: var(--text-sm); } +.wlb-counter { color: rgba(255,255,255,.45); font-size: var(--text-xs); } + /* Steckbrief-Grid */ .wiki-steckbrief-grid { display: grid; diff --git a/backend/static/index.html b/backend/static/index.html index bea06b5..824ed47 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -562,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 04d872e..37ede79 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '663'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '664'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index b2b6390..b0c101d 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -730,36 +730,34 @@ window.Page_wiki = (() => { : ''; const _dogSvgLg = _DOG_SILHOUETTE.replace('width="48" height="48"', 'width="56" height="56"'); - const photoHtml = rasse.foto_url - ? `
- ${_esc(rasse.name)} -
- ` + // Alle Fotos: Hauptbild zuerst, dann Community-Fotos + const allFotos = []; + if (rasse.foto_url) allFotos.push({ foto_url: rasse.foto_url, user_name: null }); + (rasse.user_fotos || []).forEach(f => allFotos.push(f)); + + const photoHtml = allFotos.length + ? `` : `
${_dogSvgLg}Kein Foto verfügbar
`; const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug); - - const userFotosHtml = (rasse.user_fotos || []).length - ? `
-
Community-Fotos
-
- ${rasse.user_fotos.map(f => ` -
- ${_esc(f.user_name)} -
von ${_esc(f.user_name)}
-
- `).join('')} -
-
` - : ''; + const userFotosHtml = ''; const body = ` ${/* 1. Hero */ ''} @@ -851,6 +849,65 @@ window.Page_wiki = (() => { document.getElementById('wiki-zuchter-placeholder')?.remove(); }); + // Gallery-Thumbnails + Lightbox + const mainImg = document.getElementById('wiki-main-photo'); + const strip = document.getElementById('wiki-gallery-strip'); + if (strip && mainImg) { + strip.querySelectorAll('.wiki-gallery-thumb').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + mainImg.src = allFotos[idx].foto_url; + mainImg.style.display = ''; + document.getElementById('wiki-photo-fallback').style.display = 'none'; + strip.querySelectorAll('.wiki-gallery-thumb').forEach(b => b.classList.toggle('active', b === btn)); + }); + }); + } + + document.getElementById('wiki-gallery-expand')?.addEventListener('click', () => { + const src = mainImg?.src || allFotos[0]?.foto_url; + if (!src) return; + let curIdx = allFotos.findIndex(f => f.foto_url && src.endsWith(f.foto_url.split('/').pop())); + if (curIdx < 0) curIdx = 0; + + function _lbOpen(idx) { + const f = allFotos[idx]; + const lb = document.getElementById('wiki-lightbox'); + lb.querySelector('.wlb-img').src = f.foto_url; + lb.querySelector('.wlb-caption').textContent = f.user_name ? `Foto von ${f.user_name}` : rasse.name; + lb.querySelector('.wlb-counter').textContent = `${idx + 1} / ${allFotos.length}`; + lb.querySelector('.wlb-prev').style.display = allFotos.length > 1 ? '' : 'none'; + lb.querySelector('.wlb-next').style.display = allFotos.length > 1 ? '' : 'none'; + curIdx = idx; + } + + const lb = document.createElement('div'); + lb.id = 'wiki-lightbox'; + lb.innerHTML = ` +
+
+ + + + +
+
+
`; + document.body.appendChild(lb); + _lbOpen(curIdx); + + const close = () => lb.remove(); + lb.querySelector('.wlb-close').addEventListener('click', close); + lb.querySelector('.wlb-backdrop').addEventListener('click', close); + lb.querySelector('.wlb-prev').addEventListener('click', () => _lbOpen((curIdx - 1 + allFotos.length) % allFotos.length)); + lb.querySelector('.wlb-next').addEventListener('click', () => _lbOpen((curIdx + 1) % allFotos.length)); + document.addEventListener('keydown', function onKey(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } + if (e.key === 'ArrowLeft') lb.querySelector('.wlb-prev').click(); + if (e.key === 'ArrowRight') lb.querySelector('.wlb-next').click(); + }); + }); + document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => { UI.modal.close(); setTimeout(() => _showBerichtForm(slug, rasse.name), 350); diff --git a/backend/static/sw.js b/backend/static/sw.js index 92786e4..6cb3317 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v663'; +const CACHE_VERSION = 'by-v664'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 759979ffcedead599ef5453c310a1fbb41b23b4c Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:06:30 +0200 Subject: [PATCH 15/27] =?UTF-8?q?Feature:=20St=C3=BCndliche=20Niederschlag?= =?UTF-8?q?swahrscheinlichkeit=20auf=20Wetter-Seite=20(SW=20by-v690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Open-Meteo Forecast-Request um hourly precipitation_probability, precipitation und weathercode erweitert; stündliche Daten werden pro Tag gruppiert und im API-Response unter "hourly" je Tag ausgeliefert - Frontend: Neue _renderRainTimeline()-Funktion rendert horizontale Balken-Zeitskala für alle 24 Stunden des gewählten Tages; bei "Heute" wird automatisch zur aktuellen Stunde gescrollt und "jetzt" hervorgehoben; Farb-Gradient von hellgrau (<10%) bis dunkelblau (≥75%) - SW/APP_VER/CSS auf 690 gebumpt --- backend/static/index.html | 12 +-- backend/static/js/app.js | 2 +- backend/static/js/pages/wetter.js | 138 ++++++++++++++++++++++++++++++ backend/static/sw.js | 5 +- backend/weather.py | 21 +++++ 5 files changed, 169 insertions(+), 9 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 824ed47..cbcdd15 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -9,7 +9,6 @@ - @@ -76,6 +75,7 @@ + @@ -93,9 +93,9 @@ - - - + + + @@ -562,12 +562,12 @@ - + - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 37ede79..d2655e8 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '664'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '690'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index c838916..d9fbab2 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -187,6 +187,11 @@ window.Page_wetter = (() => { style="margin-bottom:var(--space-4)">
+ +
+
+
@@ -198,11 +203,13 @@ window.Page_wetter = (() => { _selDay = parseInt(card.dataset.wttrDay); _updateStrip(); _renderDetail(); + _renderRainTimeline(); _renderDog(); }); }); _renderDetail(); + _renderRainTimeline(); _renderDog(); } @@ -380,6 +387,137 @@ window.Page_wetter = (() => { `; } + // ---------------------------------------------------------- + // NIEDERSCHLAGS-ZEITSKALA (stündlich) + // ---------------------------------------------------------- + function _renderRainTimeline() { + const el = _container.querySelector('#wttr-rain'); + if (!el || !_data) return; + const d = (_data.days || [])[_selDay]; + if (!d) return; + + const hourly = d.hourly || []; + // Filtere auf Stunden mit Daten, die eine Niederschlagswahrscheinlichkeit haben + const entries = hourly.filter(h => h.precip_prob != null); + if (!entries.length) { el.style.display = 'none'; return; } + el.style.display = ''; + + // Für "Heute" (Tag 0): ab jetzt, sonst alle 24h + const now = new Date(); + const nowMin = now.getHours() * 60 + now.getMinutes(); + let slots = entries; + if (_selDay === 0) { + // Zeige ab der aktuellen Stunde (und die letzten 2h als Kontext) + const pastCutoff = now.getHours() - 2; + slots = entries.filter(h => { + const hHour = parseInt(h.hour.split(':')[0]); + return hHour >= pastCutoff; + }); + // Falls nichts übrig bleibt, zeige alles + if (!slots.length) slots = entries; + } + + // Max probability für Skalierung (mindestens 30 damit die Balken sichtbar sind) + const maxProb = Math.max(30, ...slots.map(h => h.precip_prob ?? 0)); + + // Farb-Funktion: blau basierend auf Wahrscheinlichkeit + function _rainColor(prob) { + if (prob < 10) return 'rgba(148,163,184,0.4)'; // grau, kaum Regen + if (prob < 25) return 'rgba(147,197,253,0.65)'; // hellblau + if (prob < 50) return 'rgba(96,165,250,0.8)'; // blau + if (prob < 75) return 'rgba(59,130,246,0.9)'; // kräftig blau + return 'rgba(29,78,216,1)'; // dunkelblau + } + + // Aktuell aktiver Slot (nur bei Heute) + const currentHour = now.getHours(); + + const bars = slots.map(h => { + const prob = h.precip_prob ?? 0; + const hHour = parseInt(h.hour.split(':')[0]); + const isNow = _selDay === 0 && hHour === currentHour; + const barH = Math.max(2, Math.round((prob / 100) * 56)); // max 56px Balkenhöhe + const color = _rainColor(prob); + const labelHour = h.hour.substring(0, 2); // 'HH' + + return ` +
+ +
+ ${prob >= 20 ? prob + '%' : ''} +
+ +
+
+
+ +
+ ${isNow ? 'jetzt' : labelHour + 'h'} +
+
+ `; + }).join(''); + + // Gibt es überhaupt nennenswerten Niederschlag? + const hasRain = slots.some(h => (h.precip_prob ?? 0) >= 10); + const titleColor = hasRain ? '#60A5FA' : 'var(--c-text-secondary)'; + const titleIcon = hasRain ? 'cloud-rain' : 'cloud'; + + el.innerHTML = ` +
+ + + + + Niederschlagswahrscheinlichkeit + + + ${_selDay === 0 ? 'heute' : _esc(d.date ? new Date(d.date + 'T12:00').toLocaleDateString('de', {weekday:'short', day:'numeric', month:'short'}) : '')} + +
+ +
+
+
+ ${bars} +
+
+ +
+
+ ${!hasRain ? ` +
+ Kein Regen erwartet +
` : ''} + `; + + // Scroll zum aktuellen Slot wenn Heute + if (_selDay === 0) { + requestAnimationFrame(() => { + const wrap = el.querySelector('div[style*="overflow-x"]'); + if (!wrap) return; + const nowIdx = slots.findIndex(h => parseInt(h.hour.split(':')[0]) === currentHour); + if (nowIdx > 2) { + wrap.scrollLeft = (nowIdx - 2) * 41; // ca. 38px + 3px gap + } + }); + } + } + // ---------------------------------------------------------- // HUNDE-WETTER // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 6cb3317..ca60ee8 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v664'; +const CACHE_VERSION = 'by-v690'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache @@ -202,7 +202,8 @@ self.addEventListener('fetch', event => { .then(resp => { if (resp.ok) { _cacheMark(url.pathname); - caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + const toCache = resp.clone(); + caches.open(CACHE_API).then(c => c.put(event.request, toCache)); } return resp; }) diff --git a/backend/weather.py b/backend/weather.py index e5f2317..5e836c6 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -222,6 +222,7 @@ async def get_forecast(lat: float, lon: float) -> dict: "apparent_temperature_min,precipitation_probability_max,precipitation_sum," "weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max," "sunrise,sunset" + "&hourly=precipitation_probability,precipitation,weathercode" "&timezone=auto&forecast_days=7" ) pollen_url = ( @@ -245,6 +246,7 @@ async def get_forecast(lat: float, lon: float) -> dict: raw = forecast_resp.json() daily = raw.get('daily', {}) + hourly_fc = raw.get('hourly', {}) timezone = raw.get('timezone', 'auto') dates = daily.get('time', []) @@ -261,6 +263,24 @@ async def get_forecast(lat: float, lon: float) -> dict: sunrises = daily.get('sunrise', []) sunsets = daily.get('sunset', []) + # --- Hourly precipitation data grouped by day --- + hourly_times = hourly_fc.get('time', []) + hourly_pp = hourly_fc.get('precipitation_probability', []) + hourly_precip = hourly_fc.get('precipitation', []) + hourly_wcode = hourly_fc.get('weathercode', []) + # Build: date_str → list of {hour, precip_prob, precip, weathercode} + _hourly_by_day: dict = {} + for idx, ts_str in enumerate(hourly_times): + day_str = ts_str[:10] # 'YYYY-MM-DD' + hour_str = ts_str[11:16] # 'HH:MM' + entry = { + 'hour': hour_str, + 'precip_prob': hourly_pp[idx] if idx < len(hourly_pp) else None, + 'precip': hourly_precip[idx] if idx < len(hourly_precip) else None, + 'weathercode': int(hourly_wcode[idx]) if idx < len(hourly_wcode) and hourly_wcode[idx] is not None else None, + } + _hourly_by_day.setdefault(day_str, []).append(entry) + # --- Pollen (optional) --- pollen_daily: dict | None = None if not isinstance(pollen_resp, Exception): @@ -361,6 +381,7 @@ async def get_forecast(lat: float, lon: float) -> dict: 'zecken': zecken, 'thunderstorm': wcode in {95, 96, 99}, 'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0), + 'hourly': _hourly_by_day.get(date_str, []), }) result = {'timezone': timezone, 'days': days} From 471633867c5f20ffe3487e0f5df7d2bd5449ba4f Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:18:38 +0200 Subject: [PATCH 16/27] =?UTF-8?q?Feature:=20Wetter=20=E2=80=94=20Gassi-Sco?= =?UTF-8?q?re,=20Schn=C3=BCffel-Index,=20Hunde-Alter-Hinweis=20(SW=20by-v6?= =?UTF-8?q?92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/static/js/app.js | 2 +- backend/static/js/pages/wetter.js | 232 +++++++++++++++++++++++++++++- backend/static/sw.js | 2 +- 3 files changed, 229 insertions(+), 7 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d2655e8..bf0f697 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '690'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '692'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index d9fbab2..538b64f 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -325,6 +325,9 @@ window.Page_wetter = (() => {
+ + ${_gassiScoreBadge(d)} + ${sunriseStr && sunsetStr ? `
@@ -528,11 +531,23 @@ window.Page_wetter = (() => { if (!d) return; const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' }; - let html = `

- - Hunde-Wetter -

`; + const _wl = _dogWeatherLabel(d); + let html = ` +
+
${_wl.emoji}
+
+ ${_esc(_wl.label)} +
+
+ ${_esc(_wl.sub)} +
+
+

+ + Hunde-Hinweise +

`; // Asphalt-Temperatur if (d.asphalt_temp != null) { @@ -638,6 +653,16 @@ window.Page_wetter = (() => { `; } + // Schnüffel-Index + Hunde-Alter Chips + const ageYears = _dogAgeYears(); + html += _dogAgeChip(ageYears); + + html += ` +
+ ${_schnueffelChip(d)} +
+ `; + // Wenn keine Hunde-Daten vorhanden if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm && !d.zecken && !(pollen && Object.keys(pollen).length)) { @@ -651,6 +676,160 @@ window.Page_wetter = (() => { el.innerHTML = html; } + // ---------------------------------------------------------- + // GASSI-SCORE (1–10) + // ---------------------------------------------------------- + function _gassiScore(d) { + let score = 10; + const temp = d.temp_max ?? 20; + const precip = d.precip_prob ?? 0; + const wind = d.windspeed_max ?? 0; + const asphalt = d.asphalt_temp ?? 0; + + // Temperatur (ideal: 10–20°C) + if (temp > 30) score -= 3; + else if (temp > 25) score -= 1; + else if (temp < 0) score -= 3; + else if (temp < 5) score -= 1; + + // Regen + if (precip > 70) score -= 3; + else if (precip > 40) score -= 2; + else if (precip > 20) score -= 1; + + // Wind + if (wind > 60) score -= 2; + else if (wind > 40) score -= 1; + + // Asphalt + if (asphalt > 55) score -= 2; + else if (asphalt > 45) score -= 1; + + // Gewitter + if (d.thunderstorm) score -= 3; + + return Math.max(1, Math.min(10, score)); + } + + function _gassiScoreBadge(d) { + const score = _gassiScore(d); + let color, text; + if (score >= 8) { + color = '#10B981'; + text = 'Toller Gassi-Tag!'; + } else if (score >= 5) { + color = '#F59E0B'; + text = 'Geht so'; + } else { + color = '#EF4444'; + text = 'Lieber drinbleiben'; + } + return ` +
+ 🐾 Gassi-Score + + ${score} + + / 10 + — ${_esc(text)} +
+ `; + } + + // ---------------------------------------------------------- + // SCHNÜFFEL-INDEX + // ---------------------------------------------------------- + function _schnueffelIndex(d) { + const temp = d.temp_max ?? 20; + const precip = d.precip_prob ?? 0; + + // Feuchtigkeit aus precip_prob ableiten + const feucht = precip > 60 ? 'feucht' : precip > 30 ? 'leicht-feucht' : 'trocken'; + + if (feucht === 'feucht' && temp >= 10 && temp <= 18) + return { label:'Exzellent 👃', color:'#10B981' }; + if (feucht === 'feucht' && temp > 10 && temp <= 22) + return { label:'Sehr gut 👃', color:'#34D399' }; + if (temp < 5) + return { label:'Gut (kalte Luft trägt Gerüche)', color:'#60A5FA' }; + if (feucht === 'leicht-feucht' && temp >= 10 && temp <= 22) + return { label:'Gut 👃', color:'#4CAF50' }; + if (feucht === 'trocken' && temp > 25) + return { label:'Schwach', color:'#94A3B8' }; + return { label:'Mittel', color:'#F59E0B' }; + } + + function _schnueffelChip(d) { + const s = _schnueffelIndex(d); + return ` + + + Schnüffel: ${_esc(s.label)} + + `; + } + + // ---------------------------------------------------------- + // HUNDE-ALTER aus appState + // ---------------------------------------------------------- + function _dogAgeYears() { + try { + const dog = _appState?.activeDog || _appState?.dog || _appState?.active_dog; + if (!dog) return null; + const geb = dog.geburtsdatum || dog.birthdate; + if (!geb) return null; + const birth = new Date(geb); + if (isNaN(birth)) return null; + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const m = now.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--; + return age < 0 ? 0 : age; + } catch { return null; } + } + + function _dogAgeChip(ageYears) { + if (ageYears === null) return ''; + if (ageYears < 1) { + return ` +
+ +
+ Welpe — kurze Spaziergänge, max. 15 Min bei Hitze. + Gelenke und Pfoten besonders schonen. +
+
+ `; + } + if (ageYears >= 8) { + return ` +
+ +
+ Seniorhund — Hitze und Kälte vermeiden, kurze Runden bevorzugen. + Auf Gelenkbeschwerden achten. +
+
+ `; + } + return ''; + } + // ---------------------------------------------------------- // HILFSFUNKTIONEN — Wetter // ---------------------------------------------------------- @@ -695,6 +874,49 @@ window.Page_wetter = (() => { return '#F44336'; // level 4+ } + function _dogWeatherLabel(d) { + const temp = d.temp_max ?? 20; + const tempMin = d.temp_min ?? temp; + const precip = d.precip_prob ?? 0; + const wind = d.windspeed_max ?? 0; + const asphalt = d.asphalt_temp ?? 0; + const wcode = d.weathercode ?? 0; + const isSnow = wcode >= 71 && wcode <= 77; + if (d.thunderstorm) + return { label:'Gewitterangst-Wetter', sub:'Angsthasen lieber zu Hause lassen', emoji:'⛈️', color:'#7C3AED' }; + if (isSnow && temp < 3) + return { label:'Schnee-Toben-Wetter', sub:'Pudel im Schnee — der Klassiker', emoji:'❄️', color:'#38BDF8' }; + if (isSnow) + return { label:'Matschpfoten-Wetter', sub:'Pfoten nach der Runde gut abtrocknen', emoji:'🌨️', color:'#60A5FA' }; + if (tempMin < 0 && precip < 30) + return { label:'Kristallklare Nasenluft', sub:'Kalt aber herrlich — Schnüffeln auf Maximum', emoji:'🌡️', color:'#60A5FA' }; + if (temp < 5 && precip > 50) + return { label:'Kuschelwetter', sub:'Kurze Runde, dann ab auf das Sofa', emoji:'🏠', color:'#6B7280' }; + if (temp < 5) + return { label:'Fellkuschelwetter', sub:'Frisch und klar — ideal für aktive Rassen', emoji:'🧣', color:'#93C5FD' }; + if (temp > 28 && asphalt > 50) + return { label:'Pfoten-Alarm!', sub:'Asphalt zu heiß — früh morgens oder abends raus', emoji:'🔥', color:'#EF4444' }; + if (temp > 28) + return { label:'Schwimm-Wetter', sub:'Bach oder See suchen — Hunde überhitzen schnell', emoji:'🏊', color:'#F97316' }; + if (precip > 70 && temp < 15) + return { label:'Nass-Hund-Wetter', sub:'Handtuch bereit? Der Geruch kommt garantiert', emoji:'💧', color:'#3B82F6' }; + if (precip > 70) + return { label:'Warm-Dusch-Wetter', sub:'Wer braucht noch ein Bad — der Regen übernimmt', emoji:'🌧️', color:'#60A5FA' }; + if (precip > 30 && temp >= 10 && temp <= 20) + return { label:'Schnüffel-Wetter', sub:'Feuchte Luft = Nasenarbeit pur — Gerüche lieben das', emoji:'👃', color:'#34D399' }; + if (wind > 50) + return { label:'Sturmfrisur-Wetter', sub:'Fell in alle Richtungen — Leine gut festhalten', emoji:'🌬️', color:'#A78BFA' }; + if (wind > 30 && temp >= 15) + return { label:'Ohren-im-Wind-Wetter', sub:'Optimal für Hunde mit Schlappohren', emoji:'💨', color:'#A78BFA' }; + if (precip > 30 && precip <= 70) + return { label:'Gassiregen-Wetter', sub:'Leichte Jacke, kurze Runde — Hund findet es gut', emoji:'🌦️', color:'#60A5FA' }; + if (temp >= 18 && temp <= 26 && precip < 20) + return { label:'Perfektes Gassi-Wetter',sub:'Heute müssen alle Routen genossen werden', emoji:'🐾', color:'#10B981' }; + if (temp >= 10 && temp < 18 && precip < 30) + return { label:'Klassisches Hunde-Wetter', sub:'Nicht zu warm, nicht zu kalt — Vierbeiner-Paradies', emoji:'🐕', color:'#4CAF50' }; + return { label:'Gutes Hunde-Wetter', sub:'Raus mit dem Hund!', emoji:'🐶', color:'#10B981' }; + } + function _tickLevel(risk) { const r = (risk || '').toLowerCase(); if (r === 'niedrig') return ['niedrig', '#4CAF50']; diff --git a/backend/static/sw.js b/backend/static/sw.js index ca60ee8..894a342 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v690'; +const CACHE_VERSION = 'by-v692'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From af1508c0de62179daa9262a6f3a834bee0a8994f Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:21:02 +0200 Subject: [PATCH 17/27] =?UTF-8?q?Feature:=20Fell-Typ=20Einstellung=20am=20?= =?UTF-8?q?Hundeprofil=20=E2=80=94=20personalisierte=20Wetter-Hinweise=20(?= =?UTF-8?q?SW=20by-v693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB-Migration: dogs.fell_typ (kurz|mittel|lang|drahtaar|doppel|nackt) - Hund-Profil Formular: Dropdown "Felltyp" mit 6 Optionen, wird via PATCH /api/dogs/{id} gespeichert - Wetter: _dogWeatherLabel(d, felltyp) mit fell-spezifischen Hitze-/Kälteschwellen - Wetter: Fell-spezifische Hinweise (doppel + Hitze, nackt + Kälte, kurz + Frost) --- backend/database.py | 11 ++++++ backend/static/index.html | 8 ++-- backend/static/js/app.js | 2 +- backend/static/js/pages/dog-profile.js | 18 +++++++++ backend/static/js/pages/wetter.js | 51 +++++++++++++++++++++++--- backend/static/sw.js | 2 +- 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/backend/database.py b/backend/database.py index f20a924..d6b0dfe 100644 --- a/backend/database.py +++ b/backend/database.py @@ -540,6 +540,9 @@ def _migrate(conn_factory): ("pflege_tipps", "fell_pflege_art", "TEXT"), # Wiki-Foto-Einreichungen: Bildrechte-Bestätigung ("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"), + # Tagebuch-Medien: Bildmaße für Querformat-Filter + ("diary_media", "img_width", "INTEGER"), + ("diary_media", "img_height", "INTEGER"), # Tagebuch: Wetter + POI-Metadaten beim Eintrag ("diary", "weather_json", "TEXT"), ("diary", "poi_json", "TEXT"), @@ -568,6 +571,8 @@ def _migrate(conn_factory): # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), + # Fell-Typ für personalisierte Wetter-Hinweise + ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1940,6 +1945,12 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie); """) + # Goldene Gassi-Stunde: User-Einstellung + existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'gassi_stunde_push' not in existing_u: + conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0") + logger.info("Migration: users.gassi_stunde_push bereit.") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( diff --git a/backend/static/index.html b/backend/static/index.html index cbcdd15..5ce7819 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -562,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bf0f697..c235900 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '692'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '693'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 82a2e8a..5e40c51 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -970,6 +970,23 @@ window.Page_dog_profile = (() => {
+
+ + +
+
-
+
KI-Notiz-Assistent
@@ -336,6 +336,30 @@ window.Page_settings = (() => {
+ +
+ +
+
Goldene Gassi-Stunde täglich
+
+ Täglich um 07:00 Uhr: bestes Wetterfenster für den Gassi-Gang +
+
+ +
+
@@ -785,6 +809,25 @@ window.Page_settings = (() => { } }); + document.getElementById('toggle-gassi-stunde')?.addEventListener('change', async e => { + const enabled = e.target.checked; + const track = document.getElementById('toggle-gassi-stunde-track'); + const thumb = document.getElementById('toggle-gassi-stunde-thumb'); + if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = enabled ? '22px' : '2px'; + try { + await API.patch('/profile', { gassi_stunde_push: enabled ? 1 : 0 }); + _appState.user.gassi_stunde_push = enabled ? 1 : 0; + UI.toast.success(enabled ? 'Goldene Gassi-Stunde aktiviert.' : 'Goldene Gassi-Stunde deaktiviert.'); + } catch (err) { + UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); + // Revert UI + e.target.checked = !enabled; + if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = !enabled ? '22px' : '2px'; + } + }); + _loadReferral(); _loadBreederCard(); } From d0810296183a10e93177226e89bf34fe65142955 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:26:03 +0200 Subject: [PATCH 19/27] Feature: Wetter-Tapferkeits-, Jahreszeiten- und Schnee-Badges (SW by-v693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei neue Badge-Kategorien in achievements.py: - wetter_tapfer: Diary-Einträge bei Regen/Kälte/Wind (precip>60, temp<2, wind>50) - jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (max 4) - schnee_held: Diary-Einträge bei Schnee (weathercode 71-77) Beide Funktionen check_and_award und my_achievements erweitert. --- backend/routes/achievements.py | 129 ++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 10 deletions(-) diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index 1c0c1e8..00b8748 100644 --- a/backend/routes/achievements.py +++ b/backend/routes/achievements.py @@ -92,6 +92,45 @@ CATEGORIES = [ ("gold", 10, "Wiki-Fotograf"), ], }, + { + "id": "wetter_tapfer", + "name": "Wetter-Tapferkeit", + "emoji": "⛈️", + "metrik": "wetter_tapfer_score", + "einheit": " Eintrag/Einträge", + "stufen": [ + ("bronze", 1, "Regentrotzdem"), + ("silber", 5, "Wettertrotzer"), + ("gold", 15, "Allwetter-Held"), + ("platin", 30, "Hunde-Wetterheld"), + ], + }, + { + "id": "jahreszeiten", + "name": "Jahreszeiten-Erkunder", + "emoji": "🍃", + "metrik": "jahreszeiten_score", + "einheit": " Jahreszeit(en)", + "stufen": [ + ("bronze", 1, "Frühlings-Erkunder"), + ("silber", 2, "Sommer-Genießer"), + ("gold", 3, "Herbst-Schnüffler"), + ("platin", 4, "Alle-Jahreszeiten"), + ], + }, + { + "id": "schnee_held", + "name": "Schneeheld", + "emoji": "❄️", + "metrik": "schnee_eintraege", + "einheit": " Eintrag/Einträge", + "stufen": [ + ("bronze", 1, "Erster Schnee"), + ("silber", 5, "Schneehund"), + ("gold", 15, "Schneeheld"), + ("platin", 30, "Schneewolf"), + ], + }, ] # Flat-Liste aller Badge-IDs für DB-Kompatibilität @@ -150,12 +189,47 @@ def check_and_award(user_id: int, conn): "SELECT current_streak FROM users WHERE id=?", (user_id,) ).fetchone() + # Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter + wetter_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.user_id = ? + AND d.weather_json IS NOT NULL + AND ( + CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60 + OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2 + OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50 + ) + """, (user_id,)).fetchone() + + # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen + jahreszeiten_row = conn.execute(""" + SELECT + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END) + AS jahreszeiten_score + FROM (SELECT 1) + """, (user_id, user_id, user_id, user_id)).fetchone() + + # Schnee: Diary-Einträge bei Schnee (weathercode 71-77) + schnee_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary + WHERE user_id = ? + AND weather_json IS NOT NULL + AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77 + """, (user_id,)).fetchone() + metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, } earned = {r["badge_id"] for r in @@ -211,6 +285,38 @@ async def my_achievements(user=Depends(get_current_user)): "SELECT current_streak, max_streak FROM users WHERE id=?", (uid,) ).fetchone() + # Wetter-Tapferkeit + wetter_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.user_id = ? + AND d.weather_json IS NOT NULL + AND ( + CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60 + OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2 + OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50 + ) + """, (uid,)).fetchone() + + # Jahreszeiten + jahreszeiten_row = conn.execute(""" + SELECT + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) + + (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END) + AS jahreszeiten_score + FROM (SELECT 1) + """, (uid, uid, uid, uid)).fetchone() + + # Schnee-Einträge + schnee_row = conn.execute(""" + SELECT COUNT(*) AS cnt FROM diary + WHERE user_id = ? + AND weather_json IS NOT NULL + AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77 + """, (uid,)).fetchone() + earned_rows = conn.execute( "SELECT badge_id FROM user_badges WHERE user_id=?", (uid,) ).fetchall() @@ -230,11 +336,14 @@ async def my_achievements(user=Depends(get_current_user)): """, (stats["punkte"] if stats else 0,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, } # Kategorien mit aktuellem Tier + Fortschritt aufbauen From 6152d6bf0e3f1b34decfc31fcff4cc7996be87c0 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:28:06 +0200 Subject: [PATCH 20/27] Feature: Meine Wetterrekorde Sektion auf Wetter-Seite (SW by-v694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: GET /api/weather/records — liest diary-Einträge mit weather_json und berechnet Kältester/Heißester Gassi, Stürmischster Tag, Regentage - Frontend: #wttr-records 2×2 Grid-Karten unterhalb Hunde-Wetter (nur für eingeloggte User mit ≥3 Tagebucheinträgen mit Wetterdaten) - SW-Version auf by-v694 erhöht, APP_VER auf 694 --- backend/routes/weather.py | 56 ++++++++++++++ backend/static/index.html | 8 +- backend/static/js/app.js | 2 +- backend/static/js/pages/wetter.js | 117 ++++++++++++++++++++++++++++-- backend/static/sw.js | 2 +- 5 files changed, 173 insertions(+), 12 deletions(-) diff --git a/backend/routes/weather.py b/backend/routes/weather.py index fced719..ba45306 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,9 +3,11 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ +import json from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module from auth import get_current_user +from database import db router = APIRouter() @@ -31,3 +33,57 @@ async def get_weather_forecast( return await weather_module.get_forecast(lat, lon) except Exception as exc: raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}') + + +@router.get('/records') +async def weather_records(user=Depends(get_current_user)): + """Persönliche Wetterrekorde aus diary-Einträgen mit weather_json.""" + uid = user["id"] + with db() as conn: + rows = conn.execute(""" + SELECT d.datum, d.weather_json, d.titel + FROM diary d + WHERE d.user_id = ? AND d.weather_json IS NOT NULL + ORDER BY d.datum ASC + """, (uid,)).fetchall() + + if not rows: + return {"records": None} + + entries = [] + for r in rows: + try: + w = json.loads(r["weather_json"]) + entries.append({ + "datum": r["datum"], + "titel": r["titel"], + "temp_c": w.get("temp_c"), + "wind_kmh": w.get("wind_kmh"), + "precip_prob": w.get("precip_prob"), + "desc": w.get("desc", ""), + "weathercode": w.get("weathercode"), + }) + except Exception: + pass + + if not entries: + return {"records": None} + + temps = [e for e in entries if e["temp_c"] is not None] + winds = [e for e in entries if e["wind_kmh"] is not None] + + records = {} + if temps: + kaeltester = min(temps, key=lambda e: e["temp_c"]) + heissester = max(temps, key=lambda e: e["temp_c"]) + records["kaeltester"] = kaeltester + records["heissester"] = heissester + if winds: + stuermischster = max(winds, key=lambda e: e["wind_kmh"]) + records["stuermischster"] = stuermischster + + regen_count = sum(1 for e in entries if (e.get("precip_prob") or 0) > 60) + records["regen_eintraege"] = regen_count + records["gesamt_eintraege"] = len(entries) + + return {"records": records} diff --git a/backend/static/index.html b/backend/static/index.html index 5ce7819..837f2eb 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -562,7 +562,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index c235900..393bf53 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '693'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '694'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index a06ac23..755b825 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -55,11 +55,12 @@ window.Page_wetter = (() => { // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _data = null; - let _selDay = 0; - let _loading = false; + let _container = null; + let _appState = null; + let _data = null; + let _selDay = 0; + let _loading = false; + let _recordsLoaded = false; // ---------------------------------------------------------- // INIT @@ -76,7 +77,8 @@ window.Page_wetter = (() => { // REFRESH // ---------------------------------------------------------- async function refresh() { - _selDay = 0; + _selDay = 0; + _recordsLoaded = false; _renderShell(); _tryAutoLocate(); } @@ -195,6 +197,10 @@ window.Page_wetter = (() => {
+ + +
+
`; // Strip-Klick-Events @@ -211,6 +217,7 @@ window.Page_wetter = (() => { _renderDetail(); _renderRainTimeline(); _renderDog(); + _loadRecords(); } // ---------------------------------------------------------- @@ -972,6 +979,104 @@ window.Page_wetter = (() => { .replace(/"/g, '"'); } + // ---------------------------------------------------------- + // MEINE WETTERREKORDE + // ---------------------------------------------------------- + async function _loadRecords() { + // Nur wenn User eingeloggt + if (!_appState?.user) return; + // Nur einmal pro Seitenaufruf laden + if (_recordsLoaded) return; + _recordsLoaded = true; + try { + const res = await API.get('/weather/records'); + _renderRecords(res?.records || null); + } catch { + // Stumm scheitern — Rekorde sind ein Nice-to-have + } + } + + function _fmtDate(datum) { + if (!datum) return ''; + try { + return new Date(datum + 'T12:00').toLocaleDateString('de', { + day: 'numeric', month: 'short', year: 'numeric' + }); + } catch { return datum; } + } + + function _recordCard(emoji, title, value, subtitle, color) { + return ` +
+
+ ${emoji} + ${_esc(title)} +
+
+ ${_esc(value)} +
+
+ ${_esc(subtitle)} +
+
+ `; + } + + function _renderRecords(records) { + const el = _container.querySelector('#wttr-records'); + if (!el) return; + + // Mindestens 3 Einträge nötig + if (!records || (records.gesamt_eintraege || 0) < 3) { + el.innerHTML = ''; + return; + } + + const cards = []; + + if (records.kaeltester) { + const e = records.kaeltester; + const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum); + cards.push(_recordCard('🥶', 'Kältester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#60A5FA')); + } + + if (records.heissester) { + const e = records.heissester; + const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum); + cards.push(_recordCard('🔥', 'Heißester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#EF4444')); + } + + if (records.stuermischster) { + const e = records.stuermischster; + const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum); + cards.push(_recordCard('🌬️', 'Stürmischster Tag', `${Math.round(e.wind_kmh)} km/h`, sub, '#A78BFA')); + } + + const regenCount = records.regen_eintraege || 0; + const gesamt = records.gesamt_eintraege || 0; + cards.push(_recordCard('💧', 'Regentage', `${regenCount} Einträge`, `von ${gesamt} Tagebucheinträgen`, '#3B82F6')); + + el.innerHTML = ` +
+

+ + + + Meine Wetterrekorde +

+
+ ${cards.join('')} +
+
+ `; + } + // ---------------------------------------------------------- // PUBLIC API // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 926497b..7ce0992 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v693'; +const CACHE_VERSION = 'by-v694'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From b1d9fb4f54e8598ca1bc853eb13ce4239afadbc0 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:30:06 +0200 Subject: [PATCH 21/27] =?UTF-8?q?Feature:=20Wetter-Verbesserung=20im=20Tag?= =?UTF-8?q?ebuch=20=E2=80=94=20Auto-Wetter,=20Chip-Fix,=20Detail-Fix=20(SW?= =?UTF-8?q?=20by-v695)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - diary.js: Weather-Chip in der Liste nutzt jetzt temp_c (korrekter Feldname) - diary.js: Detail-View zeigt "emoji temp · X km/h Wind · Y% Regen" (precip_prob statt Luftfeuchtigkeit) - diary.js: Bei neuem Eintrag ohne GPS → Wetter wird via GPS-API vorgeholt und als weather_json mitgesendet - diary.py: DiaryCreate-Modell um weather_json-Feld erweitert; client-geliefertes Wetter wird gespeichert wenn kein GPS-basiertes Wetter verfügbar - SW by-v695, APP_VER 695 --- backend/routes/diary.py | 27 ++++++++++++++++++++++----- backend/static/js/app.js | 2 +- backend/static/js/pages/diary.js | 28 +++++++++++++++++++--------- backend/static/sw.js | 2 +- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/backend/routes/diary.py b/backend/routes/diary.py index a3dee2b..6f6cd12 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -9,7 +9,7 @@ from auth import get_current_user, require_admin import ki as KI import httpx import weather as weather_mod -from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from +from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size from timeutils import safe_client_time logger = logging.getLogger(__name__) @@ -30,6 +30,7 @@ class DiaryCreate(BaseModel): location_name: Optional[str] = None is_milestone: bool = False dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary + weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS) class DiaryUpdate(BaseModel): @@ -350,6 +351,19 @@ async def create_diary(dog_id: int, data: DiaryCreate, ) entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() + elif data.weather_json: + # Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern + try: + json.loads(data.weather_json) # Validierung + with db() as conn: + conn.execute( + "UPDATE diary SET weather_json=? WHERE id=?", + (data.weather_json, entry_id) + ) + entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() + except Exception as exc: + logger.warning("Client-weather_json ungültig: %s", exc) + return _entry_dict(entry, dogs_map, media_map) @@ -692,10 +706,12 @@ async def upload_media(dog_id: int, entry_id: int, media_url = f"/media/diary/{filename}" - # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien) - exif_gps = None + # Bildmaße + EXIF-GPS (nur bei Bilddateien) + exif_gps = None + img_size = None if media_type == "image": exif_gps = extract_gps_from_exif(raw_data) + img_size = get_image_size(raw_data) with db() as conn: # sort_order = nächste freie Position @@ -706,8 +722,9 @@ async def upload_media(dog_id: int, entry_id: int, # Erstes Item eines Eintrags wird automatisch Cover is_cover = 1 if max_order == -1 else 0 conn.execute( - "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)", - (entry_id, media_url, media_type, max_order + 1, is_cover) + "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)", + (entry_id, media_url, media_type, max_order + 1, is_cover, + img_size[0] if img_size else None, img_size[1] if img_size else None) ) new_id = conn.execute( "SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 393bf53..4b5071c 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '694'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '695'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index a39eccd..66d9150 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -868,9 +868,9 @@ window.Page_diary = (() => { if (e.weather_json) { try { const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json; - const temp = w?.temperature_2m ?? w?.temp_c; + const temp = w?.temp_c ?? w?.temperature_2m; if (temp != null) { - metaParts.push(`${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°`); + metaParts.push(`${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°`); } } catch (_) {} } @@ -1073,15 +1073,14 @@ window.Page_diary = (() => { if (entry.weather_json) { try { const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json; - const temp = w?.temperature_2m ?? w?.temp_c; + const temp = w?.temp_c ?? w?.temperature_2m; if (w && temp != null) { - const feels = w.apparent_temperature ?? w.feels_like_c; - const wind = w.wind_speed_10m ?? w.wind_kmh; + const wind = w.wind_kmh ?? w.wind_speed_10m; + const precip = w.precip_prob; const parts = [ - `${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`, - feels != null ? `gefühlt ${Math.round(feels)}°` : null, - wind != null ? `💨 ${Math.round(wind)} km/h` : null, - w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null, + `${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`, + wind != null ? `${Math.round(wind)} km/h Wind` : null, + precip != null ? `${precip}% Regen` : null, ].filter(Boolean).join(' · '); metaItems.push(`${parts}`); } @@ -1728,6 +1727,16 @@ window.Page_diary = (() => { }); await UI.asyncButton(submitBtn, async () => { + // Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort + let _clientWeather = null; + if (!isEdit && _locLat == null) { + try { + const pos = await API.getLocation(); + const wd = await API.weather.get(pos.lat, pos.lon); + if (wd && wd.temp_c != null) _clientWeather = JSON.stringify(wd); + } catch (_) { /* GPS oder Wetter nicht verfügbar → kein Problem */ } + } + const payload = { datum: fd.datum || null, typ: fd.typ, @@ -1739,6 +1748,7 @@ window.Page_diary = (() => { gps_lon: _locLon, location_name: _locName, client_time: API.clientNow(), + weather_json: _clientWeather, }; async function _uploadNewFiles(entryId) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 7ce0992..28edf7a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v694'; +const CACHE_VERSION = 'by-v695'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 6e4bf255810cfc000de65c4c11fc7fe5ee94fdd8 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:51:45 +0200 Subject: [PATCH 22/27] =?UTF-8?q?Feature:=20Hundeern=C3=A4hrungs-Feature?= =?UTF-8?q?=20=E2=80=94=20Kalorien-Rechner,=20Futter-Guide,=20Giftliste,?= =?UTF-8?q?=20KI-Berater=20(SW=20by-v698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 15 + backend/main.py | 61 ++- backend/routes/ernaehrung.py | 145 +++++++ backend/static/index.html | 16 +- backend/static/js/app.js | 4 +- backend/static/js/pages/ernaehrung.js | 603 ++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 7 files changed, 838 insertions(+), 8 deletions(-) create mode 100644 backend/routes/ernaehrung.py create mode 100644 backend/static/js/pages/ernaehrung.js diff --git a/backend/database.py b/backend/database.py index d6b0dfe..a98cda8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1951,6 +1951,21 @@ def _migrate(conn_factory): conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0") logger.info("Migration: users.gassi_stunde_push bereit.") + # Futter-Profil + conn.executescript(""" + CREATE TABLE IF NOT EXISTS futter_profil ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE, + futter_typ TEXT, + marke TEXT, + kcal_tag INTEGER, + portionen INTEGER DEFAULT 2, + notizen TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + logger.info("Migration: futter_profil bereit.") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( diff --git a/backend/main.py b/backend/main.py index 229a856..1d23aef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,9 +6,10 @@ import os import html import logging from collections import deque +import httpx from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware from brotli_asgi import BrotliMiddleware @@ -43,10 +44,43 @@ logger = logging.getLogger(__name__) # ------------------------------------------------------------------ # Startup / Shutdown # ------------------------------------------------------------------ +def _backfill_image_sizes(): + """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach.""" + import io + from database import db + from media_utils import get_image_size + MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + with db() as conn: + rows = conn.execute( + "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL" + ).fetchall() + if not rows: + return + logger.info("Backfill Bildmaße: %d Einträge...", len(rows)) + updated = 0 + for row in rows: + # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg + rel = row["url"].removeprefix("/media/") + path = os.path.join(MEDIA_DIR, rel) + try: + with open(path, "rb") as f: + data = f.read() + size = get_image_size(data) + if size: + with db() as conn: + conn.execute( + "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?", + (size[0], size[1], row["id"]) + ) + updated += 1 + except Exception: + pass + logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows)) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Ban Yaro startet...") init_db() + _backfill_image_sizes() from routes.movies import seed_movies seed_movies() logger.info(f"KI-Modus: {ki.KI_MODE}") @@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " @@ -198,6 +232,7 @@ from routes.adoption import router as adoption_router from routes.health_docs import router as health_docs_router from routes.passport import router as passport_router from routes.playdate import router as playdate_router +from routes.ernaehrung import router as ernaehrung_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) +app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"]) # ------------------------------------------------------------------ @@ -285,6 +321,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") +@app.get("/stats/script.js") +async def umami_script_proxy(): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get("https://umami.motocamp.de/script.js") + return Response(content=r.content, media_type="application/javascript", + headers={"Cache-Control": "public, max-age=86400"}) + +@app.post("/stats/api/send") +async def umami_send_proxy(request: Request): + body = await request.body() + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + "https://umami.motocamp.de/api/send", + content=body, + headers={"Content-Type": "application/json", + "User-Agent": request.headers.get("user-agent", "")}, + ) + return Response(content=r.content, status_code=r.status_code, + media_type="application/json") + + @app.get("/robots.txt") async def robots(): return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain") diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py new file mode 100644 index 0000000..c1f850e --- /dev/null +++ b/backend/routes/ernaehrung.py @@ -0,0 +1,145 @@ +"""BAN YARO — Ernährungs-Routes""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user +import ki as ki_module + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class FutterProfilUpdate(BaseModel): + futter_typ: Optional[str] = None # trocken|nass|barf|mix + marke: Optional[str] = None + kcal_tag: Optional[int] = None + portionen: Optional[int] = None + notizen: Optional[str] = None + + +class KiBeratungRequest(BaseModel): + frage: str + dog_name: Optional[str] = None + rasse: Optional[str] = None + alter: Optional[str] = None + gewicht: Optional[float] = None + aktiv: Optional[bool] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Zugriffsprüfung +# ------------------------------------------------------------------ +def _check_dog_access(conn, dog_id: int, user_id: int): + row = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Hund nicht gefunden.") + + +# ------------------------------------------------------------------ +# GET /dogs/{dog_id}/ernaehrung +# ------------------------------------------------------------------ +@router.get("/{dog_id}/ernaehrung") +async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + row = conn.execute( + "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + if not row: + return {} + return dict(row) + + +# ------------------------------------------------------------------ +# PUT /dogs/{dog_id}/ernaehrung +# ------------------------------------------------------------------ +@router.put("/{dog_id}/ernaehrung") +async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate, + user=Depends(get_current_user)): + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + existing = conn.execute( + "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + if existing: + conn.execute(""" + UPDATE futter_profil + SET futter_typ=COALESCE(?, futter_typ), + marke=COALESCE(?, marke), + kcal_tag=COALESCE(?, kcal_tag), + portionen=COALESCE(?, portionen), + notizen=COALESCE(?, notizen), + updated_at=datetime('now') + WHERE dog_id=? + """, (body.futter_typ, body.marke, body.kcal_tag, + body.portionen, body.notizen, dog_id)) + else: + conn.execute(""" + INSERT INTO futter_profil + (dog_id, futter_typ, marke, kcal_tag, portionen, notizen) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, body.futter_typ, body.marke, body.kcal_tag, + body.portionen or 2, body.notizen)) + row = conn.execute( + "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# POST /dogs/{dog_id}/ernaehrung/ki-beratung +# ------------------------------------------------------------------ +@router.post("/{dog_id}/ernaehrung/ki-beratung") +async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest, + request: Request, + user=Depends(get_current_user)): + if not body.frage or len(body.frage.strip()) < 3: + raise HTTPException(400, "Bitte stelle eine Frage.") + if len(body.frage) > 800: + raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).") + + with db() as conn: + _check_dog_access(conn, dog_id, user["id"]) + + dog_name = body.dog_name or "unbekannt" + rasse = body.rasse or "unbekannt" + alter = body.alter or "unbekannt" + gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt" + aktiv_str = "aktiv" if body.aktiv else "normal aktiv" + + system = ( + "Du bist Ernährungsberater für Hunde. " + "Antworte immer auf Deutsch, kurz und praktisch. " + "Keine unnötigen Füllsätze. " + "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. " + "Stelle keine medizinischen Diagnosen." + ) + + prompt = ( + f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, " + f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n" + f"Frage: {body.frage.strip()}\n\n" + "Antworte konkret und praktisch, maximal 200 Wörter." + ) + + try: + antwort = await ki_module.complete( + prompt=prompt, + system=system, + max_tokens=500, + requires_premium=False, + user_id=user["id"], + ) + return {"antwort": antwort} + except ki_module.KIUnavailableError as e: + raise HTTPException(503, str(e)) + except Exception: + raise HTTPException(500, "KI momentan nicht verfügbar.") diff --git a/backend/static/index.html b/backend/static/index.html index 837f2eb..37d6fcd 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -499,6 +499,14 @@
+
+
+
+ +
+
+
+ @@ -562,7 +570,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4b5071c..3ef54e9 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '695'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '698'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -76,6 +76,8 @@ const App = (() => { adoption: { title: 'Adoption', module: null }, playdate: { title: 'Playdate', module: null, requiresAuth: true }, wetter: { title: 'Wetter', module: null }, + ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true }, + personality: { title: 'Persönlichkeitstest', module: null }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/ernaehrung.js b/backend/static/js/pages/ernaehrung.js new file mode 100644 index 0000000..ec1951f --- /dev/null +++ b/backend/static/js/pages/ernaehrung.js @@ -0,0 +1,603 @@ +/* ============================================================ + BAN YARO — Ernährung + Tabs: Kalorien-Rechner | Futter-Guide | Giftliste | KI-Berater + ============================================================ */ + +window.Page_ernaehrung = (() => { + + let _container = null; + let _appState = null; + let _activeTab = 'rechner'; + let _profil = {}; + + const TABS = [ + { key: 'rechner', label: 'Kalorien', icon: '' }, + { key: 'guide', label: 'Futter-Guide', icon: '' }, + { key: 'gift', label: 'Giftliste', icon: '' }, + { key: 'ki', label: 'KI-Berater', icon: '' }, + ]; + + // ------------------------------------------------------------------ + // Escape helper + // ------------------------------------------------------------------ + function _esc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ------------------------------------------------------------------ + // LIFECYCLE + // ------------------------------------------------------------------ + async function init(container, appState, params) { + _container = container; + _appState = appState; + if (params?.tab && TABS.some(t => t.key === params.tab)) { + _activeTab = params.tab; + } + await _render(); + } + + async function refresh() { + await _render(); + } + + async function onDogChange() { + _profil = {}; + await _render(); + } + + // ------------------------------------------------------------------ + // RENDER + // ------------------------------------------------------------------ + async function _render() { + if (!_appState.activeDog) { + _container.innerHTML = UI.emptyState({ + icon: '', + title: 'Noch kein Hund angelegt', + text: 'Erstelle zuerst ein Hundeprofil.', + action: ``, + }); + return; + } + + // Profil laden + const dog = _appState.activeDog; + try { + _profil = await API.get(`/dogs/${dog.id}/ernaehrung`); + } catch (_) { + _profil = {}; + } + + _container.innerHTML = ` +
+
+ `; + + _renderTabBar(); + _renderTab(); + } + + // ------------------------------------------------------------------ + // TAB-BAR + // ------------------------------------------------------------------ + function _renderTabBar() { + const el = _container.querySelector('#ern-tabs'); + if (!el) return; + el.innerHTML = TABS.map(t => ` + `).join(''); + el.querySelectorAll('.by-tab').forEach(btn => { + btn.addEventListener('click', () => { + _activeTab = btn.dataset.tab; + el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _renderTab(); + }); + }); + } + + function _renderTab() { + const el = _container.querySelector('#ern-tab-content'); + if (!el) return; + switch (_activeTab) { + case 'rechner': _renderRechner(el); break; + case 'guide': _renderGuide(el); break; + case 'gift': _renderGift(el); break; + case 'ki': _renderKi(el); break; + } + } + + // ------------------------------------------------------------------ + // TAB 1: KALORIEN-RECHNER + // ------------------------------------------------------------------ + function _renderRechner(el) { + const dog = _appState.activeDog; + + // Auto-Werte aus Hundeprofil + const gewichtDefault = dog?.gewicht || ''; + const alterDefault = dog?.alter || ''; + + el.innerHTML = ` +
+

+ Berechne den täglichen Kalorienbedarf deines Hundes. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + + + + +
+ `; + + el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el)); + } + + function _berechne(el) { + const gewicht = parseFloat(el.querySelector('#ern-gewicht').value); + const aktivitaet = el.querySelector('#ern-aktivitaet').value; + const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja'; + + if (!gewicht || gewicht < 0.5) { + UI.toast.warning('Bitte ein gültiges Gewicht eingeben.'); + return; + } + + const rer = 70 * Math.pow(gewicht, 0.75); + const faktoren = { + gering: { intakt: 1.2, kastriert: 1.0 }, + normal: { intakt: 1.6, kastriert: 1.4 }, + aktiv: { intakt: 1.8, kastriert: 1.6 }, + sport: { intakt: 2.1, kastriert: 1.9 }, + }; + const kcal = Math.round(rer * faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt']); + + // Umrechnung in Futtermengen + const trocken = Math.round(kcal / 3.5); // ~350 kcal/100g + const nass = Math.round(kcal / 0.85); // ~85 kcal/100g + const barf = Math.round(kcal / 1.5); // ~150 kcal/100g + + const kcalFormatted = kcal.toLocaleString('de-DE'); + + const resultEl = el.querySelector('#ern-rechner-result'); + resultEl.style.display = ''; + resultEl.innerHTML = ` +
+
ca. ${kcalFormatted} kcal
+
pro Tag
+
+ +
+
+
🌾 Trockenfutter
+
+ (~350 kcal/100g) +
+
+ ${trocken} g / Tag +
+
+ = ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends +
+
+ +
+
🥫 Nassfutter
+
+ (~85 kcal/100g) +
+
+ ${nass} g / Tag +
+
+ = ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends +
+
+ +
+
🥩 BARF
+
+ (~150 kcal/100g) +
+
+ ${barf} g / Tag +
+
+ = ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends +
+
+
+ +

+ Richtwert nach Nationaler Forschungsratsformel (NRC). Immer den Körperzustand beobachten. +

+ `; + + // Profil-Speichern einblenden und kcal vorbelegen + const profilSection = el.querySelector('#ern-profil-speichern'); + profilSection.style.display = ''; + + // kcal für Speichern merken + profilSection.dataset.kcal = kcal; + + el.querySelector('#ern-prof-save-btn').onclick = () => _speichereProfil(el, kcal); + } + + async function _speichereProfil(el, kcal) { + const dog = _appState.activeDog; + const futter_typ = el.querySelector('#ern-prof-typ').value || null; + const marke = el.querySelector('#ern-prof-marke').value.trim() || null; + const portionen = parseInt(el.querySelector('#ern-prof-portionen').value) || 2; + const notizen = el.querySelector('#ern-prof-notizen').value.trim() || null; + + const btn = el.querySelector('#ern-prof-save-btn'); + await UI.asyncButton(btn, async () => { + try { + _profil = await API.put(`/dogs/${dog.id}/ernaehrung`, { + futter_typ, marke, kcal_tag: kcal, portionen, notizen, + }); + UI.toast.success('Profil gespeichert.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Speichern.'); + } + }); + } + + // ------------------------------------------------------------------ + // TAB 2: FUTTER-GUIDE + // ------------------------------------------------------------------ + function _renderGuide(el) { + const cards = [ + { + id: 'barf', + emoji: '🥩', + titel: 'BARF (Rohfütterung)', + inhalt: ` +

Zusammensetzung: 70 % Muskelfleisch, 10 % rohe Knochen, 10 % Organe, 10 % Gemüse & Obst

+

Vorteile: Naturnahste Ernährungsform, glänzendes Fell, weniger Kot, keine Zusatzstoffe

+

Risiken: Keimbelastung durch rohes Fleisch, Calcium-Phosphor-Balance muss stimmen, zeitaufwändig und teurer

+

Tipp: Niemals BARF und Trockenfutter in derselben Mahlzeit mischen — unterschiedliche Verdauungszeiten können zu Problemen führen.

+ `, + }, + { + id: 'nass', + emoji: '🥫', + titel: 'Nassfutter', + inhalt: ` +

Zusammensetzung: 70–80 % Wasseranteil, meist höherer Fleischanteil als Trockenfutter

+

Vorteile: Hunde trinken automatisch mehr (gut für die Niere), schmackhafter, gut für wählerische Hunde

+

Worauf achten: Erste Zutat auf der Liste = Fleisch (nicht „Tierische Nebenerzeugnisse"), kein Zucker, kein Karamell

+

Zähne: Schlechter für die Zahngesundheit als Trockenfutter — öfter Zähne putzen oder Kauartikel geben.

+ `, + }, + { + id: 'trocken', + emoji: '🌾', + titel: 'Trockenfutter', + inhalt: ` +

Zusammensetzung: 6–10 % Wasser, ca. 350–400 kcal/100 g, konzentrierte Nährstoffe

+

Gute Zutaten: Benanntes Fleisch an erster Stelle (Huhn, Lachs), mind. 40 % Tierprotein, kein Getreide als Hauptzutat

+

Schlechte Zutaten: „Getreide" als erste Zutat, Zucker, Karamell, Konservierungsstoffe E320 / E321

+

Wichtig: Immer frisches Wasser bereitstellen — Trockenfutter enthält kaum Feuchtigkeit.

+ `, + }, + ]; + + el.innerHTML = ` +
+

+ Klicke auf eine Karte für Details. +

+ ${cards.map(c => ` +
+
+ + ${c.emoji} ${c.titel} + + +
+ +
+ `).join('')} +
+ `; + + el.querySelectorAll('.ern-guide-card').forEach(card => { + card.querySelector('.ern-guide-head').addEventListener('click', () => { + const body = card.querySelector('.ern-guide-body'); + const chevron = card.querySelector('.ern-guide-chevron'); + const open = body.style.display !== 'none'; + body.style.display = open ? 'none' : ''; + chevron.style.transform = open ? '' : 'rotate(180deg)'; + }); + }); + } + + // ------------------------------------------------------------------ + // TAB 3: GIFTLISTE + // ------------------------------------------------------------------ + function _renderGift(el) { + const items = [ + { emoji: '🍫', name: 'Schokolade', grund: 'Theobromin → Herzrasen, Krämpfe, kann tödlich sein' }, + { emoji: '🍇', name: 'Trauben & Rosinen', grund: 'Nierenversagen — auch kleinste Mengen gefährlich' }, + { emoji: '🧅', name: 'Zwiebeln & Knoblauch', grund: 'Zerstören rote Blutkörperchen → Anämie' }, + { emoji: '🥑', name: 'Avocado', grund: 'Persin → Erbrechen, Durchfall, Atemnot' }, + { emoji: '🌰', name: 'Macadamia-Nüsse', grund: 'Lähmungserscheinungen, Zittern, Erbrechen' }, + { emoji: '🍬', name: 'Xylitol (Süßstoff)', grund: 'Schwere Leberschäden, Unterzucker — oft in Kaugummi' }, + { emoji: '🥛', name: 'Milch & Milchprodukte', grund: 'Laktose-Intoleranz bei vielen Hunden → Durchfall' }, + { emoji: '🦴', name: 'Gekochte Knochen', grund: 'Splitter → innere Verletzungen, Darmverschluss' }, + { emoji: '☕', name: 'Koffein (Kaffee, Tee)', grund: 'Herzrasen, Zittern, Nervensystem' }, + { emoji: '🧂', name: 'Salz', grund: 'Natriumvergiftung → Erbrechen, Krämpfe' }, + ]; + + el.innerHTML = ` +
+
+ ⚠️ Notfall-Tierarzt: Bei Verdacht auf Vergiftung sofort zum Tierarzt. + Nicht abwarten, auch wenn noch keine Symptome sichtbar sind. +
+ +
+ ${items.map(item => ` +
+
+ ${item.emoji} +
+
${_esc(item.name)}
+
${_esc(item.grund)}
+
+
+
+ `).join('')} +
+ +

+ Diese Liste ist nicht vollständig. Im Zweifel gilt: lieber weglassen. +

+
+ `; + } + + // ------------------------------------------------------------------ + // TAB 4: KI-FUTTERBERATER + // ------------------------------------------------------------------ + function _renderKi(el) { + const dog = _appState.activeDog; + + el.innerHTML = ` +
+
+ + Der KI-Futterberater beantwortet Ernährungsfragen für + ${_esc(dog?.name || 'deinen Hund')}. + Bei Gesundheitsfragen immer den Tierarzt zurate ziehen. +
+ + +
+ ${[ + 'Welches Futter empfiehlst du für meine Rasse?', + 'Wie oft soll ich meinen Hund füttern?', + 'Ist Getreide im Futter schlecht?', + 'Welche Leckerlis sind gesund?', + ].map(q => ` + + `).join('')} +
+ + +
+ + +
+ + +
+
+ `; + + // Vorschläge + el.querySelectorAll('.ern-ki-vorschlag').forEach(btn => { + btn.addEventListener('click', () => { + el.querySelector('#ern-ki-frage').value = btn.dataset.q; + el.querySelector('#ern-ki-frage').focus(); + }); + }); + + // Senden + el.querySelector('#ern-ki-send-btn').addEventListener('click', () => _kiSenden(el)); + el.querySelector('#ern-ki-frage').addEventListener('keydown', e => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) _kiSenden(el); + }); + } + + async function _kiSenden(el) { + const dog = _appState.activeDog; + const frageEl = el.querySelector('#ern-ki-frage'); + const frage = frageEl.value.trim(); + if (!frage) { + UI.toast.warning('Bitte eine Frage eingeben.'); + return; + } + + const chatEl = el.querySelector('#ern-ki-chat'); + const sendBtn = el.querySelector('#ern-ki-send-btn'); + + // Userfrage anzeigen + chatEl.insertAdjacentHTML('beforeend', ` +
+
+ ${_esc(frage)} +
+
+ `); + frageEl.value = ''; + + // KI-Antwort Placeholder + const placeholderId = `ern-ki-placeholder-${Date.now()}`; + chatEl.insertAdjacentHTML('beforeend', ` +
+
+ + Denke nach… +
+
+ `); + chatEl.scrollTop = chatEl.scrollHeight; + + await UI.asyncButton(sendBtn, async () => { + let antwort = ''; + try { + const result = await API.post(`/dogs/${dog.id}/ernaehrung/ki-beratung`, { + frage, + dog_name: dog?.name || null, + rasse: dog?.rasse || null, + alter: dog?.alter != null ? String(dog.alter) : null, + gewicht: dog?.gewicht || null, + aktiv: false, + }); + antwort = result.antwort || 'Keine Antwort erhalten.'; + } catch (err) { + if (err.status === 503) { + antwort = 'Die KI ist momentan nicht verfügbar. Bitte später versuchen.'; + } else { + antwort = 'Fehler bei der KI-Anfrage. Bitte später erneut versuchen.'; + } + } + + const antwortHtml = _esc(antwort) + .replace(/\n\n/g, '

') + .replace(/\n/g, '
'); + + const placeholder = document.getElementById(placeholderId); + if (placeholder) { + placeholder.innerHTML = ` +

+

${antwortHtml}

+
+ `; + } + chatEl.scrollTop = chatEl.scrollHeight; + }); + } + + // ------------------------------------------------------------------ + // PUBLIC API + // ------------------------------------------------------------------ + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 28edf7a..dc23c20 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v695'; +const CACHE_VERSION = 'by-v698'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From a4e97348ed0c5db142d58642b405e0336c0ae7e1 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:52:11 +0200 Subject: [PATCH 23/27] Feature: Schnell-Gassi-Log + Hunde-Visitenkarte mit QR-Code (SW by-v698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worlds-FAB: neuer 'Schnell-Gassi' Button im Gassi-Chip — öffnet schlankes Bottom-Sheet mit Dauer-Toggle (15/30/45/60 min), auto-Wetter aus Cache, postet direkt als Tagebucheintrag typ='gassi' ohne GPS-Tracking - dog-profile.js: 'Visitenkarte teilen' Button öffnet Modal mit gestalteter Karte (Hundefoto, Name, Rasse/Alter, Wohnort) + QR-Code via qrserver.com, Link-kopieren und native Web-Share-API --- backend/static/js/pages/dog-profile.js | 149 ++++++++++++++ backend/static/js/worlds.js | 269 +++++++++++++++++++------ 2 files changed, 356 insertions(+), 62 deletions(-) diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 5e40c51..f80b034 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -195,9 +195,18 @@ window.Page_dog_profile = (() => { Hundepass ` : ''} + ${!dog.is_guest ? `` : ''} ${!dog.is_guest ? `` : ''} + ${!dog.is_guest ? `` : ''} @@ -264,6 +273,14 @@ window.Page_dog_profile = (() => { _showPassportModal(dog); }); + document.getElementById('dp-vcard-btn')?.addEventListener('click', () => { + _showVcardModal(dog); + }); + + document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => { + _showWrappedModal(dog); + }); + // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } @@ -750,6 +767,138 @@ window.Page_dog_profile = (() => { // ---------------------------------------------------------- // TEILEN // ---------------------------------------------------------- + // ---------------------------------------------------------- + // HUNDE-VISITENKARTE MIT QR-CODE + // ---------------------------------------------------------- + function _showVcardModal(dog) { + const passportUrl = `https://banyaro.app/hund/${dog.id}`; + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`; + + const user = _appState?.user; + const ownerName = user?.name || ''; + const wohnort = user?.wohnort || ''; + + // Alter errechnen + let alterStr = ''; + if (dog.geburtstag) { + const birth = new Date(dog.geburtstag + 'T00:00:00'); + const now = new Date(); + const years = now.getFullYear() - birth.getFullYear() + - (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0); + alterStr = years < 1 + ? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate` + : years === 1 ? '1 Jahr' : `${years} Jahre`; + } + + const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · '); + + const cardHtml = ` +
+ + +
+
+ + +
+ ${dog.foto_url + ? `` + : `
🐾
`} +
+
${_esc(dog.name)}
+ ${metaLine ? `
${_esc(metaLine)}
` : ''} + ${wohnort ? `
📍 ${_esc(wohnort)}
` : ''} +
+
+ + +
+ + +
+
+ ${ownerName ? `
Besitzer
+
${_esc(ownerName)}
` : ''} +
banyaro.app
+
+
+ QR-Code +
Profil öffnen
+
+
+
+ `; + + UI.modal.open({ + title: 'Visitenkarte', + body: ` +
${cardHtml}
+

+ QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${_esc(dog.name)} sofort öffnen. +

+ `, + footer: ` + + + `, + }); + + // Link kopieren + document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(passportUrl); + UI.toast.success('Link kopiert!'); + } catch { + const inp = document.createElement('input'); + inp.value = passportUrl; + document.body.appendChild(inp); + inp.select(); + document.execCommand('copy'); + inp.remove(); + UI.toast.success('Link kopiert!'); + } + }); + + // Native Share API + document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ + title: `${dog.name} auf Ban Yaro`, + text: `Schau dir das Profil von ${dog.name} an!`, + url: passportUrl, + }); + } catch {} + } else { + // Fallback: kopieren + try { + await navigator.clipboard.writeText(passportUrl); + UI.toast.success('Link kopiert!'); + } catch { + UI.toast.error('Teilen nicht verfügbar.'); + } + } + }); + } + async function _showShareModal(dog) { UI.modal.open({ title: `${_esc(dog.name)} teilen`, diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e34d163..daacde0 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -165,13 +165,24 @@ window.Worlds = (() => { document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur)); } + function _fabOptions() { + const worldNames = ['jetzt', 'hund', 'welt']; + const chips = _chipsForWorld(worldNames[_cur]); + const opts = []; + for (const chip of chips) { + if (chip.fab) for (const o of chip.fab) { if (opts.length < 6) opts.push(o); } + } + return opts; + } + function _updateFab() { const fab = document.getElementById('worlds-fab'); if (!fab) return; - const icons = ['note-pencil', 'paw-print', 'warning']; - const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden']; - fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`); - fab.title = titles[_cur]; + const opts = _fabOptions(); + if (!opts.length) { fab.style.display = 'none'; return; } + fab.style.display = ''; + fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`); + fab.title = 'Schnellaktion'; } function _setupButtons() { @@ -195,21 +206,13 @@ window.Worlds = (() => { } function _openFab() { - const isWelt = _cur === 2; - const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar + const options = _fabOptions(); + if (!options.length) return; - const options = isWelt ? [ - { icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }, - { icon:'dog', color:'#3B82F6', label:'Verlorenen Hund melden', sub:'Hilf beim Wiederfinden', page:'lost' }, - { icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }, - ] : [ - { icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }, - { icon:'target', color:'#F59E0B', label:'Training aufzeichnen',sub:'Übung absolviert', page:'uebungen' }, - { icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, - { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }, - ]; + const meldenPages = new Set(['poison','lost','recalls','map']); + const meldenCount = options.filter(o => meldenPages.has(o.page)).length; + const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'; - // Overlay erstellen const ov = document.createElement('div'); ov.id = 'fab-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end'; @@ -219,9 +222,7 @@ window.Worlds = (() => { padding:20px 16px calc(env(safe-area-inset-bottom,16px) + 16px); box-shadow:0 -8px 32px rgba(0,0,0,0.2)">
-
- ${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'} -
+
${title}
+
+ +
Dauer
+
+ ${durations.map(d => ` + + `).join('')} +
+ + + + `; + + document.body.appendChild(ov); + + const _close = () => ov.remove(); + ov.querySelector('#qg-backdrop').addEventListener('click', _close); + ov.querySelector('#qg-close').addEventListener('click', _close); + + // Dauer-Toggle + ov.querySelectorAll('.qg-dur').forEach(btn => { + btn.addEventListener('click', () => { + selectedMin = parseInt(btn.dataset.min); + ov.querySelectorAll('.qg-dur').forEach(b => { + const active = parseInt(b.dataset.min) === selectedMin; + b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; + b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-bg-card)'; + b.style.color = active ? 'var(--c-primary)' : 'var(--c-text)'; + }); + }); + }); + + // Eintragen + ov.querySelector('#qg-submit').addEventListener('click', async () => { + const submitBtn = ov.querySelector('#qg-submit'); + submitBtn.disabled = true; + submitBtn.textContent = 'Wird eingetragen…'; + + try { + const payload = { + typ: 'gassi', + titel: 'Schnell-Gassi 🐾', + text: `Kurze Runde, ${selectedMin} Minuten`, + }; + if (weatherData) { + payload.weather_json = JSON.stringify(weatherData); + } + await API.post(`/dogs/${dog.id}/diary`, payload); + _close(); + UI.toast?.success(`Gassi eingetragen! ${selectedMin} min 🐾`); + // Streak-Cache invalidieren + try { localStorage.removeItem('w3_streak_' + dog.id); } catch {} + // JETZT-Welt neu rendern für aktuellen Streak + setTimeout(() => _renderJetzt(), 300); + } catch (err) { + submitBtn.disabled = false; + submitBtn.innerHTML = ' Eintragen'; + UI.toast?.error('Fehler beim Eintragen. Bitte erneut versuchen.'); + } + }); + } + // ── CHIP-KONFIGURATION ────────────────────────────────────── // Alle verfügbaren Chips mit Metadaten const _ALL_CHIPS = [ - { icon:'note-pencil', label:'Notizblock', page:'notes' }, - { icon:'currency-eur', label:'Ausgaben', page:'expenses' }, - { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, - { icon:'handshake', label:'Playdate', page:'playdate' }, - { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, - { icon:'sun', label:'Wetter', page:'wetter' }, + { icon:'note-pencil', label:'Notizblock', page:'notes', + fab:[{ icon:'note-pencil', color:'#10B981', label:'Neue Notiz', sub:'Schnellnotiz erstellen', page:'notes', action:'openNew' }] }, + { icon:'currency-eur', label:'Ausgaben', page:'expenses', + fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] }, + { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, + { icon:'handshake', label:'Playdate', page:'playdate', + fab:[{ icon:'handshake', color:'#F59E0B', label:'Playdate anfragen', sub:'Treffen mit anderen Hunden', page:'playdate', action:'openNew' }] }, + { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, + { icon:'sun', label:'Wetter', page:'wetter' }, - { icon:'book-open', label:'Tagebuch', page:'diary' }, - { icon:'heartbeat', label:'Gesundheit', page:'health' }, - { icon:'target', label:'Übungen', page:'uebungen' }, - { icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'}, - { icon:'heart', label:'Adoption', page:'adoption' }, - { icon:'house-line', label:'Sitting', page:'sitting' }, - { icon:'books', label:'Wiki', page:'wiki' }, - { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, - { icon:'map-trifold', label:'Karte', page:'map' }, - { icon:'push-pin', label:'Forum', page:'forum' }, - { icon:'users', label:'Freunde', page:'friends' }, - { icon:'paw-print', label:'Gassi', page:'walks' }, - { icon:'skull', label:'Giftköder', page:'poison' }, - { icon:'warning-circle', label:'Rückrufe', page:'recalls' }, - { icon:'dog', label:'Verlorene', page:'lost' }, - { icon:'path', label:'Routen', page:'routes' }, - { icon:'calendar-dots', label:'Events', page:'events' }, - { icon:'sparkle', label:'Jobs', page:'jobs' }, - { icon:'book-open', label:'Knigge', page:'knigge' }, - { icon:'film-slate', label:'Filme', page:'movies' }, - { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' }, - { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' }, - { icon:'sparkle', label:'Social', page:'social', role:'social' }, - { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, - { icon:'gear', label:'Admin', page:'admin', role:'admin' }, + { icon:'book-open', label:'Tagebuch', page:'diary', + fab:[{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }] }, + { icon:'heartbeat', label:'Gesundheit', page:'health', + fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, + { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] }, + { icon:'target', label:'Übungen', page:'uebungen', + fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] }, + { icon:'list-checks', label:'Trainings-\npläne', page:'trainingsplaene', + fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] }, + { icon:'heart', label:'Adoption', page:'adoption', + fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] }, + { icon:'house-line', label:'Sitting', page:'sitting', + fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] }, + { icon:'books', label:'Wiki', page:'wiki' }, + { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, + { icon:'map-trifold', label:'Karte', page:'map', + fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] }, + { icon:'push-pin', label:'Forum', page:'forum', + fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] }, + { icon:'users', label:'Freunde', page:'friends', + fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] }, + { icon:'paw-print', label:'Gassi', page:'walks', + fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' }, + { icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] }, + { icon:'skull', label:'Giftköder', page:'poison', + fab:[{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }] }, + { icon:'warning-circle', label:'Rückrufe', page:'recalls', + fab:[{ icon:'warning-circle', color:'#EF4444', label:'Rückruf melden', sub:'Produkt oder Futter', page:'recalls', action:'openNew' }] }, + { icon:'dog', label:'Verlorene', page:'lost', + fab:[{ icon:'dog', color:'#3B82F6', label:'Verlorenen melden', sub:'Hilf beim Wiederfinden', page:'lost' }] }, + { icon:'path', label:'Routen', page:'routes', + fab:[{ icon:'path', color:'#10B981', label:'Route aufzeichnen', sub:'GPS-Tracking starten', page:'routes', action:'openNew' }] }, + { icon:'calendar-dots', label:'Events', page:'events', + fab:[{ icon:'calendar-dots', color:'#06B6D4', label:'Event erstellen', sub:'Veranstaltung ankündigen', page:'events', action:'openNew' }] }, + { icon:'sparkle', label:'Jobs', page:'jobs' }, + { icon:'book-open', label:'Knigge', page:'knigge' }, + { icon:'film-slate', label:'Filme', page:'movies' }, + { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder', + fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, + { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder', + fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] }, + { icon:'sparkle', label:'Social', page:'social', role:'social', + fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, + { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, + { icon:'gear', label:'Admin', page:'admin', role:'admin' }, ]; const _DEFAULT_CONFIG = { @@ -618,18 +766,13 @@ window.Worlds = (() => { async function _loadDailyImage(dog) { if (!dog) return null; - const todayKey = 'bg_' + new Date().toISOString().slice(0, 10); + const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10); const cached = _wLoad(todayKey); if (cached?.data) return cached.data; try { - const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`); - const entries = r.data?.entries || r.data || []; - const withPhotos = entries.filter(e => (e.foto_urls?.length || e.foto_url)); - if (!withPhotos.length) { const u = dog.foto_url || null; if(u) _wSave(todayKey, u); return u; } - const day = Math.floor(Date.now() / 86400000); - const entry = withPhotos[day % withPhotos.length]; - const url = (entry.foto_urls?.[0] || entry.foto_url); - _wSave(todayKey, url); + const dash = await API.dogs.welcomeDashboard(dog.id); + const url = dash?.random_photo?.url || dog.foto_url || null; + if (url) _wSave(todayKey, url); return url; } catch { return dog.foto_url || null; } } @@ -669,10 +812,11 @@ window.Worlds = (() => { const user = _state?.user; el.innerHTML = _skeleton(3); - const [weatherRes, dogsRes, alertsRes] = await Promise.allSettled([ + const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([ _getCachedWeather(), user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }), user ? _getNearbyAlerts() : Promise.resolve([]), + user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }), ]); const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 }; @@ -681,6 +825,7 @@ window.Worlds = (() => { const dogList = dogsObj.data || []; const dog = dogList[0] || null; const alertList = alertsRes.value || []; + const totalKm = achRes.value?.data?.stats?.total_km ?? null; const isOffline = weatherObj.fromCache && dogsObj.fromCache; const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0); @@ -756,7 +901,7 @@ window.Worlds = (() => {
${_esc(greet)}${firstName ? `, ${_esc(firstName)}` : ''}${stale}
-
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}
+
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}
${user ? userAvatarHtml : ''} From 0fdc32eaf4bc20daf1cc5c5740380e56e9fb8206 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:52:51 +0200 Subject: [PATCH 24/27] =?UTF-8?q?Feature:=20Hunde-Pers=C3=B6nlichkeitstest?= =?UTF-8?q?=20+=20Kilometer-Lebenswerk-Badge=20(SW=20by-v698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - personality.js: 10-Fragen-Quiz mit 4 Typen (Abenteurer/Entdecker/Kuschler/Denker), Ergebnis-Speicherung in localStorage, Share-Funktion - achievements.py: neue Badge-Kategorie km_lebenswerk (Bronze 100 km bis Platin 5000 km) - settings.js: Lifetime-km-Balken mit Meilenstein-Markierungen bei 100/500/1000/5000 km - app.js + index.html: personality-Seite registriert --- backend/routes/achievements.py | 48 ++- backend/static/js/app.js | 2 +- backend/static/js/pages/personality.js | 480 +++++++++++++++++++++++++ backend/static/js/pages/settings.js | 96 ++++- backend/static/sw.js | 2 +- 5 files changed, 601 insertions(+), 27 deletions(-) create mode 100644 backend/static/js/pages/personality.js diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index 00b8748..0a2988e 100644 --- a/backend/routes/achievements.py +++ b/backend/routes/achievements.py @@ -131,6 +131,20 @@ CATEGORIES = [ ("platin", 30, "Schneewolf"), ], }, + { + "id": "km_lebenswerk", + "name": "Kilometer-Lebenswerk", + "emoji": "🐾", + "metrik": "gesamt_km_lebenswerk", + "einheit": " km", + "icon": "path", + "stufen": [ + ("bronze", 100, "100-km-Club"), + ("silber", 500, "500-km-Wanderer"), + ("gold", 1000, "Tausend-km-Held"), + ("platin", 5000, "Ultraläufer"), + ], + }, ] # Flat-Liste aller Badge-IDs für DB-Kompatibilität @@ -222,14 +236,15 @@ def check_and_award(user_id: int, conn): """, (user_id,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, - "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, - "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), - "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "gesamt_km_lebenswerk": stats["total_km"] if stats else 0, } earned = {r["badge_id"] for r in @@ -336,14 +351,15 @@ async def my_achievements(user=Depends(get_current_user)): """, (stats["punkte"] if stats else 0,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, - "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, - "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), - "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "gesamt_km_lebenswerk": stats["total_km"] if stats else 0, } # Kategorien mit aktuellem Tier + Fortschritt aufbauen diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3ef54e9..7c30f3a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '698'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '699'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/personality.js b/backend/static/js/pages/personality.js new file mode 100644 index 0000000..e1f159b --- /dev/null +++ b/backend/static/js/pages/personality.js @@ -0,0 +1,480 @@ +/* ============================================================ + BAN YARO — Hunde-Persönlichkeitstest + 10 Fragen, 4 Typen: Abenteurer / Entdecker / Kuschler / Denker + ============================================================ */ + +window.Page_personality = (() => { + + let _container = null; + let _appState = null; + let _current = 0; // Aktuelle Frage (0-basiert) + let _scores = { A:0, B:0, C:0, D:0 }; + let _answers = []; // Gewählte Typen je Frage + + const LS_KEY = 'banyaro_personality_result'; + + // ---------------------------------------------------------- + // FRAGEN + // ---------------------------------------------------------- + const FRAGEN = [ + { frage: "Wie reagiert dein Hund auf neue Orte?", + antworten: [ + { text: "Stürmt sofort los — alles erkunden!", typ: 'A' }, + { text: "Schaut erst vorsichtig, dann neugierig", typ: 'B' }, + { text: "Bleibt lieber bei mir in der Nähe", typ: 'C' }, + { text: "Analysiert die Lage gründlich", typ: 'D' }, + ]}, + { frage: "Was macht dein Hund am liebsten?", + antworten: [ + { text: "Rennen, Ball, endlos spielen", typ: 'A' }, + { text: "Schnüffeln und die Welt erkunden", typ: 'B' }, + { text: "Kuscheln auf dem Sofa", typ: 'C' }, + { text: "Tricks lernen und Aufgaben lösen", typ: 'D' }, + ]}, + { frage: "Wie verhält er sich mit anderen Hunden?", + antworten: [ + { text: "Spielt sofort und wild mit", typ: 'A' }, + { text: "Friendly, aber wählerisch", typ: 'B' }, + { text: "Lieber zu zweit als in der Gruppe", typ: 'C' }, + { text: "Beobachtet erstmal genau", typ: 'D' }, + ]}, + { frage: "Wie reagiert er auf Kommandos?", + antworten: [ + { text: "Macht alles — wenn er Lust hat 😅", typ: 'A' }, + { text: "Gut, aber manchmal abgelenkt", typ: 'B' }, + { text: "Sehr zuverlässig, will gefallen", typ: 'C' }, + { text: "Präzise und fokussiert", typ: 'D' }, + ]}, + { frage: "Was passiert wenn du heimkommst?", + antworten: [ + { text: "Explosiver Freudentanz!", typ: 'A' }, + { text: "Wedelt freudig, bleibt aber cool", typ: 'B' }, + { text: "Kuschelt sich sofort an dich", typ: 'C' }, + { text: "Bringt dir sein Lieblingsspielzeug", typ: 'D' }, + ]}, + { frage: "Wie ist er bei Geräuschen/Gewitter?", + antworten: [ + { text: "Interessiert sich dafür oder ignoriert es", typ: 'A' }, + { text: "Schaut kurz, dann weiter", typ: 'B' }, + { text: "Sucht Schutz bei dir", typ: 'C' }, + { text: "Analysiert die Situation", typ: 'D' }, + ]}, + { frage: "Sein Verhältnis zu Kindern?", + antworten: [ + { text: "Liebt das wilde Spielen!", typ: 'A' }, + { text: "Gut, aber auf seine Art", typ: 'B' }, + { text: "Sanft und geduldig", typ: 'C' }, + { text: "Vorsichtig, aber freundlich", typ: 'D' }, + ]}, + { frage: "Was macht er alleine zu Hause?", + antworten: [ + { text: "Schläft oder spielt mit Spielzeug", typ: 'A' }, + { text: "Schaut aus dem Fenster", typ: 'B' }, + { text: "Wartet sehnsüchtig auf dich", typ: 'C' }, + { text: "Sucht sich Beschäftigung", typ: 'D' }, + ]}, + { frage: "Beim Gassigehen:", + antworten: [ + { text: "Zieht an der Leine — immer vorwärts!", typ: 'A' }, + { text: "Läuft locker aber entdeckungsfreudig", typ: 'B' }, + { text: "Bleibt gerne neben dir", typ: 'C' }, + { text: "Systematisches Schnüffeln", typ: 'D' }, + ]}, + { frage: "Was sagt er über dich aus?", + antworten: [ + { text: "Mein Mensch hält mit mir mit!", typ: 'A' }, + { text: "Gibt mir Freiheit und Abenteuer", typ: 'B' }, + { text: "Mein bester Freund", typ: 'C' }, + { text: "Versteht mich wirklich", typ: 'D' }, + ]}, + ]; + + // ---------------------------------------------------------- + // TYPEN + // ---------------------------------------------------------- + const TYPEN = { + A: { + key: 'A', + emoji: '🏔️', + name: 'Der Abenteurer', + desc: 'Immer vorwärts, immer mehr! Dein Hund lebt im Augenblick und liebt das Unbekannte.', + staerken: ['Energiegeladen', 'Mutig', 'Lebensfroh'], + aktivitaeten: [ + { label: 'Routen', page: 'routes' }, + { label: 'Karte', page: 'map' }, + { label: 'Training', page: 'uebungen' }, + ], + aktivitaetLabels: ['Agility', 'Canicross', 'Lange Wanderungen', 'Nasenarbeit'], + rassen: ['Husky', 'Malinois', 'Border Collie'], + color: '#f97316', + bg: 'linear-gradient(135deg, #f97316, #ea580c)', + }, + B: { + key: 'B', + emoji: '🌍', + name: 'Der Entdecker', + desc: 'Neugierig auf alles, aber mit Köpfchen. Dein Hund ist der perfekte Begleiter für jede Situation.', + staerken: ['Anpassungsfähig', 'Sozial', 'Ausgeglichen'], + aktivitaeten: [ + { label: 'Karte', page: 'map' }, + { label: 'Events', page: 'events' }, + { label: 'Routen', page: 'routes' }, + ], + aktivitaetLabels: ['Mantrailing', 'Dummy-Training', 'Gassi-Treffen'], + rassen: ['Labrador', 'Golden Retriever', 'Beagle'], + color: '#0ea5e9', + bg: 'linear-gradient(135deg, #0ea5e9, #0284c7)', + }, + C: { + key: 'C', + emoji: '🥰', + name: 'Der Kuschler', + desc: 'Verbundenheit über alles. Dein Hund liebt Menschen mehr als alles andere.', + staerken: ['Loyal', 'Einfühlsam', 'Zuverlässig'], + aktivitaeten: [ + { label: 'Tagebuch', page: 'diary' }, + { label: 'Training', page: 'uebungen' }, + { label: 'Gesundheit', page: 'health' }, + ], + aktivitaetLabels: ['Trick-Training', 'Therapy-Dog-Ausbildung', 'Ruhige Spaziergänge'], + rassen: ['Cavalier KCS', 'Bichon Frisé', 'Mops'], + color: '#ec4899', + bg: 'linear-gradient(135deg, #ec4899, #db2777)', + }, + D: { + key: 'D', + emoji: '🧠', + name: 'Der Denker', + desc: 'Stratege mit Seele. Dein Hund denkt bevor er handelt — und ist dabei brillant.', + staerken: ['Intelligent', 'Fokussiert', 'Lernbegeistert'], + aktivitaeten: [ + { label: 'Übungen', page: 'uebungen' }, + { label: 'Training', page: 'trainingsplaene' }, + { label: 'Wiki', page: 'wiki' }, + ], + aktivitaetLabels: ['Zieltraining', 'Geruchsarbeit', 'Rally Obedience', 'Intelligenzspielzeug'], + rassen: ['Poodle', 'Schäferhund', 'Rottweiler'], + color: '#8b5cf6', + bg: 'linear-gradient(135deg, #8b5cf6, #7c3aed)', + }, + }; + + // ---------------------------------------------------------- + // LIFECYCLE + // ---------------------------------------------------------- + function init(container, appState) { + _container = container; + _appState = appState; + _renderPage(); + } + + function refresh() {} + + function onDogChange() {} + + // ---------------------------------------------------------- + // RENDER EINSTIEG + // ---------------------------------------------------------- + function _renderPage() { + // Gespeichertes Ergebnis aus localStorage? + const saved = _loadResult(); + if (saved) { + _renderResult(TYPEN[saved.typ], saved.scores, true); + } else { + _renderIntro(); + } + } + + // ---------------------------------------------------------- + // INTRO + // ---------------------------------------------------------- + function _renderIntro() { + _container.innerHTML = ` +
+
+
🐾
+

+ Hunde-Persönlichkeitstest +

+

+ 10 Fragen — finde heraus welcher der 4 Persönlichkeitstypen deinen Hund am besten beschreibt! +

+
+ ${Object.values(TYPEN).map(t => ` +
+
${t.emoji}
+
${t.name}
+
`).join('')} +
+ +
+
`; + + document.getElementById('quiz-start-btn').addEventListener('click', _startQuiz); + } + + // ---------------------------------------------------------- + // QUIZ STARTEN + // ---------------------------------------------------------- + function _startQuiz() { + _current = 0; + _scores = { A:0, B:0, C:0, D:0 }; + _answers = []; + _renderQuestion(); + } + + // ---------------------------------------------------------- + // FRAGE RENDERN + // ---------------------------------------------------------- + function _renderQuestion() { + const q = FRAGEN[_current]; + const pct = Math.round((_current / FRAGEN.length) * 100); + + _container.innerHTML = ` +
+ +
+
+ + Frage ${_current + 1} von ${FRAGEN.length} + + ${pct}% +
+
+
+
+
+ + +
+

${q.frage}

+
+ + +
+ ${q.antworten.map((a, i) => ` + `).join('')} +
+
`; + + _container.querySelectorAll('.quiz-answer-btn').forEach(btn => { + btn.addEventListener('click', () => _answerQuestion(btn.dataset.typ)); + btn.addEventListener('mouseenter', () => { + btn.style.borderColor = 'var(--c-primary)'; + btn.style.background = 'var(--c-primary-subtle, rgba(var(--c-primary-rgb,59,130,246),.08))'; + }); + btn.addEventListener('mouseleave', () => { + if (!btn.classList.contains('selected')) { + btn.style.borderColor = 'var(--c-border)'; + btn.style.background = 'var(--c-surface)'; + } + }); + }); + } + + // ---------------------------------------------------------- + // ANTWORT VERARBEITEN + // ---------------------------------------------------------- + function _answerQuestion(typ) { + _scores[typ]++; + _answers.push(typ); + _current++; + + if (_current < FRAGEN.length) { + // Kurze Animation — zeige Auswahl kurz grün + _renderQuestion(); + } else { + _calcAndShowResult(); + } + } + + // ---------------------------------------------------------- + // AUSWERTUNG + // ---------------------------------------------------------- + function _calcAndShowResult() { + // Mehrheits-Typ finden; bei Gleichstand letzter bestimmender Typ + let maxScore = 0; + let winner = _answers[_answers.length - 1]; // Fallback: letzte Antwort + for (const [typ, score] of Object.entries(_scores)) { + if (score > maxScore) { + maxScore = score; + winner = typ; + } + } + // Bei Gleichstand: letzter in _answers der einen der Max-Score-Typen hat + const maxTypes = Object.entries(_scores) + .filter(([, s]) => s === maxScore) + .map(([t]) => t); + if (maxTypes.length > 1) { + for (let i = _answers.length - 1; i >= 0; i--) { + if (maxTypes.includes(_answers[i])) { winner = _answers[i]; break; } + } + } + + _saveResult(winner, _scores); + _renderResult(TYPEN[winner], _scores, false); + } + + // ---------------------------------------------------------- + // ERGEBNIS RENDERN + // ---------------------------------------------------------- + function _renderResult(typ, scores, fromStorage) { + const dogName = _appState?.activeDog?.name || 'dein Hund'; + const shareText = `${dogName} ist ${typ.name} ${typ.emoji} — macht den Test auf ban.yaro.de!`; + + const scoreBars = Object.entries(scores) + .sort(([,a],[,b]) => b - a) + .map(([t, s]) => { + const tp = TYPEN[t]; + const pct = Math.round((s / FRAGEN.length) * 100); + return ` +
+ ${tp.emoji} +
+
+
+
+
+ ${s}/${FRAGEN.length} +
`; + }).join(''); + + _container.innerHTML = ` +
+ +
+
${typ.emoji}
+
Persönlichkeitstyp
+

${typ.name}

+

${typ.desc}

+
+ + +
+
Stärken
+
+ ${typ.staerken.map(s => ` + ${s}`).join('')} +
+
+ + +
+
Empfohlene Aktivitäten
+
+
+ ${typ.aktivitaetLabels.map(a => ` + ${a}`).join('')} +
+
+ ${typ.aktivitaeten.map(a => ` + `).join('')} +
+
+
+ + +
+
Typische Rassen
+
+ ${typ.rassen.map(r => ` + ${r}`).join('')} +
+
+ + +
+
Dein Profil
+
${scoreBars}
+
+ + +
+ + +
+
`; + + // Share + document.getElementById('quiz-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ text: shareText, url: 'https://ban.yaro.de' }); + } catch {} + } else { + await navigator.clipboard.writeText(shareText); + UI.toast.success('In die Zwischenablage kopiert!'); + } + }); + + // Neustart + document.getElementById('quiz-restart-btn')?.addEventListener('click', () => { + localStorage.removeItem(LS_KEY); + _startQuiz(); + }); + } + + // ---------------------------------------------------------- + // LOCALSTORAGE + // ---------------------------------------------------------- + function _saveResult(typ, scores) { + try { + localStorage.setItem(LS_KEY, JSON.stringify({ typ, scores, ts: Date.now() })); + } catch {} + } + + function _loadResult() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { return null; } + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 0b8cd88..b829dea 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -229,6 +229,7 @@ window.Page_settings = (() => {
+
@@ -442,10 +443,88 @@ window.Page_settings = (() => { : `🔥 Noch kein Streak — heute aktiv werden!`; } + // Lifetime-km Balken mit Meilenstein-Markierungen + const lifetimeEl = document.getElementById('settings-lifetime-km'); + if (lifetimeEl) { + const km = s.total_km ?? 0; + const MILESTONES = [ + { km: 100, label: '100', badge: '100-km-Club', color: '#cd7f32' }, + { km: 500, label: '500', badge: '500-km-Wanderer', color: '#94a3b8' }, + { km: 1000, label: '1k', badge: 'Tausend-km-Held', color: '#f59e0b' }, + { km: 5000, label: '5k', badge: 'Ultraläufer', color: '#cbd5e1' }, + ]; + const maxKm = 5000; + const pct = Math.min(km / maxKm * 100, 100); + const nextM = MILESTONES.find(m => km < m.km); + const reachedM = MILESTONES.filter(m => km >= m.km); + const lastBadge = reachedM.length ? reachedM[reachedM.length - 1] : null; + + const markers = MILESTONES.map(m => { + const pos = (m.km / maxKm * 100).toFixed(1); + const reached = km >= m.km; + return `
+
+
${m.label}
`; + }).join(''); + + lifetimeEl.innerHTML = ` +
+ 🐾 Lebenswerk-km + ${km} km +
+
+
+
+ ${markers} +
+ ${nextM + ? `
+ Noch ${(nextM.km - km).toLocaleString('de-DE')} km + bis ${nextM.badge} +
` + : `
+ Ultraläufer-Legende erreicht! 🏆 +
`} +
`; + } + if (badgesEl && a.categories) { - // SVG-Schild für jede Kategorie - const shield = (color, dark, emoji, opacity = 1) => ` - { + const photo = _BADGE_PHOTOS[catId]; + const clipId = `clip_${catId || Math.random().toString(36).slice(2)}`; + const path = 'M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z'; + if (photo && opacity === 1) { + return ` + + + + + + + ${emoji} + `; + } + return ` @@ -453,13 +532,12 @@ window.Page_settings = (() => { - - + + ${emoji} `; + }; badgesEl.innerHTML = (a.categories || []).map(cat => { const cur = cat.current_tier; @@ -474,8 +552,8 @@ window.Page_settings = (() => { // Aktuelles Schild const shieldSvg = cur - ? shield(cur.color, cur.dark, cat.emoji) - : shield('#9ca3af', '#6b7280', cat.emoji, 0.5); + ? shield(cur.color, cur.dark, cat.emoji, 1, cat.id) + : shield('#9ca3af', '#6b7280', cat.emoji, 0.5, cat.id); // Fortschrittsbalken const progressBar = nxt ? ` diff --git a/backend/static/sw.js b/backend/static/sw.js index dc23c20..ca84a37 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v698'; +const CACHE_VERSION = 'by-v699'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 20a4936397b83c4cb39074e9ec1ef6ba7bc33836 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:54:12 +0200 Subject: [PATCH 25/27] =?UTF-8?q?Feature:=20Ban=20Yaro=20Wrapped=20+=20Jah?= =?UTF-8?q?restags-=20und=20Monatsr=C3=BCckblick=20(SW=20by-v699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/dogs/{id}/wrapped?year= aggregiert km, Gassi-Tage, Fotos, Lieblingsmonat/-aktivität, Training, Gesundheit, Wetter-Stats aus SQLite - Frontend: Wrapped-Fullscreen-Modal in dog-profile.js — 5 Cards mit Swipe/Klick-Navigation, Dots, ESC-Taste, Copy-to-Clipboard auf Share-Card - Scheduler: _job_anniversary_reminders (täglich 09:00) sendet Push wenn heute ein Tagebucheintrag von vor 1+ Jahren existiert - Scheduler: _job_monthly_recap (1. des Monats 10:00) sendet Vormonat- Zusammenfassung (km, Einträge, Training) per Push an alle User - Beide Jobs im Status-Report-Log und Scheduler-Start-Log vermerkt - SW by-v699, APP_VER 699 --- backend/routes/dogs.py | 148 +++++++++++++++++++++- backend/scheduler.py | 163 ++++++++++++++++++++++++- backend/static/js/pages/dog-profile.js | 157 ++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 4 deletions(-) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index a44faa0..f94258f 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Hund nicht gefunden.") # Zufälliges Foto aus den letzten 100 Tagebuchbildern + # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge photos = conn.execute( """SELECT dm.url FROM diary_media dm JOIN diary d ON d.id = dm.diary_id WHERE d.dog_id=? AND dm.media_type='image' - ORDER BY d.datum DESC LIMIT 100""", + AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height + ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() + # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill) + if not photos: + photos = conn.execute( + """SELECT dm.url FROM diary_media dm + JOIN diary d ON d.id = dm.diary_id + WHERE d.dog_id=? AND dm.media_type='image' + ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", + (dog_id,) + ).fetchall() random_photo = None if photos: import datetime as _dt2 - day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days - chosen_url = photos[day_num % len(photos)]["url"] + tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days + chosen_url = photos[tick % len(photos)]["url"] random_photo = { "url": chosen_url, "preview_url": preview_url_from(chosen_url), @@ -294,6 +305,137 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): } +@router.get("/{dog_id}/wrapped") +async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)): + """Jahresrückblick ('Wrapped') für einen Hund.""" + import json as _json + from datetime import date as _date + + if year is None: + year = _date.today().year + + with db() as conn: + dog = conn.execute( + "SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + # km gelaufen (eigene Routen des Users) + gesamt_km_row = conn.execute( + "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y', created_at)=?", + (user["id"], str(year)) + ).fetchone() + gesamt_km = gesamt_km_row["km"] or 0.0 + + # Gassi-Tage (Distinct Datum in Diary) + gassi_tage = conn.execute( + "SELECT COUNT(DISTINCT datum) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Gesamte Einträge + eintraege_gesamt = conn.execute( + "SELECT COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Fotos gesamt + fotos_gesamt = conn.execute( + "SELECT COUNT(*) AS n FROM diary_media dm " + "JOIN diary d ON d.id=dm.diary_id " + "WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'", + (dog_id, str(year)) + ).fetchone()["n"] + + # Beste Route (längste distanz) + beste_route_row = conn.execute( + "SELECT MAX(distanz_km) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y', created_at)=?", + (user["id"], str(year)) + ).fetchone() + beste_route = beste_route_row["km"] or 0.0 + + # Lieblingsmonat (meiste diary-Einträge) + monat_rows = conn.execute( + "SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? " + "GROUP BY monat ORDER BY n DESC LIMIT 1", + (dog_id, str(year)) + ).fetchone() + lieblings_monat = None + if monat_rows: + _MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] + try: + lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1] + except Exception: + pass + + # Lieblingsaktivität (häufigster typ) + typ_row = conn.execute( + "SELECT typ, COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? " + "GROUP BY typ ORDER BY n DESC LIMIT 1", + (dog_id, str(year)) + ).fetchone() + lieblings_aktivitaet = typ_row["typ"] if typ_row else None + + # Training-Sessions + training_sessions = conn.execute( + "SELECT COUNT(*) AS n FROM training_sessions " + "WHERE dog_id=? AND strftime('%Y', created_at)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Gesundheits-Einträge + gesundheit_eintraege = conn.execute( + "SELECT COUNT(*) AS n FROM health " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json + wetter_kalt = 0 + wetter_warm = 0 + wetter_rows = conn.execute( + "SELECT weather_json FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL", + (dog_id, str(year)) + ).fetchall() + for wr in wetter_rows: + try: + wj = _json.loads(wr["weather_json"]) + temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp") + if temp is not None: + if float(temp) < 5: + wetter_kalt += 1 + elif float(temp) > 25: + wetter_warm += 1 + except Exception: + pass + + return { + "dog_id": dog_id, + "dog_name": dog["name"], + "year": year, + "gesamt_km": gesamt_km, + "gassi_tage": gassi_tage, + "eintraege_gesamt": eintraege_gesamt, + "fotos_gesamt": fotos_gesamt, + "beste_route": beste_route, + "lieblings_monat": lieblings_monat, + "lieblings_aktivitaet": lieblings_aktivitaet, + "training_sessions": training_sessions, + "gesundheit_eintraege": gesundheit_eintraege, + "wetter_kalt": wetter_kalt, + "wetter_warm": wetter_warm, + } + + @router.get("/{dog_id}") async def get_dog(dog_id: int, user=Depends(get_current_user)): with db() as conn: diff --git a/backend/scheduler.py b/backend/scheduler.py index 9c2f07c..4d1dbff 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -164,8 +164,24 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) + _scheduler.add_job( + _job_anniversary_reminders, + CronTrigger(hour=9, minute=0), + id="anniversary_reminders", + replace_existing=True, + misfire_grace_time=3600, + ) + # 1. des Monats 10:00 — Monatlicher Rückblick per Push + _scheduler.add_job( + _job_monthly_recap, + CronTrigger(day=1, hour=10, minute=0), + id="monthly_recap", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -890,6 +906,8 @@ async def _job_status_report(): "streak_reminder": "Streak-Erinnerung (täglich 19:00)", "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)", + "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)", + "monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)", } job_rows_html = "" job_rows_txt = "" @@ -1383,6 +1401,149 @@ async def _job_golden_gassi_hour(): _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User") +# ------------------------------------------------------------------ +# JOB: Jahrestags-Erinnerungen (täglich 09:00) +# ------------------------------------------------------------------ +async def _job_anniversary_reminders(): + """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push.""" + today = datetime.now(tz=_TZ) + today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr + + logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}") + + with db() as conn: + entries = conn.execute(""" + SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id LIMIT 1) AS foto_url + FROM diary d + WHERE strftime('%m-%d', d.datum) = ? + AND d.datum < date('now') + AND d.titel IS NOT NULL + AND d.is_milestone = 0 + """, (today_md,)).fetchall() + + sent_total = 0 + for e in entries: + try: + jahre = today.year - int(e['datum'][:4]) + if jahre < 1: + continue + jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren" + send_push_to_user(e['user_id'], { + 'type': 'anniversary_reminder', + 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}', + 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund', + 'data': {'page': 'diary'}, + 'tag': f'anniversary-{e["id"]}-{today.year}', + }) + sent_total += 1 + except Exception as ex: + logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}") + + logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.") + _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen") + + +# ------------------------------------------------------------------ +# JOB: Monatlicher Rückblick (1. des Monats 10:00) +# ------------------------------------------------------------------ +async def _job_monthly_recap(): + """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats.""" + today = datetime.now(tz=_TZ) + first_this = today.replace(day=1) + last_month_end = first_this - timedelta(days=1) + last_month_start = last_month_end.replace(day=1) + year_str = last_month_start.strftime('%Y') + month_str = last_month_start.strftime('%m') + month_label = last_month_start.strftime('%B %Y') + + logger.info(f"Monatlicher Rückblick Job läuft für {month_label}") + + with db() as conn: + # Alle User mit mindestens einem Hund + users = conn.execute( + "SELECT DISTINCT user_id FROM dogs" + ).fetchall() + + sent_total = 0 + for u in users: + user_id = u["user_id"] + try: + with db() as conn: + # Hunde des Users + dog_rows = conn.execute( + "SELECT id, name FROM dogs WHERE user_id=?", (user_id,) + ).fetchall() + if not dog_rows: + continue + + dog_ids = [d["id"] for d in dog_rows] + placeholders = ','.join('?' * len(dog_ids)) + + # km (Routen des Users im Vormonat) + km_row = conn.execute( + "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", + (user_id, year_str, month_str) + ).fetchone() + gesamt_km = km_row["km"] or 0.0 + + # Tagebucheinträge + eintraege = conn.execute( + f"SELECT COUNT(*) AS n FROM diary " + f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?", + (*dog_ids, year_str, month_str) + ).fetchone()["n"] + + # Training-Sessions + training = conn.execute( + f"SELECT COUNT(*) AS n FROM training_sessions " + f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", + (*dog_ids, year_str, month_str) + ).fetchone()["n"] + + # Lieblingsfoto (erstes Foto im Vormonat) + foto_row = conn.execute( + f"SELECT dm.url FROM diary_media dm " + f"JOIN diary d ON d.id=dm.diary_id " + f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' " + f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? " + f"ORDER BY d.datum ASC LIMIT 1", + (*dog_ids, year_str, month_str) + ).fetchone() + foto_url = foto_row["url"] if foto_row else None + + # Nur senden wenn mindestens eine Aktivität vorhanden + if eintraege == 0 and training == 0 and gesamt_km == 0: + continue + + dog_name = dog_rows[0]["name"] + parts = [] + if gesamt_km > 0: + parts.append(f"{gesamt_km} km gelaufen") + if eintraege > 0: + parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge") + if training > 0: + parts.append(f"{training} Training-Sessions") + + body_text = " · ".join(parts) + + send_push_to_user(user_id, { + 'type': 'monthly_recap', + 'title': f'📅 {month_label}: Rückblick für {dog_name}', + 'body': body_text, + 'data': {'page': 'diary'}, + 'tag': f'monthly-recap-{year_str}-{month_str}', + }) + sent_total += 1 + except Exception as ex: + logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}") + + logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.") + _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}") + + async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]: """Holt stündliche Wetterdaten für heute von Open-Meteo.""" import httpx diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index f80b034..6c0ede4 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -1953,6 +1953,163 @@ window.Page_dog_profile = (() => { } } + // ---------------------------------------------------------- + // JAHRESRÜCKBLICK — WRAPPED + // ---------------------------------------------------------- + async function _showWrappedModal(dog) { + const year = new Date().getFullYear(); + let data = null; + try { + data = await API.get(`/dogs/${dog.id}/wrapped?year=${year}`); + } catch (e) { + UI.toast.error('Rückblick konnte nicht geladen werden.'); + return; + } + + const name = _esc(data.dog_name); + const km = data.gesamt_km || 0; + const konfetti = km > 100; + + const _TYPEN = { + eintrag: 'Tagebuch', gassi: 'Gassi', training: 'Training', + tierarzt: 'Tierarzt', freizeit: 'Freizeit', milestone: 'Meilenstein', + }; + const aktivitaet = data.lieblings_aktivitaet + ? (_TYPEN[data.lieblings_aktivitaet] || data.lieblings_aktivitaet) + : null; + + const stadtpark = km > 0 ? Math.round(km / 1.5) : 0; + const schneeheld = data.wetter_kalt >= 10; + const pfotalalarm = data.wetter_warm >= 10; + + const _card = (content) => + `
${content}
`; + + const cards = [ + _card(` +
🐾
+
+ Dein Jahr mit ${name} +
+
${year} in Zahlen
+ `), + _card(` +
👟
+
${km} km
+
zusammen gelaufen
+ ${stadtpark > 0 ? `
= ${stadtpark}× um den Stadtpark
` : ''} + ${konfetti ? `
🎉 Über 100 km!
` : ''} + `), + _card(` +
📔
+
${data.eintraege_gesamt}
+
Tagebucheinträge
+ ${data.fotos_gesamt > 0 ? `
📷 ${data.fotos_gesamt} Fotos
` : ''} + ${data.gassi_tage > 0 ? `
🐾 ${data.gassi_tage} aktive Tage
` : ''} + ${data.lieblings_monat ? `
Meiste Einträge: ${_esc(data.lieblings_monat)}
` : ''} + ${aktivitaet ? `
Lieblingsaktivität: ${_esc(aktivitaet)}
` : ''} + `), + _card(` +
🌡️
+
Wetter-Tapferkeit
+
+
❄️
+
${data.wetter_kalt}
+
kalte Tage
+
☀️
+
${data.wetter_warm}
+
heiße Tage
+
+ ${schneeheld ? `
❄️ Schneeheld!
` : ''} + ${pfotalalarm ? `
🔥 Pfoten-Alarm!
` : ''} + ${data.training_sessions > 0 ? `
🏋️ ${data.training_sessions} Training-Sessions
` : ''} + `), + _card(` +
🐾
+
Was für ein Jahr!
+
+ ${name} und du — ein unschlagbares Team.
${year} war unvergesslich. +
+ + `), + ]; + + let currentCard = 0; + const totalCards = cards.length; + + const renderDots = () => Array.from({ length: totalCards }, (_, i) => + `
` + ).join(''); + + const modalEl = document.createElement('div'); + modalEl.style.cssText = 'position:fixed;inset:0;z-index:9999;background:#0d0d1a;display:flex;flex-direction:column;overflow:hidden;'; + modalEl.innerHTML = ` +
+ +
+
+
${cards[0]}
+ + +
+
${renderDots()}
+ `; + + document.body.appendChild(modalEl); + + const cardContainer = modalEl.querySelector('#dp-wrapped-card-container'); + const dotsEl = modalEl.querySelector('#dp-wrapped-dots'); + const prevBtn = modalEl.querySelector('#dp-wrapped-prev'); + const nextBtn = modalEl.querySelector('#dp-wrapped-next'); + + const updateCard = () => { + cardContainer.innerHTML = cards[currentCard]; + dotsEl.innerHTML = renderDots(); + prevBtn.style.display = currentCard > 0 ? 'flex' : 'none'; + nextBtn.style.display = currentCard < totalCards - 1 ? 'flex' : 'none'; + if (currentCard === totalCards - 1) { + cardContainer.querySelector('#dp-wrapped-copy-btn')?.addEventListener('click', async () => { + const shareText = `🐾 ${name} & ich — Jahresrückblick ${year}\n` + + (km > 0 ? `👟 ${km} km gelaufen\n` : '') + + (data.eintraege_gesamt > 0 ? `📔 ${data.eintraege_gesamt} Tagebucheinträge\n` : '') + + (data.fotos_gesamt > 0 ? `📷 ${data.fotos_gesamt} Fotos\n` : '') + + (data.training_sessions > 0 ? `🏋️ ${data.training_sessions} Training-Sessions\n` : '') + + `\nbanyaro.app`; + try { + await navigator.clipboard.writeText(shareText); + UI.toast.success('Text kopiert!'); + } catch { + UI.toast.error('Kopieren fehlgeschlagen.'); + } + }); + } + }; + + prevBtn.addEventListener('click', () => { if (currentCard > 0) { currentCard--; updateCard(); } }); + nextBtn.addEventListener('click', () => { if (currentCard < totalCards - 1) { currentCard++; updateCard(); } }); + modalEl.querySelector('#dp-wrapped-close').addEventListener('click', () => modalEl.remove()); + + let touchStartX = 0; + modalEl.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; }, { passive: true }); + modalEl.addEventListener('touchend', e => { + const dx = e.changedTouches[0].clientX - touchStartX; + if (Math.abs(dx) > 50) { + if (dx < 0 && currentCard < totalCards - 1) { currentCard++; updateCard(); } + if (dx > 0 && currentCard > 0) { currentCard--; updateCard(); } + } + }); + + const onKey = e => { if (e.key === 'Escape') { modalEl.remove(); document.removeEventListener('keydown', onKey); } }; + document.addEventListener('keydown', onKey); + } + + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- From c5030024b0a8adca5d75093bad95fe04ea1c1ab6 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 21:01:54 +0200 Subject: [PATCH 26/27] =?UTF-8?q?Feature:=20Hunde-Buch=20=E2=80=94=20druck?= =?UTF-8?q?bare=20HTML-Tagebuchansicht=20als=20PDF=20(SW=20by-v700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/dogs.py | 482 +++++++++++++++++++++++++ backend/static/js/app.js | 3 +- backend/static/js/pages/dog-profile.js | 276 ++++++++++++++ backend/static/sw.js | 2 +- 4 files changed, 761 insertions(+), 2 deletions(-) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index f94258f..42b9b32 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -436,6 +436,332 @@ async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_curren } +@router.get("/{dog_id}/buch") +async def get_hunde_buch( + dog_id: int, + jahr: int = None, + limit: int = 50, + nur_fotos: bool = False, + nur_meilensteine: bool = False, + user=Depends(get_current_user), +): + """Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege.""" + import json as _json + from datetime import date as _date + from fastapi.responses import HTMLResponse + from html import escape as _esc + + with db() as conn: + dog = conn.execute( + "SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + dog = dict(dog) + + # --- Eintraege laden --- + conditions = ["(d.dog_id=? OR dd.dog_id=?)"] + params: list = [dog_id, dog_id] + + if jahr: + conditions.append("strftime('%Y', d.datum) = ?") + params.append(str(jahr)) + + if nur_meilensteine: + conditions.append("d.is_milestone = 1") + + where = " AND ".join(conditions) + + rows = conn.execute( + f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags, + d.gps_lat, d.gps_lon, d.location_name, d.weather_json, + d.is_milestone, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id AND dm.media_type='image' + ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url + FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE {where} + AND d.datum IS NOT NULL + ORDER BY d.datum ASC""", + params + ).fetchall() + + rows = [dict(r) for r in rows] + + # Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus + if nur_fotos: + rows = [r for r in rows if r.get("cover_url")] + else: + # Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit + with_photo = [r for r in rows if r.get("cover_url")] + milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")] + rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")] + rows = with_photo + milestones + rest + rows.sort(key=lambda r: r["datum"] or "") + + rows = rows[:limit] + + # --- Hund-Alter berechnen --- + alter_str = "" + if dog.get("geburtstag"): + try: + geb = _date.fromisoformat(dog["geburtstag"]) + heute = _date.today() + jahre = (heute - geb).days // 365 + alter_str = f"{jahre} Jahre" + except Exception: + pass + + # --- HTML bauen --- + dog_name = _esc(dog["name"] or "Mein Hund") + rasse_str = _esc(dog.get("rasse") or "") + jahr_str = str(jahr) if jahr else "Alle Jahre" + foto_url = dog.get("foto_url") or "" + + cover_img = ( + f'{dog_name}' + if foto_url else + f'
🐾
' + ) + + subtitle_parts = [p for p in [rasse_str, alter_str] if p] + subtitle = " · ".join(subtitle_parts) + + _MONATE = ["Januar","Februar","März","April","Mai","Juni", + "Juli","August","September","Oktober","November","Dezember"] + + def _fmt_datum(iso: str) -> str: + try: + d = _date.fromisoformat(iso) + return f"{d.day}. {_MONATE[d.month - 1]} {d.year}" + except Exception: + return iso or "" + + def _wetter_chip(wj_str: str) -> str: + if not wj_str: + return "" + try: + wj = _json.loads(wj_str) + temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp") + if temp is None: + return "" + temp_i = int(float(temp)) + emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅") + return f'{emoji} {temp_i}°C' + except Exception: + return "" + + entries_html = "" + for e in rows: + milestone_class = "milestone" if e.get("is_milestone") else "" + datum_fmt = _fmt_datum(e.get("datum") or "") + titel = _esc(e.get("titel") or "") + text_raw = e.get("text") or "" + text = _esc(text_raw).replace("\n", "
") + wetter = _wetter_chip(e.get("weather_json") or "") + loc = _esc(e.get("location_name") or "") + cover = e.get("cover_url") or "" + + foto_html = "" + if cover: + foto_html = ( + f'
' + f'' + f'
' + ) + + loc_html = f'📍 {loc}' if loc else "" + chips_html = f'
{wetter}{loc_html}
' if (wetter or loc_html) else "" + titel_html = f'
{titel}
' if titel else "" + text_html = f'
{text}
' if text_raw else "" + + entries_html += f""" +
+ {foto_html} + + {titel_html} + {text_html} + {chips_html} +
+""" + + anzahl = len(rows) + html_page = f""" + + + + + Hunde-Buch — {dog_name} + + + + + + +
+ {cover_img} +

{dog_name}

+ {'
' + subtitle + '
' if subtitle else ''} +
{jahr_str}
+
{anzahl} Einträge
+
+ +{entries_html} + + +""" + + return HTMLResponse(content=html_page) + + @router.get("/{dog_id}") async def get_dog(dog_id: int, user=Depends(get_current_user)): with db() as conn: @@ -764,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)): "kategorien": list(dict.fromkeys(t["kategorie"] for t in result)), "fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None } + + +# ------------------------------------------------------------------ +# LEBENS-TIMELINE +# ------------------------------------------------------------------ +@router.get("/{dog_id}/timeline") +async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)): + """Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen.""" + import json as _json + + with db() as conn: + dog = conn.execute( + "SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + events = [] + + with db() as conn: + # --- Tagebuch --- + diary_rows = conn.execute( + """SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone, + dm.url AS foto_url + FROM diary d + LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0 + WHERE d.dog_id=? + ORDER BY d.datum ASC, d.id ASC""", + (dog_id,) + ).fetchall() + + for i, r in enumerate(diary_rows): + events.append({ + "datum": r["datum"], + "kategorie": "tagebuch", + "titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()), + "typ": r["typ"], + "is_first": i == 0, + "is_milestone": bool(r["is_milestone"]), + "foto_url": r["foto_url"], + "ref_id": r["id"], + }) + + # --- Gesundheit --- + health_rows = conn.execute( + """SELECT id, datum, bezeichnung, typ + FROM health + WHERE dog_id=? + ORDER BY datum ASC, id ASC""", + (dog_id,) + ).fetchall() + + typ_seen = {} + for r in health_rows: + t = r["typ"] + is_first = t not in typ_seen + if is_first: + typ_seen[t] = True + events.append({ + "datum": r["datum"], + "kategorie": "gesundheit", + "titel": r["bezeichnung"], + "typ": t, + "is_first": is_first, + "is_milestone": False, + "foto_url": None, + "ref_id": r["id"], + }) + + # --- Training-Sessions --- + ts_rows = conn.execute( + """SELECT id, datum, exercise_name, erfolgsquote, ist_top + FROM training_sessions + WHERE dog_id=? AND user_id=? + ORDER BY datum ASC, id ASC""", + (dog_id, user["id"]) + ).fetchall() + + ts_first = True + ts_best = None + ts_best_score = -1 + for r in ts_rows: + if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score: + ts_best_score = r["erfolgsquote"] + ts_best = r + + for i, r in enumerate(ts_rows): + is_first = (i == 0) + is_best = ts_best and r["id"] == ts_best["id"] and i > 0 + events.append({ + "datum": r["datum"], + "kategorie": "training", + "titel": r["exercise_name"], + "typ": "training", + "is_first": is_first, + "is_milestone": bool(r["ist_top"]) or is_best, + "foto_url": None, + "ref_id": r["id"], + }) + + # --- Routen --- + route_rows = conn.execute( + """SELECT id, name, distanz_km, + date(created_at) AS datum + FROM routes + WHERE user_id=? + ORDER BY created_at ASC""", + (user["id"],) + ).fetchall() + + route_first = True + route_longest = None + route_max_km = -1 + for r in route_rows: + km = r["distanz_km"] or 0 + if km > route_max_km: + route_max_km = km + route_longest = r + + for i, r in enumerate(route_rows): + is_first = (i == 0) + is_longest = route_longest and r["id"] == route_longest["id"] and i > 0 + events.append({ + "datum": r["datum"], + "kategorie": "route", + "titel": r["name"], + "typ": "route", + "is_first": is_first, + "is_milestone": is_longest, + "foto_url": None, + "ref_id": r["id"], + "distanz_km": r["distanz_km"], + }) + + # Geburtstag des Hundes als erster Eintrag + if dog["geburtstag"]: + events.append({ + "datum": dog["geburtstag"], + "kategorie": "meilenstein", + "titel": f"{dog['name']} wird geboren", + "typ": "geburtstag", + "is_first": True, + "is_milestone": True, + "foto_url": None, + "ref_id": None, + }) + + # Chronologisch sortieren + events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"])) + + return { + "dog_name": dog["name"], + "geburtstag": dog["geburtstag"], + "events": events, + } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7c30f3a..deb05c5 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '699'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -78,6 +78,7 @@ const App = (() => { wetter: { title: 'Wetter', module: null }, ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true }, personality: { title: 'Persönlichkeitstest', module: null }, + reise: { title: 'Reise mit Hund', module: null }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 6c0ede4..09b729a 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -207,6 +207,15 @@ window.Page_dog_profile = (() => { border-color:transparent;font-weight:700"> ✨ Jahresrückblick ${new Date().getFullYear()} ` : ''} + ${!dog.is_guest ? `` : ''} + ${!dog.is_guest ? `` : ''} @@ -281,6 +290,14 @@ window.Page_dog_profile = (() => { _showWrappedModal(dog); }); + document.getElementById('dp-buch-btn')?.addEventListener('click', () => { + _showBuchModal(dog); + }); + + document.getElementById('dp-timeline-btn')?.addEventListener('click', () => { + _showTimelineModal(dog); + }); + // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } @@ -2110,6 +2127,265 @@ window.Page_dog_profile = (() => { } + // ---------------------------------------------------------- + // HUNDE-BUCH + // ---------------------------------------------------------- + function _showBuchModal(dog) { + const currentYear = new Date().getFullYear(); + let selectedJahr = String(currentYear); + let nurFotos = false; + let nurMeilensteine = false; + + const modalEl = document.createElement('div'); + modalEl.style.cssText = ` + position:fixed;inset:0;z-index:9999; + background:rgba(0,0,0,0.55); + display:flex;align-items:center;justify-content:center;padding:16px; + `; + + const renderModal = () => { + const years = [String(currentYear - 1), String(currentYear), 'alle']; + const yearBtns = years.map(y => { + const active = selectedJahr === y + ? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;' + : 'background:#f5f0e8;color:#444;border-color:#e0d4b8;'; + const label = y === 'alle' ? 'Alle' : y; + return ``; + }).join(''); + + const togStyle = (active) => + active + ? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;' + : 'background:#f5f0e8;color:#444;border-color:#e0d4b8;'; + + modalEl.innerHTML = ` +
+
📖 Hunde-Buch erstellen
+
+ Eine druckbare Ansicht der schönsten Einträge.
Im Browser als PDF speichern. +
+ +
+
Jahrgang
+
${yearBtns}
+
+ +
+ + +
+ +
+ + +
+
+ `; + }; + + window._buchSetJahr = (y) => { selectedJahr = y; renderModal(); }; + window._buchToggleFotos = () => { nurFotos = !nurFotos; renderModal(); }; + window._buchToggleMeilensteine = () => { nurMeilensteine = !nurMeilensteine; renderModal(); }; + window._buchClose = () => { + modalEl.remove(); + delete window._buchSetJahr; + delete window._buchToggleFotos; + delete window._buchToggleMeilensteine; + delete window._buchOpen; + delete window._buchClose; + }; + window._buchOpen = () => { + const params = new URLSearchParams(); + if (selectedJahr !== 'alle') params.set('jahr', selectedJahr); + if (nurFotos) params.set('nur_fotos', 'true'); + if (nurMeilensteine) params.set('nur_meilensteine', 'true'); + const url = `/api/dogs/${dog.id}/buch?${params.toString()}`; + window.open(url, '_blank'); + }; + + renderModal(); + document.body.appendChild(modalEl); + modalEl.addEventListener('click', e => { if (e.target === modalEl) window._buchClose(); }); + + const onKey = e => { + if (e.key === 'Escape') { window._buchClose(); document.removeEventListener('keydown', onKey); } + }; + document.addEventListener('keydown', onKey); + } + + + // ---------------------------------------------------------- + // LEBENS-TIMELINE + // ---------------------------------------------------------- + async function _showTimelineModal(dog) { + UI.modal.open({ + title: `Lebens-Timeline — ${_esc(dog.name)}`, + body: `
+ +
`, + footer: ``, + size: 'large', + }); + + let data; + try { + data = await API.get(`/dogs/${dog.id}/timeline`); + } catch (e) { + const b = document.getElementById('dp-timeline-body'); + if (b) b.innerHTML = `

Fehler: ${_esc(e.message)}

`; + return; + } + + const wrap = document.getElementById('dp-timeline-body'); + if (!wrap) return; + + const events = data.events || []; + if (!events.length) { + wrap.innerHTML = `

+ Noch keine Einträge vorhanden. Beginne dein Tagebuch oder trage Gesundheitsdaten ein. +

`; + return; + } + + const _KAT = { + meilenstein: { color: '#8b5cf6', icon: 'star', label: 'Meilenstein' }, + tagebuch: { color: 'var(--c-primary)', icon: 'book-open', label: 'Tagebuch' }, + gesundheit: { color: '#ef4444', icon: 'heartbeat', label: 'Gesundheit' }, + training: { color: '#22c55e', icon: 'target', label: 'Training' }, + route: { color: '#3b82f6', icon: 'path', label: 'Route' }, + }; + + const _fmtDate = d => { + if (!d) return ''; + try { + const p = d.substring(0, 10).split('-'); + return `${p[2]}.${p[1]}.${p[0]}`; + } catch { return d; } + }; + + let lastYear = null; + let html = '
'; + + for (const ev of events) { + const year = ev.datum ? ev.datum.substring(0, 4) : null; + if (year && year !== lastYear) { + html += `
${_esc(year)}
`; + lastYear = year; + } + + const kat = _KAT[ev.kategorie] || _KAT.tagebuch; + const big = ev.is_milestone; + + let label = _esc(ev.titel); + if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`; + if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`; + if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`; + if (ev.typ === 'geburtstag') label = `🎂 ${label}`; + + const dotSize = big ? '18px' : '12px'; + const dotBorder = big ? `3px solid ${kat.color}` : `2px solid ${kat.color}`; + const dotML = big ? '6px' : '9px'; + + html += ` +
+
+
+ ${big && ev.foto_url ? ` +
` : ''} +
+ + + ${_esc(kat.label)} + + ${_fmtDate(ev.datum)} +
+
${label}
+ ${ev.distanz_km ? `
${ev.distanz_km} km
` : ''} +
+
`; + } + + html += '
'; + html += ` + `; + + wrap.innerHTML = html; + } + + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index ca84a37..883e797 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v699'; +const CACHE_VERSION = 'by-v700'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 40de0f38aa4731410fd263821c218ab9600ad9c7 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 21:02:49 +0200 Subject: [PATCH 27/27] =?UTF-8?q?Feature:=20Tierarzt-Bewertungen=20?= =?UTF-8?q?=E2=80=94=20Sterne-Rating=20pro=20Praxis=20mit=20Detail-Modal?= =?UTF-8?q?=20(SW=20by-v700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 75 +++++++++ backend/routes/tieraerzte.py | 114 +++++++++++++ backend/static/index.html | 12 +- backend/static/js/api.js | 3 + backend/static/js/pages/health.js | 262 +++++++++++++++++++++++++++++- 5 files changed, 461 insertions(+), 5 deletions(-) diff --git a/backend/database.py b/backend/database.py index a98cda8..5e38a96 100644 --- a/backend/database.py +++ b/backend/database.py @@ -573,6 +573,9 @@ def _migrate(conn_factory): ("users", "password_reset_expires", "TEXT"), # Fell-Typ für personalisierte Wetter-Hinweise ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt + # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz + ("tieraerzte", "avg_rating", "REAL DEFAULT 0"), + ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1983,3 +1986,75 @@ def _migrate(conn_factory): ); CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); """) + + # ---- Tierarzt-Bewertungen ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS tierarzt_bewertungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5), + wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5), + freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5), + kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5), + text TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(tierarzt_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt + ON tierarzt_bewertungen(tierarzt_id); + """) + + # ---- Feature: Foto-Challenge der Woche ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS foto_challenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thema TEXT NOT NULL, + beschreibung TEXT, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS challenge_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + foto_url TEXT NOT NULL, + caption TEXT, + votes INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(challenge_id, user_id) + ); + CREATE TABLE IF NOT EXISTS challenge_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(submission_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal + ON challenge_submissions(challenge_id, created_at DESC); + """) + logger.info("Migration: Foto-Challenge-Tabellen bereit.") + + # ---- Feature: Gassi-Zeiten-Pool ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS gassi_zeiten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + wochentage TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + ort_name TEXT, + lat REAL, + lon REAL, + radius_m INTEGER DEFAULT 500, + notiz TEXT, + aktiv INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user + ON gassi_zeiten(user_id, aktiv); + """) + logger.info("Migration: Gassi-Zeiten-Tabelle bereit.") diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 48287f9..8448478 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -27,6 +27,14 @@ class TierarztCreate(BaseModel): osm_id: Optional[str] = None +class BewertungCreate(BaseModel): + gesamt: int + wartezeit: Optional[int] = None + freundlichkeit: Optional[int] = None + kompetenz: Optional[int] = None + text: Optional[str] = None + + class TierarztUpdate(BaseModel): name: Optional[str] = None strasse: Optional[str] = None @@ -220,3 +228,109 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate, ) row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() return dict(row) + + +# ------------------------------------------------------------------ +# BEWERTUNGEN +# ------------------------------------------------------------------ + +def _refresh_vet_rating(conn, tierarzt_id: int): + """Aktualisiert avg_rating und anz_bewertungen in tieraerzte.""" + row = conn.execute( + """SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg + FROM tierarzt_bewertungen WHERE tierarzt_id=?""", + (tierarzt_id,) + ).fetchone() + n = row["n"] or 0 + avg = row["avg"] or 0.0 + conn.execute( + "UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?", + (round(avg, 1), n, tierarzt_id) + ) + + +@router.post("/{tierarzt_id}/bewertung", status_code=201) +async def create_bewertung(tierarzt_id: int, data: BewertungCreate, + user=Depends(get_current_user)): + """Bewertung abgeben (1×pro User+Tierarzt, UPSERT).""" + if not (1 <= data.gesamt <= 5): + raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.") + for field in ("wartezeit", "freundlichkeit", "kompetenz"): + val = getattr(data, field) + if val is not None and not (1 <= val <= 5): + raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.") + + text = (data.text or "").strip()[:500] or None + + with db() as conn: + vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + conn.execute( + """INSERT INTO tierarzt_bewertungen + (tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET + gesamt=excluded.gesamt, + wartezeit=excluded.wartezeit, + freundlichkeit=excluded.freundlichkeit, + kompetenz=excluded.kompetenz, + text=excluded.text, + created_at=datetime('now')""", + (tierarzt_id, user["id"], data.gesamt, data.wartezeit, + data.freundlichkeit, data.kompetenz, text) + ) + _refresh_vet_rating(conn, tierarzt_id) + row = conn.execute( + "SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,) + ).fetchone() + return dict(row) + + +@router.get("/{tierarzt_id}/bewertungen") +async def list_bewertungen(tierarzt_id: int): + """Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte.""" + with db() as conn: + vet = conn.execute( + "SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?", + (tierarzt_id,) + ).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + # Stern-Verteilung + verteilung = {} + for star in range(1, 6): + r = conn.execute( + "SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?", + (tierarzt_id, star) + ).fetchone() + verteilung[str(star)] = r["n"] + + # Letzte 5 Kommentare + kommentare = conn.execute( + """SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at + FROM tierarzt_bewertungen + WHERE tierarzt_id=? AND text IS NOT NULL AND text != '' + ORDER BY created_at DESC LIMIT 5""", + (tierarzt_id,) + ).fetchall() + + return { + "avg_rating": vet["avg_rating"] or 0, + "anz_bewertungen": vet["anz_bewertungen"] or 0, + "verteilung": verteilung, + "kommentare": [dict(k) for k in kommentare], + } + + +@router.get("/{tierarzt_id}/meine-bewertung") +async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)): + """Eigene Bewertung für einen Tierarzt (oder null).""" + with db() as conn: + row = conn.execute( + "SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?", + (tierarzt_id, user["id"]) + ).fetchone() + return dict(row) if row else None diff --git a/backend/static/index.html b/backend/static/index.html index 37d6fcd..77f0433 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -507,6 +507,10 @@
+
+
+
+ @@ -570,7 +574,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index c6b26da..1071fdd 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -212,6 +212,9 @@ const API = (() => { osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); }, myFavorite() { return get('/tieraerzte/my-favorite'); }, toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); }, + bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); }, + meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); }, + bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 6308eda..bec107e 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -941,14 +941,30 @@ window.Page_health = (() => { _openNoteModal('health', id, label, null); }); }); - // Praxis öffnen + // Praxis öffnen → Detail-Modal mit Bewertungen content.querySelectorAll('[data-action="open-praxis"]').forEach(el => { el.addEventListener('click', () => { const id = parseInt(el.dataset.praxisId); const p = _praxen.find(x => x.id === id); + if (p) _showPraxisDetail(p); + }); + }); + // Praxis bearbeiten + content.querySelectorAll('[data-action="edit-praxis"]').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.praxisId); + const p = _praxen.find(x => x.id === id); if (p) _showPraxForm(p); }); }); + // Bewertung abgeben + content.querySelectorAll('[data-action="bewerten"]').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.praxisId); + const p = _praxen.find(x => x.id === id); + if (p) _showBewertungModal(p); + }); + }); // Dokument löschen content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => { btn.addEventListener('click', async () => { @@ -1642,6 +1658,14 @@ window.Page_health = (() => { const renderCard = p => { const isFav = _favoritVet?.id === p.id || p.is_favorite; + const hasRating = p.anz_bewertungen > 0; + const stars = hasRating ? _renderStarsReadonly(p.avg_rating) : ''; + const ratingHtml = hasRating + ? `
+ ${stars} + ${p.avg_rating.toFixed(1)} (${p.anz_bewertungen} Bew.) +
` + : `
Noch keine Bewertungen
`; return `
@@ -1660,6 +1684,7 @@ window.Page_health = (() => { ${_esc(_fmtOeffnungszeiten(p.opening_hours))}
` : ''} + ${ratingHtml}
${p.telefon ? ` { onclick="event.stopPropagation()"> Notfall ` : ''} + +
@@ -1716,6 +1756,226 @@ window.Page_health = (() => { `; } + // ---------------------------------------------------------- + // PRAXEN — Sterne-Hilfs-Funktionen + // ---------------------------------------------------------- + + /** Rendert 5 Sterne (readonly, filled bis `rating`). */ + function _renderStarsReadonly(rating) { + const full = Math.round(rating); + return Array.from({ length: 5 }, (_, i) => { + const filled = i < full; + return ``; + }).join(''); + } + + /** Rendert 5 klickbare Sterne mit data-val. */ + function _renderStarsInput(name, current) { + return `
+ ${Array.from({ length: 5 }, (_, i) => { + const val = i + 1; + const filled = current >= val; + return ``; + }).join('')} +
`; + } + + // ---------------------------------------------------------- + // PRAXEN — Detail-Modal (Bewertungen anzeigen) + // ---------------------------------------------------------- + async function _showPraxisDetail(praxis) { + // Erst mit Lade-Spinner öffnen, dann Daten laden + UI.modal.open({ + title: _esc(praxis.name), + body: `
+ +
`, + footer: ` + `, + }); + + document.getElementById('detail-bewerten-btn') + ?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); }); + + let data; + try { + data = await API.tieraerzte.bewertungen(praxis.id); + } catch { + UI.modal.open({ title: praxis.name, body: '

Bewertungen konnten nicht geladen werden.

' }); + return; + } + + const { avg_rating, anz_bewertungen, verteilung, kommentare } = data; + + // Balkendiagramm + const balken = [5, 4, 3, 2, 1].map(s => { + const n = verteilung[String(s)] || 0; + const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0; + return `
+ ${s} + +
+
+
+ ${n} +
`; + }).join(''); + + const kommentarHtml = kommentare.length + ? kommentare.map(k => ` +
+
+ ${_renderStarsReadonly(k.gesamt)} + + ${k.created_at ? k.created_at.slice(0, 10) : ''} + +
+ ${k.wartezeit || k.freundlichkeit || k.kompetenz ? ` +
+ ${k.wartezeit ? `Wartezeit: ${_renderStarsReadonly(k.wartezeit)}` : ''} + ${k.freundlichkeit ? `Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}` : ''} + ${k.kompetenz ? `Kompetenz: ${_renderStarsReadonly(k.kompetenz)}` : ''} +
` : ''} +

${_esc(k.text || '')}

+
`).join('') + : `

Noch keine Kommentare.

`; + + const bewBody = anz_bewertungen === 0 + ? `

+ Noch keine Bewertungen — sei der Erste! +

` + : ` +
+
+
${avg_rating.toFixed(1)}
+
${_renderStarsReadonly(avg_rating)}
+
${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}
+
+
${balken}
+
+
${kommentarHtml}
`; + + // Modal-Body aktualisieren (ohne Modal neu zu öffnen) + const modalBody = document.querySelector('.modal-body'); + if (modalBody) modalBody.innerHTML = bewBody; + } + + // ---------------------------------------------------------- + // PRAXEN — Bewertungs-Modal + // ---------------------------------------------------------- + async function _showBewertungModal(praxis) { + // Ggf. bestehende Bewertung laden + let existing = null; + try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ } + + const cur = existing || {}; + + const body = ` + +
+ + ${_renderStarsInput('gesamt', cur.gesamt || 0)} + +
+
+ + ${_renderStarsInput('wartezeit', cur.wartezeit || 0)} + +
+
+ + ${_renderStarsInput('freundlichkeit', cur.freundlichkeit || 0)} + +
+
+ + ${_renderStarsInput('kompetenz', cur.kompetenz || 0)} + +
+
+ + +
max. 500 Zeichen
+
+ `; + + UI.modal.open({ + title: `${_esc(praxis.name)} bewerten`, + body, + footer: ` + + `, + }); + + // Sterne-Interaktion + document.querySelectorAll('.bew-stars').forEach(group => { + const name = group.dataset.name; + const hidden = document.getElementById(`bew-${name}`); + const stars = group.querySelectorAll('.bew-star'); + + const paint = val => { + stars.forEach(s => { + s.style.color = parseInt(s.dataset.val) <= val + ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'; + }); + }; + + stars.forEach(s => { + s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val))); + s.addEventListener('mouseleave', () => paint(parseInt(hidden.value))); + s.addEventListener('click', () => { + hidden.value = s.dataset.val; + paint(parseInt(s.dataset.val)); + }); + }); + + paint(parseInt(hidden.value)); + }); + + // Submit + document.getElementById('bew-submit-btn').addEventListener('click', async (e) => { + e.preventDefault(); + const form = document.getElementById('bew-form'); + const gesamt = parseInt(document.getElementById('bew-gesamt').value); + if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; } + + const payload = { gesamt }; + const wz = parseInt(document.getElementById('bew-wartezeit').value); + const fr = parseInt(document.getElementById('bew-freundlichkeit').value); + const ko = parseInt(document.getElementById('bew-kompetenz').value); + if (wz) payload.wartezeit = wz; + if (fr) payload.freundlichkeit = fr; + if (ko) payload.kompetenz = ko; + const txt = form.querySelector('textarea[name="text"]').value.trim(); + if (txt) payload.text = txt; + + await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => { + const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload); + // _praxen-Cache aktualisieren + _praxen = _praxen.map(p => + p.id === praxis.id + ? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen } + : p + ); + UI.modal.close(); + UI.toast.success('Bewertung gespeichert.'); + _renderTab(); + }); + }); + } + // ---------------------------------------------------------- // PRAXEN — Formular (Neu / Bearbeiten) // ----------------------------------------------------------