NextExpense/static/sw.js
cclohmar d65c0cd5fa feat: add month hierarchy, AI categories, and UI polish
- Restructure hierarchy: Month → Event → Expense (new months table, FK)
- Add MonthHandler with CRUD, monthly reports, dropdown create form
- Events now scoped under months with extended ownership chain
- AI extraction: 16 specific expense categories (Airfare, Meals, etc.)
- UI: category dropdown, button-consistent cards, centered mobile shell on desktop
- Dashboard/month views show claim totals per card
- Description field now mandatory, forms simplified
- Months sorted by name chronologically (latest first)
2026-07-14 09:29:26 +00:00

265 lines
9.2 KiB
JavaScript

/* ============================================================
* NextExpense — Service Worker
* Version: 2.0.0
* Cache name: nextexpense-v2
* Strategy: Cache-first for shell assets, network-only for API
* ============================================================ */
const CACHE_NAME = 'nextexpense-v2';
// Shell assets to pre-cache on install
const SHELL_ASSETS = [
'/',
'/static/css/style.css',
'/static/favicon.svg',
'https://unpkg.com/htmx.org@1.9.10'
];
// API path prefix pattern — requests matching these are never cached
const API_PATTERNS = [
'/api/',
'/auth/',
'/login',
'/logout',
'/register'
];
/* ------------------------------------------------------------
* Utility: determine whether a request targets an API endpoint
* ------------------------------------------------------------ */
function isApiRequest(url) {
return API_PATTERNS.some(pattern => url.pathname.startsWith(pattern));
}
/* ------------------------------------------------------------
* Utility: determine whether a request is a shell asset eligible
* for cache-first strategy
* ------------------------------------------------------------ */
function isShellAsset(url) {
const path = url.pathname;
const origin = url.origin;
// Same-origin shell pages
if (origin === self.location.origin && (path === '/' || path === '')) {
return true;
}
// Same-origin CSS
if (origin === self.location.origin && path.startsWith('/static/css/')) {
return true;
}
// Same-origin JS
if (origin === self.location.origin && path.startsWith('/static/js/')) {
return true;
}
// HTMX CDN (cache-first for fast loading)
if (url.href === 'https://unpkg.com/htmx.org@1.9.10') {
return true;
}
return false;
}
/* ------------------------------------------------------------
* INSTALL — Pre-cache shell assets
* ------------------------------------------------------------ */
self.addEventListener('install', event => {
console.log('[SW] Install event — caching shell assets');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
// Use addAll for atomic caching — if one fails, the whole
// install fails and the SW won't activate
return cache.addAll(SHELL_ASSETS).catch(err => {
console.error('[SW] Failed to cache some shell assets:', err);
// Still attempt to activate even if caching is partially
// unsuccessful — the fetch handler will fall back to network
throw err;
});
})
.then(() => {
console.log('[SW] Shell assets cached successfully');
return self.skipWaiting();
})
.catch(err => {
console.error('[SW] Install failed:', err);
// Still try to activate so the SW takes over
return self.skipWaiting();
})
);
});
/* ------------------------------------------------------------
* ACTIVATE — Clean up old caches
* ------------------------------------------------------------ */
self.addEventListener('activate', event => {
console.log('[SW] Activate event — cleaning old caches');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => {
console.log('[SW] Activated — taking control of all clients');
return self.clients.claim();
}).catch(err => {
console.error('[SW] Activation cleanup failed:', err);
// Continue even if cleanup fails
return self.clients.claim();
})
);
});
/* ------------------------------------------------------------
* FETCH — Hybrid strategy
* - Cache-first with network fallback for shell assets
* - Network-only for API / dynamic endpoints
* - Stale-while-revalidate for other same-origin assets
* ------------------------------------------------------------ */
self.addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
// Ignore non-GET requests (POST, PUT, DELETE, etc.)
if (request.method !== 'GET') {
return;
}
// Ignore browser-extension and non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// ── Strategy 1: Network-only for API calls ──
if (isApiRequest(url)) {
event.respondWith(
fetch(request).catch(err => {
console.warn('[SW] API fetch failed (offline?):', url.pathname, err);
// Return a lightweight JSON error so the app can handle it gracefully
return new Response(
JSON.stringify({ error: 'You are offline. Please check your connection.' }),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
);
})
);
return;
}
// ── Strategy 2: Cache-first with network fallback for shell assets ──
if (isShellAsset(url)) {
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
// Cache hit — return immediately
return cachedResponse;
}
// Cache miss — fetch from network, then cache for future
return fetch(request)
.then(networkResponse => {
// Only cache valid responses
if (!networkResponse || networkResponse.status !== 200) {
return networkResponse;
}
// Clone the response so we can cache one and return the other
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(request, responseToCache).catch(err => {
console.error('[SW] Failed to cache asset:', url.pathname, err);
});
})
.catch(err => {
console.error('[SW] Failed to open cache for storing:', err);
});
return networkResponse;
})
.catch(err => {
console.warn('[SW] Network fetch failed for shell asset:', url.pathname, err);
// Return a minimal offline fallback for navigations
if (request.mode === 'navigate') {
return new Response(
'<!DOCTYPE html><html><head><title>Offline — NextExpense</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:2rem;text-align:center;background:#0f172a;color:#f8fafc}h1{font-size:1.5rem;margin-bottom:0.5rem}p{color:#94a3b8;max-width:24rem}</style></head><body><h1>You\'re offline</h1><p>NextExpense needs an internet connection to load. Please check your connection and try again.</p></body></html>',
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/html; charset=utf-8' }
}
);
}
return new Response(
'Offline — resource not available',
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
}
);
});
})
.catch(err => {
console.error('[SW] Cache match error:', err);
// Fall through to network
return fetch(request).catch(() => {
return new Response(
'An unexpected error occurred.',
{ status: 502, headers: { 'Content-Type': 'text/plain' } }
);
});
})
);
return;
}
// ── Strategy 3: Network-first for all other (non-shell, non-API) assets ──
// This covers images, fonts, etc. — try network first, fall back to cache
event.respondWith(
fetch(request)
.then(networkResponse => {
// Cache successful responses for offline fallback
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(request, responseToCache).catch(err => {
console.error('[SW] Failed to cache dynamic asset:', url.pathname, err);
});
})
.catch(err => {
console.error('[SW] Failed to open cache for dynamic asset:', err);
});
}
return networkResponse;
})
.catch(err => {
console.warn('[SW] Network failed, trying cache for:', url.pathname, err);
return caches.match(request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
// Nothing in cache either — return a basic error
return new Response(
'Resource unavailable offline.',
{ status: 503, headers: { 'Content-Type': 'text/plain' } }
);
});
})
);
});