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.
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.
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
styles.css handles animations, state-driven components, and complex transitions that Tailwind alone can't express.fetch() with cache: 'no-store' for fresh data.--font-family with letter-spacing -0.02em for a tight, modern look.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.
Global object that owns all API interactions and data processing. Maintains a state object with currentLocation, weatherData, and isLoading flags.
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.
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.
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.
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.
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.
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.
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.
--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).
Three Open-Meteo endpoints hardcoded in app.js and forecast.js. All are free, public, and require no authentication.
geocoding-api.open-meteo.com/v1/search?name={query}&count=5archive-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_sumapi.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
All external dependencies loaded from CDN in index.html. No package manager, no bundler, no lock file.
cdn.tailwindcss.com (latest)cdn.jsdelivr.net/npm/chart.js (latest)cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3fonts.googleapis.com (weights 300–700)
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.
| Method | Endpoint | Description |
|---|---|---|
| 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/favorites | Save a location to the user's favorites list (requires API key) |
| DELETE | /api/v1/favorites/:id | Remove a saved location from favorites |
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.
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).(lat, lon, month). B-tree on expires_at for cleanup queries.
id (serial PK), lat (numeric(8,5)), lon (numeric(8,5)), response_json (jsonb), fetched_at (timestamptz), expires_at (timestamptz).(lat, lon). B-tree on expires_at.
display_name column stores the human-readable location label shown in the UI dropdown.
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).(api_key, lat, lon). B-tree on api_key.
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.
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.
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.
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.
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.
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.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.
.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.
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.
lint → test → docker build → docker push ghcr.io/user/weatherhistory:latest → ssh deploy@vps docker compose pull && docker compose up -d. Average deploy time: ~90 seconds from push to live.
/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.
/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.
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.
pg_cron job runs DELETE FROM climate_cache WHERE expires_at < NOW() every 6 hours.
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.
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.
lat,lon would eliminate redundant requests. cache: 'no-store' is currently hardcoded in fetchClimateHistory(), which also prevents browser-level HTTP caching.
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.
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.
showLoading() and hideLoading().
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.
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.
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.
[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.