* 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.
8.2 KiB
/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 --openflag → 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:
-
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 -
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
/outcomeis 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
datefield (which may be a year like2025or 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)
- Drafted:
- 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
sourcecolumn renders as a hyperlink if the value is a URL (starts withhttp)- 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
datedescending, 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)
- Status doughnut — slices for each status bucket, colours from the palette above
- By sector bar (horizontal) — company count per sector, sorted descending
- By channel bar — online / referral / other
- 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-reportany time after adding new entries via/applyor/outcometo 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.