Files
ai-job-search/.claude/commands/html-report.md
T
Jakob Stender Guldberg 41b5fd857f fix(apply): record the drafted application in the tracker (#269) (#291)
/apply wrote a CV and a cover letter to disk and then wrote nothing to
job_search_tracker.csv, so a drafted and submitted application was
invisible to /gmail-sync, /html-report, /notion-sync, /interview,
/upskill aggregate mode, and to /rank's dedup exclusion. The safety net
that would have caught it - /gmail-sync - refuses to create missing
rows, so the failure it exists to catch is the one that disables it.
Nothing detected the loss afterwards.

Step 6b appends a drafted row carrying the two document paths, the fit
rating and the posting URL, reusing /outcome's exact header so the two
commands cannot diverge. It runs immediately after "Files Created" and
before the optional application-form offer, which ends the turn on a
question - anything placed after that offer would be skipped whenever
the user never answers, reproducing the bug. Re-running /apply updates
the row rather than duplicating it, and never moves a row that already
reached applied or beyond back to drafted. The step is mirrored into
job-application-assistant, which defers to it rather than restating it,
because /scrape Step 5 routes straight into the skill; /scrape Step 6
now defers to the same step instead of adding a row of its own.

seen_jobs.json is deliberately left alone: drafting is not applying, and
that file's vocabulary has no value for either. /rank builds its
exclusion set from company+role in the tracker regardless of status.

drafted is introduced into the status vocabulary, and every reader that
meant "submitted" is updated to say so. These readers define their open
set by exclusion from the final statuses, so a new non-final value would
otherwise have joined all of them silently: /outcome's follow-up branch
would have drafted a chase email to an employer who never received an
application, /gmail-sync would have searched for mail about it and then
flagged it as stale, /notion-sync would have published an "Applied on"
date for it, and /html-report would have counted it in the headline
application total. /outcome Step 4 also overwrites the draft date with
the submission date when a row leaves drafted, so the date column keeps
meaning "applied on". The wider vocabulary reconciliation - underscore
versus space, the separate archive enum - stays a separate concern.
2026-08-06 17:16:07 +02:00

140 lines
7.8 KiB
Markdown

# /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 six canonical buckets before computing stats:
- `drafted`**Drafted** (documents written by `/apply`, not yet submitted)
- `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:
**Drafted rows are excluded from every statistic below** — they were never submitted. Report the Drafted count on its own, and include it only in the status breakdown.
- **Total applications**
- **By status bucket:** count per bucket
- **By sector:** count per unique sector value
- **By channel:** portal vs 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 │
├──────┬──────┬──────┬──────┬──────┬───────────┤
│Sent │Draft │Active│Inter-│Offer │Rejected/ │ ← stat cards
│ N │ N │ N │view N│ N │Closed 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:
- Drafted: `#64748b` (slate)
- 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:**
> - Applications sent: N · drafted, not yet sent: 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 `/apply` or `/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.