▍ humdrum codex / soft
3.6 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
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<Config, Payload> = {
  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<string, (number | null)[]>;
    };

    const c = j.current ?? ({} as NonNullable<typeof j.current>);
    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,
    };
  },
};