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
This commit is contained in:
2026-11-04 18:27:22 -06:00
parent 122ea392d1
commit e9c7e9df1a
68 changed files with 12831 additions and 0 deletions
@@ -0,0 +1,297 @@
import ICAL from "ical.js";
type CalendarTone = "cyan" | "amber" | "violet" | "emerald" | "rose";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: CalendarTone;
};
const tones: CalendarTone[] = ["cyan", "amber", "violet", "emerald", "rose"];
const maxFeedBytes = 5 * 1024 * 1024;
const maxOccurrencesPerEvent = 10_000;
export const dynamic = "force-dynamic";
function cleanText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
}
function calendarDate(value: ICAL.Time) {
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
function organizerDate(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
function addCalendarDays(isoDate: string, days: number) {
const [year, month, day] = isoDate.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1, day + days));
return value.toISOString().slice(0, 10);
}
function hashId(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function parseFeedUrls() {
const raw = process.env.CALENDAR_ICAL_URLS;
if (!raw) return [];
const values = JSON.parse(raw) as unknown;
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
throw new Error("Calendar feed configuration is invalid");
}
return values.map((value) => {
const url = new URL(String(value));
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar feed host is not allowed");
}
return url.toString();
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Calendar feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
function componentString(component: ICAL.Component, name: string) {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : "";
}
function isCancelled(event: ICAL.Event) {
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
}
function detach(component: ICAL.Component) {
return new ICAL.Component(structuredClone(component.toJSON()));
}
function eventIsInWindow(
start: ICAL.Time,
end: ICAL.Time,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
if (start.isDate) {
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
}
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
}
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
}
function serializeOccurrence(
event: ICAL.Event,
start: ICAL.Time,
end: ICAL.Time,
calendar: string,
tone: CalendarTone,
feedIndex: number,
) {
const allDay = start.isDate;
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
const uid = cleanText(event.uid, "event", 180);
const location = cleanText(event.location, "", 120);
return {
id: hashId(`${feedIndex}|${uid}|${startValue}`),
calendar,
title: cleanText(event.summary, "Busy", 140),
...(location ? { location } : {}),
start: startValue,
end: endValue,
allDay,
tone,
} satisfies CalendarEvent;
}
function expandCalendar(
ics: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
const root = new ICAL.Component(ICAL.parse(ics));
for (const timezone of root.getAllSubcomponents("vtimezone")) {
try {
ICAL.TimezoneService.register(timezone);
} catch {
// A feed may repeat a timezone that was already registered.
}
}
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
const calendar = sourceName.includes("@") ? "Personal" : sourceName;
const tone = tones[feedIndex % tones.length];
const components = root.getAllSubcomponents("vevent");
const exceptions = new Map<string, ICAL.Component[]>();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) continue;
const uid = componentString(component, "uid");
if (!uid) continue;
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
}
const items: CalendarEvent[] = [];
for (const component of components) {
if (component.hasProperty("recurrence-id")) continue;
try {
const event = new ICAL.Event(detach(component));
for (const exception of exceptions.get(event.uid) ?? []) {
event.relateException(detach(exception));
}
if (isCancelled(event)) continue;
if (!event.isRecurring()) {
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
}
continue;
}
const iterator = event.iterator();
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
const occurrence = iterator.next();
if (!occurrence) break;
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
const details = event.getOccurrenceDetails(occurrence);
if (isCancelled(details.item)) continue;
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
}
}
} catch {
// Ignore a malformed event while keeping the rest of the calendar usable.
}
}
return { calendar, items };
}
async function readCalendar(
url: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
let currentUrl = url;
let response: Response | undefined;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const validatedUrl = new URL(currentUrl);
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar redirect host is not allowed");
}
response = await fetch(validatedUrl, {
headers: { Accept: "text/calendar" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
currentUrl = new URL(location, validatedUrl).toString();
}
if (!response) throw new Error("Calendar feed request failed");
if (!response.ok) throw new Error("Calendar feed request failed");
return expandCalendar(await readLimitedText(response), feedIndex, windowStart, horizon, windowStartDate, horizonDate);
}
export async function GET() {
let urls: string[];
try {
urls = parseFeedUrls();
} catch {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
if (!urls.length) {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
const now = Date.now();
const windowStart = now - 45 * 24 * 60 * 60 * 1_000;
const horizon = now + 60 * 24 * 60 * 60 * 1_000;
const today = organizerDate(new Date(now));
const windowStartDate = addCalendarDays(today, -45);
const horizonDate = addCalendarDays(today, 60);
const results = await Promise.allSettled(
urls.map((url, index) => readCalendar(url, index, windowStart, horizon, windowStartDate, horizonDate)),
);
const successful = results.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof readCalendar>>> => result.status === "fulfilled");
const items = successful
.flatMap((result) => result.value.items)
.filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index)
.sort((left, right) => {
const leftValue = left.allDay ? `${left.start}T00:00:00` : left.start;
const rightValue = right.allDay ? `${right.start}T00:00:00` : right.start;
return leftValue.localeCompare(rightValue);
})
.slice(0, 160);
return Response.json(
{
items,
calendarsAvailable: successful.length,
calendarsTotal: urls.length,
updatedAt: new Date().toISOString(),
},
{
status: successful.length ? 200 : 503,
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=240" },
},
);
}
@@ -0,0 +1,17 @@
export const dynamic = "force-dynamic";
export async function GET() {
const configuredTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
let timeZone = "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: configuredTimeZone }).format(new Date());
timeZone = configuredTimeZone;
} catch {
// Keep a valid default if a deployment typo reaches the process.
}
return Response.json(
{ timeZone },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}
@@ -0,0 +1,126 @@
type FeedSource = {
name: string;
url: string;
};
type FeedItem = {
source: string;
title: string;
url: string;
publishedAt: string | null;
};
const feeds: FeedSource[] = [
{ name: "OPENAI", url: "https://openai.com/news/rss.xml" },
{ name: "GOOGLE AI", url: "https://blog.google/technology/ai/rss/" },
{ name: "DEEPMIND", url: "https://deepmind.google/blog/rss.xml" },
{ name: "NVIDIA", url: "https://nvidianews.nvidia.com/cats/generative_al.xml" },
{ name: "ARS TECHNICA", url: "https://feeds.arstechnica.com/arstechnica/technology-lab" },
{ name: "WIRED", url: "https://www.wired.com/feed/tag/ai/latest/rss" },
];
const maxFeedBytes = 1024 * 1024;
export const dynamic = "force-dynamic";
function decodeEntities(value: string) {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/\s+/g, " ")
.trim();
}
function extract(block: string, names: string[]) {
for (const name of names) {
const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "i"));
if (match?.[1]) return decodeEntities(match[1]);
}
return "";
}
function extractLink(block: string) {
const atomLink = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1];
if (atomLink) return decodeEntities(atomLink);
return extract(block, ["link", "guid"]);
}
function parseFeed(xml: string, source: string): FeedItem[] {
const blocks = xml.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi)
?? xml.match(/<entry(?:\s[^>]*)?>[\s\S]*?<\/entry>/gi)
?? [];
return blocks.slice(0, 6).flatMap((block) => {
const title = extract(block, ["title"]);
const url = extractLink(block);
const rawDate = extract(block, ["pubDate", "published", "updated", "dc:date"]);
const parsedDate = rawDate ? new Date(rawDate) : null;
if (!title || !url) return [];
return [{
source,
title: title.slice(0, 180),
url,
publishedAt: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
}];
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Headline feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Headline feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
async function readFeed(feed: FeedSource) {
const response = await fetch(feed.url, {
headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" },
signal: AbortSignal.timeout(6_000),
});
if (!response.ok) throw new Error(`${feed.name} returned ${response.status}`);
return parseFeed(await readLimitedText(response), feed.name);
}
export async function GET() {
const results = await Promise.allSettled(feeds.map(readFeed));
const items = results
.flatMap((result) => result.status === "fulfilled" ? result.value : [])
.filter((item, index, all) => all.findIndex((candidate) => candidate.url === item.url || candidate.title === item.title) === index)
.sort((a, b) => (Date.parse(b.publishedAt ?? "") || 0) - (Date.parse(a.publishedAt ?? "") || 0))
.slice(0, 10);
return Response.json(
{
items,
updatedAt: new Date().toISOString(),
feedsAvailable: results.filter((result) => result.status === "fulfilled").length,
feedsTotal: feeds.length,
},
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=900",
},
},
);
}
@@ -0,0 +1,23 @@
export const dynamic = "force-dynamic";
export async function GET() {
let calendarConfigurationValid = false;
try {
const parsed = JSON.parse(process.env.CALENDAR_ICAL_URLS || "[]") as unknown;
calendarConfigurationValid = Array.isArray(parsed) && parsed.length > 0;
} catch {
calendarConfigurationValid = false;
}
return Response.json(
{
status: "ok",
service: "hobbs-family-organizer",
version: "1.0.0",
calendarConfigurationValid,
authenticationEnabled: Boolean(process.env.ORGANIZER_ACCESS_TOKEN),
checkedAt: new Date().toISOString(),
},
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,134 @@
type Direction = "up" | "down" | "flat";
type FredSeries = {
id: string;
symbol: string;
name: string;
currency: boolean;
decimals: number;
multiplier?: number;
};
type Observation = {
date: string;
value: number;
};
const series: FredSeries[] = [
{ id: "SP500", symbol: "S&P 500", name: "Index", currency: false, decimals: 2 },
{ id: "NASDAQCOM", symbol: "NASDAQ", name: "Index", currency: false, decimals: 2 },
{ id: "DJIA", symbol: "DOW", name: "Index", currency: false, decimals: 2 },
{ id: "DCOILWTICO", symbol: "WTI", name: "USD / barrel", currency: true, decimals: 2 },
{ id: "DHHNGSP", symbol: "NAT GAS", name: "USD / MMBtu", currency: true, decimals: 2 },
{ id: "PCOPPUSDM", symbol: "COPPER", name: "USD / lb", currency: true, decimals: 2, multiplier: 1 / 2_204.62262185 },
];
const maxResponseBytes = 1024 * 1024;
const monthNumbers: Record<string, string> = {
Jan: "01",
Feb: "02",
Mar: "03",
Apr: "04",
May: "05",
Jun: "06",
Jul: "07",
Aug: "08",
Sep: "09",
Oct: "10",
Nov: "11",
Dec: "12",
};
export const dynamic = "force-dynamic";
function normalizeDate(value: string) {
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) return value;
const monthly = value.match(/^([A-Z][a-z]{2})\s+(\d{4})$/);
if (monthly && monthNumbers[monthly[1]]) return `${monthly[2]}-${monthNumbers[monthly[1]]}-01`;
return null;
}
function parseSeriesPage(html: string) {
const observations: Observation[] = [];
for (const row of html.match(/<tr(?:\s[^>]*)?>[\s\S]*?<\/tr>/gi) ?? []) {
const rawDate = row.match(/<td[^>]*>\s*([^<]+?):(?:&nbsp;|\s)*<\/td>/i)?.[1]?.trim();
const rawValue = row.match(/<td[^>]*class=["'][^"']*\bseries-obs\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i)?.[1];
if (!rawDate || !rawValue) continue;
const date = normalizeDate(rawDate);
const value = Number(rawValue.replace(/<[^>]+>/g, "").replace(/,/g, "").replace(/&minus;/g, "-").trim());
if (!date || !Number.isFinite(value)) continue;
observations.push({ date, value });
}
return observations.sort((left, right) => left.date.localeCompare(right.date));
}
function formatValue(value: number, config: FredSeries) {
const formatted = new Intl.NumberFormat("en-US", {
minimumFractionDigits: config.decimals,
maximumFractionDigits: config.decimals,
}).format(value);
return config.currency ? `$${formatted}` : formatted;
}
async function readSeries(config: FredSeries) {
const url = new URL(`/series/${encodeURIComponent(config.id)}`, "https://fred.stlouisfed.org");
const response = await fetch(url, {
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": "Dayline-TV/1.0 (+https://fred.stlouisfed.org)",
},
signal: AbortSignal.timeout(12_000),
});
if (!response.ok) throw new Error(`${config.id} returned ${response.status}`);
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const html = await response.text();
if (new TextEncoder().encode(html).byteLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const observations = parseSeriesPage(html);
if (observations.length < 2) throw new Error(`${config.id} has fewer than two observations`);
const previous = observations.at(-2)!;
const latest = observations.at(-1)!;
const multiplier = config.multiplier ?? 1;
const value = latest.value * multiplier;
const move = ((latest.value - previous.value) / previous.value) * 100;
const direction: Direction = move > 0.004 ? "up" : move < -0.004 ? "down" : "flat";
return {
symbol: config.symbol,
name: config.name,
value: formatValue(value, config),
move: `${move > 0 ? "+" : ""}${move.toFixed(2)}%`,
direction,
seriesId: config.id,
observedAt: latest.date,
};
}
export async function GET() {
const results: PromiseSettledResult<Awaited<ReturnType<typeof readSeries>>>[] = [];
for (let index = 0; index < series.length; index += 2) {
const batch = await Promise.allSettled(series.slice(index, index + 2).map(readSeries));
results.push(...batch);
}
const items = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
return Response.json(
{
items,
seriesAvailable: items.length,
seriesTotal: series.length,
updatedAt: new Date().toISOString(),
},
{
status: items.length ? 200 : 503,
headers: {
"Cache-Control": "public, max-age=900, stale-while-revalidate=21600",
"X-Content-Type-Options": "nosniff",
},
},
);
}
@@ -0,0 +1,183 @@
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" } },
);
}
}