Files
Ulas Kalayci 82a1f2c239 feat: add flexible reminder options for birthdays
Add support for customizable birthday reminders with preset offsets
(none, at time, 15min, 1h, 1d, 2d, 1w, 2w) and custom intervals.
Users can now configure when to be reminded of upcoming birthdays.

- Add migration 31: reminder_offset, reminder_custom_amount, reminder_custom_unit to birthdays table
- Update POST/PUT /birthdays routes to accept reminder fields
- Add getOffsetMinutes() helper in birthday service
- Update birthdayReminderAt() to calculate reminder time with offset
- Modify syncBirthdayReminder() to handle empty offset (no reminder)
- Add renderBirthdayReminderSection() UI component
- Move reminder-custom CSS from calendar.css to reminders.css
- Add protocol check to service worker (non-http protocol guard)

All translations already present in de.json.
Tests: 109 passing, 0 failing.

Co-Authored-By: Rafael Foster <rafaelfoster@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-04 20:31:42 +02:00

306 lines
9.6 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): Cache-First (frisch vorgeladen via install)
* PAGE_MODULES (Seiten-JS): Cache-First (frisch vorgeladen via install)
* ASSETS (Bilder, Icons): Cache-First, lazily gecacht, bei SW-Update geleert
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
*
* Nach SW-Update: alle Requests gehen einmalig cache-bypassed ans Netz
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/
const SHELL_CACHE = 'oikos-shell-v72';
const PAGES_CACHE = 'oikos-pages-v67';
const LOCALES_CACHE = 'oikos-locales-v16';
const ASSETS_CACHE = 'oikos-assets-v67';
const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_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',
'/reminders.js',
'/sw-register.js',
'/lucide.min.js',
'/styles/tokens.css',
'/styles/reset.css',
'/styles/pwa.css',
'/styles/layout.css',
'/styles/glass.css',
'/styles/login.css',
'/styles/reminders.css',
'/styles/dashboard.css',
'/styles/tasks.css',
'/styles/shopping.css',
'/styles/meals.css',
'/styles/calendar.css',
'/styles/notes.css',
'/styles/contacts.css',
'/styles/birthdays.css',
'/styles/budget.css',
'/styles/documents.css',
'/styles/settings.css',
'/styles/recipes.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',
];
const APP_LOCALES = [
'/locales/ar.json',
'/locales/de.json',
'/locales/el.json',
'/locales/en.json',
'/locales/es.json',
'/locales/fr.json',
'/locales/hi.json',
'/locales/it.json',
'/locales/ja.json',
'/locales/pt.json',
'/locales/ru.json',
'/locales/sv.json',
'/locales/tr.json',
'/locales/uk.json',
'/locales/zh.json',
];
// 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/birthdays.js',
'/pages/budget.js',
'/pages/documents.js',
'/pages/settings.js',
'/pages/login.js',
'/pages/recipes.js',
];
// --------------------------------------------------------
// Bypass-Flag: nach SW-Update einmalig alles frisch vom Netz laden.
// In-Memory-Variable (schnell) + Cache API (SW-Restart-sicher).
// --------------------------------------------------------
let bypassCacheUntil = 0;
// Beim SW-Prozess-Start: Flag aus Cache API wiederherstellen.
// Nötig falls Chrome den SW zwischen activate und erstem Fetch terminiert hat.
let _bypassInitDone = false;
const _bypassInit = (async () => {
try {
const c = await caches.open(BYPASS_CACHE);
const r = await c.match('/active');
if (r) {
const until = parseInt(r.headers.get('x-until') || '0');
if (Date.now() < until) {
bypassCacheUntil = until;
} else {
await c.delete('/active'); // abgelaufen, aufräumen
}
}
} catch { /* Fehler ignorieren */ }
_bypassInitDone = true;
})();
// --------------------------------------------------------
// Install: App-Shell + Seiten-Module vorab cachen
// cache: 'reload' umgeht den HTTP-Cache → immer frische Dateien
// --------------------------------------------------------
self.addEventListener('install', (event) => {
const freshShell = APP_SHELL.map((url) => new Request(url, { cache: 'reload' }));
const freshModules = PAGE_MODULES.map((url) => new Request(url, { cache: 'reload' }));
const freshLocales = APP_LOCALES.map((url) => new Request(url, { cache: 'reload' }));
event.waitUntil(
Promise.all([
caches.open(SHELL_CACHE).then((c) => c.addAll(freshShell)),
caches.open(PAGES_CACHE).then((c) => c.addAll(freshModules)),
caches.open(LOCALES_CACHE).then((c) => c.addAll(freshLocales)),
]).then(() => self.skipWaiting())
);
});
// --------------------------------------------------------
// Activate: Alte Cache-Versionen löschen + Bypass setzen + 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))
)
)
// Assets-Cache leeren: lazily gecachte Bilder/Icons werden sonst nie erneuert.
.then(() => caches.delete(ASSETS_CACHE))
.then(async () => {
// Bypass-Fenster setzen: nach SW-Update lädt die nächste Seite alles frisch.
// KEIN künstliches waitUntil-Delay hier — Chrome würde clients.claim()
// / controllerchange erst nach Ablauf der waitUntil-Promise feuern,
// was dazu führt dass bypassCacheUntil gerade abläuft wenn der Reload kommt.
const bypassUntil = Date.now() + 30000;
bypassCacheUntil = bypassUntil;
// Cache API: überlebt SW-Prozess-Terminierung zwischen activate und Reload
try {
const c = await caches.open(BYPASS_CACHE);
await c.put('/active', new Response('1', {
headers: { 'x-until': String(bypassUntil) },
}));
} catch { /* Fehler ignorieren */ }
self.clients.claim();
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);
if (!url.protocol.startsWith('http')) return;
if (url.pathname.startsWith('/api/')) return;
if (request.method !== 'GET') return;
// Erste Fetch-Events nach SW-Start: auf Cache-API-Initialisierung warten,
// damit bypassCacheUntil korrekt gesetzt ist bevor wir entscheiden.
if (!_bypassInitDone) {
event.respondWith(
_bypassInit.then(() => dispatchFetch(request, url))
);
return;
}
event.respondWith(dispatchFetch(request, url));
});
function dispatchFetch(request, url) {
// Nach SW-Update: direkt vom Netz, kein SW-Cache, kein HTTP-Cache.
// Gilt für ALLE Requests (JS, CSS, Images, HTML) im Bypass-Fenster.
if (Date.now() < bypassCacheUntil) {
return fetch(new Request(request, { cache: 'no-cache' })).catch(async () => {
const cached = await caches.match(request)
|| await caches.match('/index.html')
|| await caches.match('/offline.html');
return cached || new Response('Offline', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
});
}
// Bypass abgelaufen: Cache API Flag aufräumen (lazy, beim ersten Request danach)
if (bypassCacheUntil !== 0) {
bypassCacheUntil = 0;
caches.open(BYPASS_CACHE).then(c => c.delete('/active')).catch(() => {});
}
if (request.mode === 'navigate') {
return networkFirst(request, SHELL_CACHE);
}
if (url.pathname.startsWith('/locales/')) {
return networkFirst(request, LOCALES_CACHE);
}
if (url.pathname.startsWith('/pages/')) {
return networkFirst(request, PAGES_CACHE);
}
if (url.origin === self.location.origin && isMutableAppResource(url.pathname)) {
return networkFirst(request, SHELL_CACHE);
}
if (isAsset(url.pathname) && url.origin === self.location.origin) {
return cacheFirst(request, ASSETS_CACHE);
}
return cacheFirst(request, SHELL_CACHE);
}
// --------------------------------------------------------
// Strategie: Network-First (für Navigation Requests)
// --------------------------------------------------------
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 {
const cached = await cache.match(request);
if (cached) return cached;
const shell = await cache.match('/index.html');
if (shell) return shell;
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: Cache-First (für Shell, Pages, Assets)
// --------------------------------------------------------
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);
}
function isMutableAppResource(pathname) {
return pathname === '/'
|| pathname === '/index.html'
|| pathname === '/manifest.json'
|| /\.(css|js|json|html)$/i.test(pathname);
}