import type { SourceModule } from "../registry"; import { getSettings } from "@/lib/store"; import { AqiConfig, AqiPayload, type AqiConfig as Config, type AqiPayload as Payload, } from "@/lib/schemas/sources/aqi"; // Same location resolution as weather: config → settings → env. 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(aqi: number | null): string { if (aqi == null) return "—"; if (aqi <= 50) return "Good"; if (aqi <= 100) return "Moderate"; if (aqi <= 150) return "Unhealthy (sensitive)"; if (aqi <= 200) return "Unhealthy"; if (aqi <= 300) return "Very unhealthy"; return "Hazardous"; } // Pollen is reported in grains/m³ (Europe-only in Open-Meteo). Rough banding. function pollenLevel(v: number): string { if (v < 10) return "Low"; if (v < 30) return "Moderate"; if (v < 70) return "High"; return "Very High"; } const POLLENS: Array<[string, string]> = [ ["grass_pollen", "Grass"], ["birch_pollen", "Birch"], ["alder_pollen", "Alder"], ["ragweed_pollen", "Ragweed"], ["mugwort_pollen", "Mugwort"], ["olive_pollen", "Olive"], ]; export const aqiModule: SourceModule = { kind: "aqi", label: "Air Quality", keyless: true, defaultRefreshSeconds: 3600, configSchema: AqiConfig, payloadSchema: AqiPayload, 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 url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality"); url.searchParams.set("latitude", String(place.lat)); url.searchParams.set("longitude", String(place.lon)); url.searchParams.set("timezone", "auto"); url.searchParams.set("current", "us_aqi,pm2_5,pm10,ozone"); url.searchParams.set("hourly", POLLENS.map(([k]) => k).join(",")); const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`Air quality ${res.status}`); const j = (await res.json()) as { current?: { time: string; us_aqi?: number | null; pm2_5?: number | null; pm10?: number | null; ozone?: number | null; }; hourly?: { time: string[] } & Record; }; const c = j.current ?? ({} as NonNullable); const aqi = c.us_aqi ?? null; // Pollen: pick the hourly slot nearest the current hour; keep non-zero types. const pollen: Payload["pollen"] = []; if (j.hourly?.time?.length) { const nowHour = (c.time ?? j.hourly.time[0]).slice(0, 13); let idx = j.hourly.time.findIndex((t) => t.slice(0, 13) === nowHour); if (idx < 0) idx = 0; for (const [key, label] of POLLENS) { const v = j.hourly[key]?.[idx]; if (typeof v === "number" && v > 0) { pollen.push({ label, value: Math.round(v), level: pollenLevel(v) }); } } } return { location: place.label, aqi, category: aqiCategory(aqi), pm25: c.pm2_5 ?? null, pm10: c.pm10 ?? null, ozone: c.ozone ?? null, pollen, }; }, };