The attodo.app, uhh... app.
at main 502 lines 14 kB view raw
1// Service Worker for AT Todo 2const CACHE_NAME = 'attodo-v4'; // Added server ping for periodic checks 3const HEALTH_CHECK_INTERVAL = 60000; // 60 seconds 4 5// Install event - cache essential resources 6self.addEventListener('install', (event) => { 7 console.log('Service Worker installing...'); 8 event.waitUntil( 9 caches.open(CACHE_NAME).then((cache) => { 10 return cache.addAll([ 11 '/', 12 '/static/manifest.json', 13 '/static/icon.svg' 14 ]); 15 }) 16 ); 17 self.skipWaiting(); 18}); 19 20// Activate event - clean up old caches 21self.addEventListener('activate', (event) => { 22 console.log('Service Worker activating...'); 23 event.waitUntil( 24 caches.keys().then((cacheNames) => { 25 return Promise.all( 26 cacheNames.map((cacheName) => { 27 if (cacheName !== CACHE_NAME) { 28 console.log('Deleting old cache:', cacheName); 29 return caches.delete(cacheName); 30 } 31 }) 32 ); 33 }) 34 ); 35 self.clients.claim(); 36}); 37 38// Fetch event - network first, fall back to cache 39self.addEventListener('fetch', (event) => { 40 // For non-GET requests, just pass through to network without caching 41 if (event.request.method !== 'GET') { 42 event.respondWith(fetch(event.request)); 43 return; 44 } 45 46 // For GET requests: network first, fall back to cache 47 event.respondWith( 48 fetch(event.request) 49 .then((response) => { 50 // Cache successful GET responses 51 const responseToCache = response.clone(); 52 caches.open(CACHE_NAME).then((cache) => { 53 cache.put(event.request, responseToCache); 54 }); 55 return response; 56 }) 57 .catch(() => { 58 // If network fails, try cache 59 return caches.match(event.request); 60 }) 61 ); 62}); 63 64// Health check function 65async function checkHealth() { 66 try { 67 const response = await fetch('/health', { 68 method: 'GET', 69 cache: 'no-cache' 70 }); 71 72 if (response.ok) { 73 // Verify content type before parsing 74 const contentType = response.headers.get('content-type'); 75 if (!contentType || !contentType.includes('application/json')) { 76 console.error('Health check returned non-JSON response:', contentType); 77 throw new Error('Invalid content-type for health check'); 78 } 79 80 const data = await response.json(); 81 console.log('Health check passed:', data); 82 83 // Broadcast health status to all clients 84 const clients = await self.clients.matchAll(); 85 clients.forEach(client => { 86 client.postMessage({ 87 type: 'HEALTH_CHECK', 88 status: 'healthy', 89 data: data 90 }); 91 }); 92 } else { 93 console.warn('Health check failed with status:', response.status); 94 95 // Broadcast unhealthy status 96 const clients = await self.clients.matchAll(); 97 clients.forEach(client => { 98 client.postMessage({ 99 type: 'HEALTH_CHECK', 100 status: 'unhealthy', 101 statusCode: response.status 102 }); 103 }); 104 } 105 } catch (error) { 106 console.error('Health check error:', error); 107 108 // Broadcast error status 109 const clients = await self.clients.matchAll(); 110 clients.forEach(client => { 111 client.postMessage({ 112 type: 'HEALTH_CHECK', 113 status: 'error', 114 error: error.message 115 }); 116 }); 117 } 118} 119 120// Start periodic health checks when service worker activates 121self.addEventListener('activate', (event) => { 122 console.log('Starting health check interval...'); 123 124 // Initial health check 125 checkHealth(); 126 127 // Set up periodic health checks 128 setInterval(() => { 129 checkHealth(); 130 }, HEALTH_CHECK_INTERVAL); 131}); 132 133// Listen for messages from the main thread 134self.addEventListener('message', (event) => { 135 if (event.data && event.data.type === 'HEALTH_CHECK_NOW') { 136 checkHealth(); 137 } 138}); 139 140// ============================================================================ 141// NOTIFICATION SYSTEM 142// ============================================================================ 143 144// Handle push notifications 145self.addEventListener('push', (event) => { 146 console.log('[Push] Push notification received:', event); 147 148 // Default notification data 149 let notificationData = { 150 title: 'AT Todo', 151 body: 'You have a new notification', 152 icon: '/static/icon-192.png', 153 badge: '/static/icon-192.png', 154 }; 155 156 // Parse the notification payload if present 157 if (event.data) { 158 try { 159 const payload = event.data.json(); 160 console.log('[Push] Payload:', payload); 161 162 notificationData = { 163 title: payload.title || notificationData.title, 164 body: payload.body || notificationData.body, 165 icon: payload.icon || notificationData.icon, 166 badge: payload.badge || notificationData.badge, 167 tag: payload.tag, 168 data: payload.data, 169 }; 170 } catch (err) { 171 console.error('[Push] Failed to parse notification payload:', err); 172 } 173 } 174 175 // Show the notification 176 event.waitUntil( 177 self.registration.showNotification(notificationData.title, { 178 body: notificationData.body, 179 icon: notificationData.icon, 180 badge: notificationData.badge, 181 tag: notificationData.tag, 182 data: notificationData.data, 183 vibrate: [200, 100, 200], 184 }) 185 ); 186}); 187 188// Notification permission state 189let notificationsEnabled = false; 190 191// Cached settings to avoid excessive fetches 192let cachedSettings = null; 193let settingsCacheTime = 0; 194const SETTINGS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 195 196// Check for due tasks periodically 197self.addEventListener('periodicsync', (event) => { 198 if (event.tag === 'check-due-tasks') { 199 event.waitUntil( 200 Promise.all([ 201 checkDueTasksAndNotify(), // Client-side notification display 202 pingServerForCheck() // Ping server (for future server-side push) 203 ]) 204 ); 205 } 206}); 207 208// Handle background sync 209self.addEventListener('sync', (event) => { 210 if (event.tag === 'check-tasks') { 211 event.waitUntil( 212 Promise.all([ 213 checkDueTasksAndNotify(), 214 pingServerForCheck() 215 ]) 216 ); 217 } 218}); 219 220// Ping server for notification check (for future server-side checking) 221async function pingServerForCheck() { 222 try { 223 await fetch('/app/push/check', { 224 method: 'POST', 225 credentials: 'include' 226 }); 227 } catch (err) { 228 // Silently fail - server may not be available 229 console.log('[Push] Server check ping failed:', err.message); 230 } 231} 232 233// Handle notification clicks 234self.addEventListener('notificationclick', (event) => { 235 event.notification.close(); 236 237 // Open the app 238 event.waitUntil( 239 clients.matchAll({ type: 'window' }).then((clientList) => { 240 // If app is already open, focus it 241 for (const client of clientList) { 242 if (client.url.includes('/app') && 'focus' in client) { 243 return client.focus(); 244 } 245 } 246 // Otherwise open a new window 247 if (clients.openWindow) { 248 return clients.openWindow('/app'); 249 } 250 }) 251 ); 252}); 253 254// Get settings with caching to avoid excessive fetches 255async function getSettings() { 256 const now = Date.now(); 257 258 // Return cached settings if still valid 259 if (cachedSettings && (now - settingsCacheTime) < SETTINGS_CACHE_TTL) { 260 return cachedSettings; 261 } 262 263 // Fetch fresh settings from server 264 try { 265 const settingsResponse = await fetch('/app/settings', { 266 credentials: 'include' 267 }); 268 269 if (settingsResponse.ok) { 270 cachedSettings = await settingsResponse.json(); 271 settingsCacheTime = now; 272 return cachedSettings; 273 } 274 } catch (err) { 275 // Network error or server unavailable 276 } 277 278 // If we have stale cached settings, use them as fallback 279 if (cachedSettings) { 280 return cachedSettings; 281 } 282 283 // Use default settings if no cache and fetch failed 284 cachedSettings = { 285 notifyOverdue: true, 286 notifyToday: true, 287 notifySoon: false, 288 hoursBefore: 2, 289 quietHoursEnabled: false, 290 quietStart: 22, 291 quietEnd: 8 292 }; 293 settingsCacheTime = now; 294 return cachedSettings; 295} 296 297// Check tasks and send notifications 298async function checkDueTasksAndNotify() { 299 try { 300 // Get notification settings from AT Protocol (with caching) 301 const settings = await getSettings(); 302 if (!settings) { 303 console.warn('[Notifications] Failed to get settings'); 304 return; // Failed to get settings 305 } 306 307 // Check quiet hours 308 if (settings.quietHoursEnabled) { 309 const now = new Date(); 310 const hour = now.getHours(); 311 const quietStart = settings.quietStart || 22; 312 const quietEnd = settings.quietEnd || 8; 313 314 const isQuiet = quietStart < quietEnd 315 ? (hour >= quietStart || hour < quietEnd) 316 : (hour >= quietStart && hour < quietEnd); 317 318 if (isQuiet) { 319 console.log('[Notifications] Quiet hours active, skipping'); 320 return; 321 } 322 } 323 324 // Fetch tasks as JSON 325 const tasksResponse = await fetch('/app/tasks?filter=incomplete&format=json', { 326 credentials: 'include', 327 headers: { 328 'Accept': 'application/json' 329 } 330 }); 331 332 if (!tasksResponse.ok) { 333 console.error('[Notifications] Failed to fetch tasks:', tasksResponse.status, tasksResponse.statusText); 334 return; 335 } 336 337 // Verify content type 338 const contentType = tasksResponse.headers.get('content-type'); 339 if (!contentType || !contentType.includes('application/json')) { 340 console.error('[Notifications] Tasks endpoint returned non-JSON response:', contentType); 341 const text = await tasksResponse.text(); 342 console.error('[Notifications] Response body (first 200 chars):', text.substring(0, 200)); 343 return; 344 } 345 346 const data = await tasksResponse.json(); 347 const tasks = Array.isArray(data) ? data : (data.tasks || []); 348 console.log(`[Notifications] Fetched ${tasks.length} tasks`) 349 350 // Group tasks by notification type 351 const groups = { 352 overdue: [], 353 dueToday: [], 354 dueSoon: [] 355 }; 356 357 const now = new Date(); 358 359 tasks.forEach(task => { 360 if (!task.dueDate) return; // Skip tasks without due dates 361 362 const dueDate = new Date(task.dueDate); 363 const diffHours = (dueDate - now) / (1000 * 60 * 60); 364 365 if (diffHours < 0) { 366 groups.overdue.push(task); 367 } else if (diffHours < 24) { 368 groups.dueToday.push({ 369 ...task, 370 dueDate, 371 diffHours 372 }); 373 } else if (diffHours < 72) { 374 groups.dueSoon.push({ 375 ...task, 376 dueDate, 377 diffHours 378 }); 379 } 380 }); 381 382 // Send grouped notifications 383 await sendGroupedNotifications(groups, settings); 384 } catch (error) { 385 console.error('[Notifications] Error checking tasks:', error); 386 console.error('[Notifications] Stack trace:', error.stack); 387 } 388} 389 390// Send grouped notifications to avoid spam (Phase 3.2) 391async function sendGroupedNotifications(groups, settings) { 392 const { overdue, dueToday, dueSoon } = groups; 393 394 // Overdue tasks - highest priority 395 if (overdue.length > 0 && settings.notifyOverdue) { 396 const taskList = overdue 397 .slice(0, 3) // Show up to 3 tasks 398 .map(t => `${t.title}`) 399 .join('\n'); 400 401 const moreText = overdue.length > 3 ? `\n...and ${overdue.length - 3} more` : ''; 402 403 await sendNotification( 404 `${overdue.length} Overdue Task${overdue.length > 1 ? 's' : ''}`, 405 taskList + moreText, 406 { 407 tag: 'overdue-tasks', 408 badge: '/static/icon-192.png', 409 renotify: true, 410 requireInteraction: true // Overdue tasks are important 411 } 412 ); 413 return; // Only show one notification at a time 414 } 415 416 // Tasks due today 417 if (dueToday.length > 0 && settings.notifyToday) { 418 // Sort by soonest first 419 dueToday.sort((a, b) => a.diffHours - b.diffHours); 420 421 if (dueToday.length === 1) { 422 // Single task - show specific time 423 const task = dueToday[0]; 424 const hoursUntil = Math.floor(task.diffHours); 425 const minutesUntil = Math.floor((task.diffHours - hoursUntil) * 60); 426 427 let timeText = ''; 428 if (hoursUntil > 0) { 429 timeText = `in ${hoursUntil} hour${hoursUntil > 1 ? 's' : ''}`; 430 } else { 431 timeText = `in ${minutesUntil} minute${minutesUntil > 1 ? 's' : ''}`; 432 } 433 434 await sendNotification( 435 'Task Due Soon', 436 `"${task.title}" is due ${timeText}.`, 437 { 438 tag: 'due-today', 439 badge: '/static/icon-192.png' 440 } 441 ); 442 } else { 443 // Multiple tasks - show grouped notification 444 const taskList = dueToday 445 .slice(0, 3) 446 .map(t => { 447 const hours = Math.floor(t.diffHours); 448 const mins = Math.floor((t.diffHours - hours) * 60); 449 const time = hours > 0 ? `${hours}h` : `${mins}m`; 450 return `${t.title} (${time})`; 451 }) 452 .join('\n'); 453 454 const moreText = dueToday.length > 3 ? `\n...and ${dueToday.length - 3} more` : ''; 455 456 await sendNotification( 457 `${dueToday.length} Tasks Due Today`, 458 taskList + moreText, 459 { 460 tag: 'due-today', 461 badge: '/static/icon-192.png' 462 } 463 ); 464 } 465 return; // Only show one notification at a time 466 } 467 468 // Tasks due soon (within 3 days) 469 if (dueSoon.length > 0 && settings.notifySoon) { 470 const taskList = dueSoon 471 .slice(0, 3) 472 .map(t => `${t.title}`) 473 .join('\n'); 474 475 const moreText = dueSoon.length > 3 ? `\n...and ${dueSoon.length - 3} more` : ''; 476 477 await sendNotification( 478 `${dueSoon.length} Task${dueSoon.length > 1 ? 's' : ''} Due Soon`, 479 taskList + moreText, 480 { 481 tag: 'due-soon', 482 badge: '/static/icon-192.png' 483 } 484 ); 485 } 486} 487 488// Helper to send notifications 489async function sendNotification(title, body, options = {}) { 490 const defaultOptions = { 491 icon: '/static/icon-192.png', 492 badge: '/static/icon-192.png', 493 vibrate: [200, 100, 200], 494 requireInteraction: false, 495 ...options 496 }; 497 498 return self.registration.showNotification(title, { 499 body, 500 ...defaultOptions 501 }); 502}