From 2bb0be0a6478d15cccf5693e95f1a7488a18f97d Mon Sep 17 00:00:00 2001 From: meels Date: Tue, 28 Jul 2026 14:07:44 +0200 Subject: [PATCH] feat: render coverage meter and station-grouped table --- .obsidian/plugins/webinar-dash/main.js | 117 +++++++++++++++++- .obsidian/plugins/webinar-dash/styles.css | 33 +++++ .../webinar-dash/test/coverage.test.js | 19 +++ 3 files changed, 167 insertions(+), 2 deletions(-) diff --git a/.obsidian/plugins/webinar-dash/main.js b/.obsidian/plugins/webinar-dash/main.js index 9d0aa1f..1ab45cb 100644 --- a/.obsidian/plugins/webinar-dash/main.js +++ b/.obsidian/plugins/webinar-dash/main.js @@ -146,6 +146,16 @@ function groupByStation(rows) { 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) { const cfg = Object.assign({}, DEFAULTS); for (const line of String(source).split(/\r?\n/)) { @@ -241,6 +251,89 @@ function renderLeftPane(container, pipeline, onIngest) { 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 { async onload() { this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => { @@ -250,11 +343,31 @@ class WebinarDashPlugin extends PluginBase { const pipeline = await this.readPipeline(cfg); renderLeftPane(root, pipeline, () => {}); } 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) { const all = this.app.vault.getFiles(); const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/"; @@ -283,6 +396,6 @@ module.exports = WebinarDashPlugin; module.exports.default = WebinarDashPlugin; module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, - parseCoverageTable, groupByStation, + parseCoverageTable, groupByStation, reconcileConcepts, STATIONS, DEFAULTS, }; diff --git a/.obsidian/plugins/webinar-dash/styles.css b/.obsidian/plugins/webinar-dash/styles.css index 5c61b9b..52e532d 100644 --- a/.obsidian/plugins/webinar-dash/styles.css +++ b/.obsidian/plugins/webinar-dash/styles.css @@ -127,3 +127,36 @@ @media (prefers-reduced-motion: reduce) { .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); } diff --git a/.obsidian/plugins/webinar-dash/test/coverage.test.js b/.obsidian/plugins/webinar-dash/test/coverage.test.js index c326d4c..22ff85f 100644 --- a/.obsidian/plugins/webinar-dash/test/coverage.test.js +++ b/.obsidian/plugins/webinar-dash/test/coverage.test.js @@ -101,3 +101,22 @@ test("groupByStation places a multi-station row under its first station only", ( assert.equal(groups.length, 1); 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, []); +});