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
  • --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:

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:

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-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. 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →