Weekend Project: Building a Progressive Web Application

Module 25: Frontend Frameworks & State Management

Introduction

For this weekend project, you'll build a complete Progressive Web Application (PWA) using a frontend framework of your choice (React, Vue, or Angular). What makes this project special is that we'll approach it using George Polya's famous 4-step problem-solving procedure, which will help structure your work and ensure you build a robust application.

flowchart TD A[Understand the Problem] --> B[Devise a Plan] B --> C[Execute the Plan] C --> D[Review and Extend] style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#bfb,stroke:#333,stroke-width:2px style D fill:#fbf,stroke:#333,stroke-width:2px

George Polya (1887-1985) was a renowned mathematician who developed a 4-step approach to problem solving that has been widely adopted in mathematics, computer science, and engineering. His method helps break down complex problems into manageable parts, which is perfect for a project like building a PWA.

Project Overview: Weather Dashboard PWA

For this project, you'll create a Weather Dashboard PWA that allows users to:

You'll implement this application using the following technologies:

flowchart TD A[Weather Dashboard PWA] --> B[Frontend Framework] A --> C[Service Workers] A --> D[IndexedDB] A --> E[Web App Manifest] A --> F[Weather API] B --> B1[Components] B --> B2[State Management] B --> B3[Routing] C --> C1[Offline Caching] C --> C2[Background Sync] C --> C3[Push Notifications] D --> D1[Location Storage] D --> D2[Weather Data Cache] D --> D3[User Preferences]

Step 1: Understand the Problem

Following Polya's method, our first step is to thoroughly understand the problem before attempting to solve it. This involves:

Requirements Analysis

Functional Requirements:

Non-Functional Requirements:

User Personas

Sarah - The Commuter

Age: 32

Occupation: Marketing Manager

Tech Savvy: Medium

Goals: Quickly check weather before commuting, receive alerts about weather affecting her commute, save minimal data on her phone plan

Frustrations: Slow apps, having to search the same locations repeatedly, weather apps that drain battery

Marcus - The Outdoor Enthusiast

Age: 28

Occupation: Freelance Photographer

Tech Savvy: High

Goals: Check detailed weather for various locations, plan outdoor photoshoots based on weather, access weather info in remote areas with poor connectivity

Frustrations: Apps that don't work offline, inaccurate forecasts, complex interfaces

Key Questions to Answer

Before moving forward, make sure you understand:

Understanding Exercise

Take 30 minutes to create a mind map or list of all aspects of the problem. Include both technical requirements and user needs. Identify any assumptions you're making and questions that remain unanswered.

Step 2: Devise a Plan

With a solid understanding of the problem, we can now devise a comprehensive plan for building our Weather Dashboard PWA. This includes architecture decisions, component breakdown, and implementation strategy.

Technical Architecture

flowchart TD subgraph "Client Side" A[Frontend Framework] --> B[UI Components] A --> C[State Management] A --> D[Router] E[Service Worker] --> F[Cache API] E --> G[IndexedDB] E --> H[Push API] end subgraph "Third-Party Services" I[Weather API] J[Geolocation API] end C -.-> I C -.-> J C -.-> G

Frontend Framework Selection

Choose the framework you're most comfortable with. Here's a quick comparison to help you decide:

Framework Strengths Considerations Best For
React Large ecosystem, flexible, strong community support Requires additional libraries for routing and state management Developers who prefer flexibility and component-based architecture
Vue Gentle learning curve, built-in state management, simple syntax Smaller ecosystem compared to React Developers who prefer an all-in-one solution with clear conventions
Angular Comprehensive framework, TypeScript integration, dependency injection Steeper learning curve, more opinionated Developers who prefer a full-featured framework with strong structure

Component Breakdown

Regardless of the framework you choose, break down your application into these key components:

flowchart TD A[App Container] --> B[Header] A --> C[LocationSearch] A --> D[WeatherDisplay] A --> E[ForecastList] A --> F[SavedLocations] A --> G[Settings] A --> H[Notifications] D --> D1[CurrentConditions] D --> D2[WeatherDetails] D --> D3[WeatherMap] E --> E1[DayForecast] F --> F1[LocationItem]

Offline Strategy

Plan your offline functionality:

Implementation Phases

Break down the project into manageable phases:

  1. Phase 1: Core Application
    • Set up the frontend framework project structure
    • Create basic UI components
    • Implement weather API integration
    • Build location search functionality
  2. Phase 2: PWA Features
    • Configure service worker for offline caching
    • Implement IndexedDB for data persistence
    • Create Web App Manifest
    • Add install promotion
  3. Phase 3: Enhanced Features
    • Add push notifications for weather alerts
    • Implement background sync
    • Add geolocation for automatic local weather
    • Create settings for user preferences
  4. Phase 4: Polish
    • Optimize performance
    • Enhance accessibility
    • Add animations and transitions
    • Implement error handling and fallbacks

