fix(html-report): funnel from stage history, rejection rate from true rejections

The funnel was computed from current status - a state, not a history -
so an application that interviewed and was later rejected never counted
as reaching Interview, and a hired candidate produced Interview=0. The
stage checkboxes Step 1.2 already merges from outcome.md are the
history; Step 2 and chart 4 now use them. The rejection rate also
counted offer_declined (a success) and withdrawn (candidate-initiated)
as rejections and left unresolved Interview/Offer rows in the
denominator. Review findings F10 and F11 (2026-08-19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mads Lorentzen
2026-08-19 20:54:46 +02:00
co-authored by Claude Opus 5
parent dab215073e
commit 2c3d2d8558
3 changed files with 49 additions and 3 deletions
+3 -3
View File
@@ -49,8 +49,8 @@ From the normalised data compute:
- **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)
- **Funnel rates:** what % progressed past resume screen (reached Interview or beyond). Compute stage-reached from history, not current status: an application counts as having reached a stage when its current status implies it **or** its merged `outcome.md` stage checkboxes (Step 1.2) show the stage was reached - a `rejected` row whose outcome file ticks an interview stage reached Interview, and a `hired` row reached every stage before Hired. Current status alone structurally undercounts every earlier stage: a finished search would read as though nobody ever interviewed.
- **Rejection rate:** true rejections (`rejected`, `no_response`) ÷ applications with a final outcome. `offer_declined` (the candidate turned the offer down - a success) and `withdrawn` (candidate-initiated) are not rejections and stay out of the numerator; Interview and Offer rows are still unresolved, so they stay out of the denominator along with Active. The Rejected/Closed status *bucket* still groups all closed rows for the doughnut - the rate just must not reuse the bucket blindly.
---
@@ -106,7 +106,7 @@ Write a single self-contained HTML file. All CSS is inline in a `<style>` block.
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
4. **Application funnel** (horizontal bar) — Applied → Interview → Offer → Hired, each bar = count reaching that stage, derived per Step 2's funnel rule (current status **plus** the merged `outcome.md` stage checkboxes), so a candidate who interviewed and was later rejected still counts in the Interview bar
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.
+9
View File
@@ -120,6 +120,15 @@ per-file diff commands.
### Fixed
- **`/html-report`'s funnel counts stages from history; the rejection rate stops
counting non-rejections** - the funnel was computed from current status, which is a
state, not a history: an application that interviewed and was then rejected never
counted as reaching Interview, so a finished search rendered as though nobody ever
interviewed. The funnel (Step 2 and chart 4) now derives stage-reached from current
status plus the `outcome.md` stage checkboxes Step 1.2 already merges. And the
rejection rate no longer counts `offer_declined` (a success) or `withdrawn`
(candidate-initiated) as rejections, nor unresolved Interview/Offer rows in its
denominator. Pinned by two new tests in `tests/test_html_report_command.py`.
- **`jobdanmark-search detail`'s HTML fallback emits the same shapes as its JSON-LD
branch** - a posting without JSON-LD returned `datePosted` as the page's raw
`DD-MM-YYYY` text, `validThrough` as free text (including the literal `"Løbende"`,
+37
View File
@@ -94,6 +94,43 @@ class HtmlReportTrackerFieldTests(unittest.TestCase):
class HtmlReportGitignoreTests(unittest.TestCase):
"""reports/ must be gitignored — it holds personal generated output."""
def test_funnel_is_computed_from_stage_history_not_current_status(self):
"""status is a current state, not a history: an application that
interviewed and was then rejected carries status `rejected` and would
never count as having reached Interview, so a finished search reads
as though nobody ever interviewed (review finding F10, 2026-08-19).
The stage checkboxes merged from outcome.md in Step 1.2 are the
history; the funnel must be told to use them."""
text = COMMAND_FILE.read_text(encoding="utf-8")
self.assertIn(
"stage checkboxes",
text.split("## Step 3")[0].split("## Step 2")[1],
"Step 2's funnel definition must derive stage-reached from the "
"merged outcome.md stage checkboxes",
)
self.assertIn(
"not current status",
text,
"the funnel rule must say explicitly that current status alone undercounts",
)
def test_rejection_rate_excludes_declined_offers_and_withdrawals(self):
"""offer_declined is the candidate turning an offer down (a success)
and withdrawn is candidate-initiated; counting either as a rejection
inflates the rejection rate on a self-assessment dashboard (review
finding F11, 2026-08-19)."""
text = COMMAND_FILE.read_text(encoding="utf-8")
self.assertIn(
"`offer_declined`",
text.split("## Step 3")[0].split("## Step 2")[1],
"the rejection-rate definition must address offer_declined",
)
self.assertIn(
"not rejections",
text,
"the rate must exclude candidate-initiated outcomes explicitly",
)
def test_reports_folder_is_gitignored(self):
rules = {line.strip() for line in GITIGNORE.read_text(encoding="utf-8").splitlines()}
self.assertIn(