Files
ai-job-search/.claude/commands/html-report.md
T
Oscar Madera e09d3eb37b fix(workflow): define tracker status enum once in /outcome, normalise readers (#299)
* fix(workflow): define tracker status enum once in /outcome, normalise readers (#298)

The tracker CSV status column had no single authoritative definition.
Six command files restated it with inconsistent spellings, producing two
concrete bugs:

- /outcome Step 4 wrote
o response and offer declined (spaces).
  /html-report normalised only the underscore forms, so those rows matched
  no bucket and were silently dropped from the rejection-rate denominator.
- /gmail-sync Step 2 hardcoded the final-status set with space spellings,
  so a row written with underscores was never recognised as final and the
  sync kept chasing closed applications.
- /html-report included interview_only in its tracker bucket map; that
  value belongs to the archive outcome.md Status: field, not the CSV
  status column.

Fix: add a '## Tracker status vocabulary' block in /outcome (the only
CSV writer) defining the canonical underscore spellings once.  Every
reader now references that block or explicitly lists both spelling forms
as read-tolerance for existing trackers.  /outcome Step 4 writes
no_response and offer_declined.  /html-report loses interview_only and
gains offer declined as a read-tolerance variant.  /notion-sync Step 3
Status select options are aligned to the canonical spellings.

Pinned by tests/test_tracker_status_vocab.py (9 new cases following the
DraftedMeansDraftedToEveryReader CASES-table pattern).  All 205 tests pass.

framework_version: 1.3.0 -> 1.3.1

* fix(workflow): address review findings on the tracker status enum (#298)

Follow-up to ca40df2, incorporating the maintainer and issue-author reviews.

Blockers fixed:
- CHANGELOG: the #298 entry had replaced the opening line of the #286 robots
  entry, leaving its body dangling under the new fork heads-up. Restored the
  deleted line and made the #298 entry self-contained above it (MadsLorentzen).
- /notion-sync Step 4 now normalises legacy space spellings to the canonical
  underscore forms before setting the Status property. A raw push would
  auto-create a separate Notion select option per unique string, splitting
  closed applications across two filter buckets in an existing database
  (MadsLorentzen).

Issue-author findings:
- The vocabulary block now states that the space spellings are the same
  values as the underscore forms, not separate statuses, equally Final.
  Previously a reader applying the Open/Final lists literally landed on
  "not Final, not Open, undefined" for `offer declined`, and /apply Step 6b
  would refresh a closed application's row instead of appending (jakob1379).
- The block moved below Step 1's closing --- as its own section: it was
  splitting Step 1's numbered list and silently truncating section-scoped
  reads of Step 1 to item 1 (jakob1379).
- Open is derived by exclusion from the one explicit Final list, so a new
  status needs updating in a single place (jakob1379).
- /html-report's bucket map gains a case-insensitive catch-all that maps
  unrecognised values to Rejected/Closed and names them once in the status
  breakdown - the #298 failure mode with a different input (jakob1379).
- /apply Step 6b and /interview Step 0 anchor their final/open decisions to
  the vocabulary block (jakob1379).
- /gmail-sync and /html-report drop their local restatements of the
  read-tolerance rule (jakob1379).

Tests: html-report bucket assertions scoped to the Step 1 section; new pins
for the equivalence clause, open-by-exclusion, block placement, the Notion
normalisation, and the apply/interview anchors.
2026-08-07 20:47:24 +02:00

145 lines
8.2 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` / `offer declined` / `withdrawn`**Rejected/Closed**
- anything else → **Rejected/Closed**, and name the unrecognised value once in the status breakdown — matching is case-insensitive
The bucket map tolerates the legacy space spellings on read so nothing written before
the canonical forms were locked drops out of the stats; the **Tracker status vocabulary**
in `/outcome` is the authoritative set.
---
## 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.