feat: render coverage meter and station-grouped table
This commit is contained in:
117
.obsidian/plugins/webinar-dash/main.js
vendored
117
.obsidian/plugins/webinar-dash/main.js
vendored
@@ -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,
|
||||
};
|
||||
|
||||
33
.obsidian/plugins/webinar-dash/styles.css
vendored
33
.obsidian/plugins/webinar-dash/styles.css
vendored
@@ -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); }
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user