Progressive Web Apps (PWA)

Building a PWA Project (Part 2)

20 min Lesson 27 of 30

Building a PWA Project (Part 2)

In this lesson, we'll enhance our TaskMaster PWA with advanced features including IndexedDB for data persistence, push notifications, background sync, and the install prompt.

IndexedDB Integration

Let's create a database abstraction layer in js/db.js:

// db.js - IndexedDB wrapper const DB_NAME = 'TaskMasterDB'; const DB_VERSION = 1; const STORE_NAME = 'tasks'; class TaskDB { constructor() { this.db = null; } // Initialize database async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this.db); }; request.onupgradeneeded = (event) => { const db = event.target.result; // Create object store if (!db.objectStoreNames.contains(STORE_NAME)) { const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); // Create indexes objectStore.createIndex('priority', 'priority', { unique: false }); objectStore.createIndex('completed', 'completed', { unique: false }); objectStore.createIndex('dueDate', 'dueDate', { unique: false }); objectStore.createIndex('createdAt', 'createdAt', { unique: false }); } }; }); } // Add task async addTask(task) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); const taskData = { title: task.title, description: task.description || '', priority: task.priority || 'medium', dueDate: task.dueDate || null, completed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; const request = store.add(taskData); request.onsuccess = () => { taskData.id = request.result; resolve(taskData); }; request.onerror = () => reject(request.error); }); } // Get all tasks async getAllTasks() { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readonly'); const store = transaction.objectStore(STORE_NAME); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Get task by ID async getTask(id) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readonly'); const store = transaction.objectStore(STORE_NAME); const request = store.get(id); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Update task async updateTask(id, updates) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); const getRequest = store.get(id); getRequest.onsuccess = () => { const task = getRequest.result; if (!task) { reject(new Error('Task not found')); return; } Object.assign(task, updates, { updatedAt: new Date().toISOString() }); const updateRequest = store.put(task); updateRequest.onsuccess = () => resolve(task); updateRequest.onerror = () => reject(updateRequest.error); }; getRequest.onerror = () => reject(getRequest.error); }); } // Delete task async deleteTask(id) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } // Get tasks by filter async getTasksByFilter(filter) { const allTasks = await this.getAllTasks(); switch (filter) { case 'active': return allTasks.filter(task => !task.completed); case 'completed': return allTasks.filter(task => task.completed); default: return allTasks; } } } // Export singleton instance const taskDB = new TaskDB();

App Logic Implementation

Now let's implement the main application logic in js/app.js:

// app.js - Main application logic let currentFilter = 'all'; let deferredPrompt = null; // Initialize app async function initApp() { try { await taskDB.init(); await loadTasks(); setupEventListeners(); setupOnlineOfflineHandlers(); console.log('App initialized successfully'); } catch (error) { console.error('Failed to initialize app:', error); showError('Failed to initialize the app. Please refresh the page.'); } } // Setup event listeners function setupEventListeners() { // Task form submission document.getElementById('taskForm').addEventListener('submit', handleTaskSubmit); // Filter buttons document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', handleFilterChange); }); // Install button window.addEventListener('beforeinstallprompt', handleInstallPrompt); document.getElementById('installBtn').addEventListener('click', handleInstallClick); } // Handle task form submission async function handleTaskSubmit(e) { e.preventDefault(); const title = document.getElementById('taskTitle').value.trim(); const description = document.getElementById('taskDescription').value.trim(); const priority = document.getElementById('taskPriority').value; const dueDate = document.getElementById('taskDueDate').value; if (!title) { showError('Please enter a task title'); return; } try { const task = await taskDB.addTask({ title, description, priority, dueDate }); // Reset form e.target.reset(); // Reload tasks await loadTasks(); // Show success message showSuccess('Task added successfully!'); // Send push notification if supported if (Notification.permission === 'granted') { new Notification('Task Added', { body: title, icon: '/images/icons/icon-192x192.png', badge: '/images/icons/icon-96x96.png', tag: 'task-added' }); } } catch (error) { console.error('Failed to add task:', error); showError('Failed to add task. Please try again.'); } } // Load and display tasks async function loadTasks() { try { const tasks = await taskDB.getTasksByFilter(currentFilter); displayTasks(tasks); } catch (error) { console.error('Failed to load tasks:', error); showError('Failed to load tasks'); } } // Display tasks in UI function displayTasks(tasks) { const taskList = document.getElementById('taskList'); const emptyState = document.getElementById('emptyState'); if (tasks.length === 0) { taskList.innerHTML = ''; emptyState.style.display = 'block'; return; } emptyState.style.display = 'none'; taskList.innerHTML = tasks.map(task => ` <div class="task-item ${task.completed ? 'completed' : ''} priority-${task.priority}" data-id="${task.id}"> <div class="task-checkbox"> <input type="checkbox" ${task.completed ? 'checked' : ''} onchange="toggleTaskComplete(${task.id})" > </div> <div class="task-content"> <h3 class="task-title">${escapeHtml(task.title)}</h3> ${task.description ? `<p class="task-description">${escapeHtml(task.description)}</p>` : ''} <div class="task-meta"> <span class="priority-badge priority-${task.priority}"> ${task.priority} </span> ${task.dueDate ? `<span class="due-date">Due: ${formatDate(task.dueDate)}</span>` : ''} </div> </div> <div class="task-actions"> <button class="btn-icon" onclick="deleteTask(${task.id})" title="Delete"> <svg width="20" height="20" viewBox="0 0 24 24"> <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/> </svg> </button> </div> </div> `).join(''); } // Toggle task completion async function toggleTaskComplete(id) { try { const task = await taskDB.getTask(id); await taskDB.updateTask(id, { completed: !task.completed }); await loadTasks(); } catch (error) { console.error('Failed to toggle task:', error); } } // Delete task async function deleteTask(id) { if (!confirm('Are you sure you want to delete this task?')) { return; } try { await taskDB.deleteTask(id); await loadTasks(); showSuccess('Task deleted successfully'); } catch (error) { console.error('Failed to delete task:', error); showError('Failed to delete task'); } } // Handle filter change function handleFilterChange(e) { document.querySelectorAll('.filter-btn').forEach(btn => { btn.classList.remove('active'); }); e.target.classList.add('active'); currentFilter = e.target.dataset.filter; loadTasks(); } // Utility functions function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatDate(dateString) { const date = new Date(dateString); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } function showSuccess(message) { // Implement toast notification console.log('Success:', message); } function showError(message) { // Implement toast notification console.error('Error:', message); } // Initialize app when DOM is ready document.addEventListener('DOMContentLoaded', initApp);

