How the /html-report Command Generates an Application Dashboard in MadsLorentzen/ai-job-search
The /html-report command creates a self-contained HTML dashboard by parsing job_search_tracker.csv and per-application outcome.md files, normalizing status data, computing summary statistics, and rendering inline SVG visualizations—no external libraries or network required.
The /html-report command is a core reporting feature in the MadsLorentzen/ai-job-search repository that transforms scattered job search data into a polished, offline-ready dashboard. This article breaks down how the command works, from argument parsing to the final HTML file, using the authoritative specification in /.claude/commands/html-report.md.
Step 0: Argument Parsing and Output Path Resolution
The command accepts three invocation patterns:
- No arguments → writes to
reports/application-dashboard.html - Path argument → writes to the user-supplied location
--openflag → after writing, prompts the user to open the file manually (the command never launches a browser directly)
If the reports/ directory does not exist, it is created on-demand. This design keeps the command flexible while providing sensible defaults.
Step 1: Data Collection from Two Parallel Sources
The dashboard pulls from two data sources simultaneously:
Primary Source: job_search_tracker.csv
Each row is parsed into a record with these fields:
fields = [
"date", "company", "sector", "role", "role_type",
"channel", "status", "contact_person", "fit_rating",
"notes", "cv_file", "cover_letter_file", "source"
]
Secondary Source: documents/applications/*/outcome.md
For every archived application, the outcome file is read to extract:
- Interview-stage check-boxes (e.g.,
[x] Interview,[ ] Offer) - Free-form notes
The outcome data merges into CSV rows via fuzzy matching on company+role (lower-cased, punctuation stripped). Unmatched outcomes still appear as extra context rather than being discarded.
Step 2: Status Normalization and Summary Statistics
Status values are normalized into five canonical buckets:
| Raw Status | Normalized Bucket |
|---|---|
applied, new |
Active |
interview, phone screen |
Interview |
offer |
Offer |
hired, accepted |
Hired |
| everything else | Rejected/Closed |
From normalized data, the command computes aggregates feeding the visualizations:
- Total applications
- Counts per status bucket
- Counts per sector and channel (online / referral / other)
- Year/season groupings from the
datefield - Funnel rate: percentage progressing past "Applied"
- Rejection rate:
Rejected/Closed ÷ (Total – Active)
Step 3: HTML Generation with Inline SVG Charts
The output is a single, self-contained HTML file with no external dependencies.
Architecture
| Component | Implementation |
|---|---|
| Styling | Inline <style> block |
| Interactivity | Inline <script> block |
| Charts | Hand-written inline SVG (no libraries, no <canvas>) |
Four SVG Visualizations
- Status doughnut — proportional slices from status counts
- Sector bar — horizontal bars, sorted descending
- Channel bar — distribution across online, referral, other
- Application funnel — progression from Applied → Interview → Offer → Hired
Security: HTML Escaping
All dynamic text is HTML-escaped (& < > " ') before insertion:
notes = html.escape(r["notes"][:80]) # truncated table display
full_notes = html.escape(r["notes"]) # tooltip content
company_cell = html.escape(r["company"]) # table cell
This prevents markup injection attacks and ensures safe rendering of user-generated notes.
Layout Specification
The dashboard follows the ASCII sketch defined in html-report.md:
- Colour-coded status pills per normalized bucket
- Responsive grid: two-column on wide screens, stacked on narrow
- Detailed applications table with sortable columns
- Footer: "Generated by Claude Code · ai-job-search · {ISO date}"
Step 4: File Output and Confirmation
The command writes the final HTML and prints confirmation:
✅ Dashboard generated: /absolute/path/to/reports/application-dashboard.html
┌─ Summary ─────────────────┐
│ Total applications: 47 │
│ Active: 12 | Interview: 8 │
│ Offer: 3 | Hired: 2 │
│ Rejected/Closed: 22 │
│ Funnel progression: 27% │
└───────────────────────────┘
The command is idempotent and non-destructive: it never mutates job_search_tracker.csv or outcome.md files, and re-running simply overwrites the previous report.
Complete Implementation Walkthrough
Below is a runnable Python skeleton matching the specification in html-report.md:
import csv
import pathlib
import re
import html
import datetime
from pathlib import Path
# Step 0: Argument handling (simplified)
out_path = Path("reports/application-dashboard.html")
# Step 1: Read CSV tracker
tracker = []
with open("job_search_tracker.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
tracker.append(row)
# Step 1: Read per-application outcomes
outcome_dir = Path("documents/applications")
outcomes = {}
for outcome_file in outcome_dir.rglob("outcome.md"):
txt = outcome_file.read_text(encoding="utf-8")
stages = re.findall(r"\[([ x])\]\s*(\w+)", txt)
key = outcome_file.parent.name.lower()
outcomes[key] = {
"stages": {s.lower(): (c == "x") for c, s in stages},
"notes": txt.strip(),
}
# Step 2: Normalize and merge
def norm_status(s):
s = s.lower()
if s in {"applied"}: return "Active"
if s in {"interview"}: return "Interview"
if s in {"offer"}: return "Offer"
if s in {"hired"}: return "Hired"
return "Rejected/Closed"
for rec in tracker:
merge_key = f"{rec['company']}{rec['role']}".lower()
merge_key = merge_key.translate(str.maketrans("", "", r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""))
out = outcomes.get(merge_key)
rec["status_bucket"] = norm_status(rec["status"])
rec["outcome"] = out
# Step 2: Compute statistics
stats = {
"total": len(tracker),
"by_status": {s: 0 for s in ["Active","Interview","Offer","Hired","Rejected/Closed"]},
"by_sector": {},
"by_channel": {},
}
for r in tracker:
stats["by_status"][r["status_bucket"]] += 1
stats["by_sector"][r["sector"]] = stats["by_sector"].get(r["sector"], 0) + 1
stats["by_channel"][r["channel"]] = stats["by_channel"].get(r["channel"], 0) + 1
# Calculate funnel rate
past_applied = sum(v for k, v in stats["by_status"].items() if k != "Active")
funnel_rate = (past_applied / stats["total"] * 100) if stats["total"] else 0
# Step 3: Build HTML
html_parts = [
"<!DOCTYPE html><html><head>",
"<meta charset='utf-8'><title>Job Search Dashboard</title>",
"<style>",
":root{--active:#3b82f6;--interview:#8b5cf6;--offer:#f59e0b;--hired:#10b981;--rejected:#ef4444;}",
"body{font-family:system-ui,sans-serif;max-width:1200px;margin:2rem auto;line-height:1.6}",
".stat-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:1rem;margin:1.5rem 0}",
".card{padding:1rem;border-radius:8px;background:#f8fafc;text-align:center}",
".pill{display:inline-block;padding:0.25rem 0.75rem;border-radius:9999px;font-size:0.875rem;color:#fff}",
".pill.active{background:var(--active)}.pill.interview{background:var(--interview)}",
".pill.offer{background:var(--offer)}.pill.hired{background:var(--hired)}.pill.rejected{background:var(--rejected)}",
"table{width:100%;border-collapse:collapse;margin-top:1rem}",
"th,td{padding:0.75rem;text-align:left;border-bottom:1px solid #e5e7eb}",
"th{font-weight:600}",
"</style></head><body>",
f"<h1>🔍 Job Search Dashboard – {datetime.date.today().isoformat()}</h1>",
"<div class='stat-cards'>",
f"<div class='card'>Total<br><strong>{stats['total']}</strong></div>",
f"<div class='card'>Active<br><strong>{stats['by_status']['Active']}</strong></div>",
f"<div class='card'>Interview<br><strong>{stats['by_status']['Interview']}</strong></div>",
f"<div class='card'>Offer<br><strong>{stats['by_status']['Offer']}</strong></div>",
f"<div class='card'>Hired<br><strong>{stats['by_status']['Hired']}</strong></div>",
f"<div class='card'>Rejected/Closed<br><strong>{stats['by_status']['Rejected/Closed']}</strong></div>",
f"</div><p>Funnel progression: <strong>{funnel_rate:.1f}%</strong></p>",
"<table><thead><tr>",
"<th>Date</th><th>Company</th><th>Role</th><th>Sector</th>",
"<th>Channel</th><th>Status</th><th>Notes</th><th>Source</th>",
"</tr></thead><tbody>",
]
for r in sorted(tracker, key=lambda x: (x["date"], x["company"]), reverse=True):
notes_short = html.escape(r["notes"][:80]) if r["notes"] else "—"
notes_full = html.escape(r["notes"]) if r["notes"] else ""
source = r.get("source", "")
source_html = f'<a href="{source}">{html.escape(source[:40])}…</a>' if source.startswith("http") else "—"
status_class = r["status_bucket"].lower().replace("/", "").replace(" ", "")
html_parts.append(
f"<tr>"
f"<td>{html.escape(r['date'])}</td>"
f"<td>{html.escape(r['company'])}</td>"
f"<td>{html.escape(r['role'])}</td>"
f"<td>{html.escape(r['sector'])}</td>"
f"<td>{html.escape(r['channel'])}</td>"
f"<td><span class='pill {status_class}'>{r['status_bucket']}</span></td>"
f"<td title='{notes_full}'>{notes_short}</td>"
f"<td>{source_html}</td>"
f"</tr>"
)
html_parts.extend([
"</tbody></table>",
f"<footer><p>Generated by Claude Code · ai-job-search · {datetime.datetime.now().isoformat()}</p></footer>",
"</body></html>",
])
# Step 4: Write output
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n".join(html_parts), encoding="utf-8")
print(f"✅ Dashboard generated: {out_path.resolve()}")
print(f" Total: {stats['total']} | Active: {stats['by_status']['Active']} | "
f"Interview: {stats['by_status']['Interview']} | Funnel: {funnel_rate:.1f}%")
This implementation mirrors the four-step pipeline from the specification: argument handling, data ingestion, normalization and statistics, then HTML generation with proper escaping.
Key Files in the Repository
| File | Purpose |
|---|---|
/.claude/commands/html-report.md |
Authoritative command specification defining the entire pipeline |
/tests/test_html_report_command.py |
Unit tests enforcing command file existence, header format (# /html-report), and lint compatibility |
| /tools/lint_skills.py | Linting utility validating command file structure |
| /.gitignore | Excludes reports/ directory to prevent committing generated dashboards |
Summary
- The
/html-reportcommand follows a deterministic four-step pipeline: argument parsing, data collection, statistics computation, and HTML generation. - Data merges from
job_search_tracker.csvanddocuments/applications/*/outcome.mdvia fuzzy matching on company and role. - Status values normalize to five buckets (Active, Interview, Offer, Hired, Rejected/Closed) for consistent visualization.
- Charts render as hand-written inline SVG with no external dependencies, making the output fully portable.
- All user content is HTML-escaped to prevent injection attacks.
- The command is idempotent and read-only with respect to source data.
Frequently Asked Questions
What file format does /html-report generate?
The command generates a single, self-contained HTML file with inline CSS and JavaScript. All charts use inline SVG rather than external libraries or <canvas>, ensuring the dashboard works offline without network connectivity.
Can I customize where the dashboard is saved?
Yes. The command accepts an optional path argument to specify any output location. Without arguments, it defaults to reports/application-dashboard.html. The --open flag prompts you to open the file manually after generation.
How does the command match outcomes to tracker entries?
It uses fuzzy string matching on the combined company+role string, normalized to lowercase with punctuation stripped. This handles minor formatting differences between your CSV and the directory names in documents/applications/.
Are the generated reports safe to share?
Yes, but review first. The command HTML-escapes all user-generated content (notes, company names, role titles) to prevent markup injection. However, the dashboard contains your actual job search data, so share appropriately based on your privacy preferences.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →