This commit is contained in:
Cayden Schwechten 2026-10-31 21:20:16 -05:00
commit 6250d23297
53 changed files with 6381 additions and 0 deletions

25
lib/client-api.ts Normal file
View file

@ -0,0 +1,25 @@
import type { Place, ProviderId, ProviderInfo, WeatherReport } from "./weather/types";
/**
* Browser-side calls to our own API routes.
* Each throws an Error carrying the server's message, ready to show in the UI.
*/
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(url, { signal });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error ?? `Request failed (HTTP ${response.status})`);
}
return body as T;
}
export const fetchProviders = () => getJson<ProviderInfo[]>("/api/providers");
export const searchPlaces = (query: string, signal?: AbortSignal) =>
getJson<Place[]>(`/api/geocode?q=${encodeURIComponent(query)}`, signal);
export const namePlace = (lat: number, lon: number) => getJson<Place>(`/api/geocode?lat=${lat}&lon=${lon}`);
export const fetchWeather = (provider: ProviderId, place: Place, signal?: AbortSignal) =>
getJson<WeatherReport>(`/api/weather?provider=${provider}&lat=${place.lat}&lon=${place.lon}`, signal);

186
lib/display.ts Normal file
View file

@ -0,0 +1,186 @@
import type { CurrentWeather, HourlyForecast, Place } from "./weather/types";
/**
* Display helpers: unit conversion, time formatting, labels and small calculations.
* The API always speaks metric and UTC; everything here converts for the screen.
*/
export type Units = "imperial" | "metric";
// ---------- Units ----------
export function formatTemp(celsius: number, units: Units): string {
const value = units === "imperial" ? celsius * (9 / 5) + 32 : celsius;
return `${Math.round(value)}°`;
}
/** Temperature as a plain number in the chosen unit (for charts). */
export function tempValue(celsius: number, units: Units): number {
return units === "imperial" ? celsius * (9 / 5) + 32 : celsius;
}
export function formatWind(kph: number, units: Units): string {
return units === "imperial" ? `${Math.round(kph / 1.609)} mph` : `${Math.round(kph)} km/h`;
}
export function formatPressure(hpa: number, units: Units): string {
return units === "imperial" ? `${(hpa * 0.02953).toFixed(2)} inHg` : `${Math.round(hpa)} hPa`;
}
export function formatDistance(km: number, units: Units): string {
const value = units === "imperial" ? km / 1.609 : km;
const unit = units === "imperial" ? "mi" : "km";
return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${unit}`;
}
export function formatPrecip(mm: number, units: Units): string {
return units === "imperial" ? `${(mm / 25.4).toFixed(2)} in` : `${mm.toFixed(1)} mm`;
}
const COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
/** 225° → "SW". Wind direction is where the wind comes FROM. */
export function compass(degrees: number): string {
return COMPASS[Math.round(degrees / 22.5) % 16];
}
// ---------- Time (always shown in the location's time zone) ----------
/** Builds a formatter for the location's zone, falling back to the browser's. */
function inZone(timeZone: string | null, options: Intl.DateTimeFormatOptions) {
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone ?? undefined });
}
/** "7:31 PM" */
export function formatClock(iso: string, timeZone: string | null): string {
return inZone(timeZone, { hour: "numeric", minute: "2-digit" }).format(new Date(iso));
}
/** "7 PM" */
export function formatHour(iso: string, timeZone: string | null): string {
return inZone(timeZone, { hour: "numeric" }).format(new Date(iso));
}
/** "Thu" */
export function formatWeekday(iso: string, timeZone: string | null): string {
return inZone(timeZone, { weekday: "short" }).format(new Date(iso));
}
/** "Oct 23" */
export function formatShortDate(iso: string, timeZone: string | null): string {
return inZone(timeZone, { month: "short", day: "numeric" }).format(new Date(iso));
}
/** "2026-10-23" — the local calendar day, for grouping and comparisons. */
export function localDayKey(iso: string, timeZone: string | null): string {
// Canadian English happens to format dates as YYYY-MM-DD.
return new Intl.DateTimeFormat("en-CA", { timeZone: timeZone ?? undefined }).format(new Date(iso));
}
/** "Today", "Tomorrow", or "Thu" */
export function dayName(iso: string, timeZone: string | null): string {
const today = localDayKey(new Date().toISOString(), timeZone);
const tomorrow = localDayKey(new Date(Date.now() + 86_400_000).toISOString(), timeZone);
const day = localDayKey(iso, timeZone);
if (day === today) return "Today";
if (day === tomorrow) return "Tomorrow";
return formatWeekday(iso, timeZone);
}
/** "10 h 41 m" between two instants. */
export function formatDuration(fromIso: string, toIso: string): string {
const minutes = Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60_000);
return `${Math.floor(minutes / 60)} h ${minutes % 60} m`;
}
export function placeLabel(place: Place): string {
const extra = place.region ?? place.country;
return extra ? `${place.name}, ${extra}` : place.name;
}
/** "47.61°N · 122.33°W" */
export function formatCoords(lat: number, lon: number, decimals = 2): string {
const ns = lat >= 0 ? "N" : "S";
const ew = lon >= 0 ? "E" : "W";
return `${Math.abs(lat).toFixed(decimals)}°${ns} · ${Math.abs(lon).toFixed(decimals)}°${ew}`;
}
// ---------- Labels for indexes ----------
/** UV index categories from the WHO / US EPA. */
export function uvLabel(uv: number): string {
if (uv < 3) return "Low";
if (uv < 6) return "Moderate";
if (uv < 8) return "High";
if (uv < 11) return "Very high";
return "Extreme";
}
/** US EPA AQI categories. */
export function aqiLabel(aqi: number): string {
if (aqi <= 50) return "Good";
if (aqi <= 100) return "Moderate";
if (aqi <= 150) return "Unhealthy for sensitive groups";
if (aqi <= 200) return "Unhealthy";
if (aqi <= 300) return "Very unhealthy";
return "Hazardous";
}
// ---------- Moon ----------
const SYNODIC_MONTH_DAYS = 29.530588853;
const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // 6 Jan 2000, 18:14 UTC
const MOON_NAMES = [
"New moon", "Waxing crescent", "First quarter", "Waxing gibbous",
"Full moon", "Waning gibbous", "Last quarter", "Waning crescent",
];
/**
* Moon phase from simple arithmetic — accurate to within about a day,
* which is plenty for "what does the moon look like tonight".
* `fraction`: 0 = new, 0.5 = full, back to 1 = new.
*/
export function moonPhase(date = new Date()): { fraction: number; name: string; illumination: number } {
const daysSince = (date.getTime() - KNOWN_NEW_MOON) / 86_400_000;
const fraction = (((daysSince / SYNODIC_MONTH_DAYS) % 1) + 1) % 1;
const name = MOON_NAMES[Math.round(fraction * 8) % 8];
const illumination = Math.round(((1 - Math.cos(fraction * 2 * Math.PI)) / 2) * 100);
return { fraction, name, illumination };
}
// ---------- Small summaries ----------
/**
* One plain sentence about precipitation in the next 24 hours, e.g.
* "Rain likely around 9 PM (70%)." or "No precipitation expected in the next 24 hours."
*/
export function precipOutlook(hourly: HourlyForecast[], timeZone: string | null): string | null {
const next24 = hourly.filter((h) => Date.parse(h.time) < Date.now() + 24 * 3_600_000);
if (next24.length === 0 || next24.every((h) => h.precipChance === null)) return null;
const likely = next24.find((h) => (h.precipChance ?? 0) >= 50);
if (!likely) return "No precipitation expected in the next 24 hours.";
const kind = likely.condition === "snow" ? "Snow" : likely.condition === "storm" ? "Storms" : "Rain";
const isNow = likely === next24[0];
const when = isNow ? "now" : `around ${formatHour(likely.time, timeZone)}`;
return `${kind} likely ${when} (${likely.precipChance}%).`;
}
/**
* Comfort, out of 5 hearts (in half-heart steps).
* Start full, then lose hearts for being far from 21 °C, for wind, and for weather.
* It's a playful summary, not science — tweak the numbers to taste.
*/
export function comfortHearts(current: CurrentWeather): number {
let hearts = 5;
hearts -= Math.abs(current.feelsLikeC - 21) / 6; // −1 heart per 6 °C away from ideal
if (current.windKph > 25) hearts -= 1;
if (current.condition === "rain" || current.condition === "snow") hearts -= 1;
if (current.condition === "storm") hearts -= 2;
const clamped = Math.min(5, Math.max(0, hearts));
return Math.round(clamped * 2) / 2;
}

120
lib/pixel-art.ts Normal file
View file

@ -0,0 +1,120 @@
/**
* Hand-drawn pixel art, stored as text so it's easy to read and edit.
*
* Each string is one row. Each character is one pixel:
* . transparent G gold W parchment white
* D stone grey T spirit teal R heart red
* E empty-heart outline
*/
// These are the Tailwind theme colors from app/globals.css.
export const PALETTE: Record<string, string> = {
G: "var(--color-gold-bright)",
W: "var(--color-ink)",
D: "var(--color-stone)",
T: "var(--color-teal)",
R: "var(--color-heart)",
E: "var(--color-heart-empty)",
};
export const SUN = [
".....G.....",
".G.......G.",
"...GGGGG...",
"..GGGGGGG..",
"..GGGGGGG..",
"G.GGGGGGG.G",
"..GGGGGGG..",
"..GGGGGGG..",
"...GGGGG...",
".G.......G.",
".....G.....",
];
export const MOON = [
"...........",
"....WWWW...",
"..WWWW.....",
"..WWW......",
".WWW.......",
".WWW.......",
".WWW.......",
"..WWW......",
"..WWWW.....",
"....WWWW...",
"...........",
];
export const CLOUD = [
"...........",
"...........",
"....WWW....",
"...WWWWW...",
".WWWWWWWW..",
"WWWWWWWWWW.",
"WWWWWWWWWWW",
".WWWWWWWWW.",
"...........",
"...........",
"...........",
];
export const RAIN = [
"....DDD....",
"...DDDDD...",
".DDDDDDDD..",
"DDDDDDDDDD.",
"DDDDDDDDDDD",
".DDDDDDDDD.",
"...........",
".T...T...T.",
"...T...T...",
".T...T...T.",
"...........",
];
export const SNOW = [
"....DDD....",
"...DDDDD...",
".DDDDDDDD..",
"DDDDDDDDDD.",
"DDDDDDDDDDD",
".DDDDDDDDD.",
"...........",
".W...W...W.",
"...........",
"...W...W...",
"...........",
];
export const STORM = [
"....DDD....",
"...DDDDD...",
".DDDDDDDD..",
"DDDDDDDDDD.",
"DDDDDDDDDDD",
".DDDGGDDDD.",
"....GG.....",
"...GG......",
"..GGGGG....",
"....GG.....",
"...G.......",
];
export const FOG = [
"...........",
".DDDDDDDD..",
"...........",
"..DDDDDDDDD",
"...........",
"DDDDDDDD...",
"...........",
".DDDDDDDDD.",
"...........",
"...DDDDDD..",
"...........",
];
export const HEART_FULL = [".RR.RR.", "RRRRRRR", "RRRRRRR", ".RRRRR.", "..RRR..", "...R..."];
export const HEART_HALF = [".RR.EE.", "RRRREEE", "RRRREEE", ".RRREE.", "..RRE..", "...R..."];
export const HEART_EMPTY = [".EE.EE.", "EEEEEEE", "EEEEEEE", ".EEEEE.", "..EEE..", "...E..."];

86
lib/radar/layers.ts Normal file
View file

@ -0,0 +1,86 @@
import type { RadarFrame } from "../weather/types";
/**
* Every map overlay the radar panel can show, and how to get its frames.
*
* RainViewer — worldwide radar, last ~2 hours in 10-minute steps.
* Since Jan 2026: past frames only, max zoom 7, one color scheme,
* personal/educational use only.
* NEXRAD — US-only NWS radar via Iowa State's mesonet; much sharper,
* last 50 minutes in 5-minute steps. No key.
* OpenWeather — model maps (precipitation, clouds, temperature, wind, pressure).
* Needs OPENWEATHER_API_KEY; tiles go through /api/tiles so the key stays secret.
*/
export type RadarLayerId = "rainviewer" | "nexrad" | "owm-precip" | "owm-clouds" | "owm-temp" | "owm-wind" | "owm-pressure";
export interface RadarLayer {
id: RadarLayerId;
label: string;
hint: string; // shown under the map
maxNativeZoom: number; // beyond this, tiles are stretched rather than fetched
needsOpenWeather: boolean;
attribution: string;
}
const OWM_ATTRIBUTION =
'Weather data provided by <a href="https://openweathermap.org/" target="_blank" rel="noopener">OpenWeather</a> (<a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>)';
export const RADAR_LAYERS: RadarLayer[] = [
{
id: "rainviewer",
label: "Radar",
hint: "Worldwide radar, past 2 hours. Detail stops at regional zoom.",
maxNativeZoom: 7,
needsOpenWeather: false,
attribution: 'Weather data by <a href="https://www.rainviewer.com/" target="_blank" rel="noopener">RainViewer</a>',
},
{
id: "nexrad",
label: "US radar",
hint: "NWS NEXRAD radar, past 50 minutes. United States only, high detail.",
maxNativeZoom: 12,
needsOpenWeather: false,
attribution:
'NEXRAD via <a href="https://mesonet.agron.iastate.edu/" target="_blank" rel="noopener">Iowa Environmental Mesonet (Iowa State Univ.)</a>',
},
{ id: "owm-precip", label: "Precipitation", hint: "Modelled precipitation right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
{ id: "owm-clouds", label: "Clouds", hint: "Modelled cloud cover right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
{ id: "owm-temp", label: "Temperature", hint: "Modelled air temperature right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
{ id: "owm-wind", label: "Wind", hint: "Modelled wind speed right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
{ id: "owm-pressure", label: "Pressure", hint: "Modelled sea-level pressure right now.", maxNativeZoom: 9, needsOpenWeather: true, attribution: OWM_ATTRIBUTION },
];
/** Our layer ids → OpenWeather's tile layer names. Also the allow-list for /api/tiles. */
export const OWM_TILE_LAYERS: Record<string, string> = {
"owm-precip": "precipitation_new",
"owm-clouds": "clouds_new",
"owm-temp": "temp_new",
"owm-wind": "wind_new",
"owm-pressure": "pressure_new",
};
/** NEXRAD frames are fixed offsets from "now": 50, 45 … 5 minutes ago, then the latest. */
function nexradFrames(): RadarFrame[] {
const fiveMinutes = 5 * 60_000;
const latest = Math.floor(Date.now() / fiveMinutes) * fiveMinutes;
const base = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913";
return [50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0].map((minutesAgo) => ({
time: new Date(latest - minutesAgo * 60_000).toISOString(),
url: minutesAgo === 0 ? `${base}/{z}/{x}/{y}.png` : `${base}-m${String(minutesAgo).padStart(2, "0")}m/{z}/{x}/{y}.png`,
}));
}
/** Loads the frames for a layer. Single-frame layers return a list of one. */
export async function loadFrames(id: RadarLayerId): Promise<RadarFrame[]> {
if (id === "nexrad") return nexradFrames();
if (id === "rainviewer") {
const response = await fetch("/api/radar");
if (!response.ok) throw new Error("Radar frames are unavailable right now");
return (await response.json()) as RadarFrame[];
}
return [{ time: new Date().toISOString(), url: `/api/tiles/${id}/{z}/{x}/{y}` }];
}

15
lib/styles.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Tailwind class strings used by more than one component.
* Kept here so a style change happens in one place.
*/
/** Small pixel-font caption, e.g. "Feels like", "Comfort". */
export const pixelLabel = "font-pixel text-[0.7rem] tracking-[0.03em] text-stone";
/** Gold outlined button that fills in on hover. */
export const outlineButton =
"min-h-[46px] cursor-pointer whitespace-nowrap border border-gold bg-panel/70 px-2.5 font-pixel text-xs text-gold-bright transition-colors duration-150 " +
"hover:enabled:bg-gold hover:enabled:text-black disabled:cursor-wait disabled:opacity-60 sm:px-3.5";
/** Gold Cinzel section title used above Conditions, Forecast, Radar, and Waypoints. */
export const sectionHeading = "font-display text-base font-medium tracking-[0.12em] uppercase text-gold sm:text-lg";

18
lib/weather/describe.ts Normal file
View file

@ -0,0 +1,18 @@
import type { AlertSeverity, Condition } from "./types";
/** Fallback wording for providers that only give us a numeric code. */
export const CONDITION_LABELS: Record<Condition, string> = {
clear: "clear skies",
clouds: "cloudy",
rain: "rain",
storm: "thunderstorm",
snow: "snow",
fog: "fog",
};
/** Maps any provider's severity word ("Severe", "severe", "Moderate"…) onto ours. */
export function toSeverity(word: string | null | undefined): AlertSeverity {
const normalized = (word ?? "").toLowerCase();
const known: AlertSeverity[] = ["extreme", "severe", "moderate", "minor"];
return known.find((s) => normalized.includes(s)) ?? "unknown";
}

View file

@ -0,0 +1,50 @@
import { fetchJson, maybe } from "../http";
import type { AirQuality } from "../types";
/**
* Air quality from Open-Meteo's free air-quality API (CAMS data), used for
* every provider so the AQI always means the same thing.
* Docs: https://open-meteo.com/en/docs/air-quality-api
*
* The same call also tells us the location's IANA time zone, which some
* providers (Tomorrow.io, OpenWeather free) don't give us.
*/
interface AirQualityResponse {
timezone: string;
current: {
us_aqi: number | null;
pm2_5: number | null;
pm10: number | null;
ozone: number | null;
nitrogen_dioxide: number | null;
};
}
export interface LocalContext {
timeZone: string;
airQuality: AirQuality | null;
}
export async function getLocalContext(lat: number, lon: number): Promise<LocalContext> {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
current: "us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide",
timezone: "auto",
});
const data = await fetchJson<AirQualityResponse>(
`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`,
"Open-Meteo air quality",
{ cacheSeconds: 1800 }, // air quality updates hourly at most
);
const c = data.current;
return {
timeZone: data.timezone,
airQuality:
c.us_aqi === null
? null
: { usAqi: Math.round(c.us_aqi), pm25: maybe(c.pm2_5), pm10: maybe(c.pm10), ozone: maybe(c.ozone), no2: maybe(c.nitrogen_dioxide) },
};
}

View file

@ -0,0 +1,75 @@
import { toSeverity } from "../describe";
import { fetchJson } from "../http";
import type { WeatherAlert } from "../types";
/**
* Active US weather alerts from the National Weather Service (free, no key).
* Docs: https://www.weather.gov/documentation/services-web-api
*
* NWS asks every app to identify itself with a User-Agent containing contact
* info, so set NWS_CONTACT in .env.local (an email or website).
* Outside the US this returns nothing, which is fine.
*/
interface NwsResponse {
features: {
properties: {
event: string;
headline: string | null;
severity: string;
senderName: string;
onset: string | null;
effective: string;
ends: string | null;
expires: string;
description: string;
instruction: string | null;
};
}[];
}
/** Rough boxes around US states and territories — skips pointless calls elsewhere. */
function mightBeInUS(lat: number, lon: number): boolean {
const mainland = lat > 24 && lat < 50 && lon > -125 && lon < -66;
const alaska = lat > 51 && lat < 72 && (lon < -129 || lon > 172);
const hawaii = lat > 18 && lat < 23 && lon > -161 && lon < -154;
const caribbean = lat > 17 && lat < 19 && lon > -68 && lon < -64; // Puerto Rico, USVI
const pacific = lat > 13 && lat < 21 && lon > 144 && lon < 146; // Guam, Northern Marianas
return mainland || alaska || hawaii || caribbean || pacific;
}
let warnedMissingContact = false;
function buildNwsUserAgent(): string {
const explicit = process.env.NWS_CONTACT?.trim();
if (explicit) return `WayfarerWeather/1.0 (${explicit})`;
const deployedHost = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
if (deployedHost) return `WayfarerWeather/1.0 (https://${deployedHost})`;
if (process.env.NODE_ENV === "production" && !warnedMissingContact) {
warnedMissingContact = true;
console.warn("[Wayfarer] Set NWS_CONTACT (email or site URL) so api.weather.gov requests include contact details.");
}
return "WayfarerWeather/1.0 (local-dev)";
}
export async function getNwsAlerts(lat: number, lon: number): Promise<WeatherAlert[]> {
if (!mightBeInUS(lat, lon)) return [];
const point = `${lat.toFixed(4)},${lon.toFixed(4)}`;
const data = await fetchJson<NwsResponse>(`https://api.weather.gov/alerts/active?point=${point}`, "National Weather Service", {
cacheSeconds: 300, // alerts matter; refresh more often
headers: { "User-Agent": buildNwsUserAgent(), Accept: "application/geo+json" },
});
return data.features.map(({ properties: p }) => ({
title: p.headline ?? p.event,
severity: toSeverity(p.severity),
source: p.senderName,
starts: p.onset ?? p.effective,
ends: p.ends ?? p.expires,
description: p.description,
instruction: p.instruction,
}));
}

60
lib/weather/geocode.ts Normal file
View file

@ -0,0 +1,60 @@
import { fetchJson } from "./http";
import type { Place } from "./types";
/**
* Turns a city name into coordinates (and coordinates back into a name).
*
* OpenWeather's geocoder is used when its key is set; otherwise we fall back
* to Open-Meteo's free geocoder so search still works with zero keys.
*/
interface OwmPlace {
name: string;
state?: string;
country: string;
lat: number;
lon: number;
}
interface OpenMeteoGeocodeResponse {
results?: { name: string; admin1?: string; country_code: string; latitude: number; longitude: number }[];
}
const OWM_GEO = "https://api.openweathermap.org/geo/1.0";
function fromOwm(place: OwmPlace): Place {
return { name: place.name, region: place.state, country: place.country, lat: place.lat, lon: place.lon };
}
export async function searchPlaces(query: string): Promise<Place[]> {
const key = process.env.OPENWEATHER_API_KEY?.trim();
const q = encodeURIComponent(query.slice(0, 120));
if (key) {
const places = await fetchJson<OwmPlace[]>(`${OWM_GEO}/direct?q=${q}&limit=5&appid=${key}`, "OpenWeather");
return places.map(fromOwm);
}
const data = await fetchJson<OpenMeteoGeocodeResponse>(
`https://geocoding-api.open-meteo.com/v1/search?name=${q}&count=5`,
"Open-Meteo",
);
return (data.results ?? []).map((r) => ({
name: r.name,
region: r.admin1,
country: r.country_code,
lat: r.latitude,
lon: r.longitude,
}));
}
/** Used by "Use my location". Without an OpenWeather key we can't name the spot. */
export async function reversePlace(lat: number, lon: number): Promise<Place> {
const fallback: Place = { name: "Your location", lat, lon };
const key = process.env.OPENWEATHER_API_KEY?.trim();
if (!key) return fallback;
const places = await fetchJson<OwmPlace[]>(`${OWM_GEO}/reverse?lat=${lat}&lon=${lon}&limit=1&appid=${key}`, "OpenWeather");
// Keep the user's exact coordinates rather than the city centre's.
return places[0] ? { ...fromOwm(places[0]), lat, lon } : fallback;
}

93
lib/weather/http.ts Normal file
View file

@ -0,0 +1,93 @@
/**
* Small server-side helpers shared by every provider adapter.
*/
/** An error that knows which HTTP status the API route should answer with. */
export class ProviderError extends Error {
constructor(
message: string,
public readonly status: number = 502,
) {
super(message);
this.name = "ProviderError";
}
}
/** Reads an API key from the environment, or fails with a clear message. */
export function readKey(envKey: string): string {
const key = process.env[envKey]?.trim();
if (!key) {
throw new ProviderError(`${envKey} is not configured`, 500);
}
return key;
}
interface FetchOptions {
/** Seconds to cache the response. Weather barely changes in 10 minutes. */
cacheSeconds?: number;
headers?: Record<string, string>;
}
/**
* Fetches JSON with caching (default 10 minutes) to protect free-tier quotas.
* The URL often contains an API key, so it is never logged or echoed back.
*/
export async function fetchJson<T>(url: string, providerLabel: string, options: FetchOptions = {}): Promise<T> {
const { cacheSeconds = 600, headers } = options;
let response: Response;
try {
response = await fetch(url, { headers, next: { revalidate: cacheSeconds } });
} catch {
// DNS failure, offline, firewall… the request never got an answer.
throw new ProviderError(`Couldn't reach ${providerLabel}. Check your connection and try again.`, 503);
}
if (response.status === 401 || response.status === 403) {
throw new ProviderError(`${providerLabel} rejected the API key`, 401);
}
if (response.status === 429) {
throw new ProviderError(`${providerLabel} rate limit reached. Try again shortly.`, 429);
}
if (!response.ok) {
throw new ProviderError(`${providerLabel} responded with HTTP ${response.status}`, 502);
}
return (await response.json()) as T;
}
// ---------- Number and time helpers ----------
/** Rounds to one decimal place so the JSON stays tidy. */
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/** Same as round1, but passes null/undefined/NaN through as null. */
export function maybe(value: number | null | undefined): number | null {
return value === null || value === undefined || !Number.isFinite(value) ? null : round1(value);
}
/** Metres per second → km/h. */
export function msToKph(ms: number): number {
return round1(ms * 3.6);
}
export function maybeMsToKph(ms: number | null | undefined): number | null {
return ms === null || ms === undefined ? null : msToKph(ms);
}
/** Unix seconds → ISO instant. */
export function fromUnix(seconds: number): string {
return new Date(seconds * 1000).toISOString();
}
/** Keeps the first `hours` of an hourly list, starting from the current hour. */
export function nextHours<T extends { time: string }>(items: T[], hours: number): T[] {
const startOfThisHour = Date.now() - 60 * 60 * 1000;
return items.filter((item) => Date.parse(item.time) >= startOfThisHour).slice(0, hours);
}
/** The app shows at most 7 days of hourly data and 14 days of daily data. */
export const MAX_HOURS = 168;
export const MAX_DAYS = 14;

View file

@ -0,0 +1,45 @@
import type { ProviderId, ProviderInfo, WeatherProvider } from "../types";
import { openMeteo } from "./openmeteo";
import { openWeather } from "./openweather";
import { tomorrowIo } from "./tomorrow";
import { weatherApi } from "./weatherapi";
/**
* The provider registry.
* To add a provider: write an adapter that satisfies WeatherProvider,
* add it here, and add its id to ProviderId in ../types.ts.
*/
export const PROVIDERS: Record<ProviderId, WeatherProvider> = {
openweather: openWeather,
weatherapi: weatherApi,
tomorrow: tomorrowIo,
openmeteo: openMeteo,
};
/** Order shown in the picker. OpenWeather first because it's the default. */
const DISPLAY_ORDER: ProviderId[] = ["openweather", "weatherapi", "tomorrow", "openmeteo"];
export function isProviderId(value: string): value is ProviderId {
return value in PROVIDERS;
}
/** A provider is ready if it needs no key, or its key is present. */
export function isReady(provider: WeatherProvider): boolean {
return provider.envKey === null || Boolean(process.env[provider.envKey]?.trim());
}
/** Provider list for the browser — readiness and limits only, never the keys.
* Ready sources lead so the picker starts with working options out of the box. */
export function listProviders(): ProviderInfo[] {
const all = DISPLAY_ORDER.map((id) => {
const provider = PROVIDERS[id];
return {
id,
label: provider.label,
ready: isReady(provider),
signupUrl: provider.signupUrl,
...provider.limits(),
};
});
return [...all.filter((p) => p.ready), ...all.filter((p) => !p.ready)];
}

View file

@ -0,0 +1,134 @@
import { CONDITION_LABELS } from "../describe";
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* Open-Meteo — free, no key, and the deepest data: hourly and daily out to 16 days.
* Docs: https://open-meteo.com/en/docs
*
* We ask for `timeformat=unixtime` so every time comes back as a plain number.
*/
const HOURLY_VARS = [
"temperature_2m", "apparent_temperature", "precipitation_probability", "precipitation",
"weather_code", "wind_speed_10m", "wind_gusts_10m", "wind_direction_10m",
"relative_humidity_2m", "uv_index", "is_day",
] as const;
const DAILY_VARS = [
"weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_probability_max",
"precipitation_sum", "wind_speed_10m_max", "wind_gusts_10m_max", "wind_direction_10m_dominant",
"uv_index_max", "sunrise", "sunset",
] as const;
const CURRENT_VARS = [
"temperature_2m", "apparent_temperature", "relative_humidity_2m", "is_day", "precipitation",
"weather_code", "cloud_cover", "pressure_msl", "wind_speed_10m", "wind_direction_10m",
"wind_gusts_10m", "dew_point_2m", "visibility", "uv_index",
] as const;
// Each section is "variable name → value" (current) or "variable name → array of values".
type Row<Names extends readonly string[]> = Record<Names[number], number>;
type Columns<Names extends readonly string[]> = Record<Names[number], (number | null)[]> & { time: number[] };
interface OpenMeteoResponse {
timezone: string;
current: Row<typeof CURRENT_VARS> & { time: number };
hourly: Columns<typeof HOURLY_VARS>;
daily: Columns<typeof DAILY_VARS>;
}
/** WMO weather codes, listed at the bottom of the Open-Meteo docs page. */
function toCondition(code: number | null): Condition {
if (code === null || code <= 1) return "clear";
if (code <= 3) return "clouds";
if (code === 45 || code === 48) return "fog";
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
if (code >= 95) return "storm";
return "rain"; // drizzle (51–57), rain (61–67), showers (80–82)
}
const TWELVE_HOURS = 12 * 60 * 60;
export const openMeteo: WeatherProvider = {
id: "openmeteo",
label: "Open-Meteo",
envKey: null,
signupUrl: "https://open-meteo.com/",
limits: () => ({ forecastDays: 14, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
current: CURRENT_VARS.join(","),
hourly: HOURLY_VARS.join(","),
daily: DAILY_VARS.join(","),
timezone: "auto",
timeformat: "unixtime",
forecast_days: String(MAX_DAYS),
});
const { current: c, hourly: h, daily: d, timezone } = await fetchJson<OpenMeteoResponse>(
`https://api.open-meteo.com/v1/forecast?${params}`,
"Open-Meteo",
);
const condition = toCondition(c.weather_code);
const hourly = h.time.map((t, i) => ({
time: fromUnix(t),
tempC: round1(h.temperature_2m[i] ?? 0),
feelsLikeC: maybe(h.apparent_temperature[i]),
condition: toCondition(h.weather_code[i]),
isDay: h.is_day[i] === 1,
precipChance: maybe(h.precipitation_probability[i]),
precipMm: maybe(h.precipitation[i]),
windKph: round1(h.wind_speed_10m[i] ?? 0),
gustKph: maybe(h.wind_gusts_10m[i]),
windDeg: maybe(h.wind_direction_10m[i]),
humidity: maybe(h.relative_humidity_2m[i]),
uvIndex: maybe(h.uv_index[i]),
}));
return {
timeZone: timezone,
hourlyStepHours: 1,
alerts: [], // Open-Meteo has no alerts; the API route adds NWS alerts for US locations
current: {
time: fromUnix(c.time),
tempC: round1(c.temperature_2m),
feelsLikeC: round1(c.apparent_temperature),
condition,
description: CONDITION_LABELS[condition],
isDay: c.is_day === 1,
humidity: c.relative_humidity_2m,
windKph: round1(c.wind_speed_10m),
gustKph: maybe(c.wind_gusts_10m),
windDeg: maybe(c.wind_direction_10m),
pressureHpa: maybe(c.pressure_msl),
dewPointC: maybe(c.dew_point_2m),
visibilityKm: c.visibility == null ? null : round1(c.visibility / 1000),
uvIndex: maybe(c.uv_index),
cloudCover: maybe(c.cloud_cover),
precipMm: maybe(c.precipitation),
},
hourly: nextHours(hourly, MAX_HOURS),
daily: d.time.map((t, i) => ({
time: fromUnix(t + TWELVE_HOURS), // daily times are local midnight; use midday
highC: round1(d.temperature_2m_max[i] ?? 0),
lowC: round1(d.temperature_2m_min[i] ?? 0),
condition: toCondition(d.weather_code[i]),
summary: null,
precipChance: maybe(d.precipitation_probability_max[i]),
precipMm: maybe(d.precipitation_sum[i]),
windKph: maybe(d.wind_speed_10m_max[i]),
gustKph: maybe(d.wind_gusts_10m_max[i]),
windDeg: maybe(d.wind_direction_10m_dominant[i]),
uvIndexMax: maybe(d.uv_index_max[i]),
humidity: null,
sunrise: d.sunrise[i] ? fromUnix(d.sunrise[i]!) : null,
sunset: d.sunset[i] ? fromUnix(d.sunset[i]!) : null,
})),
};
},
};

