diff --git a/backend/static/index.html b/backend/static/index.html
index f42f248..559a4e0 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -8,6 +8,11 @@
+
+
+
+
+
@@ -178,6 +183,9 @@
+
+
+
+
+
+
+
+
+
+ JETZT
+ HUND
+ WELT
+
+
+
+
+
+
+
+
+
+
+
+ Zurück
+
+
@@ -525,6 +566,7 @@
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 850b1a0..792086d 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '607'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '639'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@@ -75,6 +75,7 @@ const App = (() => {
recalls: { title: 'Rückrufe', module: null },
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
+ wetter: { title: 'Wetter', module: null },
};
// ----------------------------------------------------------
@@ -98,6 +99,7 @@ const App = (() => {
// ----------------------------------------------------------
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
+ if (window.Worlds?._visible) window.Worlds.hide();
// Aktive Seite ausblenden
document.querySelector('.page.active')?.classList.remove('active');
@@ -852,6 +854,9 @@ const App = (() => {
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams);
+
+ // Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
+ if (window.Worlds) window.Worlds.init(state);
}
async function _handleInvite(token) {
@@ -925,6 +930,8 @@ const App = (() => {
})();
+window.App = App; // Worlds kann App.navigate() aufrufen
+
// App starten
document.addEventListener('DOMContentLoaded', () => {
App.init();
diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js
new file mode 100644
index 0000000..37c9ba7
--- /dev/null
+++ b/backend/static/js/worlds.js
@@ -0,0 +1,988 @@
+/* ============================================================
+ 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 _lastUserId = undefined;
+ let _dogs = []; // gecachte Hundesliste
+ let _dogIdx = 0; // aktuell angezeigter Hund
+
+ // 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;
+ _cur = 1; // immer HUND als Start
+ _setupSwipe();
+ _setupButtons();
+ _goTo(_cur, false);
+ show();
+ // Welten parallel rendern
+ _renderJetzt();
+ _renderHund();
+ }
+
+ 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(); }
+
+ // Nach Login/Logout neu rendern
+ const currentUserId = _state?.user?.id ?? null;
+ if (currentUserId !== _lastUserId) {
+ _lastUserId = currentUserId;
+ _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();
+ _t.moved = dx;
+ const base = -_cur * (100 / 3);
+ track.style.transform = `translateX(calc(${base}% + ${dx}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(); }
+ });
+ }
+
+ 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));
+ }
+
+ function _updateFab() {
+ const fab = document.getElementById('worlds-fab');
+ if (!fab) return;
+ const icons = ['note-pencil', 'paw-print', 'warning'];
+ const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden'];
+ fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`);
+ fab.title = titles[_cur];
+ }
+
+ function _setupButtons() {
+ document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
+ document.getElementById('worlds-settings')?.addEventListener('click', () => navigateTo('settings'));
+ document.getElementById('worlds-back')?.addEventListener('click', () => show());
+ document.querySelectorAll('.wdot').forEach((dot, i) => {
+ dot.style.pointerEvents = 'auto';
+ dot.addEventListener('click', () => {
+ _goTo(i, true);
+ if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
+ });
+ });
+ }
+
+ function _openFab() {
+ const isWelt = _cur === 2;
+ const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar
+
+ const options = isWelt ? [
+ { icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' },
+ { icon:'dog', color:'#3B82F6', label:'Verlorenen Hund melden', sub:'Hilf beim Wiederfinden', page:'lost' },
+ { icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' },
+ ] : [
+ { icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' },
+ { icon:'target', color:'#F59E0B', label:'Training aufzeichnen',sub:'Übung absolviert', page:'uebungen' },
+ { 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' },
+ ];
+
+ // Overlay erstellen
+ const ov = document.createElement('div');
+ ov.id = 'fab-overlay';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end';
+ ov.innerHTML = `
+
+
+
+
+ ${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'}
+
+
+
+
+
+
+ ${options.map(o => `
+
+
+
+
+
+
+
+
${o.label}
+
${o.sub}
+
+
+ `).join('')}
+
+
+ `;
+
+ document.body.appendChild(ov);
+
+ const _close = () => ov.remove();
+ ov.querySelector('#fab-backdrop').addEventListener('click', _close);
+ ov.querySelector('#fab-close').addEventListener('click', _close);
+ ov.querySelectorAll('.fab-option').forEach(btn => {
+ btn.addEventListener('click', () => {
+ _close();
+ const page = btn.dataset.page;
+ const action = btn.dataset.action;
+ navigateTo(page);
+ if (action === 'openNew') {
+ setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
+ }
+ });
+ });
+ }
+
+ // ── CHIP-KONFIGURATION ──────────────────────────────────────
+ // Alle verfügbaren Chips mit Metadaten
+
+ const _ALL_CHIPS = [
+ { icon:'note-pencil', label:'Notizblock', page:'notes' },
+ { icon:'currency-eur', label:'Ausgaben', page:'expenses' },
+ { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' },
+ { icon:'handshake', label:'Playdate', page:'playdate' },
+ { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' },
+ { icon:'sun', label:'Wetter', page:'wetter' },
+ { icon:'gear', label:'Profil', page:'settings', pinned:true },
+ { icon:'book-open', label:'Tagebuch', page:'diary' },
+ { icon:'heartbeat', label:'Gesundheit', page:'health' },
+ { icon:'target', label:'Übungen', page:'uebungen' },
+ { icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'},
+ { icon:'heart', label:'Adoption', page:'adoption' },
+ { icon:'house-line', label:'Sitting', page:'sitting' },
+ { icon:'books', label:'Wiki', page:'wiki' },
+ { icon:'scales', label:'Wurfbörse', page:'wurfboerse' },
+ { icon:'map-trifold', label:'Karte', page:'map' },
+ { icon:'push-pin', label:'Forum', page:'forum' },
+ { icon:'users', label:'Freunde', page:'friends' },
+ { icon:'paw-print', label:'Gassi', page:'walks' },
+ { icon:'skull', label:'Giftköder', page:'poison' },
+ { icon:'warning-circle', label:'Rückrufe', page:'recalls' },
+ { icon:'dog', label:'Verlorene', page:'lost' },
+ { icon:'path', label:'Routen', page:'routes' },
+ { icon:'calendar-dots', label:'Events', page:'events' },
+ { icon:'sparkle', label:'Jobs', page:'jobs' },
+ { icon:'book-open', label:'Knigge', page:'knigge' },
+ { icon:'film-slate', label:'Filme', page:'movies' },
+ { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' },
+ { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' },
+ { icon:'sparkle', label:'Social', page:'social', role:'social' },
+ { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
+ { icon:'gear', label:'Admin', page:'admin', role:'admin' },
+ ];
+
+ const _DEFAULT_CONFIG = {
+ jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'],
+ hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'],
+ welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
+ };
+
+ function _getConfig() {
+ try { return JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; }
+ catch { return _DEFAULT_CONFIG; }
+ }
+ function _saveConfig(cfg) {
+ try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
+ }
+ function _chipMeta(page) {
+ return _ALL_CHIPS.find(c => c.page === page) || null;
+ }
+ function _chipAllowed(chip) {
+ const u = _state?.user;
+ if (!chip?.role) return true;
+ if (chip.role === 'breeder') 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 === 'admin') return u?.rolle === 'admin';
+ return true;
+ }
+ function _chipsForWorld(world) {
+ const pages = _getConfig()[world] || _DEFAULT_CONFIG[world];
+ return pages.map(_chipMeta).filter(c => c && _chipAllowed(c));
+ }
+
+ // ── KONFIGURATIONS-MODAL ─────────────────────────────────────
+
+ function _openConfigModal() {
+ let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy
+ let _drag = null; // { page, fromWorld, ghost }
+
+ 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 ov = document.createElement('div');
+ ov.id = 'wc-overlay';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
+ document.body.appendChild(ov);
+
+ const _removeDragListeners = () => {
+ document.removeEventListener('touchmove', _onDragMove);
+ document.removeEventListener('touchend', _onDragEnd);
+ document.removeEventListener('touchcancel', _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() {
+ ov.innerHTML = `
+
+
+
+
+
Abbrechen
+
Welten einrichten
+
Fertig
+
+
+
+
+ Lang drücken & ziehen zum Verschieben. ✕ zum Entfernen.
+
+
+ Zurücksetzen
+
+
+ ${['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 `
+
+
+
+ ${worldLabels[w]}
+ ${w !== 'pool' ? `(${chips.length}) ` : ''}
+
+
+ ${chips.map(c => `
+
+ ${!c.pinned ? `
+
+
+
+
+ ` : `
+
+
+
+
+
`}
+
+
+
+
${c.label.replace('\n',' ')}
+
+ `).join('')}
+ ${chips.length === 0 ? `
+ ${w==='pool'?'Alle Chips sind zugeordnet':'Leer — ziehe Chips hierher'}
+
` : ''}
+
+
+ `;
+ }).join('')}
+
+ `;
+ _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);
+ _render();
+ });
+ });
+
+ // Touch-Drag
+ ov.querySelectorAll('.wc-chip').forEach(chip => {
+ chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true });
+ });
+ document.addEventListener('touchmove', _onDragMove, { passive: false });
+ document.addEventListener('touchend', _onDragEnd);
+ }
+
+ function _onDragStart(e, chipEl) {
+ if (_drag) _cancelDrag();
+ const touch = e.touches[0];
+ // Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte)
+ _drag = {
+ page: chipEl.dataset.page, zone: chipEl.dataset.zone,
+ chipEl, ghost: null, dropZone: null, active: false,
+ startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0,
+ };
+ document.addEventListener('touchmove', _onDragMove, { passive: false });
+ document.addEventListener('touchend', _onDragEnd);
+ document.addEventListener('touchcancel', _onDragEnd);
+ }
+
+ function _activateDrag(touch) {
+ 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 = (touch.clientX - _drag.ox) + 'px';
+ ghost.style.top = (touch.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;
+ const touch = e.touches[0];
+
+ if (!_drag.active) {
+ const dx = Math.abs(touch.clientX - _drag.startX);
+ const dy = Math.abs(touch.clientY - _drag.startY);
+ if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht
+ _activateDrag(touch);
+ }
+
+ e.preventDefault(); // Scroll erst NACH Threshold blockieren
+ _drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px';
+ _drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px';
+
+ let foundZone = null;
+ ov.querySelectorAll('.wc-zone').forEach(z => {
+ const r = z.getBoundingClientRect();
+ const over = touch.clientX >= r.left && touch.clientX <= r.right
+ && touch.clientY >= r.top && touch.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 todayKey = 'bg_' + new Date().toISOString().slice(0, 10);
+ const cached = _wLoad(todayKey);
+ if (cached?.data) return cached.data;
+ try {
+ const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`);
+ const entries = r.data?.entries || r.data || [];
+ const withPhotos = entries.filter(e => (e.foto_urls?.length || e.foto_url));
+ if (!withPhotos.length) { const u = dog.foto_url || null; if(u) _wSave(todayKey, u); return u; }
+ const day = Math.floor(Date.now() / 86400000);
+ const entry = withPhotos[day % withPhotos.length];
+ const url = (entry.foto_urls?.[0] || entry.foto_url);
+ _wSave(todayKey, url);
+ return url;
+ } catch { return dog.foto_url || null; }
+ }
+
+ function _applyBgImage(url) {
+ const track = document.getElementById('worlds-track');
+ if (!track) return;
+ if (url) {
+ const img = new Image();
+ img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; };
+ img.onerror = () => _applyBgImage(null);
+ img.src = url;
+ } else {
+ track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)';
+ track.style.backgroundSize = '100% 100%';
+ }
+ }
+
+ // ── CHIP-HELPER ──────────────────────────────────────────────
+
+ function _chip(icon, label, page) {
+ return `
+
+
+
+
+ ${label}
+
`;
+ }
+
+ // ── 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] = await Promise.allSettled([
+ _getCachedWeather(),
+ user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }),
+ user ? _getNearbyAlerts() : Promise.resolve([]),
+ ]);
+
+ 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 isOffline = weatherObj.fromCache && dogsObj.fromCache;
+ const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0);
+
+ // Panorama-Bild setzen (nur wenn noch kein Bild vorhanden)
+ const track = document.getElementById('worlds-track');
+ if (dog && !track?.style.backgroundImage?.startsWith('url')) {
+ _loadDailyImage(dog).then(_applyBgImage);
+ } else if (!dog) { _applyBgImage(null); }
+
+ const hour = new Date().getHours();
+ const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
+ const firstName = user?.name?.split(' ')[0] || '';
+ const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
+ const stale = isOffline && staleMin > 5
+ ? `· Offline ` : '';
+ const weatherLine = w
+ ? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen`
+ : '';
+
+ // Streak-Reminder
+ let streakHtml = '';
+ if (user && dog) {
+ try {
+ const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`);
+ const s = sr.data;
+ const streak = s?.current_streak || 0;
+ const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10);
+ const col = trainedToday ? 'rgba(16,185,129,0.8)' : (streak > 0 ? 'rgba(245,158,11,0.8)' : 'rgba(255,255,255,0.4)');
+ const label = streak > 0
+ ? (trainedToday ? `✓ ${streak} Tage Streak` : `🔥 ${streak} Tage — heute noch trainieren!`)
+ : (trainedToday ? '✓ Heute trainiert' : 'Noch kein Training heute');
+ streakHtml = `
+
+
+
+
+ ${label}
+
`;
+ } catch {}
+ }
+
+ // Alert-Reminder
+ const alertHtml = alertList.slice(0,1).map(a => `
+
+
+
+
+ ${_esc(a.title)}
+
`).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?.foto_url;
+ const uInitial = firstName ? firstName.charAt(0).toUpperCase() : '?';
+ const userAvatarHtml = `
+
+ ${uFoto
+ ? `
`
+ : `
${uInitial}
`}
+
`;
+
+ el.innerHTML = `
+
+
+
+
+
+ ${_esc(greet)}${firstName ? `, ${_esc(firstName)} ` : ''}${stale}
+
+
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}
+
+ ${user ? userAvatarHtml : ''}
+
+
+ ${alertHtml}
+ ${streakHtml}
+
+
+
Deine Bereiche
+
+ ${features.map(f => _chip(f.icon, f.label, f.page)).join('')}
+
+
+ `;
+ el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
+ }
+
+ // ── HUND WORLD ───────────────────────────────────────────────
+
+ async function _renderHund() {
+ const el = document.getElementById('wh-content');
+ if (!el) return;
+ const user = _state?.user;
+
+ if (!user) {
+ el.innerHTML = `
+
+
🐾
+
Dein Hund wartet
+
Melde dich an um loszulegen
+
Anmelden
+
`;
+ return;
+ }
+
+ el.innerHTML = _skeleton(4);
+ const dogsRes = await _cachedGet('dogs', '/dogs');
+ const dogs = dogsRes.data || [];
+
+ if (!dogs.length) {
+ el.innerHTML = `
+
+
🐶
+
Noch kein Hund angelegt
+
Erstelle das Profil deines Hundes
+
Hund anlegen
+
`;
+ return;
+ }
+
+ const dog = dogs[0];
+ 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;
+
+ // Hunde global cachen für schnelles Cycling
+ _dogs = dogs;
+ if (_dogIdx >= _dogs.length) _dogIdx = 0;
+ const dog = _dogs[_dogIdx];
+
+ 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
+ ? ` `
+ : `🐶
`;
+
+ // Andere Hunde-Avatare (rechts, überlagernd, → Hund wechseln)
+ const otherAvatarsHtml = otherDogs.length > 0 ? `
+
+ ${otherDogs.slice(0, 4).map((d, i) => {
+ const targetIdx = _dogs.indexOf(d);
+ return `
+
+ ${d.foto_url
+ ? `
`
+ : `
+ ${_esc(d.name?.charAt(0)?.toUpperCase() || '?')}
+
`}
+
`;
+ }).join('')}
+
` : '';
+
+ el.innerHTML = `
+
+
+
+ ${dogAvatarHtml}
+
+
${_esc(dog.name)}
+ ${stats ? `
${_esc(stats)}
` : ''}
+
+ ${otherAvatarsHtml}
+
+
+
+
+
Alles über ${_esc(dog.name)}
+
+ ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
+
+
+ `;
+
+ // Avatar → Hundeprofil
+ el.querySelector('#wh-avatar')?.addEventListener('click', () => 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(); }
+ });
+ });
+ }
+
+ // ── 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 = `
+
+
+
Gedanke des Tages
+
+ »${_esc(quote.t)}«
+
+
+ — ${_esc(quote.a)}
+
+
+
+
+
Die Welt da draußen
+
+ ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
+
+
+ `;
+ 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=5`).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) => `
+
+ `).join('');
+ }
+
+ 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,'&').replace(//g,'>').replace(/"/g,'"');
+ }
+
+ return { init, show, hide, navigateTo, openConfig: _openConfigModal, get _visible() { return _visible; } };
+
+})();
diff --git a/backend/static/sw.js b/backend/static/sw.js
index 177e86b..6ff893c 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
-const CACHE_VERSION = 'by-v607';
+const CACHE_VERSION = 'by-v639';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
@@ -125,11 +125,34 @@ const _CACHEABLE_GET = [
/^\/api\/training\/progress/,
/^\/api\/wiki\/rassen/,
/^\/api\/dogs\/\d+\/diary\/stats/,
+ // Drei Welten — offline-fähig
+ /^\/api\/streak\/\d+/,
+ /^\/api\/forum\/threads/,
+ /^\/api\/weather$/,
+ /^\/api\/passport\/\d+$/,
];
function _isCacheableGet(pathname) {
return _CACHEABLE_GET.some(re => re.test(pathname));
}
+// Cache-TTL: stabile Daten länger, dynamische kürzer
+const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/];
+const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde
+const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten
+
+const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart)
+
+function _cacheTTL(pathname) {
+ return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _TTL_DEFAULT;
+}
+function _cacheStale(pathname) {
+ const ts = _cacheTs.get(pathname);
+ return !ts || (Date.now() - ts) > _cacheTTL(pathname);
+}
+function _cacheMark(pathname) {
+ _cacheTs.set(pathname, Date.now());
+}
+
// ----------------------------------------------------------
// INSTALL — App Shell cachen
// ----------------------------------------------------------
@@ -173,19 +196,27 @@ self.addEventListener('fetch', event => {
if (method === 'GET' && _isCacheableGet(url.pathname)) {
event.respondWith((async () => {
const cached = await caches.match(event.request);
+ const stale = _cacheStale(url.pathname);
+
const networkPromise = _fetchTimeout(event.request.clone(), 8000)
.then(resp => {
- if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
+ if (resp.ok) {
+ _cacheMark(url.pathname);
+ caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
+ }
return resp;
})
.catch(() => null);
- // Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen
- if (cached) {
- networkPromise.catch(() => {}); // fire and forget
+
+ // Cache noch frisch → sofort zurückgeben, Netz im Hintergrund
+ if (cached && !stale) {
+ networkPromise.catch(() => {});
return cached;
}
+ // Cache vorhanden aber abgelaufen → Netz zuerst, Cache als Fallback
const fresh = await networkPromise;
if (fresh) return fresh;
+ if (cached) return cached; // lieber veraltet als nichts
return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } });
})());