${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) {
if (_offlineTilesMode()) await _downloadOfflineRegion();
else await _prefetchTiles();
}
}
await Promise.all(tasks);
}
// GL-Offline: Gebiet (~5 MB budget-getrieben) um den aktuellen Standort in IndexedDB laden.
async function _downloadOfflineRegion() {
let pos = null;
try { pos = await API.getLocation(); } catch (e) {}
if (!pos) {
try {
const raw = localStorage.getItem(LS_LAST_POS);
if (raw) { const p = JSON.parse(raw); pos = { lat: p.lat, lon: p.lon }; }
} catch (e) {}
}
if (!pos) { UI.toast.warning('Standort nötig, um die Gegend offline zu speichern.'); return; }
try {
await UI.loadMapLibreUI();
if (window.MapOffline) await MapOffline.downloadAround(pos.lat, pos.lon, { budgetMB: 5 });
} catch (e) { console.warn('Offline-Region-Download fehlgeschlagen:', e); }
}
// Gibt es überhaupt Funkloch-Zonen? — direkt aus IndexedDB, OHNE den GL-Stack zu
// laden. Auch GEFÜLLTE zählen: der Start-Check verifiziert deren Kacheln (nach
// „Alles löschen"/Eviction werden sie automatisch neu geladen, Modell René 2026-06-08).
function _anyDeadZonesStored() {
return new Promise(res => {
try {
const r = indexedDB.open('by-offline-tiles', 1);
r.onupgradeneeded = () => {
const d = r.result;
if (!d.objectStoreNames.contains('tiles')) d.createObjectStore('tiles');
if (!d.objectStoreNames.contains('meta')) d.createObjectStore('meta');
};
r.onsuccess = () => {
const db = r.result;
if (!db.objectStoreNames.contains('meta')) { db.close(); return res(false); }
const rq = db.transaction('meta', 'readonly').objectStore('meta').get('deadzones');
rq.onsuccess = () => { res((rq.result || []).length > 0); db.close(); };
rq.onerror = () => { res(false); db.close(); };
};
r.onerror = () => res(false);
} catch (e) { res(false); }
});
}
// Letzte bekannte Position: GPS nur wenn Permission schon erteilt (kein Popup),
// sonst localStorage-Stand (gesetzt von wetter.js u.a.).
async function _lastKnownPos() {
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) return { lat: pos.coords.latitude, lon: pos.coords.longitude };
}
}
} catch {}
try {
const raw = localStorage.getItem(LS_LAST_POS);
if (raw) { const p = JSON.parse(raw); if (p?.lat != null) return { lat: p.lat, lon: p.lon }; }
} catch {}
return null;
}
// Funkloch-Zonen automatisch füllen/verifizieren, sobald Netz da ist — das Gerät lernt
// selbst, wo Offline-Karten nötig sind. Mit Position werden nur Zonen im Umkreis
// (50 km) geladen → Speicher bleibt minimal, ferne Zonen kommen, wenn man dort ist.
let _autoFillTimer = null;
function _scheduleAutoFill(delayMs) {
if (!_offlineTilesMode()) return;
clearTimeout(_autoFillTimer);
_autoFillTimer = setTimeout(async () => {
if (!navigator.onLine) return;
try {
if (!(await _anyDeadZonesStored())) return; // nichts zu tun → GL-Stack nicht laden
const pos = await _lastKnownPos();
await UI.loadMapLibreUI();
const n = await window.MapOffline?.autoFillDeadZones?.(pos ? { lat: pos.lat, lon: pos.lon } : {});
if (n) {
UI.toast?.info(`${n} Funkloch-${n === 1 ? 'Gebiet' : 'Gebiete'} automatisch offline gespeichert.`);
refresh();
}
} catch (e) {}
}, delayMs);
}
// ----------------------------------------------------------
// 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 });
}
// ----------------------------------------------------------
// Storage-Quota überwachen — iOS-PWA hat ~50MB Limit
// ----------------------------------------------------------
let _storageWarned = false;
async function _checkStorageQuota() {
if (!navigator.storage?.estimate) return;
try {
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
if (!quota) return;
const ratio = usage / quota;
// Ab 80% Auslastung: Tile-Cache aggressiv trimmen (per SW-Message)
if (ratio >= 0.8) {
const cache = await caches.open(CACHE_TILES).catch(() => null);
if (cache) {
const keys = await cache.keys();
// Auf 100 Tiles trimmen (statt 500) bei knappem Speicher
if (keys.length > 100) {
const toDelete = keys.slice(0, keys.length - 100);
await Promise.all(toDelete.map(k => cache.delete(k).catch(() => {})));
}
}
// Einmaliger User-Hinweis pro Session bei kritischer Auslastung (>90%)
if (ratio >= 0.9 && !_storageWarned && window.UI?.toast) {
_storageWarned = true;
const mb = Math.round(usage / 1024 / 1024);
const max = Math.round(quota / 1024 / 1024);
window.UI.toast.warning(
`Speicher fast voll (${mb}/${max} MB) — älteste Karten-Tiles werden gelöscht.`,
6000
);
}
}
} catch {}
}
// 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(() => {});
}
}
// Long-Press auf #worlds-fab → Status-Modal (normaler Click bleibt = FAB-Aktion)
function _bindLongPress() {
const fab = document.getElementById('worlds-fab');
if (!fab || fab.dataset.lpBound) return;
fab.dataset.lpBound = '1';
// iOS: Long-Press würde sonst Textauswahl/Callout-Menü triggern
fab.style.webkitUserSelect = 'none';
fab.style.userSelect = 'none';
fab.style.webkitTouchCallout = 'none';
let timer = null;
let fired = false;
const start = () => {
fired = false;
clearTimeout(timer);
timer = setTimeout(() => { fired = true; openStatus(); }, 350);
};
const cancel = () => clearTimeout(timer);
fab.addEventListener('touchstart', start, { passive: true });
fab.addEventListener('touchend', cancel);
fab.addEventListener('touchmove', cancel);
fab.addEventListener('touchcancel',cancel);
fab.addEventListener('mousedown', start);
fab.addEventListener('mouseup', cancel);
fab.addEventListener('mouseleave', cancel);
// Wenn Long-Press gefeuert hat, normalen Click verhindern
fab.addEventListener('click', e => {
if (fired) { e.stopImmediatePropagation(); e.preventDefault(); fired = false; }
}, true);
}
function init() {
refresh();
_prefetchPages();
// Automatischer OSM-Raster-Prefetch ENTFERNT (2026-06-07): Flag ist auf allen deployten
// Hosts AN, die GL-Karte nutzt das Raster nicht. _prefetchTiles bleibt nur noch für den
// manuellen „Fehlende nachladen"-Pfad im Leaflet-Modus (localhost / by_map_gl=0).
_prefetchData();
_bindLongPress();
// 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(); _bindLongPress(); }, delay);
});
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', e => {
if (e?.data?.type === 'CACHE_UPDATE') refresh();
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
});
}
// Funkloch-Zonen nachladen: verzögert beim Start + sobald die Verbindung zurückkommt.
_scheduleAutoFill(30_000);
window.addEventListener('online', () => _scheduleAutoFill(8_000));
_checkStorageQuota(); // beim Init prüfen
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
}
return { init, refresh, openStatus };
})();
if (document.readyState !== 'loading') {
window.OfflineIndicator.init();
} else {
document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init());
}