Building a Real-Time Weather Dashboard with the OpenWeather API

Beginner 105 views Jul 13, 2026

A weather dashboard is one of the best beginner-to-intermediate projects for practicing API integration in vanilla JavaScript. In this tutorial you'll build a fully functional dashboard that fetches live conditions for any city in the world, using nothing but HTML, CSS, and JavaScript.

This project is well suited to beginners learning API integration, students practicing asynchronous JavaScript, and developers who want a practical, real-world app with live data rather than another static layout exercise.

By the end of this guide you'll have a working dashboard that shows current conditions, temperature, humidity, wind speed, cloud cover, visibility, and more, all updated in real time.

What You'll Learn

  • How to work with REST APIs and fetch live data using JavaScript
  • How to handle asynchronous operations with async/await
  • How to parse and display JSON data dynamically
  • How to build a responsive weather UI with dynamic content
  • How to handle API errors gracefully
  • How to cache results so you don't burn through your rate limit
  • Why a client-side app can't hide an API key, and what to do about it

Features of This Weather Dashboard

  • Real-time weather data from the OpenWeather API
  • Search for any city worldwide
  • Displays temperature, humidity, wind speed, and more
  • Dynamic weather icons based on current conditions
  • Responsive design that works on all devices
  • Offline detection with a clear notice
  • Five-minute result cache to reduce API calls
  • Error handling for invalid city names
  • Keyboard-accessible search

Prerequisites

Before starting, you should be comfortable with the basics of HTML, CSS, and JavaScript. You'll also need a code editor such as Visual Studio Code, a modern web browser, and a free API key from a weather service.

Where to Find a Free Weather API

You'll need a free API key to fetch weather data. Here are the best options and what their free tiers offer:

1. OpenWeatherMap (used in this tutorial)

Website: openweathermap.org/api
Free tier: 1,000 calls/day, 60 calls/minute
Features: Current weather, 5-day forecast, air pollution data
How to get it: Sign up for a free account; your API key becomes available in your account dashboard.

2. WeatherAPI.com

Website: weatherapi.com
Free tier: 1,000,000 calls/month
Features: Current weather, forecast, astronomy data, air quality

3. Visual Crossing Weather

Website: visualcrossing.com
Free tier: 1,000 records/day
Features: Historical weather, forecasts, current conditions

4. Weatherstack API

Website: weatherstack.com
Free tier: 100 requests/month
Features: Current weather, historical data, forecasts

5. Open-Meteo (no API key required)

Website: open-meteo.com
Free tier: Free for non-commercial use, no signup needed
Features: Current weather, forecasts, historical data

Recommendation: This tutorial uses OpenWeatherMap as the primary source since it's the most widely documented option. The code also includes an Open-Meteo fallback, so the dashboard still works even without an API key.

A Note on API Key Security

Before we start, one thing worth being honest about: you cannot hide an API key in a static front-end app. Anything in your JavaScript is visible to anyone who opens DevTools. A .env file only helps if you have a build step that injects the value at compile time, and even then the key still ends up in the shipped bundle.

For a learning project this is fine, especially with a free tier that has a daily cap. For anything public, the correct approach is a small server-side proxy: your front end calls your own endpoint, your server holds the key and forwards the request. That's outside the scope of this tutorial, but don't ship a public app assuming the key is private.

At minimum, restrict your key by domain in the provider dashboard where that option exists — and if a key ever ends up somewhere public, revoke it and issue a new one rather than hoping nobody noticed.

Project Structure

TEXT
weather-dashboard/
├── index.html
├── style.css
└── script.js

Step 1: Build the HTML Structure

