From c5030024b0a8adca5d75093bad95fe04ea1c1ab6 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 21:01:54 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Hunde-Buch=20=E2=80=94=20druckbare?= =?UTF-8?q?=20HTML-Tagebuchansicht=20als=20PDF=20(SW=20by-v700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/dogs.py | 482 +++++++++++++++++++++++++ backend/static/js/app.js | 3 +- backend/static/js/pages/dog-profile.js | 276 ++++++++++++++ backend/static/sw.js | 2 +- 4 files changed, 761 insertions(+), 2 deletions(-) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index f94258f..42b9b32 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -436,6 +436,332 @@ async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_curren } +@router.get("/{dog_id}/buch") +async def get_hunde_buch( + dog_id: int, + jahr: int = None, + limit: int = 50, + nur_fotos: bool = False, + nur_meilensteine: bool = False, + user=Depends(get_current_user), +): + """Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege.""" + import json as _json + from datetime import date as _date + from fastapi.responses import HTMLResponse + from html import escape as _esc + + with db() as conn: + dog = conn.execute( + "SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + dog = dict(dog) + + # --- Eintraege laden --- + conditions = ["(d.dog_id=? OR dd.dog_id=?)"] + params: list = [dog_id, dog_id] + + if jahr: + conditions.append("strftime('%Y', d.datum) = ?") + params.append(str(jahr)) + + if nur_meilensteine: + conditions.append("d.is_milestone = 1") + + where = " AND ".join(conditions) + + rows = conn.execute( + f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags, + d.gps_lat, d.gps_lon, d.location_name, d.weather_json, + d.is_milestone, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id AND dm.media_type='image' + ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url + FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE {where} + AND d.datum IS NOT NULL + ORDER BY d.datum ASC""", + params + ).fetchall() + + rows = [dict(r) for r in rows] + + # Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus + if nur_fotos: + rows = [r for r in rows if r.get("cover_url")] + else: + # Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit + with_photo = [r for r in rows if r.get("cover_url")] + milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")] + rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")] + rows = with_photo + milestones + rest + rows.sort(key=lambda r: r["datum"] or "") + + rows = rows[:limit] + + # --- Hund-Alter berechnen --- + alter_str = "" + if dog.get("geburtstag"): + try: + geb = _date.fromisoformat(dog["geburtstag"]) + heute = _date.today() + jahre = (heute - geb).days // 365 + alter_str = f"{jahre} Jahre" + except Exception: + pass + + # --- HTML bauen --- + dog_name = _esc(dog["name"] or "Mein Hund") + rasse_str = _esc(dog.get("rasse") or "") + jahr_str = str(jahr) if jahr else "Alle Jahre" + foto_url = dog.get("foto_url") or "" + + cover_img = ( + f'{dog_name}' + if foto_url else + f'
🐾
' + ) + + subtitle_parts = [p for p in [rasse_str, alter_str] if p] + subtitle = " · ".join(subtitle_parts) + + _MONATE = ["Januar","Februar","März","April","Mai","Juni", + "Juli","August","September","Oktober","November","Dezember"] + + def _fmt_datum(iso: str) -> str: + try: + d = _date.fromisoformat(iso) + return f"{d.day}. {_MONATE[d.month - 1]} {d.year}" + except Exception: + return iso or "" + + def _wetter_chip(wj_str: str) -> str: + if not wj_str: + return "" + try: + wj = _json.loads(wj_str) + temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp") + if temp is None: + return "" + temp_i = int(float(temp)) + emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅") + return f'{emoji} {temp_i}°C' + except Exception: + return "" + + entries_html = "" + for e in rows: + milestone_class = "milestone" if e.get("is_milestone") else "" + datum_fmt = _fmt_datum(e.get("datum") or "") + titel = _esc(e.get("titel") or "") + text_raw = e.get("text") or "" + text = _esc(text_raw).replace("\n", "
") + wetter = _wetter_chip(e.get("weather_json") or "") + loc = _esc(e.get("location_name") or "") + cover = e.get("cover_url") or "" + + foto_html = "" + if cover: + foto_html = ( + f'
' + f'' + f'
' + ) + + loc_html = f'📍 {loc}' if loc else "" + chips_html = f'
{wetter}{loc_html}
' if (wetter or loc_html) else "" + titel_html = f'
{titel}
' if titel else "" + text_html = f'
{text}
' if text_raw else "" + + entries_html += f""" +
+ {foto_html} + + {titel_html} + {text_html} + {chips_html} +
+""" + + anzahl = len(rows) + html_page = f""" + + + + + Hunde-Buch — {dog_name} + + + + + + +
+ {cover_img} +

{dog_name}

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

Fehler: ${_esc(e.message)}

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

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

`; + return; + } + + const _KAT = { + meilenstein: { color: '#8b5cf6', icon: 'star', label: 'Meilenstein' }, + tagebuch: { color: 'var(--c-primary)', icon: 'book-open', label: 'Tagebuch' }, + gesundheit: { color: '#ef4444', icon: 'heartbeat', label: 'Gesundheit' }, + training: { color: '#22c55e', icon: 'target', label: 'Training' }, + route: { color: '#3b82f6', icon: 'path', label: 'Route' }, + }; + + const _fmtDate = d => { + if (!d) return ''; + try { + const p = d.substring(0, 10).split('-'); + return `${p[2]}.${p[1]}.${p[0]}`; + } catch { return d; } + }; + + let lastYear = null; + let html = '
'; + + for (const ev of events) { + const year = ev.datum ? ev.datum.substring(0, 4) : null; + if (year && year !== lastYear) { + html += `
${_esc(year)}
`; + lastYear = year; + } + + const kat = _KAT[ev.kategorie] || _KAT.tagebuch; + const big = ev.is_milestone; + + let label = _esc(ev.titel); + if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`; + if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`; + if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`; + if (ev.typ === 'geburtstag') label = `🎂 ${label}`; + + const dotSize = big ? '18px' : '12px'; + const dotBorder = big ? `3px solid ${kat.color}` : `2px solid ${kat.color}`; + const dotML = big ? '6px' : '9px'; + + html += ` +
+
+
+ ${big && ev.foto_url ? ` +
` : ''} +
+ + + ${_esc(kat.label)} + + ${_fmtDate(ev.datum)} +
+
${label}
+ ${ev.distanz_km ? `
${ev.distanz_km} km
` : ''} +
+
`; + } + + html += '
'; + html += ` + `; + + wrap.innerHTML = html; + } + + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index ca84a37..883e797 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v699'; +const CACHE_VERSION = 'by-v700'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache