The attodo.app, uhh... app.
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}