mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
feat(html-report): add /html-report command for application tracker dashboard (#131)
Self-contained HTML dashboard generated from job_search_tracker.csv and the application archives: stat cards, status/sector/channel/funnel charts as hand-generated inline SVG (no CDN, fully offline), HTML-escaped interpolation throughout, and a filterable applications table. Includes Python guards for the command file and the reports/ gitignore rule.
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
# /html-report - Generate Application Tracker Dashboard
|
||||||
|
|
||||||
|
Generate a self-contained HTML dashboard from `job_search_tracker.csv` and the application archives under `documents/applications/`. The output is a single `.html` file — no server, no dependencies — that can be opened directly in a browser.
|
||||||
|
|
||||||
|
## Step 0: Parse Arguments
|
||||||
|
|
||||||
|
- No argument → output to `reports/application-dashboard.html`
|
||||||
|
- A path argument (e.g. `/html-report ~/Desktop/report.html`) → use that path
|
||||||
|
- `--open` flag → after writing, tell the user to open the file (cannot open a browser directly)
|
||||||
|
|
||||||
|
Create `reports/` if it does not exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Collect Data
|
||||||
|
|
||||||
|
Read in parallel:
|
||||||
|
|
||||||
|
1. **`job_search_tracker.csv`** — the primary source. Parse every row into a record with fields:
|
||||||
|
`date`, `company`, `sector`, `role`, `role_type`, `channel`, `status`, `contact_person`, `fit_rating`, `notes`, `cv_file`, `cover_letter_file`, `source`
|
||||||
|
|
||||||
|
2. **`documents/applications/*/outcome.md`** — for each resolved application, read the outcome file to get the exact interview stages reached (the checkboxes) and any notes. Merge this into the matching tracker row by company+role fuzzy match (lowercase, ignore punctuation). If an archive exists for a row but there is no match, attach it as extra context anyway.
|
||||||
|
|
||||||
|
Status normalisation — map tracker values to five canonical buckets before computing stats:
|
||||||
|
- `applied` → **Active** (resume submitted, no further signal)
|
||||||
|
- `interview` → **Interview**
|
||||||
|
- `offer` → **Offer**
|
||||||
|
- `hired` → **Hired**
|
||||||
|
- `rejected` / `no_response` / `no response` / `offer_declined` / `interview_only` / `withdrawn` → **Rejected/Closed**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Compute Summary Stats
|
||||||
|
|
||||||
|
From the normalised data compute:
|
||||||
|
|
||||||
|
- **Total applications**
|
||||||
|
- **By status bucket:** count per bucket
|
||||||
|
- **By sector:** count per unique sector value
|
||||||
|
- **By channel:** online vs referral vs other
|
||||||
|
- **By year/season:** group by the `date` field (which may be a year like `2025` or a full date)
|
||||||
|
- **Funnel rates:** what % progressed past resume screen (reached Interview or beyond)
|
||||||
|
- **Rejection rate:** Rejected/Closed ÷ Total with a resolved status (exclude Active)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Generate the HTML
|
||||||
|
|
||||||
|
Write a single self-contained HTML file. All CSS is inline in a `<style>` block. All JS is inline in a `<script>` block. Draw the doughnut and bar charts as hand-generated inline SVG — no Chart.js, no CDN, no external dependencies of any kind. The report must render fully offline on every open.
|
||||||
|
|
||||||
|
**Escaping (required):** HTML-escape every CSV/outcome-file value (`&` `<` `>` `"` `'`) before interpolating it into the page — this includes table cells, `title` attributes on truncated notes, and any text placed inside SVG (`<text>` labels, chart tooltips). Notes and company names copied from job postings routinely contain these characters; unescaped, they break the layout or inject markup into a page the user opens routinely.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 🔍 Job Search Dashboard Generated: DATE │
|
||||||
|
├──────┬──────┬──────┬──────┬──────────────────┤
|
||||||
|
│Total │Active│Inter-│Offer │Rejected/Closed │ ← stat cards
|
||||||
|
│ N │ N │view N│ N │ N │
|
||||||
|
├──────┴──────┴──────┴──────┴──────────────────┤
|
||||||
|
│ Status breakdown (doughnut) │ By sector (bar)│ ← charts row
|
||||||
|
├───────────────────────────────────────────── ┤
|
||||||
|
│ By channel (bar) │ Funnel (horizontal bar) │ ← charts row
|
||||||
|
├────────────────────────────────────────────── ┤
|
||||||
|
│ Applications [Status ▾] [Sector ▾] [🔍 ...]│ ← table with filters
|
||||||
|
│ date │ company │ sector │ role │ status │ ... │
|
||||||
|
│ ... │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design spec
|
||||||
|
|
||||||
|
- **Colour palette:** CSS custom properties. Status colours:
|
||||||
|
- Active: `#3b82f6` (blue)
|
||||||
|
- Interview: `#f59e0b` (amber)
|
||||||
|
- Offer: `#8b5cf6` (purple)
|
||||||
|
- Hired: `#22c55e` (green)
|
||||||
|
- Rejected/Closed: `#ef4444` (red)
|
||||||
|
- **Font:** system-ui stack, no web fonts
|
||||||
|
- **Stat cards:** white background, subtle shadow, large bold number, label below, left border in status colour
|
||||||
|
- **Charts:** contained in a 2-column grid on wide screens, stacked on narrow
|
||||||
|
- **Table:**
|
||||||
|
- Alternating row shading
|
||||||
|
- Status column uses a coloured pill/badge
|
||||||
|
- `source` column renders as a hyperlink if the value is a URL (starts with `http`)
|
||||||
|
- Empty cells render as `—`
|
||||||
|
- Client-side filter: a text search input filters rows across company + role + sector; the status and sector dropdowns filter independently; all three combine (AND)
|
||||||
|
- Rows are sorted newest-first by default (by `date` descending, then alphabetically by company)
|
||||||
|
- **Responsive:** usable at 900px+, not broken below that
|
||||||
|
- **Footer:** "Generated by Claude Code · ai-job-search · {ISO date}"
|
||||||
|
|
||||||
|
### Charts (inline SVG)
|
||||||
|
|
||||||
|
1. **Status doughnut** — slices for each status bucket, colours from the palette above
|
||||||
|
2. **By sector bar** (horizontal) — company count per sector, sorted descending
|
||||||
|
3. **By channel bar** — online / referral / other
|
||||||
|
4. **Application funnel** (horizontal bar) — Applied → Interview → Offer → Hired, each bar = count reaching that stage
|
||||||
|
|
||||||
|
Build each chart as a hand-written `<svg>` element: compute bar lengths/doughnut arc angles from the stats in Step 2 and emit the `<rect>`/`<path>`/`<circle>` and `<text>` elements directly — no charting library, no `<canvas>`. Each `<svg>` has `role="img"` and an `aria-label` summarizing the chart (e.g. "Status breakdown: 3 Active, 2 Interview, 1 Offer"). Wrap each in a `<div class="chart-card">` with an `<h3>` title above. Remember to escape any label/value text drawn into `<text>` nodes per the escaping rule above.
|
||||||
|
|
||||||
|
### Table: columns to include
|
||||||
|
|
||||||
|
`Date` · `Company` · `Role` · `Sector` · `Channel` · `Status` · `Notes` (truncated to 80 chars with `title` tooltip for full text) · `Source` (link or `—`)
|
||||||
|
|
||||||
|
Columns with only empty values across all rows may be omitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Write and Confirm
|
||||||
|
|
||||||
|
Write the complete HTML to the output path using the Write tool.
|
||||||
|
|
||||||
|
Then present:
|
||||||
|
|
||||||
|
> **Dashboard generated:** `<output path>`
|
||||||
|
>
|
||||||
|
> Open it in any browser — no server needed.
|
||||||
|
>
|
||||||
|
> **Summary:**
|
||||||
|
> - Total applications: N
|
||||||
|
> - Active: N · Interview: N · Hired: N · Rejected/Closed: N
|
||||||
|
> - Funnel: N% progressed past resume screen
|
||||||
|
>
|
||||||
|
> Re-run `/html-report` any time after adding new entries via `/outcome` to refresh the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **Self-contained.** One file, fully offline — charts are inline SVG, no CDN or external requests of any kind.
|
||||||
|
- **Data-only.** This command reads and renders; it never writes to the tracker or archive.
|
||||||
|
- **Idempotent.** Re-running overwrites the previous report at the same path — no accumulation.
|
||||||
|
- **Graceful on sparse data.** With only a few rows (as now), charts render correctly for small N; the table is the primary value. Do not suppress charts just because N is small.
|
||||||
|
- **No fabrication.** Every number in the report comes directly from the CSV or outcome files. Do not infer or estimate missing fields.
|
||||||
@@ -64,6 +64,9 @@ documents/applications/**
|
|||||||
# Personal job search tracking
|
# Personal job search tracking
|
||||||
job_search_tracker.csv
|
job_search_tracker.csv
|
||||||
|
|
||||||
|
# Generated reports (personal output from /html-report)
|
||||||
|
reports/
|
||||||
|
|
||||||
# Upskill reports (personal output)
|
# Upskill reports (personal output)
|
||||||
upskill/*.md
|
upskill/*.md
|
||||||
|
|
||||||
|
|||||||
@@ -133,13 +133,14 @@ This runs the full workflow: evaluate fit, draft CV + cover letter, review with
|
|||||||
|
|
||||||
## Other commands
|
## Other commands
|
||||||
|
|
||||||
`/setup`, `/scrape`, and `/apply` form the core workflow. Seven more commands extend it once your profile is in place:
|
`/setup`, `/scrape`, and `/apply` form the core workflow. Eight more commands extend it once your profile is in place:
|
||||||
|
|
||||||
- **`/interview`** preps you for a scheduled interview on a tracked application. It builds a stage-specific prep pack from the application's archive (the exact posting, the CV and cover letter the interviewer actually read, feedback recorded from earlier rounds), researches the company and interviewers with a verify-before-use rule, maps likely questions to your STAR examples, and offers a mock interview following the roleplay protocol in `07-interview-prep.md`. Gaps get honest bridge answers, never invented experience.
|
- **`/interview`** preps you for a scheduled interview on a tracked application. It builds a stage-specific prep pack from the application's archive (the exact posting, the CV and cover letter the interviewer actually read, feedback recorded from earlier rounds), researches the company and interviewers with a verify-before-use rule, maps likely questions to your STAR examples, and offers a mock interview following the roleplay protocol in `07-interview-prep.md`. Gaps get honest bridge answers, never invented experience.
|
||||||
- **`/outcome`** records what happened to an application - interview stages, offers, rejections, silence. It archives the submitted CV, cover letter, and posting text into `documents/applications/<company>_<role>/`, keeps `outcome.md` in the format `/setup` Path A parses, and updates the tracker. Once a few applications resolve, it points you back to `/setup` to calibrate the fit framework from what actually got interviews.
|
- **`/outcome`** records what happened to an application - interview stages, offers, rejections, silence. It archives the submitted CV, cover letter, and posting text into `documents/applications/<company>_<role>/`, keeps `outcome.md` in the format `/setup` Path A parses, and updates the tracker. Once a few applications resolve, it points you back to `/setup` to calibrate the fit framework from what actually got interviews.
|
||||||
- **`/rank`** bridges `/scrape` and `/apply`: it batch-scores all newly scraped postings against the fit framework (parallel agents fetch each posting and score the five evaluation dimensions) and returns a ranked shortlist with honest per-job strengths and gaps. Deal-breakers veto, deadlines get urgency flags, dead postings get marked expired. Pick a number and it hands off to the full `/apply` workflow.
|
- **`/rank`** bridges `/scrape` and `/apply`: it batch-scores all newly scraped postings against the fit framework (parallel agents fetch each posting and score the five evaluation dimensions) and returns a ranked shortlist with honest per-job strengths and gaps. Deal-breakers veto, deadlines get urgency flags, dead postings get marked expired. Pick a number and it hands off to the full `/apply` workflow.
|
||||||
- **`/expand`** enriches your profile by scanning public sources you've already linked in it (GitHub repos, portfolio site, Kaggle, Google Scholar) and looking up syllabi for named courses and certifications. Discovered competencies are added to your profile with a source tag. Useful right after `/setup` to surface skills that documents alone don't make explicit.
|
- **`/expand`** enriches your profile by scanning public sources you've already linked in it (GitHub repos, portfolio site, Kaggle, Google Scholar) and looking up syllabi for named courses and certifications. Discovered competencies are added to your profile with a source tag. Useful right after `/setup` to surface skills that documents alone don't make explicit.
|
||||||
- **`/upskill`** analyzes the gap between your profile and your tracked job postings (or a single posting via `/upskill <URL>`). Produces a prioritized heatmap of skill gaps and a learning plan with web-searched study resources and time estimates. Useful for career planning between applications.
|
- **`/upskill`** analyzes the gap between your profile and your tracked job postings (or a single posting via `/upskill <URL>`). Produces a prioritized heatmap of skill gaps and a learning plan with web-searched study resources and time estimates. Useful for career planning between applications.
|
||||||
|
- **`/html-report`** generates a self-contained HTML dashboard from `job_search_tracker.csv` and the application archives — stat cards, status/sector/channel/funnel charts (inline SVG, no external dependencies), and a filterable applications table. Opens directly in a browser, fully offline. Re-run it any time after `/outcome` adds new entries.
|
||||||
- **`/add-template`** registers your own LaTeX CV or cover letter template in place of the stock ones. It captures the template's instructions (compile engine, fonts, style rules, page limit), runs a mandatory test compile, and wires the template into `/apply`. See [LaTeX templates](#latex-templates) below.
|
- **`/add-template`** registers your own LaTeX CV or cover letter template in place of the stock ones. It captures the template's instructions (compile engine, fonts, style rules, page limit), runs a mandatory test compile, and wires the template into `/apply`. See [LaTeX templates](#latex-templates) below.
|
||||||
- **`/add-portal`** generates a job-portal search skill for a job board in your market. It investigates the portal (search URL pattern, result structure, access rules), scaffolds the CLI skill from the same structure as the shipped ones, and test-runs a live query before registering. See [Job search tools](#job-search-tools) below.
|
- **`/add-portal`** generates a job-portal search skill for a job board in your market. It investigates the portal (search URL pattern, result structure, access rules), scaffolds the CLI skill from the same structure as the shipped ones, and test-runs a live query before registering. See [Job search tools](#job-search-tools) below.
|
||||||
|
|
||||||
@@ -160,6 +161,7 @@ ai-job-search/
|
|||||||
│ │ ├── rank.md # /rank triage scraped jobs into a ranked shortlist
|
│ │ ├── rank.md # /rank triage scraped jobs into a ranked shortlist
|
||||||
│ │ ├── outcome.md # /outcome record application results, archive materials
|
│ │ ├── outcome.md # /outcome record application results, archive materials
|
||||||
│ │ ├── interview.md # /interview stage-specific prep pack + mock interview
|
│ │ ├── interview.md # /interview stage-specific prep pack + mock interview
|
||||||
|
│ │ ├── html-report.md # /html-report generate application tracker dashboard
|
||||||
│ │ └── reset.md # /reset wipe profile data or documents folder
|
│ │ └── reset.md # /reset wipe profile data or documents folder
|
||||||
│ ├── skills/
|
│ ├── skills/
|
||||||
│ │ ├── job-application-assistant/ # Core application skill
|
│ │ ├── job-application-assistant/ # Core application skill
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Tests for the /html-report command and its gitignore rule.
|
||||||
|
|
||||||
|
Mirrors the pattern in test_security_guards.py: one class that verifies
|
||||||
|
properties of the real repo, testing the things CI would catch if the
|
||||||
|
command file or gitignore rule were wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml # noqa: F401 - only probing availability for the lint integration test
|
||||||
|
_HAVE_YAML = True
|
||||||
|
except ImportError:
|
||||||
|
_HAVE_YAML = False
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
COMMAND_FILE = REPO_ROOT / ".claude" / "commands" / "html-report.md"
|
||||||
|
LINT_SCRIPT = REPO_ROOT / "tools" / "lint_skills.py"
|
||||||
|
GITIGNORE = REPO_ROOT / ".gitignore"
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlReportCommandFileTests(unittest.TestCase):
|
||||||
|
"""Structural checks on the command file itself."""
|
||||||
|
|
||||||
|
def test_command_file_exists(self):
|
||||||
|
self.assertTrue(COMMAND_FILE.exists(), f"{COMMAND_FILE} not found")
|
||||||
|
|
||||||
|
def test_command_file_starts_with_correct_header(self):
|
||||||
|
"""lint_skills.py rejects command files that don't start with '# /<name>'."""
|
||||||
|
text = COMMAND_FILE.read_text(encoding="utf-8")
|
||||||
|
first_line = text.lstrip().splitlines()[0]
|
||||||
|
self.assertTrue(
|
||||||
|
first_line.startswith("# /html-report"),
|
||||||
|
f"Command file must start with '# /html-report', got: {first_line!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_command_file_is_non_empty(self):
|
||||||
|
text = COMMAND_FILE.read_text(encoding="utf-8").strip()
|
||||||
|
self.assertGreater(len(text), 100, "Command file appears suspiciously short")
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlReportGitignoreTests(unittest.TestCase):
|
||||||
|
"""reports/ must be gitignored — it holds personal generated output."""
|
||||||
|
|
||||||
|
def test_reports_folder_is_gitignored(self):
|
||||||
|
rules = {line.strip() for line in GITIGNORE.read_text(encoding="utf-8").splitlines()}
|
||||||
|
self.assertIn(
|
||||||
|
"reports/",
|
||||||
|
rules,
|
||||||
|
"reports/ must be listed in .gitignore — generated dashboards are personal output",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
_HAVE_YAML,
|
||||||
|
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",
|
||||||
|
)
|
||||||
|
class HtmlReportLintIntegrationTests(unittest.TestCase):
|
||||||
|
"""lint_skills.py must pass after the command is added."""
|
||||||
|
|
||||||
|
def test_lint_passes_on_real_repo(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(LINT_SCRIPT)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result.returncode,
|
||||||
|
0,
|
||||||
|
f"lint_skills.py failed:\n{result.stdout}{result.stderr}",
|
||||||
|
)
|
||||||
|
self.assertIn("OK", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user