Files
oikos/public/sw.js
T
Ulas 2c36fa0307 feat(tasks): add optional "none" priority level for tasks without urgency
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
2026-04-04 22:13:51 +02:00

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);
}