View file

@ -0,0 +1,134 @@
import { fetchJson, fromUnix, maybe, maybeMsToKph, msToKph, round1 } from "../../http";
import type { DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
/**
* OpenWeather FREE plan:
* /data/2.5/weather → current conditions
* /data/2.5/forecast → 5 days in 3-hour steps
* There is no daily endpoint, so we build days by grouping the 3-hour steps.
*/
const BASE_URL = "https://api.openweathermap.org/data/2.5";
interface OwmCurrentResponse {
dt: number;
timezone: number; // seconds from UTC
weather: OwmWeatherTag[];
main: { temp: number; feels_like: number; humidity: number; pressure: number };
visibility?: number; // metres
wind: { speed: number; deg?: number; gust?: number }; // m/s
clouds?: { all: number };
rain?: { "1h"?: number };
snow?: { "1h"?: number };
sys: { sunrise: number; sunset: number };
}
interface OwmForecastEntry {
dt: number;
main: { temp: number; feels_like: number; temp_min: number; temp_max: number; humidity: number };
weather: OwmWeatherTag[];
wind: { speed: number; deg?: number; gust?: number };
pop?: number;
rain?: { "3h"?: number };
snow?: { "3h"?: number };
sys: { pod: "d" | "n" };
}
interface OwmForecastResponse {
list: OwmForecastEntry[];
city: { timezone: number };
}
function toHourly(e: OwmForecastEntry): HourlyForecast {
return {
time: fromUnix(e.dt),
tempC: round1(e.main.temp),
feelsLikeC: round1(e.main.feels_like),
condition: toCondition(e.weather[0].id),
isDay: e.sys.pod === "d",
precipChance: popToPercent(e.pop),
precipMm: precipitation(e.rain?.["3h"], e.snow?.["3h"]),
windKph: msToKph(e.wind.speed),
gustKph: maybeMsToKph(e.wind.gust),
windDeg: maybe(e.wind.deg),
humidity: e.main.humidity,
uvIndex: null, // not on the free plan
};
}
/** Groups the 3-hour steps by local calendar day and summarises each day. */
function buildDaily(response: OwmForecastResponse, today: OwmCurrentResponse): DailyForecast[] {
const offset = response.city.timezone;
const localDay = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).toISOString().slice(0, 10);
const localHour = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).getUTCHours();
const entriesByDay = new Map<string, OwmForecastEntry[]>();
for (const entry of response.list) {
const day = localDay(entry);
entriesByDay.set(day, [...(entriesByDay.get(day) ?? []), entry]);
}
return [...entriesByDay.values()].map((entries, index) => {
// The step nearest midday best represents "what the day looks like".
const midday = entries.reduce((best, e) => (Math.abs(localHour(e) - 12) < Math.abs(localHour(best) - 12) ? e : best));
const pops = entries.map((e) => popToPercent(e.pop) ?? 0);
const gusts = entries.map((e) => e.wind.gust ?? 0);
return {
time: fromUnix(midday.dt),
highC: round1(Math.max(...entries.map((e) => e.main.temp_max))),
lowC: round1(Math.min(...entries.map((e) => e.main.temp_min))),
condition: toCondition(midday.weather[0].id),
summary: midday.weather[0].description,
precipChance: Math.max(...pops),
precipMm: round1(entries.reduce((sum, e) => sum + (precipitation(e.rain?.["3h"], e.snow?.["3h"]) ?? 0), 0)),
windKph: msToKph(Math.max(...entries.map((e) => e.wind.speed))),
gustKph: Math.max(...gusts) > 0 ? msToKph(Math.max(...gusts)) : null,
windDeg: maybe(midday.wind.deg),
uvIndexMax: null,
humidity: Math.round(entries.reduce((sum, e) => sum + e.main.humidity, 0) / entries.length),
// Only today's sun times are available on this plan.
sunrise: index === 0 ? fromUnix(today.sys.sunrise) : null,
sunset: index === 0 ? fromUnix(today.sys.sunset) : null,
};
});
}
export async function getFreeForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
// Both requests are independent, so run them at the same time.
const [now, forecast] = await Promise.all([
fetchJson<OwmCurrentResponse>(`${BASE_URL}/weather?${query}`, OWM_LABEL),
fetchJson<OwmForecastResponse>(`${BASE_URL}/forecast?${query}`, OWM_LABEL),
]);
const tag = now.weather[0];
return {
timeZone: null, // only a UTC offset is given; the API route fills in the name
hourlyStepHours: 3,
alerts: [],
current: {
time: fromUnix(now.dt),
tempC: round1(now.main.temp),
feelsLikeC: round1(now.main.feels_like),
condition: toCondition(tag.id),
description: tag.description,
isDay: tag.icon.endsWith("d"),
humidity: now.main.humidity,
windKph: msToKph(now.wind.speed),
gustKph: maybeMsToKph(now.wind.gust),
windDeg: maybe(now.wind.deg),
pressureHpa: now.main.pressure,
dewPointC: null,
visibilityKm: now.visibility === undefined ? null : round1(now.visibility / 1000),
uvIndex: null,
cloudCover: maybe(now.clouds?.all),
precipMm: precipitation(now.rain?.["1h"], now.snow?.["1h"]),
},
hourly: forecast.list.map(toHourly),
daily: buildDaily(forecast, now),
};
}

