The dashboard would hang indefinitely on the loading screen because: 1. `bls.mjs` used a raw `fetch()` without any timeout/AbortSignal — if the BLS API was slow or unresponsive, it would block forever. 2. `runSource()` in `briefing.mjs` had no per-source timeout, so a single hanging API could stall the entire sweep indefinitely. 3. `server.mjs` loaded cached `latest.json` via a fire-and-forget promise (`.then()`) instead of `await`, meaning the dashboard never received the cached data before the sweep started. 4. `loading.html` relied solely on SSE for redirect — if the SSE connection missed the update event, the page would never redirect. Changes: - Add 15s AbortController timeout to BLS `getSeries()` fetch call - Add 30s per-source timeout via `Promise.race()` in `runSource()` - Await `synthesize()` when loading cached data so the dashboard serves instantly on restart when `runs/latest.json` exists - Add 5s fallback polling to loading page alongside SSE Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
5.3 KiB
JavaScript
167 lines
5.3 KiB
JavaScript
// BLS — Bureau of Labor Statistics
|
|
// CPI, unemployment, nonfarm payrolls, PPI. No auth required (v1 API).
|
|
// v2 with registration key supports more requests; v1 is rate-limited but functional.
|
|
|
|
import { safeFetch, daysAgo } from '../utils/fetch.mjs';
|
|
|
|
const V1_BASE = 'https://api.bls.gov/publicAPI/v1/timeseries/data/';
|
|
const V2_BASE = 'https://api.bls.gov/publicAPI/v2/timeseries/data/';
|
|
|
|
// Key economic series
|
|
const SERIES = {
|
|
'CUUR0000SA0': 'CPI-U All Items',
|
|
'CUUR0000SA0L1E': 'CPI-U Core (ex Food & Energy)',
|
|
'LNS14000000': 'Unemployment Rate',
|
|
'CES0000000001': 'Nonfarm Payrolls (thousands)',
|
|
'WPUFD49104': 'PPI Final Demand',
|
|
};
|
|
|
|
// Fetch a single series via GET (v1, no key needed)
|
|
export async function getSeriesV1(seriesId) {
|
|
return safeFetch(`${V1_BASE}/${seriesId}`);
|
|
}
|
|
|
|
// Fetch one or more series via POST (v2 if key available, v1 otherwise)
|
|
export async function getSeries(seriesIds, opts = {}) {
|
|
const { startYear, endYear, apiKey } = opts;
|
|
const now = new Date();
|
|
const start = startYear || String(now.getFullYear() - 1);
|
|
const end = endYear || String(now.getFullYear());
|
|
|
|
const base = apiKey ? V2_BASE : V1_BASE;
|
|
const payload = {
|
|
seriesid: Array.isArray(seriesIds) ? seriesIds : [seriesIds],
|
|
startyear: start,
|
|
endyear: end,
|
|
};
|
|
if (apiKey) payload.registrationkey = apiKey;
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 15000);
|
|
const res = await fetch(base, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
return await res.json();
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
// Extract the latest observation from a BLS series response
|
|
function latestFromSeries(seriesData) {
|
|
if (!seriesData?.data?.length) return null;
|
|
// BLS returns data sorted by year desc, period desc
|
|
// Filter out unavailable values (BLS uses "-" for missing data)
|
|
const valid = seriesData.data.filter(d => d.value !== '-' && d.value !== '.');
|
|
if (!valid.length) return null;
|
|
const sorted = [...valid].sort((a, b) => {
|
|
const ya = parseInt(a.year), yb = parseInt(b.year);
|
|
if (ya !== yb) return yb - ya;
|
|
// period is M01..M12 or M13 (annual avg) or Q01..Q05
|
|
return b.period.localeCompare(a.period);
|
|
});
|
|
return sorted[0];
|
|
}
|
|
|
|
// Get the two most recent observations to compute month-over-month change
|
|
function momChange(seriesData) {
|
|
if (!seriesData?.data?.length || seriesData.data.length < 2) return null;
|
|
const sorted = [...seriesData.data]
|
|
.filter(d => d.period.startsWith('M') && d.period !== 'M13' && d.value !== '-' && d.value !== '.')
|
|
.sort((a, b) => {
|
|
const ya = parseInt(a.year), yb = parseInt(b.year);
|
|
if (ya !== yb) return yb - ya;
|
|
return b.period.localeCompare(a.period);
|
|
});
|
|
if (sorted.length < 2) return null;
|
|
const curr = parseFloat(sorted[0].value);
|
|
const prev = parseFloat(sorted[1].value);
|
|
if (isNaN(curr) || isNaN(prev) || prev === 0) return null;
|
|
return {
|
|
current: curr,
|
|
previous: prev,
|
|
change: +(curr - prev).toFixed(4),
|
|
changePct: +(((curr - prev) / prev) * 100).toFixed(4),
|
|
currentPeriod: `${sorted[0].year}-${sorted[0].period}`,
|
|
previousPeriod: `${sorted[1].year}-${sorted[1].period}`,
|
|
};
|
|
}
|
|
|
|
// Briefing — pull latest CPI, unemployment, payrolls
|
|
export async function briefing(apiKey) {
|
|
const seriesIds = Object.keys(SERIES);
|
|
const resp = await getSeries(seriesIds, { apiKey });
|
|
|
|
if (resp.error) {
|
|
return { source: 'BLS', error: resp.error, timestamp: new Date().toISOString() };
|
|
}
|
|
|
|
if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) {
|
|
return {
|
|
source: 'BLS',
|
|
error: resp.message?.[0] || 'BLS API returned no data',
|
|
rawStatus: resp.status,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
const indicators = [];
|
|
const signals = [];
|
|
|
|
for (const s of resp.Results.series) {
|
|
const id = s.seriesID;
|
|
const label = SERIES[id] || id;
|
|
const latest = latestFromSeries(s);
|
|
const mom = momChange(s);
|
|
|
|
if (!latest) {
|
|
indicators.push({ id, label, value: null, date: null });
|
|
continue;
|
|
}
|
|
|
|
const value = parseFloat(latest.value);
|
|
const period = `${latest.year}-${latest.period}`;
|
|
|
|
indicators.push({
|
|
id,
|
|
label,
|
|
value,
|
|
period,
|
|
date: latest.year + '-' + latest.period.replace('M', '').padStart(2, '0'),
|
|
momChange: mom ? mom.change : null,
|
|
momChangePct: mom ? mom.changePct : null,
|
|
});
|
|
|
|
// Generate signals
|
|
if (id === 'LNS14000000' && value > 5.0) {
|
|
signals.push(`Unemployment elevated at ${value}%`);
|
|
}
|
|
if (id === 'CUUR0000SA0' && mom && mom.changePct > 0.4) {
|
|
signals.push(`CPI-U MoM jump: ${mom.changePct}% (${mom.previousPeriod} -> ${mom.currentPeriod})`);
|
|
}
|
|
if (id === 'CUUR0000SA0L1E' && mom && mom.changePct > 0.3) {
|
|
signals.push(`Core CPI MoM rising: ${mom.changePct}%`);
|
|
}
|
|
if (id === 'CES0000000001' && mom && mom.change < -50) {
|
|
signals.push(`Nonfarm payrolls dropped by ${Math.abs(mom.change)}K`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
source: 'BLS',
|
|
timestamp: new Date().toISOString(),
|
|
indicators,
|
|
signals,
|
|
};
|
|
}
|
|
|
|
if (process.argv[1]?.endsWith('bls.mjs')) {
|
|
const data = await briefing(process.env.BLS_API_KEY);
|
|
console.log(JSON.stringify(data, null, 2));
|
|
}
|