Create an index.html file with the following markup:

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 - Real-Time Weather App</title>
    <meta name="description" content="Real-time weather dashboard with live data from OpenWeather API.">
    <link rel="stylesheet" href="style.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
    <div class="container">

        <header>
            <h1>🌤️ Weather Dashboard</h1>
            <p class="subtitle">Real-time weather for any location</p>
        </header>

        <!-- ===== OFFLINE NOTICE ===== -->
        <div class="offline-indicator" id="offlineIndicator" role="status" aria-live="polite">
            <i class="fas fa-wifi"></i>
            <span>You appear to be offline. Cached results will still show.</span>
        </div>

        <!-- ===== SEARCH SECTION ===== -->
        <div class="search-section">
            <div class="search-box" id="searchBox">
                <input
                    type="text"
                    id="cityInput"
                    placeholder="Enter city name..."
                    autocomplete="off"
                    aria-label="Search for a city"
                >
                <button id="searchBtn" aria-label="Search weather">
                    <i class="fas fa-search"></i>
                </button>
            </div>
            <button id="locationBtn" class="location-btn" aria-label="Get weather for your location">
                <i class="fas fa-location-dot"></i>
                Use My Location
            </button>
        </div>

        <!-- ===== LOADING STATE ===== -->
        <div class="loading" id="loading" role="status" aria-live="polite">
            <div class="spinner"></div>
            <p>Fetching weather data...</p>
        </div>

        <!-- ===== WEATHER DISPLAY ===== -->
        <div class="weather-display" id="weatherDisplay" style="display: none;">

            <div class="weather-header">
                <div>
                    <h2 id="cityName">City Name</h2>
                    <p id="countryName">Country</p>
                </div>
                <div class="weather-icon">
                    <img id="weatherIcon" src="" alt="Weather condition">
                    <p id="weatherDescription">Clear Sky</p>
                </div>
            </div>

            <div class="temperature-section">
                <h1 id="temperature">--°C</h1>
                <p id="feelsLike">Feels like --°C</p>
            </div>

            <div class="weather-details">
                <div class="detail-card">
                    <i class="fas fa-water"></i>
                    <div>
                        <span id="humidity">--%</span>
                        <p>Humidity</p>
                    </div>
                </div>
                <div class="detail-card">
                    <i class="fas fa-wind"></i>
                    <div>
                        <span id="windSpeed">-- km/h</span>
                        <p>Wind Speed</p>
                    </div>
                </div>
                <div class="detail-card">
                    <i class="fas fa-cloud"></i>
                    <div>
                        <span id="cloudCover">--%</span>
                        <p>Cloud Cover</p>
                    </div>
                </div>
                <div class="detail-card">
                    <i class="fas fa-eye"></i>
                    <div>
                        <span id="visibility">-- km</span>
                        <p>Visibility</p>
                    </div>
                </div>
            </div>

            <div class="additional-info">
                <div>
                    <i class="fas fa-arrow-up"></i>
                    <span>Pressure: <strong id="pressure">-- hPa</strong></span>
                </div>
                <div>
                    <i class="fas fa-clock"></i>
                    <span>Updated: <strong id="updatedAt">--</strong></span>
                </div>
            </div>

        </div>

        <!-- ===== ERROR MESSAGE ===== -->
        <div class="error-message" id="errorMessage" role="alert" style="display: none;">
            <i class="fas fa-exclamation-circle"></i>
            <p id="errorText">City not found. Please try again.</p>
        </div>

        <!-- ===== FOOTER ===== -->
        <footer>
            <p>Data provided by <a href="#" id="apiSource" target="_blank" rel="noopener">OpenWeatherMap</a></p>
        </footer>

    </div>

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

Note the offline indicator. It's easy to reference an element in JavaScript that you forgot to add to the HTML. When that happens, document.getElementById() returns null, and the first line that touches it throws a TypeError that stops the entire script — so nothing works, not just that one feature. If your dashboard is completely blank, an element ID mismatch is the first thing to check.

Step 2: Add the CSS Styling

Create a style.css file and add the following:

CSS
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap');

/* ===== RESET & VARIABLES ===== */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --primary: #4A90D9;
    --primary-dark: #2C5F8A;
    --secondary: #6C63FF;
    --success: #4CAF50;
    --danger: #FF6B6B;
    --warning: #FFA94D;
    --text: #2D3436;
    --text-light: #636E72;
    --white: #FFFFFF;
    --shadow: 0 10px 30px rgba(0,0,0,0.15);
    --radius: 16px;
    --transition: all 0.3s ease;
}

body {
    font-family: 'Poppins', sans-serif;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 20px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    background-attachment: fixed;
}

/* ===== CONTAINER ===== */
.container {
    max-width: 700px;
    width: 100%;
    background: rgba(255,255,255,0.95);
    backdrop-filter: blur(10px);
    border-radius: var(--radius);
    padding: 40px;
    box-shadow: var(--shadow);
    animation: slideUp 0.6s ease;
}

@keyframes slideUp {
    from { opacity: 0; transform: translateY(40px); }
    to { opacity: 1; transform: translateY(0); }
}

/* ===== HEADER ===== */
header {
    text-align: center;
    margin-bottom: 25px;
}

header h1 {
    font-size: 2.2rem;
    font-weight: 700;
    color: var(--text);
    letter-spacing: -0.5px;
}

header .subtitle {
    color: var(--text-light);
    font-size: 0.95rem;
    margin-top: 4px;
}

/* ===== OFFLINE INDICATOR ===== */
.offline-indicator {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 10px;
    background: #FEF3C7;
    border: 1px solid var(--warning);
    border-radius: 12px;
    padding: 12px 16px;
    margin-bottom: 18px;
    color: #92400E;
    font-size: 0.85rem;
}

.offline-indicator.visible {
    display: flex;
}

/* ===== SEARCH ===== */
.search-section {
    display: flex;
    gap: 12px;
    flex-wrap: wrap;
    margin-bottom: 25px;
}

