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}
+
+
+
+
+
+
+
+ {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