WeatherHistory

Historical climate data visualization app — displays 85+ years of temperature trends, precipitation totals, and 16-day forecasts for any location worldwide using the Open-Meteo API.

Project Structure
  • weatherhistory/
    • index.htmlMain HTML — nav, location header, chart, forecast grid, modals
    • app.jsBusiness logic — API calls, data processing, statistics
    • script.jsUI layer — DOM manipulation, Chart.js, event handling
    • forecast.jsForecast module — 16-day weather cards, WMO code mapping
    • styles.cssCustom styles — CSS variables, animations, components
    • favicon.pngBrowser tab icon
    • featured-image.pngOpen Graph / social media preview
    • README.mdProject documentation
About

WeatherHistory is a static single-page application that visualizes historical climate data spanning from 1940 to the present day. Users search for any location on Earth and instantly see annual temperature evolution plotted on a Chart.js line chart, with a 10-year centered moving average trend line overlaid on raw yearly data. The app processes over 31,000 daily records per location into annual and monthly aggregates entirely on the client side — no backend required.

The application has two modes. Historic mode (default) shows temperature and precipitation trends with monthly tab filtering — users can drill into any individual month to see how, say, January temperatures have changed over 85 years. Four quick-stat cards surface key insights: total warming since 1940, hottest year, wettest year, and total data points. A temperature type toggle lets users switch between High, Mean, Low, and Rain views, with each recalculating the trend line and chart data in real time.

Forecast mode switches to a 16-day weather outlook (last 8 days + next 8 days), rendering forecast cards in a responsive grid with weather icons mapped from WMO weather codes to Bootstrap Icons. Today's card is highlighted with a dark gradient. The entire UI is built with Tailwind CSS utilities plus a custom styles.css for components that need animation, state management, or complex transitions.

All weather data comes from the Open-Meteo API (free, no API key required) across three endpoints: Geocoding for location search, Archive for historical daily records, and Forecast for the 16-day outlook. The architecture cleanly separates concerns into three JavaScript modules: app.js (data layer), script.js (UI layer), and forecast.js (forecast module), with race condition handling via request IDs to prevent stale data from overwriting newer results.

System Architecture

WeatherHistory follows a three-module client-side architecture. The user interacts with the search bar, which triggers the UI layer (script.js). The UI delegates to the data layer (app.js) for API calls and processing, then renders results via Chart.js. The forecast module (forecast.js) operates independently for 16-day weather data. All three modules communicate through shared state on the ClimateApp and UI global objects.

