feat: Set up PWA infrastructure with service worker, offline support, and manifest

- Created service worker (public/sw.js) with caching strategies
  - Cache-first strategy for static assets
  - Stale-while-revalidate for API responses and pages
  - Network-first fallback strategy
- Implemented offline fallback page (/offline)
- Updated manifest.json with complete PWA metadata
- Added service worker registration in app layout
- Created ServiceWorkerRegistration component for client-side registration
- Generated SVG icons for PWA (192x192 and 512x512)
- Added PWA meta tags for iOS Safari support

Validates Requirements: 4.1, 4.3, 4.4
This commit is contained in:
2025-12-01 20:30:07 +00:00
parent 4f29a48ae1
commit ef6a0bf5af
10 changed files with 369 additions and 9 deletions

7
public/icon-192.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="48" fill="#3B82F6"/>
<path d="M57.599999999999994 96L86.4 124.80000000000001L134.39999999999998 67.19999999999999"
stroke="white" stroke-width="15.36" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="115.19999999999999" width="96" height="15.36" rx="3.84" fill="white"/>
<rect x="48" y="138.24" width="76.80000000000001" height="15.36" rx="3.84" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

7
public/icon-512.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="128" fill="#3B82F6"/>
<path d="M153.6 256L230.4 332.8L358.4 179.2"
stroke="white" stroke-width="40.96" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="128" y="307.2" width="256" height="40.96" rx="10.24" fill="white"/>
<rect x="128" y="368.64" width="204.8" height="40.96" rx="10.24" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

6
public/icon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="128" fill="#3B82F6"/>
<path d="M160 200L220 260L360 120" stroke="white" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="120" y="300" width="272" height="40" rx="8" fill="white"/>
<rect x="120" y="360" width="200" height="40" rx="8" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -1,21 +1,37 @@
{
"name": "Ultimate Lists",
"short_name": "Lists",
"description": "The ultimate todo list app",
"description": "The ultimate todo list app with advanced task management",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#3B82F6",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"src": "/icon-192.svg",
"sizes": "192x192",
"type": "image/png"
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/png"
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
],
"screenshots": [],
"shortcuts": [
{
"name": "New List",
"short_name": "New List",
"description": "Create a new list",
"url": "/?action=new-list",
"icons": []
}
],
"prefer_related_applications": false
}

164
public/sw.js Normal file
View File

@@ -0,0 +1,164 @@
// Service Worker for Ultimate Lists App
// Implements caching strategies for offline functionality
const CACHE_NAME = 'ultimate-lists-v1';
const RUNTIME_CACHE = 'ultimate-lists-runtime-v1';
// Static assets to cache on install
const STATIC_ASSETS = [
'/',
'/offline',
'/manifest.json',
'/icon-192.svg',
'/icon-512.svg',
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS).catch((error) => {
console.error('Failed to cache static assets:', error);
// Continue even if some assets fail to cache
return Promise.resolve();
});
})
);
// Activate immediately
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
.map((name) => caches.delete(name))
);
})
);
// Take control of all pages immediately
return self.clients.claim();
});
// Fetch event - implement caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip chrome extensions and other protocols
if (!url.protocol.startsWith('http')) {
return;
}
// Cache-first strategy for static assets
if (isStaticAsset(url)) {
event.respondWith(cacheFirst(request));
return;
}
// Stale-while-revalidate for API responses and pages
if (isApiRequest(url) || isPageRequest(url)) {
event.respondWith(staleWhileRevalidate(request));
return;
}
// Network-first for everything else
event.respondWith(networkFirst(request));
});
// Helper: Check if request is for a static asset
function isStaticAsset(url) {
const staticExtensions = ['.js', '.css', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
return staticExtensions.some((ext) => url.pathname.endsWith(ext));
}
// Helper: Check if request is an API request
function isApiRequest(url) {
return url.pathname.startsWith('/api/');
}
// Helper: Check if request is a page request
function isPageRequest(url) {
return url.origin === self.location.origin && !url.pathname.includes('.');
}
// Strategy: Cache-first (for static assets)
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) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
console.error('Cache-first fetch failed:', error);
return new Response('Network error', { status: 408 });
}
}
// Strategy: Stale-while-revalidate (for API and pages)
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
}).catch((error) => {
console.error('Stale-while-revalidate fetch failed:', error);
// If we have a cached version, return it on network error
if (cached) {
return cached;
}
// Otherwise, return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline');
}
throw error;
});
return cached || fetchPromise;
}
// Strategy: Network-first (fallback)
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline');
}
throw error;
}
}