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( + 'Offline

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 = ` - - - - - - Offline - TimeTracker - - - -
-
📡
-

You're Offline

-

It looks like you've lost your internet connection. Don't worry, your data is safe!

- -
- - - `; - - return new Response(html, { - headers: new Headers({ - 'Content-Type': 'text/html; charset=utf-8' - }) - }); -} - -// Background sync for offline actions -self.addEventListener('sync', event => { - if (event.tag === 'sync-time-entries') { - event.waitUntil(syncTimeEntries()); - } -}); - -// Sync time entries when back online -async function syncTimeEntries() { - try { - // Get pending entries from IndexedDB - const db = await openDB(); - const entries = await getPendingEntries(db); - - if (entries.length === 0) { - return; - } - - // Sync each entry - for (const entry of entries) { - try { - const response = await fetch('/api/time-entries', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(entry.data) - }); - - if (response.ok) { - await markEntryAsSynced(db, entry.id); - } - } catch (error) { - console.error('[ServiceWorker] Failed to sync entry:', error); - } - } - - // Notify all clients - const clients = await self.clients.matchAll(); - clients.forEach(client => { - client.postMessage({ - type: 'SYNC_COMPLETE', - count: entries.length - }); - }); - - } catch (error) { - console.error('[ServiceWorker] Background sync failed:', error); - throw error; - } -} - -// IndexedDB helpers -function openDB() { - return new Promise((resolve, reject) => { - const request = indexedDB.open('TimeTrackerDB', 1); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { - const db = event.target.result; - - if (!db.objectStoreNames.contains('pendingEntries')) { - const store = db.createObjectStore('pendingEntries', { - keyPath: 'id', - autoIncrement: true - }); - store.createIndex('timestamp', 'timestamp', { unique: false }); - } - }; - }); -} - -function getPendingEntries(db) { - return new Promise((resolve, reject) => { - const transaction = db.transaction(['pendingEntries'], 'readonly'); - const store = transaction.objectStore('pendingEntries'); - const request = store.getAll(); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); -} - -function markEntryAsSynced(db, id) { - return new Promise((resolve, reject) => { - const transaction = db.transaction(['pendingEntries'], 'readwrite'); - const store = transaction.objectStore('pendingEntries'); - const request = store.delete(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); -} - -// Push notifications -self.addEventListener('push', event => { - const data = event.data ? event.data.json() : {}; - const title = data.title || 'TimeTracker'; - const options = { - body: data.body || 'You have a new notification', - icon: '/static/images/timetracker-logo.svg', - badge: '/static/images/timetracker-logo.svg', - vibrate: [200, 100, 200], - data: data, - actions: data.actions || [] - }; - - event.waitUntil( - self.registration.showNotification(title, options) - ); -}); - -// Notification click -self.addEventListener('notificationclick', event => { - event.notification.close(); - - const urlToOpen = event.notification.data?.url || '/'; - - event.waitUntil( - clients.matchAll({ type: 'window', includeUncontrolled: true }) - .then(windowClients => { - // Check if there's already a window open - for (const client of windowClients) { - if (client.url === urlToOpen && 'focus' in client) { - return client.focus(); - } - } - // Open new window - if (clients.openWindow) { - return clients.openWindow(urlToOpen); - } - }) - ); -}); - -// Message handling -self.addEventListener('message', event => { - if (event.data.type === 'SKIP_WAITING') { - self.skipWaiting(); - } - - if (event.data.type === 'CACHE_URLS') { - event.waitUntil( - caches.open(CACHE_NAME) - .then(cache => cache.addAll(event.data.urls)) - ); - } - - if (event.data.type === 'CLEAR_CACHE') { - event.waitUntil( - caches.keys() - .then(cacheNames => Promise.all( - cacheNames.map(cacheName => caches.delete(cacheName)) - )) - ); - } -}); - diff --git a/app/templates/base.html b/app/templates/base.html index 105f014f..7b8b816a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,7 +6,7 @@ {% block title %}{{ app_name }}{% endblock %} - + @@ -42,7 +42,7 @@ Telemetry instrumentation should avoid these elements by design. --> - + @@ -2436,6 +2436,11 @@ function trackBannerImpression(source) { {% block scripts_extra %}{% endblock %} {% include 'partials/_bottom_nav.html' %} + diff --git a/app/templates/offline.html b/app/templates/offline.html new file mode 100644 index 00000000..f1ff0d44 --- /dev/null +++ b/app/templates/offline.html @@ -0,0 +1,45 @@ + + + + + + + Offline — TimeTracker + + + +
+ +

