feat: render coverage meter and station-grouped table

This commit is contained in:
meels
2026-07-28 14:07:44 +02:00
parent a5ca1b0ee4
commit 2bb0be0a64
3 changed files with 167 additions and 2 deletions

View File

@@ -146,6 +146,16 @@ function groupByStation(rows) {
return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) })); return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) }));
} }
function reconcileConcepts(rows, conceptNames) {
const named = new Set(conceptNames);
const rowed = new Set(rows.map((r) => r.concept));
return {
rows,
unsynced: conceptNames.filter((n) => !rowed.has(n)).sort(),
stale: rows.filter((r) => !named.has(r.concept)),
};
}
function parseConfig(source) { function parseConfig(source) {
const cfg = Object.assign({}, DEFAULTS); const cfg = Object.assign({}, DEFAULTS);
for (const line of String(source).split(/\r?\n/)) { for (const line of String(source).split(/\r?\n/)) {
@@ -241,6 +251,89 @@ function renderLeftPane(container, pipeline, onIngest) {
return pane; return pane;
} }
const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };
function renderRightPane(container, parsed, reconciled) {
const pane = container.createDiv({ cls: "wd-pane" });
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });
const counts = { covered: 0, partial: 0, absent: 0 };
for (const row of parsed.rows) counts[row.status] += 1;
const total = parsed.rows.length;
if (total > 0) {
const meter = pane.createDiv({ cls: "wd-meter" });
meter.setAttr(
"aria-label",
`Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}`
);
for (const key of ["covered", "partial", "absent"]) {
if (counts[key] === 0) continue;
const seg = meter.createEl("i", { cls: `wd-seg-${key}` });
seg.style.flex = String(counts[key]);
}
const key = pane.createDiv({ cls: "wd-key" });
for (const name of ["covered", "partial", "absent"]) {
const span = key.createSpan();
span.createEl("i", { cls: `wd-seg-${name}` });
span.createSpan({ text: `${counts[name]} ${name}` });
}
}
if (parsed.errors.length > 0) {
const box = pane.createDiv({ cls: "wd-error" });
box.createDiv({ text: `${parsed.errors.length} unparseable row(s):` });
for (const e of parsed.errors) {
box.createDiv({ cls: "wd-mono", text: `line ${e.line}${e.reason}` });
}
}
const table = pane.createEl("table", { cls: "wd-tbl" });
const head = table.createEl("thead").createEl("tr");
for (const h of ["Concept", "Status", "Station", ""]) head.createEl("th", { text: h });
const body = table.createEl("tbody");
for (const group of groupByStation(parsed.rows)) {
const gr = body.createEl("tr", { cls: "wd-grp" });
gr.createEl("td", { attr: { colspan: "4" }, text: `${group.station}${group.rows.length}` });
for (const row of group.rows) {
const tr = body.createEl("tr");
// Obsidian's click handler resolves internal links via data-href, so both
// attributes are required for the link to open the concept page.
tr.createEl("td").createEl("a", {
cls: "internal-link",
text: row.concept,
attr: { href: row.concept, "data-href": row.concept },
});
const stat = tr.createEl("td").createSpan({ cls: `wd-stat wd-${row.status}` });
addIcon(stat, STATUS_ICON[row.status]);
stat.createSpan({ text: row.status });
tr.createEl("td", { cls: "wd-mono", text: row.stations.join(", ") || "—" });
tr.createEl("td", { cls: "wd-pin", text: row.pinned ? "pinned" : "" });
}
}
if (reconciled.unsynced.length > 0) {
const box = pane.createDiv({ cls: "wd-block" });
box.createDiv({ cls: "wd-eyebrow", text: `Unsynced — ${reconciled.unsynced.length}` });
box.createDiv({
cls: "wd-note",
text: `Concept pages with no row. Run "sync script coverage": ${reconciled.unsynced.join(", ")}`,
});
}
if (reconciled.stale.length > 0) {
const box = pane.createDiv({ cls: "wd-block" });
box.createDiv({ cls: "wd-eyebrow", text: `Stale — ${reconciled.stale.length}` });
box.createDiv({
cls: "wd-note",
text: `Rows whose concept page is gone: ${reconciled.stale.map((r) => r.concept).join(", ")}`,
});
}
return pane;
}
class WebinarDashPlugin extends PluginBase { class WebinarDashPlugin extends PluginBase {
async onload() { async onload() {
this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => { this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
@@ -250,11 +343,31 @@ class WebinarDashPlugin extends PluginBase {
const pipeline = await this.readPipeline(cfg); const pipeline = await this.readPipeline(cfg);
renderLeftPane(root, pipeline, () => {}); renderLeftPane(root, pipeline, () => {});
} catch (err) { } catch (err) {
root.createDiv({ cls: "wd-error", text: `Dashboard failed: ${err.message}` }); root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
}
try {
const { parsed, reconciled } = await this.readCoverage(cfg);
renderRightPane(root, parsed, reconciled);
} catch (err) {
root.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` });
} }
}); });
} }
async readCoverage(cfg) {
const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
const parsed = parseCoverageTable(await this.app.vault.cachedRead(file));
const conceptDir = cfg.conceptDir.replace(/\/+$/, "") + "/";
const conceptNames = this.app.vault
.getFiles()
.filter((f) => f.path.startsWith(conceptDir) && f.extension === "md")
.map((f) => f.basename);
return { parsed, reconciled: reconcileConcepts(parsed.rows, conceptNames) };
}
async readPipeline(cfg) { async readPipeline(cfg) {
const all = this.app.vault.getFiles(); const all = this.app.vault.getFiles();
const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/"; const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/";
@@ -283,6 +396,6 @@ module.exports = WebinarDashPlugin;
module.exports.default = WebinarDashPlugin; module.exports.default = WebinarDashPlugin;
module.exports.__test__ = { module.exports.__test__ = {
parseConfig, extractRawPath, derivePipeline, parseConfig, extractRawPath, derivePipeline,
parseCoverageTable, groupByStation, parseCoverageTable, groupByStation, reconcileConcepts,
STATIONS, DEFAULTS, STATIONS, DEFAULTS,
}; };

View File

@@ -127,3 +127,36 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.webinar-dash * { transition-duration: 0.01ms !important; } .webinar-dash * { transition-duration: 0.01ms !important; }
} }
.wd-meter { display: flex; gap: 2px; height: 8px; width: 100%; }
.wd-meter > i { display: block; height: 100%; }
.wd-seg-covered { background: var(--wd-ok); }
.wd-seg-partial { background: var(--wd-warn); }
.wd-seg-absent { background: var(--wd-danger); }
.wd-key { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: var(--wd-fg-2); }
.wd-key > span { display: inline-flex; align-items: center; gap: 6px; }
.wd-key i { width: 8px; height: 8px; flex: none; }
.wd-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.wd-tbl th {
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg-3); font-weight: 500;
text-align: left; padding: 0 12px 8px 0;
border-bottom: 1px solid var(--wd-border-strong);
}
.wd-tbl td {
padding: 6px 12px 6px 0; border-bottom: 1px solid var(--wd-border-subtle);
vertical-align: baseline;
}
.wd-grp td {
padding-top: 16px; border-bottom: 1px solid var(--wd-border-strong);
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg); font-weight: 500;
}
.wd-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; }
.wd-stat svg { width: 13px; height: 13px; flex: none; }
.wd-covered { color: var(--wd-ok); }
.wd-partial { color: var(--wd-warn); }
.wd-absent { color: var(--wd-danger); }
.wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); }

View File

@@ -101,3 +101,22 @@ test("groupByStation places a multi-station row under its first station only", (
assert.equal(groups.length, 1); assert.equal(groups.length, 1);
assert.equal(groups[0].station, "Memory"); assert.equal(groups[0].station, "Memory");
}); });
const { reconcileConcepts } = require("../main.js").__test__;
test("reconcileConcepts finds concept pages with no row", () => {
const rows = [{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 }];
const out = reconcileConcepts(rows, ["harness", "brand-new-concept"]);
assert.deepEqual(out.unsynced, ["brand-new-concept"]);
assert.deepEqual(out.stale, []);
});
test("reconcileConcepts finds rows whose concept page is gone", () => {
const rows = [
{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 },
{ concept: "deleted-idea", status: "absent", stations: [], pinned: false, line: 2 },
];
const out = reconcileConcepts(rows, ["harness"]);
assert.deepEqual(out.stale.map((r) => r.concept), ["deleted-idea"]);
assert.deepEqual(out.unsynced, []);
});