type WeatherIcon = "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm"; type NwsPeriod = { number?: number; startTime?: string; isDaytime?: boolean; temperature?: number; temperatureUnit?: string; shortForecast?: string; probabilityOfPrecipitation?: { value?: number | null }; }; type NwsForecast = { properties?: { periods?: NwsPeriod[] }; }; type DayAggregate = { date: string; high?: number; low?: number; temperatures: number[]; condition: string; icon: WeatherIcon; precipitationChance: number; }; function numericSetting(name: string, fallback: number, minimum: number, maximum: number) { const value = Number(process.env[name] ?? fallback); return Number.isFinite(value) && value >= minimum && value <= maximum ? value : fallback; } const latitude = numericSetting("WEATHER_LATITUDE", 35.9987, -90, 90); const longitude = numericSetting("WEATHER_LONGITUDE", -96.11417, -180, 180); const location = (process.env.WEATHER_LOCATION || "Sapulpa, OK").trim().slice(0, 100); const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago"; const userAgent = (process.env.WEATHER_USER_AGENT || "Hobbs-Family-Organizer/1.0 (self-hosted)").trim().slice(0, 200); function configuredForecastUrl() { const value = new URL(process.env.NWS_FORECAST_URL || "https://api.weather.gov/gridpoints/TSA/37,98/forecast"); if (value.protocol !== "https:" || value.hostname !== "api.weather.gov") { throw new Error("NWS forecast URL must use https://api.weather.gov"); } return value; } export const dynamic = "force-dynamic"; function weatherIcon(condition: string): WeatherIcon { const value = condition.toLowerCase(); if (value.includes("thunder")) return "storm"; if (value.includes("snow") || value.includes("sleet") || value.includes("ice")) return "snow"; if (value.includes("rain") || value.includes("shower") || value.includes("drizzle")) return "rain"; if (value.includes("fog") || value.includes("haze")) return "fog"; if (value.includes("partly") || value.includes("mostly sunny") || value.includes("mostly clear")) return "partly-cloudy"; if (value.includes("cloud") || value.includes("overcast")) return "cloudy"; return "clear"; } function fahrenheit(value: number, unit?: string) { return unit?.toUpperCase() === "C" ? (value * 9) / 5 + 32 : value; } function normalizeDegrees(value: number) { return ((value % 360) + 360) % 360; } function normalizeHours(value: number) { return ((value % 24) + 24) % 24; } function dayOfYear(dateKey: string) { const [year, month, day] = dateKey.split("-").map(Number); const current = Date.UTC(year, month - 1, day); return Math.floor((current - Date.UTC(year, 0, 0)) / 86_400_000); } function solarTime(dateKey: string, sunrise: boolean) { const radians = (degrees: number) => degrees * Math.PI / 180; const degrees = (value: number) => value * 180 / Math.PI; const longitudeHour = longitude / 15; const approximateTime = dayOfYear(dateKey) + (((sunrise ? 6 : 18) - longitudeHour) / 24); const meanAnomaly = (0.9856 * approximateTime) - 3.289; const trueLongitude = normalizeDegrees( meanAnomaly + (1.916 * Math.sin(radians(meanAnomaly))) + (0.02 * Math.sin(radians(2 * meanAnomaly))) + 282.634, ); let rightAscension = normalizeDegrees(degrees(Math.atan(0.91764 * Math.tan(radians(trueLongitude))))); rightAscension += Math.floor(trueLongitude / 90) * 90 - Math.floor(rightAscension / 90) * 90; rightAscension /= 15; const sinDeclination = 0.39782 * Math.sin(radians(trueLongitude)); const cosDeclination = Math.cos(Math.asin(sinDeclination)); const cosHour = ( Math.cos(radians(90.833)) - (sinDeclination * Math.sin(radians(latitude))) ) / (cosDeclination * Math.cos(radians(latitude))); if (cosHour > 1 || cosHour < -1) return ""; const hourAngle = (sunrise ? 360 - degrees(Math.acos(cosHour)) : degrees(Math.acos(cosHour))) / 15; const localMeanTime = hourAngle + rightAscension - (0.06571 * approximateTime) - 6.622; const utcHours = normalizeHours(localMeanTime - longitudeHour); const [year, month, day] = dateKey.split("-").map(Number); const instant = new Date(Date.UTC(year, month - 1, day, 0, Math.round(utcHours * 60))); const parts = new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(instant); const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? "00"; return `${dateKey}T${part("hour")}:${part("minute")}`; } function dailyForecast(periods: NwsPeriod[]) { const days = new Map(); for (const period of periods) { if (!period.startTime || typeof period.temperature !== "number") continue; const date = period.startTime.slice(0, 10); const temperature = fahrenheit(period.temperature, period.temperatureUnit); const condition = period.shortForecast?.trim() || "Clear"; const existing = days.get(date) ?? { date, temperatures: [], condition, icon: weatherIcon(condition), precipitationChance: 0, }; existing.temperatures.push(temperature); if (period.isDaytime) { existing.high = temperature; existing.condition = condition; existing.icon = weatherIcon(condition); } else { existing.low = temperature; } existing.precipitationChance = Math.max( existing.precipitationChance, Number(period.probabilityOfPrecipitation?.value) || 0, ); days.set(date, existing); } return [...days.values()].slice(0, 7).map((day) => ({ date: day.date, code: 0, condition: day.condition, icon: day.icon, high: day.high ?? Math.max(...day.temperatures), low: day.low ?? Math.min(...day.temperatures), sunrise: solarTime(day.date, true), sunset: solarTime(day.date, false), precipitationChance: day.precipitationChance, })); } export async function GET() { try { const response = await fetch(configuredForecastUrl(), { headers: { Accept: "application/geo+json", "User-Agent": userAgent, }, signal: AbortSignal.timeout(8_000), }); if (!response.ok) throw new Error(`NWS returned ${response.status}`); const payload = await response.json() as NwsForecast; const items = dailyForecast(payload.properties?.periods ?? []); if (items.length < 7) throw new Error("NWS forecast did not include seven days"); return Response.json( { location, latitude, longitude, source: "National Weather Service", items, updatedAt: new Date().toISOString() }, { headers: { "Cache-Control": "public, max-age=900, stale-while-revalidate=1800" } }, ); } catch (error) { console.error("Weather refresh failed", error instanceof Error ? error.message : "Unknown error"); return Response.json( { location, items: [], updatedAt: new Date().toISOString() }, { status: 503, headers: { "Cache-Control": "no-store" } }, ); } }