You’re offline — your timer is still running on the server.

+
+ + diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 1abfe98e..0499abb9 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -167,7 +167,10 @@ Some formats require manual conversion: - **Base template:** `app/templates/base.html` - **Login page:** `app/templates/auth/login.html` - **About page:** `app/templates/main/about.html` -- **Manifest:** `app/static/manifest.webmanifest` +- **PWA manifest:** `app/static/manifest.json` (linked from `base.html`; `GET /manifest.webmanifest` redirects here for compatibility) +- **PWA service worker source:** `app/static/js/sw.js` (served at `GET /service-worker.js` for site-wide scope; registered from `base.html`) +- **PWA offline fallback page:** `app/templates/offline.html` (`GET /offline`, public, cache-friendly) +- **Install icons (PNG):** `app/static/images/android-chrome-192x192.png`, `android-chrome-512x512.png` — regenerate with `python3 scripts/generate_pwa_icons.py` after visual changes ### Desktop Application - **Main window:** `desktop/src/main/window.js` diff --git a/docs/BUILD_CONFIGURATION.md b/docs/BUILD_CONFIGURATION.md index 455285ff..7c790778 100644 --- a/docs/BUILD_CONFIGURATION.md +++ b/docs/BUILD_CONFIGURATION.md @@ -104,7 +104,7 @@ See `desktop/assets/README.md` for detailed instructions on generating icons fro The web application uses the logo as favicon: - Location: `app/static/images/timetracker-logo.svg` - Configured in: `app/templates/base.html` -- Also used in PWA manifest: `app/static/manifest.webmanifest` +- Also used in PWA manifest: `app/static/manifest.json` (see `scripts/generate_pwa_icons.py` for install icons) ## Build Optimization diff --git a/docs/INCOMPLETE_IMPLEMENTATIONS_ANALYSIS.md b/docs/INCOMPLETE_IMPLEMENTATIONS_ANALYSIS.md index 306aa341..9a94c5a5 100644 --- a/docs/INCOMPLETE_IMPLEMENTATIONS_ANALYSIS.md +++ b/docs/INCOMPLETE_IMPLEMENTATIONS_ANALYSIS.md @@ -350,9 +350,9 @@ The dedicated PostHog feature-flag helper under `app/utils/` was **removed**. Re ### 2. Frontend Features #### 2.1 Service Worker -- **File:** `app/static/service-worker.js` -- **Status:** Basic implementation exists but may need enhancement for full PWA functionality. - **Priority:** Medium +- **File:** `app/static/js/sw.js` (registered URL: `/service-worker.js`) +- **Status:** PWA shell caching, offline page, and `/api/v1/*` pass-through are implemented; further enhancements (e.g. broader precache, background sync tuning) remain optional. + **Priority:** Low #### 2.2 Kiosk Mode - **File:** `app/routes/kiosk.py` diff --git a/docs/features/LAYOUT_IMPROVEMENTS_COMPLETE.md b/docs/features/LAYOUT_IMPROVEMENTS_COMPLETE.md index 45e494fa..0ced2734 100644 --- a/docs/features/LAYOUT_IMPROVEMENTS_COMPLETE.md +++ b/docs/features/LAYOUT_IMPROVEMENTS_COMPLETE.md @@ -326,9 +326,10 @@ new DragDropManager(document.getElementById('sortable-list'), { - Offline page - Cache strategies -**Files Created:** -- `app/static/service-worker.js` -- Updated `manifest.webmanifest` +**Files Created / current layout:** +- `app/static/js/sw.js` (service worker; registered as `/service-worker.js`) +- `app/static/manifest.json` (PWA manifest; legacy `GET /manifest.webmanifest` redirects) +- `app/templates/offline.html`, `GET /offline` **Features:** - ✅ Offline mode @@ -584,10 +585,10 @@ window.onboardingManager.reset() ## 🔧 Configuration -### Service Worker Cache Version -Edit `service-worker.js`: +### Service Worker cache name +Edit `app/static/js/sw.js` and bump the cache constant when changing caching behavior (e.g. after breaking static asset changes): ```javascript -const CACHE_VERSION = 'v1.0.0'; +const CACHE_NAME = 'timetracker-v1'; ``` ### Chart Default Colors diff --git a/docs/implementation-notes/APPLICATION_REVIEW_2025.md b/docs/implementation-notes/APPLICATION_REVIEW_2025.md index aee64e1d..87a0e6a4 100644 --- a/docs/implementation-notes/APPLICATION_REVIEW_2025.md +++ b/docs/implementation-notes/APPLICATION_REVIEW_2025.md @@ -258,7 +258,7 @@ Routes → Services → Repositories → Models → Database - Lazy loading for routes - Image optimization - CDN for static assets - - Service worker caching (exists: `app/static/service-worker.js`) + - Service worker caching (`app/static/js/sw.js`, served at `/service-worker.js`) #### 🟡 Medium Priority diff --git a/docs/implementation-notes/IMPLEMENTATION_COMPLETE_SUMMARY.md b/docs/implementation-notes/IMPLEMENTATION_COMPLETE_SUMMARY.md index ec9e67e5..dac2a447 100644 --- a/docs/implementation-notes/IMPLEMENTATION_COMPLETE_SUMMARY.md +++ b/docs/implementation-notes/IMPLEMENTATION_COMPLETE_SUMMARY.md @@ -48,7 +48,7 @@ All 16 planned improvements have been successfully implemented and tested. The T 8. `app/static/enhanced-ui.js` - **950 lines** - Core enhanced functionality 9. `app/static/charts.js` - **450 lines** - Chart management utilities 10. `app/static/onboarding.js` - **380 lines** - Onboarding system -11. `app/static/service-worker.js` - **400 lines** - PWA service worker +11. `app/static/js/sw.js` - PWA service worker (served at `/service-worker.js`; replaces former `app/static/service-worker.js`) ### Documentation (3) 12. `LAYOUT_IMPROVEMENTS_COMPLETE.md` - **800 lines** - Complete documentation @@ -58,7 +58,7 @@ All 16 planned improvements have been successfully implemented and tested. The T 14. `tests/test_enhanced_ui.py` - **350 lines** - Comprehensive test suite ### Configuration (1) -15. Updated `app/static/manifest.webmanifest` - PWA manifest with shortcuts +15. `app/static/manifest.json` - PWA web app manifest (`/manifest.webmanifest` redirects for old clients) --- diff --git a/scripts/generate_pwa_icons.py b/scripts/generate_pwa_icons.py new file mode 100644 index 00000000..39752559 --- /dev/null +++ b/scripts/generate_pwa_icons.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Generate android-chrome-192x192.png and android-chrome-512x512.png for PWA (matches timetracker icon style).""" +import os +import sys + +try: + from PIL import Image, ImageDraw +except ImportError: + print("Pillow required: pip install Pillow", file=sys.stderr) + sys.exit(1) + + +def build_icon(size: int) -> Image.Image: + """Raster icon matching scripts/generate-mobile-icon.py style, scaled to size.""" + scale = size / 1024.0 + r_rect = int(round(256 * scale)) + cx, cy = size // 2, size // 2 + r_clock = int(round(360 * scale)) + stroke_circle = int(round(64 * scale)) + stroke_mark = int(round(48 * scale)) + stroke_check = int(round(80 * scale)) + + grad = Image.new("RGB", (size, size), (0, 0, 0)) + px = grad.load() + for y in range(size): + for x in range(size): + t = (x + y) / (2 * size) + t = max(0, min(1, t)) + r = int(0x4A + (0x50 - 0x4A) * t) + g = int(0x90 + (0xE3 - 0x90) * t) + b = int(0xE2 + (0xC2 - 0xE2) * t) + px[x, y] = (r, g, b) + + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius=r_rect, fill=255) + + base = Image.new("RGB", (size, size), (0x4A, 0x90, 0xE2)) + base.paste(grad, (0, 0), mask) + draw = ImageDraw.Draw(base) + + draw.ellipse([cx - r_clock, cy - r_clock, cx + r_clock, cy + r_clock], fill="white", outline=None) + inner_r = r_clock - stroke_circle + mid = ((0x4A + 0x50) // 2, (0x90 + 0xE3) // 2, (0xE2 + 0xC2) // 2) + draw.ellipse([cx - inner_r, cy - inner_r, cx + inner_r, cy + inner_r], fill=mid, outline=None) + + draw.line([(cx, cy - r_clock), (cx, cy - r_clock + stroke_mark)], fill="white", width=max(1, stroke_mark)) + draw.line([(cx, cy + r_clock - stroke_mark), (cx, cy + r_clock)], fill="white", width=max(1, stroke_mark)) + draw.line([(cx - r_clock, cy), (cx - r_clock + stroke_mark, cy)], fill="white", width=max(1, stroke_mark)) + draw.line([(cx + r_clock - stroke_mark, cy), (cx + r_clock, cy)], fill="white", width=max(1, stroke_mark)) + + draw.line( + [(int(390 * scale), int(540 * scale)), (int(510 * scale), int(660 * scale)), (int(730 * scale), int(440 * scale))], + fill="white", + width=max(1, stroke_check), + joint="curve", + ) + return base + + +def main() -> int: + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + out_dir = os.path.join(project_root, "app", "static", "images") + os.makedirs(out_dir, exist_ok=True) + for name, dim in (("android-chrome-192x192.png", 192), ("android-chrome-512x512.png", 512)): + path = os.path.join(out_dir, name) + build_icon(dim).save(path, "PNG") + print(f"Wrote {path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_enhanced_ui.py b/tests/test_enhanced_ui.py index 8fffa226..39b24fa6 100644 --- a/tests/test_enhanced_ui.py +++ b/tests/test_enhanced_ui.py @@ -3,7 +3,6 @@ Tests for enhanced UI features """ import os -import pytest from flask import url_for @@ -214,30 +213,31 @@ class TestPWA: """Test PWA features""" def test_service_worker_exists(self): - """Test that service worker file exists""" + """Test that service worker source file exists""" import os - sw_path = "app/static/service-worker.js" + sw_path = "app/static/js/sw.js" assert os.path.exists(sw_path) def test_manifest_exists(self): """Test that manifest file exists""" import os - manifest_path = "app/static/manifest.webmanifest" + manifest_path = "app/static/manifest.json" assert os.path.exists(manifest_path) def test_manifest_linked_in_base(self, authenticated_client): """Test that manifest is linked in base template""" response = authenticated_client.get(url_for("main.dashboard")) assert response.status_code == 200 - assert b"manifest.webmanifest" in response.data + assert b"manifest.json" in response.data def test_pwa_meta_tags(self, authenticated_client): """Test that PWA meta tags are present""" response = authenticated_client.get(url_for("main.dashboard")) assert response.status_code == 200 assert b"theme-color" in response.data + assert b"#4F46E5" in response.data class TestAccessibility: @@ -345,54 +345,7 @@ class TestStaticFiles: assert os.path.exists("app/static/onboarding.js") def test_service_worker_js_exists(self): - """Test service-worker.js exists""" + """Test PWA service worker source exists""" import os - assert os.path.exists("app/static/service-worker.js") - - -# Fixtures -@pytest.fixture -def app(): - """Create application for testing""" - from app import create_app, db - from sqlalchemy.pool import StaticPool - - app = create_app( - { - "TESTING": True, - "WTF_CSRF_ENABLED": False, - "SQLALCHEMY_DATABASE_URI": "sqlite://", - "SQLALCHEMY_ENGINE_OPTIONS": { - "connect_args": {"check_same_thread": False, "timeout": 30}, - "poolclass": StaticPool, - }, - "SQLALCHEMY_SESSION_OPTIONS": {"expire_on_commit": False}, - } - ) - with app.app_context(): - db.create_all() - try: - db.session.execute("PRAGMA journal_mode=WAL;") - db.session.execute("PRAGMA synchronous=NORMAL;") - db.session.execute("PRAGMA busy_timeout=30000;") - db.session.commit() - except Exception: - db.session.rollback() - return app - - -@pytest.fixture -def client(app): - """Create test client""" - return app.test_client() - - -@pytest.fixture -def auth_headers(client): - """Get authentication headers""" - # Login first - response = client.post("/auth/login", data={"username": "testuser"}, follow_redirects=True) - - # Return headers with session cookie - return {} + assert os.path.exists("app/static/js/sw.js") diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 5434953b..87ccd07c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1,13 +1,20 @@ -import re - - -def test_service_worker_serves_assets(client): +def test_service_worker_serves_sw_js(client): resp = client.get("/service-worker.js") assert resp.status_code == 200 text = resp.get_data(as_text=True) - # Ensure JS content type and presence of cache list with known asset assert "application/javascript" in (resp.headers.get("Content-Type") or "") - assert "dist/output.css" in text - assert "enhanced-ui.js" in text - # Basic sanity: ASSETS array present - assert "const ASSETS=" in text + assert "const CACHE_NAME = 'timetracker-v1'" in text + + +def test_manifest_legacy_redirect(client): + resp = client.get("/manifest.webmanifest", follow_redirects=False) + assert resp.status_code == 302 + assert "/static/manifest.json" in resp.headers.get("Location", "") + + +def test_offline_page_public(client): + resp = client.get("/offline") + assert resp.status_code == 200 + body = resp.get_data(as_text=True) + assert "timer is still running on the server" in body + assert "Cache-Control" in resp.headers