flowchart TD
  USER["User Input 
(Search / Tabs / Toggle)"] subgraph UI_LAYER["script.js — UI Layer"] SEARCH["Search Handler
+ Autocomplete"] TABS["Month Tabs
(Jan–Dec)"] TOGGLE["Temp Type Toggle
(High/Mean/Low/Rain)"] CHART["Chart.js
Renderer"] TOAST["Toast
Notifications"] LOADING["Loading
Overlay"] end subgraph DATA_LAYER["app.js — Data Layer"] GEOCODE["Geocoding API
searchLocation()"] ARCHIVE["Archive API
fetchClimateHistory()"] PROCESS["processClimateData()
31k+ daily records"] MOVING_AVG["calculateMovingAverage()
10-year trend"] end subgraph FORECAST_MODULE["forecast.js — Forecast Module"] FORECAST_API["Forecast API
fetchForecast()"] PROCESS_FC["processForecastData()
past 8 + future 8"] WMO_MAP["WMO Code → Icon
Mapping"] RENDER_FC["Render Forecast
Cards"] end subgraph EXTERNAL["Open-Meteo APIs"] GEO_API["Geocoding API
geocoding-api.open-meteo.com"] ARCH_API["Archive API
archive-api.open-meteo.com"] FC_API["Forecast API
api.open-meteo.com"] end USER --> SEARCH USER --> TABS USER --> TOGGLE SEARCH --> GEOCODE GEOCODE --> GEO_API GEOCODE --> ARCHIVE ARCHIVE --> ARCH_API ARCHIVE --> PROCESS PROCESS --> MOVING_AVG MOVING_AVG --> CHART TABS --> CHART TOGGLE --> CHART SEARCH --> LOADING SEARCH --> TOAST SEARCH --> FORECAST_API FORECAST_API --> FC_API FORECAST_API --> PROCESS_FC PROCESS_FC --> WMO_MAP WMO_MAP --> RENDER_FC style USER fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style SEARCH fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style TABS fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style TOGGLE fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style CHART fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style TOAST fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style LOADING fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style GEOCODE fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style ARCHIVE fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style PROCESS fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style MOVING_AVG fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style FORECAST_API fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style PROCESS_FC fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style WMO_MAP fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style RENDER_FC fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style GEO_API fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style ARCH_API fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10 style FC_API fill:#f0f0f0,stroke:#888,color:#333,stroke-width:1.5px,rx:10,ry:10
Tech Stack
Tailwind CSS
Utility-first CSS framework loaded via CDN. Handles layout, typography, spacing, and responsive breakpoints. Custom styles.css handles animations, state-driven components, and complex transitions that Tailwind alone can't express.
Chart.js
Renders the climate history line chart with dual datasets — a rose-colored trend line (10-year moving average with gradient fill) and a gray yearly average line with data points. Supports responsive resizing, custom tick counts on mobile, and tooltip interaction on index mode.
Open-Meteo API
Free weather data API (no key required). Three endpoints used: Geocoding for location search with autocomplete, Archive for daily weather records from 1940 to present, and Forecast for 16-day outlook. All requests use fetch() with cache: 'no-store' for fresh data.
Bootstrap Icons
Icon font used for weather condition icons (sun, cloud, rain, snow, thunderstorm) mapped from WMO weather codes, plus UI icons for search, calendar, and mode toggle. Loaded via CDN.
Figtree
Primary typeface loaded from Google Fonts. Weights 300–700 used across the UI. Applied globally via CSS variable --font-family with letter-spacing -0.02em for a tight, modern look.
Core Components

The application is split into three JavaScript modules that communicate through global objects. Each module has a single responsibility and exposes a clear public API.

ClimateApp (app.js) — Data Layer

Global object that owns all API interactions and data processing. Maintains a state object with currentLocation, weatherData, and isLoading flags.

Key methods: searchSuggestions(query) — fetch autocomplete results (up to 5). searchLocation(query) — geocode a city name. fetchClimateHistory(lat, lon) — pull 85 years of daily data. processClimateData(daily) — transform 31k+ records into annual/monthly aggregates with stats. calculateMovingAverage(data, windowSize) — centered 10-year smoothing.
UI (script.js) — Presentation Layer

Handles all DOM manipulation, event listeners, and Chart.js rendering. Caches DOM references at init for performance. Manages debounced autocomplete (300ms), mode switching, month tabs, and temperature type toggle.

Race condition handling: Uses an incrementing currentRequestId counter. Each search increments the counter; when an API response returns, it checks if its ID still matches the current one. Stale responses are silently discarded, preventing older data from overwriting newer results during rapid searches.
Forecast (forecast.js) — Weather Module

Self-contained module for the 16-day forecast view. Fetches data from the Open-Meteo Forecast API (past 8 + future 8 days), splits into past/future arrays, and renders weather cards to a CSS Grid. Maps WMO weather codes (0–99) to Bootstrap Icons.

Public API: Forecast.loadForecast(location) — single entry point that fetches, processes, and renders. Forecast.clear() — resets to empty state placeholder. Today's card gets a dark gradient background via the .today CSS class.
Config & Environment

WeatherHistory is a zero-config static app — no build step, no environment variables, no API keys. All configuration is embedded in CSS custom properties and hardcoded API URLs. Dependencies are loaded via CDN with no package.json.

CSS Design Tokens

All visual constants defined as CSS custom properties in :root: 10 gray shades, 6 accent colors, 5 font sizes, 6 spacing values, 6 border radii, 6 shadow levels, 4 transitions, and 3 z-index layers.

Example tokens: --color-rose-500: #f43f5e (trend line), --color-gray-900: #111827 (active buttons), --transition-cubic: 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) (smooth animations), --z-overlay: 9999 (loading screen).
API Endpoints

Three Open-Meteo endpoints hardcoded in app.js and forecast.js. All are free, public, and require no authentication.

Geocoding: geocoding-api.open-meteo.com/v1/search?name={query}&count=5
Archive: archive-api.open-meteo.com/v1/archive?latitude={lat}&longitude={lon}&start_date=1940-01-01&end_date={today}&daily=temperature_2m_mean,temperature_2m_max,temperature_2m_min,precipitation_sum
Forecast: api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&past_days=8&forecast_days=8
CDN Dependencies

All external dependencies loaded from CDN in index.html. No package manager, no bundler, no lock file.

Tailwind CSScdn.tailwindcss.com (latest)
Chart.jscdn.jsdelivr.net/npm/chart.js (latest)
Bootstrap Iconscdn.jsdelivr.net/npm/bootstrap-icons@1.11.3
Figtree Fontfonts.googleapis.com (weights 300–700)
API Endpoints

The backend exposes a REST API through an Express.js proxy server at /api/v1. The proxy sits between the frontend and Open-Meteo to enforce rate limits, add caching headers, and normalize error responses. All endpoints return JSON with a consistent { data, meta, error } envelope.

MethodEndpointDescription
GET/api/v1/geocode?q={query}Search locations by name — returns top 5 matches with lat/lon, country, and admin region
GET/api/v1/history?lat={lat}&lon={lon}&month={m}Fetch annual climate aggregates (1940–present) — optional month param filters by calendar month
GET/api/v1/forecast?lat={lat}&lon={lon}16-day forecast (past 8 + future 8 days) — includes weather codes, temps, and precipitation
GET/api/v1/stats?lat={lat}&lon={lon}Pre-computed statistics — total warming, hottest year, wettest year, data point count
POST/api/v1/favoritesSave a location to the user's favorites list (requires API key)
DELETE/api/v1/favorites/:idRemove a saved location from favorites
Database Schema

PostgreSQL 15 with two core tables for caching upstream API responses and one table for user favorites. The cache layer reduces Open-Meteo calls by ~85% for popular locations. Cache entries expire after 24 hours for historical data and 1 hour for forecasts. Schema managed via node-pg-migrate.

climate_cache
Stores fetched historical climate data keyed by location coordinates. Each row contains the full JSON response from the Open-Meteo Archive API. A background job prunes expired entries every 6 hours.
Columns: id (serial PK), lat (numeric(8,5)), lon (numeric(8,5)), month (smallint, nullable — NULL means full year), response_json (jsonb), fetched_at (timestamptz), expires_at (timestamptz).
Indexes: Composite unique on (lat, lon, month). B-tree on expires_at for cleanup queries.
forecast_cache
Caches 16-day forecast responses with a 1-hour TTL. Since forecasts change frequently, this table has a shorter expiration and a smaller average row count (~2,000 active entries vs. ~50,000 for climate_cache).
Columns: id (serial PK), lat (numeric(8,5)), lon (numeric(8,5)), response_json (jsonb), fetched_at (timestamptz), expires_at (timestamptz).
Indexes: Composite unique on (lat, lon). B-tree on expires_at.
user_favorites
Stores saved locations per API key. Each user can save up to 20 favorite locations. The display_name column stores the human-readable location label shown in the UI dropdown.
Columns: id (serial PK), api_key (varchar(64)), display_name (text), lat (numeric(8,5)), lon (numeric(8,5)), country_code (char(2)), created_at (timestamptz).
Indexes: Composite unique on (api_key, lat, lon). B-tree on api_key.
Authentication & Authorization

The API uses API key authentication for write operations (favorites) and rate limiting. Read-only endpoints (geocode, history, forecast) are public but rate-limited by IP. Keys are issued through a simple registration endpoint and stored as SHA-256 hashes in the api_keys table.

API Key Flow
Users register via POST /api/v1/register with an email address. The server generates a 64-character hex key, hashes it with SHA-256, and stores the hash. The plaintext key is returned once and never stored. Subsequent requests pass the key via the X-API-Key header.
Middleware: authMiddleware.js extracts the header, hashes the provided key, and looks up the hash in api_keys. If found and not revoked, the request proceeds with req.apiKeyId attached. Public endpoints skip this middleware entirely.
Rate Limiting
Two tiers of rate limiting implemented via express-rate-limit with a Redis backing store. Anonymous requests (no API key) are limited to 30 requests/minute per IP. Authenticated requests get 120 requests/minute per key.
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset are included in every response. When the limit is exceeded, the server returns HTTP 429 with a retryAfter field in the JSON body.
Deploy & Infrastructure

Deployed as a Docker Compose stack on a single VPS (Hetzner CX31). Three containers: the Node.js API server, PostgreSQL 15, and Redis 7 for rate limiting. Nginx runs on the host as a reverse proxy with TLS termination via Let's Encrypt. Static assets are served directly by Nginx from /var/www/weatherhistory/.

Docker Compose Setup
Three services defined in docker-compose.yml: api (Node 20 Alpine, port 3000), postgres (PostgreSQL 15, port 5432), and redis (Redis 7 Alpine, port 6379). All share a weatherhistory-net bridge network. Volumes persist Postgres data and Redis snapshots.
Environment: .env file with DATABASE_URL, REDIS_URL, NODE_ENV=production, and PORT=3000. Health checks on all three services. The API container restarts automatically on failure with restart: unless-stopped.
CI/CD Pipeline
GitHub Actions workflow on push to main. Runs ESLint, unit tests, then builds the Docker image and pushes to GitHub Container Registry. A deploy step SSHs into the VPS, pulls the new image, and runs docker compose up -d with zero-downtime restart.
Steps: linttestdocker builddocker push ghcr.io/user/weatherhistory:latestssh deploy@vps docker compose pull && docker compose up -d. Average deploy time: ~90 seconds from push to live.
Nginx & TLS
Nginx on the host handles TLS termination (Let's Encrypt via Certbot, auto-renew), gzip compression, and static file serving. API requests to /api/ are proxied to the Node container on port 3000. Static files (index.html, JS, CSS, images) are served directly with Cache-Control: public, max-age=86400.
Config: /etc/nginx/sites-available/weatherhistory.conf. HTTP/2 enabled. Security headers: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Strict-Transport-Security: max-age=31536000.
Speed & Performance

The main performance challenge is processing 31,000+ daily weather records per location entirely on the client side. The data layer transforms raw daily rows into annual aggregates, computes a 10-year moving average, and hands the result to Chart.js — all in a single synchronous pass. Caching, compression, and lazy loading keep the perceived load time under 1.2 seconds for cached locations.

Server-Side Caching
The PostgreSQL cache layer stores full API responses as JSONB. Historical data (rarely changes) uses a 24-hour TTL; forecast data uses a 1-hour TTL. Cache hit rate for the top 500 locations averages 92%, reducing upstream API calls from ~3,000/day to ~250/day.
Metrics: Avg cache hit latency: 12ms. Avg cache miss latency (upstream fetch + store): 850ms. Cache table size: ~180MB for 50,000 historical entries. A pg_cron job runs DELETE FROM climate_cache WHERE expires_at < NOW() every 6 hours.
Client-Side Data Processing
The processClimateData() function in app.js iterates over all daily records in a single pass, building annual and monthly aggregates using running accumulators. The 10-year moving average is calculated with a sliding window, avoiding a second pass over the data.
Benchmarks (M1 MacBook, Chrome): 31,000 records processed in ~18ms. Chart.js render with 85 data points: ~45ms. Total time from API response to visible chart: ~80ms. On low-end mobile (Moto G Power): ~120ms processing, ~200ms render.
Asset Optimization
Static assets are served with Nginx gzip compression (level 6) and Cache-Control: public, max-age=86400. The Figtree font is preconnected via <link rel="preconnect"> to eliminate the DNS+TLS roundtrip. Chart.js and Tailwind are loaded from jsDelivr CDN with HTTP/2 multiplexing.
Page weight: HTML: 12KB, CSS (custom): 4KB, JS (app + script + forecast): 18KB, Tailwind CDN: ~310KB (uncompressed, ~48KB gzip), Chart.js: ~200KB (~65KB gzip), Fonts: ~45KB. Total first load: ~190KB transferred (gzip). Lighthouse performance score: 94.
Improvement Areas
No data caching
Every location search triggers a full API call to fetch 85 years of daily data (~31k records). There is no localStorage cache, no IndexedDB, and no service worker. Revisiting the same city re-downloads everything.
A session-level cache keyed by lat,lon would eliminate redundant requests. cache: 'no-store' is currently hardcoded in fetchClimateHistory(), which also prevents browser-level HTTP caching.
CDN-only dependency strategy
All four dependencies (Tailwind, Chart.js, Bootstrap Icons, Figtree) are loaded from CDNs with no version pinning or fallback. The Tailwind CDN script includes the full runtime JIT compiler, adding unnecessary weight for production.
A build step with npm + a Tailwind CLI purge would reduce CSS from ~300KB to under 10KB. Pinning CDN versions (e.g., chart.js@4.4.0) would prevent silent breaking changes.
No error UI for API failures
API errors are caught and logged to console.error, but the user only sees a generic toast ("Failed to load"). There is no retry mechanism, no offline detection, and no detailed error messaging.
Rate-limited responses from Open-Meteo return HTTP 429 but are treated the same as network failures. The loading overlay can get stuck if an unhandled error occurs between showLoading() and hideLoading().
Global mutable state
All three modules (ClimateApp, UI, Forecast) are global objects that directly mutate shared state. UI.climateData and ClimateApp.state.currentLocation are written by multiple code paths with no single source of truth.
The UI object has 12 state variables at the top level, mixing DOM references, timers, flags, and cached data. Refactoring to a small reactive store or even a simple pub/sub pattern would make data flow more predictable.
No dark mode
The app is light-mode only. The white background: rgba(255, 255, 255, 0.92) loading overlay, the gray color palette in styles.css, and all Tailwind classes (e.g., bg-gray-50, text-gray-900) are hardcoded for a light background.
Adding dark mode would require a [data-theme="dark"] override block for all CSS custom properties, plus Tailwind's dark: variant on dozens of utility classes in index.html. The Chart.js grid and axis colors are also hardcoded.