banyaro/backend/static/js/offline-indicator.js
rene 6224044654 Fix: Long-Press auf FAB 350ms statt 600ms + iOS-Textauswahl unterdrücken, SW by-v1094
User-Feedback: 600ms Long-Press triggert iOS-Textauswahl.

- Trigger-Dauer von 600ms auf 350ms verkürzt — feuert vor iOS-
  Auswahl-Callout (typisch ~500ms)
- Am #worlds-fab user-select:none, -webkit-user-select:none,
  -webkit-touch-callout:none — verhindert dass iOS bei Press ein
  Lupensymbol/Auswahl-Menü öffnet
2026-05-26 19:18:38 +02:00

318 lines
12 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 = 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);
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 — 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(() => {});
}
}
// 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();
_prefetchTiles();
_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();
});
}
setInterval(() => { _prefetchData(); refresh(); }, 60_000);
}
return { init, refresh, openStatus };
})();
if (document.readyState !== 'loading') {
window.OfflineIndicator.init();
} else {
document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init());
}