.search-box {
    flex: 1;
    display: flex;
    background: #F0F0F0;
    border: 2px solid transparent;
    border-radius: 50px;
    overflow: hidden;
    transition: var(--transition);
    min-width: 200px;
}

.search-box:focus-within {
    box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.2);
}

/* Invalid-input state, applied to the wrapper (the input itself has no border) */
.search-box.invalid {
    border-color: var(--danger);
}

#cityInput {
    flex: 1;
    padding: 14px 20px;
    border: none;
    background: transparent;
    font-size: 1rem;
    font-family: 'Poppins', sans-serif;
    color: var(--text);
    outline: none;
}

#cityInput::placeholder {
    color: var(--text-light);
}

#searchBtn {
    padding: 14px 22px;
    background: var(--primary);
    color: var(--white);
    border: none;
    cursor: pointer;
    font-size: 1rem;
    transition: var(--transition);
}

#searchBtn:hover:not(:disabled) {
    background: var(--primary-dark);
}

#searchBtn:disabled,
.location-btn:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}

.location-btn {
    padding: 14px 20px;
    background: var(--secondary);
    color: var(--white);
    border: none;
    border-radius: 50px;
    font-size: 0.9rem;
    font-weight: 500;
    font-family: 'Poppins', sans-serif;
    cursor: pointer;
    transition: var(--transition);
    display: flex;
    align-items: center;
    gap: 8px;
    white-space: nowrap;
}

.location-btn:hover:not(:disabled) {
    background: #5A52D5;
    transform: scale(1.02);
}

.location-btn:active:not(:disabled) {
    transform: scale(0.98);
}

/* ===== LOADING ===== */
.loading {
    text-align: center;
    padding: 40px 20px;
    display: none;
}

.loading.visible {
    display: block;
}

.spinner {
    width: 50px;
    height: 50px;
    border: 4px solid #F0F0F0;
    border-top: 4px solid var(--primary);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 0 auto 15px;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

.loading p {
    color: var(--text-light);
}

/* ===== WEATHER DISPLAY ===== */
.weather-display {
    animation: fadeIn 0.5s ease;
}

@keyframes fadeIn {
    from { opacity: 0; transform: translateY(20px); }
    to { opacity: 1; transform: translateY(0); }
}

.weather-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 20px;
    border-bottom: 2px solid #F0F0F0;
    margin-bottom: 20px;
    flex-wrap: wrap;
    gap: 15px;
}

.weather-header h2 {
    font-size: 1.8rem;
    font-weight: 700;
    color: var(--text);
}

.weather-header p {
    color: var(--text-light);
    font-size: 0.95rem;
}

.weather-icon {
    text-align: center;
}

.weather-icon img {
    width: 70px;
    height: 70px;
}

.weather-icon p {
    font-size: 0.85rem;
    color: var(--text-light);
    text-transform: capitalize;
}

.temperature-section {
    text-align: center;
    padding: 20px 0;
}

.temperature-section h1 {
    font-size: 4.5rem;
    font-weight: 800;
    color: var(--text);
    line-height: 1;
}

.temperature-section p {
    color: var(--text-light);
    font-size: 1.1rem;
    margin-top: 5px;
}

/* ===== WEATHER DETAILS ===== */
.weather-details {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
    gap: 15px;
    margin: 20px 0;
}

.detail-card {
    background: #F8F9FA;
    border-radius: 12px;
    padding: 16px;
    text-align: center;
    transition: var(--transition);
}

.detail-card:hover {
    transform: translateY(-3px);
    box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}

.detail-card i {
    font-size: 1.5rem;
    color: var(--primary);
    margin-bottom: 6px;
}

.detail-card span {
    display: block;
    font-size: 1.1rem;
    font-weight: 600;
    color: var(--text);
}

