feat(pwa): implement service worker and PWA enhancements
- Added service worker (sw.js) for offline functionality, caching strategies, and performance improvements. - Registered service worker in App component to manage updates and offline readiness. - Introduced ServiceWorkerStatus component to display connection status and provide cache management controls. - Created manifest.json for PWA configuration, including app name, icons, and display settings. - Updated index.html with PWA meta tags and links to support mobile web app capabilities. - Refactored authentication guards to utilize useAuthStatus hook for improved user state management. - Removed deprecated unregister-sw.js file to streamline service worker management.
This commit is contained in:
@@ -6,6 +6,27 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2b2b2b" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="application-name" content="Worklenz" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Worklenz" />
|
||||
<meta name="description" content="A comprehensive project management application for teams" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||
<meta name="msapplication-TileColor" content="#2b2b2b" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/favicon.ico" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Resource hints for better loading performance -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
78
worklenz-frontend/public/manifest.json
Normal file
78
worklenz-frontend/public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
345
worklenz-frontend/public/sw.js
Normal file
345
worklenz-frontend/public/sw.js
Normal file
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
|
||||
<rect width="200" height="150" fill="#f0f0f0"/>
|
||||
<text x="100" y="75" text-anchor="middle" fill="#999" font-family="Arial, sans-serif" font-size="14">
|
||||
Offline
|
||||
</text>
|
||||
</svg>`;
|
||||
|
||||
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');
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -29,24 +29,12 @@ const withCodeSplitting = (Component: React.LazyExoticComponent<React.ComponentT
|
||||
};
|
||||
|
||||
// Memoized guard components with defensive programming
|
||||
import { useAuthStatus } from '@/hooks/useAuthStatus';
|
||||
|
||||
export const AuthGuard = memo(({ children }: GuardProps) => {
|
||||
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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
@@ -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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
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 <Navigate to={guardResult.redirect} state={guardResult.state} replace />;
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/worklenz/unauthorized" />;
|
||||
}
|
||||
|
||||
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 <Navigate to="/worklenz/license-expired" replace />;
|
||||
}
|
||||
|
||||
@@ -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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (!isSetupComplete) {
|
||||
return <Navigate to="/worklenz/setup" />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
||||
|
||||
17
worklenz-frontend/src/app/routes/utils.ts
Normal file
17
worklenz-frontend/src/app/routes/utils.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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<ServiceWorkerStatusProps> = ({
|
||||
minimal = false,
|
||||
showControls = false
|
||||
}) => {
|
||||
const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker();
|
||||
const [swVersion, setSwVersion] = React.useState<string>('');
|
||||
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 (
|
||||
<Tooltip title={isOffline ? 'You are offline' : 'You are online'}>
|
||||
<Badge
|
||||
status={isOffline ? 'error' : 'success'}
|
||||
text={isOffline ? 'Offline' : 'Online'}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px' }}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Connection Status */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{isOffline ? (
|
||||
<DisconnectOutlined style={{ color: '#ff4d4f' }} />
|
||||
) : (
|
||||
<WifiOutlined style={{ color: '#52c41a' }} />
|
||||
)}
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
{isOffline ? 'Offline Mode' : 'Online'}
|
||||
</span>
|
||||
{swVersion && (
|
||||
<span style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
v{swVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Information */}
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
{isOffline ? (
|
||||
'App is running from cache. Changes will sync when online.'
|
||||
) : (
|
||||
'App is cached for offline use. Ready to work anywhere!'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{showControls && swManager && (
|
||||
<Space size="small">
|
||||
<Tooltip title="Clear all cached data">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isClearing}
|
||||
onClick={handleClearCache}
|
||||
>
|
||||
Clear Cache
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Check for updates and reload">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={isUpdating}
|
||||
onClick={handleForceUpdate}
|
||||
>
|
||||
Update App
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceWorkerStatus;
|
||||
52
worklenz-frontend/src/hooks/useAuthStatus.ts
Normal file
52
worklenz-frontend/src/hooks/useAuthStatus.ts
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -11,11 +11,7 @@ const SettingsLayout = () => {
|
||||
const currentSession = getCurrentSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSession?.is_expired) {
|
||||
navigate('/worklenz/license-expired');
|
||||
}
|
||||
}, [currentSession, navigate]);
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
259
worklenz-frontend/src/utils/README-ServiceWorker.md
Normal file
259
worklenz-frontend/src/utils/README-ServiceWorker.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Service Worker Implementation
|
||||
|
||||
This directory contains the service worker implementation for Worklenz, providing offline functionality, caching, and performance improvements.
|
||||
|
||||
## Files Overview
|
||||
|
||||
- **`sw.js`** (in `/public/`) - The main service worker file
|
||||
- **`serviceWorkerRegistration.ts`** - Registration and management utilities
|
||||
- **`ServiceWorkerStatus.tsx`** (in `/components/`) - React component for SW status
|
||||
|
||||
## Features
|
||||
|
||||
### 🔄 Caching Strategies
|
||||
|
||||
1. **Cache First** - Static assets (JS, CSS, images)
|
||||
- Serves from cache first, falls back to network
|
||||
- Perfect for unchanging resources
|
||||
|
||||
2. **Network First** - API requests
|
||||
- Tries network first, falls back to cache
|
||||
- Ensures fresh data when online
|
||||
|
||||
3. **Stale While Revalidate** - HTML pages
|
||||
- Serves cached version immediately
|
||||
- Updates cache in background
|
||||
|
||||
### 📱 PWA Features
|
||||
|
||||
- **Offline Support** - App works without internet
|
||||
- **Installable** - Can be installed on devices
|
||||
- **Background Sync** - Sync data when online (framework ready)
|
||||
- **Push Notifications** - Real-time notifications (framework ready)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Integration
|
||||
|
||||
The service worker is automatically registered in `App.tsx`:
|
||||
|
||||
```tsx
|
||||
import { registerSW } from './utils/serviceWorkerRegistration';
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div>
|
||||
<p>Status: {isOffline ? 'Offline' : 'Online'}</p>
|
||||
<button onClick={clearCache}>Clear Cache</button>
|
||||
<button onClick={forceUpdate}>Update App</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Status Component
|
||||
|
||||
```tsx
|
||||
import ServiceWorkerStatus from '../components/service-worker-status/ServiceWorkerStatus';
|
||||
|
||||
// Minimal offline indicator
|
||||
<ServiceWorkerStatus minimal />
|
||||
|
||||
// Full status with controls
|
||||
<ServiceWorkerStatus showControls />
|
||||
```
|
||||
|
||||
## 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*
|
||||
273
worklenz-frontend/src/utils/serviceWorkerRegistration.ts
Normal file
273
worklenz-frontend/src/utils/serviceWorkerRegistration.ts
Normal file
@@ -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<any> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<StorageEstimate | null> {
|
||||
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<ServiceWorkerManager | null>(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(),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user