Feature: Hunde-Buch — druckbare HTML-Tagebuchansicht als PDF (SW by-v700)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rene 2026-05-04 21:01:54 +02:00
parent 20a4936397
commit c5030024b0
4 changed files with 761 additions and 2 deletions

View file

@ -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'<img src="{_esc(foto_url)}" alt="{dog_name}" '
f'style="border-radius:50%;width:200px;height:200px;object-fit:cover;'
f'border:4px solid #b97c2a;margin-bottom:24px;" onerror="this.style.display=\'none\'">'
if foto_url else
f'<div style="width:200px;height:200px;border-radius:50%;background:#f0e8d8;'
f'display:flex;align-items:center;justify-content:center;font-size:5rem;'
f'margin:0 auto 24px">&#x1F43E;</div>'
)
subtitle_parts = [p for p in [rasse_str, alter_str] if p]
subtitle = " &middot; ".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 = "&#x2600;&#xFE0F;" if temp_i > 20 else ("&#x1F327;&#xFE0F;" if temp_i < 10 else "&#x26C5;")
return f'<span class="chip">{emoji} {temp_i}&deg;C</span>'
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", "<br>")
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'<div class="entry-photo">'
f'<img src="{_esc(cover)}" alt="" loading="lazy">'
f'</div>'
)
loc_html = f'<span class="chip">&#x1F4CD; {loc}</span>' if loc else ""
chips_html = f'<div class="chips">{wetter}{loc_html}</div>' if (wetter or loc_html) else ""
titel_html = f'<div class="entry-title">{titel}</div>' if titel else ""
text_html = f'<div class="entry-text">{text}</div>' if text_raw else ""
entries_html += f"""
<div class="entry {milestone_class}">
{foto_html}
<div class="entry-date">{datum_fmt}</div>
{titel_html}
{text_html}
{chips_html}
</div>
"""
anzahl = len(rows)
html_page = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Buch &mdash; {dog_name}</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; }}
body {{
font-family: Georgia, 'Times New Roman', serif;
max-width: 800px;
margin: 0 auto;
padding: 0 20px 60px;
color: #2c2c2c;
background: #fff;
line-height: 1.6;
}}
/* Druck-Button */
.print-btn {{
position: fixed;
top: 20px;
right: 20px;
background: #b97c2a;
color: #fff;
border: none;
border-radius: 50px;
padding: 12px 24px;
font-size: 1rem;
font-family: system-ui, sans-serif;
cursor: pointer;
box-shadow: 0 4px 16px rgba(185,124,42,0.35);
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
}}
.print-btn:hover {{ background: #a06820; }}
/* Titelseite */
.cover {{
text-align: center;
padding: 80px 40px 100px;
min-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}}
.cover h1 {{
font-size: 3em;
color: #b97c2a;
margin: 0 0 12px;
line-height: 1.2;
}}
.cover .subtitle {{
font-size: 1.15em;
color: #666;
margin-bottom: 8px;
}}
.cover .year-label {{
font-size: 1em;
color: #aaa;
margin-bottom: 32px;
}}
.cover .stat-line {{
font-size: 0.95em;
color: #888;
font-family: system-ui, sans-serif;
margin-top: 24px;
}}
/* Eintraege */
.entry {{
padding: 32px 0;
border-bottom: 1px solid #e8e0d0;
break-inside: avoid;
}}
.entry:last-child {{ border-bottom: none; }}
.entry-photo {{ margin-bottom: 20px; }}
.entry-photo img {{
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 10px;
display: block;
}}
.entry-date {{
color: #999;
font-size: 0.85em;
font-family: system-ui, sans-serif;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}}
.entry-title {{
font-size: 1.45em;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 10px;
line-height: 1.3;
}}
.entry-text {{
font-size: 1em;
line-height: 1.75;
color: #333;
}}
/* Meilenstein */
.milestone {{
border-left: 4px solid #b97c2a;
padding-left: 24px;
}}
.milestone .entry-title::before {{
content: "\\2605 ";
color: #b97c2a;
}}
/* Chips */
.chips {{
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
.chip {{
background: #f5f0e8;
border-radius: 50px;
padding: 4px 12px;
font-size: 0.82em;
font-family: system-ui, sans-serif;
color: #666;
}}
/* Drucken */
@media print {{
.print-btn {{ display: none !important; }}
.cover {{ page-break-after: always; }}
.entry {{ break-inside: avoid; }}
body {{ padding: 0; }}
@page {{ margin: 2cm; }}
}}
</style>
</head>
<body>
<button class="print-btn" onclick="window.print()">
&#x1F5A8; Drucken / Als PDF speichern
</button>
<div class="cover">
{cover_img}
<h1>{dog_name}</h1>
{'<div class="subtitle">' + subtitle + '</div>' if subtitle else ''}
<div class="year-label">{jahr_str}</div>
<div class="stat-line">{anzahl} Eintr&auml;ge</div>
</div>
{entries_html}
</body>
</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,
}

View file

@ -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 },
};
// ----------------------------------------------------------

View file

@ -207,6 +207,15 @@ window.Page_dog_profile = (() => {
border-color:transparent;font-weight:700">
Jahresrückblick ${new Date().getFullYear()}
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-buch-btn"
style="background:linear-gradient(135deg,#5c3a10,#7a4f1a);color:#f5e4c0;
border-color:transparent;font-weight:700">
📖 Hunde-Buch erstellen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-timeline-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#timeline"></use></svg>
Lebens-Timeline 🐾
</button>` : ''}
</div>
</div>
@ -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 `<button onclick="window._buchSetJahr('${y}')" style="
border:1px solid;border-radius:8px;padding:8px 16px;
font-size:0.9rem;cursor:pointer;font-family:inherit;
${active}
">${label}</button>`;
}).join('');
const togStyle = (active) =>
active
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
modalEl.innerHTML = `
<div style="
background:#fff;border-radius:16px;padding:32px 24px;
max-width:420px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.2);
font-family:system-ui,sans-serif;
">
<div style="font-size:1.4rem;font-weight:700;margin-bottom:4px">📖 Hunde-Buch erstellen</div>
<div style="font-size:0.9rem;color:#888;margin-bottom:24px">
Eine druckbare Ansicht der schönsten Einträge.<br>Im Browser als PDF speichern.
</div>
<div style="margin-bottom:20px">
<div style="font-weight:600;margin-bottom:10px;color:#555;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em">Jahrgang</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">${yearBtns}</div>
</div>
<div style="margin-bottom:20px;display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button onclick="window._buchToggleFotos()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurFotos)}
">${nurFotos ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Einträge mit Fotos</span>
</label>
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button onclick="window._buchToggleMeilensteine()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurMeilensteine)}
">${nurMeilensteine ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Meilensteine</span>
</label>
</div>
<div style="display:flex;gap:10px">
<button onclick="window._buchOpen()" style="
flex:1;background:#7a4f1a;color:#f5e4c0;border:none;border-radius:10px;
padding:14px;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit;
">📖 Buch öffnen</button>
<button onclick="window._buchClose()" style="
background:#f0f0f0;color:#555;border:none;border-radius:10px;
padding:14px 18px;font-size:1rem;cursor:pointer;font-family:inherit;
"></button>
</div>
</div>
`;
};
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: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
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 = `<p style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
return;
}
const wrap = document.getElementById('dp-timeline-body');
if (!wrap) return;
const events = data.events || [];
if (!events.length) {
wrap.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-6)">
Noch keine Einträge vorhanden. Beginne dein Tagebuch oder trage Gesundheitsdaten ein.
</p>`;
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 = '<div class="tl-wrap">';
for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) {
html += `<div class="tl-year">${_esc(year)}</div>`;
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 += `
<div class="tl-item${big ? ' tl-item--big' : ''}">
<div class="tl-dot" style="width:${dotSize};height:${dotSize};
background:${kat.color};border:${dotBorder};
margin-left:${dotML};
box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
<div class="tl-card">
${big && ev.foto_url ? `
<div class="tl-foto" style="background-image:url(${_esc(ev.foto_url)})"></div>` : ''}
<div class="tl-meta">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg>
${_esc(kat.label)}
</span>
<span class="tl-date">${_fmtDate(ev.datum)}</span>
</div>
<div class="tl-title${big ? ' tl-title--big' : ''}">${label}</div>
${ev.distanz_km ? `<div class="tl-sub">${ev.distanz_km} km</div>` : ''}
</div>
</div>`;
}
html += '</div>';
html += `
<style>
.tl-wrap { padding:var(--space-2) 0;position:relative; }
.tl-wrap::before {
content:'';position:absolute;left:15px;top:0;bottom:0;width:2px;
background:var(--c-border);border-radius:1px;
}
.tl-year {
padding:var(--space-2) 0 var(--space-2) 48px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.08em;
}
.tl-item {
display:flex;align-items:flex-start;gap:var(--space-3);
margin-bottom:var(--space-3);position:relative;
}
.tl-dot {
flex-shrink:0;border-radius:50%;margin-top:4px;z-index:1;position:relative;
}
.tl-card {
flex:1;min-width:0;background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);overflow:hidden;
}
.tl-item--big .tl-card { border-left:3px solid var(--c-primary); }
.tl-foto {
width:100%;height:120px;background-size:cover;background-position:center;
border-radius:var(--radius-sm);margin-bottom:var(--space-2);
}
.tl-meta {
display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap;
}
.tl-badge {
display:inline-flex;align-items:center;gap:3px;
padding:2px 8px;border-radius:var(--radius-full);
font-size:var(--text-xs);font-weight:var(--weight-semibold);
}
.tl-date { font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto; }
.tl-title { font-size:var(--text-sm);color:var(--c-text);font-weight:var(--weight-medium); }
.tl-title--big { font-weight:var(--weight-semibold);font-size:var(--text-base); }
.tl-sub { font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px; }
</style>`;
wrap.innerHTML = html;
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -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