diff --git a/backend/database.py b/backend/database.py index 01dfc17..ab82594 100644 --- a/backend/database.py +++ b/backend/database.py @@ -189,6 +189,13 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id); + CREATE TABLE IF NOT EXISTS route_dogs ( + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (route_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id); + CREATE TABLE IF NOT EXISTS exercise_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -1974,6 +1981,38 @@ def _migrate(conn_factory): """) logger.info("Migration: futter_profil bereit.") + # Futter-Einträge & Reaktionen (Verträglichkeits-Tracking) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS futter_eintraege ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + futter_name TEXT NOT NULL, + futter_typ TEXT NOT NULL DEFAULT 'trockenfutter', + menge_g INTEGER, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC); + + CREATE TABLE IF NOT EXISTS futter_reaktionen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + reaktion_typ TEXT NOT NULL, + intensitaet INTEGER NOT NULL DEFAULT 3, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC); + """) + logger.info("Migration: futter_eintraege + futter_reaktionen bereit.") + except Exception as e: + logger.warning(f"Migration futter_eintraege/reaktionen: {e}") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( @@ -2104,6 +2143,85 @@ def _migrate(conn_factory): except Exception: pass # Spalte existiert bereits + # exercise_progress + training_plan_progress: dog_id ergänzen + existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()] + if 'dog_id' not in existing_ep: + try: + # Neue Tabelle mit dog_id erstellen + conn.execute(""" + CREATE TABLE exercise_progress_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE, + exercise_id TEXT NOT NULL, + status TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, exercise_id) + ) + """) + # Bestehende Daten migrieren: dog_id = erster Hund des Users + conn.execute(""" + INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at) + SELECT ep.user_id, + (SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1), + ep.exercise_id, ep.status, ep.updated_at + FROM exercise_progress ep + """) + conn.execute("DROP TABLE exercise_progress") + conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress") + conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)") + logger.info("Migration: exercise_progress.dog_id hinzugefügt.") + except Exception as e: + logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}") + + existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()] + if 'dog_id' not in existing_tp: + try: + conn.execute(""" + CREATE TABLE training_plan_progress_new ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE, + item_key TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 1, + checked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (dog_id, item_key) + ) + """) + conn.execute(""" + INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at) + SELECT tp.user_id, + (SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1), + tp.item_key, tp.checked, tp.checked_at + FROM training_plan_progress tp + """) + conn.execute("DROP TABLE training_plan_progress") + conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress") + logger.info("Migration: training_plan_progress.dog_id hinzugefügt.") + except Exception as e: + logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}") + + # verstorben_am: Hund als verstorben markierbar + try: + conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT") + logger.info("Migration: dogs.verstorben_am hinzugefügt.") + except Exception: + pass + + # route_dogs: bestehende Routen allen Hunden des Users zuweisen + try: + existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] + if existing == 0: + conn.execute(""" + INSERT OR IGNORE INTO route_dogs (route_id, dog_id) + SELECT r.id, d.id + FROM routes r + JOIN dogs d ON d.user_id = r.user_id + """) + logger.info("Migration: route_dogs mit bestehenden Routen befüllt.") + except Exception as e: + logger.warning(f"Migration route_dogs fehlgeschlagen: {e}") + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 3c3c3b8..2b16cbe 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -361,3 +361,65 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" "hinweis": parsed.get("hinweis") or None, "verbleibende_anfragen": remaining_after, } + + +# ------------------------------------------------------------------ +# POST /ki/abschied — Persönlicher Abschiedstext für verstorbenen Hund +# ------------------------------------------------------------------ +class AbschiedRequest(BaseModel): + dog_id: int + name: str + rasse: Optional[str] = None + km_total: Optional[float] = None + diary_count: Optional[int] = None + gemeinsam_tage: Optional[int] = None + last_entry_titel: Optional[str] = None + +@router.post("/abschied") +async def ki_abschied(req: AbschiedRequest, request: Request, + user=Depends(get_current_user)): + """Persönlicher Abschiedstext — einmalig generiert, DB-gecacht.""" + with db() as conn: + cached = conn.execute( + "SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=9999 AND mode='abschied'", + (req.dog_id,) + ).fetchone() + if cached: + return {"text": cached["content"], "cached": True} + + name = req.name.strip()[:40] + rasse = req.rasse or "" + km = f"{req.km_total:.0f} km" if req.km_total else None + tage = f"{req.gemeinsam_tage} gemeinsame Tage" if req.gemeinsam_tage else None + eintr = f"{req.diary_count} Tagebucheinträge" if req.diary_count else None + + stats_str = ", ".join(filter(None, [km, tage, eintr])) + rasse_str = f" ({rasse})" if rasse else "" + + system = ( + "Du bist ein einfühlsamer Begleiter für Menschen in Trauer um ihren Hund. " + "Schreibe warmherzig, persönlich und respektvoll auf Deutsch. " + "Keine Floskeln, kein Kitsch — echte Wärme. " + "Erwähne die Statistiken natürlich eingebunden." + ) + prompt = ( + f"{name}{rasse_str} ist über die Regenbogenbrücke gegangen. " + f"Schreibe einen kurzen, persönlichen Abschiedstext (ca. 80–100 Wörter) " + f"der die Verbundenheit würdigt. " + f"Statistiken: {stats_str or 'nicht bekannt'}. " + f"Sei warm, nicht sentimental überladen. Schließe mit einem hoffnungsvollen Gedanken." + ) + + try: + text = await ki_module.complete( + system=system, prompt=prompt, max_tokens=300, + requires_premium=False, user_id=user["id"], + ) + with db() as conn: + conn.execute( + "INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,9999,'abschied',?)", + (req.dog_id, text) + ) + return {"text": text, "cached": False} + except Exception as e: + raise HTTPException(503, str(e)) diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 3abbec3..e1060ef 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -58,6 +58,7 @@ class RouteCreate(BaseModel): is_public: Optional[bool] = False hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium client_time: Optional[str] = None + dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind class RouteUpdate(BaseModel): name: Optional[str] = None @@ -69,6 +70,9 @@ class RouteUpdate(BaseModel): is_public: Optional[bool] = None hunde_tauglichkeit: Optional[str] = None +class RouteDogs(BaseModel): + dog_ids: List[int] + def _simplify_track(track: list, max_pts: int = 40) -> list: """Reduziert GPS-Track auf max_pts Punkte für Vorschau.""" @@ -168,7 +172,26 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)): int(data.is_public) if data.is_public is not None else 1, data.hunde_tauglichkeit, is_valid, ct, )) - row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() + route_id = cur.lastrowid + row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone() + + # Hunde zuordnen — entweder explizit oder alle Hunde des Users + dog_ids = data.dog_ids or [] + if not dog_ids: + # Fallback: alle Hunde des Users + all_dogs = conn.execute( + "SELECT id FROM dogs WHERE user_id=?", (user['id'],) + ).fetchall() + dog_ids = [d['id'] for d in all_dogs] + for did in dog_ids: + try: + conn.execute( + "INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)", + (route_id, did) + ) + except Exception: + pass + update_streak(user['id'], conn) check_and_award(user['id'], conn) result = _parse(row) @@ -317,9 +340,14 @@ async def get_route(route_id: int): "SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?", (route_id,) ).fetchone() - if not row: - raise HTTPException(404, "Route nicht gefunden.") - return _parse(row) + if not row: + raise HTTPException(404, "Route nicht gefunden.") + dog_rows = conn.execute( + "SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,) + ).fetchall() + result = _parse(row) + result['dog_ids'] = [r['dog_id'] for r in dog_rows] + return result # ------------------------------------------------------------------ @@ -346,6 +374,26 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren return _parse(row) +# ------------------------------------------------------------------ +# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren +# ------------------------------------------------------------------ +@router.patch("/{route_id}/dogs") +async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if row['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,)) + for did in data.dog_ids: + conn.execute( + "INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)", + (route_id, did) + ) + return {"ok": True} + + # ------------------------------------------------------------------ # PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz) # ------------------------------------------------------------------ diff --git a/backend/routes/training.py b/backend/routes/training.py index 05e8e94..263d90d 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -85,28 +85,43 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ # ------------------------------------------------------------------ class ProgressUpdate(BaseModel): exercise_id: str - status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt + status: Optional[str] = None + dog_id: Optional[int] = None @router.get("/progress") -async def get_progress(user=Depends(get_current_user)): +async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - rows = conn.execute( - "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", - (uid,) - ).fetchall() + if dog_id: + rows = conn.execute( + "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?", + (dog_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() return [dict(r) for r in rows] @router.post("/progress") async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - conn.execute(""" - INSERT INTO exercise_progress (user_id, exercise_id, status) - VALUES (?,?,?) - ON CONFLICT(user_id, exercise_id) DO UPDATE - SET status=excluded.status, updated_at=datetime('now') - """, (uid, body.exercise_id, body.status)) + if body.dog_id: + conn.execute(""" + INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status) + VALUES (?,?,?,?) + ON CONFLICT(dog_id, exercise_id) DO UPDATE + SET status=excluded.status, updated_at=datetime('now') + """, (uid, body.dog_id, body.exercise_id, body.status)) + else: + conn.execute(""" + INSERT INTO exercise_progress (user_id, exercise_id, status) + VALUES (?,?,?) + ON CONFLICT(dog_id, exercise_id) DO UPDATE + SET status=excluded.status, updated_at=datetime('now') + """, (uid, body.exercise_id, body.status)) return {"ok": True} # ------------------------------------------------------------------ @@ -115,15 +130,22 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): class PlanProgress(BaseModel): item_key: str checked: bool + dog_id: Optional[int] = None @router.get("/plan-progress") -async def get_plan_progress(user=Depends(get_current_user)): +async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - rows = conn.execute( - "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", - (uid,) - ).fetchall() + if dog_id: + rows = conn.execute( + "SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?", + (dog_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", + (uid,) + ).fetchall() return [dict(r) for r in rows] @router.post("/plan-progress") @@ -132,13 +154,13 @@ async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user with db() as conn: if body.checked: conn.execute(""" - INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked) - VALUES (?,?,1) - """, (uid, body.item_key)) + INSERT OR REPLACE INTO training_plan_progress (user_id, dog_id, item_key, checked) + VALUES (?,?,?,1) + """, (uid, body.dog_id, body.item_key)) else: conn.execute( - "DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?", - (uid, body.item_key) + "DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?", + (body.dog_id, body.item_key) ) return {"ok": True} @@ -149,13 +171,19 @@ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus / TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen'] @router.get("/suggestions") -async def get_suggestions(user=Depends(get_current_user)): +async def get_suggestions(dog_id: Optional[int] = None, user=Depends(get_current_user)): uid = user["id"] with db() as conn: - rows = conn.execute( - "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", - (uid,) - ).fetchall() + if dog_id: + rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?", + (dog_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() progress = {r["exercise_id"]: r["status"] for r in rows} diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 0e14dc1..5781a62 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -142,6 +142,15 @@ const API = (() => { deletePhoto(id) { return del(`/dogs/${id}/photo`); }, getSkills(id) { return get(`/dogs/${id}/skills`); }, welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); }, + gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); }, + gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); }, + futterList(id) { return get(`/dogs/${id}/futter`); }, + futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); }, + futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); }, + reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); }, + reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); }, + reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); }, + futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); }, }; // ---------------------------------------------------------- @@ -287,6 +296,7 @@ const API = (() => { rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); }, walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); }, reverse(id) { return post(`/routes/${id}/reverse`, {}); }, + updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); }, addPhoto(id, file) { const fd = new FormData(); fd.append('file', file); @@ -300,11 +310,11 @@ const API = (() => { // TRAINING & ÜBUNGSFORTSCHRITT // ---------------------------------------------------------- const training = { - getProgress() { return get('/training/progress'); }, - setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); }, - getSuggestions() { return get('/training/suggestions'); }, - getPlanProgress() { return get('/training/plan-progress'); }, - setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); }, + getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); }, + setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); }, + getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); }, + getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); }, + setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); }, getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); }, }; diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 66d9150..42e0b5d 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -149,12 +149,6 @@ window.Page_diary = (() => { // ---------------------------------------------------------- async function refresh() { if (!_appState.activeDog) return; - // Mehrere Hunde → Picker zeigen (User kann Hund wählen) - if (_appState.dogs.length > 1) { - _renderDogPicker(); - return; - } - // Einzelner Hund → Diary direkt neu laden _offset = 0; _entries = []; _totalStats = null; @@ -194,52 +188,7 @@ window.Page_diary = (() => { return; } - if (_appState.dogs.length > 1) { - _renderDogPicker(); - } else { - await _renderDiary(); - } - } - - // ---------------------------------------------------------- - // HUNDE-PICKER — Einstiegsseite bei mehreren Hunden - // ---------------------------------------------------------- - function _renderDogPicker() { - const activeDogId = _appState.activeDog?.id; - - const cards = _appState.dogs.map(dog => { - const isActive = dog.id === activeDogId; - const av = dog.foto_url - ? `${UI.escape(dog.name)}` - : `${UI.icon('dog')}`; - return ` -
-
${av}
-
${UI.escape(dog.name)}
- ${dog.rasse ? `
${UI.escape(dog.rasse)}
` : ''} -
`; - }).join(''); - - _container.innerHTML = ` -
-

Wessen Tagebuch?

-
${cards}
-
`; - - _container.querySelectorAll('.diary-picker-card').forEach(el => { - el.addEventListener('click', async () => { - const id = parseInt(el.dataset.dogId); - if (id === _appState.activeDog?.id) { - // Bereits aktiver Hund → direkt Diary laden - _offset = 0; _entries = []; - await _renderDiary(); - } else { - App.setActiveDog(id); - // onDogChange() → _renderDiary() via _notifyDogChange() - } - }); - }); + await _renderDiary(); } // ---------------------------------------------------------- @@ -247,6 +196,7 @@ window.Page_diary = (() => { // ---------------------------------------------------------- async function _renderDiary() { _container.innerHTML = ` + ${UI.dogChip(_appState)}
@@ -274,6 +224,7 @@ window.Page_diary = (() => { `; + UI.bindDogChip(_container, _appState); _container.querySelector('#diary-milestone-filter') ?.addEventListener('click', async () => { diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 85ce02d..6de34d5 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -1060,6 +1060,12 @@ window.Page_dog_profile = (() => {
+
@@ -1279,6 +1285,11 @@ window.Page_dog_profile = (() => { document.getElementById('dp-form-cancel') ?.addEventListener('click', UI.modal.close); + document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => { + UI.modal.close(); + _openGedenkenFlow(dog); + }); + document.getElementById('dp-delete-btn')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title : `${dog.name} löschen?`, @@ -2414,6 +2425,178 @@ window.Page_dog_profile = (() => { // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- + // ---------------------------------------------------------- + // GEDENKEN-FLOW + // ---------------------------------------------------------- + async function _openGedenkenFlow(dog) { + // Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe + UI.modal.open({ + title: `Abschied von ${dog.name}`, + body: ` +
+ +

+ ${dog.name} hinterlässt eine riesige Lücke.
+ Die gemeinsamen Erinnerungen bleiben für immer. +

+
+
+ + +
`, + footer: ` +
+ + +
`, + }); + + document.getElementById('gedenken-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.getElementById('gedenken-save-btn'); + const datum = document.getElementById('gedenken-datum').value; + await UI.asyncButton(btn, async () => { + await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum }); + // Aus aktiver Hundeliste entfernen + _appState.dogs = _appState.dogs.filter(d => d.id !== dog.id); + _appState.activeDog = _appState.dogs[0] || null; + UI.modal.close(); + // Gedenkseite öffnen + await _openGedenkseite(dog.id, dog.name); + await _render(); + }); + }); + } + + async function _openGedenkseite(dogId, dogName) { + UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: ` +
+ + + +
` }); + + let data; + try { data = await API.get(`/dogs/${dogId}/gedenkseite`); } + catch { UI.modal.close(); return; } + + const d = data; + const av = d.dog.foto_url + ? `` + : `
`; + + const photoGrid = d.photos.length ? ` +
+ ${d.photos.map(url => ``).join('')} +
` : ''; + + const statsHtml = ` +
+ ${d.km_total ? `
+ +
${d.km_total}
+
km zusammen
+
` : ''} + ${d.diary_count ? `
+ +
${d.diary_count}
+
Tagebucheinträge
+
` : ''} + ${d.media_count ? `
+ +
${d.media_count}
+
Fotos
+
` : ''} + ${d.gemeinsam_tage ? `
+ +
${d.gemeinsam_tage}
+
gemeinsame Tage
+
` : ''} +
`; + + // Trauer-Support-Texte + const supportHtml = ` +
+
+ + Für dich in dieser Zeit +
+

+ Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein. + Lass dich trauern — so lange du brauchst. Die Erinnerungen bleiben immer bei dir. +

+
+
+
+ + Sprich mit Freunden oder der Familie über ${d.dog.name} — Geschichten lebendig halten hilft. +
+
+ + Das Tagebuch bleibt erhalten — es ist ein kostbares Stück gemeinsamer Geschichte. +
+
+ + Professionelle Hilfe bei Tiertrauer: Tiertrauer-Hotline 0800 111 0 111 (kostenlos) +
+
+
+ +
`; + + const modal = UI.modal.open({ + title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`, + body: ` +
+ ${av} +
${UI.escape(d.dog.name)}
+ ${d.dog.rasse ? `
${UI.escape(d.dog.rasse)}
` : ''} + ${d.dog.verstorben_am ? `
+ + Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')} +
` : ''} +
+ ${photoGrid} + ${statsHtml} + ${supportHtml}`, + }); + + document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('gedenk-ki-btn'); + await UI.asyncButton(btn, async () => { + const result = await API.post('/ki/abschied', { + dog_id: dogId, + name: d.dog.name, + rasse: d.dog.rasse, + km_total: d.km_total, + diary_count: d.diary_count, + gemeinsam_tage: d.gemeinsam_tage, + }); + const wrap = document.getElementById('gedenk-ki-wrap'); + if (wrap) wrap.innerHTML = ` +
+ "${UI.escape(result.text)}" +
`; + }); + }); + } + return { init, refresh, onDogChange, addNew: _openCreateModal }; })(); diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 91f082f..b984515 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -50,10 +50,6 @@ window.Page_health = (() => { async function refresh() { if (!_appState.activeDog) return; - if (_appState.dogs.length > 1) { - _renderDogPicker(); - return; - } _data = {}; await _renderHealth(); } @@ -81,52 +77,7 @@ window.Page_health = (() => { return; } - if (_appState.dogs.length > 1) { - _renderDogPicker(); - } else { - await _renderHealth(); - } - } - - // ---------------------------------------------------------- - // HUNDE-PICKER - // ---------------------------------------------------------- - function _renderDogPicker() { - const activeDogId = _appState.activeDog?.id; - - const cards = _appState.dogs.map(dog => { - const isActive = dog.id === activeDogId; - const av = dog.foto_url - ? `${_esc(dog.name)}` - : `${UI.icon('dog')}`; - return ` -
-
${av}
-
${_esc(dog.name)}
- ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} -
`; - }).join(''); - - _container.innerHTML = ` -
-

Wessen Gesundheitsakte?

-
${cards}
-
`; - - _container.querySelectorAll('.diary-picker-card').forEach(el => { - el.addEventListener('click', async () => { - const id = parseInt(el.dataset.dogId); - if (id === _appState.activeDog?.id) { - // Bereits aktiver Hund → direkt Health laden - _data = {}; - await _renderHealth(); - } else { - App.setActiveDog(id); - // onDogChange() → _renderHealth() via _notifyDogChange() - } - }); - }); + await _renderHealth(); } // ---------------------------------------------------------- @@ -147,6 +98,7 @@ window.Page_health = (() => {
`; _container.innerHTML = ` + ${UI.dogChip(_appState)}
+ + `; + UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer }); + + // Checkbox-Pill Styling + document.querySelectorAll('.rd-dog-cb').forEach(cb => { + const label = cb.closest('label'); + cb.addEventListener('change', () => { + label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)'; + label.style.background = cb.checked ? 'var(--c-primary-subtle)' : ''; + label.style.color = cb.checked ? 'var(--c-primary)' : ''; + }); + }); + + document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('rd-dogs-save')?.addEventListener('click', async () => { + const btn = document.getElementById('rd-dogs-save'); + await UI.asyncButton(btn, async () => { + const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value)); + await API.routes.updateDogs(route.id, dogIds); + route.dog_ids = dogIds; + UI.modal.close(); + UI.toast.success('Hunde aktualisiert.'); + }); + }); + } + // Richtungspfeile gleichmäßig entlang des Tracks platzieren function _addRouteArrows(map, track, color = '#fff') { if (track.length < 2) return; diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js index 35383f3..346a4ab 100644 --- a/backend/static/js/pages/trainingsplaene.js +++ b/backend/static/js/pages/trainingsplaene.js @@ -40,7 +40,8 @@ window.Page_trainingsplaene = (() => { } function _lsKey(planId, goalIdx) { - return `tp_${planId}_${goalIdx}`; + const dogId = _dogId() || 'x'; + return `tp_d${dogId}_${planId}_${goalIdx}`; } function _saveGoal(key, checked) { @@ -537,6 +538,8 @@ window.Page_trainingsplaene = (() => { // BIND EVENTS // ---------------------------------------------------------- function _bindEvents() { + UI.bindDogChip(_container, _appState); + // Notiz-Button const dogId = _dogId(); _container.querySelector('#tp-note-btn')?.addEventListener('click', e => { @@ -612,8 +615,9 @@ window.Page_trainingsplaene = (() => { : `Erwachsener Hund – ${_activeAdultTab}`; _container.innerHTML = ` -
-
+
+ ${UI.dogChip(_appState)} +