Planning Exercise

Create a detailed component breakdown for your chosen framework. Define the props/inputs and outputs for each component. Draft a timeline for implementation phases, allocating time for each feature based on complexity.

Step 3: Execute the Plan

Now it's time to implement our Weather Dashboard PWA according to the plan. We'll examine key implementation aspects for each framework, but remember to apply the concepts to your chosen framework.

Project Setup

Start by setting up your project using the appropriate CLI tool:

React (with Create React App)


# Create the project
npx create-react-app weather-dashboard --template cra-template-pwa

# Navigate to project folder
cd weather-dashboard

# Install additional dependencies
npm install react-router-dom axios styled-components
                    

Vue (with Vue CLI)


# Install Vue CLI if you haven't
npm install -g @vue/cli

# Create the project with PWA plugin
vue create weather-dashboard
# (Select Manual, then choose PWA)

# Navigate to project folder
cd weather-dashboard

# Install additional dependencies
npm install axios vue-router
                    

Angular (with Angular CLI)


# Install Angular CLI if you haven't
npm install -g @angular/cli

# Create the project
ng new weather-dashboard --routing

# Navigate to project folder
cd weather-dashboard

# Add PWA support
ng add @angular/pwa

# Install additional dependencies
npm install @angular/material
                    

API Integration

Set up a service to interact with the weather API. Here's an example using the OpenWeatherMap API:

React API Service


// src/services/weatherService.js
import axios from 'axios';

const API_KEY = 'your_api_key';
const BASE_URL = 'https://api.openweathermap.org/data/2.5';

const weatherService = {
  async getCurrentWeather(city) {
    try {
      const response = await axios.get(`${BASE_URL}/weather`, {
        params: {
          q: city,
          units: 'metric',
          appid: API_KEY
        }
      });
      return response.data;
    } catch (error) {
      console.error('Error fetching current weather:', error);
      throw error;
    }
  },
  
  async getForecast(city) {
    try {
      const response = await axios.get(`${BASE_URL}/forecast`, {
        params: {
          q: city,
          units: 'metric',
          appid: API_KEY
        }
      });
      
      // Group forecast by day
      const forecastByDay = [];
      const groupedData = {};
      
      response.data.list.forEach(item => {
        const date = new Date(item.dt * 1000).toLocaleDateString();
        if (!groupedData[date]) {
          groupedData[date] = [];
        }
        groupedData[date].push(item);
      });
      
      // Get average values for each day
      Object.keys(groupedData).forEach(date => {
        const dayData = groupedData[date];
        const temperatures = dayData.map(item => item.main.temp);
        const avgTemp = temperatures.reduce((sum, temp) => sum + temp, 0) / temperatures.length;
        
        // Use the weather of noon as representative
        const noonData = dayData.find(item => {
          const hour = new Date(item.dt * 1000).getHours();
          return hour >= 11 && hour <= 13;
        }) || dayData[0];
        
        forecastByDay.push({
          date,
          avgTemp,
          weather: noonData.weather[0],
          humidity: noonData.main.humidity,
          wind: noonData.wind.speed
        });
      });
      
      return forecastByDay.slice(0, 5); // Return 5-day forecast
    } catch (error) {
      console.error('Error fetching forecast:', error);
      throw error;
    }
  }
};

export default weatherService;
                    

Implementing Core Components

Create the key components for your application. Here's a React example of the WeatherDisplay component:


// src/components/WeatherDisplay.js
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import weatherService from '../services/weatherService';
import CurrentConditions from './CurrentConditions';
import WeatherDetails from './WeatherDetails';
import { saveToIndexedDB } from '../utils/indexedDBHelpers';

const WeatherContainer = styled.div`
  background: linear-gradient(to bottom, #57c1eb 0%,#246fa8 100%);
  border-radius: 10px;
  box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
  color: white;
  padding: 20px;
  margin-bottom: 20px;
`;

const LoadingContainer = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  height: 200px;
  width: 100%;
`;

const ErrorContainer = styled.div`
  background-color: #ff6b6b;
  color: white;
  padding: 15px;
  border-radius: 5px;
  margin-bottom: 20px;
