/* ============================================================ BAN YARO — Offline-Bereitschafts-Indikator 5-stufige Pfote im Header, zeigt wie viel der App offline verfügbar ist. Klick → Status-Modal mit Nachlade-Button. ============================================================ */ window.OfflineIndicator = (() => { 'use strict'; // Cache-Namen — müssen mit sw.js übereinstimmen const CACHE_STATIC = `by-v${(window.APP_VER || '0')}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; const CACHE_API = 'ban-yaro-api-v1'; const TILE_MIN = 50; // Mindest-Tiles für Stufe 4 // 5 Offline-Bereitschafts-Checks, in Reihenfolge der Pfoten-Stufen const CHECKS = [ { step: 1, title: 'App-Grundgerüst', detail: 'CSS, Layout und Hauptmodule — die Basis', probe: async () => (await caches.match('/css/design-system.css?v=' + window.APP_VER)) != null || (await caches.match('/css/design-system.css')) != null }, { step: 2, title: 'Wichtige Seiten', detail: 'Tagebuch, Karte, Gassi, Erste Hilfe', probe: async () => { const c = await caches.open(CACHE_STATIC).catch(() => null); if (!c) return false; const must = ['diary.js','map.js','walks.js','erste-hilfe.js']; const keys = await c.keys(); const have = new Set(keys.map(r => r.url)); return must.every(name => [...have].some(u => u.includes('/js/pages/' + name))); } }, { step: 3, title: 'Hund- und Tagebuchdaten', detail: 'Letzte Einträge und Hund-Profil', probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; const keys = await c.keys(); const urls = keys.map(r => r.url); return urls.some(u => /\/api\/dogs\/\d+/.test(u)) && urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)); } }, { step: 4, title: 'Karten-Kacheln', detail: `Mindestens ${TILE_MIN} Tiles im Umkreis`, probe: async () => { const c = await caches.open(CACHE_TILES).catch(() => null); if (!c) return false; const keys = await c.keys(); return keys.length >= TILE_MIN; } }, { step: 5, title: 'Training & Wissen', detail: 'Übungen, Wiki-Rassen, Wetter — Welt-Inhalte', probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; const urls = (await c.keys()).map(r => r.url); return urls.some(u => u.includes('/api/training/exercises')) && urls.some(u => u.includes('/api/wiki/rassen')); } }, ]; let _btn = null; let _svg = null; let _lastScore = -1; // ---------------------------------------------------------- // Score berechnen + Pfote einfärben // ---------------------------------------------------------- async function refresh() { if (!_btn) return; if (!('caches' in window)) { _btn.style.display = 'none'; return; } const results = await Promise.all(CHECKS.map(async c => { try { return { ...c, ok: await c.probe() }; } catch { return { ...c, ok: false }; } })); const score = results.filter(r => r.ok).length; _applyScore(score, results); _lastScore = score; return { score, results }; } function _applyScore(score, results) { if (!_svg) return; _svg.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); }); _btn.title = `Offline-Bereitschaft: ${score} von 5`; _btn.setAttribute('aria-label', `Offline-Bereitschaft: ${score} von 5`); } // ---------------------------------------------------------- // Status-Modal beim Klick // ---------------------------------------------------------- async function _openModal() { const data = await refresh(); if (!data) return; const { score, results } = data; const rows = results.map(r => `
${r.ok ? '✓' : '○'}
${r.title}
${r.detail}
`).join(''); const missing = results.filter(r => !r.ok); const allOk = missing.length === 0; UI.modal.open({ title: `🐾 Offline-Bereitschaft ${score}/5`, body: `

${allOk ? 'Deine App ist voll offline-fähig. Du kannst Tagebuch, Karte und Daten auch ohne Internet nutzen.' : 'Je grüner deine Pfote, desto besser klappt die App ohne Internet. 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(); }); } // ---------------------------------------------------------- // Fehlende Inhalte aktiv nachladen // ---------------------------------------------------------- async function _fetchMissing(missing) { const tasks = []; for (const m of missing) { if (m.step === 2) { // Page-Module fetchen → SW cached sie automatisch ['diary.js','map.js','walks.js','erste-hilfe.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(() => {})); } } else if (m.step === 4) { // Karten-Tiles: SW per Message anstoßen if (navigator.serviceWorker?.controller) { const pos = await new Promise(res => navigator.geolocation?.getCurrentPosition(p => res(p), () => res(null), { timeout: 4000 })); if (pos) { navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', lat: pos.coords.latitude, lon: pos.coords.longitude, zoom: 14, radius: 2, }); } } } else if (m.step === 5) { tasks.push(fetch('/api/training/exercises').catch(() => {})); tasks.push(fetch('/api/wiki/rassen?limit=50').catch(() => {})); tasks.push(fetch('/api/weather').catch(() => {})); } } await Promise.all(tasks); } // ---------------------------------------------------------- // Sichtbarkeit an Welten-Overlay koppeln // — default sichtbar; nur ausblenden wenn explizit auf Detail-Seite // ---------------------------------------------------------- function _syncVisibility() { if (!_btn) return; const ov = document.getElementById('worlds-overlay'); if (!ov) return; // ohne Welten-Overlay sichtbar lassen const inWorlds = ov.classList.contains('worlds-visible') || ov.style.display === 'block' || ov.style.display === ''; _btn.classList.toggle('is-hidden', !inWorlds); } // ---------------------------------------------------------- // Init // ---------------------------------------------------------- function init() { _btn = document.getElementById('offline-indicator'); if (!_btn) { console.warn('[OfflineIndicator] #offline-indicator nicht im DOM'); return; } _svg = _btn.querySelector('.offline-paw'); _btn.addEventListener('click', _openModal); // MutationObserver: Welten-Overlay Klassenänderung → Indikator zeigen/verstecken const ov = document.getElementById('worlds-overlay'); if (ov) { _syncVisibility(); new MutationObserver(_syncVisibility).observe(ov, { attributes: true, attributeFilter: ['class', 'style'] }); } refresh(); // bei SW-Updates und alle 60s neu prüfen if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', e => { if (e?.data?.type === 'CACHE_UPDATE') refresh(); }); } setInterval(refresh, 60_000); } return { init, refresh }; })(); if (document.readyState !== 'loading') { window.OfflineIndicator.init(); } else { document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init()); }