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

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