Feature: Offline-Bereitschafts-Indikator (Pfote im Header), SW by-v1077
- Neue Pfote oben rechts im Header zeigt 5-stufige Offline-Bereitschaft (1 Ballen + 4 Zehen, je 20% — grau outline → grün gefüllt) - 5 Checks: App-Shell · Page-Module · Hund-/Tagebuchdaten · Karten- Tiles (≥50) · Foto-Previews - Klick öffnet Status-Modal mit Checkliste + 'Fehlende nachladen'- Button. Lädt aktiv: Page-Module per fetch, API-Daten für aktiven Hund, Tile-Cache per SW-Message CACHE_TILES, Diary-Foto-Previews - Refresh: alle 60s + bei SW CACHE_UPDATE-Message - Eigene offline-indicator.js (nicht im app.js mit reingequetscht); ins STATIC_ASSETS-Precache aufgenommen
This commit is contained in:
parent
280213c11d
commit
8097d21605
6 changed files with 305 additions and 10 deletions
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1076'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1077'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen.
|
||||
|
|
|
|||
226
backend/static/js/offline-indicator.js
Normal file
226
backend/static/js/offline-indicator.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/* ============================================================
|
||||
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.
|
||||
============================================================ */
|
||||
|
||||
window.OfflineIndicator = (() => {
|
||||
'use strict';
|
||||
|
||||
// Cache-Namen — müssen mit sw.js übereinstimmen
|
||||
const CACHE_STATIC = `by-v${(window.APP_VER || '0')}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1';
|
||||
const CACHE_API = 'by-api';
|
||||
const TILE_MIN = 50; // Mindest-Tiles für Stufe 4
|
||||
|
||||
// 5 Offline-Bereitschafts-Checks, in Reihenfolge der Pfoten-Stufen
|
||||
const CHECKS = [
|
||||
{ step: 1, title: 'App-Grundgerüst',
|
||||
detail: 'CSS, Layout und Hauptmodule — die Basis',
|
||||
probe: async () => (await caches.match('/css/design-system.css?v=' + window.APP_VER)) != null
|
||||
|| (await caches.match('/css/design-system.css')) != null },
|
||||
|
||||
{ step: 2, title: 'Wichtige Seiten',
|
||||
detail: 'Tagebuch, Karte, Gassi, Erste Hilfe',
|
||||
probe: async () => {
|
||||
const c = await caches.open(CACHE_STATIC).catch(() => null);
|
||||
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)));
|
||||
} },
|
||||
|
||||
{ step: 3, title: 'Hund- und Tagebuchdaten',
|
||||
detail: 'Letzte Einträge und Hund-Profil',
|
||||
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);
|
||||
return urls.some(u => /\/api\/dogs\/\d+/.test(u))
|
||||
&& urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u));
|
||||
} },
|
||||
|
||||
{ step: 4, title: 'Karten-Kacheln',
|
||||
detail: `Mindestens ${TILE_MIN} Tiles im Umkreis`,
|
||||
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;
|
||||
} },
|
||||
|
||||
{ step: 5, title: 'Tagebuch-Fotos',
|
||||
detail: 'Vorschau-Bilder der letzten Einträge',
|
||||
probe: async () => {
|
||||
const c = await caches.open(CACHE_API).catch(() => null);
|
||||
if (!c) return false;
|
||||
const keys = await c.keys();
|
||||
return keys.some(r => r.url.includes('/data/diary/') && r.url.includes('_preview'));
|
||||
} },
|
||||
];
|
||||
|
||||
let _btn = null;
|
||||
let _svg = null;
|
||||
let _lastScore = -1;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Score berechnen + Pfote einfärben
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
if (!_btn) return;
|
||||
if (!('caches' in window)) { _btn.classList.add('hidden'); return; }
|
||||
|
||||
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 => {
|
||||
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`);
|
||||
_btn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Status-Modal beim Klick
|
||||
// ----------------------------------------------------------
|
||||
async function _openModal() {
|
||||
const data = await refresh();
|
||||
if (!data) return;
|
||||
const { score, results } = data;
|
||||
|
||||
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('');
|
||||
|
||||
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.'}
|
||||
</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();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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) {
|
||||
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(() => {}));
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (m.step === 5) {
|
||||
const dogId = window._appState?.activeDog?.id;
|
||||
if (dogId) {
|
||||
try {
|
||||
const entries = await fetch(`/api/dogs/${dogId}/diary?limit=10`).then(r => r.json());
|
||||
(entries || []).slice(0, 10).forEach(e => {
|
||||
if (e.cover_url) tasks.push(fetch(e.cover_url).catch(() => {}));
|
||||
(e.media_items || []).slice(0, 3).forEach(m => {
|
||||
if (m.url) tasks.push(fetch(m.url).catch(() => {}));
|
||||
});
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Init
|
||||
// ----------------------------------------------------------
|
||||
function init() {
|
||||
_btn = document.getElementById('offline-indicator');
|
||||
if (!_btn) return;
|
||||
_svg = _btn.querySelector('.offline-paw');
|
||||
_btn.addEventListener('click', _openModal);
|
||||
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();
|
||||
});
|
||||
}
|
||||
setInterval(refresh, 60_000);
|
||||
}
|
||||
|
||||
return { init, refresh };
|
||||
})();
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
window.OfflineIndicator.init();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue