UX: Offline-Score direkt im FAB statt separater Pfote, SW by-v1083

User-Feedback: separater Indikator zu viel — die Pfote IM FAB selbst
soll je nach Score grün eingefärbt werden.

- Separater #offline-indicator Button entfernt (HTML + CSS)
- Welten-FAB-Icon: <use phosphor.svg#paw-print> ersetzt durch
  Inline-SVG mit 5 einzelnen paw-elem-Pfaden (1 Ballen + 4 Zehen)
- CSS: Default weiß (wie bisher), .filled wird leuchtendes Grün
  (#16a34a) — überzeichnet auf orangem FAB klar erkennbar
- offline-indicator.js: zeigt jetzt nur noch die FAB-Pfade ein/aus,
  kein eigenes Element mehr; Klick-Status-Modal als window.OfflineIndicator.openStatus() weiter verfügbar (kann
  später bei Bedarf an Long-Press oder Menüpunkt gehängt werden)
This commit is contained in:
rene 2026-05-26 14:57:19 +02:00
parent 53c80b9bf6
commit b9fe5b5bc3
6 changed files with 61 additions and 152 deletions

View file

@ -1,7 +1,8 @@
/* ============================================================
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.
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 = (() => {
@ -11,9 +12,8 @@ window.OfflineIndicator = (() => {
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
const TILE_MIN = 50;
// 5 Offline-Bereitschafts-Checks, in Reihenfolge der Pfoten-Stufen
const CHECKS = [
{ step: 1, title: 'App-Grundgerüst',
detail: 'CSS, Layout und Hauptmodule — die Basis',
@ -27,8 +27,8 @@ window.OfflineIndicator = (() => {
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)));
const have = keys.map(r => r.url);
return must.every(name => have.some(u => u.includes('/js/pages/' + name)));
} },
{ step: 3, title: 'Hund- und Tagebuchdaten',
@ -36,8 +36,7 @@ window.OfflineIndicator = (() => {
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);
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));
} },
@ -47,12 +46,11 @@ window.OfflineIndicator = (() => {
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;
return (await c.keys()).length >= TILE_MIN;
} },
{ step: 5, title: 'Training & Wissen',
detail: 'Übungen, Wiki-Rassen, Wetter — Welt-Inhalte',
detail: 'Übungen, Wiki-Rassen, Wetter',
probe: async () => {
const c = await caches.open(CACHE_API).catch(() => null);
if (!c) return false;
@ -62,47 +60,34 @@ window.OfflineIndicator = (() => {
} },
];
let _btn = null;
let _svg = null;
let _lastScore = -1;
let _fab = null;
// ----------------------------------------------------------
// Score berechnen + Pfote einfärben
// ----------------------------------------------------------
async function refresh() {
if (!_btn) return;
if (!('caches' in window)) { _btn.style.display = 'none'; return; }
_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 }; }
}));
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 => {
_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);
});
_btn.title = `Offline-Bereitschaft: ${score} von 5`;
_btn.setAttribute('aria-label', `Offline-Bereitschaft: ${score} von 5`);
const score = results.filter(r => r.ok).length;
_fab.setAttribute('data-offline-score', `${score}/5`);
return { score, results };
}
// ----------------------------------------------------------
// Status-Modal beim Klick
// ----------------------------------------------------------
async function _openModal() {
// 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>
@ -113,25 +98,21 @@ window.OfflineIndicator = (() => {
</div>
`).join('');
const missing = results.filter(r => !r.ok);
const allOk = missing.length === 0;
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)">
${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.'}
${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>` : ''}
${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>
`,
@ -139,8 +120,7 @@ window.OfflineIndicator = (() => {
document.getElementById('offline-fill-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('offline-fill-btn');
btn.disabled = true;
btn.textContent = 'Lade …';
btn.disabled = true; btn.textContent = 'Lade …';
await _fetchMissing(missing);
UI.modal.close();
UI.toast.success('Offline-Inhalte aktualisiert.');
@ -148,14 +128,10 @@ window.OfflineIndicator = (() => {
});
}
// ----------------------------------------------------------
// 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) {
@ -165,17 +141,13 @@ window.OfflineIndicator = (() => {
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,
type: 'CACHE_TILES', lat: pos.coords.latitude, lon: pos.coords.longitude,
zoom: 14, radius: 2,
});
}
}
@ -188,39 +160,8 @@ window.OfflineIndicator = (() => {
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();
@ -229,7 +170,7 @@ window.OfflineIndicator = (() => {
setInterval(refresh, 60_000);
}
return { init, refresh };
return { init, refresh, openStatus };
})();
if (document.readyState !== 'loading') {