From ef6a0bf5af865b628c16f8dda62dfc880d987818 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Mon, 1 Dec 2025 20:30:07 +0000 Subject: [PATCH] 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 --- .kiro/specs/ultimate-lists-app/tasks.md | 2 +- app/layout.tsx | 25 +++- app/offline/page.tsx | 50 +++++++ components/ServiceWorkerRegistration.tsx | 44 ++++++ public/icon-192.svg | 7 + public/icon-512.svg | 7 + public/icon.svg | 6 + public/manifest.json | 28 +++- public/sw.js | 164 +++++++++++++++++++++++ scripts/generate-icons.js | 45 +++++++ 10 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 app/offline/page.tsx create mode 100644 components/ServiceWorkerRegistration.tsx create mode 100644 public/icon-192.svg create mode 100644 public/icon-512.svg create mode 100644 public/icon.svg create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.js diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 278e7ad..9aaa2fb 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -151,7 +151,7 @@ - [x] 10. Checkpoint - Ensure all tests pass - Ensure all tests pass, ask the user if questions arise. -- [ ] 11. Set up PWA infrastructure +- [x] 11. Set up PWA infrastructure - Create service worker (public/sw.js) with caching strategies - Implement cache-first strategy for static assets - Implement stale-while-revalidate for API responses diff --git a/app/layout.tsx b/app/layout.tsx index ec6c5a8..47f9146 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,23 @@ import type { Metadata } from 'next' import './globals.css' +import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration' export const metadata: Metadata = { title: 'Ultimate Lists', - description: 'The ultimate todo list app', + description: 'The ultimate todo list app with advanced task management', manifest: '/manifest.json', themeColor: '#3B82F6', viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no', + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: 'Ultimate Lists', + }, + applicationName: 'Ultimate Lists', + icons: { + icon: '/icon-192.svg', + apple: '/icon-192.svg', + }, } export default function RootLayout({ @@ -16,7 +27,17 @@ export default function RootLayout({ }) { return ( - {children} + + + + + + + + + + {children} + ) } \ No newline at end of file diff --git a/app/offline/page.tsx b/app/offline/page.tsx new file mode 100644 index 0000000..3ee02f4 --- /dev/null +++ b/app/offline/page.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { WifiOff } from 'lucide-react'; + +export default function OfflinePage() { + return ( +
+
+
+
+ +
+
+ +

+ You're Offline +

+ +

+ It looks like you've lost your internet connection. Don't worry, your data is safe and will sync when you're back online. +

+ +
+

What you can do:

+
    +
  • + • + View your cached lists and tasks +
  • +
  • + • + Make changes that will sync later +
  • +
  • + • + Check your connection and try again +
  • +
+
+ + +
+
+ ); +} diff --git a/components/ServiceWorkerRegistration.tsx b/components/ServiceWorkerRegistration.tsx new file mode 100644 index 0000000..6006cc4 --- /dev/null +++ b/components/ServiceWorkerRegistration.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function ServiceWorkerRegistration() { + useEffect(() => { + if ('serviceWorker' in navigator) { + // Register service worker + navigator.serviceWorker + .register('/sw.js') + .then((registration) => { + console.log('Service Worker registered with scope:', registration.scope); + + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60000); // Check every minute + + // Listen for updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (newWorker) { + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // New service worker available, could show update notification + console.log('New service worker available'); + } + }); + } + }); + }) + .catch((error) => { + console.error('Service Worker registration failed:', error); + }); + + // Listen for controller change (new service worker activated) + navigator.serviceWorker.addEventListener('controllerchange', () => { + console.log('Service Worker controller changed'); + }); + } + }, []); + + return null; // This component doesn't render anything +} diff --git a/public/icon-192.svg b/public/icon-192.svg new file mode 100644 index 0000000..ac6a5ca --- /dev/null +++ b/public/icon-192.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/public/icon-512.svg b/public/icon-512.svg new file mode 100644 index 0000000..14f6b9f --- /dev/null +++ b/public/icon-512.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..32dddb2 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/manifest.json b/public/manifest.json index 1c2bf33..421f902 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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 } \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..9b2850a --- /dev/null +++ b/public/sw.js @@ -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; + } +} diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100644 index 0000000..1833f1b --- /dev/null +++ b/scripts/generate-icons.js @@ -0,0 +1,45 @@ +// Icon generation script +// This script creates placeholder icon files for the PWA +// In production, you should replace these with proper high-quality icons + +const fs = require('fs'); +const path = require('path'); + +// Create a simple data URL for a blue icon with a checkmark +function createIconDataURL(size) { + // Simple SVG icon + const svg = ` + + + + + `; + + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; +} + +// For now, we'll create SVG files that browsers can use +function createSVGIcon(size, outputPath) { + const svg = ` + + + + +`; + + fs.writeFileSync(outputPath, svg); + console.log(`Created ${outputPath}`); +} + +// Create icons +const publicDir = path.join(__dirname, '..', 'public'); + +createSVGIcon(192, path.join(publicDir, 'icon-192.svg')); +createSVGIcon(512, path.join(publicDir, 'icon-512.svg')); + +console.log('\nNote: SVG icons created. For production, convert these to PNG using:'); +console.log(' - Online tools like https://cloudconvert.com/svg-to-png'); +console.log(' - ImageMagick: convert icon-192.svg icon-192.png'); +console.log(' - Or use a design tool like Figma/Sketch to export PNG versions');