`;

const WeatherDisplay = ({ location }) => {
  const [currentWeather, setCurrentWeather] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!location) return;
    
    const fetchWeatherData = async () => {
      try {
        setLoading(true);
        setError(null);
        
        // Try to get from cached data first if offline
        if (!navigator.onLine) {
          // Implementation will be covered in the IndexedDB section
          return;
        }
        
        const data = await weatherService.getCurrentWeather(location);
        setCurrentWeather(data);
        
        // Save to IndexedDB for offline access
        saveToIndexedDB('weatherData', {
          id: location,
          data: data,
          timestamp: Date.now()
        });
      } catch (err) {
        setError('Failed to load weather data. Please try again later.');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };
    
    fetchWeatherData();
  }, [location]);
  
  if (loading) {
    return (
      
        
); } if (error) { return (

{error}

); } if (!currentWeather) { return null; } return ( ); }; export default WeatherDisplay;

Setting Up IndexedDB

Create a utility file for IndexedDB operations:


// src/utils/indexedDBHelpers.js
const DB_NAME = 'weatherDashboardDB';
const DB_VERSION = 1;

// Initialize the database
export const initDB = () => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    
    request.onerror = (event) => {
      reject('Error opening IndexedDB');
    };
    
    request.onsuccess = (event) => {
      const db = event.target.result;
      resolve(db);
    };
    
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      
      // Create object stores
      if (!db.objectStoreNames.contains('weatherData')) {
        db.createObjectStore('weatherData', { keyPath: 'id' });
      }
      
      if (!db.objectStoreNames.contains('forecastData')) {
        db.createObjectStore('forecastData', { keyPath: 'id' });
      }
      
      if (!db.objectStoreNames.contains('savedLocations')) {
        const savedLocationsStore = db.createObjectStore('savedLocations', { keyPath: 'id' });
        savedLocationsStore.createIndex('name', 'name', { unique: false });
      }
      
      if (!db.objectStoreNames.contains('settings')) {
        db.createObjectStore('settings', { keyPath: 'id' });
      }
    };
  });
};

// Save data to IndexedDB
export const saveToIndexedDB = (storeName, data) => {
  return new Promise((resolve, reject) => {
    initDB()
      .then(db => {
        const transaction = db.transaction([storeName], 'readwrite');
        const store = transaction.objectStore(storeName);
        const request = store.put(data);
        
        request.onsuccess = () => {
          resolve();
        };
        
        request.onerror = () => {
          reject('Error saving to IndexedDB');
        };
      })
      .catch(error => {
        reject(error);
      });
  });
};

// Get data from IndexedDB
export const getFromIndexedDB = (storeName, id) => {
  return new Promise((resolve, reject) => {
    initDB()
      .then(db => {
        const transaction = db.transaction([storeName], 'readonly');
        const store = transaction.objectStore(storeName);
        const request = store.get(id);
        
        request.onsuccess = () => {
          resolve(request.result);
        };
        
        request.onerror = () => {
          reject('Error reading from IndexedDB');
        };
      })
      .catch(error => {
        reject(error);
      });
  });
};

// Get all data from a store
export const getAllFromIndexedDB = (storeName) => {
  return new Promise((resolve, reject) => {
    initDB()
      .then(db => {
        const transaction = db.transaction([storeName], 'readonly');
        const store = transaction.objectStore(storeName);
        const request = store.getAll();
        
        request.onsuccess = () => {
          resolve(request.result);
        };
        
        request.onerror = () => {
          reject('Error reading from IndexedDB');
        };
      })
      .catch(error => {
        reject(error);
      });
  });
};

// Delete data from IndexedDB
export const deleteFromIndexedDB = (storeName, id) => {
  return new Promise((resolve, reject) => {
    initDB()
      .then(db => {
        const transaction = db.transaction([storeName], 'readwrite');
        const store = transaction.objectStore(storeName);
        const request = store.delete(id);
        
        request.onsuccess = () => {
          resolve();
        };
        
        request.onerror = () => {
          reject('Error deleting from IndexedDB');
        };
      })
      .catch(error => {
        reject(error);
      });
  });
};
            

Service Worker Implementation

Customize the service worker to handle offline capabilities for your weather app:


// public/service-worker.js
const CACHE_NAME = 'weather-dashboard-v1';
const STATIC_CACHE_URLS = [
  '/',
  '/index.html',
  '/static/js/main.chunk.js',
  '/static/js/0.chunk.js',
  '/static/js/bundle.js',
  '/manifest.json',
  '/favicon.ico',
  '/logo192.png',
  '/logo512.png'
];

// Install event - cache the app shell
self.addEventListener('install', event => {
  console.log('Service Worker installing');
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Caching app shell');
        return cache.addAll(STATIC_CACHE_URLS);
      })
  );
});

// Activate event - clean up old caches
self.addEventListener('activate', event => {
  console.log('Service Worker activating');
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheName !== CACHE_NAME) {
            console.log('Removing old cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// Fetch event - serve from cache, fall back to network
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  
  // Skip cross-origin requests
  if (url.origin !== self.location.origin) {
    return;
  }
  
  // OpenWeatherMap API requests - network first strategy
  if (url.hostname === 'api.openweathermap.org') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          // Clone the response for caching
          const responseToCache = response.clone();
          
          caches.open(CACHE_NAME)
            .then(cache => {
              cache.put(event.request, responseToCache);
            });
            
          return response;
        })
        .catch(() => {
          // If network fails, try to get from cache
          return caches.match(event.request);
        })
    );
    return;
  }
  
  // Weather icons from OpenWeatherMap - cache first strategy
  if (url.hostname === 'openweathermap.org' && url.pathname.includes('/img/')) {
    event.respondWith(
      caches.match(event.request)
        .then(cachedResponse => {
          if (cachedResponse) {
            return cachedResponse;
          }
          
          return fetch(event.request)
            .then(response => {
              // Clone the response for caching
              const responseToCache = response.clone();
              
              caches.open(CACHE_NAME)
                .then(cache => {
                  cache.put(event.request, responseToCache);
                });
                
              return response;
            });
        })
    );
    return;
  }
  
  // For HTML navigation requests - cache first with network fallback
  if (event.request.mode === 'navigate') {
    event.respondWith(
      caches.match('/index.html')
        .then(cachedResponse => {
          return cachedResponse || fetch(event.request);
        })
        .catch(() => {
          return caches.match('/index.html');
        })
    );
    return;
  }
  
  // For other static assets - cache first strategy
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse;
        }
        
        return fetch(event.request)
          .then(response => {
            // Don't cache non-successful responses
            if (!response || response.status !== 200) {
              return response;
            }
            
            // Clone the response for caching
            const responseToCache = response.clone();
            
            caches.open(CACHE_NAME)
              .then(cache => {
                cache.put(event.request, responseToCache);
              });
              
            return response;
          });
      })
  );
});

// Background sync for offline operations
self.addEventListener('sync', event => {
  if (event.tag === 'sync-saved-locations') {
    event.waitUntil(syncSavedLocations());
  }
});

// Push notification event handler
self.addEventListener('push', event => {
  const data = event.data.json();
  const options = {
    body: data.body,
    icon: '/logo192.png',
    badge: '/logo192.png',
    data: {
      url: data.url || '/'
    }
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Notification click event handler
self.addEventListener('notificationclick', event => {
  event.notification.close();
  
  if (event.notification.data && event.notification.data.url) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});
            

Web App Manifest

Configure your manifest.json file for installability:


// public/manifest.json
{
  "short_name": "Weather",
  "name": "Weather Dashboard",
  "description": "A progressive web application for weather forecasts and alerts",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64",
      "type": "image/x-icon"
    },
    {
      "src": "logo192.png",
      "type": "image/png",
      "sizes": "192x192",
      "purpose": "any maskable"
    },
    {
      "src": "logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "theme_color": "#246fa8",
  "background_color": "#ffffff",
  "orientation": "portrait",
  "categories": ["weather", "utilities"],
  "screenshots": [
    {
      "src": "screenshot1.png",
      "sizes": "1280x720",
      "type": "image/png"
    },
    {
      "src": "screenshot2.png",
      "sizes": "1280x720",
      "type": "image/png"
    }
  ]
}
            

Implementation Exercise

Choose a specific aspect of the application to implement based on your framework of choice. For example, create the CurrentConditions component or implement the saved locations functionality with IndexedDB storage. Focus on making it work offline by properly caching API responses.

Step 4: Review and Extend

The final step in Polya's approach is to review your solution and look for ways to improve it. This is when you'll test your application, optimize its performance, and add enhancements.

Testing

Test your application thoroughly to ensure it works as expected:

Performance Optimization

Measure and improve your application's performance:

Accessibility Improvements

Ensure your application is accessible to all users:

Feature Extensions

Consider adding these enhancements to your application:

Review Exercise

Run a Lighthouse audit on your application and identify at least three improvements you can make. Implement these improvements and measure the performance impact. Also, choose one extension feature from the list above and implement it.

Project Deliverables

By the end of this weekend project, you should have:

  1. A functioning Weather Dashboard PWA built with your chosen frontend framework
  2. Service worker implementation with offline capabilities
  3. IndexedDB data storage for saved locations and weather data
  4. A proper Web App Manifest allowing installation
  5. Clean, documented code following best practices
  6. A README.md file explaining your application and implementation decisions

Bonus Deliverables

Applying Polya's Method Beyond This Project

George Polya's 4-step method is a valuable problem-solving approach that you can apply to any development project:

  1. Understand the Problem: Always take time to thoroughly analyze requirements, user needs, and constraints before writing any code
  2. Devise a Plan: Create a clear architecture and implementation strategy before diving into coding
  3. Execute the Plan: Follow your plan while remaining flexible enough to adjust when obstacles arise
  4. Review and Extend: Never consider a solution complete without testing, optimization, and reflection on potential improvements

This disciplined approach leads to more robust, maintainable solutions and reduces the need for major refactoring later.

As you continue your development career, cultivate the habit of applying these steps to problems of all sizes, from small bug fixes to large-scale applications.

Resources

APIs

PWA Development

Framework-Specific Resources

Design Resources