Feature: Reise-Checkliste + Lebens-Timeline (SW by-v700)

- Neue Seite reise.js mit 3 Tabs: Checkliste (localStorage-persistent), EU-Länder-Info, Notfälle
- Timeline-Endpoint GET /api/dogs/{id}/timeline aggregiert Tagebuch, Gesundheit, Training, Routen
- dog-profile.js: Button "Lebens-Timeline" + _showTimelineModal mit vertikaler Timeline, Jahreszahl-Marker, kategoriespezifische Farben, Milestone-Hervorhebung
This commit is contained in:
rene 2026-05-04 21:03:46 +02:00
parent 40de0f38aa
commit d6206d378e

View file

@ -0,0 +1,381 @@
/* ============================================================
BAN YARO Reise mit Hund
Tabs: Checkliste | EU-Länder | Notfälle
============================================================ */
window.Page_reise = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'checkliste';
const TABS = [
{ key: 'checkliste', label: 'Checkliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-square"></use></svg>' },
{ key: 'laender', label: 'EU-Länder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg>' },
{ key: 'notfall', label: 'Notfälle', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
];
const CHECKLIST = [
{
key: 'dokumente',
label: 'Dokumente',
icon: 'file-text',
items: [
'EU-Heimtierausweis (Pflicht innerhalb EU)',
'Impfpass (Tollwut mind. 21 Tage alt)',
'Krankenkassen-Notfallkarte Tierarzt',
'Foto des Hundes (für Vermisst-Fall)',
'Chip-Nummer notiert',
],
},
{
key: 'gesundheit',
label: 'Gesundheit',
icon: 'heartbeat',
items: [
'Zecken-/Flohschutz aufgefrischt',
'Reisekrankheit-Mittel (falls nötig)',
'Medikamente ausreichend eingepackt',
'Tierarzt-Kontakt am Zielort recherchiert',
'Verbandszeug für Hunde',
],
},
{
key: 'ausruestung',
label: 'Ausrüstung',
icon: 'backpack',
items: [
'Leine + Ersatzleine',
'Halsband mit Adressanhänger',
'Transportbox/Reisekorb',
'Lieblingsdecke/Schlafplatz',
'Spielzeug (23 Stück)',
],
},
{
key: 'futter',
label: 'Futter & Wasser',
icon: 'bowl-food',
items: [
'Genug Futter (+ Reserve)',
'Wassernapf + Flasche',
'Futternapf',
'Bekannte Leckerlis',
],
},
{
key: 'auto',
label: 'Im Auto',
icon: 'car',
items: [
'Sicherheitsgurt-Adapter oder Box gesichert',
'Sonnenschutz-Netz für Fenster',
'Pausen alle 2h eingeplant',
],
},
];
const LAENDER = [
{ flag: '🇩🇪', name: 'Deutschland', regel: 'Keine Einschränkungen bei EU-Pass + Chip' },
{ flag: '🇦🇹', name: 'Österreich', regel: 'Gleiche Regeln wie DE, Leinenpflicht in Bergbahnen' },
{ flag: '🇨🇭', name: 'Schweiz', regel: 'Nicht-EU → eigene Einfuhrregeln, Tollwut-Titer-Test', warn: true },
{ flag: '🇮🇹', name: 'Italien', regel: 'Leinenpflicht öffentlich, Maulkorb in öffentlichen Verkehrsmitteln' },
{ flag: '🇫🇷', name: 'Frankreich', regel: 'Manche Strände im Sommer hundeverboten' },
{ flag: '🇬🇷', name: 'Griechenland', regel: 'Hunde erlaubt, kaum Einschränkungen' },
{ flag: '🇭🇷', name: 'Kroatien', regel: 'Viele Strände hundefreundlich, EU-Pass genügt' },
{ flag: '🇬🇧', name: 'Großbritannien', regel: 'Strenge Einreise! PETS-Zertifikat + Tollwut-Impfung + Bandwurm-Behandlung nötig', warn: true },
];
const SOFORTMASSNAHMEN = [
{ icon: 'thermometer-hot', text: 'Hitzschlag: Sofort Schatten, kühlen mit lauwarmem Wasser, Tierarzt rufen' },
{ icon: 'skull', text: 'Vergiftung: Ruhig halten, NICHT erbrechen lassen, sofort Tiergift-Notfall anrufen' },
{ icon: 'drop', text: 'Starke Blutung: Druckverband anlegen, Druck halten, Tierarzt aufsuchen' },
{ icon: 'bone', text: 'Knochenbruch: Ruhigstellen, nicht bewegen, Tierarzt aufsuchen' },
{ icon: 'heartbeat', text: 'Bewusstlosigkeit: Atemwege freihalten, stabile Seitenlage, 112 rufen' },
];
const LS_KEY = 'banyaro_reise_checkliste';
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _loadChecked() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); }
catch { return {}; }
}
function _saveChecked(state) {
try { localStorage.setItem(LS_KEY, JSON.stringify(state)); }
catch {}
}
function _itemKey(catKey, idx) { return `${catKey}__${idx}`; }
// ------------------------------------------------------------------
// LIFECYCLE
// ------------------------------------------------------------------
function init(container, appState, params = {}) {
_container = container;
_appState = appState;
if (params?.tab && TABS.some(t => t.key === params.tab)) {
_activeTab = params.tab;
}
_render();
}
function refresh() { _renderTabContent(); }
function onDogChange() {}
// ------------------------------------------------------------------
// RENDER
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-tabs" id="reise-tabs"></div>
<div id="reise-content"></div>
`;
_renderTabBar();
_renderTabContent();
}
function _renderTabBar() {
const el = _container.querySelector('#reise-tabs');
if (!el) return;
el.innerHTML = TABS.map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}" data-tab="${t.key}">
${t.icon} ${t.label}
</button>`).join('');
el.querySelectorAll('.by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderTabContent();
});
});
}
function _renderTabContent() {
const el = _container.querySelector('#reise-content');
if (!el) return;
if (_activeTab === 'checkliste') _renderCheckliste(el);
else if (_activeTab === 'laender') _renderLaender(el);
else if (_activeTab === 'notfall') _renderNotfall(el);
}
// ------------------------------------------------------------------
// TAB 1: CHECKLISTE
// ------------------------------------------------------------------
function _renderCheckliste(el) {
const checked = _loadChecked();
const totalItems = CHECKLIST.reduce((s, c) => s + c.items.length, 0);
const doneItems = Object.values(checked).filter(Boolean).length;
const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
const progressBar = `
<div style="margin-bottom:var(--space-5)">
<div style="display:flex;justify-content:space-between;align-items:center;
margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${doneItems} von ${totalItems} erledigt
</span>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-primary)">${pct}%</span>
</div>
<div style="height:8px;background:var(--c-surface-2);border-radius:var(--radius-full);overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--c-primary);
border-radius:var(--radius-full);transition:width .3s ease"></div>
</div>
</div>`;
const cats = CHECKLIST.map(cat => {
const rows = cat.items.map((item, idx) => {
const key = _itemKey(cat.key, idx);
const done = !!checked[key];
return `<label class="reise-check-row${done ? ' done' : ''}" data-key="${_esc(key)}">
<input type="checkbox" class="reise-cb" data-key="${_esc(key)}" ${done ? 'checked' : ''}>
<span>${_esc(item)}</span>
</label>`;
}).join('');
return `<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(cat.icon)}"></use>
</svg>
<span style="font-weight:var(--weight-semibold)">${_esc(cat.label)}</span>
</div>
<div style="padding:var(--space-2) var(--space-4)">
${rows}
</div>
</div>`;
}).join('');
el.innerHTML = `
${progressBar}
${cats}
<div style="text-align:center;margin-bottom:var(--space-6)">
<button class="btn btn-secondary" id="reise-reset-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-counter-clockwise"></use></svg>
Alle zurücksetzen
</button>
</div>
<style>
.reise-check-row {
display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-2) 0;cursor:pointer;
border-bottom:1px solid var(--c-surface-2);
font-size:var(--text-sm);color:var(--c-text);line-height:1.5;
}
.reise-check-row:last-child { border-bottom:none; }
.reise-check-row.done span { text-decoration:line-through;color:var(--c-text-secondary); }
.reise-check-row input[type=checkbox] {
flex-shrink:0;margin-top:2px;width:18px;height:18px;
accent-color:var(--c-primary);cursor:pointer;
}
</style>
`;
// Checkbox events
el.querySelectorAll('.reise-cb').forEach(cb => {
cb.addEventListener('change', () => {
const key = cb.dataset.key;
const cur = _loadChecked();
cur[key] = cb.checked;
_saveChecked(cur);
_renderTabContent(); // re-render to update progress
});
});
el.querySelector('#reise-reset-btn')?.addEventListener('click', () => {
_saveChecked({});
_renderTabContent();
});
}
// ------------------------------------------------------------------
// TAB 2: EU-LÄNDER
// ------------------------------------------------------------------
function _renderLaender(el) {
const cards = LAENDER.map(l => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<span style="font-size:2rem;line-height:1">${l.flag}</span>
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
${_esc(l.name)}
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${_esc(l.regel)}
</div>
</div>
${l.warn ? `<svg class="ph-icon" style="color:var(--c-warning,#f59e0b);flex-shrink:0;width:20px;height:20px" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use>
</svg>` : ''}
</div>
</div>`).join('');
el.innerHTML = `
<div style="margin-bottom:var(--space-4);padding:var(--space-3) var(--space-4);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary);
display:flex;gap:var(--space-2);align-items:flex-start">
<svg class="ph-icon" style="flex-shrink:0;margin-top:1px" aria-hidden="true">
<use href="/icons/phosphor.svg#info"></use>
</svg>
<span>EU-Heimtierausweis + Mikrochip + gültige Tollwut-Impfung (mindestens 21 Tage alt)
sind Pflicht für alle EU-Reisen. Informationen können sich ändern immer beim
Zielland-Konsulat oder Tierarzt aktuell prüfen.</span>
</div>
${cards}
`;
}
// ------------------------------------------------------------------
// TAB 3: NOTFÄLLE
// ------------------------------------------------------------------
function _renderNotfall(el) {
const massnahmen = SOFORTMASSNAHMEN.map(m => `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-surface-2)">
<svg class="ph-icon" style="color:var(--c-danger,#ef4444);flex-shrink:0;margin-top:1px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(m.icon)}"></use>
</svg>
<span style="font-size:var(--text-sm);color:var(--c-text)">${_esc(m.text)}</span>
</div>`).join('');
el.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
font-weight:var(--weight-semibold);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="color:var(--c-danger,#ef4444)" aria-hidden="true">
<use href="/icons/phosphor.svg#phone"></use>
</svg>
Notrufnummern
</div>
<div style="padding:var(--space-4)">
<a href="tel:112" class="btn btn-danger w-full"
style="margin-bottom:var(--space-3);display:flex;align-items:center;
justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone-call"></use></svg>
112 EU-Notruf
</a>
<a href="tel:+498919240" class="btn btn-secondary w-full"
style="margin-bottom:var(--space-2);display:flex;align-items:center;
justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
Tiergift-Notruf München
</a>
<div style="text-align:center;font-size:var(--text-xs);color:var(--c-text-secondary)">
+49 89 19240 (Tierärztliche Hochschule)
</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
font-weight:var(--weight-semibold)">Tierarzt finden</div>
<div style="padding:var(--space-4)">
<button class="btn btn-primary w-full" id="reise-map-btn"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
Tierarzt in der Nähe suchen
</button>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
font-weight:var(--weight-semibold);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="color:var(--c-warning,#f59e0b)" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
Sofortmaßnahmen
</div>
<div style="padding:var(--space-2) var(--space-4)">
${massnahmen}
</div>
</div>
`;
el.querySelector('#reise-map-btn')?.addEventListener('click', () => {
App.navigate('map');
});
}
// ------------------------------------------------------------------
// PUBLIC
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();