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: `
+
+ ${_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 = (() => {
+
+
+ Felltyp
+ (optional)
+ ${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
+
+
+ – nicht angegeben –
+ Kurzhaar (Labrador, Boxer)
+ Mittellang (Spaniel, Husky)
+ Langhaar (Collie, Berner Senne)
+ Drahthaar (Terrier, Schnauzer)
+ Doppeltes Unterfell (Husky, Malamute, Samojede)
+ Nackthund (Chinese Crested, Xolo)
+
+
+
-
+
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: `Profil erstellen `,
+ });
+ 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 => `
+
+ ${t.icon} ${t.label}
+ `).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.
+
+
+
+ Gewicht (kg)
+
+
+
+
+ Alter (Jahre)
+
+
+
+
+ Aktivität
+
+ Gering (Couch-Hund)
+ Normal
+ Aktiv
+ Sehr aktiv (Sport)
+
+
+
+
+
+
+
+ Berechnen
+
+
+
+
+
+
+
Profil speichern
+
+
+ Futter-Typ
+
+ -- wählen --
+ Trockenfutter
+ Nassfutter
+ BARF
+ Mix
+
+
+
+ Marke / Produkt
+
+
+
+ Portionen pro Tag
+
+
+
+ Notizen
+
+
+
+
+ Profil speichern
+
+
+
+
+ `;
+
+ 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}
+
+
+
+
+
+
+ ${c.inhalt}
+
+
+ `).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 => `
+ ${_esc(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', `
+
+ `);
+ frageEl.value = '';
+
+ // KI-Antwort Placeholder
+ const placeholderId = `ern-ki-placeholder-${Date.now()}`;
+ chatEl.insertAdjacentHTML('beforeend', `
+
+ `);
+ 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 = `
+
+ `;
+ }
+ 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 ? `
+
+ Visitenkarte teilen
+ ` : ''}
${!dog.is_guest ? `
+ Weiteren Hund anlegen
` : ''}
+ ${!dog.is_guest ? `
+ ✨ Jahresrückblick ${new Date().getFullYear()}
+ ` : ''}
@@ -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
+
+
+
+
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
+
+
+
+ Teilen
+
+ `,
+ });
+
+ // 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}
@@ -260,6 +261,10 @@ window.Worlds = (() => {
_close();
const page = btn.dataset.page;
const action = btn.dataset.action;
+ if (action === 'quickGassi') {
+ _openQuickGassi();
+ return;
+ }
navigateTo(page);
if (action === 'openNew') {
setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
@@ -268,42 +273,185 @@ window.Worlds = (() => {
});
}
+ // ── SCHNELL-GASSI ─────────────────────────────────────────────
+
+ async function _openQuickGassi() {
+ const dog = _dogs[_dogIdx] || null;
+ if (!dog) {
+ UI.toast?.error('Kein Hund gefunden. Bitte zuerst ein Profil anlegen.');
+ navigateTo('dog-profile');
+ return;
+ }
+
+ // Wetter aus Cache holen (kein Wait nötig)
+ let weatherData = null;
+ try {
+ const wc = _wLoad('weather');
+ if (wc?.data) weatherData = wc.data;
+ } catch {}
+
+ let selectedMin = 30;
+ const durations = [15, 30, 45, 60];
+
+ const ov = document.createElement('div');
+ ov.id = 'quick-gassi-overlay';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:400;display:flex;flex-direction:column;justify-content:flex-end';
+
+ const weatherLine = weatherData
+ ? `
+ 🌡 ${Math.round(weatherData.temp_c)}° · ${_esc(weatherData.desc?.split(' ')[0] || '')}
+
` : '';
+
+ ov.innerHTML = `
+
+
+
+
+
🐾 Schnell-Gassi
+
+ ${_esc(dog.name)} · ohne GPS
+
+ ${weatherLine}
+
+
+
+
+
+
+
Dauer
+
+ ${durations.map(d => `
+
+ ${d} min
+
+ `).join('')}
+
+
+
+
+ Eintragen
+
+
+ `;
+
+ 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('')}
+
+
Quiz starten
+
+
`;
+
+ 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) => `
+
+
+ ${String.fromCharCode(65 + i)}
+
+ ${a.text}
+ `).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 => `
+ ${a.label} → `).join('')}
+
+
+
+
+
+
+
Typische Rassen
+
+ ${typ.rassen.map(r => `
+ ${r} `).join('')}
+
+
+
+
+
+
Dein Profil
+
${scoreBars}
+
+
+
+
+
+
+
+
+
+
+ Ergebnis teilen
+
+
+ Nochmal machen
+
+
+
`;
+
+ // 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
+
+
+
+ ${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.
+
+
+ 📋 Text kopieren
+
+ `),
+ ];
+
+ 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 = `
+
+ ×
+
+
+ ${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' '
+ 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}
+
{datum_fmt}
+ {titel_html}
+ {text_html}
+ {chips_html}
+
+"""
+
+ anzahl = len(rows)
+ html_page = f"""
+
+
+
+
+ Hunde-Buch — {dog_name}
+
+
+
+
+
+ 🖨 Drucken / Als PDF speichern
+
+
+
+ {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 ? `
+ 📖 Hunde-Buch erstellen
+ ` : ''}
+ ${!dog.is_guest ? `
+
+ Lebens-Timeline 🐾
+ ` : ''}
@@ -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 `${label} `;
+ }).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}
+
+
+
+
+ ${nurFotos ? '✓' : ''}
+ Nur Einträge mit Fotos
+
+
+ ${nurMeilensteine ? '✓' : ''}
+ Nur Meilensteine
+
+
+
+
+ 📖 Buch öffnen
+ ✕
+
+
+ `;
+ };
+
+ 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: `Schließen `,
+ 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}
@@ -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: `Schließen
+
+
+ Jetzt bewerten
+ `,
+ });
+
+ 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 ``;
+ }).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 = `
+ `;
+
+ UI.modal.open({
+ title: `${_esc(praxis.name)} bewerten`,
+ body,
+ footer: `
+ Abbrechen
+
+
+ ${existing ? 'Bewertung aktualisieren' : 'Bewertung abgeben'}
+ `,
+ });
+
+ // 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)
// ----------------------------------------------------------