Generates dc_md_va_unemployment_report.html: 4 stat cards (DC/MD/VA/DC MSA), 24-month unemployment rate trend line chart, and current-month summary table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""
|
||
DC/MD/VA Unemployment — Static HTML Report Generator
|
||
Run: python3 generate_report.py
|
||
Output: dc_md_va_unemployment_report.html (open in any browser)
|
||
"""
|
||
|
||
import requests
|
||
import json
|
||
from datetime import datetime
|
||
from config import BLS_API_KEY, BLS_API_BASE
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Series config (same as dashboard script)
|
||
# ---------------------------------------------------------------------------
|
||
GEOS = ["DC", "MD", "VA", "DCMSA"]
|
||
|
||
LABELS = {
|
||
"DC": "District of Columbia",
|
||
"MD": "Maryland",
|
||
"VA": "Virginia",
|
||
"DCMSA": "DC Metro MSA",
|
||
}
|
||
|
||
COLORS = {
|
||
"DC": "#c0392b",
|
||
"MD": "#2471a3",
|
||
"VA": "#1e8449",
|
||
"DCMSA": "#7d3c98",
|
||
}
|
||
|
||
SERIES = {
|
||
"DC_rate": "LAUST110000000000003",
|
||
"DC_unemployed": "LAUST110000000000004",
|
||
"DC_employed": "LAUST110000000000005",
|
||
"DC_laborforce": "LAUST110000000000006",
|
||
"MD_rate": "LAUST240000000000003",
|
||
"MD_unemployed": "LAUST240000000000004",
|
||
"MD_employed": "LAUST240000000000005",
|
||
"MD_laborforce": "LAUST240000000000006",
|
||
"VA_rate": "LAUST510000000000003",
|
||
"VA_unemployed": "LAUST510000000000004",
|
||
"VA_employed": "LAUST510000000000005",
|
||
"VA_laborforce": "LAUST510000000000006",
|
||
"DCMSA_rate": "LAUMT114790000000003",
|
||
"DCMSA_unemployed":"LAUMT114790000000004",
|
||
"DCMSA_employed": "LAUMT114790000000005",
|
||
"DCMSA_laborforce":"LAUMT114790000000006",
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Data fetching
|
||
# ---------------------------------------------------------------------------
|
||
def fetch(series_ids, start_year, end_year):
|
||
payload = {
|
||
"seriesid": series_ids,
|
||
"startyear": str(start_year),
|
||
"endyear": str(end_year),
|
||
"registrationkey": BLS_API_KEY,
|
||
"catalog": True,
|
||
"calculations": True,
|
||
"annualaverage": False,
|
||
}
|
||
r = requests.post(f"{BLS_API_BASE}/timeseries/data/", json=payload, timeout=30)
|
||
r.raise_for_status()
|
||
body = r.json()
|
||
if body["status"] != "REQUEST_SUCCEEDED":
|
||
raise RuntimeError(f"BLS API error: {body['message']}")
|
||
return {s["seriesID"]: s["data"] for s in body["Results"]["series"]}
|
||
|
||
|
||
def latest(data):
|
||
for obs in data:
|
||
if obs["value"] != "-":
|
||
return obs
|
||
return {}
|
||
|
||
|
||
def chg(obs, window):
|
||
try:
|
||
return obs["calculations"]["net_changes"].get(window)
|
||
except (KeyError, TypeError):
|
||
return None
|
||
|
||
|
||
def pct_chg(obs, window):
|
||
try:
|
||
return obs["calculations"]["pct_changes"].get(window)
|
||
except (KeyError, TypeError):
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Prepare chart data — chronological, last 24 months, no gaps
|
||
# ---------------------------------------------------------------------------
|
||
MONTH_ORDER = {f"M{i:02d}": i for i in range(1, 13)}
|
||
MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||
|
||
|
||
def sort_obs(data):
|
||
"""Return observations sorted chronologically, monthly only, no nulls."""
|
||
monthly = [o for o in data if o["period"].startswith("M") and o["value"] != "-"]
|
||
return sorted(monthly, key=lambda o: (o["year"], MONTH_ORDER.get(o["period"], 0)))
|
||
|
||
|
||
def build_rate_chart_data(results, months=24):
|
||
"""Return labels list and per-geo value lists for the rate trend chart."""
|
||
# Build a merged label set from all four geos
|
||
all_labels = {}
|
||
for geo in GEOS:
|
||
sid = SERIES[f"{geo}_rate"]
|
||
for obs in sort_obs(results.get(sid, [])):
|
||
key = (obs["year"], obs["period"])
|
||
m = MONTH_ORDER.get(obs["period"], 0)
|
||
label = f"{MONTH_NAMES[m]} {obs['year']}"
|
||
all_labels[key] = label
|
||
|
||
sorted_keys = sorted(all_labels.keys())[-months:]
|
||
labels = [all_labels[k] for k in sorted_keys]
|
||
|
||
datasets = []
|
||
for geo in GEOS:
|
||
sid = SERIES[f"{geo}_rate"]
|
||
obs_by_key = {
|
||
(o["year"], o["period"]): float(o["value"])
|
||
for o in sort_obs(results.get(sid, []))
|
||
}
|
||
values = [obs_by_key.get(k) for k in sorted_keys]
|
||
datasets.append({
|
||
"label": LABELS[geo],
|
||
"data": values,
|
||
"borderColor": COLORS[geo],
|
||
"backgroundColor": COLORS[geo] + "22",
|
||
"borderWidth": 2,
|
||
"pointRadius": 2,
|
||
"tension": 0.3,
|
||
"fill": False,
|
||
})
|
||
|
||
return labels, datasets
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTML helpers
|
||
# ---------------------------------------------------------------------------
|
||
def arrow(val):
|
||
if val is None:
|
||
return '<span class="neutral">—</span>'
|
||
try:
|
||
n = float(val)
|
||
if n > 0:
|
||
return f'<span class="up">▲ {n:+.1f}</span>'
|
||
if n < 0:
|
||
return f'<span class="down">▼ {n:.1f}</span>'
|
||
return f'<span class="neutral">— {n:.1f}</span>'
|
||
except (ValueError, TypeError):
|
||
return '<span class="neutral">—</span>'
|
||
|
||
|
||
def fmt_rate(val):
|
||
try:
|
||
return f"{float(val):.1f}%"
|
||
except (ValueError, TypeError):
|
||
return "—"
|
||
|
||
|
||
def fmt_num(val):
|
||
try:
|
||
return f"{float(val):,.0f}"
|
||
except (ValueError, TypeError):
|
||
return "—"
|
||
|
||
|
||
def build_cards(results):
|
||
cards_html = []
|
||
for geo in GEOS:
|
||
sid = SERIES[f"{geo}_rate"]
|
||
obs = latest(results.get(sid, []))
|
||
rate = fmt_rate(obs.get("value"))
|
||
mom = arrow(chg(obs, "1"))
|
||
yoy = arrow(chg(obs, "12"))
|
||
period = f"{obs.get('periodName', '')} {obs.get('year', '')}"
|
||
color = COLORS[geo]
|
||
cards_html.append(f"""
|
||
<div class="card" style="border-top: 4px solid {color}">
|
||
<div class="card-geo">{LABELS[geo]}</div>
|
||
<div class="card-rate" style="color:{color}">{rate}</div>
|
||
<div class="card-period">{period}</div>
|
||
<div class="card-changes">
|
||
<div><span class="chg-label">MoM</span> {mom} pp</div>
|
||
<div><span class="chg-label">YoY</span> {yoy} pp</div>
|
||
</div>
|
||
</div>""")
|
||
return "\n".join(cards_html)
|
||
|
||
|
||
def build_summary_table(results):
|
||
measures = [
|
||
("rate", "Unemp Rate", True),
|
||
("unemployed", "Unemployed", False),
|
||
("employed", "Employed", False),
|
||
("laborforce", "Labor Force", False),
|
||
]
|
||
rows = []
|
||
for measure, label, is_rate in measures:
|
||
row = f"<tr><td><strong>{label}</strong></td>"
|
||
for geo in GEOS:
|
||
sid = SERIES[f"{geo}_{measure}"]
|
||
obs = latest(results.get(sid, []))
|
||
val = fmt_rate(obs.get("value")) if is_rate else fmt_num(obs.get("value"))
|
||
mom = chg(obs, "1")
|
||
yoy = chg(obs, "12")
|
||
suffix = "pp" if is_rate else ""
|
||
mom_html = arrow(mom)
|
||
yoy_html = arrow(yoy)
|
||
row += f"""<td>
|
||
<div class="tval">{val}</div>
|
||
<div class="tchg"><span class="chg-label">MoM</span> {mom_html}{' ' + suffix if suffix else ''}</div>
|
||
<div class="tchg"><span class="chg-label">YoY</span> {yoy_html}{' ' + suffix if suffix else ''}</div>
|
||
</td>"""
|
||
row += "</tr>"
|
||
rows.append(row)
|
||
geo_headers = "".join(f'<th style="color:{COLORS[g]}">{LABELS[g]}</th>' for g in GEOS)
|
||
return f"""
|
||
<table>
|
||
<thead><tr><th>Measure</th>{geo_headers}</tr></thead>
|
||
<tbody>{"".join(rows)}</tbody>
|
||
</table>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main HTML template
|
||
# ---------------------------------------------------------------------------
|
||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DC / MD / VA Unemployment Report — {report_date}</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
background: #f4f6f9;
|
||
color: #222;
|
||
margin: 0;
|
||
padding: 24px;
|
||
}}
|
||
.header {{
|
||
background: #1a2744;
|
||
color: #fff;
|
||
padding: 24px 32px;
|
||
border-radius: 8px;
|
||
margin-bottom: 24px;
|
||
}}
|
||
.header h1 {{ margin: 0 0 4px; font-size: 1.5rem; }}
|
||
.header .subtitle {{ opacity: 0.7; font-size: 0.875rem; }}
|
||
.cards {{
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}}
|
||
.card {{
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||
}}
|
||
.card-geo {{ font-size: 0.8rem; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: .05em; color: #666; margin-bottom: 6px; }}
|
||
.card-rate {{ font-size: 2.2rem; font-weight: 700; line-height: 1; margin-bottom: 4px; }}
|
||
.card-period {{ font-size: 0.75rem; color: #888; margin-bottom: 12px; }}
|
||
.card-changes {{ display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; }}
|
||
.panel {{
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||
margin-bottom: 24px;
|
||
}}
|
||
.panel h2 {{ margin: 0 0 20px; font-size: 1rem; color: #333; }}
|
||
.chart-wrap {{ position: relative; height: 320px; }}
|
||
table {{ width: 100%; border-collapse: collapse; font-size: 0.875rem; }}
|
||
th {{ text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb;
|
||
font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; }}
|
||
td {{ padding: 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
|
||
tr:last-child td {{ border-bottom: none; }}
|
||
.tval {{ font-weight: 600; font-size: 1rem; }}
|
||
.tchg {{ font-size: 0.75rem; color: #555; margin-top: 2px; }}
|
||
.up {{ color: #c0392b; font-weight: 600; }}
|
||
.down {{ color: #1e8449; font-weight: 600; }}
|
||
.neutral {{ color: #888; }}
|
||
.chg-label {{ background: #eef0f3; border-radius: 3px; padding: 1px 4px;
|
||
font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; }}
|
||
.note {{ font-size: 0.75rem; color: #999; margin-top: 8px; }}
|
||
@media (max-width: 900px) {{
|
||
.cards {{ grid-template-columns: repeat(2, 1fr); }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<h1>DC / MD / VA Unemployment Report</h1>
|
||
<div class="subtitle">
|
||
Source: BLS Local Area Unemployment Statistics (LAUS) |
|
||
Not seasonally adjusted |
|
||
Generated {report_date}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cards">
|
||
{cards}
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>Unemployment Rate Trend — Last 24 Months</h2>
|
||
<div class="chart-wrap">
|
||
<canvas id="rateChart"></canvas>
|
||
</div>
|
||
<p class="note">▲ Red = rate rising (more unemployment). ▼ Green = rate falling.</p>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>Current Month Summary</h2>
|
||
{summary_table}
|
||
<p class="note">MoM = month-over-month net change | YoY = year-over-year net change | pp = percentage points</p>
|
||
</div>
|
||
|
||
<script>
|
||
const rateData = {rate_chart_json};
|
||
|
||
new Chart(document.getElementById("rateChart"), {{
|
||
type: "line",
|
||
data: {{
|
||
labels: rateData.labels,
|
||
datasets: rateData.datasets,
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {{ mode: "index", intersect: false }},
|
||
plugins: {{
|
||
legend: {{ position: "top", labels: {{ usePointStyle: true, padding: 16 }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
label: ctx => ` ${{ctx.dataset.label}}: ${{ctx.parsed.y !== null ? ctx.parsed.y.toFixed(1) + "%" : "N/A"}}`
|
||
}}
|
||
}}
|
||
}},
|
||
scales: {{
|
||
y: {{
|
||
title: {{ display: true, text: "Unemployment Rate (%)" }},
|
||
ticks: {{ callback: v => v + "%" }},
|
||
grid: {{ color: "#f0f0f0" }},
|
||
}},
|
||
x: {{
|
||
grid: {{ display: false }},
|
||
ticks: {{ maxTicksLimit: 12 }},
|
||
}}
|
||
}}
|
||
}}
|
||
}});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry point
|
||
# ---------------------------------------------------------------------------
|
||
def main():
|
||
current_year = datetime.now().year
|
||
start_year = current_year - 2 # 3 years covers 24+ months for the chart
|
||
|
||
print(f"Fetching {len(SERIES)} LAUS series ({start_year}–{current_year})...")
|
||
results = fetch(list(SERIES.values()), start_year, current_year)
|
||
|
||
report_date = datetime.now().strftime("%B %d, %Y")
|
||
|
||
labels, datasets = build_rate_chart_data(results, months=24)
|
||
rate_chart_json = json.dumps({"labels": labels, "datasets": datasets})
|
||
|
||
html = HTML_TEMPLATE.format(
|
||
report_date=report_date,
|
||
cards=build_cards(results),
|
||
summary_table=build_summary_table(results),
|
||
rate_chart_json=rate_chart_json,
|
||
)
|
||
|
||
out = "dc_md_va_unemployment_report.html"
|
||
with open(out, "w") as f:
|
||
f.write(html)
|
||
|
||
print(f"Report written → {out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|