Introduction to JavaScript · Lesson 5 of 5

Project: Weather App

Build a Weather App — JavaScript Project

This project puts together everything from the JS beginner series: DOM manipulation, Fetch API, async/await, error handling, and localStorage. You'll build a working weather app that fetches real data.


What We're Building

  • Search by city name
  • Current weather (temperature, humidity, wind, condition)
  • 5-day forecast
  • Error handling for invalid cities
  • Loading spinner
  • Save last searched city to localStorage

Step 1: HTML Structure

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Weather App</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="app">
    <header>
      <h1>Weather</h1>
      <form id="search-form">
        <input
          type="text"
          id="city-input"
          placeholder="Search city..."
          autocomplete="off"
        />
        <button type="submit">Search</button>
      </form>
    </header>

    <div id="error-message" class="error hidden"></div>

    <div id="spinner" class="spinner hidden">
      <div class="spin"></div>
    </div>

    <section id="current-weather" class="hidden">
      <div class="city-name" id="city-name"></div>
      <div class="temp" id="temperature"></div>
      <div class="condition" id="condition"></div>
      <div class="details">
        <span id="humidity"></span>
        <span id="wind"></span>
        <span id="feels-like"></span>
      </div>
    </section>

    <section id="forecast" class="hidden">
      <h2>5-Day Forecast</h2>
      <div class="forecast-grid" id="forecast-grid"></div>
    </section>
  </div>

  <script src="app.js"></script>
</body>
</html>

Step 2: CSS Styling

CSS
/* style.css */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: linear-gradient(135deg, #1a1a2e, #16213e);
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  padding: 20px;
}

.app {
  width: 100%;
  max-width: 480px;
}

header {
  text-align: center;
  margin-bottom: 24px;
}

h1 {
  font-size: 2rem;
  margin-bottom: 16px;
  letter-spacing: 2px;
}

#search-form {
  display: flex;
  gap: 8px;
}

#city-input {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid rgba(255,255,255,0.2);
  border-radius: 12px;
  background: rgba(255,255,255,0.1);
  color: white;
  font-size: 1rem;
  outline: none;
  transition: border-color 0.2s;
}
#city-input:focus { border-color: rgba(255,255,255,0.5); }
#city-input::placeholder { color: rgba(255,255,255,0.4); }

button[type="submit"] {
  padding: 12px 20px;
  background: #4f46e5;
  color: white;
  border: none;
  border-radius: 12px;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 600;
  transition: opacity 0.2s;
}
button[type="submit"]:hover { opacity: 0.9; }

/* Current weather */
#current-weather {
  background: rgba(255,255,255,0.1);
  border-radius: 20px;
  padding: 32px;
  text-align: center;
  margin-bottom: 20px;
  backdrop-filter: blur(10px);
}

.city-name { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
.temp { font-size: 4rem; font-weight: 700; margin: 12px 0; }
.condition { font-size: 1.1rem; color: rgba(255,255,255,0.7); margin-bottom: 20px; text-transform: capitalize; }

.details {
  display: flex;
  justify-content: center;
  gap: 24px;
  font-size: 0.9rem;
  color: rgba(255,255,255,0.7);
}

/* Forecast */
#forecast h2 { margin-bottom: 12px; font-size: 1rem; opacity: 0.7; }

.forecast-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 8px;
}

.forecast-card {
  background: rgba(255,255,255,0.1);
  border-radius: 12px;
  padding: 12px 8px;
  text-align: center;
  font-size: 0.85rem;
}
.forecast-card .day { font-weight: 600; margin-bottom: 4px; }
.forecast-card .fc-temp { font-size: 1.1rem; font-weight: 700; margin-top: 4px; }

/* Error */
.error {
  background: rgba(239, 68, 68, 0.2);
  border: 1px solid rgba(239, 68, 68, 0.4);
  border-radius: 12px;
  padding: 12px 16px;
  text-align: center;
  margin-bottom: 16px;
  color: #fca5a5;
}

/* Spinner */
.spinner {
  display: flex;
  justify-content: center;
  padding: 40px;
}
.spin {
  width: 40px;
  height: 40px;
  border: 4px solid rgba(255,255,255,0.2);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.hidden { display: none !important; }

Step 3: JavaScript — app.js

JavaScript
// ─────────────────────────────────────────────
// CONFIGURATION
// Sign up free at openweathermap.org to get an API key
// ─────────────────────────────────────────────
const API_KEY = "YOUR_API_KEY_HERE";
const BASE_URL = "https://api.openweathermap.org/data/2.5";

// ─────────────────────────────────────────────
// DOM REFERENCES
// ─────────────────────────────────────────────
const form = document.getElementById("search-form");
const input = document.getElementById("city-input");
const errorEl = document.getElementById("error-message");
const spinner = document.getElementById("spinner");
const currentWeatherEl = document.getElementById("current-weather");
const forecastEl = document.getElementById("forecast");

// ─────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────
let abortController = null;

// ─────────────────────────────────────────────
// EVENT LISTENERS
// ─────────────────────────────────────────────
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const city = input.value.trim();
  if (!city) return;
  loadWeather(city);
});

// Load last searched city on page load
document.addEventListener("DOMContentLoaded", () => {
  const lastCity = localStorage.getItem("lastCity");
  if (lastCity) {
    input.value = lastCity;
    loadWeather(lastCity);
  }
});

