Weather data is a deceptively simple problem. The Weather Dashboard at /lab/weather coordinates four separate Open-Meteo endpoints to render a single city view: geocoding, current conditions, hourly forecast (24 hours), and air quality (European AQI bands). All four hit the network through our /api/weather route, which proxies the calls server-side so the visitor's IP never touches Open-Meteo.
**Geocoding.** The CitySearchInput component debounces user input (300 ms) and calls /api/weather/search?q=… which forwards to https://geocoding-api.open-meteo.com/v1/search. Results are cached in the Next.js data layer with revalidate: 86400 — cities don't move, so a 24-hour TTL is fine. The "Use my location" button reads navigator.geolocation, falls back to a "Permission denied" message if the user blocks it, and never persists coordinates to our origin.
**Forecast and air quality fan-out.** Once a city is selected, /api/weather?lat=…&lon=… runs three Promise.all branches: forecast (current + hourly + daily in one call), air-quality (separate endpoint with PM2.5, PM10, ozone, NO₂, European AQI), and a thin metadata wrap. The route validates the response shape with a Zod schema before returning — malformed Open-Meteo responses (rare but happen during their maintenance windows) trigger a 503 with a generic message rather than crashing the renderer.
**Caching.** Forecast revalidates every 15 minutes (next: { revalidate: 900 }). Air quality uses the same TTL since both come from the same upstream provider. Geocoding revalidates daily. The Next.js data cache keys by URL, so two visitors searching the same city share a cache hit — useful when traffic spikes around weather events.
**WMO weather code mapping.** Open-Meteo returns numeric WMO codes (0 = clear, 95 = thunderstorm, etc.). We map the 100+ codes into 11 friendly buckets via a lookup table in lib/weather/wmo.ts, each with a localized label and an icon name. The mapping is the source of truth — if a code is missing, we return "Unknown conditions" rather than crashing.
**State management.** Weather uses a small Zustand store (apps/web/src/stores/weather-store.ts) persisted to localStorage under the key revolarch-weather. It tracks favorites (array of CityRef), the last-viewed city, and the unit toggle (metric / imperial). On mount the page restores last-viewed, then optionally re-fetches the current geolocation if permission is already granted. Switching units only re-renders — no new fetch, since Open-Meteo gives us both Celsius and Fahrenheit.
**Air quality presentation.** The AirQuality card classifies AQI into European bands (0–40 Good, 41–60 Fair, 61–100 Moderate, 101–150 Poor, 151+ Very Poor) and uses a dynamic AccentCard accent — the left border colour reflects the band, so you can scan multiple cities mentally without reading the number. The function aqiAccent(aqi) lives next to classifyAqi(aqi) in AirQuality.tsx.
**Sunrise / sunset arc.** The SunriseSunsetArc component receives the day's sunrise and sunset ISO strings and renders an SVG arc parameterized on the daylight elapsed fraction. The math is plain trig: angle = π × elapsed_fraction, mapped onto a unit semicircle. No external library — pure SVG path commands.
**Forecast layout.** HourlyForecast is a horizontal scroll strip (overflow-x-auto, snap-x), DailyForecast is a 7-row stacked list with min/max temperatures and precipitation totals. Both render from the same forecast payload — Open-Meteo returns hourly + daily in one response, so we don't pay extra latency.
**Where the code lives.** apps/web/src/app/lab/weather/page.tsx orchestrates everything; apps/web/src/components/weather/ contains 8 components; apps/web/src/lib/weather/ holds the schema, types, and WMO mapping. The Open-Meteo route handler is at apps/web/src/app/api/weather/route.ts. End-to-end coverage is in apps/web/tests/e2e/weather-tool.spec.ts (5 scenarios).