View file

@ -0,0 +1,26 @@
import { readKey } from "../../http";
import type { WeatherProvider } from "../../types";
import { getFreeForecast } from "./free";
import { getOneCallForecast } from "./onecall";
/**
* OpenWeather (the default provider).
*
* Set OPENWEATHER_PLAN in .env.local:
* free (default) → 5 days in 3-hour steps
* onecall → One Call 4.0: 48 hours hourly + daily, plus dew point and UV
*/
const usesOneCall = () => process.env.OPENWEATHER_PLAN === "onecall";
export const openWeather: WeatherProvider = {
id: "openweather",
label: "OpenWeather",
envKey: "OPENWEATHER_API_KEY",
signupUrl: "https://home.openweathermap.org/users/sign_up",
limits: () => (usesOneCall() ? { forecastDays: 8, hourlyStepHours: 1 } : { forecastDays: 5, hourlyStepHours: 3 }),
getForecast(lat, lon) {
const key = readKey("OPENWEATHER_API_KEY");
return usesOneCall() ? getOneCallForecast(lat, lon, key) : getFreeForecast(lat, lon, key);
},
};

View file

@ -0,0 +1,146 @@
import { fetchJson, fromUnix, maybe, maybeMsToKph, MAX_DAYS, msToKph, ProviderError, round1 } from "../../http";
import type { CurrentWeather, DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
/**
* OpenWeather ONE CALL 4.0 (needs a separate "One Call" subscription;
* 1,000 calls/day free, card on file). Docs: https://openweathermap.org/api/one-call-4
*
* /data/4.0/onecall/current → now
* /data/4.0/onecall/timeline/1h → hourly, 48 h of forecast, 20 records per page
* /data/4.0/onecall/timeline/1day → daily, 10 records per page
*
* Timelines are paginated with a `next` URL, and each page costs one call.
* A full load here is about 5 calls; the 10-minute cache keeps that sane.
*/
const BASE_URL = "https://api.openweathermap.org/data/4.0/onecall";
const HOURS_AVAILABLE = 48;
interface OcRecord {
dt: number;
temp: number | { min: number; max: number; day: number };
feels_like: number | { day: number };
humidity: number;
pressure: number;
dew_point: number;
uvi: number;
clouds: number;
visibility?: number; // metres
wind_speed: number;
wind_gust?: number;
wind_deg: number;
pop?: number;
rain?: number | { "1h": number };
snow?: number | { "1h": number };
weather: OwmWeatherTag[];
sunrise?: number;
sunset?: number;
summary?: string;
}
interface OcPage {
data: OcRecord[];
next?: string;
timezone?: string;
}
/** Rain/snow is a number on daily records and `{ "1h": n }` on current/hourly ones. */
const volume = (v: OcRecord["rain"]) => (typeof v === "number" ? v : v?.["1h"]);
/** Follows `next` links until we have `wanted` records or the timeline ends. */
async function fetchTimeline(firstUrl: string, key: string, wanted: number): Promise<OcRecord[]> {
const records: OcRecord[] = [];
let url: string | undefined = firstUrl;
while (url && records.length < wanted) {
const page: OcPage = await fetchJson<OcPage>(url, OWM_LABEL);
records.push(...page.data);
// Make sure follow-up pages carry the key even if `next` omits it.
url = page.next ? (page.next.includes("appid=") ? page.next : `${page.next}&appid=${key}`) : undefined;
}
return records.slice(0, wanted);
}
function toHourly(r: OcRecord): HourlyForecast {
return {
time: fromUnix(r.dt),
tempC: round1(r.temp as number),
feelsLikeC: round1(r.feels_like as number),
condition: toCondition(r.weather[0].id),
isDay: r.weather[0].icon.endsWith("d"),
precipChance: popToPercent(r.pop),
precipMm: precipitation(volume(r.rain), volume(r.snow)),
windKph: msToKph(r.wind_speed),
gustKph: maybeMsToKph(r.wind_gust),
windDeg: r.wind_deg,
humidity: r.humidity,
uvIndex: maybe(r.uvi),
};
}
function toDaily(r: OcRecord): DailyForecast {
const temp = r.temp as { min: number; max: number };
return {
time: fromUnix(r.dt), // OpenWeather stamps daily records at local midday
highC: round1(temp.max),
lowC: round1(temp.min),
condition: toCondition(r.weather[0].id),
summary: r.summary ?? r.weather[0].description,
precipChance: popToPercent(r.pop),
precipMm: precipitation(volume(r.rain), volume(r.snow)),
windKph: msToKph(r.wind_speed),
gustKph: maybeMsToKph(r.wind_gust),
windDeg: r.wind_deg,
uvIndexMax: maybe(r.uvi),
humidity: r.humidity,
sunrise: r.sunrise ? fromUnix(r.sunrise) : null,
sunset: r.sunset ? fromUnix(r.sunset) : null,
};
}
function toCurrent(r: OcRecord): CurrentWeather {
const hour = toHourly(r);
return {
time: hour.time,
tempC: hour.tempC,
feelsLikeC: hour.feelsLikeC ?? hour.tempC,
condition: hour.condition,
description: r.weather[0].description,
isDay: hour.isDay,
humidity: r.humidity,
windKph: hour.windKph,
gustKph: hour.gustKph,
windDeg: hour.windDeg,
pressureHpa: r.pressure,
dewPointC: round1(r.dew_point),
visibilityKm: r.visibility === undefined ? null : round1(r.visibility / 1000),
uvIndex: maybe(r.uvi),
cloudCover: r.clouds,
precipMm: hour.precipMm,
};
}
export async function getOneCallForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
const nowSeconds = Math.floor(Date.now() / 1000);
const startOfHour = nowSeconds - (nowSeconds % 3600);
const [currentPage, hourly, daily] = await Promise.all([
fetchJson<OcPage>(`${BASE_URL}/current?${query}`, OWM_LABEL),
fetchTimeline(`${BASE_URL}/timeline/1h?${query}&start=${startOfHour}`, key, HOURS_AVAILABLE),
fetchTimeline(`${BASE_URL}/timeline/1day?${query}&start=${nowSeconds}`, key, MAX_DAYS),
]);
const currentRecord = currentPage.data[0];
if (!currentRecord) throw new ProviderError("OpenWeather returned no current reading", 502);
return {
timeZone: currentPage.timezone ?? null,
hourlyStepHours: 1,
alerts: [], // 4.0 only returns alert IDs; the API route adds NWS alerts for US locations
current: toCurrent(currentRecord),
hourly: hourly.map(toHourly),
daily: daily.map(toDaily),
};
}