Push Notifications Implementation

// Request notification permission async function requestNotificationPermission() { if (!('Notification' in window)) { console.log('This browser does not support notifications'); return false; } if (Notification.permission === 'granted') { return true; } if (Notification.permission !== 'denied') { const permission = await Notification.requestPermission(); return permission === 'granted'; } return false; } // Setup push notifications async function setupPushNotifications() { const hasPermission = await requestNotificationPermission(); if (!hasPermission) { console.log('Notification permission denied'); return; } // Register push subscription const registration = await navigator.serviceWorker.ready; try { const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY') }); // Send subscription to server await fetch('/api/push-subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); console.log('Push subscription successful'); } catch (error) { console.error('Failed to subscribe to push:', error); } } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; }

Background Sync for Offline Actions

Add to service worker (sw.js):

// Background sync event self.addEventListener('sync', event => { if (event.tag === 'sync-tasks') { event.waitUntil(syncTasks()); } }); async function syncTasks() { try { // Get pending sync data from IndexedDB const db = await openDatabase(); const pendingTasks = await getPendingTasks(db); // Sync with server for (const task of pendingTasks) { try { const response = await fetch('/api/tasks/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) }); if (response.ok) { await markTaskSynced(db, task.id); } } catch (error) { console.error('Failed to sync task:', task.id, error); } } // Notify user self.registration.showNotification('Tasks Synced', { body: `${pendingTasks.length} tasks synced successfully`, icon: '/images/icons/icon-192x192.png', badge: '/images/icons/icon-96x96.png' }); } catch (error) { console.error('Background sync failed:', error); throw error; } }

Install Prompt Implementation

// Handle install prompt function handleInstallPrompt(e) { // Prevent the mini-infobar from appearing e.preventDefault(); // Store the event for later use deferredPrompt = e; // Show install button const installBtn = document.getElementById('installBtn'); installBtn.style.display = 'block'; console.log('Install prompt ready'); } // Handle install button click async function handleInstallClick() { if (!deferredPrompt) { return; } // Show the install prompt deferredPrompt.prompt(); // Wait for the user's response const { outcome } = await deferredPrompt.userChoice; console.log(`User response: ${outcome}`); // Clear the deferred prompt deferredPrompt = null; // Hide install button document.getElementById('installBtn').style.display = 'none'; } // Listen for app installed event window.addEventListener('appinstalled', () => { console.log('PWA installed successfully'); // Hide install button document.getElementById('installBtn').style.display = 'none'; // Track installation if ('ga' in window) { ga('send', 'event', 'PWA', 'installed'); } });

Online/Offline Status Handling

// Setup online/offline handlers function setupOnlineOfflineHandlers() { updateOnlineStatus(); window.addEventListener('online', () => { updateOnlineStatus(); showSuccess('You're back online!'); // Trigger background sync if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { navigator.serviceWorker.ready.then(registration => { return registration.sync.register('sync-tasks'); }); } }); window.addEventListener('offline', () => { updateOnlineStatus(); showError('You're offline. Changes will sync when you're back online.'); }); } function updateOnlineStatus() { const statusIndicator = document.getElementById('onlineStatus'); const statusText = document.getElementById('statusText'); if (navigator.onLine) { statusIndicator.classList.remove('offline'); statusIndicator.classList.add('online'); statusText.textContent = 'Online'; } else { statusIndicator.classList.remove('online'); statusIndicator.classList.add('offline'); statusText.textContent = 'Offline'; } }
Best Practice: Always provide visual feedback when the app goes offline or comes back online. Users should understand the current connection state and what features are available.
Exercise: Implement the IndexedDB integration, add push notification support, and test the app in offline mode. Verify that tasks are saved locally and the offline indicator works correctly.
Next Steps: In Part 3, we'll add advanced features, optimize performance, deploy the app, and run a complete PWA audit.