Progressive Web Apps (PWA)
Building a PWA Project (Part 2)
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.