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:
@@ -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*([^<]+?):(?: |\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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user