diff --git a/dc_md_va_unemployment_report.html b/dc_md_va_unemployment_report.html new file mode 100644 index 0000000..93238b0 --- /dev/null +++ b/dc_md_va_unemployment_report.html @@ -0,0 +1,243 @@ + + + + + +DC / MD / VA Unemployment Report — June 16, 2026 + + + + + +
+

DC / MD / VA Unemployment Report

+
+ Source: BLS Local Area Unemployment Statistics (LAUS)  |  + Not seasonally adjusted  |  + Generated June 16, 2026 +
+
+ +
+ +
+
District of Columbia
+
5.5%
+
April 2026
+
+
MoM ▼ -0.2 pp
+
YoY — 0.0 pp
+
+
+ +
+
Maryland
+
4.4%
+
April 2026
+
+
MoM — 0.0 pp
+
YoY ▲ +1.0 pp
+
+
+ +
+
Virginia
+
3.4%
+
April 2026
+
+
MoM ▼ -0.4 pp
+
YoY ▲ +0.4 pp
+
+
+ +
+
DC Metro MSA
+
3.9%
+
April 2026
+
+
MoM ▼ -0.2 pp
+
YoY ▲ +0.6 pp
+
+
+
+ +
+

Unemployment Rate Trend — Last 24 Months

+
+ +
+

▲ Red = rate rising (more unemployment). ▼ Green = rate falling.

+
+ +
+

Current Month Summary

+ + + + +
MeasureDistrict of ColumbiaMarylandVirginiaDC Metro MSA
Unemp Rate +
5.5%
+
MoM ▼ -0.2 pp
+
YoY — 0.0 pp
+
+
4.4%
+
MoM — 0.0 pp
+
YoY ▲ +1.0 pp
+
+
3.4%
+
MoM ▼ -0.4 pp
+
YoY ▲ +0.4 pp
+
+
3.9%
+
MoM ▼ -0.2 pp
+
YoY ▲ +0.6 pp
+
Unemployed +
22,184
+
MoM ▼ -694.0
+
YoY ▼ -676.0
+
+
139,946
+
MoM ▼ -646.0
+
YoY ▲ +28851.0
+
+
151,108
+
MoM ▼ -19833.0
+
YoY ▲ +16516.0
+
+
134,887
+
MoM ▼ -7625.0
+
YoY ▲ +17530.0
+
Employed +
379,272
+
MoM ▼ -495.0
+
YoY ▼ -9795.0
+
+
3,066,322
+
MoM ▼ -4868.0
+
YoY ▼ -53005.0
+
+
4,333,772
+
MoM ▲ +15266.0
+
YoY ▼ -83211.0
+
+
3,314,544
+
MoM ▲ +5437.0
+
YoY ▼ -100507.0
+
Labor Force +
401,456
+
MoM ▼ -1189.0
+
YoY
+
+
3,206,268
+
MoM ▼ -5514.0
+
YoY
+
+
4,484,880
+
MoM ▼ -4567.0
+
YoY
+
+
3,449,431
+
MoM ▼ -2188.0
+
YoY
+
+

MoM = month-over-month net change  |  YoY = year-over-year net change  |  pp = percentage points

+
+ + + + + diff --git a/generate_report.py b/generate_report.py new file mode 100644 index 0000000..630ce1f --- /dev/null +++ b/generate_report.py @@ -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 '' + try: + n = float(val) + if n > 0: + return f'▲ {n:+.1f}' + if n < 0: + return f'▼ {n:.1f}' + return f'— {n:.1f}' + except (ValueError, TypeError): + return '' + + +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""" +
+
{LABELS[geo]}
+
{rate}
+
{period}
+
+
MoM {mom} pp
+
YoY {yoy} pp
+
+
""") + 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"{label}" + 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""" +
{val}
+
MoM {mom_html}{' ' + suffix if suffix else ''}
+
YoY {yoy_html}{' ' + suffix if suffix else ''}
+ """ + row += "" + rows.append(row) + geo_headers = "".join(f'{LABELS[g]}' for g in GEOS) + return f""" + + {geo_headers} + {"".join(rows)} +
Measure
""" + + +# --------------------------------------------------------------------------- +# Main HTML template +# --------------------------------------------------------------------------- +HTML_TEMPLATE = """ + + + + +DC / MD / VA Unemployment Report — {report_date} + + + + + +
+

DC / MD / VA Unemployment Report

+
+ Source: BLS Local Area Unemployment Statistics (LAUS)  |  + Not seasonally adjusted  |  + Generated {report_date} +
+
+ +
+{cards} +
+ +
+

Unemployment Rate Trend — Last 24 Months

+
+ +
+

▲ Red = rate rising (more unemployment). ▼ Green = rate falling.

+
+ +
+

Current Month Summary

+{summary_table} +

MoM = month-over-month net change  |  YoY = year-over-year net change  |  pp = percentage points

+
+ + + + + +""" + + +# --------------------------------------------------------------------------- +# 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()