Files
TimeTracker/app/static/js/sw.js
T
Dries Peeters 8fc823c252 feat(pwa): static manifest, root-scoped worker, offline fallback
Add app/static/manifest.json (TimeTracker / Tracker, indigo theme) and PNG install icons via scripts/generate_pwa_icons.py.

Replace inline Flask service worker with app/static/js/sw.js served at /service-worker.js for full-site scope. Cache name timetracker-v1: cache-first for /static, network-first for HTML and non-v1 /api, no interception of /api/v1/* (preserves Authorization).

Add public GET /offline and offline.html for SW navigation fallback; redirect /manifest.webmanifest to the static manifest.

Wire base.html (manifest link, theme-color #4F46E5, SW registration) and pwa-enhancements.js (ready/update/push without duplicate registration). Remove legacy app/static/service-worker.js and manifest.webmanifest.

Tests: service worker and offline routes, manifest redirect, TestPWA expectations; drop duplicate test_enhanced_ui app/client fixtures in favor of conftest.

Docs: ASSETS.md, BUILD_CONFIGURATION.md, implementation notes, and incomplete-features analysis updated for new paths.
2026-04-27 18:43:14 +02:00

124 lines
2.9 KiB
JavaScript

/* TimeTracker service worker — cache static assets; do not touch /api/v1/* (token auth). */
const CACHE_NAME = 'timetracker-v1';
const PRECACHE_URLS = [
'/offline',
'/static/manifest.json',
'/static/dist/output.css',
'/static/enhanced-ui.css',
'/static/enhanced-ui.js',
'/static/charts.js',
'/static/interactions.js',
'/static/images/timetracker-logo.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
try {
await cache.addAll(PRECACHE_URLS);
} catch (e) {
console.warn('[SW] precache partial failure', e);
}
self.skipWaiting();
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.map((k) => {
if (k !== CACHE_NAME) return caches.delete(k);
return undefined;
})
);
await self.clients.claim();
})()
);
});
function isSameOrigin(url) {
return url.origin === self.location.origin;
}
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok && request.method === 'GET') {
const clone = response.clone();
try {
await cache.put(request, clone);
} catch (_) {}
}
return response;
} catch (e) {
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
async function networkFirstDocument(request) {
try {
return await fetch(request);
} catch (_) {
const fallback = await caches.match('/offline');
if (fallback) return fallback;
return new Response(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Offline</title></head><body><p>You are offline.</p></body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
}
async function networkFirstApi(request) {
try {
return await fetch(request);
} catch (_) {
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
let url;
try {
url = new URL(request.url);
} catch (_) {
return;
}
if (!isSameOrigin(url)) {
return;
}
const path = url.pathname;
// Never intercept token-auth API — browser handles the request unchanged.
if (path.startsWith('/api/v1/')) {
return;
}
if (path.startsWith('/static/')) {
event.respondWith(cacheFirst(request));
return;
}
if (path.startsWith('/api/')) {
event.respondWith(networkFirstApi(request));
return;
}
if (request.mode === 'navigate' || request.destination === 'document') {
event.respondWith(networkFirstDocument(request));
return;
}
});