CodingNic

Web APIs and Backend Communication

Mini Project: Weather Dashboard

Web APIs and Backend Communication 40 min read

Mini Project: Weather Dashboard

What This Combines

This project reuses fetch() and async/await (Module 3), DOM updates (Module 1), form handling (Module 1 and 2), localStorage (Module 5), and everything from this module: a real REST API, GET requests, nested resources, and status codes.

๐Ÿ’ก Why this matters: This is the payoff for the whole course so far: search for a real city, and see real, live weather data appear on the page, built from nothing but what you already know.

โš ๏ธ A note on verification: this project uses Open-Meteo, a free, real weather API that needs no signup or API key. Its geocoding endpoint (Step 2 below) was fetched live for this lesson, the exact response shown is real. Its forecast endpoint (Step 3) wasn’t reachable from this sandbox’s restricted network, that part is accurate and based on Open-Meteo’s own documented response shape, run it yourself to see it firsthand.

What You’re Building

Type a city name, and see its current temperature, conditions, humidity, and wind speed, pulled live from a real weather API, with the last-searched city remembered for next time.

text
weather-dashboard/
โ”œโ”€โ”€ index.html
โ”œโ”€โ”€ script.js
โ””โ”€โ”€ icons/
    โ”œโ”€โ”€ sunny.png
    โ”œโ”€โ”€ cloudy.png
    โ”œโ”€โ”€ rainy.png
    โ”œโ”€โ”€ snowy.png
    โ””โ”€โ”€ stormy.png
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weather Dashboard</title>
<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: Arial, Helvetica, sans-serif;
}
body {
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #4facfe, #00f2fe);
  padding: 20px;
}
.card {
  width: 100%;
  max-width: 380px;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
  padding: 28px;
  text-align: center;
}
h1 {
  font-size: 20px;
  color: #333;
  margin-bottom: 18px;
}
.search-box {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
}
.search-box input {
  flex: 1;
  padding: 10px 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 14px;
}
.search-box button {
  padding: 10px 16px;
  border: none;
  border-radius: 8px;
  background: #4facfe;
  color: #fff;
  font-weight: bold;
  cursor: pointer;
}
.search-box button:hover {
  background: #2d95f0;
}
#weatherIcon {
  width: 80px;
  height: 80px;
  margin: 10px auto;
  display: block;
}
#cityName {
  font-size: 22px;
  color: #222;
  margin-bottom: 4px;
}
#temperature {
  font-size: 42px;
  color: #4facfe;
  font-weight: bold;
}
#condition {
  color: #666;
  margin-bottom: 14px;
}
.details {
  display: flex;
  justify-content: space-around;
  color: #555;
  font-size: 14px;
  border-top: 1px solid #eee;
  padding-top: 14px;
}
#status {
  margin-top: 14px;
  color: #999;
  font-size: 13px;
  min-height: 18px;
}
</style>
</head>
<body>
  <div class="card">
    <h1>Weather Dashboard</h1>

    <div class="search-box">
      <input id="cityInput" type="text" placeholder="Enter a city">
      <button id="searchBtn">Search</button>
    </div>

    <img id="weatherIcon" src="" alt="" style="display: none;">
    <p id="cityName"></p>
    <p id="temperature"></p>
    <p id="condition"></p>

    <div class="details">
      <span id="humidity"></span>
      <span id="wind"></span>
    </div>

    <p id="status"></p>
  </div>

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

The design is already done, a centered card, a search box, and a results area that starts empty. Everything below is about filling in script.js. The icons/ folder holds five small weather icon images, referenced by filename in Step 4, you can use any square image you like for each condition.

javascript
const cityInput = document.getElementById("cityInput");
const searchBtn = document.getElementById("searchBtn");
const status = document.getElementById("status");

searchBtn.addEventListener("click", () => {
  const city = cityInput.value.trim();
  if (!city) return;
  searchWeather(city);
});

cityInput.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    searchBtn.click();
  }
});

searchWeather() doesn’t exist yet, it’s built across the next few steps, this just wires up both ways of triggering a search: clicking the button, or pressing Enter (Module 2), matching the pattern from Module 2’s mini project.

Step 2: Geocode the City Name

A weather API needs coordinates, not a city name. Open-Meteo’s free geocoding endpoint converts one into the other.

