Files
hobbs-iac/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/markets/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

135 lines
4.7 KiB
TypeScript

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