e9c7e9df1a
- 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
135 lines
4.7 KiB
TypeScript
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*([^<]+?):(?: |\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(/−/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",
|
|
},
|
|
},
|
|
);
|
|
}
|