.detail-card p {
    font-size: 0.75rem;
    color: var(--text-light);
    margin-top: 2px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

/* ===== ADDITIONAL INFO ===== */
.additional-info {
    display: flex;
    justify-content: center;
    gap: 30px;
    padding: 15px 0;
    border-top: 2px solid #F0F0F0;
    margin-top: 10px;
    flex-wrap: wrap;
}

.additional-info div {
    display: flex;
    align-items: center;
    gap: 8px;
    color: var(--text-light);
    font-size: 0.9rem;
}

.additional-info i {
    color: var(--primary);
}

.additional-info strong {
    color: var(--text);
}

/* ===== ERROR MESSAGE ===== */
.error-message {
    background: #FEE2E2;
    border: 2px solid var(--danger);
    border-radius: 12px;
    padding: 20px;
    display: flex;
    align-items: center;
    gap: 12px;
    margin-top: 15px;
    animation: shake 0.4s ease;
}

@keyframes shake {
    0%, 100% { transform: translateX(0); }
    25% { transform: translateX(-8px); }
    75% { transform: translateX(8px); }
}

.error-message i {
    font-size: 1.5rem;
    color: var(--danger);
}

.error-message p {
    color: #991B1B;
    font-weight: 500;
}

/* ===== FOOTER ===== */
footer {
    text-align: center;
    padding-top: 20px;
    margin-top: 20px;
    border-top: 2px solid #F0F0F0;
    color: var(--text-light);
    font-size: 0.8rem;
}

footer a {
    color: var(--primary);
    text-decoration: none;
    font-weight: 500;
}

footer a:hover {
    text-decoration: underline;
}

/* ===== REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

/* ===== RESPONSIVE ===== */
@media (max-width: 600px) {
    .container { padding: 24px 16px; }
    header h1 { font-size: 1.6rem; }
    .search-section { flex-direction: column; }
    .search-box { border-radius: 12px; }
    .location-btn { justify-content: center; border-radius: 12px; }
    .weather-header { flex-direction: column; text-align: center; }
    .weather-header h2 { font-size: 1.4rem; }
    .temperature-section h1 { font-size: 3.5rem; }
    .weather-details { grid-template-columns: 1fr 1fr; }
    .additional-info { flex-direction: column; align-items: center; gap: 10px; }
}

@media (max-width: 400px) {
    .weather-details { grid-template-columns: 1fr; }
}

Step 3: Add the JavaScript Logic

Create a script.js file with the following code:

JAVASCRIPT
// ===== API CONFIGURATION =====
// 🔑 Get your free API key from: https://openweathermap.org/api
// ⚠️ This key is visible to anyone who views source. See the security note
//    above — use a server-side proxy for anything public, and never commit
//    a working key to a public repository.
const API = {
    key: 'YOUR_API_KEY',
    baseUrl: 'https://api.openweathermap.org/data/2.5/weather',
    units: 'metric'   // 'metric' = Celsius + metres/sec, 'imperial' = Fahrenheit + mph
};

// Fallback: Open-Meteo needs no API key at all.
const OPEN_METEO = {
    baseUrl: 'https://api.open-meteo.com/v1/forecast',
    geoUrl:  'https://geocoding-api.open-meteo.com/v1/search'
};

const CACHE_TTL_MS   = 5 * 60 * 1000;   // 5 minutes
const CACHE_MAX_KEYS = 30;              // keep localStorage from growing forever

const hasApiKey = () => Boolean(API.key) && API.key !== 'YOUR_OPENWEATHER_API_KEY';

// ===== DOM ELEMENTS =====
const cityInput         = document.getElementById('cityInput');
const searchBox         = document.getElementById('searchBox');
const searchBtn         = document.getElementById('searchBtn');
const locationBtn       = document.getElementById('locationBtn');
const weatherDisplay    = document.getElementById('weatherDisplay');
const loading           = document.getElementById('loading');
const errorMessage      = document.getElementById('errorMessage');
const errorText         = document.getElementById('errorText');
const cityName          = document.getElementById('cityName');
const countryName       = document.getElementById('countryName');
const temperature       = document.getElementById('temperature');
const feelsLike         = document.getElementById('feelsLike');
const weatherIcon       = document.getElementById('weatherIcon');
const weatherDescription= document.getElementById('weatherDescription');
const humidity          = document.getElementById('humidity');
const windSpeed         = document.getElementById('windSpeed');
const cloudCover        = document.getElementById('cloudCover');
const visibility        = document.getElementById('visibility');
const pressure          = document.getElementById('pressure');
const updatedAt         = document.getElementById('updatedAt');
const apiSource         = document.getElementById('apiSource');
const offlineIndicator  = document.getElementById('offlineIndicator');

// ===== UNIT HELPERS =====
// OpenWeather returns m/s under 'metric' and mph under 'imperial'.
// Open-Meteo we request directly in km/h. Normalise everything to km/h.
const msToKmh  = ms  => ms * 3.6;
const mphToKmh = mph => mph * 1.609344;

// ===== CACHE =====
// Results are cached for five minutes. Weather doesn't change second to
// second, and the free tier is capped at 60 calls a minute.
let weatherCache = {};

try {
    const saved = localStorage.getItem('weatherCache');
    if (saved) weatherCache = JSON.parse(saved);
} catch (e) {
    weatherCache = {};   // corrupt or unavailable storage: start clean
}

function cacheKeyForCity(city) {
    return `city:${city.toLowerCase().trim()}`;
}

// Round coordinates so nearby readings share a cache entry.
// Without rounding, no two geolocation calls ever produce the same key.
function cacheKeyForCoords(lat, lon) {
    return `geo:${lat.toFixed(2)},${lon.toFixed(2)}`;
}

function getCachedWeather(key) {
    const entry = weatherCache[key];
    if (entry && (Date.now() - entry.timestamp) < CACHE_TTL_MS) {
        return entry.data;
    }
    return null;
}

function setCachedWeather(key, data) {
    weatherCache[key] = { data, timestamp: Date.now() };

    // Drop expired entries, then trim to the newest CACHE_MAX_KEYS
    const now = Date.now();
    const live = Object.entries(weatherCache)
        .filter(([, v]) => (now - v.timestamp) < CACHE_TTL_MS)
        .sort((a, b) => b[1].timestamp - a[1].timestamp)
        .slice(0, CACHE_MAX_KEYS);

    weatherCache = Object.fromEntries(live);

    try {
        localStorage.setItem('weatherCache', JSON.stringify(weatherCache));
    } catch (e) {
        // Storage full or blocked (private browsing). Caching is optional.
    }
}

// ===== UI STATE HELPERS =====
let inFlight = false;

function setBusy(state) {
    inFlight = state;
    searchBtn.disabled = state;
    locationBtn.disabled = state;
}

function showLoading() {
    loading.classList.add('visible');
    weatherDisplay.style.display = 'none';
    errorMessage.style.display = 'none';
}

function hideLoading() {
    loading.classList.remove('visible');
}

function showError(message) {
    errorMessage.style.display = 'flex';
    errorText.textContent = message;
    weatherDisplay.style.display = 'none';
}

function hideError() {
    errorMessage.style.display = 'none';
}

function flagInvalidInput() {
    searchBox.classList.add('invalid');
    cityInput.focus();
    setTimeout(() => searchBox.classList.remove('invalid'), 1200);
}

function getWeatherIconUrl(iconCode) {
    return `https://openweathermap.org/img/wn/${iconCode}@4x.png`;
}

function nowTimeString() {
    return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

function isOnline() {
    const online = navigator.onLine;
    offlineIndicator.classList.toggle('visible', !online);
    return online;
}

// ===== OPEN-METEO WEATHER CODE MAP =====
const WEATHER_CODES = {
    0:  { desc: 'Clear sky',                   icon: '01d' },
    1:  { desc: 'Mainly clear',                icon: '01d' },
    2:  { desc: 'Partly cloudy',               icon: '02d' },
    3:  { desc: 'Overcast',                    icon: '03d' },
    45: { desc: 'Fog',                         icon: '50d' },
    48: { desc: 'Depositing rime fog',         icon: '50d' },
    51: { desc: 'Light drizzle',               icon: '09d' },
    53: { desc: 'Moderate drizzle',            icon: '09d' },
    55: { desc: 'Dense drizzle',               icon: '09d' },
    56: { desc: 'Light freezing drizzle',      icon: '09d' },
    57: { desc: 'Dense freezing drizzle',      icon: '09d' },
    61: { desc: 'Slight rain',                 icon: '10d' },
    63: { desc: 'Moderate rain',               icon: '10d' },
    65: { desc: 'Heavy rain',                  icon: '10d' },
    66: { desc: 'Light freezing rain',         icon: '13d' },
    67: { desc: 'Heavy freezing rain',         icon: '13d' },
    71: { desc: 'Slight snow',                 icon: '13d' },
    73: { desc: 'Moderate snow',               icon: '13d' },
    75: { desc: 'Heavy snow',                  icon: '13d' },
    77: { desc: 'Snow grains',                 icon: '13d' },
    80: { desc: 'Slight rain showers',         icon: '09d' },
    81: { desc: 'Moderate rain showers',       icon: '09d' },
    82: { desc: 'Violent rain showers',        icon: '09d' },
    85: { desc: 'Slight snow showers',         icon: '13d' },
    86: { desc: 'Heavy snow showers',          icon: '13d' },
    95: { desc: 'Thunderstorm',                icon: '11d' },
    96: { desc: 'Thunderstorm with hail',      icon: '11d' },
    99: { desc: 'Thunderstorm with heavy hail',icon: '11d' }
};

// ===== NORMALISED WEATHER SHAPE =====
// Both providers get mapped into this one object so displayWeather()
// never has to care which API the data came from.
function fromOpenWeather(data) {
    const raw = data.wind?.speed;
    const windKmh = raw == null
        ? null
        : (API.units === 'imperial' ? mphToKmh(raw) : msToKmh(raw));

    return {
        city:        data.name || 'Unknown',
        country:     data.sys?.country || '',
        temp:        data.main?.temp,
        feelsLike:   data.main?.feels_like,
        humidity:    data.main?.humidity,
        pressure:    data.main?.pressure,
        windKmh,
        clouds:      data.clouds?.all,
        visibilityM: data.visibility,
        description: data.weather?.[0]?.description || '',
        icon:        data.weather?.[0]?.icon || '01d',
        source:      'OpenWeatherMap'
    };
}

function fromOpenMeteo(data, city, country) {
    const c = data.current || {};
    const info = WEATHER_CODES[c.weather_code] || { desc: 'Unknown', icon: '01d' };

    return {
        city:        city || 'Unknown',
        country:     country || '',
        temp:        c.temperature_2m,
        feelsLike:   c.apparent_temperature,
        humidity:    c.relative_humidity_2m,
        pressure:    c.surface_pressure == null ? null : Math.round(c.surface_pressure),
        windKmh:     c.wind_speed_10m,   // already requested in km/h
        clouds:      c.cloud_cover,
        // ?? not || — a visibility of 0 (dense fog) is a real reading
        visibilityM: c.visibility ?? null,
        description: info.desc,
        icon:        info.icon,
        source:      'Open-Meteo'
    };
}

// ===== DISPLAY =====
function displayWeather(w) {
    hideError();
    weatherDisplay.style.display = 'block';

    cityName.textContent    = w.city;
    countryName.textContent = w.country;

    temperature.textContent = w.temp != null ? `${Math.round(w.temp)}°C` : '--°C';
    feelsLike.textContent   = w.feelsLike != null
        ? `Feels like ${Math.round(w.feelsLike)}°C`
        : 'Feels like --°C';

    weatherIcon.src = getWeatherIconUrl(w.icon);
    weatherIcon.alt = w.description || 'Weather condition';
    weatherDescription.textContent = w.description;

    humidity.textContent   = w.humidity != null ? `${w.humidity}%` : '--%';
    windSpeed.textContent  = w.windKmh  != null ? `${Math.round(w.windKmh)} km/h` : '-- km/h';
    cloudCover.textContent = w.clouds   != null ? `${w.clouds}%` : '--%';
    visibility.textContent = w.visibilityM != null
        ? `${(w.visibilityM / 1000).toFixed(1)} km`
        : 'N/A';
    pressure.textContent   = w.pressure != null ? `${w.pressure} hPa` : '-- hPa';
    updatedAt.textContent  = nowTimeString();

    apiSource.textContent = w.source;
    apiSource.href = w.source === 'OpenWeatherMap'
        ? 'https://openweathermap.org'
        : 'https://open-meteo.com';
}

// ===== FETCH: OPEN-METEO BY COORDINATES =====
async function fetchOpenMeteo(lat, lon) {
    const params = new URLSearchParams({
        latitude:  lat,
        longitude: lon,
        current: [
            'temperature_2m',
            'apparent_temperature',
            'relative_humidity_2m',
            'surface_pressure',
            'cloud_cover',
            'wind_speed_10m',
            'visibility',
            'weather_code'
        ].join(','),
        temperature_unit: 'celsius',
        wind_speed_unit:  'kmh',
        timezone: 'auto'
    });

    const res = await fetch(`${OPEN_METEO.baseUrl}?${params}`);
    if (!res.ok) throw new Error('Weather service is unavailable. Please try again.');

    const data = await res.json();
    if (!data.current) throw new Error('Could not read weather data from the response.');
    return data;
}

// ===== FETCH: GEOCODE A CITY NAME =====
async function geocodeCity(city) {
    const params = new URLSearchParams({ name: city, count: 1, language: 'en', format: 'json' });
    const res = await fetch(`${OPEN_METEO.geoUrl}?${params}`);
    if (!res.ok) throw new Error('Could not look up that location. Please try again.');

    const data = await res.json();
    if (!data.results || data.results.length === 0) {
        throw new Error(`City "${city}" not found. Try adding the country, e.g. "Springfield, US".`);
    }
    return data.results[0];
}

// ===== MAIN: FETCH BY CITY =====
async function fetchWeatherByCity(city) {
    if (!city || !city.trim()) {
        showError('Please enter a city name.');
        flagInvalidInput();
        return;
    }
    if (inFlight) return;

    const trimmed = city.trim();
    const key = cacheKeyForCity(trimmed);

    const cached = getCachedWeather(key);
    if (cached) {
        displayWeather(cached);
        return;
    }

    if (!isOnline()) {
        showError('You appear to be offline, and this city is not cached.');
        return;
    }

    setBusy(true);
    showLoading();
    hideError();

    try {
        if (hasApiKey()) {
            const params = new URLSearchParams({
                q: trimmed,
                appid: API.key,
                units: API.units
            });
            const res = await fetch(`${API.baseUrl}?${params}`);

            if (!res.ok) {
                if (res.status === 401) throw new Error('Invalid API key. Check your OpenWeather key — new keys can take up to two hours to activate.');
                if (res.status === 404) throw new Error(`City "${trimmed}" not found. Please check the spelling.`);
                if (res.status === 429) throw new Error('Rate limit reached. Wait a minute and try again.');
                throw new Error('Failed to fetch weather data. Please try again.');
            }

            const weather = fromOpenWeather(await res.json());
            setCachedWeather(key, weather);
            displayWeather(weather);
            return;
        }

        // No key configured — fall back to Open-Meteo
        const place   = await geocodeCity(trimmed);
        const data    = await fetchOpenMeteo(place.latitude, place.longitude);
        const weather = fromOpenMeteo(data, place.name, place.country);
        setCachedWeather(key, weather);
        displayWeather(weather);

    } catch (error) {
        console.error('Weather fetch error:', error);
        showError(error.message || 'Something went wrong. Please try again.');
    } finally {
        hideLoading();
        setBusy(false);
    }
}

// ===== GEOLOCATION (promisified so try/finally actually works) =====
function getPosition() {
    return new Promise((resolve, reject) => {
        if (!navigator.geolocation) {
            reject(new Error('Geolocation is not supported by your browser.'));
            return;
        }
        navigator.geolocation.getCurrentPosition(resolve, reject, {
            timeout: 10000,
            maximumAge: 60000
        });
    });
}

function geolocationErrorMessage(err) {
    switch (err.code) {
        case 1: return 'Location permission denied. Allow access, or search by city name instead.';
        case 2: return 'Your location is unavailable right now. Try searching by city name.';
        case 3: return 'Location request timed out. Try again, or search by city name.';
        default: return err.message || 'Could not get your location.';
    }
}

async function fetchWeatherByLocation() {
    if (inFlight) return;

    setBusy(true);
    showLoading();
    hideError();

    try {
        const pos = await getPosition();
        const { latitude, longitude } = pos.coords;
        const key = cacheKeyForCoords(latitude, longitude);

        const cached = getCachedWeather(key);
        if (cached) {
            displayWeather(cached);
            return;
        }

        if (!isOnline()) {
            showError('You appear to be offline. Reconnect and try again.');
            return;
        }

        if (hasApiKey()) {
            const params = new URLSearchParams({
                lat: latitude,
                lon: longitude,
                appid: API.key,
                units: API.units
            });
            const res = await fetch(`${API.baseUrl}?${params}`);
            if (!res.ok) throw new Error('Failed to fetch weather for your location.');

            const weather = fromOpenWeather(await res.json());
            setCachedWeather(key, weather);
            displayWeather(weather);
            return;
        }

        const data    = await fetchOpenMeteo(latitude, longitude);
        const label   = `${latitude.toFixed(2)}, ${longitude.toFixed(2)}`;
        const weather = fromOpenMeteo(data, 'Your location', label);
        setCachedWeather(key, weather);
        displayWeather(weather);

    } catch (error) {
        console.error('Location weather error:', error);
        const msg = (typeof error.code === 'number')
            ? geolocationErrorMessage(error)
            : (error.message || 'Failed to get weather for your location.');
        showError(msg);
    } finally {
        hideLoading();
        setBusy(false);
    }
}

// ===== EVENT LISTENERS =====
searchBtn.addEventListener('click', () => {
    const city = cityInput.value.trim();
    if (city) {
        fetchWeatherByCity(city);
    } else {
        flagInvalidInput();
    }
});

cityInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
        e.preventDefault();
        searchBtn.click();
    }
});

