import type { SourceModule } from "../registry"; import { getSettings } from "@/lib/store"; import { WeatherConfig, WeatherPayload, type WeatherConfig as Config, type WeatherPayload as Payload, } from "@/lib/schemas/sources/weather"; // WMO weather interpretation codes → short text. const WMO: Record = { 0: "Clear", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast", 45: "Fog", 48: "Rime fog", 51: "Light drizzle", 53: "Drizzle", 55: "Dense drizzle", 56: "Freezing drizzle", 57: "Freezing drizzle", 61: "Light rain", 63: "Rain", 65: "Heavy rain", 66: "Freezing rain", 67: "Freezing rain", 71: "Light snow", 73: "Snow", 75: "Heavy snow", 77: "Snow grains", 80: "Light showers", 81: "Showers", 82: "Violent showers", 85: "Snow showers", 86: "Snow showers", 95: "Thunderstorm", 96: "Thunderstorm + hail", 99: "Thunderstorm + hail", }; const codeText = (c: number) => WMO[c] ?? "—"; function resolveLocation(config: Config): { lat: number; lon: number; label: string } | null { if (config.lat != null && config.lon != null) { return { lat: config.lat, lon: config.lon, label: config.label ?? "Custom" }; } const loc = getSettings().location; if (loc) return { lat: loc.lat, lon: loc.lon, label: loc.label || "Home" }; const envLat = process.env.WEATHER_LAT; const envLon = process.env.WEATHER_LON; if (envLat && envLon) return { lat: Number(envLat), lon: Number(envLon), label: "Home" }; return null; } function aqiCategory(v: number | null): string { if (v == null) return ""; if (v <= 50) return "Good"; if (v <= 100) return "Moderate"; if (v <= 150) return "Unhealthy (sensitive)"; if (v <= 200) return "Unhealthy"; if (v <= 300) return "Very Unhealthy"; return "Hazardous"; } function pollenLevel(v: number): string { if (v < 10) return "Low"; if (v < 30) return "Moderate"; if (v < 70) return "High"; return "Very High"; } const POLLEN_KEYS: Array<[string, string]> = [ ["grass_pollen", "Grass"], ["birch_pollen", "Birch"], ["alder_pollen", "Alder"], ["ragweed_pollen", "Ragweed"], ["mugwort_pollen", "Mugwort"], ["olive_pollen", "Olive"], ]; const SEVERITY_RANK: Record = { Extreme: 4, Severe: 3, Moderate: 2, Minor: 1, Unknown: 0, }; function moonPhase(date: Date): { phase: string; emoji: string } { // Reference new moon: Jan 6 2000 18:14 UTC const refNewMoon = Date.UTC(2000, 0, 6, 18, 14, 0); const synodicPeriod = 29.53058867; const days = (date.getTime() - refNewMoon) / 86_400_000; const pos = ((days % synodicPeriod) + synodicPeriod) % synodicPeriod; if (pos < 1.85) return { phase: "New Moon", emoji: "🌑" }; if (pos < 7.38) return { phase: "Waxing Crescent", emoji: "🌒" }; if (pos < 9.22) return { phase: "First Quarter", emoji: "🌓" }; if (pos < 14.77) return { phase: "Waxing Gibbous", emoji: "🌔" }; if (pos < 16.61) return { phase: "Full Moon", emoji: "🌕" }; if (pos < 22.15) return { phase: "Waning Gibbous", emoji: "🌖" }; if (pos < 23.99) return { phase: "Last Quarter", emoji: "🌗" }; return { phase: "Waning Crescent", emoji: "🌘" }; } export const weatherModule: SourceModule = { kind: "weather", label: "Weather", keyless: true, defaultRefreshSeconds: 1800, configSchema: WeatherConfig, payloadSchema: WeatherPayload, isConfigured: (config) => resolveLocation(config) !== null, async fetch({ config, signal }) { const place = resolveLocation(config); if (!place) throw new Error("No location set — add one in Settings."); const units = getSettings().units; const tempUnit = units === "metric" ? "celsius" : "fahrenheit"; const windUnit = units === "metric" ? "kmh" : "mph"; const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast"); weatherUrl.searchParams.set("latitude", String(place.lat)); weatherUrl.searchParams.set("longitude", String(place.lon)); weatherUrl.searchParams.set( "current", "temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,uv_index", ); weatherUrl.searchParams.set( "daily", "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max,sunrise,sunset", ); weatherUrl.searchParams.set( "hourly", "temperature_2m,weather_code,precipitation_probability", ); weatherUrl.searchParams.set("temperature_unit", tempUnit); weatherUrl.searchParams.set("wind_speed_unit", windUnit); weatherUrl.searchParams.set("timezone", "auto"); weatherUrl.searchParams.set("forecast_days", "3"); const aqiUrl = new URL("https://air-quality-api.open-meteo.com/v1/air-quality"); aqiUrl.searchParams.set("latitude", String(place.lat)); aqiUrl.searchParams.set("longitude", String(place.lon)); aqiUrl.searchParams.set("timezone", "auto"); aqiUrl.searchParams.set("current", "us_aqi,pm2_5"); aqiUrl.searchParams.set("hourly", POLLEN_KEYS.map(([k]) => k).join(",")); const alertsUrl = `https://api.weather.gov/alerts/active?point=${place.lat},${place.lon}`; const [weatherRes, aqiRes, alertsRes] = await Promise.all([ fetch(weatherUrl, { signal }), fetch(aqiUrl, { signal }).catch(() => null), fetch(alertsUrl, { signal, headers: { "User-Agent": "personal-dashboard/1.0" }, }).catch(() => null), ]); if (!weatherRes.ok) throw new Error(`Open-Meteo ${weatherRes.status}`); const j = (await weatherRes.json()) as { current: { temperature_2m: number; apparent_temperature: number; weather_code: number; wind_speed_10m: number; relative_humidity_2m: number; uv_index: number | null; }; daily: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weather_code: number[]; precipitation_probability_max: (number | null)[]; sunrise: string[]; sunset: string[]; }; hourly?: { time: string[]; temperature_2m: number[]; weather_code: number[]; precipitation_probability: (number | null)[]; }; }; // --- AQI + pollen --- let aqi: number | null = null; let aqiCat = ""; const pollen: Payload["pollen"] = []; if (aqiRes?.ok) { const aq = (await aqiRes.json()) as { current?: { time?: string; us_aqi?: number | null; pm2_5?: number | null }; hourly?: { time: string[] } & Record; }; aqi = aq.current?.us_aqi ?? null; aqiCat = aqiCategory(aqi); if (aq.hourly?.time?.length) { const nowHour = (aq.current?.time ?? aq.hourly.time[0]).slice(0, 13); let idx = aq.hourly.time.findIndex((t) => t.slice(0, 13) === nowHour); if (idx < 0) idx = 0; for (const [key, label] of POLLEN_KEYS) { const v = aq.hourly[key]?.[idx]; if (typeof v === "number" && v > 0) { pollen.push({ label, value: Math.round(v), level: pollenLevel(v) }); } } } } // --- NWS alerts (US only; non-US locations 404 or error → empty) --- const alerts: Payload["alerts"] = []; if (alertsRes?.ok) { const al = (await alertsRes.json()) as { features?: Array<{ properties: { event?: string; severity?: string; headline?: string; ends?: string | null; }; }>; }; const raw = (al.features ?? []) .filter((f) => f.properties.event) .map((f) => ({ event: f.properties.event!, severity: f.properties.severity ?? "Unknown", headline: f.properties.headline, ends: f.properties.ends ?? null, })); raw.sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)); alerts.push(...raw.slice(0, 3)); } // --- Daily --- const daily = j.daily.time.map((date, i) => ({ date, max: Math.round(j.daily.temperature_2m_max[i]), min: Math.round(j.daily.temperature_2m_min[i]), code: j.daily.weather_code[i], text: codeText(j.daily.weather_code[i]), precipProb: j.daily.precipitation_probability_max[i] ?? null, })); // --- Day-parts strip --- const today = j.daily.time[0]; const parts: Payload["parts"] = []; if (j.hourly && today) { const anchors: Array<[string, number]> = [ ["Morning", 8], ["Noon", 12], ["Evening", 18], ["Night", 22], ]; const hours = j.hourly.time .map((t, i) => ({ t, i, hr: Number(t.slice(11, 13)), day: t.slice(0, 10) })) .filter((h) => h.day === today && Number.isFinite(h.hr)); for (const [label, anchor] of anchors) { let best: (typeof hours)[number] | null = null; for (const h of hours) { if (!best || Math.abs(h.hr - anchor) < Math.abs(best.hr - anchor)) best = h; } if (!best) continue; const code = j.hourly.weather_code[best.i]; parts.push({ label, temp: Math.round(j.hourly.temperature_2m[best.i]), code, text: codeText(code), precipProb: j.hourly.precipitation_probability[best.i] ?? null, }); } } // --- Moon phase --- const moon = moonPhase(new Date()); return { location: place.label, units, current: { temp: Math.round(j.current.temperature_2m), feelsLike: Math.round(j.current.apparent_temperature), code: j.current.weather_code, text: codeText(j.current.weather_code), wind: Math.round(j.current.wind_speed_10m), humidity: j.current.relative_humidity_2m ?? null, uvIndex: j.current.uv_index != null ? Math.round(j.current.uv_index) : null, }, daily, parts, sunrise: j.daily.sunrise?.[0] ?? null, sunset: j.daily.sunset?.[0] ?? null, moonPhase: moon.phase, moonEmoji: moon.emoji, aqi, aqiCategory: aqiCat, pollen, alerts, }; }, };