From fc2002847c61b172f2e051dd8f16ba8f98c32d49 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:18:11 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Welten=20Info-Cards=20=E2=80=94=20Us?= =?UTF-8?q?er-Avatar=20in=20JETZT,=20Hunde-Avatar+Cycle+Overlap=20in=20HUN?= =?UTF-8?q?D,=20SW=20by-v639?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 42 ++ backend/static/js/app.js | 9 +- backend/static/js/worlds.js | 988 ++++++++++++++++++++++++++++++++++++ backend/static/sw.js | 41 +- 4 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 backend/static/js/worlds.js 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 @@ + Soziales + +
+
+ + + +
+
+ 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 => ` + + `).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 = ` +
+
+ +
+ +
Welten einrichten
+ +
+ +
+
+ Lang drücken & ziehen zum Verschieben. ✕ zum Entfernen. +
+ +
+ ${['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} +
+
+ +
+ ${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
+ +
`; + 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
+ +
`; + 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} +
+
+
+
+ +
+ ${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)} +
+
+
+
+ +
+ ${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' } }); })());