2c36fa0307
New tasks default to "none" priority instead of "medium". Tasks with no priority hide the badge in list and dashboard views, reducing visual noise for routine items. Includes DB migration v4 and i18n keys (de, en, it). Closes #15
240 lines
7.4 KiB
JavaScript
240 lines
7.4 KiB
JavaScript
/**
|
|
* Modul: Service Worker
|
|
* Zweck: Offline-Fähigkeit, differenzierte Caching-Strategien, Update-Notification
|
|
* Abhängigkeiten: keine
|
|
*
|
|
* Caching-Strategien:
|
|
* APP_SHELL (HTML + kritische JS/CSS): Stale-While-Revalidate
|
|
* → Sofortiger Render aus Cache, Update im Hintergrund
|
|
* PAGE_MODULES (Seiten-JS): Stale-While-Revalidate
|
|
* → Navigation bleibt schnell, neue Module werden im Hintergrund geladen
|
|
* ASSETS (Bilder, Icons): Cache-First, 30-Tage-TTL
|
|
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
|
*/
|
|
|
|
const SHELL_CACHE = 'oikos-shell-v24';
|
|
const PAGES_CACHE = 'oikos-pages-v24';
|
|
const ASSETS_CACHE = 'oikos-assets-v24';
|
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
|
|
|
// App-Shell: sofort benötigt für ersten Render
|
|
const APP_SHELL = [
|
|
'/',
|
|
'/index.html',
|
|
'/api.js',
|
|
'/router.js',
|
|
'/i18n.js',
|
|
'/rrule-ui.js',
|
|
'/locales/de.json',
|
|
'/locales/en.json',
|
|
'/sw-register.js',
|
|
'/lucide.min.js',
|
|
'/styles/tokens.css',
|
|
'/styles/reset.css',
|
|
'/styles/pwa.css',
|
|
'/styles/layout.css',
|
|
'/styles/login.css',
|
|
'/styles/dashboard.css',
|
|
'/styles/tasks.css',
|
|
'/styles/shopping.css',
|
|
'/styles/meals.css',
|
|
'/styles/calendar.css',
|
|
'/styles/notes.css',
|
|
'/styles/contacts.css',
|
|
'/styles/budget.css',
|
|
'/styles/settings.css',
|
|
'/components/oikos-install-prompt.js',
|
|
'/offline.html',
|
|
'/manifest.json',
|
|
'/favicon.ico',
|
|
'/icons/favicon-32.png',
|
|
'/icons/apple-touch-icon.png',
|
|
'/icons/icon-192.png',
|
|
'/icons/icon-512.png',
|
|
'/icons/icon-maskable-192.png',
|
|
'/icons/icon-maskable-512.png',
|
|
];
|
|
|
|
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
|
|
const PAGE_MODULES = [
|
|
'/pages/dashboard.js',
|
|
'/pages/tasks.js',
|
|
'/pages/shopping.js',
|
|
'/pages/meals.js',
|
|
'/pages/calendar.js',
|
|
'/pages/notes.js',
|
|
'/pages/contacts.js',
|
|
'/pages/budget.js',
|
|
'/pages/settings.js',
|
|
'/pages/login.js',
|
|
];
|
|
|
|
// --------------------------------------------------------
|
|
// Install: App-Shell + Seiten-Module vorab cachen
|
|
// --------------------------------------------------------
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
Promise.all([
|
|
caches.open(SHELL_CACHE).then((c) => c.addAll(APP_SHELL)),
|
|
caches.open(PAGES_CACHE).then((c) => c.addAll(PAGE_MODULES)),
|
|
])
|
|
);
|
|
// Sofort aktivieren ohne auf bestehende Clients zu warten
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Activate: Alte Cache-Versionen löschen + Clients informieren
|
|
// --------------------------------------------------------
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) =>
|
|
Promise.all(
|
|
keys
|
|
.filter((key) => !ALL_CACHES.includes(key))
|
|
.map((key) => caches.delete(key))
|
|
)
|
|
).then(() => {
|
|
self.clients.claim();
|
|
// Alle offenen Tabs über das Update informieren
|
|
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
|
clients.forEach((client) => client.postMessage({ type: 'SW_UPDATED' }));
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Fetch: Strategie je nach Request-Typ
|
|
// --------------------------------------------------------
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// API: immer Netzwerk - niemals Nutzerdaten cachen
|
|
if (url.pathname.startsWith('/api/')) return;
|
|
|
|
// Nur GET cachen
|
|
if (request.method !== 'GET') return;
|
|
|
|
// Navigation Requests: Network-first, Fallback auf gecachte Shell
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(networkFirst(request, SHELL_CACHE));
|
|
return;
|
|
}
|
|
|
|
// Bilder + Fonts: Cache-First, langer TTL - nur Same-Origin
|
|
// Cross-Origin-Assets (z.B. Wetter-Icons von openweathermap.org) nicht
|
|
// abfangen: opaque Responses führen im PWA-Modus zu Darstellungsfehlern.
|
|
if (isAsset(url.pathname) && url.origin === self.location.origin) {
|
|
event.respondWith(cacheFirst(request, ASSETS_CACHE));
|
|
return;
|
|
}
|
|
|
|
// Seiten-Module (/pages/*.js): Stale-While-Revalidate
|
|
if (url.pathname.startsWith('/pages/')) {
|
|
event.respondWith(staleWhileRevalidate(request, PAGES_CACHE));
|
|
return;
|
|
}
|
|
|
|
// App-Shell (JS, CSS): Stale-While-Revalidate
|
|
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Strategie: Network-First (für Navigation Requests)
|
|
// Versucht Netzwerk, fällt auf gecachte Shell zurück (Offline).
|
|
// --------------------------------------------------------
|
|
async function networkFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok && response.type === 'basic') {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Offline: gecachte Shell liefern
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
|
|
// Fallback auf index.html (SPA-Routing)
|
|
const shell = await cache.match('/index.html');
|
|
if (shell) return shell;
|
|
|
|
// Letzter Ausweg: Offline-Seite
|
|
const offline = await caches.match('/offline.html');
|
|
if (offline) return offline;
|
|
|
|
return new Response('Keine Verbindung', {
|
|
status: 503,
|
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
});
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Strategie: Stale-While-Revalidate
|
|
// Liefert sofort aus Cache, aktualisiert im Hintergrund.
|
|
// Fallback auf Netzwerk wenn nicht gecacht; Fallback auf
|
|
// index.html für Navigations-Requests (Offline-SPA).
|
|
// --------------------------------------------------------
|
|
async function staleWhileRevalidate(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
|
|
// Netzwerk-Request im Hintergrund starten
|
|
const networkPromise = fetch(request).then((response) => {
|
|
if (response.ok && response.type === 'basic') {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
}).catch(() => null);
|
|
|
|
if (cached) {
|
|
// Hintergrund-Update läuft, Cache-Version sofort zurückgeben
|
|
networkPromise; // fire-and-forget
|
|
return cached;
|
|
}
|
|
|
|
// Nicht im Cache → auf Netzwerk warten
|
|
const networkResponse = await networkPromise;
|
|
if (networkResponse) return networkResponse;
|
|
|
|
// Offline-Fallback für Navigation
|
|
if (request.mode === 'navigate') {
|
|
const shell = await caches.match('/index.html');
|
|
if (shell) return shell;
|
|
const offline = await caches.match('/offline.html');
|
|
if (offline) return offline;
|
|
}
|
|
|
|
// Letzter Ausweg: leere 503-Antwort statt Promise-Rejection
|
|
return new Response('Service unavailable', { status: 503 });
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Strategie: Cache-First mit TTL (für Bilder/Fonts)
|
|
// --------------------------------------------------------
|
|
async function cacheFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) cache.put(request, response.clone());
|
|
return response;
|
|
} catch {
|
|
return new Response('', { status: 408 });
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Hilfsfunktionen
|
|
// --------------------------------------------------------
|
|
function isAsset(pathname) {
|
|
return /\.(png|jpg|jpeg|ico|svg|webp|woff2?|gif)$/i.test(pathname);
|
|
}
|