View file

@ -0,0 +1,32 @@
import type { Condition } from "../../types";
/** Shared by both OpenWeather plans. */
export const OWM_LABEL = "OpenWeather";
export interface OwmWeatherTag {
id: number;
description: string;
icon: string; // ends in "d" (day) or "n" (night)
}
/** Condition codes: https://openweathermap.org/weather-conditions */
export function toCondition(code: number): Condition {
if (code >= 200 && code < 300) return "storm";
if (code >= 300 && code < 600) return "rain"; // drizzle + rain
if (code >= 600 && code < 700) return "snow";
if (code >= 700 && code < 800) return "fog"; // mist, haze, smoke, dust…
if (code === 800) return "clear";
return "clouds";
}
/** OpenWeather gives probability of precipitation as 0–1; we use 0–100. */
export function popToPercent(pop: number | undefined): number | null {
return pop === undefined ? null : Math.round(pop * 100);
}
/** Rain + snow volume, either of which may be missing. */
export function precipitation(rain?: number, snow?: number): number | null {
if (rain === undefined && snow === undefined) return null;
return Math.round(((rain ?? 0) + (snow ?? 0)) * 10) / 10;
}

View file

@ -0,0 +1,138 @@
import { CONDITION_LABELS } from "../describe";
import { fetchJson, maybe, maybeMsToKph, MAX_HOURS, msToKph, nextHours, ProviderError, readKey, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* Tomorrow.io — one forecast request with hourly (≈120 h) and daily (≈6 days) timelines.
* "Current" is the first hourly entry, which Tomorrow.io sets to the present hour.
* Free plan: 25 requests/hour, so the 10-minute cache matters here.
* Docs: https://docs.tomorrow.io/reference/weather-forecast
*/
interface TmrwHourlyValues {
temperature: number;
temperatureApparent: number;
humidity: number;
windSpeed: number; // m/s with units=metric
windGust?: number;
windDirection?: number;
weatherCode: number;
precipitationProbability?: number;
rainIntensity?: number; // mm/h
snowIntensity?: number;
uvIndex?: number;
pressureSurfaceLevel?: number;
dewPoint?: number;
visibility?: number; // km
cloudCover?: number;
}
interface TmrwDailyValues {
temperatureMax: number;
temperatureMin: number;
weatherCodeMax: number;
precipitationProbabilityMax?: number;
rainAccumulationSum?: number;
snowAccumulationSum?: number;
windSpeedMax?: number;
windGustMax?: number;
windDirectionAvg?: number;
uvIndexMax?: number;
humidityAvg?: number;
sunriseTime?: string;
sunsetTime?: string;
}
interface TmrwResponse {
timelines: {
hourly: { time: string; values: TmrwHourlyValues }[];
daily: { time: string; values: TmrwDailyValues }[];
};
}
/** Weather codes: https://docs.tomorrow.io/reference/data-layers-weather-codes */
function toCondition(code: number): Condition {
if (code === 1000 || code === 1100) return "clear";
if (code === 8000) return "storm";
if (code >= 2000 && code < 3000) return "fog";
if (code >= 4000 && code < 5000) return "rain";
if (code >= 5000 && code < 6000) return "snow";
if (code >= 6000 && code < 7000) return "rain"; // freezing rain / drizzle
if (code >= 7000 && code < 8000) return "snow"; // ice pellets
return "clouds";
}
/** Daytime if the instant falls between sunrise and sunset of a day that has them. */
function isDaytime(instant: string, days: TmrwResponse["timelines"]["daily"]): boolean {
const t = Date.parse(instant);
return days.some(({ values: v }) =>
v.sunriseTime && v.sunsetTime ? t >= Date.parse(v.sunriseTime) && t < Date.parse(v.sunsetTime) : false,
);
}
export const tomorrowIo: WeatherProvider = {
id: "tomorrow",
label: "Tomorrow.io",
envKey: "TOMORROW_API_KEY",
signupUrl: "https://app.tomorrow.io/signup",
limits: () => ({ forecastDays: 6, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const key = readKey("TOMORROW_API_KEY");
const url = `https://api.tomorrow.io/v4/weather/forecast?location=${lat},${lon}&timesteps=1h,1d&units=metric&apikey=${key}`;
const { timelines } = await fetchJson<TmrwResponse>(url, "Tomorrow.io");
const hourly = timelines.hourly.map(({ time, values: v }) => ({
time: new Date(time).toISOString(),
tempC: round1(v.temperature),
feelsLikeC: round1(v.temperatureApparent),
condition: toCondition(v.weatherCode),
isDay: isDaytime(time, timelines.daily),
precipChance: maybe(v.precipitationProbability),
precipMm: maybe((v.rainIntensity ?? 0) + (v.snowIntensity ?? 0)),
windKph: msToKph(v.windSpeed),
gustKph: maybeMsToKph(v.windGust),
windDeg: maybe(v.windDirection),
humidity: maybe(v.humidity),
uvIndex: maybe(v.uvIndex),
}));
const now = timelines.hourly[0];
if (!now) throw new ProviderError("Tomorrow.io returned an empty forecast", 502);
const condition = toCondition(now.values.weatherCode);
return {
timeZone: null, // Tomorrow.io doesn't say; the API route fills it in
hourlyStepHours: 1,
alerts: [],
current: {
...hourly[0],
feelsLikeC: hourly[0].feelsLikeC ?? hourly[0].tempC,
humidity: Math.round(now.values.humidity),
description: CONDITION_LABELS[condition],
pressureHpa: maybe(now.values.pressureSurfaceLevel),
dewPointC: maybe(now.values.dewPoint),
visibilityKm: maybe(now.values.visibility),
cloudCover: maybe(now.values.cloudCover),
precipMm: hourly[0].precipMm,
},
hourly: nextHours(hourly, MAX_HOURS),
daily: timelines.daily.map(({ time, values: v }) => ({
time: new Date(time).toISOString(), // Tomorrow.io starts each day at 6 am local
highC: round1(v.temperatureMax),
lowC: round1(v.temperatureMin),
condition: toCondition(v.weatherCodeMax),
summary: null,
precipChance: maybe(v.precipitationProbabilityMax),
precipMm: maybe((v.rainAccumulationSum ?? 0) + (v.snowAccumulationSum ?? 0)),
windKph: maybeMsToKph(v.windSpeedMax),
gustKph: maybeMsToKph(v.windGustMax),
windDeg: maybe(v.windDirectionAvg),
uvIndexMax: maybe(v.uvIndexMax),
humidity: maybe(v.humidityAvg),
sunrise: v.sunriseTime ?? null,
sunset: v.sunsetTime ?? null,
})),
};
},
};

View file

@ -0,0 +1,192 @@
import { toSeverity } from "../describe";
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, readKey, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* WeatherAPI.com — one request returns current, daily, hourly and alerts.
* The free plan returns 3 days; paid plans return up to 14. We ask for 14
* and use whatever comes back.
* Docs: https://www.weatherapi.com/docs/
*/
interface WapiCondition {
code: number;
text: string;
}
interface WapiHour {
time_epoch: number;
temp_c: number;
feelslike_c: number;
is_day: 0 | 1;
condition: WapiCondition;
wind_kph: number;
wind_degree: number;
gust_kph: number;
humidity: number;
chance_of_rain: number;
chance_of_snow: number;
precip_mm: number;
uv: number;
}
interface WapiDay {
date: string; // "2026-10-21"
day: {
maxtemp_c: number;
mintemp_c: number;
maxwind_kph: number;
totalprecip_mm: number;
avghumidity: number;
daily_chance_of_rain: number;
daily_chance_of_snow: number;
uv: number;
condition: WapiCondition;
};
astro: { sunrise: string; sunset: string }; // "07:31 AM", local time
hour: WapiHour[];
}
interface WapiResponse {
location: { tz_id: string; localtime_epoch: number; localtime: string };
current: {
last_updated_epoch: number;
temp_c: number;
feelslike_c: number;
humidity: number;
wind_kph: number;
wind_degree: number;
gust_kph: number;
pressure_mb: number;
dewpoint_c?: number;
vis_km: number;
uv: number;
cloud: number;
precip_mm: number;
is_day: 0 | 1;
condition: WapiCondition;
};
forecast: { forecastday: WapiDay[] };
alerts?: {
alert: { headline: string; event: string; severity: string; effective: string; expires: string; desc: string; instruction: string }[];
};
}
// Condition codes: https://www.weatherapi.com/docs/weather_conditions.json
const STORM_CODES = [1087, 1273, 1276, 1279, 1282];
const FOG_CODES = [1030, 1135, 1147];
const CLOUD_CODES = [1003, 1006, 1009];
const SNOW_CODES = [1066, 1069, 1072, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1249, 1252, 1255, 1258, 1261, 1264];
function toCondition(code: number): Condition {
if (code === 1000) return "clear";
if (STORM_CODES.includes(code)) return "storm";
if (FOG_CODES.includes(code)) return "fog";
if (CLOUD_CODES.includes(code)) return "clouds";
if (SNOW_CODES.includes(code)) return "snow";
return "rain"; // everything left is some flavour of rain or drizzle
}
/**
* Sunrise comes as "07:31 AM" in local time. To turn that into a real instant
* we need the location's UTC offset, which we work out from `localtime`
* (the local wall-clock time) versus `localtime_epoch` (the true instant).
*/
function utcOffsetSeconds(location: WapiResponse["location"]): number {
const wallClockAsUtc = Date.parse(`${location.localtime.replace(" ", "T")}:00Z`) / 1000;
return Math.round((wallClockAsUtc - location.localtime_epoch) / 60) * 60;
}
function localTimeToIso(date: string, clock: string, offsetSeconds: number): string | null {
const match = clock.match(/(\d+):(\d+)\s*(AM|PM)/i);
if (!match) return null; // e.g. "No sunrise" near the poles
let hours = Number(match[1]) % 12;
if (match[3].toUpperCase() === "PM") hours += 12;
const wallClockAsUtc = Date.parse(`${date}T00:00:00Z`) + (hours * 60 + Number(match[2])) * 60_000;
return new Date(wallClockAsUtc - offsetSeconds * 1000).toISOString();
}
export const weatherApi: WeatherProvider = {
id: "weatherapi",
label: "WeatherAPI",
envKey: "WEATHERAPI_API_KEY",
signupUrl: "https://www.weatherapi.com/signup.aspx",
// Set WEATHERAPI_PLAN=paid in .env.local if your plan includes 14 days.
limits: () => ({ forecastDays: process.env.WEATHERAPI_PLAN === "paid" ? 14 : 3, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const key = readKey("WEATHERAPI_API_KEY");
const url = `https://api.weatherapi.com/v1/forecast.json?key=${key}&q=${lat},${lon}&days=${MAX_DAYS}&aqi=no&alerts=yes`;
const data = await fetchJson<WapiResponse>(url, "WeatherAPI");
const now = data.current;
const offset = utcOffsetSeconds(data.location);
const days = data.forecast.forecastday;
const hourly = days.flatMap((day) =>
day.hour.map((h) => ({
time: fromUnix(h.time_epoch),
tempC: round1(h.temp_c),
feelsLikeC: round1(h.feelslike_c),
condition: toCondition(h.condition.code),
isDay: h.is_day === 1,
precipChance: Math.max(h.chance_of_rain, h.chance_of_snow),
precipMm: maybe(h.precip_mm),
windKph: round1(h.wind_kph),
gustKph: maybe(h.gust_kph),
windDeg: h.wind_degree,
humidity: h.humidity,
uvIndex: maybe(h.uv),
})),
);
return {
timeZone: data.location.tz_id,
hourlyStepHours: 1,
current: {
time: fromUnix(now.last_updated_epoch),
tempC: round1(now.temp_c),
feelsLikeC: round1(now.feelslike_c),
condition: toCondition(now.condition.code),
description: now.condition.text.toLowerCase(),
isDay: now.is_day === 1,
humidity: now.humidity,
windKph: round1(now.wind_kph),
gustKph: maybe(now.gust_kph),
windDeg: now.wind_degree,
pressureHpa: maybe(now.pressure_mb),
dewPointC: maybe(now.dewpoint_c),
visibilityKm: maybe(now.vis_km),
uvIndex: maybe(now.uv),
cloudCover: now.cloud,
precipMm: maybe(now.precip_mm),
},
hourly: nextHours(hourly, MAX_HOURS),
daily: days.map((day) => ({
time: `${day.date}T12:00:00.000Z`, // midday UTC lands on the right date almost everywhere
highC: round1(day.day.maxtemp_c),
lowC: round1(day.day.mintemp_c),
condition: toCondition(day.day.condition.code),
summary: day.day.condition.text,
precipChance: Math.max(day.day.daily_chance_of_rain, day.day.daily_chance_of_snow),
precipMm: maybe(day.day.totalprecip_mm),
windKph: maybe(day.day.maxwind_kph),
gustKph: null,
windDeg: null,
uvIndexMax: maybe(day.day.uv),
humidity: maybe(day.day.avghumidity),
sunrise: localTimeToIso(day.date, day.astro.sunrise, offset),
sunset: localTimeToIso(day.date, day.astro.sunset, offset),
})),
alerts: (data.alerts?.alert ?? []).map((a) => ({
title: a.headline || a.event,
severity: toSeverity(a.severity),
source: "WeatherAPI",
starts: a.effective || null,
ends: a.expires || null,
description: a.desc,
instruction: a.instruction || null,
})),
};
},
};

35
lib/weather/respond.ts Normal file
View file

@ -0,0 +1,35 @@
import { NextResponse, type NextRequest } from "next/server";
import { ProviderError } from "./http";
/** Turns any thrown error into a JSON `{ error }` response with a sensible status. */
export function errorResponse(error: unknown): NextResponse {
if (error instanceof ProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error(error);
return NextResponse.json({ error: "Something went wrong reaching the weather service" }, { status: 500 });
}
export function badRequest(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 400 });
}
/** Rejects cross-site browser requests so external pages can't hotlink your API/tile quotas. */
export function forbidCrossSite(request: NextRequest): NextResponse | null {
if (request.headers.get("sec-fetch-site") === "cross-site") {
return NextResponse.json({ error: "Cross-site requests are not permitted" }, { status: 403 });
}
return null;
}
/** Parses a lat/lon pair from query params, or returns null if either is missing or invalid. */
export function parseCoords(params: URLSearchParams): { lat: number; lon: number } | null {
const rawLat = params.get("lat")?.trim() ?? "";
const rawLon = params.get("lon")?.trim() ?? "";
if (!rawLat || !rawLon) return null;
const lat = Number(rawLat);
const lon = Number(rawLon);
const valid = Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
return valid ? { lat, lon } : null;
}

144
lib/weather/types.ts Normal file
View file

@ -0,0 +1,144 @@
/**
* The one shape the UI understands.
*
* Every provider returns wildly different JSON. Each adapter in ./providers
* translates its provider's response into these types, so the UI never has
* to know which provider it is talking to.
*
* Rules every adapter follows:
* - Units are metric: °C, km/h, hPa, km, mm.
* - Times are ISO-8601 instants in UTC ("2026-10-21T23:00:00.000Z").
* The UI formats them in the location's time zone.
* - A reading the provider doesn't offer is `null`, and the UI hides it.
*/
export type ProviderId = "openweather" | "weatherapi" | "tomorrow" | "openmeteo";
/** A deliberately small set of conditions — one per pixel icon. */
export type Condition = "clear" | "clouds" | "rain" | "storm" | "snow" | "fog";
export interface Place {
name: string;
region?: string;
country?: string;
lat: number;
lon: number;
}
export interface CurrentWeather {
time: string;
tempC: number;
feelsLikeC: number;
condition: Condition;
description: string; // human words, e.g. "light rain"
isDay: boolean;
humidity: number; // %
windKph: number;
gustKph: number | null;
windDeg: number | null; // direction the wind blows FROM, 0 = north
pressureHpa: number | null;
dewPointC: number | null;
visibilityKm: number | null;
uvIndex: number | null;
cloudCover: number | null; // %
precipMm: number | null; // in the last hour
}
export interface HourlyForecast {
time: string; // start of the hour (or 3-hour block)
tempC: number;
feelsLikeC: number | null;
condition: Condition;
isDay: boolean;
precipChance: number | null; // %
precipMm: number | null;
windKph: number;
gustKph: number | null;
windDeg: number | null;
humidity: number | null;
uvIndex: number | null;
}
export interface DailyForecast {
time: string; // any instant inside that local day (midday where possible)
highC: number;
lowC: number;
condition: Condition;
summary: string | null; // provider's own one-liner, if it has one
precipChance: number | null;
precipMm: number | null;
windKph: number | null; // daily max
gustKph: number | null;
windDeg: number | null;
uvIndexMax: number | null;
humidity: number | null; // daily average
sunrise: string | null;
sunset: string | null;
}
export type AlertSeverity = "extreme" | "severe" | "moderate" | "minor" | "unknown";
export interface WeatherAlert {
title: string;
severity: AlertSeverity;
source: string; // who issued it, e.g. "NWS Twin Cities/Chanhassen MN"
starts: string | null;
ends: string | null;
description: string;
instruction: string | null;
}
export interface AirQuality {
usAqi: number; // US EPA scale, 0–500
pm25: number | null; // µg/m³
pm10: number | null;
ozone: number | null;
no2: number | null;
}
/** What one provider adapter returns. */
export interface ProviderForecast {
current: CurrentWeather;
hourly: HourlyForecast[];
daily: DailyForecast[];
hourlyStepHours: 1 | 3;
timeZone: string | null; // IANA name, if the provider tells us
alerts: WeatherAlert[];
}
/** What /api/weather returns: the provider's forecast plus extras from other sources. */
export interface WeatherReport extends ProviderForecast {
provider: ProviderId;
timeZone: string | null;
airQuality: AirQuality | null;
alertSource: string | null;
fetchedAt: string;
}
/** The contract each provider adapter fulfils. */
export interface WeatherProvider {
id: ProviderId;
label: string;
/** Name of the env var holding its key, or null if no key is needed. */
envKey: string | null;
signupUrl: string;
/** What this provider can deliver on the plan configured in .env.local. */
limits(): { forecastDays: number; hourlyStepHours: 1 | 3 };
getForecast(lat: number, lon: number): Promise<ProviderForecast>;
}
/** Safe-to-share provider info for the browser (never includes keys). */
export interface ProviderInfo {
id: ProviderId;
label: string;
ready: boolean;
signupUrl: string;
forecastDays: number;
hourlyStepHours: 1 | 3;
}
/** Map overlays the radar panel can show. */
export interface RadarFrame {
time: string;
url: string; // Leaflet tile template with {z}/{x}/{y}
}