Steps so umverteilt dass sie genau die Datentypen abdecken die der
User offline braucht — auch wenn er die Seiten nie geöffnet hat:
1 App-Grundgerüst CSS + Core-JS
2 Wichtige Seiten alle 10 Page-Module (precached via SW)
3 Hund-Daten Profil + Tagebuch + Gesundheit
4 Weitere Listen Ausgaben + Routen + Notizen
5 Karten-Kacheln OSM-Tiles im Umkreis
Automatischer Prefetch im _prefetchData() beim App-Start:
- /api/expenses · /api/routes · /api/notes (Step 4)
- /api/dogs/{id}/health · /api/dogs/{id}/diary (Step 3)
- Tiles via _prefetchTiles wenn GPS-Permission da (Step 5)
Wiki, Übungen, Streak, Wetter werden NICHT mehr vorgeladen — kommen
beim normalen Welten-Besuch ins Cache, sind aber nicht Pflicht für
'offline-bereit'.
setTimeout-Retry nach 3s: aktiver Hund ist beim ersten Init oft
noch nicht in _appState, danach kommt der Health/Diary-Prefetch.
248 lines
9.5 KiB
JavaScript
248 lines
9.5 KiB
JavaScript
/* ============================================================
|
|
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 = 50;
|
|
|
|
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, Karte, Gassi, Erste Hilfe, Notizblock, Ausgaben, Routen',
|
|
probe: async () => {
|
|
const c = await _staticCache();
|
|
if (!c) return false;
|
|
const must = ['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js'];
|
|
const urls = (await c.keys()).map(r => r.url);
|
|
return must.every(name => urls.some(u => u.includes('/js/pages/' + name)));
|
|
} },
|
|
|
|
{ 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);
|
|
return urls.some(u => /\/api\/dogs\/\d+(\?|$)/.test(u))
|
|
&& urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u))
|
|
&& urls.some(u => /\/api\/dogs\/\d+\/health/.test(u));
|
|
} },
|
|
|
|
{ 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);
|
|
return urls.some(u => u.includes('/api/expenses'))
|
|
&& urls.some(u => u.includes('/api/routes'))
|
|
&& urls.some(u => u.includes('/api/notes'));
|
|
} },
|
|
|
|
{ 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 => `
|
|
<div class="offline-status-row ${r.ok ? 'ok' : 'miss'}">
|
|
<div class="osr-check">${r.ok ? '✓' : '○'}</div>
|
|
<div class="osr-text">
|
|
<div class="osr-title">${r.title}</div>
|
|
<div class="osr-detail">${r.detail}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
UI.modal.open({
|
|
title: `🐾 Offline-Bereitschaft ${score}/5`,
|
|
body: `
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
|
|
${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.'}
|
|
</p>
|
|
${rows}
|
|
`,
|
|
footer: `
|
|
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
|
${missing.length ? `<button class="btn btn-primary" id="offline-fill-btn" style="width:100%">
|
|
${UI.icon('cloud-arrow-down')} Fehlende jetzt nachladen
|
|
</button>` : ''}
|
|
<button class="btn btn-secondary" data-modal-close style="width:100%">Schließen</button>
|
|
</div>
|
|
`,
|
|
});
|
|
|
|
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 im Umkreis der aktuellen GPS-Position (nur wenn Permission schon da)
|
|
async function _prefetchTiles() {
|
|
if (!navigator.serviceWorker?.controller) return;
|
|
if (!navigator.permissions || !navigator.geolocation) return;
|
|
try {
|
|
const perm = await navigator.permissions.query({ name: 'geolocation' });
|
|
if (perm.state !== 'granted') return; // kein Popup wenn nicht schon erlaubt
|
|
const pos = await new Promise(res =>
|
|
navigator.geolocation.getCurrentPosition(p => res(p), () => res(null), { timeout: 5000 }));
|
|
if (!pos) return;
|
|
const urls = [];
|
|
TILE_PREFETCH.forEach(({ zoom, radius }) =>
|
|
urls.push(..._tileUrls(pos.coords.latitude, pos.coords.longitude, zoom, radius)));
|
|
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
|
|
} catch {}
|
|
}
|
|
|
|
// Daten-Prefetch beim App-Start: Listen die offline brauchbar sein müssen,
|
|
// auch wenn der User die Seiten noch nie geöffnet hat
|
|
async function _prefetchData() {
|
|
fetch('/api/expenses').catch(() => {});
|
|
fetch('/api/routes').catch(() => {});
|
|
fetch('/api/notes').catch(() => {});
|
|
// Hund-spezifische Daten nur wenn aktiver Hund bekannt
|
|
const dogId = window._appState?.activeDog?.id;
|
|
if (dogId) {
|
|
fetch(`/api/dogs/${dogId}/health`).catch(() => {});
|
|
fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
refresh();
|
|
_prefetchTiles(); // Karten-Tiles (nur wenn GPS schon erlaubt)
|
|
_prefetchData(); // Listen-Daten (Expenses, Routes, Notes, Health)
|
|
|
|
// Wenn der aktive Hund erst nach init() gesetzt wird → nochmal triggern
|
|
setTimeout(() => { _prefetchData(); refresh(); }, 3000);
|
|
|
|
if (navigator.serviceWorker) {
|
|
navigator.serviceWorker.addEventListener('message', e => {
|
|
if (e?.data?.type === 'CACHE_UPDATE') refresh();
|
|
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
|
|
});
|
|
}
|
|
setInterval(refresh, 60_000);
|
|
}
|
|
|
|
return { init, refresh, openStatus };
|
|
})();
|
|
|
|
if (document.readyState !== 'loading') {
|
|
window.OfflineIndicator.init();
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init());
|
|
}
|