/* ============================================================ BAN YARO — Offline-Bereitschafts-Anzeige IM Welten-FAB Färbt die 5 Pfoten-Pfade je nach Cache-Stand grün: 1 = App-Shell · 2 = Wichtige Seiten · 3 = Hund-/Tagebuchdaten 4 = Karten-Tiles · 5 = Training & Wissen ============================================================ */ window.OfflineIndicator = (() => { 'use strict'; // Cache-Namen dynamisch finden — robust gegen Versions-Updates const CACHE_TILES = 'ban-yaro-tiles-v1'; const CACHE_API = 'ban-yaro-api-v1'; const TILE_MIN = 20; // niedriger Schwellwert: 5x4 Tiles reichen für Nahbereich const LS_LAST_POS = 'by_last_position'; // teilt sich Storage mit wetter.js async function _staticCache() { const names = await caches.keys(); const found = names.find(n => /^by-v\d+-static$/.test(n)); return found ? await caches.open(found) : null; } const CHECKS = [ { step: 1, title: 'App-Grundgerüst', detail: 'CSS, Layout und Hauptmodule — die Basis', probe: async () => { const c = await _staticCache(); if (!c) return false; const urls = (await c.keys()).map(r => r.url); return urls.some(u => u.includes('/css/design-system.css')) && urls.some(u => u.includes('/js/app.js')); } }, { step: 2, title: 'Wichtige Seiten', detail: 'Tagebuch, Gesundheit, Karte, Gassi, Erste Hilfe, Notizen, Ausgaben, Routen', probe: async () => { const c = await _staticCache(); if (!c) return false; const want = ['diary.js','health.js','map.js','walks.js','erste-hilfe.js', 'notes.js','expenses.js','routes.js']; const urls = (await c.keys()).map(r => r.url); const have = want.filter(name => urls.some(u => u.includes('/js/pages/' + name))); return have.length >= want.length - 1; // 1 Toleranz (falls einzelner Fetch fehlschlug) } }, { step: 3, title: 'Hund-Daten', detail: 'Profil, Tagebuch und Gesundheit', probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; const urls = (await c.keys()).map(r => r.url); const hasProfile = urls.some(u => /\/api\/dogs\/\d+(\?|$)/.test(u)) || urls.some(u => /\/api\/dogs\/\d+\/welcome-dashboard/.test(u)); const hasDiary = urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)); const hasHealth = urls.some(u => /\/api\/dogs\/\d+\/health/.test(u)); // Profil + mindestens eine Datenquelle (Tagebuch oder Gesundheit) return hasProfile && (hasDiary || hasHealth); } }, { step: 4, title: 'Weitere Listen', detail: 'Ausgaben, Routen, Notizen', probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; const urls = (await c.keys()).map(r => r.url); const found = [ urls.some(u => u.includes('/api/expenses')), urls.some(u => u.includes('/api/routes')), urls.some(u => u.includes('/api/notes')), ].filter(Boolean).length; return found >= 2; // 2 von 3 — toleriert wenn z.B. notes 401 lieferte } }, { step: 5, title: 'Karten-Kacheln', detail: `Mindestens ${TILE_MIN} OSM-Tiles im Umkreis`, probe: async () => { const c = await caches.open(CACHE_TILES).catch(() => null); if (!c) return false; return (await c.keys()).length >= TILE_MIN; } }, ]; // Tile-Prefetch-Konfiguration const TILE_PREFETCH = [ { zoom: 14, radius: 3 }, // 7x7 = 49 Tiles im Nahbereich { zoom: 13, radius: 1 }, // 3x3 = 9 Tiles für Übersicht ]; let _fab = null; async function refresh() { _fab = document.getElementById('worlds-fab'); if (!_fab || !('caches' in window)) return null; const results = await Promise.all(CHECKS.map(async c => { try { return { ...c, ok: await c.probe() }; } catch { return { ...c, ok: false }; } })); _fab.querySelectorAll('.paw-elem').forEach(el => { const step = Number(el.dataset.step); const isOk = results.find(r => r.step === step)?.ok; el.classList.toggle('filled', !!isOk); }); const score = results.filter(r => r.ok).length; _fab.setAttribute('data-offline-score', `${score}/5`); return { score, results }; } // Optional aufrufbar: zeigt das Status-Modal mit Nachlade-Button async function openStatus() { const data = await refresh(); if (!data) return; const { score, results } = data; const missing = results.filter(r => !r.ok); const rows = results.map(r => `
${r.ok ? '✓' : '○'}
${r.title}
${r.detail}
`).join(''); UI.modal.open({ title: `🐾 Offline-Bereitschaft ${score}/5`, body: `

${missing.length === 0 ? 'Voll offline-fähig — Tagebuch, Karte und Daten funktionieren auch ohne Internet.' : 'Je grüner deine Pfote im FAB, desto mehr klappt offline. Fehlende Inhalte werden beim nächsten Online-Aufruf automatisch geladen.'}

${rows} `, footer: `
${missing.length ? `` : ''}
`, }); document.getElementById('offline-fill-btn')?.addEventListener('click', async () => { const btn = document.getElementById('offline-fill-btn'); btn.disabled = true; btn.textContent = 'Lade …'; await _fetchMissing(missing); UI.modal.close(); UI.toast.success('Offline-Inhalte aktualisiert.'); refresh(); }); } async function _fetchMissing(missing) { const tasks = []; for (const m of missing) { if (m.step === 2) { ['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js'].forEach(p => tasks.push(fetch(`/js/pages/${p}?v=${window.APP_VER}`).catch(() => {}))); } else if (m.step === 3) { const dogId = window._appState?.activeDog?.id; if (dogId) { tasks.push(fetch(`/api/dogs/${dogId}`).catch(() => {})); tasks.push(fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {})); tasks.push(fetch(`/api/dogs/${dogId}/health`).catch(() => {})); } } else if (m.step === 4) { tasks.push(fetch('/api/expenses').catch(() => {})); tasks.push(fetch('/api/routes').catch(() => {})); tasks.push(fetch('/api/notes').catch(() => {})); } else if (m.step === 5) { await _prefetchTiles(); } } await Promise.all(tasks); } // ---------------------------------------------------------- // Tile-URL-Berechnung (OSM, Subdomain 'a') // ---------------------------------------------------------- function _tile(lat, lon, z) { const n = Math.pow(2, z); const x = Math.floor((lon + 180) / 360 * n); const latRad = lat * Math.PI / 180; const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1/Math.cos(latRad)) / Math.PI) / 2 * n); return { x, y }; } function _tileUrls(lat, lon, zoom, radius) { const center = _tile(lat, lon, zoom); const out = []; for (let dx = -radius; dx <= radius; dx++) { for (let dy = -radius; dy <= radius; dy++) { out.push(`https://a.tile.openstreetmap.org/${zoom}/${center.x + dx}/${center.y + dy}.png`); } } return out; } // Tile-Prefetch — versucht aktuelle GPS-Position, sonst fällt auf gespeicherte zurück async function _prefetchTiles() { if (!navigator.serviceWorker?.controller) return; let lat = null, lon = null; // 1. Versuch: GPS wenn Permission schon erteilt (kein Popup) try { if (navigator.permissions && navigator.geolocation) { const perm = await navigator.permissions.query({ name: 'geolocation' }); if (perm.state === 'granted') { const pos = await new Promise(res => navigator.geolocation.getCurrentPosition(p => res(p), () => res(null), { timeout: 5000 })); if (pos) { lat = pos.coords.latitude; lon = pos.coords.longitude; } } } } catch {} // 2. Fallback: zuletzt bekannte Position aus localStorage (gesetzt von wetter.js u.a.) if (lat == null) { try { const raw = localStorage.getItem(LS_LAST_POS); if (raw) { const stored = JSON.parse(raw); if (stored?.lat != null && stored?.lon != null) { lat = stored.lat; lon = stored.lon; } } } catch {} } if (lat == null) return; const urls = []; TILE_PREFETCH.forEach(({ zoom, radius }) => urls.push(..._tileUrls(lat, lon, zoom, radius))); navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls }); } // Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte function _prefetchPages() { ['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost'] .forEach(p => fetch(`/js/pages/${p}.js?v=${window.APP_VER}`).catch(() => {})); } // Daten-Prefetch: Listen die offline brauchbar sein müssen, // auch wenn der User die Seiten noch nie geöffnet hat function _prefetchData() { fetch('/api/expenses').catch(() => {}); fetch('/api/routes').catch(() => {}); fetch('/api/notes').catch(() => {}); const dogId = window._appState?.activeDog?.id; if (dogId) { fetch(`/api/dogs/${dogId}`).catch(() => {}); fetch(`/api/dogs/${dogId}/health`).catch(() => {}); fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {}); } } function init() { refresh(); _prefetchPages(); _prefetchTiles(); _prefetchData(); // Mehrere Retries für hund-spezifische Daten — _appState.activeDog wird oft // erst nach Login/Hunde-Load gesetzt, manchmal mehrere Sekunden nach Init [2_000, 5_000, 10_000, 20_000].forEach(delay => { setTimeout(() => { _prefetchData(); refresh(); }, delay); }); if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', e => { if (e?.data?.type === 'CACHE_UPDATE') refresh(); if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh(); }); } setInterval(() => { _prefetchData(); refresh(); }, 60_000); } return { init, refresh, openStatus }; })(); if (document.readyState !== 'loading') { window.OfflineIndicator.init(); } else { document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init()); }