${_icon('clipboard-text')} Trainingspläne

diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index d8639e6..c6ba308 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -75,6 +75,7 @@ window.Page_uebungen = (() => { // In-memory cache (loaded from API on init) let _progressCache = {}; // key → statusId + let _progressLoaded = false; let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend} function _progressKey(tab, name) { @@ -83,17 +84,13 @@ window.Page_uebungen = (() => { function _getStatus(tab, name) { const k = _progressKey(tab, name); - // Fallback to localStorage while API loads - return _progressCache[k] !== undefined - ? _progressCache[k] - : localStorage.getItem(_statusKey(tab, name)) || null; + return _progressCache[k] ?? null; } function _setStatus(tab, name, statusId) { const k = _progressKey(tab, name); _progressCache[k] = statusId; - localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync - API.training.setProgress(k, statusId).catch(() => {}); + API.training.setProgress(k, statusId, _dogId()).catch(() => {}); } function _nextStatus(currentId) { @@ -504,28 +501,19 @@ window.Page_uebungen = (() => { _scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' }; } - // Progress vom Server laden - API.training.getProgress().then(rows => { - rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); - // localStorage-Daten migrieren falls noch nicht im Backend - Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => { - const parts = lsKey.replace('ub_status_', '').split('_'); - const tab = parts[0]; - const name = parts.slice(1).join('_'); - const apiKey = `${tab}_${name}`; - if (_progressCache[apiKey] === undefined) { - const val = localStorage.getItem(lsKey); - if (val) { - _progressCache[apiKey] = val; - API.training.setProgress(apiKey, val).catch(() => {}); - } - } - }); - _renderContent(); // Re-render with loaded progress - }).catch(() => {}); + // Progress vom Server laden (hund-spezifisch) + const _did = _dogId(); + _progressLoaded = false; + API.training.getProgress(_did) + .then(rows => { + _progressCache = {}; + rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); + _progressLoaded = true; + _renderContent(); + }).catch(() => { _progressLoaded = true; _renderContent(); }); // Empfehlungen laden - API.training.getSuggestions().then(suggestions => { + API.training.getSuggestions(_did).then(suggestions => { if (suggestions.length) _showSuggestions(suggestions); }).catch(() => {}); @@ -556,6 +544,7 @@ window.Page_uebungen = (() => { _statsData = null; _badgesData = null; _progressCache = {}; + _progressLoaded = false; _exerciseStats = {}; _render(); _loadStatsAndBadges(); @@ -568,6 +557,7 @@ window.Page_uebungen = (() => { function _render() { _container.innerHTML = `
+
${UI.dogChip(_appState)}
@@ -604,6 +594,7 @@ window.Page_uebungen = (() => {
`; + UI.bindDogChip(_container, _appState); _container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal); _container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2)); _container.querySelector('#ueb-search')?.addEventListener('input', e => { @@ -613,7 +604,12 @@ window.Page_uebungen = (() => { _renderContent(); }); _bindTabs(); - _renderContent(); + if (_progressLoaded) { + _renderContent(); + } else { + const el = _container.querySelector('#ueb-content'); + if (el) el.innerHTML = `
`; + } _renderStatsBanner(); } @@ -782,7 +778,18 @@ window.Page_uebungen = (() => { // ---------------------------------------------------------- // SCHNELL-SETUP: Stand aller Übungen erfassen // ---------------------------------------------------------- - function _openQuickSetupModal() { + async function _openQuickSetupModal() { + // Sicherstellen dass Progress geladen ist bevor das Modal öffnet + if (!_progressLoaded) { + const did = _dogId(); + try { + const rows = await API.training.getProgress(did); + _progressCache = {}; + rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); + _progressLoaded = true; + _renderContent(); + } catch { _progressLoaded = true; } + } const ALL = [ { group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS }, { group: 'Tricks', tab: 'tricks', items: TRICKS }, @@ -883,11 +890,8 @@ window.Page_uebungen = (() => { // Alle geänderten Status speichern const parts = Object.entries(pending).map(([key, val]) => { - const [tab, ...rest] = key.split('_'); - const name = rest.join('_').replace(/_/g, ' '); _progressCache[key] = val || null; - localStorage.setItem(`ub_status_${key}`, val || ''); - return API.training.setProgress(key, val || null); + return API.training.setProgress(key, val || null, _dogId()); }); await Promise.allSettled(parts); diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index 8eae462..2dceccd 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -397,7 +397,9 @@ window.Page_wetter = (() => { : 0; } + const locName = _data.location_name ? `
${_esc(_data.location_name)}
` : ''; el.innerHTML = ` + ${locName}
${_wmoIcon(d.weathercode, '3.5rem')}
diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 5a580f4..38d6528 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -995,6 +995,30 @@ const UI = (() => { _load(); } + function dogChip(appState) { + const dog = appState?.activeDog; + const dogs = appState?.dogs || []; + if (!dog) return ''; + const av = dog.foto_url + ? `` + : ``; + const sw = dogs.length > 1 + ? `` : ''; + return `
${av}${escape(dog.name)}${sw}
`; + } + + function bindDogChip(container, appState) { + if ((appState?.dogs?.length || 0) < 2) return; + container.querySelector('[data-dog-chip]')?.addEventListener('click', () => { + const dogs = appState.dogs; + const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0]; + if (next) App.setActiveDog(next.id); + }); + } + // Öffentliche API return { toast, modal, @@ -1009,6 +1033,10 @@ const UI = (() => { leafletMarker, locationPicker, ratingStars, + dogChip, + bindDogChip, + dogChip, + bindDogChip, }; })(); diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e7ab1ab..52dddee 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1114,6 +1114,7 @@ window.Worlds = (() => { ${gassiScore ? `/10` : ''}
${w ? `
+ ${w.location_name ? `
${w.location_name}
` : ''}
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
${w.rain_warning_time ? `
⚠ Umschwung ab ${w.rain_warning_time}
` : w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''}
` : ''} diff --git a/backend/weather.py b/backend/weather.py index afde8a2..cdf09d8 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -4,6 +4,7 @@ BAN YARO — Wetter via Open-Meteo - get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache """ +import asyncio import time import logging import httpx @@ -62,9 +63,28 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: "&timezone=Europe%2FBerlin&forecast_days=1" ) async with httpx.AsyncClient(timeout=8.0) as client: - resp = await client.get(url) - resp.raise_for_status() - raw = resp.json() + resp, geo_resp = await asyncio.gather( + client.get(url), + client.get( + f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10", + headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"} + ), + return_exceptions=True, + ) + resp.raise_for_status() + raw = resp.json() + + # Ortsname aus Reverse-Geocoding + location_name = None + try: + if not isinstance(geo_resp, Exception) and geo_resp.status_code == 200: + geo = geo_resp.json() + addr = geo.get("address", {}) + location_name = (addr.get("city") or addr.get("town") or + addr.get("village") or addr.get("municipality") or + addr.get("county") or geo.get("name")) + except Exception: + pass cur = raw.get('current', {}) daily = raw.get('daily', {}) @@ -130,9 +150,10 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: 'precip_prob': precip, 'uv_index': uv, 'is_day': bool(is_day), - 'zecken_warnung': zecken, - 'next_rain_time': next_rain_time, + 'zecken_warnung': zecken, + 'next_rain_time': next_rain_time, 'rain_warning_time': rain_warning_time, + 'location_name': location_name, } _location_cache[key] = (now, data) return data @@ -272,9 +293,23 @@ async def get_forecast(lat: float, lon: float) -> dict: async with httpx.AsyncClient(timeout=10.0) as client: forecast_task = client.get(forecast_url) pollen_task = client.get(pollen_url) - forecast_resp, pollen_resp = await asyncio.gather( - forecast_task, pollen_task, return_exceptions=True + geo_task = client.get( + f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10", + headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"} ) + forecast_resp, pollen_resp, geo_resp_fc = await asyncio.gather( + forecast_task, pollen_task, geo_task, return_exceptions=True + ) + + location_name_fc = None + try: + if not isinstance(geo_resp_fc, Exception) and geo_resp_fc.status_code == 200: + addr = geo_resp_fc.json().get("address", {}) + location_name_fc = (addr.get("city") or addr.get("town") or + addr.get("village") or addr.get("municipality") or + addr.get("county")) + except Exception: + pass # --- Forecast (required) --- if isinstance(forecast_resp, Exception): @@ -421,7 +456,7 @@ async def get_forecast(lat: float, lon: float) -> dict: 'hourly': _hourly_by_day.get(date_str, []), }) - result = {'timezone': timezone, 'days': days} + result = {'timezone': timezone, 'days': days, 'location_name': location_name_fc} _forecast_cache[key] = (now, result) _log_forecast(round(lat, 1), round(lon, 1), days) return result