locationBtn.addEventListener('click', fetchWeatherByLocation);

window.addEventListener('online',  isOnline);
window.addEventListener('offline', isOnline);

// ===== INITIALISATION =====
if (!hasApiKey()) {
    console.warn('OpenWeather API key not configured — using Open-Meteo fallback.');
    console.info('Get a free key at https://openweathermap.org/api (1,000 calls/day).');
    apiSource.textContent = 'Open-Meteo';
}

isOnline();
fetchWeatherByCity('London');
cityInput.focus();

How This Weather Dashboard Works

A single normalised data shape. The most important design decision here is that neither API's raw response reaches the display function. fromOpenWeather() and fromOpenMeteo() each map their provider's response into one common object, so displayWeather() has exactly one shape to handle. Without this, unit bugs creep in fast, because the two providers disagree about what they return.

Wind speed is the classic trap. OpenWeather returns metres per second when you request units=metric, not kilometres per hour. If you label that value "km/h" without converting, every reading is wrong by a factor of about 3.6 — and it looks plausible enough that nobody notices. The conversion happens in fromOpenWeather(); Open-Meteo is asked for km/h directly via wind_speed_unit.

Open-Meteo's parameter is current, not current_weather. The older current_weather=true flag returns only temperature, wind, and a weather code — no humidity, pressure, or cloud cover. Requesting the fields you actually want under current= is what makes those cards show real numbers instead of blanks.

