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:
243
dc_md_va_unemployment_report.html
Normal file
243
dc_md_va_unemployment_report.html
Normal file
@ -0,0 +1,243 @@
|
||||
<!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 — June 16, 2026</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 June 16, 2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card" style="border-top: 4px solid #c0392b">
|
||||
<div class="card-geo">District of Columbia</div>
|
||||
<div class="card-rate" style="color:#c0392b">5.5%</div>
|
||||
<div class="card-period">April 2026</div>
|
||||
<div class="card-changes">
|
||||
<div><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div>
|
||||
<div><span class="chg-label">YoY</span> <span class="neutral">— 0.0</span> pp</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-top: 4px solid #2471a3">
|
||||
<div class="card-geo">Maryland</div>
|
||||
<div class="card-rate" style="color:#2471a3">4.4%</div>
|
||||
<div class="card-period">April 2026</div>
|
||||
<div class="card-changes">
|
||||
<div><span class="chg-label">MoM</span> <span class="neutral">— 0.0</span> pp</div>
|
||||
<div><span class="chg-label">YoY</span> <span class="up">▲ +1.0</span> pp</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-top: 4px solid #1e8449">
|
||||
<div class="card-geo">Virginia</div>
|
||||
<div class="card-rate" style="color:#1e8449">3.4%</div>
|
||||
<div class="card-period">April 2026</div>
|
||||
<div class="card-changes">
|
||||
<div><span class="chg-label">MoM</span> <span class="down">▼ -0.4</span> pp</div>
|
||||
<div><span class="chg-label">YoY</span> <span class="up">▲ +0.4</span> pp</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-top: 4px solid #7d3c98">
|
||||
<div class="card-geo">DC Metro MSA</div>
|
||||
<div class="card-rate" style="color:#7d3c98">3.9%</div>
|
||||
<div class="card-period">April 2026</div>
|
||||
<div class="card-changes">
|
||||
<div><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div>
|
||||
<div><span class="chg-label">YoY</span> <span class="up">▲ +0.6</span> pp</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Measure</th><th style="color:#c0392b">District of Columbia</th><th style="color:#2471a3">Maryland</th><th style="color:#1e8449">Virginia</th><th style="color:#7d3c98">DC Metro MSA</th></tr></thead>
|
||||
<tbody><tr><td><strong>Unemp Rate</strong></td><td>
|
||||
<div class="tval">5.5%</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral">— 0.0</span> pp</div>
|
||||
</td><td>
|
||||
<div class="tval">4.4%</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="neutral">— 0.0</span> pp</div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +1.0</span> pp</div>
|
||||
</td><td>
|
||||
<div class="tval">3.4%</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.4</span> pp</div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +0.4</span> pp</div>
|
||||
</td><td>
|
||||
<div class="tval">3.9%</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +0.6</span> pp</div>
|
||||
</td></tr><tr><td><strong>Unemployed</strong></td><td>
|
||||
<div class="tval">22,184</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -694.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -676.0</span></div>
|
||||
</td><td>
|
||||
<div class="tval">139,946</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -646.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +28851.0</span></div>
|
||||
</td><td>
|
||||
<div class="tval">151,108</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -19833.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +16516.0</span></div>
|
||||
</td><td>
|
||||
<div class="tval">134,887</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -7625.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +17530.0</span></div>
|
||||
</td></tr><tr><td><strong>Employed</strong></td><td>
|
||||
<div class="tval">379,272</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -495.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -9795.0</span></div>
|
||||
</td><td>
|
||||
<div class="tval">3,066,322</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -4868.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -53005.0</span></div>
|
||||
</td><td>
|
||||
<div class="tval">4,333,772</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="up">▲ +15266.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -83211.0</span></div>
|
||||
</td><td>
|
||||
<div class="tval">3,314,544</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="up">▲ +5437.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -100507.0</span></div>
|
||||
</td></tr><tr><td><strong>Labor Force</strong></td><td>
|
||||
<div class="tval">401,456</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -1189.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral">—</span></div>
|
||||
</td><td>
|
||||
<div class="tval">3,206,268</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -5514.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral">—</span></div>
|
||||
</td><td>
|
||||
<div class="tval">4,484,880</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -4567.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral">—</span></div>
|
||||
</td><td>
|
||||
<div class="tval">3,449,431</div>
|
||||
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -2188.0</span></div>
|
||||
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral">—</span></div>
|
||||
</td></tr></tbody>
|
||||
</table>
|
||||
<p class="note">MoM = month-over-month net change | YoY = year-over-year net change | pp = percentage points</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const rateData = {"labels": ["Apr 2024", "May 2024", "Jun 2024", "Jul 2024", "Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024", "Jan 2025", "Feb 2025", "Mar 2025", "Apr 2025", "May 2025", "Jun 2025", "Jul 2025", "Aug 2025", "Sep 2025", "Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026", "Mar 2026", "Apr 2026"], "datasets": [{"label": "District of Columbia", "data": [4.5, 5.0, 5.7, 6.0, 6.1, 5.3, 5.2, 5.2, 5.1, 5.8, 6.0, 6.1, 5.5, 5.8, 6.4, 6.8, 7.1, 6.8, 6.8, 6.4, 6.2, 6.1, 5.7, 5.5], "borderColor": "#c0392b", "backgroundColor": "#c0392b22", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Maryland", "data": [2.6, 2.7, 3.5, 3.6, 3.6, 3.1, 3.3, 3.4, 3.0, 3.6, 3.9, 3.8, 3.4, 3.7, 4.2, 4.4, 4.6, 4.3, 4.2, 3.7, 4.6, 4.9, 4.4, 4.4], "borderColor": "#2471a3", "backgroundColor": "#2471a322", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Virginia", "data": [2.5, 2.8, 3.1, 3.2, 3.4, 2.9, 2.9, 3.1, 2.7, 3.2, 3.3, 3.3, 3.0, 3.3, 3.5, 3.5, 3.6, 3.4, 3.9, 3.5, 3.9, 3.9, 3.8, 3.4], "borderColor": "#1e8449", "backgroundColor": "#1e844922", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "DC Metro MSA", "data": [2.6, 2.8, 3.3, 3.4, 3.5, 3.1, 3.2, 3.2, 2.9, 3.4, 3.6, 3.6, 3.3, 3.6, 3.9, 4.1, 4.2, 4.1, 4.3, 3.8, 4.3, 4.5, 4.1, 3.9], "borderColor": "#7d3c98", "backgroundColor": "#7d3c9822", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}]};
|
||||
|
||||
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>
|
||||
403
generate_report.py
Normal file
403
generate_report.py
Normal 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) |
|
||||
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()
|
||||
Reference in New Issue
Block a user