banyaro/backend/static/js/worlds.js
rene 51aad6cf1b Tagebuch-Wochenrückblick + 171 Hundezitate
Wochenrückblick (diary.js _loadPraise) merkt sich jetzt das Wegklicken pro
Kalenderwoche (localStorage by_diary_praise_dismissed) — kommt nicht mehr bei
jedem Öffnen. Lob-Text abwechslungsreich (scheduler.py): wöchentlich
rotierender KI-Fokus + Fallback-Varianten-Pool statt einem festen Satz,
prominente Wochenzahl raus.

WELT-Welt Tageszitat: _QUOTES von 31 auf 171 erweitert (web-recherchiert,
57% mit benannter Quelle statt vorher 29%) — Wiederholung erst nach ~5,7
Monaten statt monatlich.
2026-06-14 20:22:44 +02:00

2071 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Drei Welten Navigation
JETZT | HUND | WELT — horizontales Swipe-System
============================================================ */
window.Worlds = (() => {
let _state = null;
let _cur = 1; // 0=JETZT 1=HUND 2=WELT
let _visible = false;
let _map = null;
let _weltInited = false;
let _refreshPending = false; // gesetzt wenn refresh() während !_visible aufgerufen wird
let _lastUserId = undefined;
let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
let _setupDone = false; // Swipe/Button-Listener nur einmal registrieren
// Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 };
// ── OFFLINE-CACHE (localStorage) ────────────────────────────
// Letzten bekannten Zustand speichern → sofort zeigen, dann updaten
function _wSave(key, data) {
try { localStorage.setItem('w3_' + key, JSON.stringify({ ts: Date.now(), data })); } catch {}
}
function _wLoad(key) {
try {
const p = JSON.parse(localStorage.getItem('w3_' + key) || 'null');
return p ? { data: p.data, ageMin: Math.round((Date.now() - p.ts) / 60000) } : null;
} catch { return null; }
}
// API-Aufruf mit Cache-Fallback: sofort Cache, dann Netz
async function _cachedGet(cacheKey, path) {
const cached = _wLoad(cacheKey);
let fresh = null;
try {
fresh = await API.get(path);
_wSave(cacheKey, fresh);
} catch {}
return { data: fresh ?? cached?.data ?? null, fromCache: !fresh, ageMin: fresh ? 0 : (cached?.ageMin ?? null) };
}
// ── PUBLIC ──────────────────────────────────────────────────
async function init(appState) {
_state = appState;
_lastUserId = undefined; // Neurender erzwingen
_cur = 1;
if (!_setupDone) {
_setupDone = true;
_setupSwipe();
_setupButtons();
_showSwipeHints();
}
_goTo(_cur, false);
show();
}
function _showSwipeHints() {
if (localStorage.getItem('worlds_swipe_seen')) return;
localStorage.setItem('worlds_swipe_seen', '1');
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
const hint = document.createElement('div');
hint.style.cssText = [
'position:absolute;inset:0;pointer-events:none;z-index:55',
'display:flex;align-items:center;justify-content:space-between',
'padding:0 8px;transition:opacity 1s ease',
].join(';');
const arrowStyle = `
display:flex;flex-direction:column;align-items:center;gap:4px;
background:rgba(0,0,0,0.42);backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,0.18);border-radius:14px;
padding:10px 10px;animation:worlds-pulse 1.2s ease infinite alternate;
`;
hint.innerHTML = `
<style>
@keyframes worlds-pulse {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(-3px); }
}
.wsh-right { animation-name:worlds-pulse-r !important; }
@keyframes worlds-pulse-r {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(3px); }
}
</style>
<div style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M165.66 202.34a8 8 0 0 1-11.32 11.32l-80-80a8 8 0 0 1 0-11.32l80-80a8 8 0 0 1 11.32 11.32L91.31 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">JETZT</span>
</div>
<div class="wsh-right" style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32l-80 80a8 8 0 0 1-11.32-11.32L164.69 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">WELT</span>
</div>
`;
ov.appendChild(hint);
setTimeout(() => { hint.style.opacity = '0'; }, 2800);
setTimeout(() => hint.remove(), 3900);
}
function show(worldIdx) {
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
ov.style.display = 'block';
requestAnimationFrame(() => ov.classList.add('worlds-visible'));
_visible = true;
document.getElementById('app-header')?.classList.add('worlds-hidden');
document.getElementById('bottom-nav')?.classList.add('worlds-hidden');
document.getElementById('worlds-back')?.classList.remove('worlds-back-visible');
if (worldIdx != null) _goTo(worldIdx, false);
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
// Ausstehender Refresh (z.B. nach Foto-Upload während Worlds unsichtbar)
if (_refreshPending) {
_refreshPending = false;
_renderJetzt();
_renderHund();
return;
}
// Nach Login/Logout: Config aus DB laden, dann rendern
const currentUserId = _state?.user?.id ?? null;
if (currentUserId !== _lastUserId) {
_lastUserId = currentUserId;
if (currentUserId) {
_loadConfigFromServer().then(() => { _renderJetzt(); _renderHund(); });
} else {
_cfgCache = null;
_renderJetzt();
_renderHund();
}
}
}
function hide() {
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
ov.classList.remove('worlds-visible');
ov.style.display = 'none';
_visible = false;
document.getElementById('app-header')?.classList.remove('worlds-hidden');
document.getElementById('bottom-nav')?.classList.remove('worlds-hidden');
document.getElementById('worlds-back')?.classList.add('worlds-back-visible');
}
function navigateTo(pageId) {
hide();
if (window.App?.navigate) window.App.navigate(pageId);
}
// ── SWIPE ────────────────────────────────────────────────────
function _setupSwipe() {
const track = document.getElementById('worlds-track');
if (!track) return;
track.addEventListener('touchstart', e => {
_t.x = e.touches[0].clientX;
_t.y = e.touches[0].clientY;
_t.active = true; _t.vert = null; _t.moved = 0;
track.style.transition = 'none';
}, { passive: true });
track.addEventListener('touchmove', e => {
if (!_t.active) return;
const dx = e.touches[0].clientX - _t.x;
const dy = e.touches[0].clientY - _t.y;
if (_t.vert === null) _t.vert = Math.abs(dy) > Math.abs(dx) + 4;
if (_t.vert) return;
e.preventDefault();
// Nicht über erste/letzte Seite hinausziehen
const cdx = _cur === 0 ? Math.min(0, dx) : _cur === 2 ? Math.max(0, dx) : dx;
_t.moved = cdx;
const base = -_cur * (100 / 3);
track.style.transform = `translateX(calc(${base}% + ${cdx}px))`;
}, { passive: false });
track.addEventListener('touchend', () => {
if (!_t.active || _t.vert) { _t.active = false; return; }
_t.active = false;
let next = _cur;
if (_t.moved < -55 && _cur < 2) next = _cur + 1;
else if (_t.moved > 55 && _cur > 0) next = _cur - 1;
_goTo(next, true);
if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
});
// Mausrad-Navigation (Desktop)
let _wheelCooldown = false;
track.addEventListener('wheel', e => {
e.preventDefault();
if (_wheelCooldown) return;
const next = e.deltaX > 30 || e.deltaY > 30 ? Math.min(2, _cur + 1)
: e.deltaX < -30 || e.deltaY < -30 ? Math.max(0, _cur - 1)
: _cur;
if (next === _cur) return;
_wheelCooldown = true;
setTimeout(() => { _wheelCooldown = false; }, 500);
_goTo(next, true);
if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
}, { passive: false });
}
function _goTo(idx, animated) {
_cur = Math.max(0, Math.min(2, idx));
const track = document.getElementById('worlds-track');
if (!track) return;
track.style.transition = animated
? 'transform 0.32s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
: 'none';
track.style.transform = `translateX(${-_cur * (100 / 3)}%)`;
_updateDots();
_updateFab();
// Karte neu rendern nachdem Transition abgeschlossen
if (_cur === 2 && _map) {
setTimeout(() => _map.invalidateSize(), animated ? 380 : 50);
}
}
function _updateDots() {
document.querySelectorAll('.wdot').forEach((d, i) => d.classList.toggle('active', i === _cur));
document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur));
}
function _fabOptions() {
const worldNames = ['jetzt', 'hund', 'welt'];
const chips = _chipsForWorld(worldNames[_cur]);
const opts = [];
for (const chip of chips) {
if (chip.fab) for (const o of chip.fab) { if (opts.length < 6) opts.push(o); }
}
return opts;
}
function _updateFab() {
const fab = document.getElementById('worlds-fab');
if (!fab) return;
const opts = _fabOptions();
if (!opts.length) { fab.style.display = 'none'; return; }
fab.style.display = '';
fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`);
fab.title = 'Schnellaktion';
}
function _setupButtons() {
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
document.getElementById('worlds-back')?.addEventListener('click', () => {
if (_state?.user) show();
else if (window.App) window.App.navigate('welcome');
});
document.querySelectorAll('.wdot').forEach((dot, i) => {
dot.style.pointerEvents = 'auto';
dot.addEventListener('click', () => {
_goTo(i, true);
if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
});
});
document.querySelectorAll('.wlabel').forEach((lbl, i) => {
lbl.style.pointerEvents = 'auto';
lbl.style.cursor = 'pointer';
lbl.addEventListener('click', () => {
_goTo(i, true);
if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
});
});
}
function _openFab() {
const options = _fabOptions();
const meldenPages = new Set(['poison','lost','recalls','map']);
const meldenCount = options.filter(o => meldenPages.has(o.page)).length;
const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?';
const ov = document.createElement('div');
ov.id = 'fab-overlay';
ov.className = 'w3-sheet-overlay';
ov.innerHTML = `
<div id="fab-backdrop" class="w3-backdrop"></div>
<div class="w3-sheet-panel">
<div class="w3-sheet-header">
<div class="w3-sheet-title">${options.length ? title : 'Schnellzugriff'}</div>
<button id="fab-close" class="w3-close-btn">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
${options.map(o => `
<button class="fab-option w3-fab-option" data-page="${o.page}" data-action="${o.action || ''}">
<div class="w3-icon-dot" style="background:${o.color}18">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:${o.color}">
<use href="/icons/phosphor.svg#${o.icon}"></use>
</svg>
</div>
<div>
<div style="font-size:var(--text-sm);font-weight:700">${o.label}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${o.sub}</div>
</div>
</button>
`).join('')}
</div>
<button id="fab-all-btn" class="w3-all-btn" style="margin-top:${options.length ? '14px' : '0'}">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#squares-four"></use></svg>
Weitere Funktionen
</button>
</div>
`;
document.body.appendChild(ov);
const _close = () => ov.remove();
ov.querySelector('#fab-backdrop').addEventListener('click', _close);
ov.querySelector('#fab-close').addEventListener('click', _close);
ov.querySelector('#fab-all-btn').addEventListener('click', () => { _close(); _openAllChips(); });
ov.querySelectorAll('.fab-option').forEach(btn => {
btn.addEventListener('click', () => {
_close();
const page = btn.dataset.page;
const action = btn.dataset.action;
if (action === 'quickGassi') {
_openQuickGassi();
return;
}
navigateTo(page);
if (action === 'openNew') {
setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
}
});
});
}
function _openAllChips() {
const worldNames = ['jetzt', 'hund', 'welt'];
const worldLabels = { jetzt: 'JETZT', hund: 'HUND', welt: 'WELT' };
// Alle Seiten die aktuell in irgendeiner Welt konfiguriert sind
const cfg = _getConfig();
const configured = new Set(worldNames.flatMap(w => cfg[w] || []));
const sections = worldNames.map(w => {
const chips = (_DEFAULT_CONFIG[w] || []).map(_chipMeta)
.filter(c => c && _chipAllowed(c) && !configured.has(c.page));
if (!chips.length) return '';
return `
<div style="margin-bottom:20px">
<div class="w3-section-label">${worldLabels[w]}</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
${chips.map(c => `
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#${c.icon}"></use>
</svg>
<span class="w3-chip-label">${c.label}</span>
</button>
`).join('')}
</div>
</div>
`;
}).join('');
const ov = document.createElement('div');
ov.id = 'fab-overlay';
ov.className = 'w3-sheet-overlay';
ov.innerHTML = `
<div id="fab-backdrop" class="w3-backdrop"></div>
<div class="w3-sheet-panel w3-sheet-panel--scroll">
<div class="w3-sheet-header w3-sheet-header--mb20">
<div class="w3-sheet-title">Ausgeblendete Funktionen</div>
<button id="fab-close" class="w3-close-btn">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
${sections || `<div style="text-align:center;padding:24px 0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Alle Funktionen sind bereits in deinen Welten sichtbar.
</div>`}
<div style="margin-top:16px;padding:12px 14px;border-radius:12px;
background:var(--c-surface-raised,rgba(0,0,0,.04));
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6;
display:flex;align-items:flex-start;gap:10px">
<svg class="ph-icon" style="width:1rem;height:1rem;flex-shrink:0;margin-top:1px;color:var(--c-primary)">
<use href="/icons/phosphor.svg#info"></use>
</svg>
<span>Einzelne Funktionen ausblenden oder zwischen den Welten verschieben:
<button id="fab-all-goto-worlds" style="background:none;border:none;padding:0;cursor:pointer;
color:var(--c-primary);font-size:inherit;font-family:inherit;font-weight:600;
text-decoration:underline;text-underline-offset:2px">
Welten bearbeiten
</button>
in deinem Profil.
</span>
</div>
</div>
`;
document.body.appendChild(ov);
const _close = () => ov.remove();
ov.querySelector('#fab-backdrop').addEventListener('click', _close);
ov.querySelector('#fab-close').addEventListener('click', _close);
ov.querySelectorAll('.all-chip-btn').forEach(btn => {
btn.addEventListener('click', () => {
_close();
navigateTo(btn.dataset.page);
});
});
ov.querySelector('#fab-all-goto-worlds')?.addEventListener('click', () => {
_close();
_openConfigModal();
});
}
// ── SCHNELL-GASSI ─────────────────────────────────────────────
async function _openQuickGassi() {
const dog = _dogs[_dogIdx] || null;
if (!dog) {
UI.toast?.error('Kein Hund gefunden. Bitte zuerst ein Profil anlegen.');
navigateTo('dog-profile');
return;
}
// Wetter aus Cache holen (kein Wait nötig)
let weatherData = null;
try {
const wc = _wLoad('weather');
if (wc?.data) weatherData = wc.data;
} catch {}
let selectedMin = 30;
const durations = [15, 30, 45, 60];
const ov = document.createElement('div');
ov.id = 'quick-gassi-overlay';
ov.className = 'w3-sheet-overlay';
ov.style.zIndex = '400';
const weatherLine = weatherData
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:6px">
🌡 ${Math.round(weatherData.temp_c)}° · ${_esc(weatherData.desc?.split(' ')[0] || '')}
</div>` : '';
ov.innerHTML = `
<div id="qg-backdrop" class="w3-backdrop" style="background:rgba(0,0,0,0.6);backdrop-filter:blur(3px)"></div>
<div class="w3-sheet-panel" style="padding:24px 16px calc(env(safe-area-inset-bottom,16px) + 20px)">
<div class="w3-sheet-header w3-sheet-header--mb20">
<div>
<div class="w3-sheet-title">🐾 Schnell-Gassi</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${_esc(dog.name)} · ohne GPS
</div>
${weatherLine}
</div>
<button id="qg-close" class="w3-close-btn--lg">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div style="font-size:var(--text-sm);font-weight:600;margin-bottom:10px">Dauer</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:24px">
${durations.map(d => `
<button class="qg-dur w3-dur-btn${d === selectedMin ? ' active' : ''}" data-min="${d}">
${d} min
</button>
`).join('')}
</div>
<button id="qg-submit" class="w3-submit-btn">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg>
Eintragen
</button>
</div>
`;
document.body.appendChild(ov);
const _close = () => ov.remove();
ov.querySelector('#qg-backdrop').addEventListener('click', _close);
ov.querySelector('#qg-close').addEventListener('click', _close);
// Dauer-Toggle
ov.querySelectorAll('.qg-dur').forEach(btn => {
btn.addEventListener('click', () => {
selectedMin = parseInt(btn.dataset.min);
ov.querySelectorAll('.qg-dur').forEach(b => {
b.classList.toggle('active', parseInt(b.dataset.min) === selectedMin);
});
});
});
// Eintragen
ov.querySelector('#qg-submit').addEventListener('click', async () => {
const submitBtn = ov.querySelector('#qg-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird eingetragen…';
try {
// Kein Tagebucheintrag — nur Streak pingen
await API.post(`/streak/${dog.id}/ping`);
_close();
UI.toast?.success(`Gassi eingetragen! ${selectedMin} min 🐾`);
// Streak-Cache invalidieren
try { localStorage.removeItem('w3_streak_' + dog.id); } catch {}
// JETZT-Welt neu rendern für aktuellen Streak
setTimeout(() => _renderJetzt(), 300);
} catch (err) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg> Eintragen';
UI.toast?.error('Fehler beim Eintragen. Bitte erneut versuchen.');
}
});
}
// ── CHIP-KONFIGURATION ──────────────────────────────────────
// Alle verfügbaren Chips mit Metadaten
const _ALL_CHIPS = [
{ icon:'note-pencil', label:'Notizblock', page:'notes', pro: true,
fab:[{ icon:'note-pencil', color:'#10B981', label:'Neue Notiz', sub:'Schnellnotiz erstellen', page:'notes', action:'openNew' }] },
{ icon:'currency-eur', label:'Ausgaben', page:'expenses',
fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] },
{ icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' },
{ icon:'handshake', label:'Playdate', page:'playdate', pro: true,
fab:[{ icon:'handshake', color:'#F59E0B', label:'Playdate anfragen', sub:'Treffen mit anderen Hunden', page:'playdate', action:'openNew' }] },
{ icon:'chat-circle-dots', label:'Nachrichten', page:'chat', pro: true },
{ icon:'sun', label:'Wetter', page:'wetter' },
{ icon:'book-open', label:'Tagebuch', page:'diary',
fab:[{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }] },
{ icon:'heartbeat', label:'Gesundheit', page:'health',
fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' },
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] },
{ icon:'target', label:'Übungen', page:'uebungen',
fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] },
{ icon:'list-checks', label:'Trainingspläne', page:'trainingsplaene',
fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] },
{ icon:'heart', label:'Adoption', page:'adoption',
fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] },
{ icon:'house-line', label:'Sitting', page:'sitting',
fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] },
{ icon:'books', label:'Wiki', page:'wiki' },
{ icon:'scales', label:'Wurfbörse', page:'wurfboerse' },
{ icon:'map-trifold', label:'Karte', page:'map',
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
{ icon:'push-pin', label:'Forum', page:'forum',
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
{ icon:'users', label:'Freunde', page:'friends',
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true,
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
{ icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] },
{ icon:'skull', label:'Giftköder', page:'poison',
fab:[{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }] },
{ icon:'warning-circle', label:'Rückrufe', page:'recalls',
fab:[{ icon:'warning-circle', color:'#EF4444', label:'Rückruf melden', sub:'Produkt oder Futter', page:'recalls', action:'openNew' }] },
{ icon:'dog', label:'Verlorene', page:'lost',
fab:[{ icon:'dog', color:'#3B82F6', label:'Verlorenen melden', sub:'Hilf beim Wiederfinden', page:'lost' }] },
{ icon:'path', label:'Routen', page:'routes',
fab:[{ icon:'path', color:'#10B981', label:'Route aufzeichnen', sub:'GPS-Tracking starten', page:'routes', action:'openNew' }] },
{ icon:'calendar-dots', label:'Events', page:'events',
fab:[{ icon:'calendar-dots', color:'#06B6D4', label:'Event erstellen', sub:'Veranstaltung ankündigen', page:'events', action:'openNew' }] },
{ icon:'sparkle', label:'Jobs', page:'jobs' },
{ icon:'book-open', label:'Knigge', page:'knigge' },
{ icon:'film-slate', label:'Filme', page:'movies' },
{ icon:'certificate', label:'Züchter', page:'breeder-dashboard', role:'breeder',
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' },
{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] },
{ icon:'sparkle', label:'Social', page:'social', role:'social',
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
{ icon:'handshake', label:'Partner', page:'partner-dashboard', role:'partner' },
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
// ── NEUE FEATURES ────────────────────────────────────────────
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true,
fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] },
{ icon:'airplane', label:'Reise', page:'reise', pro: true },
{ icon:'smiley', label:'Persönlichkeit', page:'personality' },
];
const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
'breeder-dashboard','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
'jobs','knigge','movies','reise'],
};
// _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default
let _cfgCache = null;
function _mergeDefaults(cfg) {
const result = JSON.parse(JSON.stringify(cfg));
const hidden = new Set(result.hidden || []);
// Chips die bereits einer Welt zugewiesen sind, nicht nochmal einfügen
const allAssigned = new Set([
...(result.jetzt || []), ...(result.hund || []), ...(result.welt || []),
]);
for (const world of ['jetzt', 'hund', 'welt']) {
const def = _DEFAULT_CONFIG[world] || [];
const saved = result[world] || [];
for (const page of def) {
// Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet
if (!allAssigned.has(page) && !hidden.has(page)) {
saved.push(page);
allAssigned.add(page);
}
}
result[world] = saved;
}
return result;
}
async function _loadConfigFromServer() {
try {
const res = await API.get('/profile/world-config');
if (res?.config) {
_cfgCache = _mergeDefaults(res.config);
try { localStorage.setItem('world_chips', JSON.stringify(_cfgCache)); } catch {}
return;
}
// Noch nichts in DB: lokale Config hochladen (einmalige Migration)
const local = (() => { try { return JSON.parse(localStorage.getItem('world_chips') || 'null'); } catch { return null; } })();
if (local) {
_cfgCache = _mergeDefaults(local);
API.put('/profile/world-config', { config: _cfgCache }).catch(() => {});
return;
}
} catch {}
// Fallback: localStorage → Default
try { _cfgCache = _mergeDefaults(JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG); }
catch { _cfgCache = _DEFAULT_CONFIG; }
}
function _getConfig() {
return _cfgCache || _DEFAULT_CONFIG;
}
function _saveConfig(cfg) {
// Bewusst ausgeblendete Chips tracken: Default-Chips die keiner Welt zugewiesen sind
const allAssigned = new Set([...(cfg.jetzt||[]), ...(cfg.hund||[]), ...(cfg.welt||[])]);
const allDefault = [..._DEFAULT_CONFIG.jetzt, ..._DEFAULT_CONFIG.hund, ..._DEFAULT_CONFIG.welt];
cfg.hidden = allDefault.filter(p => !allAssigned.has(p));
_cfgCache = cfg;
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
if (_state?.user) {
API.put('/profile/world-config', { config: cfg }).catch(() => {});
}
}
function _chipMeta(page) {
return _ALL_CHIPS.find(c => c.page === page) || null;
}
function _chipAllowed(chip) {
const u = _state?.user;
const tier = u?.subscription_tier || 'standard';
const isTest = tier.endsWith('_test');
// Pro-Chips: komplett ausblenden wenn kein Zugriff
if (chip.pro) {
if (!u) return false;
if (isTest) return ['pro_test','breeder_test'].includes(tier);
if (u.rolle === 'admin' || u.rolle === 'moderator') return true;
if (u.is_moderator || u.is_social_media) return true;
return ['pro','breeder'].includes(tier);
}
// Role-Checks (hart — komplett ausblenden)
if (!chip?.role) return true;
if (chip.role === 'breeder') {
if (isTest) return tier === 'breeder_test';
return u?.rolle === 'breeder' || u?.rolle === 'admin';
}
if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin';
if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
if (chip.role === 'partner') return !!u?.is_partner || u?.rolle === 'admin';
if (chip.role === 'admin') return u?.rolle === 'admin';
return true;
}
// Gibt true zurück wenn User vollen Pro-Zugriff hat
function _hasProAccess() {
const u = _state?.user;
if (!u) return false;
const tier = u.subscription_tier || 'standard';
if (tier.endsWith('_test')) return ['pro_test','breeder_test'].includes(tier);
if (u.rolle === 'admin' || u.rolle === 'moderator') return true;
if (u.is_moderator || u.is_social_media) return true;
return ['pro','breeder'].includes(tier);
}
function _chipsForWorld(world) {
const cfg = _getConfig();
const pages = cfg[world] || _DEFAULT_CONFIG[world];
// Deduplizieren + filtern — _chipAllowed entscheidet ob angezeigt
const seen = new Set();
const chips = pages
.filter(p => { if (seen.has(p)) return false; seen.add(p); return true; })
.map(_chipMeta)
.filter(c => c && _chipAllowed(c));
return chips;
}
// ── KONFIGURATIONS-MODAL ─────────────────────────────────────
function _openConfigModal() {
let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy
let _drag = null; // { page, fromWorld, ghost }
let _removeHintShown = false; // „ausblenden ≠ löschen"-Toast nur einmal pro Session
const isAdmin = _state?.user?.rolle === 'admin';
const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' };
const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' };
const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]);
const poolChips = () => _ALL_CHIPS.filter(c => _chipAllowed(c) && !allAssigned().has(c.page) && !c.pinned);
const bottomNav = document.getElementById('bottom-nav');
if (bottomNav) bottomNav.style.display = 'none';
const _isDesktop = window.innerWidth >= 768;
const ov = document.createElement('div');
ov.id = 'wc-overlay';
ov.style.cssText = _isDesktop
? 'position:fixed;inset:0;z-index:500;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)'
: 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
document.body.appendChild(ov);
const _removeDragListeners = () => {
document.removeEventListener('pointermove', _onDragMove);
document.removeEventListener('pointerup', _onDragEnd);
document.removeEventListener('pointercancel', _onDragEnd);
};
const _cancelDrag = () => {
if (!_drag) return;
_removeDragListeners();
_drag.ghost?.remove();
if (_drag.chipEl) _drag.chipEl.style.opacity = '';
ov.querySelectorAll('.wc-zone').forEach(z => z.style.borderColor = 'transparent');
_drag = null;
};
const _closeModal = () => {
_cancelDrag(); // laufenden Drag abbrechen
ov.remove();
if (bottomNav) bottomNav.style.removeProperty('display');
};
function _render() {
const _sheetStyle = _isDesktop
? 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:20px;width:90%;max-width:1100px;max-height:88vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 20px'
: 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:24px 24px 0 0;max-height:92vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 calc(env(safe-area-inset-bottom,16px)+16px)';
const _gridCols = _isDesktop ? 'repeat(auto-fill,minmax(120px,1fr))' : 'repeat(4,1fr)';
const _chipH = _isDesktop ? '64px' : '80px';
ov.innerHTML = `
${!_isDesktop ? '<div id="wc-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>' : ''}
<div id="wc-sheet" style="${_sheetStyle}">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;
padding:20px 20px 16px;position:sticky;top:0;background:rgba(18,22,32,0.97);
border-bottom:1px solid rgba(255,255,255,0.1);z-index:2">
<button id="wc-cancel" style="background:none;border:none;color:rgba(255,255,255,0.5);
cursor:pointer;font-size:var(--text-sm);padding:8px">Abbrechen</button>
<div style="font-size:var(--text-base);font-weight:700;color:white">Welten einrichten</div>
<button id="wc-save" style="background:var(--c-primary);color:white;border:none;
border-radius:999px;padding:7px 16px;cursor:pointer;font-weight:700;
font-size:var(--text-sm)">Fertig</button>
</div>
<!-- Hinweis + Reset -->
<div style="padding:10px 20px 6px;display:flex;align-items:center;justify-content:space-between;gap:12px">
<div style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);flex:1">
Lang drücken &amp; ziehen zum Verschieben. ✕ blendet aus (löscht nicht) —
ausgeblendete Funktionen bleiben über „Weitere Funktionen" abrufbar.
</div>
<button id="wc-reset" style="background:none;border:1px solid rgba(255,255,255,0.2);
color:rgba(255,255,255,0.5);border-radius:999px;padding:5px 12px;
cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0">
Zurücksetzen
</button>
</div>
${['jetzt','hund','welt','pool'].map(w => {
const chips = w === 'pool' ? poolChips() : (cfg[w] || []).map(_chipMeta).filter(c => c && _chipAllowed(c));
const col = worldColors[w] || 'var(--c-border)';
return `
<div style="padding:12px 14px 6px">
<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:rgba(255,255,255,0.5);
margin-bottom:8px;display:flex;align-items:center;gap:8px">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;
background:${col}"></span>
${worldLabels[w]}
${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''}
</div>
<div class="wc-zone" data-zone="${w}"
style="display:grid;grid-template-columns:${_gridCols};grid-auto-rows:${_chipH};gap:${_isDesktop?'6px':'8px'};
min-height:${w==='pool'&&chips.length===0?'40px':'auto'};
border:2px dashed transparent;border-radius:16px;padding:4px;
transition:border-color .2s">
${chips.map(c => `
<div class="wc-chip" data-page="${c.page}" data-zone="${w}"
style="background:rgba(38,46,62,0.95);border:1.5px solid ${col};
border-radius:16px;padding:${_isDesktop?'6px 4px':'10px 4px 8px'};height:${_chipH};box-sizing:border-box;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${_isDesktop?'3px':'5px'};
cursor:grab;position:relative;min-width:0;overflow:hidden;
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
border-radius:50%;background:#EF4444;border:3px solid rgba(18,22,32,0.95);
cursor:pointer;display:flex;align-items:center;justify-content:center;
z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.7)">
<svg class="ph-icon" style="width:17px;height:17px;color:white;stroke:white;stroke-width:1.5">
<use href="/icons/phosphor.svg#x"></use>
</svg>
</button>` : `
<div style="position:absolute;top:-4px;right:-4px;width:14px;height:14px;
border-radius:50%;background:rgba(196,132,58,0.9);
display:flex;align-items:center;justify-content:center"
title="Immer sichtbar">
<svg class="ph-icon" style="width:8px;height:8px;color:white">
<use href="/icons/phosphor.svg#lock-simple"></use>
</svg>
</div>`}
${isAdmin && c.pro ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
${isAdmin && c.role === 'breeder' ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">Z</span>` : ''}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
<use href="/icons/phosphor.svg#${c.icon}"></use>
</svg>
<span style="font-size:9px;font-weight:600;color:rgba(255,255,255,0.9);
line-height:1.2;text-align:center;width:100%;
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical;
padding:0 2px">${c.label.replace('\n','<br>')}</span>
</div>
`).join('')}
${chips.length === 0 ? `<div style="grid-column:1/-1;text-align:center;
font-size:var(--text-xs);color:rgba(255,255,255,0.35);padding:12px">
${w==='pool'?'Alle Chips sind zugeordnet':'Leer — ziehe Chips hierher'}
</div>` : ''}
</div>
</div>
`;
}).join('')}
</div>
`;
_bindEvents();
}
function _bindEvents() {
ov.querySelector('#wc-bg')?.addEventListener('click', _closeModal);
ov.querySelector('#wc-cancel')?.addEventListener('click', _closeModal);
ov.querySelector('#wc-reset')?.addEventListener('click', () => {
cfg = JSON.parse(JSON.stringify(_DEFAULT_CONFIG));
_render();
});
ov.querySelector('#wc-save')?.addEventListener('click', () => {
_saveConfig(cfg);
_closeModal();
_renderJetzt(); _renderHund(); _renderWelt();
});
// Remove-Buttons
ov.querySelectorAll('.wc-remove').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const page = btn.dataset.page, zone = btn.dataset.zone;
const meta = _chipMeta(page);
if (meta?.pinned) return; // gepinnte Chips können nicht entfernt werden
if (zone !== 'pool') {
cfg[zone] = cfg[zone].filter(p => p !== page);
// Klarstellen: ausblenden ≠ löschen (einmal pro Session)
if (!_removeHintShown) {
_removeHintShown = true;
UI.toast?.info('Ausgeblendet, nicht gelöscht — über „Weitere Funktionen" jederzeit wieder einblendbar.');
}
}
_render();
});
});
// Pointer-Drag (funktioniert auf Mouse + Touch)
ov.querySelectorAll('.wc-chip').forEach(chip => {
chip.addEventListener('pointerdown', e => _onDragStart(e, chip));
});
}
function _onDragStart(e, chipEl) {
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
if (_drag) _cancelDrag();
chipEl.setPointerCapture(e.pointerId);
_drag = {
page: chipEl.dataset.page, zone: chipEl.dataset.zone,
chipEl, ghost: null, dropZone: null, active: false,
startX: e.clientX, startY: e.clientY, ox: 0, oy: 0,
};
document.addEventListener('pointermove', _onDragMove);
document.addEventListener('pointerup', _onDragEnd);
document.addEventListener('pointercancel', _onDragEnd);
}
function _activateDrag(e) {
const rect = _drag.chipEl.getBoundingClientRect();
_drag.ox = _drag.startX - rect.left;
_drag.oy = _drag.startY - rect.top;
_drag.active = true;
const ghost = _drag.chipEl.cloneNode(true);
ghost.querySelectorAll('button').forEach(b => b.style.display = 'none');
ghost.style.position = 'fixed';
ghost.style.zIndex = '9999';
ghost.style.opacity = '0.9';
ghost.style.pointerEvents= 'none';
ghost.style.transform = 'scale(1.08) rotate(-2deg)';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
ghost.style.left = (e.clientX - _drag.ox) + 'px';
ghost.style.top = (e.clientY - _drag.oy) + 'px';
ghost.style.transition = 'none';
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
document.body.appendChild(ghost);
_drag.ghost = ghost;
_drag.chipEl.style.opacity = '0.2';
}
function _onDragMove(e) {
if (!_drag) return;
if (!_drag.active) {
const dx = Math.abs(e.clientX - _drag.startX);
const dy = Math.abs(e.clientY - _drag.startY);
if (dx < 8 && dy < 8) return;
_activateDrag(e);
}
_drag.ghost.style.left = (e.clientX - _drag.ox) + 'px';
_drag.ghost.style.top = (e.clientY - _drag.oy) + 'px';
let foundZone = null;
ov.querySelectorAll('.wc-zone').forEach(z => {
const r = z.getBoundingClientRect();
const over = e.clientX >= r.left && e.clientX <= r.right
&& e.clientY >= r.top && e.clientY <= r.bottom;
z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent';
if (over) foundZone = z.dataset.zone;
});
_drag.dropZone = foundZone;
}
function _onDragEnd() {
if (!_drag) return;
_removeDragListeners();
const { ghost, chipEl, dropZone, page, zone: fromZone, active } = _drag;
_drag = null;
ov.querySelectorAll('.wc-zone').forEach(z => z.style.borderColor = 'transparent');
ghost?.remove();
if (chipEl) chipEl.style.opacity = '';
if (!active) return; // nur Tap, kein Drag — nichts verschieben
const meta = _chipMeta(page);
if (dropZone && dropZone !== fromZone && !(meta?.pinned && dropZone === 'pool')) {
if (fromZone !== 'pool') cfg[fromZone] = cfg[fromZone].filter(p => p !== page);
if (dropZone !== 'pool' && !cfg[dropZone].includes(page)) cfg[dropZone].push(page);
_render();
}
}
_render();
}
// ── PANORAMA HINTERGRUNDBILD ─────────────────────────────────
async function _loadDailyImage(dog) {
if (!dog) return null;
const userId = _state?.user?.id || 'anon';
const todayKey = `bg3_${userId}_` + new Date().toISOString().slice(0, 10);
const cached = _wLoad(todayKey);
if (cached?.data) return cached.data;
try {
const dash = await API.dogs.welcomeDashboard(dog.id);
const url = dash?.random_photo?.url || dog.foto_url || null;
if (url) _wSave(todayKey, url);
return url;
} catch { return dog.foto_url || null; }
}
let _bgUrl = null; // aktuell gesetztes Hintergrundbild
let _bgBrightness = null; // { top, bottom } Roh-Helligkeit (0255) je Bildhälfte für adaptive Abdunklung
function _isDarkMode() {
const t = document.documentElement.getAttribute('data-theme');
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function _bgWithOverlay(url) {
return _isDarkMode()
? `linear-gradient(rgba(0,0,0,0.45),rgba(0,0,0,0.45)), url('${url}')`
: `url('${url}')`;
}
function _applyBgOrientation() {
const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track');
if (!ov || !track || !_bgUrl) return;
const portrait = window.matchMedia('(orientation: portrait)').matches;
if (portrait) {
// Panorama: Bild über alle drei Welten, scrollt mit dem Swipe
ov.style.backgroundImage = '';
track.style.backgroundImage = _bgWithOverlay(_bgUrl);
track.style.backgroundSize = 'cover';
track.style.backgroundPosition = 'center 40%';
track.style.backgroundRepeat = 'no-repeat';
} else {
// Vollbild pro Welt (Landscape / Desktop)
track.style.backgroundImage = '';
ov.style.backgroundImage = _bgWithOverlay(_bgUrl);
ov.style.backgroundSize = 'cover';
ov.style.backgroundPosition = 'center 40%';
ov.style.backgroundRepeat = 'no-repeat';
}
}
// Orientierungswechsel → Bild neu setzen
window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation);
// Theme-Wechsel → Overlay-Intensität + adaptive Abdunklung neu anwenden
new MutationObserver(() => { _applyBgOrientation(); _applyAdaptiveDim(); })
.observe(document.documentElement, { attributeFilter: ['data-theme'] });
// Helligkeit (0255) der oberen und unteren Bildhälfte via Mini-Canvas.
// Oben = Begrüßungs-Banner + JETZT-Chip-Reihe, unten = Feature-Chips.
// Gibt null zurück bei CORS-Tainting oder Fehler (→ Fallback-Abdunklung).
function _measureBrightnessRegions(img) {
try {
const c = document.createElement('canvas');
const w = c.width = 32, h = c.height = 32;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
const half = h / 2;
let sumTop = 0, sumBot = 0, nTop = 0, nBot = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
if (y < half) { sumTop += lum; nTop++; } else { sumBot += lum; nBot++; }
}
}
return { top: sumTop / nTop, bottom: sumBot / nBot };
} catch { return null; }
}
// Helligkeit (0255) → Abdunklungs-Alpha. Im Dark Mode liegt zusätzlich
// ein 0.45-Overlay über dem Bild, daher wirkt es dort dunkler.
function _dimForBrightness(b) {
if (_isDarkMode()) b *= 0.55;
if (b <= 90) return 0.14;
if (b >= 190) return 0.48;
return 0.14 + (b - 90) / 100 * (0.48 - 0.14);
}
// Setzt --wbg-dim-top/-bottom adaptiv pro Bildbereich: heller Bereich →
// mehr Abdunklung (Lesbarkeit), dunkler → wenig.
function _applyAdaptiveDim() {
const r = _bgBrightness || { top: 110, bottom: 110 };
const dimTop = _dimForBrightness(r.top).toFixed(2);
const dimBot = _dimForBrightness(r.bottom).toFixed(2);
['wp-jetzt', 'wp-hund', 'wp-welt'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.style.setProperty('--wbg-dim-top', dimTop);
el.style.setProperty('--wbg-dim-bottom', dimBot);
});
}
function _applyBgImage(url) {
const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track');
if (!ov || !track) return;
if (url) {
const toLoad = new Image();
toLoad.onload = () => {
_hasBgPhoto = true;
_bgUrl = url;
_bgBrightness = _measureBrightnessRegions(toLoad);
_applyAdaptiveDim();
_applyBgOrientation();
const hint = document.getElementById('wh-photo-hint');
if (hint) {
const seen = parseInt(localStorage.getItem('banyaro_wh_hint_seen') || '0');
if (seen < 2) {
localStorage.setItem('banyaro_wh_hint_seen', String(seen + 1));
setTimeout(() => {
hint.style.transition = 'opacity 0.6s';
hint.style.opacity = '0';
setTimeout(() => hint.remove(), 650);
}, 4000);
} else {
hint.remove();
}
}
};
toLoad.onerror = () => _applyBgImage(null);
toLoad.src = url;
} else {
_hasBgPhoto = false;
_bgUrl = null;
_bgBrightness = { top: 20, bottom: 20 }; // dunkler Fallback-Gradient → minimale Abdunklung
_applyAdaptiveDim();
track.style.backgroundImage = '';
ov.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)';
ov.style.backgroundSize = '100% 100%';
}
}
// ── CHIP-HELPER ──────────────────────────────────────────────
function _isRoleBasedPro() {
const u = _state?.user;
if (!u) return false;
const t = u.subscription_tier || 'standard';
if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false;
return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media;
}
function _chip(icon, label, page, locked = false, proBadge = false, breederBadge = false) {
const style = locked ? 'opacity:0.25;cursor:default;' : '';
const badge = proBadge
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>`
: breederBadge
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px">Z</span>`
: '';
return `
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`}
style="${style}position:relative">
${badge}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<span class="world-chip-label">${label}</span>
</div>`;
}
// ── JETZT WORLD ──────────────────────────────────────────────
async function _renderJetzt() {
const el = document.getElementById('wj-content');
if (!el) return;
const user = _state?.user;
el.innerHTML = _skeleton(3);
const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([
_getCachedWeather(),
user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }),
user ? _getNearbyAlerts() : Promise.resolve([]),
user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }),
]);
const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 };
const dogsObj = dogsRes.value || { data: [], fromCache: false, ageMin: 0 };
const w = weatherObj.data;
const dogList = dogsObj.data || [];
const dog = dogList[0] || null;
const alertList = alertsRes.value || [];
const totalKm = achRes.value?.data?.stats?.total_km ?? null;
const isOffline = weatherObj.fromCache && dogsObj.fromCache;
const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0);
// Panorama-Bild setzen (nur wenn noch kein Bild vorhanden)
if (dog && !_bgUrl) {
_loadDailyImage(dog).then(_applyBgImage);
} else if (!dog) { _applyBgImage(null); }
const hour = new Date().getHours();
const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
// User-Geburtstag heute?
const _todayDdMm = (() => { const d = new Date(); return String(d.getDate()).padStart(2,'0')+'.'+String(d.getMonth()+1).padStart(2,'0'); })();
const userBdayToday = user?.geburtstag && user.geburtstag === _todayDdMm;
const greet = userBdayToday
? `Herzlichen Glückwunsch`
: (hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend');
const stale = isOffline && staleMin > 5
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
// Gassi-Score aus Wetterdaten berechnen
function _calcGassiScore(wd) {
if (!wd) return null;
let s = 10;
const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0;
if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1;
if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1;
if (wind > 60) s -= 2; else if (wind > 40) s -= 1;
if (wd.thunderstorm) s -= 3;
return Math.max(1, Math.min(10, s));
}
const gassiScore = _calcGassiScore(w);
const gassiColor = gassiScore >= 8 ? '#10B981' : gassiScore >= 5 ? '#F59E0B' : '#EF4444';
const weatherEmoji = !w ? ['🌤️']
: w.thunderstorm ? ['⛈️']
: (w.precip_prob ?? 0) > 70 ? ['🌧️']
: (w.precip_prob ?? 0) > 30 ? ['🌦️']
: (w.temp_c ?? 20) > 28 ? ['☀️', '🔥']
: (w.temp_c ?? 20) < 2 ? ['🌨️']
: ['☀️'];
// User-Geburtstag Reminder
const userBdayHtml = userBdayToday ? `
<div class="world-reminder" style="border-color:rgba(196,132,58,0.6);
flex-direction:column;align-items:center;text-align:center;gap:6px;padding:12px 14px">
<div style="display:flex;gap:8px;align-items:center;justify-content:center">
<svg class="ph-icon bday-fw1" style="width:1.3rem;height:1.3rem;color:#f59e0b">
<use href="/icons/phosphor.svg#confetti"></use></svg>
<svg class="ph-icon bday-pop" style="width:1.8rem;height:1.8rem;color:#fff">
<use href="/icons/phosphor.svg#cake"></use></svg>
<svg class="ph-icon bday-fw2" style="width:1.3rem;height:1.3rem;color:#f59e0b">
<use href="/icons/phosphor.svg#confetti"></use></svg>
</div>
<div style="font-weight:800;font-size:var(--text-sm);color:#fff">
Alles Gute zum Geburtstag, ${_esc(firstName)}!
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.55)">
Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾
</div>
</div>` : '';
// Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => `
<div class="world-reminder" data-wnav="${a.page}" style="border-color:rgba(239,68,68,0.5)">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:#EF4444">
<use href="/icons/phosphor.svg#${a.icon}"></use>
</svg>
<span style="font-size:var(--text-xs);font-weight:700;color:#FCA5A5">${_esc(a.title)}</span>
</div>`).join('');
// Feature Chips aus Config
const features = user
? _chipsForWorld('jetzt')
: [
{ icon:'sun', label:'Wetter', page:'wetter' },
{ icon:'first-aid',label:'Erste Hilfe', page:'erste-hilfe' },
{ icon:'gear', label:'Anmelden', page:'settings' },
];
// User-Avatar für JETZT Info-Card
const uFoto = _state?.user?.avatar_url;
const uInitial = firstName ? firstName.charAt(0).toUpperCase() : '?';
const userAvatarHtml = `
<div data-wnav="settings" style="flex-shrink:0;cursor:pointer"
title="Mein Profil">
${uFoto
? `<img src="${_esc(uFoto)}" style="width:40px;height:40px;border-radius:50%;
object-fit:cover;border:2px solid rgba(255,255,255,0.35);display:block">`
: `<div style="width:40px;height:40px;border-radius:50%;background:var(--c-primary);
display:flex;align-items:center;justify-content:center;
font-size:var(--text-base);font-weight:800;color:white;
border:2px solid rgba(255,255,255,0.35)">${uInitial}</div>`}
</div>`;
el.innerHTML = `
<div class="world-top">
<div class="world-info-card">
<div style="display:flex;align-items:center;gap:12px">
<div style="flex:1;min-width:0">
<div class="world-info-title">
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
</div>
<div class="world-info-sub">${_esc(dayStr)}</div>
</div>
${user ? userAvatarHtml : ''}
</div>
</div>
${userBdayHtml}
${alertHtml}
${user && dog ? `
<div class="wj-chip-row">
<div class="wj-chip" data-wnav="wetter">
<div style="display:flex;align-items:center;gap:6px;width:100%">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:1px;flex-shrink:0;font-size:1.4rem;line-height:1">
${weatherEmoji.map(e => `<span>${e}</span>`).join('')}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:9px;color:rgba(255,255,255,0.55);font-weight:600;letter-spacing:.05em;text-transform:uppercase">Gassi-Score</div>
<div style="display:flex;align-items:baseline;gap:3px;margin-top:1px">
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div>
${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
${w.location_name ? `<div style="color:rgba(255,255,255,0.5)">${w.location_name}</div>` : ''}
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
</div>` : ''}
</div>
</div>
</div>
<div class="wj-chip" data-wnav="routes">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#path"></use></svg>
<span class="wj-chip-label">Gassirunde</span>
<span class="wj-chip-val" id="wj-route-val">…</span>
${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
</div>
<div class="wj-chip" id="wj-exercise-chip">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#barbell"></use></svg>
<span class="wj-chip-label">Übung</span>
<span class="wj-chip-val" id="wj-exercise-val">…</span>
</div>
</div>` : ''}
</div>
<div class="world-bottom">
<div class="world-chips-grid">
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="impressum">Impressum</span>
</div>
</div>
`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
if (user && dog) {
_loadJetztExercise(dog);
_loadJetztRoute();
}
}
async function _loadJetztExercise(dog) {
const valEl = document.getElementById('wj-exercise-val');
if (!valEl) return;
try {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise;
valEl.textContent = ex?.name || 'Stand erfassen →';
const chip = document.getElementById('wj-exercise-chip');
if (chip) {
chip.style.cursor = 'pointer';
chip.onclick = () => {
hide();
if (window.App) window.App.navigate('uebungen', true,
ex ? { exercise_id: ex.exercise_id || '', name: ex.name || '' } : {}
);
};
}
} catch { valEl.textContent = 'Stand erfassen →'; }
}
async function _loadJetztRoute() {
const valEl = document.getElementById('wj-route-val');
if (!valEl) return;
// Tages-Cache (gleicher Key wie welcome.js)
const today = new Date().toISOString().slice(0, 10);
const cacheKey = 'by_daily_route_' + today;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try { _applyJetztRoute(valEl, JSON.parse(cached)); return; } catch {}
}
let loc;
try { loc = await API.getLocation({ timeout: 5000, maximumAge: 300000 }); }
catch { valEl.textContent = 'Standort nötig'; return; }
const dayIdx = Math.floor(Date.now() / 86400000);
const km = [2, 4, 6][dayIdx % 3];
const seed = dayIdx % 5;
try {
const result = await API.post('/routes/suggest', { lat: loc.lat, lon: loc.lon, distance_km: km, seed });
if (!result?.gps_track?.length) { valEl.textContent = 'Keine Route gefunden'; return; }
localStorage.setItem(cacheKey, JSON.stringify(result));
// alte Einträge aufräumen
Object.keys(localStorage)
.filter(k => k.startsWith('by_daily_route_') && k !== cacheKey)
.forEach(k => localStorage.removeItem(k));
_applyJetztRoute(valEl, result);
} catch { valEl.textContent = 'Route nicht verfügbar'; }
}
function _applyJetztRoute(valEl, result) {
const durStr = result.dauer_min < 60
? `${result.dauer_min} min`
: `${Math.floor(result.dauer_min / 60)}h ${result.dauer_min % 60 > 0 ? ' ' + (result.dauer_min % 60) + 'min' : ''}`;
valEl.textContent = `${result.distanz_km} km · ${durStr}`;
}
// ── HUND WORLD ───────────────────────────────────────────────
async function _renderHund() {
const el = document.getElementById('wh-content');
if (!el) return;
const user = _state?.user;
if (!user) {
el.innerHTML = `
<div class="world-info-card" style="text-align:center">
<div style="font-size:4rem;margin-bottom:12px">🐾</div>
<div class="world-info-title">Dein Hund wartet</div>
<div class="world-info-sub" style="margin-bottom:20px">Melde dich an um loszulegen</div>
<button class="btn btn-primary" id="world-login-btn">Anmelden</button>
</div>`;
el.querySelector('#world-login-btn')?.addEventListener('click', () => navigateTo('settings'));
return;
}
el.innerHTML = _skeleton(4);
const dogsRes = await _cachedGet('dogs', '/dogs');
const dogs = dogsRes.data || [];
if (!dogs.length) {
const features = [
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
{ icon:'scales', color:'#3B82F6', title:'Wurfbörse', sub:'Welpen finden' },
{ icon:'currency-eur', color:'#06B6D4', title:'Ausgaben', sub:'Budget im Blick' },
];
el.innerHTML = `
<div class="world-top">
<div class="world-info-card" style="text-align:center">
<div style="font-size:3.2rem;margin-bottom:10px">🐶</div>
<div class="world-info-title">Dein Hund wartet!</div>
<div class="world-info-sub" style="margin-bottom:16px">
Lege ein Profil an und schalte alle Features frei
</div>
<button class="btn btn-primary" id="welcome-add-dog-btn" style="width:100%">
Hund anlegen
</button>
</div>
</div>
<div class="world-bottom">
<div class="world-section-label">Was dich erwartet</div>
<div class="world-chips-grid">
${features.map(f => `
<div class="world-chip" style="opacity:0.7;cursor:default">
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:${f.color}">
<use href="/icons/phosphor.svg#${f.icon}"></use>
</svg>
<span class="world-chip-label">${f.title}</span>
</div>
`).join('')}
</div>
</div>`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
el.querySelector('#welcome-add-dog-btn')?.addEventListener('click', () => navigateTo('dog-profile'));
return;
}
// Hunde global cachen für schnelles Cycling
_dogs = dogs;
if (_dogIdx >= _dogs.length) _dogIdx = 0;
const dog = _dogs[_dogIdx];
// Geburtstag prüfen (heute oder morgen → Feature sichtbar)
function _birthdayState(geb) {
if (!geb) return null;
const today = new Date();
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const mt = String(tomorrow.getMonth() + 1).padStart(2, '0');
const dt = String(tomorrow.getDate()).padStart(2, '0');
const mmdd = geb.slice(5); // 'MM-DD'
if (mmdd === `${mm}-${dd}`) return 'today';
if (mmdd === `${mt}-${dt}`) return 'tomorrow';
return null;
}
const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null;
// Großes Banner nur wenn der AKTIVE Hund Geburtstag hat
const bday = (bdayDog && bdayDog.id === dog.id) ? _birthdayState(dog.geburtstag) : null;
const bdayYear = bday && dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
// Hinweis in Info-Karte wenn ein ANDERER Hund Geburtstag hat
const otherBdayDog = (bdayDog && bdayDog.id !== dog.id) ? bdayDog : null;
const [streakRes, diaryRes] = await Promise.allSettled([
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
_cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`),
]);
const streak = streakRes.value?.data ?? streakRes.value;
const diaryData = diaryRes.value?.data ?? diaryRes.value;
const lastEntry = diaryData?.entries?.[0] || diaryData?.[0] || null;
const ageStr = dog.alter_jahre ? _fmtAlter(dog.alter_jahre) : '';
const stats = [dog.rasse, ageStr, dog.gewicht_kg ? dog.gewicht_kg + ' kg' : null].filter(Boolean).join(' · ');
const otherDogs = _dogs.filter((_, i) => i !== _dogIdx);
const chips = _chipsForWorld('hund');
// Avatar des aktuellen Hundes (links, → Hundeprofil)
const dogAvatarHtml = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" style="width:44px;height:44px;border-radius:50%;
object-fit:cover;border:2px solid rgba(255,255,255,0.35);
cursor:pointer;flex-shrink:0" id="wh-avatar">`
: `<div id="wh-avatar" style="width:44px;height:44px;border-radius:50%;background:var(--c-primary);
display:flex;align-items:center;justify-content:center;
font-size:1.3rem;flex-shrink:0;cursor:pointer;
border:2px solid rgba(255,255,255,0.25)">🐶</div>`;
// Andere Hunde-Avatare (rechts, überlagernd, → Hund wechseln)
const otherAvatarsHtml = otherDogs.length > 0 ? `
<div style="display:flex;align-items:center;flex-shrink:0">
${otherDogs.slice(0, 4).map((d, i) => {
const targetIdx = _dogs.indexOf(d);
return `
<div class="wh-other-dog" data-idx="${targetIdx}"
style="width:30px;height:30px;border-radius:50%;overflow:hidden;
border:2px solid rgba(255,255,255,0.4);
margin-left:${i > 0 ? '-10px' : '0'};
cursor:pointer;position:relative;z-index:${10 - i}">
${d.foto_url
? `<img src="${_esc(d.foto_url)}" style="width:100%;height:100%;object-fit:cover">`
: `<div style="width:100%;height:100%;background:rgba(100,70,40,0.85);
display:flex;align-items:center;justify-content:center;
font-size:10px;font-weight:700;color:white">
${_esc(d.name?.charAt(0)?.toUpperCase() || '?')}
</div>`}
</div>`;
}).join('')}
</div>` : '';
el.innerHTML = `
<div class="world-top">
<div class="world-info-card">
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center">
<div style="justify-self:start">${dogAvatarHtml}</div>
<div id="wh-cycle-btn" style="text-align:center;
cursor:${_dogs.length > 1 ? 'pointer' : 'default'};padding:0 8px">
<div class="world-info-title">${_esc(dog.name)}</div>
${stats ? `<div class="world-info-sub">${_esc(stats)}</div>` : ''}
</div>
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
</div>
${otherBdayDog ? `
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.12);
display:flex;align-items:center;gap:8px;cursor:pointer"
id="wh-other-bday-hint">
<span style="animation:by-bday-bounce 1.2s ease-in-out infinite;display:inline-flex">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#f59e0b" aria-hidden="true">
<use href="/icons/phosphor.svg#cake"></use>
</svg>
</span>
<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.75);font-weight:600">
${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!
</span>
<svg class="ph-icon" style="width:.9rem;height:.9rem;color:rgba(196,132,58,0.8);margin-left:auto" aria-hidden="true">
<use href="/icons/phosphor.svg#arrow-circle-right"></use>
</svg>
</div>` : ''}
</div>
${bday ? `
<style>
@keyframes bday-pop {
0% { transform: scale(0.7) rotate(-8deg); opacity:0; }
60% { transform: scale(1.15) rotate(4deg); }
100% { transform: scale(1) rotate(0deg); opacity:1; }
}
@keyframes bday-fw1 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-8px) scale(1.3);opacity:0.7} }
@keyframes bday-fw2 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-12px) scale(1.4);opacity:0.6} }
@keyframes bday-fw3 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-6px) scale(1.2);opacity:0.8} }
.bday-pop { animation: bday-pop .5s cubic-bezier(.34,1.56,.64,1) both; }
.bday-fw1 { display:inline-block; animation: bday-fw1 1.4s ease-in-out infinite; }
.bday-fw2 { display:inline-block; animation: bday-fw2 1.1s ease-in-out infinite .2s; }
.bday-fw3 { display:inline-block; animation: bday-fw3 1.6s ease-in-out infinite .4s; }
</style>
<div class="world-reminder bday-pop" id="wh-bday-banner" style="flex-direction:column;align-items:center;
text-align:center;gap:6px;padding:14px 16px;cursor:pointer;
background:rgba(0,0,0,0.42);border-color:rgba(196,132,58,0.6)">
<div style="display:flex;gap:10px;align-items:center;justify-content:center">
<svg class="ph-icon bday-fw1" style="width:1.6rem;height:1.6rem;color:#f59e0b"><use href="/icons/phosphor.svg#confetti"></use></svg>
<svg class="ph-icon bday-pop" style="width:2.2rem;height:2.2rem;color:#fff"><use href="/icons/phosphor.svg#${bday === 'today' ? 'cake' : 'gift'}"></use></svg>
<svg class="ph-icon bday-fw2" style="width:1.6rem;height:1.6rem;color:#f59e0b"><use href="/icons/phosphor.svg#confetti"></use></svg>
</div>
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
${bday === 'today'
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!`
: `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`}
</div>
<div style="display:flex;gap:8px;align-items:center">
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
<svg class="ph-icon bday-fw1" style="width:1rem;height:1rem;color:#f59e0b"><use href="/icons/phosphor.svg#star"></use></svg>
<svg class="ph-icon bday-fw2" style="width:1.1rem;height:1.1rem;color:#e8c96e"><use href="/icons/phosphor.svg#balloon"></use></svg>
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#f59e0b"><use href="/icons/phosphor.svg#star"></use></svg>
<svg class="ph-icon bday-fw1" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
</div>
${bdayYear ? `<div style="font-size:10px;color:rgba(255,255,255,0.55)">
${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`}
</div>` : ''}
<div style="display:flex;align-items:center;gap:4px;font-size:10px;color:rgba(196,132,58,0.9);font-weight:700;margin-top:2px">
<svg class="ph-icon" style="width:11px;height:11px"><use href="/icons/phosphor.svg#magic-wand"></use></svg>
${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'}
</div>
</div>
${bday === 'today' && new Date().getHours() >= 18 ? `
<div class="world-reminder" id="wh-bday-evening" style="cursor:pointer;
background:rgba(0,0,0,0.32);border-color:rgba(196,132,58,0.4)">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#book-open"></use>
</svg>
<span style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
Halte den besonderen Tag im Tagebuch fest 🐾
</span>
</div>` : ''}` : ''}
</div>
<div class="world-bottom">
${!_hasBgPhoto ? `
<div id="wh-photo-hint" data-wnav="diary"
style="background:rgba(0,0,0,0.32);backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);
border-radius:16px;padding:11px 14px;display:flex;align-items:center;
gap:10px;cursor:pointer;color:white;flex-shrink:0">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:rgba(196,132,58,0.9);flex-shrink:0">
<use href="/icons/phosphor.svg#camera"></use>
</svg>
<div>
<div style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
Hintergrund-Foto hinzufügen
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
Tagebuchfotos im Querformat erscheinen hier als Panorama
</div>
</div>
</div>
` : ''}
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span>
<span>·</span>
<span data-wnav="partner">Unsere Partner</span>
</div>
</div>
`;
// Avatar → Hundeprofil (aktiven Hund auf den angezeigten setzen)
el.querySelector('#wh-avatar')?.addEventListener('click', () => {
const shown = _dogs[_dogIdx];
if (shown && shown.id !== _state?.activeDog?.id) App.setActiveDog(shown.id);
navigateTo('dog-profile');
});
// Chips
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
// Name → nächster Hund
if (_dogs.length > 1) {
el.querySelector('#wh-cycle-btn')?.addEventListener('click', () => {
_dogIdx = (_dogIdx + 1) % _dogs.length;
_renderHund();
});
}
// Andere Hund-Avatare → zu diesem Hund wechseln
el.querySelectorAll('.wh-other-dog').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); }
});
});
// Geburtstag-Hinweis → zum Geburtstagshund wechseln
if (otherBdayDog) {
if (!document.getElementById('by-bday-anim-style')) {
const s = document.createElement('style');
s.id = 'by-bday-anim-style';
s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.3)}}';
document.head.appendChild(s);
}
el.querySelector('#wh-other-bday-hint')?.addEventListener('click', () => {
const idx = _dogs.indexOf(otherBdayDog);
if (idx >= 0) { _dogIdx = idx; _renderHund(); }
});
}
// Geburtstags-Banner → KI
el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday));
// Abend-Banner: nach 18 Uhr am echten Geburtstag → Tagebucheintrag anregen
if (bday === 'today' && new Date().getHours() >= 18) {
el.querySelector('#wh-bday-evening')?.addEventListener('click', () => {
navigateTo('diary');
setTimeout(() => window.App?.callModule?.('diary', 'openNew'), 400);
});
}
}
async function _openBdayKI(dog, bdayMode) {
const isToday = bdayMode === 'today';
const title = isToday ? `🎂 ${_esc(dog.name)}s Geburtstagstraum` : `🎁 Überraschungen für ${_esc(dog.name)}`;
const ov = document.createElement('div');
ov.className = 'w3-sheet-overlay';
ov.innerHTML = `
<div class="w3-backdrop"></div>
<div class="w3-sheet-panel w3-sheet-panel--scroll">
<div class="w3-sheet-header w3-sheet-header--mb20">
<div class="w3-sheet-title">${title}</div>
<button id="bday-ki-close" class="w3-close-btn">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div id="bday-ki-body" style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7">
<div style="display:flex;align-items:center;gap:10px;color:var(--c-text-secondary);padding:var(--space-4) 0">
<svg class="ph-icon" style="width:20px;height:20px"><use href="/icons/phosphor.svg#sparkle"></use></svg>
KI denkt nach…
</div>
</div>
${isToday ? `
<button id="bday-diary-btn" style="margin-top:var(--space-4);width:100%;display:flex;align-items:center;
justify-content:center;gap:var(--space-2);padding:var(--space-3);border-radius:var(--radius-md);
border:1px solid var(--c-primary);background:transparent;color:var(--c-primary);
font-size:var(--text-sm);font-weight:600;cursor:pointer">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#book-open"></use></svg>
Besonderen Tagebucheintrag anlegen
</button>` : ''}
</div>`;
document.body.appendChild(ov);
const _close = () => ov.remove();
ov.querySelector('.w3-backdrop').addEventListener('click', _close);
ov.querySelector('#bday-ki-close').addEventListener('click', _close);
ov.querySelector('#bday-diary-btn')?.addEventListener('click', () => {
_close();
navigateTo('diary');
setTimeout(() => window.App?.callModule?.('diary', 'openNew'), 400);
});
try {
const res = await API.post('/ki/geburtstag', {
dog_id: bdayDog.id,
name: bdayDog.name,
rasse: bdayDog.rasse || null,
alter: bdayDog.alter_jahre ? Math.round(bdayDog.alter_jahre) : null,
mode: bdayMode,
});
const body = ov.querySelector('#bday-ki-body');
if (body) {
const html = _esc(res.answer || '')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
body.innerHTML = `<div style="padding-bottom:var(--space-2)">${html}</div>`;
}
} catch (err) {
const body = ov.querySelector('#bday-ki-body');
if (body) {
const msg = err?.status === 429
? 'Heute bereits abgerufen — morgen gibt es neue Ideen 🐾'
: 'KI momentan nicht verfügbar. Versuch es später nochmal 🐾';
body.innerHTML = `<div style="color:var(--c-text-secondary)">${msg}</div>`;
}
}
}
// ── WELT WORLD ───────────────────────────────────────────────
const _QUOTES = [
{ t:'Ein Hund ist die einzige Kreatur, die dich mehr liebt als sich selbst.', a:'Josh Billings' },
{ t:'Hunde haben Besitzer. Katzen haben Personal.', a:'Unbekannt' },
{ t:'Bis man einen Hund geliebt hat, ist ein Teil der Seele unerwacht.', a:'Anatole France' },
{ t:'Hunde sind nicht unser ganzes Leben — aber sie machen unser Leben ganz.', a:'Roger Caras' },
{ t:'Der Hund hat nur einen Fehler: Er vertraut dem Menschen zu sehr.', a:'Unbekannt' },
{ t:'Ein treuer Hund ist besser als ein unzuverlässiger Mensch.', a:'Deutsches Sprichwort' },
{ t:'Wer einen Hund hat, gibt nie allein zu Tisch.', a:'Unbekannt' },
{ t:'Die reinste Form der Freude ist die Freude eines Hundes.', a:'Milan Kundera' },
{ t:'Hunde lachen nicht mit dem Mund, sondern mit dem Schwanz.', a:'Max Eastman' },
{ t:'Ein Haus ohne Hund ist ein leeres Haus.', a:'Unbekannt' },
{ t:'Wenn du verstehen willst, was Liebe ist, beobachte einen Hund.', a:'Unbekannt' },
{ t:'Der Hund fragt nicht warum. Er fragt nur: wann gehen wir?', a:'Unbekannt' },
{ t:'Hunde riechen nicht schlecht. Sie riechen nur anders als wir denken.', a:'Unbekannt' },
{ t:'In einer Welt voller Unsicherheiten ist der Hund das Verlässlichste.', a:'Unbekannt' },
{ t:'Ein Spaziergang mit einem Hund ist nie wirklich ein Umweg.', a:'Unbekannt' },
{ t:'Hunde bringen das Beste im Menschen hervor.', a:'Unbekannt' },
{ t:'Wer mit Hunden liegt, steht mit Flöhen auf — und mit einem Lächeln.', a:'Unbekannt' },
{ t:'Ein Hund weiß, wann du traurig bist. Er weiß nicht warum. Aber er ist da.', a:'Unbekannt' },
{ t:'Das Geheimnis eines Hundes: Er beurteilt dich nie.', a:'Unbekannt' },
{ t:'Der Hund ist Gattung: Mensch.', a:'Charles M. Schulz' },
{ t:'Hunde haben viel zu sagen. Wir haben verlernt zuzuhören.', a:'Unbekannt' },
{ t:'Ein guter Hund macht aus einem schlechten Tag einen erträglichen.', a:'Unbekannt' },
{ t:'Der Hund ist der philosophischste aller Haustiere.', a:'George Graham Vest' },
{ t:'Wer einen Hund hat, braucht keinen Therapeuten.', a:'Unbekannt' },
{ t:'Hunde lieben bedingungslos. Das ist ihr größtes Geschenk.', a:'Unbekannt' },
{ t:'Der schönste Empfang ist der eines Hundes an der Tür.', a:'Unbekannt' },
{ t:'Ein Hund ändert dein Leben. Meistens zum Besseren.', a:'Unbekannt' },
{ t:'Hunde altern in Würde. Menschen könnten von ihnen lernen.', a:'Unbekannt' },
{ t:'Kein Psychiater der Welt kann so gut zuhören wie ein Hund.', a:'Unbekannt' },
{ t:'Wo Hunde sind, da ist das Zuhause.', a:'Unbekannt' },
{ t:'Der Hund hat keinen Begriff von Vergangenheit oder Zukunft. Er lebt.', a:'Milan Kundera' },
{ t:"Wenn du einen verhungernden Hund aufnimmst und es ihm gutgehen lässt, wird er dich nicht beißen. Das ist der wesentliche Unterschied zwischen Hund und Mensch.", a:"Mark Twain" },
{ t:"Der Hund ist ein Gentleman. Ich hoffe, in seinen Himmel zu kommen, nicht in den der Menschen.", a:"Mark Twain" },
{ t:"Je mehr ich über die Menschen lerne, desto mehr liebe ich meinen Hund.", a:"Mark Twain" },
{ t:"Der Himmel vergibt nach Gunst. Ginge es nach Verdienst, bliebest du draußen und dein Hund käme hinein.", a:"Mark Twain" },
{ t:"Die Treue eines Hundes ist ein kostbares Geschenk, das nicht weniger bindende Pflichten auferlegt als die Freundschaft eines Menschen.", a:"Konrad Lorenz" },
{ t:"Es gibt keine Treue, die nicht schon einmal gebrochen worden wäre, außer der eines wahrhaft treuen Hundes.", a:"Konrad Lorenz" },
{ t:"Die Bindung an einen echten Hund ist so beständig, wie es Bande auf dieser Erde nur sein können.", a:"Konrad Lorenz" },
{ t:"Der Wunsch, einen Hund zu halten, entspringt der uralten Sehnsucht des zivilisierten Menschen nach dem verlorenen Paradies.", a:"Konrad Lorenz" },
{ t:"Wenn ich daran denke, dass mein Hund mich mehr liebt, als ich ihn, dann beschämt mich das.", a:"Konrad Lorenz" },
{ t:"Der Hund ist, mit Recht, das Sinnbild der Treue.", a:"Arthur Schopenhauer" },
{ t:"Woran sollte man sich von der Falschheit der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen blicken kann.", a:"Arthur Schopenhauer" },
{ t:"Der Anblick jedes Tieres erfreut mich unmittelbar und mir geht dabei das Herz auf, am meisten bei dem der Hunde.", a:"Arthur Schopenhauer" },
{ t:"Hunde lieben ihre Freunde und beißen ihre Feinde, ganz anders als Menschen, die niemals rein lieben, sondern stets Liebe und Hass vermengen.", a:"Sigmund Freud" },
{ t:"Hunde schenken Zuneigung ohne Zwiespalt und die Schönheit eines Daseins, das ganz in sich ruht.", a:"Sigmund Freud" },
{ t:"Gibt es im Himmel keine Hunde, dann will ich, wenn ich sterbe, dorthin gehen, wo sie hingekommen sind.", a:"Will Rogers" },
{ t:"Für seinen Hund ist jeder Mensch ein Napoleon, daher die anhaltende Beliebtheit der Hunde.", a:"Aldous Huxley" },
{ t:"Bevor du einen Hund hast, kannst du dir kaum vorstellen, wie das Leben mit ihm wäre. Danach kannst du dir kein anderes Leben mehr vorstellen.", a:"Caroline Knapp" },
{ t:"Wer einmal einen wundervollen Hund hatte, dessen Leben ist ohne einen ärmer.", a:"Dean Koontz" },
{ t:"Einen Hund zu streicheln und zu kraulen kann den Geist so beruhigen wie tiefe Meditation und ist fast so gut für die Seele wie ein Gebet.", a:"Dean Koontz" },
{ t:"Tausendmal hat er mir gesagt, dass ich sein Grund zu leben bin, durch die Art, wie er sich an mein Bein lehnt.", a:"Gene Hill" },
{ t:"Hunde sind unser Bindeglied zum Paradies. Sie kennen weder Bosheit noch Neid noch Unzufriedenheit.", a:"Milan Kundera" },
{ t:"Mit einem Hund an einem schönen Nachmittag auf einem Hügel zu sitzen ist wie eine Rückkehr nach Eden.", a:"Milan Kundera" },
{ t:"Hunde sprechen sehr wohl, doch nur zu denen, die zu lauschen verstehen.", a:"Orhan Pamuk" },
{ t:"Tiere sind so angenehme Freunde, sie stellen keine Fragen und üben keine Kritik.", a:"George Eliot" },
{ t:"Das größte Vergnügen mit einem Hund ist, dass man sich vor ihm zum Narren machen kann und er nicht nur nicht tadelt, sondern selbst mitmacht.", a:"Samuel Butler" },
{ t:"Bedenke, dass der Allmächtige, der uns den Hund zum Gefährten gab, ihm ein edles Wesen verlieh, das des Betrugs unfähig ist.", a:"Sir Walter Scott" },
{ t:"Ich habe oft über den Grund nachgedacht, warum Hunde so kurz leben, und bin überzeugt, es geschieht aus Mitleid mit dem Menschen.", a:"Sir Walter Scott" },
{ t:"Hunde beißen mich nie. Nur Menschen.", a:"Marilyn Monroe" },
{ t:"Wer hält dich für so großartig wie dein Hund.", a:"Audrey Hepburn" },
{ t:"Ich gehe mit meinen Hunden, das hält mich fit. Ich rede mit meinen Hunden, das hält mich gesund.", a:"Audrey Hepburn" },
{ t:"Sobald er seinen Herrn erblickte, ließ Argos die Ohren sinken und wedelte mit dem Schwanz, doch zu ihm hin zu kommen vermochte er nicht mehr.", a:"Homer" },
{ t:"Hunde und Philosophen tun das meiste Gute und erhalten den geringsten Lohn.", a:"Diogenes" },
{ t:"Hunde sind besser als Menschen, denn sie wissen, doch sie verraten es nicht.", a:"Emily Dickinson" },
{ t:"Bei Schlachten, die das Schicksal von Völkern entschieden, blieb ich ungerührt. Hier aber, beim Kummer eines einzigen Hundes, war ich zu Tränen gerührt.", a:"Napoleon Bonaparte" },
{ t:"Wenn Hunde in den Himmel kommen, brauchen sie keine Flügel, denn Gott weiß, dass Hunde das Laufen am meisten lieben.", a:"Cynthia Rylant" },
{ t:"Meine ganze Bestimmung war es, ihn zu lieben, bei ihm zu sein und ihn glücklich zu machen.", a:"W. Bruce Cameron" },
{ t:"Meine Hundefreunde scheinen meine Grenzen zu verstehen und bleiben immer dicht an meiner Seite.", a:"Helen Keller" },
{ t:"Gib einem Hund dein Herz, das er zerreißen kann.", a:"Rudyard Kipling" },
{ t:"Ein Hund lehrt einen Jungen Treue, Beharrlichkeit und sich dreimal zu drehen, bevor man sich hinlegt.", a:"Robert Benchley" },
{ t:"Du kannst zu einem Hund den größten Unsinn sagen, und er schaut dich an, als wollte er sagen: Donnerwetter, du hast recht, darauf wäre ich nie gekommen.", a:"Dave Barry" },
{ t:"Wenn ich an die Unsterblichkeit glaube, dann daran, dass gewisse Hunde in den Himmel kommen, und nur sehr, sehr wenige Menschen.", a:"James Thurber" },
{ t:"Eine Tür ist das, auf deren falscher Seite ein Hund sich ständig befindet.", a:"Ogden Nash" },
{ t:"Natürlich kann man ohne Hund leben, es lohnt sich nur nicht.", a:"Heinz Rühmann" },
{ t:"Ein Leben ohne Mops ist möglich, aber sinnlos.", a:"Loriot" },
{ t:"Die Welt wäre ein schönerer Ort, wenn jeder so bedingungslos lieben könnte wie ein Hund.", a:"M.K. Clinton" },
{ t:"Der durchschnittliche Hund ist ein netterer Mensch als der durchschnittliche Mensch.", a:"Andy Rooney" },
{ t:"Niemand schätzt das ganz besondere Genie deiner Unterhaltung so sehr wie dein Hund.", a:"Christopher Morley" },
{ t:"Ich habe meinem Schmerz einen Namen gegeben und nenne ihn Hund, denn er ist ebenso treu und klug wie jeder andere Hund.", a:"Friedrich Nietzsche" },
{ t:"Dem Hunde, wenn er gut gezogen, wird selbst ein weiser Mann gewogen.", a:"Johann Wolfgang von Goethe" },
{ t:"Verstößt ein Herr seinen Hund, weil er ihm das Brot nicht mehr verdienen kann, so zeigt das stets eine sehr kleine Seele des Herrn an.", a:"Immanuel Kant" },
{ t:"Wenn du keinen Hund besitzt, ist nicht unbedingt etwas mit dir verkehrt, aber vielleicht stimmt etwas mit deinem Leben nicht.", a:"Roger Caras" },
{ t:"Alte Hunde sind wie alte Schuhe, bequem. Sie sind vielleicht etwas aus der Form, aber sie passen einfach gut.", a:"Bonnie Wilcox" },
{ t:"Kommt ein Hund nicht zu dir, nachdem er dir ins Gesicht gesehen hat, so solltest du heimgehen und dein Gewissen prüfen.", a:"Woodrow Wilson" },
{ t:"Springt ein Hund auf deinen Schoß, dann weil er dich mag. Tut eine Katze dasselbe, ist es nur, weil dein Schoß wärmer ist.", a:"Alfred North Whitehead" },
{ t:"Geld kann dir einen feinen Hund kaufen, aber nur Liebe bringt ihn dazu, mit dem Schwanz zu wedeln.", a:"Kinky Friedman" },
{ t:"Wenn ich für eine Reise den Koffer hervorhole, weiß er es lange vorher und gerät in einen Zustand milder Aufregung.", a:"John Steinbeck" },
{ t:"Ein Hund ist ein Band zwischen Fremden.", a:"John Steinbeck" },
{ t:"Mein kleiner Hund, ein Herzschlag zu meinen Füßen.", a:"Edith Wharton" },
{ t:"Geschaffen wurde der Hund eigens für die Kinder. Er ist der Gott des Übermuts.", a:"Henry Ward Beecher" },
{ t:"Hunde sind klug. Sie kriechen in eine stille Ecke und lecken ihre Wunden und kehren erst in die Welt zurück, wenn sie wieder heil sind.", a:"Agatha Christie" },
{ t:"Schönheit ohne Eitelkeit, Stärke ohne Übermut, Mut ohne Wildheit und alle Tugenden des Menschen ohne seine Laster.", a:"Lord Byron" },
{ t:"Gott dreht Wolken um und um, um den Hunden im Hundehimmel flauschige Betten zu bereiten.", a:"Cynthia Rylant" },
{ t:"Hunde besitzen eine Eigenschaft, die unter Menschen selten ist, nämlich zu erkennen, wer Hilfe braucht, und sie zu geben.", a:"Caroline Knapp" },
{ t:"In manchen Dingen ist mein Hund klüger als ich, in anderen ist er bodenlos unwissend.", a:"John Steinbeck" },
{ t:"Ein Hund zur Hand ist besser als ein Bruder in der Ferne.", a:"Persisches Sprichwort" },
{ t:"Wenn du bei jedem bellenden Hund stehen bleibst, beendest du deine Reise nie.", a:"Arabisches Sprichwort" },
{ t:"Es ist schwer, einen so treuen Gefährten zu finden wie einen Hund.", a:"Mongolisches Sprichwort" },
{ t:"Ein Hund ohne Schwanz kann nicht zeigen, dass er sich freut.", a:"Albanisches Sprichwort" },
{ t:"Ein Hund, der mit dem Schwanz wedelt, bezieht keine Prügel.", a:"Japanisches Sprichwort" },
{ t:"Der hungrige Hund fürchtet den Stock nicht.", a:"Japanisches Sprichwort" },
{ t:"Treffen sich im Paradies eine Menschenseele und eine Hundeseele, verneigt sich der Mensch vor dem Hund.", a:"Sibirisches Sprichwort" },
{ t:"Solange der Mensch denkt, Tiere fühlten nicht, fühlen Tiere, dass der Mensch nicht denkt.", a:"Indianische Weisheit" },
{ t:"Mit Hunden zu leben tut dem Menschen gut.", a:"Tibetisches Sprichwort" },
{ t:"Ein Hund ist ein Herz auf vier Beinen.", a:"Irisches Sprichwort" },
{ t:"Der Hund vergisst den einen Bissen nicht, und wirfst du ihm auch hundert Steine nach.", a:"Chinesisches Sprichwort" },
{ t:"Sei der Freund meines Hundes, dann bist du auch der meine.", a:"Indianische Weisheit" },
{ t:"Hüte dich vor dem Menschen, der nicht spricht, und vor dem Hund, der nicht bellt.", a:"Indianische Weisheit" },
{ t:"Ein kluger Hund bellt nicht ohne Grund.", a:"Französisches Sprichwort" },
{ t:"In seiner eigenen Hütte ist jeder Hund ein Löwe.", a:"Französisches Sprichwort" },
{ t:"Hunde, die sich beißen, halten gegen den Wolf zusammen.", a:"Armenisches Sprichwort" },
{ t:"Der Hund bellt, doch die Karawane zieht weiter.", a:"Türkisches Sprichwort" },
{ t:"Hat ein Armer den Hund großgezogen, folgt er keinem Reichen mehr.", a:"Mongolisches Sprichwort" },
{ t:"Hat der Hund zu viele Herren, schläft er hungrig ein.", a:"Afrikanisches Sprichwort" },
{ t:"Ich hoffe, einmal der Mensch zu werden, für den mein Hund mich hält.", a:"Ungarisches Sprichwort" },
{ t:"Eines Hundes Treue währt ein ganzes Leben lang.", a:"Spanisches Sprichwort" },
{ t:"Faule Schäfer haben die besten Hunde.", a:"Deutsches Sprichwort" },
{ t:"Hunde, die viel bellen, beißen selten.", a:"Italienisches Sprichwort" },
{ t:"Wer einen guten Hund hat, braucht keinen Wächter.", a:"Italienisches Sprichwort" },
{ t:"Wo der Hund frei laufen darf, ist das Glück nicht weit.", a:"Unbekannt" },
{ t:"Ein Hund schaut nicht auf deinen Stand, nur auf dein Herz.", a:"Unbekannt" },
{ t:"Dem Hund ist gleich, ob du reich bist; ihm reicht, dass du heimkommst.", a:"Unbekannt" },
{ t:"Ein Hund braucht keine Worte, um dich zu trösten.", a:"Unbekannt" },
{ t:"Der beste Platz der Welt ist neben einem Hund.", a:"Unbekannt" },
{ t:"Ein Tag mit Hund ist nie ganz verloren.", a:"Unbekannt" },
{ t:"Ein Hund füllt die Stille im Haus mit leisem Glück.", a:"Unbekannt" },
{ t:"Hunde messen die Zeit nicht in Stunden, sondern in Spaziergängen.", a:"Unbekannt" },
{ t:"Ein Hund findet zum Glück immer den kürzesten Weg.", a:"Unbekannt" },
{ t:"Mit einem Hund an der Seite läuft man nie allein.", a:"Unbekannt" },
{ t:"Ein Hund vergibt schneller, als wir uns entschuldigen können.", a:"Unbekannt" },
{ t:"Wer die Sprache der Hunde lernt, hört auf zu reden und beginnt zu fühlen.", a:"Unbekannt" },
{ t:"Ein Hund kennt deinen Namen nicht, aber er kennt dein Herz.", a:"Unbekannt" },
{ t:"Hunde sind die Pünktlichsten, wenn es ums Glücklichsein geht.", a:"Unbekannt" },
{ t:"Ein Hund wartet nicht auf morgen, um dich heute zu lieben.", a:"Unbekannt" },
{ t:"Manche Engel haben Fell und kalte Pfoten.", a:"Unbekannt" },
{ t:"Ein Hund nimmt dich, wie du bist, und macht dich trotzdem besser.", a:"Unbekannt" },
{ t:"Was ein Hund über Freundschaft weiß, lernt der Mensch ein Leben lang.", a:"Unbekannt" },
{ t:"Hunde haben kurze Leben, weil sie das Lieben so gut können, dass sie keine Zeit verschwenden.", a:"Unbekannt" },
{ t:"Glück hat vier Pfoten und einen wedelnden Schwanz.", a:"Unbekannt" },
{ t:"Ein Hund teilt dein Schweigen, ohne es zu füllen.", a:"Unbekannt" },
{ t:"Der treueste Blick der Welt kommt von unten und wedelt dabei.", a:"Unbekannt" },
{ t:"Ein Hund braucht keinen Sonntag, jeder Tag mit dir ist ihm Feiertag.", a:"Unbekannt" },
{ t:"Wo ein Hund die Stiefel bringt, fehlt es nie an Liebe.", a:"Unbekannt" },
{ t:"Hunde rechnen nicht nach, wie viel du gibst; sie geben einfach alles zurück.", a:"Unbekannt" },
{ t:"Ein Hund sieht nicht, wie du aussiehst, sondern wer du bist.", a:"Unbekannt" },
{ t:"Ein Hund macht aus einem Spaziergang ein kleines Abenteuer.", a:"Unbekannt" },
{ t:"Wer einem Hund ins Auge sieht, schaut der Ehrlichkeit beim Atmen zu.", a:"Unbekannt" },
{ t:"Ein Hund spürt deinen schlechten Tag und bleibt trotzdem.", a:"Unbekannt" },
{ t:"Das Schwierigste am Hundeleben ist, dass es zu kurz für so viel Liebe ist.", a:"Unbekannt" },
{ t:"Ein Hund braucht wenig und schenkt davon das Meiste.", a:"Unbekannt" },
{ t:"Hunde sind der Beweis, dass Treue keine Worte braucht.", a:"Unbekannt" },
{ t:"Ein wedelnder Schwanz hat schon manchen Tag gerettet.", a:"Unbekannt" },
{ t:"Wer einen Hund versteht, braucht den Menschen weniger zu erklären.", a:"Unbekannt" },
{ t:"Ein Hund spart seine Liebe nicht auf, er verschenkt sie sofort und vollständig.", a:"Unbekannt" },
{ t:"Die kürzeste Verbindung zwischen zwei Menschen ist manchmal eine Hundeleine.", a:"Unbekannt" },
{ t:"Ein Hund kennt keinen Stolz, nur Wiedersehensfreude.", a:"Unbekannt" },
{ t:"Wer mit einem Hund alt wird, lernt das Glück im Kleinen.", a:"Unbekannt" },
{ t:"Ein Hund hält dir keine Reden, er hält dir die Treue.", a:"Unbekannt" },
{ t:"Vor einem Hund muss man nichts vorgeben; er liebt das Echte.", a:"Unbekannt" },
{ t:"Ein Hund schreibt keine Briefe, doch sein Schwanz erzählt alles.", a:"Unbekannt" },
{ t:"Hunde nehmen die kleinen Dinge ernst, darum sind sie so groß im Lieben.", a:"Unbekannt" },
{ t:"Ein Hund verzeiht dir den Regen, solange du mit ihm hinausgehst.", a:"Unbekannt" },
{ t:"Wer das Vertrauen eines Hundes gewinnt, hat etwas Selteneres als Gold.", a:"Unbekannt" },
{ t:"Ein Hund macht stille Tage warm und laute Tage leiser.", a:"Unbekannt" },
{ t:"Die treueste Uhr im Haus ist der Hund vor dem Futternapf.", a:"Unbekannt" },
{ t:"Ein Hund fragt nicht, wie der Tag war; er macht ihn einfach besser.", a:"Unbekannt" },
{ t:"Wer einen Hund an der Seite hat, ist nirgends ganz fremd.", a:"Unbekannt" },
{ t:"Ein Hund kennt nur ein Tempo bei der Liebe: sofort.", a:"Unbekannt" },
{ t:"Ein Hund am Feuer wärmt mehr als das Feuer selbst.", a:"Unbekannt" },
{ t:"Wer den Hund gut behandelt, dem öffnet sich das Herz von selbst.", a:"Unbekannt" },
{ t:"Ein Hund braucht keinen Kalender, er weiß genau, wann du nach Hause kommst.", a:"Unbekannt" },
];
function _renderWelt() {
const el = document.getElementById('ww-content');
if (!el) return;
const user = _state?.user;
const isMod = user?.rolle === 'admin' || user?.rolle === 'moderator' || user?.is_moderator;
const isAdmin = user?.rolle === 'admin';
const isSocial = user?.is_social_media || isAdmin;
// Tagesbasierter Spruch
const day = Math.floor(Date.now() / 86400000);
const quote = _QUOTES[day % _QUOTES.length];
const chips = _chipsForWorld('welt');
el.innerHTML = `
<div class="world-top">
<div class="world-info-card">
<div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
color:rgba(255,255,255,0.45);margin-bottom:10px">Gedanke des Tages</div>
<div style="font-size:var(--text-sm);color:white;line-height:1.55;font-style:italic">
»${_esc(quote.t)}«
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:8px;text-align:right">
${_esc(quote.a)}
</div>
</div>
</div>
<div class="world-bottom">
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span>
<span style="color:var(--c-border)">·</span>
<span data-wnav="agb">AGB</span>
</div>
</div>
`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
}
// ── HELPERS ──────────────────────────────────────────────────
async function _getCachedWeather() {
const cached = _wLoad('weather');
let fresh = null, pos = null;
try {
pos = await API.getLocation({ timeout: 5000, maximumAge: 600_000 });
fresh = await API.weather.get(pos.lat, pos.lon);
_wSave('weather', fresh);
} catch {}
const data = fresh ?? cached?.data ?? null;
return { data, fromCache: !fresh, ageMin: fresh ? 0 : (cached?.ageMin ?? null) };
}
async function _getWeather() {
const r = await _getCachedWeather();
return r.data;
}
async function _getNearbyAlerts() {
const out = [];
try {
const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 });
// /poison + /lost filtern per lat/lon serverseitig (poison: Radius in METERN,
// lost: radius_km). Die früheren /nearby-Pfade existierten nie (459cd42-Verlust)
// — das doppelte catch hat den 404 jahrelang verschluckt.
const [p, l] = await Promise.allSettled([
API.get(`/poison?lat=${pos.lat}&lon=${pos.lon}&radius=5000`).catch(() => []),
API.get(`/lost?lat=${pos.lat}&lon=${pos.lon}&radius_km=20`).catch(() => []),
]);
if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' });
if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' });
} catch {}
return out;
}
function _skeleton(n) {
return Array.from({length: n}, (_, i) => `
<div style="height:${i===0?80:60}px;background:var(--c-border);border-radius:12px;margin:0 16px 10px;opacity:${0.7 - i*0.1};animation:pulse 1.5s ease-in-out infinite"></div>
`).join('');
}
function _updateBdayTabIndicator(bdayDog) {
if (bdayDog && !document.getElementById('by-bday-tab-style')) {
const s = document.createElement('style');
s.id = 'by-bday-tab-style';
s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.25)}}' +
'.wlabel-bday-ic{display:inline-block;animation:by-bday-bounce 1.2s ease-in-out infinite;margin-left:3px;font-size:.85em}';
document.head.appendChild(s);
}
const hundTab = document.querySelectorAll('#world-labels .wlabel')[1];
if (!hundTab) return;
hundTab.querySelector('.wlabel-bday-ic')?.remove();
if (bdayDog) {
const ic = document.createElement('span');
ic.className = 'wlabel-bday-ic';
ic.textContent = '🎂';
ic.title = `${bdayDog.name} hat ${_birthdayState(bdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!`;
hundTab.appendChild(ic);
}
}
function _fmtDate(d) {
if (!d) return '';
try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); }
catch { return d; }
}
function _fmtAlter(j) {
if (!j) return '';
if (j < 1) return `${Math.round(j * 12)} Monate`;
return j < 2 ? '1 Jahr' : `${Math.round(j)} Jahre`;
}
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function refresh(appState) {
if (appState) _state = appState;
localStorage.removeItem('w3_dogs');
_bgUrl = null;
if (_visible) {
if (_cur === 0) _renderJetzt();
else if (_cur === 1) _renderHund();
else _renderWelt();
} else {
_refreshPending = true;
}
}
return { init, show, hide, navigateTo, refresh, openConfig: _openConfigModal, get _visible() { return _visible; } };
})();