feat(pwa): start and keep working offline

The service worker now caches the app shell: page loads are network
first with the last index.html as fallback, hashed bundles come from the
cache, icons and manifest are refreshed in the background; API calls are
never intercepted. GET answers are kept per user in IndexedDB and served
when the server cannot be reached, so the app starts logged in with the
last known thoughts, tasks and lists. Captured thoughts and checked tasks
or list items go to an outbox that is sent in order when the connection
returns (also at start and every 30 s). A banner shows offline state and
waiting changes. Logging out clears cache and outbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:11:47 +01:00
1 parent 5e20710aa7
commit 7bba174454
9 files changed
+472 -76

No files matched your search

+85 -7
View File
@@ -1,18 +1,96 @@
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
self.addEventListener('install', () => {
self.skipWaiting();
// MindSift service worker: keeps the app shell so the PWA also starts without network.
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
// last loaded index.html is used.
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
// - Icons and manifest: from the cache, refreshed in the background.
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
// Raise VERSION when this file changes in a way that needs fresh caches.
const VERSION = 'v2';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHELL_URLS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/icon.svg',
'/icon-192x192.png',
'/icon-512x512.png',
'/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', () => {
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
return;
// Old bundles pile up with every release; keep only the newest entries
async function trimAssets() {
const cache = await caches.open(ASSET_CACHE);
const keys = await cache.keys();
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
}
async function networkFirstPage(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
return response;
} catch {
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
}
}
async function cacheFirstAsset(request) {
const cache = await caches.open(ASSET_CACHE);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
await cache.put(request, response.clone());
trimAssets();
}
return response;
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(SHELL_CACHE);
const hit = await cache.match(request);
const refresh = fetch(request)
.then((response) => {
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
return response;
})
.catch(() => hit || Response.error());
return hit || refresh;
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
if (request.mode === 'navigate') {
event.respondWith(networkFirstPage(request));
} else if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirstAsset(request));
} else if (SHELL_URLS.includes(url.pathname)) {
event.respondWith(staleWhileRevalidate(request));
}
});