Missing data says so. Where a value genuinely isn't available, the UI shows N/A rather than a hardcoded stand-in. Note the use of ?? rather than || for visibility: in dense fog, visibility can legitimately be 0, and || would treat that real reading as missing.

Caching keeps you under the rate limit. Results are stored in localStorage for five minutes, keyed by city name or by coordinates rounded to two decimal places. The rounding matters: raw GPS coordinates carry enough precision that two readings from the same spot never produce an identical key, so an unrounded cache would never register a single hit.

Geolocation is promisified. navigator.geolocation.getCurrentPosition() is callback-based, so wrapping it in a promise lets the whole flow use the same try/catch/finally as the search path. That single finally is what guarantees the spinner stops and the buttons re-enable, no matter which branch failed.

Concurrent requests are blocked. The inFlight flag and disabled buttons stop a user from firing five requests by mashing the search button. There is deliberately no debounced search-as-you-type here: typing "Bathinda" would fire a request for "Bat", then "Bath", then "Bathi", and so on, which is a fast route to a 429 on a 60-calls-per-minute tier.

Final Output

Once all three files are in place, the dashboard will:

  • Display current weather conditions for any city
  • Show temperature, humidity, wind speed, cloud cover, visibility and pressure
  • Work with or without an API key, falling back to Open-Meteo automatically
  • Support geolocation for local weather
  • Serve repeat searches from cache for five minutes
  • Detect when you go offline and say so
  • Report errors clearly instead of failing silently