// ─────────────────────────────────────────────
// MAIN FUNCTION
// ─────────────────────────────────────────────
async function loadWeather(city) {
  // Cancel any in-flight request
  abortController?.abort();
  abortController = new AbortController();

  setUIState("loading");

  try {
    // Fetch current weather and 5-day forecast in parallel
    const [current, forecast] = await Promise.all([
      fetchWeather(`${BASE_URL}/weather?q=${city}&units=metric&appid=${API_KEY}`, abortController.signal),
      fetchWeather(`${BASE_URL}/forecast?q=${city}&cnt=40&units=metric&appid=${API_KEY}`, abortController.signal)
    ]);

    renderCurrentWeather(current);
    renderForecast(forecast);
    setUIState("success");

    // Save to localStorage
    localStorage.setItem("lastCity", city);
  } catch (err) {
    if (err.name === "AbortError") return; // cancelled, ignore
    setUIState("error", getErrorMessage(err));
  }
}

// ─────────────────────────────────────────────
// API HELPER
// ─────────────────────────────────────────────
async function fetchWeather(url, signal) {
  const res = await fetch(url, { signal });
  if (!res.ok) {
    const errorData = await res.json().catch(() => ({}));
    const err = new Error(errorData.message || `HTTP ${res.status}`);
    err.status = res.status;
    throw err;
  }
  return res.json();
}

// ─────────────────────────────────────────────
// RENDER FUNCTIONS
// ─────────────────────────────────────────────
function renderCurrentWeather(data) {
  document.getElementById("city-name").textContent =
    `${data.name}, ${data.sys.country}`;
  document.getElementById("temperature").textContent =
    `${Math.round(data.main.temp)}°C`;
  document.getElementById("condition").textContent =
    data.weather[0].description;
  document.getElementById("humidity").textContent =
    `💧 ${data.main.humidity}%`;
  document.getElementById("wind").textContent =
    `💨 ${Math.round(data.wind.speed)} m/s`;
  document.getElementById("feels-like").textContent =
    `🌡 Feels ${Math.round(data.main.feels_like)}°C`;
}

function renderForecast(data) {
  // API returns data every 3h — get one entry per day (noon reading)
  const dailyData = getDailyForecasts(data.list);

  document.getElementById("forecast-grid").innerHTML = dailyData
    .map(item => {
      const date = new Date(item.dt * 1000);
      const day = date.toLocaleDateString("en", { weekday: "short" });
      const temp = Math.round(item.main.temp);
      const icon = getWeatherEmoji(item.weather[0].main);

      return `
        <div class="forecast-card">
          <div class="day">${day}</div>
          <div>${icon}</div>
          <div class="fc-temp">${temp}°C</div>
        </div>
      `;
    })
    .join("");
}

// Pick one forecast per day — the closest to noon
function getDailyForecasts(list) {
  const days = new Map();
  for (const item of list) {
    const date = new Date(item.dt * 1000);
    const dayKey = date.toDateString();
    const hour = date.getHours();
    if (!days.has(dayKey) || Math.abs(hour - 12) < Math.abs(new Date(days.get(dayKey).dt * 1000).getHours() - 12)) {
      days.set(dayKey, item);
    }
  }
  return Array.from(days.values()).slice(0, 5);
}

function getWeatherEmoji(condition) {
  const map = {
    Clear: "☀️", Clouds: "☁️", Rain: "🌧️", Drizzle: "🌦️",
    Thunderstorm: "⛈️", Snow: "❄️", Mist: "🌫️", Fog: "🌫️",
  };
  return map[condition] || "🌤️";
}

// ─────────────────────────────────────────────
// UI STATE MANAGEMENT
// ─────────────────────────────────────────────
function setUIState(state, message = "") {
  spinner.classList.add("hidden");
  errorEl.classList.add("hidden");
  currentWeatherEl.classList.add("hidden");
  forecastEl.classList.add("hidden");

  if (state === "loading") {
    spinner.classList.remove("hidden");
  } else if (state === "success") {
    currentWeatherEl.classList.remove("hidden");
    forecastEl.classList.remove("hidden");
  } else if (state === "error") {
    errorEl.textContent = message;
    errorEl.classList.remove("hidden");
  }
}

function getErrorMessage(err) {
  if (err.status === 404) return "City not found. Check the spelling and try again.";
  if (err.status === 401) return "Invalid API key. Get a free key at openweathermap.org";
  if (err.status === 429) return "Too many requests. Please wait a moment.";
  return "Failed to load weather. Check your connection and try again.";
}

Step 4: Getting Your API Key

  1. Go to openweathermap.org and create a free account
  2. Go to "My API Keys" and copy your key
  3. Replace "YOUR_API_KEY_HERE" in app.js
  4. Free tier: 1,000 calls/day, enough for development

What This Project Demonstrates

  • Fetch API with real external data
  • Promise.all for parallel requests (current + forecast)
  • AbortController to cancel stale requests
  • Error handling with meaningful user messages
  • localStorage for persistence across sessions
  • DOM manipulation to render dynamic content
  • UI state management (loading/success/error)
  • CSS Grid for the forecast layout

Extensions to Try

  1. Add temperature unit toggle (°C / °F)
  2. Add geolocation to detect user's city automatically
  3. Add hourly forecast chart with Canvas or a library
  4. Store a list of favourite cities
  5. Add dark/light mode toggle

Key Takeaways

This project shows how a real app works: fetch data, handle all states (loading/success/error), update the UI, and persist user preferences. These exact patterns appear in every React, Vue, or plain-JS app you'll ever build.