banyaro/backend/static/js/worlds.js
rene 487dacc7c7 Fix: /breeder/my-editor Endpoint (Crash 'Cannot destructure profile') + Läufigkeit in Züchter-Bereich
breeder-editor.js (aus 459cd42) rief /api/breeder/my-editor auf — Endpoint
existierte nie (gleicher Worktree-Verlust wie /partner/my-profile). Jetzt
gebaut: profile + litters + storage_mb/limit; ohne Profil klare 404 statt
Destrukturierungs-Crash. 3 Tests.

Läufigkeit (Rene): eigener HUND-Chip entfällt, stattdessen vierte Karte im
Züchter-Bereich (Zyklen, Progesterontests, Deckdaten). Suite: 58 passed.
2026-06-07 20:04:43 +02:00

1928 lines
92 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' },
];
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 });
const [p, l] = await Promise.allSettled([
API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=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; } };
})();