Getting Your API Key: Step-by-Step (OpenWeatherMap)

  1. Go to OpenWeatherMap: Visit openweathermap.org/api
  2. Sign up: Click "Sign Up" and create a free account
  3. Verify your email: Check your inbox for a verification link
  4. Get your API key: After logging in, open the "API keys" tab in your account dashboard
  5. Wait for activation: New keys typically activate within 10 minutes to 2 hours. A 401 response before then is normal, not a mistake on your end.
  6. Add it to the code: Set the key value in script.js to your own key

Quick Comparison of Free Weather APIs

API Free Tier API Key Required Best For
OpenWeatherMap 1,000 calls/day, 60/min Yes Most popular, well-documented
Open-Meteo Free for non-commercial use No No registration, ideal for beginners
WeatherAPI.com 1,000,000 calls/month Yes High free-tier limits
Visual Crossing 1,000 records/day Yes Historical weather data

Check each provider's current terms before using them in a commercial project — free-tier limits change, and "free" usually means non-commercial.

Customization Guide

1. Change Temperature Units

JAVASCRIPT
// In script.js
const API = {
    key: 'YOUR_API_KEY',
    baseUrl: 'https://api.openweathermap.org/data/2.5/weather',
    units: 'imperial'   // Fahrenheit
};

fromOpenWeather() already handles the mph-to-km/h conversion that comes with imperial. If you also want the displayed temperature in Fahrenheit, change the °C literals in displayWeather() and set temperature_unit: 'fahrenheit' in the Open-Meteo params.

