From 0739f87f738947349377909a68481b1eda777c90 Mon Sep 17 00:00:00 2001 From: BizzleBot Date: Mon, 16 Feb 2026 21:02:15 +0000 Subject: [PATCH] feat: Add PWA support - manifest, service worker, offline banner --- frontend/index.html | 9 +++ frontend/public/icons/icon-192.svg | 10 +++ frontend/public/icons/icon-512.svg | 10 +++ frontend/public/manifest.json | 24 +++++++ frontend/public/sw.js | 78 +++++++++++++++++++++++ frontend/src/components/OfflineBanner.jsx | 12 ++++ frontend/src/hooks/useOnlineStatus.js | 18 ++++++ frontend/src/main.jsx | 2 + 8 files changed, 163 insertions(+) create mode 100644 frontend/public/icons/icon-192.svg create mode 100644 frontend/public/icons/icon-512.svg create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/components/OfflineBanner.jsx create mode 100644 frontend/src/hooks/useOnlineStatus.js diff --git a/frontend/index.html b/frontend/index.html index 84ceac1..f3c2e76 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,8 @@ + + Coastal Contracting Timesheet @@ -12,5 +14,12 @@
+ diff --git a/frontend/public/icons/icon-192.svg b/frontend/public/icons/icon-192.svg new file mode 100644 index 0000000..774c2d4 --- /dev/null +++ b/frontend/public/icons/icon-192.svg @@ -0,0 +1,10 @@ + + + + + + + + + 🌊 + diff --git a/frontend/public/icons/icon-512.svg b/frontend/public/icons/icon-512.svg new file mode 100644 index 0000000..f622a8e --- /dev/null +++ b/frontend/public/icons/icon-512.svg @@ -0,0 +1,10 @@ + + + + + + + + + 🌊 + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..bc82688 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Coastal Contracting Timesheet", + "short_name": "Timesheet", + "description": "Employee timesheet management for Coastal Contracting", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0ea5e9", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..d719a2d --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,78 @@ +const CACHE_NAME = 'coastal-timesheet-v1'; +const STATIC_ASSETS = [ + '/', + '/manifest.json', + '/icons/icon-192.svg', + '/icons/icon-512.svg', +]; + +// Install: cache shell +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +// Activate: clean old caches +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// Fetch: network-first for API, cache-first for assets +self.addEventListener('fetch', (e) => { + const { request } = e; + const url = new URL(request.url); + + // Skip non-GET + if (request.method !== 'GET') return; + + // API calls: network-first with offline fallback + if (url.pathname.startsWith('/api/')) { + e.respondWith( + fetch(request) + .then((res) => { + // Cache successful GET API responses for offline use + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return res; + }) + .catch(() => caches.match(request).then((cached) => cached || offlineResponse())) + ); + return; + } + + // Static assets: cache-first + e.respondWith( + caches.match(request).then((cached) => { + if (cached) { + // Background refresh + fetch(request).then((res) => { + if (res.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, res)); + }).catch(() => {}); + return cached; + } + return fetch(request).then((res) => { + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return res; + }).catch(() => caches.match('/')); + }) + ); +}); + +function offlineResponse() { + return new Response( + JSON.stringify({ error: 'offline', message: 'You are currently offline. Data will sync when connection is restored.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } } + ); +} diff --git a/frontend/src/components/OfflineBanner.jsx b/frontend/src/components/OfflineBanner.jsx new file mode 100644 index 0000000..e0d4ff1 --- /dev/null +++ b/frontend/src/components/OfflineBanner.jsx @@ -0,0 +1,12 @@ +import { useOnlineStatus } from '../hooks/useOnlineStatus'; + +export default function OfflineBanner() { + const isOnline = useOnlineStatus(); + if (isOnline) return null; + + return ( +
+ ⚡ You're offline — viewing cached data. Changes will sync when reconnected. +
+ ); +} diff --git a/frontend/src/hooks/useOnlineStatus.js b/frontend/src/hooks/useOnlineStatus.js new file mode 100644 index 0000000..6d97a19 --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.js @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react'; + +export function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const goOnline = () => setIsOnline(true); + const goOffline = () => setIsOnline(false); + window.addEventListener('online', goOnline); + window.addEventListener('offline', goOffline); + return () => { + window.removeEventListener('online', goOnline); + window.removeEventListener('offline', goOffline); + }; + }, []); + + return isOnline; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 9cbc273..7707a4f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -7,6 +7,7 @@ import Login from './pages/Login'; import Timesheet from './pages/Timesheet'; import History from './pages/History'; import Admin from './pages/Admin'; +import OfflineBanner from './components/OfflineBanner'; import './index.css'; function ProtectedRoute({ children }) { @@ -40,6 +41,7 @@ ReactDOM.createRoot(document.getElementById('root')).render( +