diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index faeccff7..5ac671f0 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -6,6 +6,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/worklenz-frontend/public/manifest.json b/worklenz-frontend/public/manifest.json new file mode 100644 index 00000000..08214a45 --- /dev/null +++ b/worklenz-frontend/public/manifest.json @@ -0,0 +1,78 @@ +{ + "name": "Worklenz - Project Management", + "short_name": "Worklenz", + "description": "A comprehensive project management application for teams", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2b2b2b", + "orientation": "portrait-primary", + "categories": ["productivity", "business"], + "lang": "en", + "scope": "/", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48", + "type": "image/x-icon", + "purpose": "any maskable" + }, + { + "src": "/assets/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View your project dashboard", + "url": "/worklenz", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48" + } + ] + }, + { + "name": "Tasks", + "short_name": "Tasks", + "description": "Manage your tasks", + "url": "/worklenz/tasks", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48" + } + ] + } + ], + "screenshots": [ + { + "src": "/assets/images/screenshot-desktop.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "/assets/images/screenshot-mobile.png", + "sizes": "480x854", + "type": "image/png", + "form_factor": "narrow" + } + ], + "prefer_related_applications": false, + "related_applications": [], + "launch_handler": { + "client_mode": "focus-existing" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/sw.js b/worklenz-frontend/public/sw.js new file mode 100644 index 00000000..526541b0 --- /dev/null +++ b/worklenz-frontend/public/sw.js @@ -0,0 +1,345 @@ +// Worklenz Service Worker +// Provides offline functionality, caching, and performance improvements + +const CACHE_VERSION = 'v1.0.0'; +const CACHE_NAMES = { + STATIC: `worklenz-static-${CACHE_VERSION}`, + DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`, + API: `worklenz-api-${CACHE_VERSION}`, + IMAGES: `worklenz-images-${CACHE_VERSION}` +}; + +// Resources to cache immediately on install +const STATIC_CACHE_URLS = [ + '/', + '/index.html', + '/favicon.ico', + '/env-config.js', + '/manifest.json', + // Ant Design and other critical CSS/JS will be cached as they're requested +]; + +// API endpoints that can be cached +const CACHEABLE_API_PATTERNS = [ + /\/api\/project-categories/, + /\/api\/project-statuses/, + /\/api\/task-priorities/, + /\/api\/task-statuses/, + /\/api\/job-titles/, + /\/api\/teams\/\d+\/members/, + /\/api\/auth\/user/, // Cache user info for offline access +]; + +// Resources that should never be cached +const NEVER_CACHE_PATTERNS = [ + /\/api\/auth\/login/, + /\/api\/auth\/logout/, + /\/api\/notifications/, + /\/socket\.io/, + /\.hot-update\./, + /sw\.js$/, + /chrome-extension/, + /moz-extension/, +]; + +// Install event - Cache static resources +self.addEventListener('install', event => { + console.log('Service Worker: Installing...'); + + event.waitUntil( + (async () => { + try { + const cache = await caches.open(CACHE_NAMES.STATIC); + await cache.addAll(STATIC_CACHE_URLS); + console.log('Service Worker: Static resources cached'); + + // Skip waiting to activate immediately + await self.skipWaiting(); + } catch (error) { + console.error('Service Worker: Installation failed', error); + } + })() + ); +}); + +// Activate event - Clean up old caches +self.addEventListener('activate', event => { + console.log('Service Worker: Activating...'); + + event.waitUntil( + (async () => { + try { + // Clean up old caches + const cacheNames = await caches.keys(); + const oldCaches = cacheNames.filter(name => + Object.values(CACHE_NAMES).every(currentCache => currentCache !== name) + ); + + await Promise.all( + oldCaches.map(cacheName => caches.delete(cacheName)) + ); + + console.log('Service Worker: Old caches cleaned up'); + + // Take control of all pages + await self.clients.claim(); + } catch (error) { + console.error('Service Worker: Activation failed', error); + } + })() + ); +}); + +// Fetch event - Handle all network requests +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests and browser extensions + if (request.method !== 'GET' || NEVER_CACHE_PATTERNS.some(pattern => pattern.test(url.href))) { + return; + } + + event.respondWith(handleFetchRequest(request)); +}); + +// Main fetch handler with different strategies based on resource type +async function handleFetchRequest(request) { + const url = new URL(request.url); + + try { + // Static assets - Cache First strategy + if (isStaticAsset(url)) { + return await cacheFirstStrategy(request, CACHE_NAMES.STATIC); + } + + // Images - Cache First with long-term storage + if (isImageRequest(url)) { + return await cacheFirstStrategy(request, CACHE_NAMES.IMAGES); + } + + // API requests - Network First with fallback + if (isAPIRequest(url)) { + return await networkFirstStrategy(request, CACHE_NAMES.API); + } + + // HTML pages - Stale While Revalidate + if (isHTMLRequest(request)) { + return await staleWhileRevalidateStrategy(request, CACHE_NAMES.DYNAMIC); + } + + // Everything else - Network First + return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC); + + } catch (error) { + console.error('Service Worker: Fetch failed', error); + return createOfflineResponse(request); + } +} + +// Cache First Strategy - Try cache first, fallback to network +async function cacheFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + if (networkResponse.status === 200) { + // Clone before caching as response can only be used once + const responseClone = networkResponse.clone(); + await cache.put(request, responseClone); + } + return networkResponse; + } catch (error) { + console.error('Cache First: Network failed', error); + throw error; + } +} + +// Network First Strategy - Try network first, fallback to cache +async function networkFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + + try { + const networkResponse = await fetch(request); + + if (networkResponse.status === 200) { + // Cache successful responses + const responseClone = networkResponse.clone(); + await cache.put(request, responseClone); + } + + return networkResponse; + } catch (error) { + console.warn('Network First: Network failed, trying cache', error); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + throw error; + } +} + +// Stale While Revalidate Strategy - Return cached version while updating in background +async function staleWhileRevalidateStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + // Fetch from network in background + const networkResponsePromise = fetch(request).then(async networkResponse => { + if (networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + await cache.put(request, responseClone); + } + return networkResponse; + }).catch(error => { + console.warn('Stale While Revalidate: Background update failed', error); + }); + + // Return cached version immediately if available + if (cachedResponse) { + return cachedResponse; + } + + // If no cached version, wait for network + return await networkResponsePromise; +} + +// Helper functions to identify resource types +function isStaticAsset(url) { + return /\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) || + url.pathname.includes('/assets/') || + url.pathname === '/' || + url.pathname === '/index.html' || + url.pathname === '/favicon.ico' || + url.pathname === '/env-config.js'; +} + +function isImageRequest(url) { + return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) || + url.pathname.includes('/file-types/'); +} + +function isAPIRequest(url) { + return url.pathname.startsWith('/api/') || + CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname)); +} + +function isHTMLRequest(request) { + return request.headers.get('accept')?.includes('text/html'); +} + +// Create offline fallback response +function createOfflineResponse(request) { + if (isImageRequest(new URL(request.url))) { + // Return a simple SVG placeholder for images + const svg = ` + + + Offline + + `; + + return new Response(svg, { + headers: { 'Content-Type': 'image/svg+xml' } + }); + } + + if (isAPIRequest(new URL(request.url))) { + // Return empty array or error for API requests + return new Response(JSON.stringify({ + error: 'Offline', + message: 'This feature requires an internet connection' + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + // For HTML requests, try to return cached index.html + return caches.match('/') || new Response('Offline', { status: 503 }); +} + +// Handle background sync events (for future implementation) +self.addEventListener('sync', event => { + console.log('Service Worker: Background sync', event.tag); + + if (event.tag === 'background-sync') { + event.waitUntil(handleBackgroundSync()); + } +}); + +async function handleBackgroundSync() { + // This is where you would handle queued actions when coming back online + console.log('Service Worker: Handling background sync'); + + // Example: Send queued task updates, sync offline changes, etc. + // Implementation would depend on your app's specific needs +} + +// Handle push notification events (for future implementation) +self.addEventListener('push', event => { + if (!event.data) return; + + const options = { + body: event.data.text(), + icon: '/favicon.ico', + badge: '/favicon.ico', + vibrate: [200, 100, 200], + data: { + dateOfArrival: Date.now(), + primaryKey: 1 + } + }; + + event.waitUntil( + self.registration.showNotification('Worklenz', options) + ); +}); + +// Handle notification click events +self.addEventListener('notificationclick', event => { + event.notification.close(); + + event.waitUntil( + self.clients.openWindow('/') + ); +}); + +// Message handling for communication with main thread +self.addEventListener('message', event => { + const { type, payload } = event.data; + + switch (type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'GET_VERSION': + event.ports[0].postMessage({ version: CACHE_VERSION }); + break; + + case 'CLEAR_CACHE': + clearAllCaches().then(() => { + event.ports[0].postMessage({ success: true }); + }); + break; + + default: + console.log('Service Worker: Unknown message type', type); + } +}); + +async function clearAllCaches() { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => caches.delete(name))); + console.log('Service Worker: All caches cleared'); +} + +console.log('Service Worker: Loaded successfully'); \ No newline at end of file diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js deleted file mode 100644 index 4fbd8774..00000000 --- a/worklenz-frontend/public/unregister-sw.js +++ /dev/null @@ -1,23 +0,0 @@ -if ('serviceWorker' in navigator) { - // Check if we've already attempted to unregister in this session - if (!sessionStorage.getItem('swUnregisterAttempted')) { - navigator.serviceWorker.getRegistrations().then(function (registrations) { - const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker')); - - if (ngswWorker) { - // Mark that we've attempted to unregister - sessionStorage.setItem('swUnregisterAttempted', 'true'); - // Unregister the ngsw-worker - ngswWorker.unregister().then(() => { - // Reload the page after unregistering - window.location.reload(true); - }); - } else { - // If no ngsw-worker is found, unregister any other service workers - for (let registration of registrations) { - registration.unregister(); - } - } - }); - } -} diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 9fdd1605..3ed779c4 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -19,6 +19,9 @@ import { Language } from './features/i18n/localesSlice'; import logger from './utils/errorLogger'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; +// Service Worker +import { registerSW } from './utils/serviceWorkerRegistration'; + /** * Main App Component - Performance Optimized * @@ -96,6 +99,25 @@ const App: React.FC = memo(() => { }; }, []); + // Register service worker + useEffect(() => { + registerSW({ + onSuccess: (registration) => { + console.log('Service Worker registered successfully', registration); + }, + onUpdate: (registration) => { + console.log('New content is available and will be used when all tabs for this page are closed.'); + // You could show a toast notification here for user to refresh + }, + onOfflineReady: () => { + console.log('This web app has been cached for offline use.'); + }, + onError: (error) => { + logger.error('Service Worker registration failed:', error); + } + }); + }, []); + // Defer non-critical initialization useEffect(() => { const initializeNonCriticalApp = () => { diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index eb9148b7..a7f75760 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -29,24 +29,12 @@ const withCodeSplitting = (Component: React.LazyExoticComponent { - const authService = useAuthService(); - const location = useLocation(); + const { isAuthenticated, location } = useAuthStatus(); - const shouldRedirect = useMemo(() => { - try { - // Defensive check to ensure authService and its methods exist - if (!authService || typeof authService.isAuthenticated !== 'function') { - return false; // Don't redirect if auth service is not ready - } - return !authService.isAuthenticated(); - } catch (error) { - console.error('Error in AuthGuard:', error); - return false; // Don't redirect on error, let the app handle it - } - }, [authService]); - - if (shouldRedirect) { + if (!isAuthenticated) { return ; } @@ -56,41 +44,14 @@ export const AuthGuard = memo(({ children }: GuardProps) => { AuthGuard.displayName = 'AuthGuard'; export const AdminGuard = memo(({ children }: GuardProps) => { - const authService = useAuthService(); - const location = useLocation(); + const { isAuthenticated, isAdmin, location } = useAuthStatus(); - const guardResult = useMemo(() => { - try { - // Defensive checks to ensure authService and its methods exist - if ( - !authService || - typeof authService.isAuthenticated !== 'function' || - typeof authService.isOwnerOrAdmin !== 'function' || - typeof authService.getCurrentSession !== 'function' - ) { - return null; // Don't redirect if auth service is not ready - } + if (!isAuthenticated) { + return ; + } - if (!authService.isAuthenticated()) { - return { redirect: '/auth', state: { from: location } }; - } - - const currentSession = authService.getCurrentSession(); - const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; - - if (!authService.isOwnerOrAdmin() || isFreePlan) { - return { redirect: '/worklenz/unauthorized' }; - } - - return null; - } catch (error) { - console.error('Error in AdminGuard:', error); - return null; // Don't redirect on error - } - }, [authService, location]); - - if (guardResult) { - return ; + if (!isAdmin) { + return ; } return <>{children}; @@ -99,77 +60,12 @@ export const AdminGuard = memo(({ children }: GuardProps) => { AdminGuard.displayName = 'AdminGuard'; export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { - const authService = useAuthService(); - const location = useLocation(); + const { isLicenseExpired, location } = useAuthStatus(); - const shouldRedirect = useMemo(() => { - try { - // Defensive checks to ensure authService and its methods exist - if ( - !authService || - typeof authService.isAuthenticated !== 'function' || - typeof authService.getCurrentSession !== 'function' - ) { - return false; // Don't redirect if auth service is not ready - } + const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); + const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - if (!authService.isAuthenticated()) return false; - - const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); - const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - - // Don't check or redirect if we're already on the license-expired page - if (isLicenseExpiredRoute) return false; - - const currentSession = authService.getCurrentSession(); - - // Check if trial is expired more than 7 days or if is_expired flag is set - const isLicenseExpiredMoreThan7Days = () => { - // Quick bail if no session data is available - if (!currentSession) return false; - - // Check is_expired flag first - if (currentSession.is_expired) { - // If no trial_expire_date exists but is_expired is true, defer to backend check - if (!currentSession.trial_expire_date) return true; - - // If there is a trial_expire_date, check if it's more than 7 days past - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // Redirect if more than 7 days past expiration - return diffDays > 7; - } - - // If not marked as expired but has trial_expire_date, do a date check - if ( - currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && - currentSession.trial_expire_date - ) { - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); - - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // If expired more than 7 days, redirect - return diffDays > 7; - } - - // No expiration data found - return false; - }; - - return isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute; - } catch (error) { - console.error('Error in LicenseExpiryGuard:', error); - return false; // Don't redirect on error - } - }, [authService, location.pathname]); - - if (shouldRedirect) { + if (isLicenseExpired && !isAdminCenterRoute && !isLicenseExpiredRoute) { return ; } @@ -179,26 +75,16 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { LicenseExpiryGuard.displayName = 'LicenseExpiryGuard'; export const SetupGuard = memo(({ children }: GuardProps) => { - const authService = useAuthService(); - const location = useLocation(); + const { isAuthenticated, isSetupComplete, location } = useAuthStatus(); - const shouldRedirect = useMemo(() => { - try { - // Defensive check to ensure authService and its methods exist - if (!authService || typeof authService.isAuthenticated !== 'function') { - return false; // Don't redirect if auth service is not ready - } - return !authService.isAuthenticated(); - } catch (error) { - console.error('Error in SetupGuard:', error); - return false; // Don't redirect on error - } - }, [authService]); - - if (shouldRedirect) { + if (!isAuthenticated) { return ; } + if (!isSetupComplete) { + return ; + } + return <>{children}; }); diff --git a/worklenz-frontend/src/app/routes/utils.ts b/worklenz-frontend/src/app/routes/utils.ts new file mode 100644 index 00000000..ca1d81d6 --- /dev/null +++ b/worklenz-frontend/src/app/routes/utils.ts @@ -0,0 +1,17 @@ +import { redirect } from 'react-router-dom'; +import { store } from '../store'; +import { verifyAuthentication } from '@/features/auth/authSlice'; + +export const authLoader = async () => { + const session = await store.dispatch(verifyAuthentication()).unwrap(); + + if (!session.user) { + return redirect('/auth/login'); + } + + if (session.user.is_expired) { + return redirect('/worklenz/license-expired'); + } + + return session; +}; diff --git a/worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx b/worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx new file mode 100644 index 00000000..90c7f589 --- /dev/null +++ b/worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx @@ -0,0 +1,140 @@ +// Service Worker Status Component +// Shows offline status and provides cache management controls + +import React from 'react'; +import { Badge, Button, Space, Tooltip, message } from 'antd'; +import { WifiOutlined, DisconnectOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useServiceWorker } from '../../utils/serviceWorkerRegistration'; + +interface ServiceWorkerStatusProps { + minimal?: boolean; // Show only basic offline indicator + showControls?: boolean; // Show cache management controls +} + +const ServiceWorkerStatus: React.FC = ({ + minimal = false, + showControls = false +}) => { + const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker(); + const [swVersion, setSwVersion] = React.useState(''); + const [isClearing, setIsClearing] = React.useState(false); + const [isUpdating, setIsUpdating] = React.useState(false); + + // Get service worker version on mount + React.useEffect(() => { + if (getVersion) { + const versionPromise = getVersion(); + if (versionPromise) { + versionPromise.then(version => { + setSwVersion(version); + }).catch(() => { + // Ignore errors when getting version + }); + } + } + }, [getVersion]); + + const handleClearCache = async () => { + if (!clearCache) return; + + setIsClearing(true); + try { + const success = await clearCache(); + if (success) { + message.success('Cache cleared successfully'); + } else { + message.error('Failed to clear cache'); + } + } catch (error) { + message.error('Error clearing cache'); + } finally { + setIsClearing(false); + } + }; + + const handleForceUpdate = async () => { + if (!forceUpdate) return; + + setIsUpdating(true); + try { + await forceUpdate(); + message.success('Application will reload with updates'); + } catch (error) { + message.error('Failed to update application'); + setIsUpdating(false); + } + }; + + // Minimal version - just show offline status + if (minimal) { + return ( + + + + ); + } + + return ( +
+ + {/* Connection Status */} +
+ {isOffline ? ( + + ) : ( + + )} + + {isOffline ? 'Offline Mode' : 'Online'} + + {swVersion && ( + + v{swVersion} + + )} +
+ + {/* Information */} +
+ {isOffline ? ( + 'App is running from cache. Changes will sync when online.' + ) : ( + 'App is cached for offline use. Ready to work anywhere!' + )} +
+ + {/* Controls */} + {showControls && swManager && ( + + + + + + + + + + )} +
+
+ ); +}; + +export default ServiceWorkerStatus; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useAuthStatus.ts b/worklenz-frontend/src/hooks/useAuthStatus.ts new file mode 100644 index 00000000..2158d5fe --- /dev/null +++ b/worklenz-frontend/src/hooks/useAuthStatus.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAuthService } from '@/hooks/useAuth'; +import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; + +export const useAuthStatus = () => { + const authService = useAuthService(); + const location = useLocation(); + + const status = useMemo(() => { + try { + if (!authService || typeof authService.isAuthenticated !== 'function') { + return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false }; + } + + const isAuthenticated = authService.isAuthenticated(); + if (!isAuthenticated) { + return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false }; + } + + const currentSession = authService.getCurrentSession(); + const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; + const isAdmin = authService.isOwnerOrAdmin() && !isFreePlan; + const isSetupComplete = currentSession?.setup_completed ?? false; + + const isLicenseExpired = () => { + if (!currentSession) return false; + if (currentSession.is_expired) return true; + + if ( + currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && + currentSession.trial_expire_date + ) { + const today = new Date(); + const expiryDate = new Date(currentSession.trial_expire_date); + const diffTime = today.getTime() - expiryDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays > 7; + } + + return false; + }; + + return { isAuthenticated, isLicenseExpired: isLicenseExpired(), isAdmin, isSetupComplete }; + } catch (error) { + console.error('Error in useAuthStatus:', error); + return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false }; + } + }, [authService]); + + return { ...status, location }; +}; diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 0d4c97c3..53f31bc7 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -1,36 +1,25 @@ import { Col, ConfigProvider, Layout } from 'antd'; import { Outlet, useNavigate } from 'react-router-dom'; -import { useEffect, memo, useMemo, useCallback } from 'react'; +import { memo, useMemo } from 'react'; import { useMediaQuery } from 'react-responsive'; import Navbar from '../features/navbar/navbar'; import { useAppSelector } from '../hooks/useAppSelector'; import { useAppDispatch } from '../hooks/useAppDispatch'; import { colors } from '../styles/colors'; -import { verifyAuthentication } from '@/features/auth/authSlice'; + import { useRenderPerformance } from '@/utils/performance'; import HubSpot from '@/components/HubSpot'; const MainLayout = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); + // Performance monitoring in development useRenderPerformance('MainLayout'); - // Memoize auth verification function - const verifyAuthStatus = useCallback(async () => { - const session = await dispatch(verifyAuthentication()).unwrap(); - if (!session.user.setup_completed) { - navigate('/worklenz/setup'); - } - }, [dispatch, navigate]); - - useEffect(() => { - void verifyAuthStatus(); - }, [verifyAuthStatus]); + // Memoize styles to prevent object recreation on every render const headerStyles = useMemo( diff --git a/worklenz-frontend/src/layouts/ReportingLayout.tsx b/worklenz-frontend/src/layouts/ReportingLayout.tsx index a95807cc..09f8c6b2 100644 --- a/worklenz-frontend/src/layouts/ReportingLayout.tsx +++ b/worklenz-frontend/src/layouts/ReportingLayout.tsx @@ -22,11 +22,7 @@ const ReportingLayout = () => { const currentSession = getCurrentSession(); const navigate = useNavigate(); - useEffect(() => { - if (currentSession?.is_expired) { - navigate('/worklenz/license-expired'); - } - }, [currentSession, navigate]); + // function to handle collapse const handleCollapsedToggler = () => { diff --git a/worklenz-frontend/src/layouts/SettingsLayout.tsx b/worklenz-frontend/src/layouts/SettingsLayout.tsx index 450bda31..87f0f525 100644 --- a/worklenz-frontend/src/layouts/SettingsLayout.tsx +++ b/worklenz-frontend/src/layouts/SettingsLayout.tsx @@ -11,11 +11,7 @@ const SettingsLayout = () => { const currentSession = getCurrentSession(); const navigate = useNavigate(); - useEffect(() => { - if (currentSession?.is_expired) { - navigate('/worklenz/license-expired'); - } - }, [currentSession, navigate]); + return (
diff --git a/worklenz-frontend/src/layouts/admin-center-layout.tsx b/worklenz-frontend/src/layouts/admin-center-layout.tsx index 2ce73c63..4addfd3c 100644 --- a/worklenz-frontend/src/layouts/admin-center-layout.tsx +++ b/worklenz-frontend/src/layouts/admin-center-layout.tsx @@ -1,10 +1,9 @@ import { Flex, Typography } from 'antd'; -import React, { useEffect } from 'react'; +import React from 'react'; import { Outlet } from 'react-router-dom'; import { useMediaQuery } from 'react-responsive'; import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar'; import { useTranslation } from 'react-i18next'; -import { verifyAuthentication } from '@/features/auth/authSlice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; const AdminCenterLayout: React.FC = () => { @@ -13,9 +12,7 @@ const AdminCenterLayout: React.FC = () => { const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' }); const { t } = useTranslation('admin-center/sidebar'); - useEffect(() => { - void dispatch(verifyAuthentication()); - }, [dispatch]); + return (
{ + registerSW({ + onSuccess: (registration) => { + console.log('SW registered successfully'); + }, + onUpdate: (registration) => { + // Show update notification to user + }, + onOfflineReady: () => { + console.log('App ready for offline use'); + } + }); +}, []); +``` + +### Using the Hook + +```tsx +import { useServiceWorker } from '../utils/serviceWorkerRegistration'; + +const MyComponent = () => { + const { isOffline, swManager, clearCache, forceUpdate } = useServiceWorker(); + + return ( +
+

Status: {isOffline ? 'Offline' : 'Online'}

+ + +
+ ); +}; +``` + +### Status Component + +```tsx +import ServiceWorkerStatus from '../components/service-worker-status/ServiceWorkerStatus'; + +// Minimal offline indicator + + +// Full status with controls + +``` + +## Configuration + +### Cacheable Resources + +Edit the patterns in `sw.js`: + +```javascript +// API endpoints that can be cached +const CACHEABLE_API_PATTERNS = [ + /\/api\/project-categories/, + /\/api\/task-statuses/, + // Add more patterns... +]; + +// Resources that should never be cached +const NEVER_CACHE_PATTERNS = [ + /\/api\/auth\/login/, + /\/socket\.io/, + // Add more patterns... +]; +``` + +### Cache Names + +Update version to force cache refresh: + +```javascript +const CACHE_VERSION = 'v1.0.1'; // Increment when deploying +``` + +## Development + +### Testing Offline + +1. Open DevTools → Application → Service Workers +2. Check "Offline" to simulate offline mode +3. Verify app still functions + +### Debugging + +```javascript +// Check service worker status +navigator.serviceWorker.ready.then(registration => { + console.log('SW ready:', registration); +}); + +// Check cache contents +caches.keys().then(names => { + console.log('Cache names:', names); +}); +``` + +### Cache Management + +```javascript +// Clear all caches +caches.keys().then(names => + Promise.all(names.map(name => caches.delete(name))) +); + +// Clear specific cache +caches.delete('worklenz-api-v1.0.0'); +``` + +## Best Practices + +### 1. Cache Strategy Selection + +- **Static Assets**: Cache First (fast loading) +- **API Data**: Network First (fresh data) +- **User Content**: Network Only (always fresh) +- **App Shell**: Cache First (instant loading) + +### 2. Cache Invalidation + +- Increment `CACHE_VERSION` when deploying +- Use versioned URLs for assets +- Set appropriate cache headers + +### 3. Offline UX + +- Show offline indicators +- Queue actions for later sync +- Provide meaningful offline messages +- Cache critical user data + +### 4. Performance + +- Cache only necessary resources +- Set cache size limits +- Clean up old caches regularly +- Monitor cache usage + +## Monitoring + +### Storage Usage + +```javascript +// Check storage quota +navigator.storage.estimate().then(estimate => { + console.log('Used:', estimate.usage); + console.log('Quota:', estimate.quota); +}); +``` + +### Cache Hit Rate + +Monitor in DevTools → Network: +- Look for "from ServiceWorker" requests +- Check cache effectiveness + +## Troubleshooting + +### Common Issues + +1. **SW not updating** + - Hard refresh (Ctrl+Shift+R) + - Clear browser cache + - Check CACHE_VERSION + +2. **Resources not caching** + - Verify URL patterns + - Check NEVER_CACHE_PATTERNS + - Ensure HTTPS in production + +3. **Offline features not working** + - Verify SW registration + - Check browser support + - Test cache strategies + +### Reset Service Worker + +```javascript +// Unregister and reload +navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + window.location.reload(); +}); +``` + +## Browser Support + +- ✅ Chrome 45+ +- ✅ Firefox 44+ +- ✅ Safari 11.1+ +- ✅ Edge 17+ +- ❌ Internet Explorer + +## Future Enhancements + +1. **Background Sync** + - Queue offline actions + - Sync when online + +2. **Push Notifications** + - Task assignments + - Project updates + - Deadline reminders + +3. **Advanced Caching** + - Intelligent prefetching + - ML-based cache eviction + - Compression + +4. **Offline Analytics** + - Track offline usage + - Cache hit rates + - Performance metrics + +--- + +*Last updated: January 2025* \ No newline at end of file diff --git a/worklenz-frontend/src/utils/serviceWorkerRegistration.ts b/worklenz-frontend/src/utils/serviceWorkerRegistration.ts new file mode 100644 index 00000000..1f70f785 --- /dev/null +++ b/worklenz-frontend/src/utils/serviceWorkerRegistration.ts @@ -0,0 +1,273 @@ +// Service Worker Registration Utility +// Handles registration, updates, and error handling + +import React from 'react'; + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + window.location.hostname === '[::1]' || + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) +); + +type Config = { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; + onOfflineReady?: () => void; + onError?: (error: Error) => void; +}; + +export function registerSW(config?: Config) { + if ('serviceWorker' in navigator) { + // Only register in production or when explicitly testing + const swUrl = '/sw.js'; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + } else { + console.log('Service workers are not supported in this browser.'); + } +} + +function registerValidSW(swUrl: string, config?: Config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + console.log('Service Worker registered successfully:', registration); + + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + + if (config && config.onOfflineReady) { + config.onOfflineReady(); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + if (config && config.onError) { + config.onError(error); + } + }); +} + +function checkValidServiceWorker(swUrl: string, config?: Config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregisterSW() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + console.log('Service Worker unregistered successfully'); + }) + .catch(error => { + console.error('Error during service worker unregistration:', error); + }); + } +} + +// Utility to communicate with service worker +export class ServiceWorkerManager { + private registration: ServiceWorkerRegistration | null = null; + + constructor(registration?: ServiceWorkerRegistration) { + this.registration = registration || null; + } + + // Send message to service worker + async sendMessage(type: string, payload?: any): Promise { + if (!this.registration || !this.registration.active) { + throw new Error('Service Worker not available'); + } + + return new Promise((resolve, reject) => { + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + if (event.data.error) { + reject(event.data.error); + } else { + resolve(event.data); + } + }; + + this.registration!.active!.postMessage( + { type, payload }, + [messageChannel.port2] + ); + + // Timeout after 5 seconds + setTimeout(() => { + reject(new Error('Service Worker message timeout')); + }, 5000); + }); + } + + // Get service worker version + async getVersion(): Promise { + try { + const response = await this.sendMessage('GET_VERSION'); + return response.version; + } catch (error) { + console.error('Failed to get service worker version:', error); + return 'unknown'; + } + } + + // Clear all caches + async clearCache(): Promise { + try { + await this.sendMessage('CLEAR_CACHE'); + return true; + } catch (error) { + console.error('Failed to clear cache:', error); + return false; + } + } + + // Force update service worker + async forceUpdate(): Promise { + if (!this.registration) return; + + try { + await this.registration.update(); + await this.sendMessage('SKIP_WAITING'); + window.location.reload(); + } catch (error) { + console.error('Failed to force update service worker:', error); + throw error; + } + } + + // Check if app is running offline + isOffline(): boolean { + return !navigator.onLine; + } + + // Get cache storage estimate + async getCacheSize(): Promise { + if ('storage' in navigator && 'estimate' in navigator.storage) { + try { + return await navigator.storage.estimate(); + } catch (error) { + console.error('Failed to get storage estimate:', error); + } + } + return null; + } +} + +// Hook to use service worker in React components +export function useServiceWorker() { + const [isOffline, setIsOffline] = React.useState(!navigator.onLine); + const [swManager, setSWManager] = React.useState(null); + + React.useEffect(() => { + const handleOnline = () => setIsOffline(false); + const handleOffline = () => setIsOffline(true); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + // Register service worker + registerSW({ + onSuccess: (registration) => { + setSWManager(new ServiceWorkerManager(registration)); + }, + onUpdate: (registration) => { + // You could show a toast here asking user to refresh + console.log('New version available'); + setSWManager(new ServiceWorkerManager(registration)); + }, + onOfflineReady: () => { + console.log('App ready for offline use'); + } + }); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return { + isOffline, + swManager, + clearCache: () => swManager?.clearCache(), + forceUpdate: () => swManager?.forceUpdate(), + getVersion: () => swManager?.getVersion(), + }; +} \ No newline at end of file diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index d0ba2dac..6bca483d 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -138,5 +138,8 @@ export default defineConfig(({ command, mode }) => { define: { __DEV__: !isProduction, }, + + // **Public Directory** - sw.js will be automatically copied from public/ to build/ + publicDir: 'public', }; }); \ No newline at end of file