# How the /html-report Command Generates an Application Dashboard in MadsLorentzen/ai-job-search

> Discover how the /html-report command generates a self-contained application dashboard in MadsLorentzen/ai-job-search by parsing CSV and Markdown files for instant insights without external dependencies.

- Repository: [Mads Lorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search)
- Tags: how-to-guide
- Published: 2026-08-05

---

**The `/html-report` command creates a self-contained HTML dashboard by parsing `job_search_tracker.csv` and per-application [`outcome.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main//.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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/reports/application-dashboard.html)
- **Path argument** → writes to the user-supplied location
- **`--open` flag** → 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:

```python
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 `date` field
- **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

1. **Status doughnut** — proportional slices from status counts
2. **Sector bar** — horizontal bars, sorted descending
3. **Channel bar** — distribution across online, referral, other
4. **Application funnel** — progression from Applied → Interview → Offer → Hired

### Security: HTML Escaping

All dynamic text is **HTML-escaped** (`& < > " '`) before insertion:

```python
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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/html-report.md):

```python
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`](https://github.com/MadsLorentzen/ai-job-search/blob/main//.claude/commands/html-report.md) | Authoritative command specification defining the entire pipeline |
| [`/tests/test_html_report_command.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main//tests/test_html_report_command.py) | Unit tests enforcing command file existence, header format (`# /html-report`), and lint compatibility |

| [`/tools/lint_skills.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main//tools/lint_skills.py) | Linting utility validating command file structure |
| `/.gitignore` | Excludes `reports/` directory to prevent committing generated dashboards |

## Summary

- The `/html-report` command follows a **deterministic four-step pipeline**: argument parsing, data collection, statistics computation, and HTML generation.
- Data merges from **`job_search_tracker.csv`** and **`documents/applications/*/outcome.md`** via 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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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.