Install
openclaw skills install @chenyuan99/html-reportGenerates a self-contained HTML dashboard (stat cards, Chart.js charts, filterable table) from the same SQLite tracker database used by application-manager — opens directly in a browser, no server required
openclaw skills install @chenyuan99/html-reportTrigger when the user asks to:
Keywords: html report, dashboard, visualize applications, application chart, job search report, funnel chart, /html-report
This skill is read-only: it never modifies the tracker database. Use
the job-application-manager skill (or swelist tracker add/update) to
change application status — this skill only renders what's already there.
Requires the sqlite tracker backend. If profile.md has
Tracker backend: notion, this skill has nothing to read — see Edge Cases.
Always read skills/profile.md first. It's the same file
job-application-manager reads, so both skills stay pointed at the same
database.
Resolve before continuing:
Integrations > Tracker Backend
sqlite. If notion, tell the user this report needs the
SQLite backend and stop (see Edge Cases) rather than fabricating data.Integrations > Tracker Backend > SQLite DB path
~/.offerplus/applications.dbswelist tracker init
(or sync via job-application-manager first) — do not generate an
empty report silently.reports/application-dashboard.html.
Create the reports/ directory if it doesn't exist.| Input | Behavior |
|---|---|
| No argument | Output to reports/application-dashboard.html |
A path argument (e.g. /html-report ~/Desktop/report.html) | Use that path instead |
--open flag | After writing, tell the user to open the file — the skill cannot launch a browser itself |
Same table job-application-manager writes to. See that skill's
"Config: SQLite Schema" section for the authoritative DDL and migration
history. This skill never runs ALTER TABLE — it just adapts to whichever
columns exist.
Full (v0.5.0) schema:
CREATE TABLE applications (
name TEXT PRIMARY KEY, -- "Company — Role Title"
status TEXT NOT NULL,
job_id TEXT,
company TEXT,
applied_on TEXT, -- ISO date YYYY-MM-DD
last_touch TEXT, -- ISO date of most recent email
interview_date TEXT, -- ISO date of next scheduled interview
next_action TEXT, -- Waiting | Prep | Follow up | Send availability
link TEXT, -- job posting or portal URL
tags TEXT, -- comma-separated e.g. "Referral,Remote"
notes TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
Older (v0.2.x) schema only has name, status, job_id, applied_on, notes, updated_at — that's also exactly what swelist tracker export returns
today. Check which columns are present before building the SELECT:
sqlite3 <DB_PATH> "PRAGMA table_info(applications);"
If company, last_touch, interview_date, next_action, link, or
tags are missing, treat them as empty for every row and omit any
chart/table section that depends entirely on them (see Layout, Edge Cases).
Map every row's status value to one of five canonical buckets before
computing stats. Comparisons are case-insensitive. Covers both the current
pipeline statuses and the older v0.2.x values, so the report works
regardless of which schema generation wrote the row.
| Status value | Canonical bucket | Colour |
|---|---|---|
Applied / Received, OA, Recruiter screen, Not started | Active | #3b82f6 |
Interviewing, In progress | Interview | #f59e0b |
Offer | Offer | #8b5cf6 |
Hired, Done | Hired | #22c55e |
Rejected, Withdrawn | Rejected/Closed | #ef4444 |
| any other/unrecognised value | Active (fallback) — note in the report footer as "N rows had an unmapped status" | #3b82f6 |
In progress (v0.2.x) can't be distinguished from a true "Interviewing"
stage — bucket it as Interview and note in the footer that granular
staging requires the v0.5.0 schema.
INPUT: output_path (optional, default "reports/application-dashboard.html")
open_flag (optional)
STEP 0 Load profile + resolve DB
Read skills/profile.md → tracker_backend, DB_PATH
IF tracker_backend != "sqlite": stop, explain (see Edge Cases)
IF DB_PATH file does not exist: stop, tell user to run `swelist tracker init`
STEP 1 Parse arguments
IF path argument given: output_path = that path
ELSE: output_path = "reports/application-dashboard.html"
mkdir -p dirname(output_path)
STEP 2 Collect data
columns = sqlite3 DB_PATH "PRAGMA table_info(applications);"
select_cols = ["name","status","job_id","applied_on","notes","updated_at"]
+ any of ["company","last_touch","interview_date","next_action","link","tags"]
that exist in columns
rows = sqlite3 -json DB_PATH
"SELECT {', '.join(select_cols)} FROM applications ORDER BY applied_on DESC, name ASC"
FOR each row:
row.company_display = row.company OR extract_company_from_name(row.name) # part before " — "
row.role_display = extract_role_from_name(row.name) # part after " — "
row.bucket = normalise_status(row.status) # Config: Status Normalisation
row.tag_list = split(row.tags, ",") if present else []
STEP 3 Compute summary stats
total = len(rows)
by_bucket = count rows grouped by row.bucket
by_tag = count occurrences of each tag across row.tag_list (flatten)
by_month = count rows grouped by YYYY-MM of row.applied_on (blank → "Unknown")
resolved = rows where bucket != "Active"
past_screen = rows where bucket IN ("Interview", "Offer", "Hired")
funnel_rate_pct = round(100 * len(past_screen) / total) if total else 0
rejection_rate_pct = round(100 * count(bucket == "Rejected/Closed") / len(resolved)) if resolved else 0
funnel_stages = { Applied: total,
Interview: count(bucket IN (Interview, Offer, Hired)),
Offer: count(bucket IN (Offer, Hired)),
Hired: count(bucket == Hired) }
STEP 4 Generate HTML (see Layout, Design spec, and Charts below)
Build one self-contained HTML string:
- inline <style> block (palette, cards, charts grid, table)
- stat cards row
- two 2-column chart rows (doughnut, tags bar / by-month bar, funnel bar)
- filterable table (search input + status/company dropdowns, client-side JS)
- footer: "Generated by Claude Code · swelist · {ISO date}"
Charts render via Chart.js loaded from https://cdn.jsdelivr.net/npm/chart.js
(only external dependency; wrap chart init in try/catch so a failed CDN
fetch degrades to the stat cards instead of breaking the page)
STEP 5 Write and confirm
Write the complete HTML to output_path (Write tool — overwrite if it exists)
IF open_flag: tell the user to open the file themselves (cannot launch a browser)
Print the Report Format summary below
┌─────────────────────────────────────────────┐
│ 🔍 Job Search Dashboard Generated: DATE │
├──────┬──────┬──────┬──────┬──────────────────┤
│Active│Inter-│Offer │Hired │Rejected/Closed │ ← stat cards
│ N │view N│ N │ N │ N │
├──────┴──────┴──────┴──────┴──────────────────┤
│ Status breakdown (doughnut) │ By tag (bar) │ ← charts row
├───────────────────────────────────────────── ┤
│ By month (bar) │ Funnel (horizontal bar) │ ← charts row
├────────────────────────────────────────────── ┤
│ Applications [Status ▾] [Company ▾] [🔍 ...]│ ← table with filters
│ date │ company │ role │ status │ next action │ ...│
│ ... │
└───────────────────────────────────────────────┘
<768px).link column renders as a hyperlink (target="_blank" rel="noopener")
if present; otherwise —.—.applied_on descending, then company A→Z).Generated by Claude Code · swelist · {ISO date}.Referral, Remote,
Urgent, Visa — see job-application-manager's Tag Inference config),
sorted descending. Omit entirely if no row has a tags value.YYYY-MM of applied_on, oldest
to newest.All chart <canvas> elements get an aria-label. Wrap each chart in
<div class="chart-card"><h3>Title</h3><canvas ...></canvas></div>.
Date (applied_on) · Company · Role · Status · Next action ·
Tags · Interview date · Notes (truncate to 80 chars, full text in a
title tooltip) · Link (hyperlink or —)
Omit any column whose value is empty across every row, and omit
next_action / interview_date / tags / link entirely if the column
doesn't exist in the DB (v0.2.x schema).
sqlite3 ~/.offerplus/applications.db "PRAGMA table_info(applications);"
sqlite3 -json ~/.offerplus/applications.db \
"SELECT name, status, job_id, company, applied_on, last_touch, interview_date,
next_action, link, tags, notes, updated_at
FROM applications ORDER BY applied_on DESC, name ASC;"
sqlite3 -json ~/.offerplus/applications.db \
"SELECT name, status, job_id, applied_on, notes, updated_at
FROM applications ORDER BY applied_on DESC, name ASC;"
Write(file_path=output_path, content=full_html_string)
Dashboard generated: <output path>
Open it in any browser — no server needed.
Summary:
- Total applications: N
- Active: N · Interview: N · Offer: N · Hired: N · Rejected/Closed: N
- Funnel: N% progressed past resume screen
Re-run /html-report any time after syncing via application-manager
(or `swelist tracker add/update`) to refresh the dashboard.
(Omit the funnel line if total == 0; print "No applications tracked yet" instead of the stat breakdown.)
job-application-manager writes to — no separate export/CSV step, so the dashboard is never stale relative to a sync that already happened.| Situation | Handling |
|---|---|
Tracker backend in profile.md is notion, not sqlite | Stop and tell the user this skill currently only reads the SQLite tracker; offer to switch tracker_backend to sqlite in profile.md, or point at an explicit --db path if they maintain a separate local copy |
| SQLite DB file does not exist at the resolved path | Tell the user to run swelist tracker init (or do an initial sync via job-application-manager); do not generate an empty report |
DB exists but applications table has zero rows | Still generate the HTML shell; show "No applications tracked yet" instead of stat cards/charts |
DB uses the older v0.2.x schema (no company/tags/etc.) | Build the SELECT from only the columns that exist (Step 2); omit dependent chart/table columns entirely rather than erroring |
A row's status value isn't in the normalisation table | Fall back to Active; count it in an "N rows had an unmapped status" footer note |
applied_on is null or blank | Group under "Unknown" in the by-month chart; sort such rows last in the table |
No row has a tags value | Omit the "By tag" chart entirely rather than rendering an empty one |
link value doesn't start with http | Render as plain text, not a hyperlink |
A table column (e.g. next_action) is empty for every row | Omit that column from the rendered table entirely |
| Output path's parent directory doesn't exist | Create it (mkdir -p) before writing |
| Re-running with the same output path | Overwrite; never append or version the filename |
| User is offline when the report is later opened | Chart.js CDN fetch fails; charts area shows nothing but stat cards and table still render — never let a failed script tag blank the whole page |
--open flag passed | Tell the user the file is ready and where it is; the skill cannot launch a browser itself |