Add static HTML report generator with Chart.js charts

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>
This commit is contained in:
2026-06-16 11:18:02 -04:00
parent 2b9d3e6d68
commit fd9491f869
2 changed files with 646 additions and 0 deletions

403
generate_report.py Normal file
View File

@ -0,0 +1,403 @@
"""
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) &nbsp;|&nbsp;
Not seasonally adjusted &nbsp;|&nbsp;
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 &nbsp;|&nbsp; YoY = year-over-year net change &nbsp;|&nbsp; 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()