${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 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());
}