javascript
async function geocodeCity(city) {
  const response = await fetch(
    `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
  );
  const data = await response.json();

  if (!data.results || data.results.length === 0) {
    return null;
  }

  const { latitude, longitude, name, country } = data.results[0];
  return { latitude, longitude, name, country };
}

encodeURIComponent(city) (Course 1) safely handles spaces and special characters in whatever the user typed, "New York" becomes "New%20York" in the actual URL. count=1 asks for just the single best match. Here’s what a real response looks like, fetched live for this lesson:

text
GET https://geocoding-api.open-meteo.com/v1/search?name=Berlin&count=1

{
  "results": [
    {
      "id": 2950159,
      "name": "Berlin",
      "latitude": 52.52437,
      "longitude": 13.41053,
      "country": "Germany",
      ...
    }
  ]
}

When a city genuinely doesn’t exist, Open-Meteo’s documented behavior is to omit results entirely rather than return an empty array, the !data.results || data.results.length === 0 check handles either case.

Step 3: Fetch the Forecast

With coordinates in hand, request the actual weather.

javascript
async function getForecast(latitude, longitude) {
  const response = await fetch(
    `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code`
  );
  const data = await response.json();
  return data.current;
}

The current query parameter lists exactly which fields to include, temperature_2m (temperature at 2 meters above ground, the standard measurement height), relative_humidity_2m, wind_speed_10m, and weather_code, a numeric code representing the condition (clear, cloudy, rain, and so on). The response’s data.current object holds all four as plain numbers.

Step 4: Translate the Weather Code

weather_code follows a standard numeric scale (the WMO weather interpretation codes), not something readable on its own. A small lookup table converts it to text and picks a matching icon.

javascript
function describeWeather(code) {
  if (code === 0) return { text: "Clear sky", icon: "icons/sunny.png" };
  if (code >= 1 && code <= 3) return { text: "Partly cloudy", icon: "icons/cloudy.png" };
  if (code >= 45 && code <= 48) return { text: "Fog", icon: "icons/cloudy.png" };
  if (code >= 51 && code <= 67) return { text: "Rain", icon: "icons/rainy.png" };
  if (code >= 71 && code <= 77) return { text: "Snow", icon: "icons/snowy.png" };
  if (code >= 80 && code <= 82) return { text: "Rain showers", icon: "icons/rainy.png" };
  if (code >= 95) return { text: "Thunderstorm", icon: "icons/stormy.png" };
  return { text: "Unknown", icon: "icons/cloudy.png" };
}

This is a straightforward range check, weather_code groups related conditions into number ranges (all the different rain intensities sit between 51 and 67, for instance), each range maps to one simple description and one icon.

Step 5: Put It All Together

javascript
const weatherIcon = document.getElementById("weatherIcon");
const cityName = document.getElementById("cityName");
const temperature = document.getElementById("temperature");
const condition = document.getElementById("condition");
const humidity = document.getElementById("humidity");
const wind = document.getElementById("wind");

async function searchWeather(city) {
  status.textContent = "Searching...";

  try {
    const location = await geocodeCity(city);

    if (!location) {
      status.textContent = `Couldn't find "${city}". Try another city.`;
      return;
    }

    const current = await getForecast(location.latitude, location.longitude);
    const weather = describeWeather(current.weather_code);

    cityName.textContent = `${location.name}, ${location.country}`;
    temperature.textContent = `${Math.round(current.temperature_2m)}ยฐC`;
    condition.textContent = weather.text;
    humidity.textContent = `Humidity: ${current.relative_humidity_2m}%`;
    wind.textContent = `Wind: ${current.wind_speed_10m} km/h`;
    weatherIcon.src = weather.icon;
    weatherIcon.style.display = "block";

    localStorage.setItem("lastCity", city);
    status.textContent = "";
  } catch (error) {
    status.textContent = "Something went wrong. Check your connection and try again.";
  }
}

searchWeather() is the function Step 1’s event listeners call. It chains Steps 2 through 4: geocode, then forecast, then translate the code, updating the page only once everything succeeded. The try/catch (Module 3) catches a genuine network failure, either fetch() call failing outright, not a “city not found” result, that’s already handled separately by the if (!location) check, since it’s a normal, expected outcome, not an error.

Step 6: Remember the Last City

searchWeather() already saves the last searched city to localStorage. Load it back when the page first opens.

javascript
const lastCity = localStorage.getItem("lastCity");
if (lastCity) {
  cityInput.value = lastCity;
  searchWeather(lastCity);
}

Run this once, after every function above it is defined. Now reopening the page shows the same city’s weather immediately, without retyping it, the same localStorage pattern from Module 5, lesson 1’s notes app, applied here to a search term instead of a note.

Try It

Build the full weather dashboard from this lesson:

  1. Wire up Steps 1 through 5, and search for a real city you know.
  2. Search for a city that doesn’t exist (try mashing the keyboard) and confirm the “Couldn’t find” message shows instead of the page breaking.
  3. Add Step 6, reload the page, and confirm the last city you searched loads automatically.
  4. Extend describeWeather() to also set a background color on .card based on the condition, blue-ish for rain, gray for cloudy, your choice for the rest.

Recap

  • Two API calls, chained: geocode a city name into coordinates, then fetch the forecast for those coordinates, this module’s REST and nested-resource ideas applied to a real, live API.
  • A numeric weather_code needed a small translation layer, range checks mapping groups of codes to one readable description and icon.
  • try/catch handled genuine failures (no network), while “city not found” was handled as a normal, expected result, not an error, worth telling apart in any real app.
  • localStorage (Module 5) remembered the last search across reloads, the exact same pattern as the notes app, reused here for something completely different.

Next module: the Checkpoint Project, combining everything from this entire course, the DOM, events, async data fetching, and a real server, into one final interactive page.