Files
hobbs-iac/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/weather/route.ts
T
jeremy e9c7e9df1a feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:27:22 -06:00

184 lines
7.0 KiB
TypeScript

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<string, DayAggregate>();
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" } },
);
}
}