diff --git a/app/routes/main.py b/app/routes/main.py index 2552be41..f9251b2a 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,4 +1,3 @@ -import json import os from datetime import datetime, timedelta @@ -11,6 +10,7 @@ from flask import ( redirect, render_template, request, + send_from_directory, session, url_for, ) @@ -627,155 +627,19 @@ def search(): @main_bp.route("/manifest.webmanifest") def manifest(): - """Serve PWA manifest with theme_color. Prepared for custom themes - extend to use user accent preference when implemented.""" - theme_color = getattr(current_app.config, "PWA_THEME_COLOR", "#4A90E2") - manifest_data = { - "name": "TimeTracker - Professional Time Tracking", - "short_name": "TimeTracker", - "description": "Professional time tracking and project management application", - "start_url": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": theme_color, - "orientation": "any", - "icons": [ - { - "src": url_for("static", filename="images/timetracker-logo.svg"), - "sizes": "any", - "type": "image/svg+xml", - "purpose": "any maskable", - }, - { - "src": url_for("static", filename="images/android-chrome-192x192.png"), - "sizes": "192x192", - "type": "image/png", - "purpose": "any maskable", - }, - { - "src": url_for("static", filename="images/android-chrome-512x512.png"), - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable", - }, - { - "src": url_for("static", filename="images/apple-touch-icon.png"), - "sizes": "180x180", - "type": "image/png", - "purpose": "any", - }, - ], - "screenshots": [], - "categories": ["productivity", "business"], - "shortcuts": [ - { - "name": "Start Timer", - "short_name": "Timer", - "description": "Start tracking time", - "url": url_for("timer.manual_entry"), - "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}], - }, - { - "name": "Dashboard", - "short_name": "Dashboard", - "description": "View dashboard", - "url": url_for("main.dashboard"), - "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}], - }, - { - "name": "Projects", - "short_name": "Projects", - "description": "Manage projects", - "url": url_for("projects.list_projects"), - "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}], - }, - { - "name": "Reports", - "short_name": "Reports", - "description": "View reports", - "url": url_for("reports.reports"), - "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}], - }, - ], - "share_target": { - "action": url_for("timer.manual_entry"), - "method": "GET", - "params": {"title": "notes", "text": "notes"}, - }, - "prefer_related_applications": False, - "display_override": ["window-controls-overlay", "standalone"], - "edge_side_panel": {"preferred_width": 400}, - "launch_handler": {"client_mode": "focus-existing"}, - } - resp = make_response(json.dumps(manifest_data, indent=2)) - resp.headers["Content-Type"] = "application/manifest+json" + """Legacy URL: canonical manifest is /static/manifest.json.""" + return redirect(url_for("static", filename="manifest.json"), code=302) + + +@main_bp.route("/offline") +def offline_page(): + """Public offline fallback for PWA (no login required).""" + resp = make_response(render_template("offline.html")) + resp.headers["Cache-Control"] = "public, max-age=3600" return resp @main_bp.route("/service-worker.js") def service_worker(): - """Serve a minimal service worker for PWA offline caching.""" - # Build absolute URLs for static assets to ensure proper caching - assets = [ - "/", - # CSS - url_for("static", filename="dist/output.css"), - url_for("static", filename="enhanced-ui.css"), - url_for("static", filename="ui-enhancements.css"), - url_for("static", filename="form-validation.css"), - url_for("static", filename="keyboard-shortcuts.css"), - url_for("static", filename="toast-notifications.css"), - # JS - url_for("static", filename="mobile.js"), - url_for("static", filename="commands.js"), - url_for("static", filename="enhanced-ui.js"), - url_for("static", filename="ui-enhancements.js"), - url_for("static", filename="toast-notifications.js"), - ] - preamble = "const CACHE_NAME='tt-cache-v2';\n" - assets_js = "const ASSETS=" + json.dumps(assets) + ";\n\n" - body = "self.addEventListener('install', (event)=>{ event.waitUntil(caches.open(CACHE_NAME).then((c)=>c.addAll(ASSETS))); self.skipWaiting()); });\n".replace( - "); );", ");" - ) # guard against formatting - body = ( - "self.addEventListener('install', (event)=>{\n" - " event.waitUntil((async()=>{\n" - " const cache = await caches.open(CACHE_NAME);\n" - " try { await cache.addAll(ASSETS); } catch(e) {}\n" - " self.skipWaiting();\n" - " })());\n" - "});\n" - "self.addEventListener('activate', (event)=>{\n" - " event.waitUntil((async()=>{\n" - " const keys = await caches.keys();\n" - " await Promise.all(keys.map((k)=>{ if(k!==CACHE_NAME){ return caches.delete(k); } return null; }));\n" - " self.clients.claim();\n" - " })());\n" - "});\n" - "self.addEventListener('fetch', (event)=>{\n" - " const req = event.request;\n" - " if (req.method !== 'GET') { return; }\n" - " const url = new URL(req.url);\n" - " const sameOrigin = url.origin === self.location.origin;\n" - " if (!sameOrigin) {\n" - " // Do not intercept cross-origin (CDN) requests\n" - " return;\n" - " }\n" - " event.respondWith((async()=>{\n" - " const cached = await caches.match(req);\n" - " if (cached) return cached;\n" - " try {\n" - " const res = await fetch(req);\n" - " const cache = await caches.open(CACHE_NAME);\n" - " cache.put(req, res.clone());\n" - " return res;\n" - " } catch(e) {\n" - " const fallback = await caches.match('/');\n" - " return fallback || new Response('', { status: 504, statusText: 'Gateway Timeout' });\n" - " }\n" - " })());\n" - "});\n" - ) - sw_js = preamble + assets_js + body - resp = make_response(sw_js) - resp.headers["Content-Type"] = "application/javascript" - return resp + """Site-scoped service worker; implementation lives in app/static/js/sw.js.""" + return send_from_directory(current_app.static_folder, "js/sw.js", mimetype="application/javascript") diff --git a/app/static/images/android-chrome-192x192.png b/app/static/images/android-chrome-192x192.png new file mode 100644 index 00000000..d684f8d2 Binary files /dev/null and b/app/static/images/android-chrome-192x192.png differ diff --git a/app/static/images/android-chrome-512x512.png b/app/static/images/android-chrome-512x512.png new file mode 100644 index 00000000..bad46d8f Binary files /dev/null and b/app/static/images/android-chrome-512x512.png differ diff --git a/app/static/js/sw.js b/app/static/js/sw.js new file mode 100644 index 00000000..9c269975 --- /dev/null +++ b/app/static/js/sw.js @@ -0,0 +1,123 @@ +/* TimeTracker service worker — cache static assets; do not touch /api/v1/* (token auth). */ +const CACHE_NAME = 'timetracker-v1'; + +const PRECACHE_URLS = [ + '/offline', + '/static/manifest.json', + '/static/dist/output.css', + '/static/enhanced-ui.css', + '/static/enhanced-ui.js', + '/static/charts.js', + '/static/interactions.js', + '/static/images/timetracker-logo.svg', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE_NAME); + try { + await cache.addAll(PRECACHE_URLS); + } catch (e) { + console.warn('[SW] precache partial failure', e); + } + self.skipWaiting(); + })() + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all( + keys.map((k) => { + if (k !== CACHE_NAME) return caches.delete(k); + return undefined; + }) + ); + await self.clients.claim(); + })() + ); +}); + +function isSameOrigin(url) { + return url.origin === self.location.origin; +} + +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + if (response.ok && request.method === 'GET') { + const clone = response.clone(); + try { + await cache.put(request, clone); + } catch (_) {} + } + return response; + } catch (e) { + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +async function networkFirstDocument(request) { + try { + return await fetch(request); + } catch (_) { + const fallback = await caches.match('/offline'); + if (fallback) return fallback; + return new Response( + '
You are offline.
', + { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } } + ); + } +} + +async function networkFirstApi(request) { + try { + return await fetch(request); + } catch (_) { + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') { + return; + } + let url; + try { + url = new URL(request.url); + } catch (_) { + return; + } + if (!isSameOrigin(url)) { + return; + } + + const path = url.pathname; + + // Never intercept token-auth API — browser handles the request unchanged. + if (path.startsWith('/api/v1/')) { + return; + } + + if (path.startsWith('/static/')) { + event.respondWith(cacheFirst(request)); + return; + } + + if (path.startsWith('/api/')) { + event.respondWith(networkFirstApi(request)); + return; + } + + if (request.mode === 'navigate' || request.destination === 'document') { + event.respondWith(networkFirstDocument(request)); + return; + } +}); diff --git a/app/static/manifest.json b/app/static/manifest.json new file mode 100644 index 00000000..aba61ec4 --- /dev/null +++ b/app/static/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "TimeTracker", + "short_name": "Tracker", + "start_url": "/", + "display": "standalone", + "theme_color": "#4F46E5", + "background_color": "#4F46E5", + "icons": [ + { + "src": "/static/images/timetracker-logo-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/static/images/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/images/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/app/static/manifest.webmanifest b/app/static/manifest.webmanifest deleted file mode 100644 index fa8af282..00000000 --- a/app/static/manifest.webmanifest +++ /dev/null @@ -1,104 +0,0 @@ -{ - "name": "TimeTracker - Professional Time Tracking", - "short_name": "TimeTracker", - "description": "Professional time tracking and project management application", - "start_url": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#4A90E2", - "orientation": "any", - "icons": [ - { - "src": "/static/images/timetracker-logo.svg", - "sizes": "any", - "type": "image/svg+xml", - "purpose": "any maskable" - }, - { - "src": "/static/images/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any maskable" - }, - { - "src": "/static/images/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - }, - { - "src": "/static/images/apple-touch-icon.png", - "sizes": "180x180", - "type": "image/png", - "purpose": "any" - } - ], - "screenshots": [], - "categories": ["productivity", "business"], - "shortcuts": [ - { - "name": "Start Timer", - "short_name": "Timer", - "description": "Start tracking time", - "url": "/timer/manual_entry", - "icons": [ - { - "src": "/static/images/timetracker-logo.svg", - "sizes": "96x96" - } - ] - }, - { - "name": "Dashboard", - "short_name": "Dashboard", - "description": "View dashboard", - "url": "/main/dashboard", - "icons": [ - { - "src": "/static/images/timetracker-logo.svg", - "sizes": "96x96" - } - ] - }, - { - "name": "Projects", - "short_name": "Projects", - "description": "Manage projects", - "url": "/projects/", - "icons": [ - { - "src": "/static/images/timetracker-logo.svg", - "sizes": "96x96" - } - ] - }, - { - "name": "Reports", - "short_name": "Reports", - "description": "View reports", - "url": "/reports/", - "icons": [ - { - "src": "/static/images/timetracker-logo.svg", - "sizes": "96x96" - } - ] - } - ], - "share_target": { - "action": "/timer/manual_entry", - "method": "GET", - "params": { - "title": "notes", - "text": "notes" - } - }, - "prefer_related_applications": false, - "display_override": ["window-controls-overlay", "standalone"], - "edge_side_panel": { - "preferred_width": 400 - }, - "launch_handler": { - "client_mode": "focus-existing" - } -} diff --git a/app/static/pwa-enhancements.js b/app/static/pwa-enhancements.js index 38bb2917..0b863fa7 100644 --- a/app/static/pwa-enhancements.js +++ b/app/static/pwa-enhancements.js @@ -11,9 +11,25 @@ class PWAEnhancements { } async init() { - // Register service worker + // Service worker is registered from base.html as /service-worker.js (full site scope). if ('serviceWorker' in navigator) { - await this.registerServiceWorker(); + try { + this.serviceWorkerRegistration = await navigator.serviceWorker.ready; + this.serviceWorkerRegistration.addEventListener('updatefound', () => { + const nw = this.serviceWorkerRegistration.installing; + if (!nw) return; + nw.addEventListener('statechange', () => { + if (nw.state === 'installed' && navigator.serviceWorker.controller) { + this.showUpdateNotification(); + } + }); + }); + setInterval(() => { + this.serviceWorkerRegistration.update(); + }, 60000); + } catch (e) { + console.warn('Service worker ready failed:', e); + } } // Setup offline detection @@ -31,32 +47,6 @@ class PWAEnhancements { await this.setupIndexedDB(); } - async registerServiceWorker() { - try { - const registration = await navigator.serviceWorker.register('/static/service-worker.js'); - this.serviceWorkerRegistration = registration; - - // Listen for updates - registration.addEventListener('updatefound', () => { - const newWorker = registration.installing; - newWorker.addEventListener('statechange', () => { - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - // New service worker available - this.showUpdateNotification(); - } - }); - }); - - // Check for updates periodically - setInterval(() => { - registration.update(); - }, 60000); // Check every minute - - } catch (error) { - console.error('Service Worker registration failed:', error); - } - } - setupOfflineDetection() { // Listen for online/offline events window.addEventListener('online', () => { diff --git a/app/static/service-worker.js b/app/static/service-worker.js deleted file mode 100644 index fe5e2967..00000000 --- a/app/static/service-worker.js +++ /dev/null @@ -1,393 +0,0 @@ -/** - * Service Worker for TimeTracker PWA - * Provides offline support and background sync - */ - -const CACHE_VERSION = 'v1.0.1'; -const CACHE_NAME = `timetracker-${CACHE_VERSION}`; - -// Resources to cache immediately (static assets only; never HTML or API) -const PRECACHE_URLS = [ - '/static/dist/output.css', - '/static/enhanced-ui.css', - '/static/enhanced-ui.js', - '/static/charts.js', - '/static/interactions.js', - '/static/images/timetracker-logo.svg' -]; - -// Install event - precache critical resources -self.addEventListener('install', event => { - event.waitUntil( - caches.open(CACHE_NAME) - .then(cache => { - // Only cache same-origin resources to avoid CSP violations - const sameOriginUrls = PRECACHE_URLS.filter(url => { - try { - const urlObj = new URL(url, self.location.origin); - return urlObj.origin === self.location.origin; - } catch { - // Relative URLs are same-origin - return !url.startsWith('http://') && !url.startsWith('https://'); - } - }); - return cache.addAll(sameOriginUrls).catch(error => { - console.warn('[ServiceWorker] Some resources failed to cache:', error); - // Continue even if some resources fail - }); - }) - .then(() => self.skipWaiting()) - ); -}); - -// Activate event - clean up old caches -self.addEventListener('activate', event => { - event.waitUntil( - caches.keys() - .then(cacheNames => { - return Promise.all( - cacheNames.map(cacheName => { - if (cacheName !== CACHE_NAME) { - return caches.delete(cacheName); - } - }) - ); - }) - .then(() => self.clients.claim()) - ); -}); - -// Paths that must never be cached (authenticated or sensitive) -function shouldNotCache(url) { - const path = url.pathname; - return path.startsWith('/api/') || - path.startsWith('/auth/') || - path === '/login' || path === '/logout' || - path.startsWith('/setup') || - path.startsWith('/admin/'); -} - -// Fetch event - serve from cache when offline -self.addEventListener('fetch', event => { - const { request } = event; - const url = new URL(request.url); - - // Skip cross-origin requests - if (url.origin !== location.origin) { - return; - } - - // Never cache uploads, API, auth, or admin - if (url.pathname.startsWith('/uploads/') || shouldNotCache(url)) { - event.respondWith(networkOnly(request)); - return; - } - - // Static assets - cache first, network fallback - if (request.destination === 'style' || - request.destination === 'script' || - request.destination === 'image' || - request.destination === 'font') { - event.respondWith(cacheFirst(request)); - return; - } - - // HTML/document - never cache (may be user-specific); network only with offline fallback - if (request.mode === 'navigate' || request.destination === 'document') { - event.respondWith(networkOnly(request)); - return; - } - - // Default: network only (do not cache unknown types) - event.respondWith(networkOnly(request)); -}); - -// Cache first strategy (only for static assets; never used for API/auth) -async function cacheFirst(request) { - const cache = await caches.open(CACHE_NAME); - const cached = await cache.match(request); - - if (cached) { - updateCache(request); - return cached; - } - - try { - const response = await fetch(request); - const cacheControl = response.headers.get('Cache-Control') || ''; - if (response.ok && !cacheControl.includes('no-store') && !cacheControl.includes('no-cache')) { - cache.put(request, response.clone()); - } - return response; - } catch (error) { - console.error('[ServiceWorker] Fetch failed:', error); - return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); - } -} - -// Network only - never cache; for navigate return offline page when unreachable -async function networkOnly(request) { - try { - const response = await fetch(request); - if (response.headers.get('Cache-Control') && response.headers.get('Cache-Control').includes('no-store')) { - return response; - } - return response; - } catch (error) { - if (request.mode === 'navigate') { - return createOfflinePage(); - } - return new Response('Offline', { - status: 503, - statusText: 'Service Unavailable', - headers: new Headers({ 'Content-Type': 'text/plain' }) - }); - } -} - -// Update cache in background (only for static assets) -async function updateCache(request) { - const cache = await caches.open(CACHE_NAME); - try { - const response = await fetch(request); - const cacheControl = response.headers.get('Cache-Control') || ''; - if (response.ok && !cacheControl.includes('no-store') && !cacheControl.includes('no-cache')) { - await cache.put(request, response); - } - } catch (error) { - // Silently fail - we're updating in background - } -} - -// Create offline page response -function createOfflinePage() { - const html = ` - - - - - -It looks like you've lost your internet connection. Don't worry, your data is safe!
- -