2. Adjust the Cache Duration

JAVASCRIPT
const CACHE_TTL_MS = 15 * 60 * 1000;   // 15 minutes instead of 5

3. Change the Default City

JAVASCRIPT
// At the bottom of script.js
fetchWeatherByCity('New York');

4. Add a Background Based on Conditions

CSS
.weather-display.clear-sky {
    background: linear-gradient(135deg, #FDBB2D, #FDC830);
}
.weather-display.rainy {
    background: linear-gradient(135deg, #2C3E50, #3498DB);
}

Troubleshooting Guide

Issue Solution
Page is completely blank, nothing works An element ID in the JS doesn't exist in the HTML. getElementById returned null and the first access threw, stopping the whole script. Check the console for a TypeError.
401 Unauthorized New keys take up to two hours to activate. Also check for a stray space when pasting.
City not found Use the full name and add a country where the name is ambiguous, e.g. "Springfield, US"
Rate limit exceeded (429) The free tier caps at 60 calls/minute. Avoid search-as-you-type, which fires a call per keystroke.
Wind speed looks far too low You're displaying OpenWeather's m/s value as km/h. Multiply by 3.6.
Humidity and pressure show as blank In the Open-Meteo call, use current= with named fields, not current_weather=true
Geolocation not working Geolocation requires HTTPS (or localhost) in modern browsers. Check permissions too.
Spinner never stops Make sure hideLoading() is in a finally block, not only on the success path
Stale data after the weather changes Results are cached for five minutes. Lower CACHE_TTL_MS or clear localStorage.

Future Enhancements

  • 5-day weather forecast
  • A server-side proxy so the API key is never exposed
  • Air quality index (AQI) data
  • Saved favourites alongside the cache
  • Weather maps visualisation
  • Dark/light theme toggle
  • Hourly forecast breakdown
  • Multiple language support

Conclusion

This project covers the core skills behind most real-world front-end apps: fetching live data from a REST API, handling asynchronous requests with async/await, normalising responses from providers that disagree about units and field names, caching sensibly, managing errors gracefully, and building a responsive layout that adapts to any screen.

The unit-conversion and normalisation work is the part worth internalising. Any app that talks to more than one data source will hit the same problem, and it's the kind of bug that ships quietly because wrong numbers still look like numbers.

Technologies Used

HTML5 CSS3 JavaScript

Live Output

See the project in action below. This is the live preview of the code you just learned.

Output Preview