wowbagger 0.1.0-alpha.2 → 0.1.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +74 -35
- package/adapters/claude-code/wowbagger-adapter.json +1 -1
- package/docs/adapter-contract.md +1280 -0
- package/docs/work-claim-contract.md +21 -4
- package/package.json +3 -1
- package/skills/wowbagger/SKILL.md +37 -5
- package/src/adapter/entrypoint-main.js +19 -5
- package/src/claim-coordinator.js +113 -7
- package/src/claim-journal.js +45 -2
- package/src/claim-publication.js +121 -59
- package/src/claim-store.js +20 -8
- package/src/cli.js +118 -11
- package/src/mutation.js +151 -32
- package/src/ready.js +64 -38
- package/src/report-html.js +198 -0
- package/src/report-markdown.js +171 -0
- package/src/report.js +396 -0
package/src/ready.js
CHANGED
|
@@ -1,11 +1,46 @@
|
|
|
1
1
|
import { isDependencySatisfied } from './dependencies.js';
|
|
2
2
|
|
|
3
|
-
export function
|
|
3
|
+
export function projectReadiness(items, asOf) {
|
|
4
4
|
const byId = new Map(items.map((item) => [item.data.id, item]));
|
|
5
|
-
const
|
|
5
|
+
const ancestorReasonsById = new Map();
|
|
6
|
+
const projection = new Map();
|
|
7
|
+
|
|
8
|
+
for (const item of items) {
|
|
9
|
+
const reasons = [];
|
|
10
|
+
if (item.data.kind !== 'task') {
|
|
11
|
+
reasons.push({ code: 'kind-not-task' });
|
|
12
|
+
}
|
|
13
|
+
if (item.data.status !== 'backlog') {
|
|
14
|
+
reasons.push({ code: 'status-not-backlog' });
|
|
15
|
+
}
|
|
16
|
+
if (item.data.snoozed_until && item.data.snoozed_until > asOf) {
|
|
17
|
+
reasons.push({ code: 'snoozed' });
|
|
18
|
+
}
|
|
19
|
+
const ineligible = reasons.length > 0;
|
|
20
|
+
if (Array.isArray(item.data.depends_on)) {
|
|
21
|
+
for (const dependencyId of item.data.depends_on) {
|
|
22
|
+
const dependencyIsSatisfied = item.data.schema_version !== 1
|
|
23
|
+
&& isDependencySatisfied(byId.get(dependencyId)?.data.status);
|
|
24
|
+
if (!dependencyIsSatisfied) {
|
|
25
|
+
reasons.push({ code: 'dependency-unsatisfied', item_id: dependencyId });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
reasons.push(...ancestorReasons(item.data.parent, byId, ancestorReasonsById));
|
|
30
|
+
projection.set(item.data.id, {
|
|
31
|
+
state: ineligible ? 'ineligible' : reasons.length > 0 ? 'blocked' : 'ready',
|
|
32
|
+
reasons,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return projection;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function selectReady(items, asOf) {
|
|
40
|
+
const projection = projectReadiness(items, asOf);
|
|
6
41
|
|
|
7
42
|
return items
|
|
8
|
-
.filter((item) =>
|
|
43
|
+
.filter((item) => projection.get(item.data.id).state === 'ready')
|
|
9
44
|
.sort((left, right) => comparePriority(left.data, right.data)
|
|
10
45
|
|| compareText(left.data.created, right.data.created)
|
|
11
46
|
|| compareText(left.data.id, right.data.id))
|
|
@@ -32,54 +67,45 @@ function comparePriority(left, right) {
|
|
|
32
67
|
return left.priority - right.priority;
|
|
33
68
|
}
|
|
34
69
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
&& Array.isArray(data.depends_on)
|
|
42
|
-
&& dependenciesAreSatisfied(data, byId)
|
|
43
|
-
&& ancestorsAreBacklog(data, byId, ancestorsBacklogById);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function dependenciesAreSatisfied(data, byId) {
|
|
47
|
-
if (data.schema_version === 1) {
|
|
48
|
-
return data.depends_on.length === 0;
|
|
70
|
+
function ancestorReasons(startId, byId, cache) {
|
|
71
|
+
if (!startId) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
if (cache.has(startId)) {
|
|
75
|
+
return cache.get(startId);
|
|
49
76
|
}
|
|
50
|
-
return data.depends_on.every((id) => isDependencySatisfied(byId.get(id)?.data.status));
|
|
51
|
-
}
|
|
52
77
|
|
|
53
|
-
function ancestorsAreBacklog(data, byId, ancestorsBacklogById) {
|
|
54
|
-
let parentId = data.parent;
|
|
55
|
-
const visited = new Set();
|
|
56
78
|
const path = [];
|
|
57
|
-
|
|
79
|
+
const visited = new Set();
|
|
80
|
+
let ancestorId = startId;
|
|
81
|
+
let suffix = [];
|
|
58
82
|
|
|
59
|
-
while (
|
|
60
|
-
if (
|
|
61
|
-
|
|
83
|
+
while (ancestorId) {
|
|
84
|
+
if (cache.has(ancestorId)) {
|
|
85
|
+
suffix = cache.get(ancestorId);
|
|
62
86
|
break;
|
|
63
87
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
result = false;
|
|
88
|
+
if (visited.has(ancestorId)) {
|
|
89
|
+
suffix = [{ code: 'ancestor-not-backlog', item_id: ancestorId }];
|
|
67
90
|
break;
|
|
68
91
|
}
|
|
69
|
-
visited.add(
|
|
70
|
-
path.push(parentId);
|
|
92
|
+
visited.add(ancestorId);
|
|
71
93
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const ancestor = byId.get(ancestorId);
|
|
95
|
+
path.push({ id: ancestorId, item: ancestor });
|
|
96
|
+
if (!ancestor) {
|
|
75
97
|
break;
|
|
76
98
|
}
|
|
77
|
-
|
|
99
|
+
ancestorId = ancestor.data.parent;
|
|
78
100
|
}
|
|
79
101
|
|
|
80
|
-
for (
|
|
81
|
-
|
|
102
|
+
for (let index = path.length - 1; index >= 0; index -= 1) {
|
|
103
|
+
const entry = path[index];
|
|
104
|
+
if (!entry.item || entry.item.data.status !== 'backlog') {
|
|
105
|
+
suffix = [{ code: 'ancestor-not-backlog', item_id: entry.id }, ...suffix];
|
|
106
|
+
}
|
|
107
|
+
cache.set(entry.id, suffix);
|
|
82
108
|
}
|
|
83
109
|
|
|
84
|
-
return
|
|
110
|
+
return cache.get(startId) ?? suffix;
|
|
85
111
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { markdownBrowserSource } from './report-markdown.js';
|
|
2
|
+
|
|
3
|
+
const READINESS_LABELS = {
|
|
4
|
+
ready: 'Ready',
|
|
5
|
+
blocked: 'Blocked',
|
|
6
|
+
ineligible: 'Ineligible',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const REASON_LABELS = {
|
|
10
|
+
'kind-not-task': 'Not a task',
|
|
11
|
+
'status-not-backlog': 'Not in backlog',
|
|
12
|
+
snoozed: 'Snoozed',
|
|
13
|
+
'dependency-unsatisfied': 'Dependency is not done',
|
|
14
|
+
'ancestor-not-backlog': 'Ancestor is not in backlog',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function escapeHtml(value) {
|
|
18
|
+
return String(value)
|
|
19
|
+
.replaceAll('&', '&')
|
|
20
|
+
.replaceAll('<', '<')
|
|
21
|
+
.replaceAll('>', '>')
|
|
22
|
+
.replaceAll('"', '"');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function scriptJson(value) {
|
|
26
|
+
return JSON.stringify(value)
|
|
27
|
+
.replaceAll('<', '\\u003c')
|
|
28
|
+
.replaceAll('\u2028', '\\u2028')
|
|
29
|
+
.replaceAll('\u2029', '\\u2029');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fieldLabel(name) {
|
|
33
|
+
return name.split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderBadges(item) {
|
|
37
|
+
const badges = [
|
|
38
|
+
`<span class="badge state-${escapeHtml(item.readiness.state)}">${READINESS_LABELS[item.readiness.state]}</span>`,
|
|
39
|
+
`<span class="badge">${escapeHtml(item.status)}</span>`,
|
|
40
|
+
];
|
|
41
|
+
if (item.priority !== null) {
|
|
42
|
+
badges.push(`<span class="badge">Priority ${escapeHtml(item.priority)}</span>`);
|
|
43
|
+
}
|
|
44
|
+
for (const [name, value] of Object.entries(item.fields)) {
|
|
45
|
+
badges.push(`<span class="badge mapped">${escapeHtml(fieldLabel(name))}: ${escapeHtml(value)}</span>`);
|
|
46
|
+
}
|
|
47
|
+
return badges.join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderReasons(item) {
|
|
51
|
+
if (item.readiness.reasons.length === 0) {
|
|
52
|
+
return '<span class="muted">No blockers.</span>';
|
|
53
|
+
}
|
|
54
|
+
return `<ul class="reasons">${item.readiness.reasons.map((reason) => {
|
|
55
|
+
const label = REASON_LABELS[reason.code] ?? reason.code;
|
|
56
|
+
const relatedId = reason.item_id === undefined ? '' : `: ${escapeHtml(reason.item_id)}`;
|
|
57
|
+
return `<li>${escapeHtml(label)}${relatedId}</li>`;
|
|
58
|
+
}).join('')}</ul>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderRelations(item) {
|
|
62
|
+
const rows = [
|
|
63
|
+
['Parent', item.parent === null ? [] : [item.parent]],
|
|
64
|
+
['Depends on', item.dependsOn],
|
|
65
|
+
['Related', item.related],
|
|
66
|
+
].filter(([, values]) => values.length > 0);
|
|
67
|
+
if (rows.length === 0) {
|
|
68
|
+
return '<span class="muted">No relations.</span>';
|
|
69
|
+
}
|
|
70
|
+
return `<dl class="relations">${rows.map(([label, values]) => `<dt>${label}</dt><dd>${values.map(escapeHtml).join(', ')}</dd>`).join('')}</dl>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderDecisions(item) {
|
|
74
|
+
if (item.decisions.length === 0) {
|
|
75
|
+
return '<span class="muted">No decisions.</span>';
|
|
76
|
+
}
|
|
77
|
+
return `<ol class="decisions">${item.decisions.map((decision) => `<li><strong>${escapeHtml(decision.action)}</strong> · ${escapeHtml(decision.date)}<br>${escapeHtml(decision.summary)}<br><span class="muted">${escapeHtml(decision.rationale)}</span></li>`).join('')}</ol>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bodyExcerpt(body) {
|
|
81
|
+
const compact = String(body).replace(/\s+/g, ' ').trim();
|
|
82
|
+
return compact.length <= 220 ? compact : `${compact.slice(0, 217)}...`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderCard(item, index) {
|
|
86
|
+
const handle = item.number === null ? item.id : `#${item.number}`;
|
|
87
|
+
const searchText = [handle, item.id, item.title, item.kind, item.status, ...Object.values(item.fields)]
|
|
88
|
+
.join(' ')
|
|
89
|
+
.toLocaleLowerCase('en-US');
|
|
90
|
+
const latestDecision = item.decisions.at(-1);
|
|
91
|
+
return `<details class="card" data-item data-order="${index}" data-state="${escapeHtml(item.readiness.state)}" data-status="${escapeHtml(item.status)}" data-priority="${item.priority ?? ''}" data-created="${escapeHtml(item.created)}" data-title="${escapeHtml(item.title.toLocaleLowerCase('en-US'))}" data-fields="${escapeHtml(JSON.stringify(item.fields))}" data-search="${escapeHtml(searchText)}">
|
|
92
|
+
<summary><span class="summary-main"><span class="handle">${escapeHtml(handle)}</span><span class="title">${escapeHtml(item.title)}</span></span><span class="badges">${renderBadges(item)}</span></summary>
|
|
93
|
+
<div class="card-detail">
|
|
94
|
+
<div class="basic-grid"><section><h3>Readiness</h3>${renderReasons(item)}</section><section><h3>Dates</h3><p>Created ${escapeHtml(item.created)}<br>Updated ${escapeHtml(item.updated)}</p></section></div>
|
|
95
|
+
<section class="standard-only"><h3>Relations</h3>${renderRelations(item)}</section>
|
|
96
|
+
<section class="standard-only"><h3>Latest decision</h3>${latestDecision === undefined ? '<span class="muted">No decisions.</span>' : renderDecisions({ decisions: [latestDecision] })}</section>
|
|
97
|
+
<section class="body-excerpt standard-only"><h3>Body excerpt</h3><p>${escapeHtml(bodyExcerpt(item.body))}</p></section>
|
|
98
|
+
<section class="detailed-only"><h3>Decision history</h3>${renderDecisions(item)}</section>
|
|
99
|
+
<section class="body-section detailed-only"><h3>Item body</h3><script type="text/markdown">${scriptJson(item.body)}</script><div class="rendered-markdown" data-rendered="0"></div><noscript><pre>${escapeHtml(item.body)}</pre></noscript></section>
|
|
100
|
+
</div>
|
|
101
|
+
</details>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderTerminalTable(items) {
|
|
105
|
+
if (items.length === 0) {
|
|
106
|
+
return '<p class="muted">No completed, killed, deferred, or archived items.</p>';
|
|
107
|
+
}
|
|
108
|
+
return `<div class="table-scroll"><table><thead><tr><th>Item</th><th>Title</th><th>Status</th><th>Date</th><th>Decision</th></tr></thead><tbody>${items.map((item) => {
|
|
109
|
+
const handle = item.number === null ? item.id : `#${item.number}`;
|
|
110
|
+
const decision = item.decisions.at(-1)?.summary ?? '';
|
|
111
|
+
return `<tr><td>${escapeHtml(handle)}</td><td>${escapeHtml(item.title)}</td><td>${escapeHtml(item.status)}</td><td>${escapeHtml(item.terminalDate)}</td><td>${escapeHtml(decision)}</td></tr>`;
|
|
112
|
+
}).join('')}</tbody></table></div>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderSwarm(batches) {
|
|
116
|
+
if (batches.length === 0) {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
return `<section class="panel swarm"><div class="section-heading"><div><p class="eyebrow">Parallel work</p><h2>Swarm candidates</h2></div><p class="muted">Ready items only. Each batch contains no repeated mapped area.</p></div><div class="batch-grid">${batches.map((batch, index) => `<section class="batch"><h3>Batch ${index + 1}</h3><ol>${batch.map((item) => `<li><span>${escapeHtml(item.number === null ? item.id : `#${item.number}`)}</span> ${escapeHtml(item.title)}</li>`).join('')}</ol></section>`).join('')}</div></section>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function styleSource() {
|
|
123
|
+
return `:root{color-scheme:light;--canvas:#f3f0e9;--surface:#fffdf8;--ink:#20231f;--muted:#687069;--line:#d9d7ce;--navy:#15324a;--ready:#1e6b4f;--ready-bg:#e6f3ec;--blocked:#9b4b21;--blocked-bg:#f9e9df;--ineligible:#60666a;--ineligible-bg:#eceeef;--focus:#0b67b2;--shadow:0 10px 28px rgba(32,35,31,.07)}*{box-sizing:border-box}html{background:var(--canvas);color:var(--ink);font:14px/1.55 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0}button,input,select{font:inherit}button,select,input{border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink)}button,select{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px}.shell{width:min(1500px,100%);margin:auto;padding:28px 36px 56px}.masthead{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:4px 0 26px}.identity{display:flex;align-items:center;gap:16px}.logo{width:48px;height:48px;object-fit:contain}.eyebrow{margin:0 0 4px;color:var(--muted);font-size:.75rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase}.masthead h1,.section-heading h2{margin:0;font-family:ui-serif,Georgia,serif;color:var(--navy);line-height:1.1}.masthead h1{font-size:clamp(1.8rem,4vw,3rem)}.as-of{text-align:right;color:var(--muted)}.stats{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:16px}.stat,.panel{border:1px solid var(--line);border-radius:12px;background:var(--surface);box-shadow:var(--shadow)}.stat{padding:14px}.stat strong{display:block;color:var(--navy);font-size:1.55rem}.stat span{color:var(--muted)}.controls{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:12px;margin-bottom:18px;background:color-mix(in srgb,var(--surface) 95%,transparent);backdrop-filter:blur(10px)}.controls input{min-width:min(320px,100%);flex:1;padding:9px 11px}.controls select,.controls button{padding:8px 10px}.filter{font-weight:700}.filter.active{border-color:var(--navy);background:var(--navy);color:#fff}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:14px}.section-heading p{margin:0}.items{display:grid;gap:18px}.group h2{margin:0 0 8px;color:var(--navy);font:700 1.05rem/1.2 ui-sans-serif,-apple-system,sans-serif}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,370px),1fr));gap:12px}.card{min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--surface);box-shadow:0 3px 12px rgba(32,35,31,.04);overflow:hidden}.card[open]{grid-column:1/-1;box-shadow:var(--shadow)}summary{display:block;padding:14px;cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}.summary-main{display:flex;gap:8px;align-items:baseline}.handle{flex:none;color:var(--navy);font-variant-numeric:tabular-nums;font-weight:800}.title{font-weight:700;overflow-wrap:anywhere}.badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px}.badge{display:inline-flex;border-radius:999px;background:#efeee9;padding:2px 8px;color:#4d534e;font-size:.73rem;font-weight:700}.badge.state-ready{background:var(--ready-bg);color:var(--ready)}.badge.state-blocked{background:var(--blocked-bg);color:var(--blocked)}.badge.state-ineligible{background:var(--ineligible-bg);color:var(--ineligible)}.badge.mapped{background:#e8edf2;color:var(--navy)}.card-detail{border-top:1px solid var(--line);padding:16px}.card-detail h3,.batch h3{margin:0 0 7px;color:var(--navy);font-size:.83rem;letter-spacing:.04em;text-transform:uppercase}.card-detail p{margin:0}.basic-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.standard-only,.detailed-only,.body-section{margin-top:18px}.reasons,.decisions{margin:0;padding-left:20px}.relations{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:0}.relations dt{font-weight:700}.relations dd{margin:0;overflow-wrap:anywhere}.rendered-markdown{overflow-wrap:anywhere}.rendered-markdown h1,.rendered-markdown h2,.rendered-markdown h3{font-family:ui-serif,Georgia,serif;text-transform:none;letter-spacing:0}.rendered-markdown pre,noscript pre{overflow:auto;border-radius:8px;background:#202622;color:#f5f7f3;padding:12px}.rendered-markdown code{border-radius:4px;background:#eeece6;padding:1px 4px}.rendered-markdown pre code{background:transparent;padding:0}.rendered-markdown a{color:#075fa7}.rendered-markdown table,table{width:100%;border-collapse:collapse}.rendered-markdown th,.rendered-markdown td,th,td{border-bottom:1px solid var(--line);padding:8px;text-align:left;vertical-align:top}.panel{margin-top:26px;padding:18px}.table-scroll{overflow-x:auto}.batch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}.batch{border:1px solid var(--line);border-radius:9px;padding:12px}.batch ol{margin:0;padding-left:22px}.muted{color:var(--muted)}.empty{padding:32px;text-align:center;color:var(--muted)}body[data-richness="basic"] .standard-only,body[data-richness="basic"] .detailed-only,body[data-richness="standard"] .detailed-only{display:none}[hidden]{display:none!important}@media(max-width:850px){.shell{padding:20px 16px 40px}.stats{grid-template-columns:repeat(3,1fr)}.masthead,.section-heading{align-items:flex-start;flex-direction:column}.as-of{text-align:left}.controls{position:static}.basic-grid{grid-template-columns:1fr}}@media(max-width:480px){.stats{grid-template-columns:repeat(2,1fr)}.controls>*{width:100%}.summary-main{align-items:flex-start;flex-direction:column}.card-detail{padding:13px}}@media(prefers-reduced-motion:no-preference){.card{transition:box-shadow .15s ease,border-color .15s ease}.filter{transition:background-color .15s ease,color .15s ease}}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function clientSource() {
|
|
127
|
+
return `'use strict';
|
|
128
|
+
const itemRoot=document.getElementById('items');
|
|
129
|
+
const cards=Array.from(itemRoot.querySelectorAll('[data-item]'));
|
|
130
|
+
const search=document.getElementById('search');
|
|
131
|
+
const groupBy=document.getElementById('group-by');
|
|
132
|
+
const sortBy=document.getElementById('sort-by');
|
|
133
|
+
const richness=document.getElementById('richness');
|
|
134
|
+
const slotFilters=Array.from(document.querySelectorAll('.slot-filter'));
|
|
135
|
+
const empty=document.getElementById('empty');
|
|
136
|
+
const showHistory=document.getElementById('show-history');
|
|
137
|
+
const history=document.getElementById('history');
|
|
138
|
+
let state='all';
|
|
139
|
+
function fields(card){return JSON.parse(card.dataset.fields);}
|
|
140
|
+
function fieldValue(card,key){const value=fields(card)[key];return value===undefined?'':String(value);}
|
|
141
|
+
function groupLabel(card){const key=groupBy.value;if(key==='none')return 'All items';if(key==='readiness')return card.dataset.state;if(key==='status')return card.dataset.status;if(key.startsWith('field:'))return fieldValue(card,key.slice(6))||'Unclassified';return 'Unclassified';}
|
|
142
|
+
function compareText(left,right){return left<right?-1:left>right?1:0;}
|
|
143
|
+
function compareCards(left,right){const key=sortBy.value;if(key==='default')return Number(left.dataset.order)-Number(right.dataset.order);if(key==='priority'){const a=left.dataset.priority===''?null:Number(left.dataset.priority);const b=right.dataset.priority===''?null:Number(right.dataset.priority);if(a===null||b===null)return a===b?Number(left.dataset.order)-Number(right.dataset.order):a===null?1:-1;return a-b||Number(left.dataset.order)-Number(right.dataset.order);}if(key==='created')return compareText(left.dataset.created,right.dataset.created)||Number(left.dataset.order)-Number(right.dataset.order);if(key==='title')return compareText(left.dataset.title,right.dataset.title)||Number(left.dataset.order)-Number(right.dataset.order);if(key.startsWith('field:'))return compareText(fieldValue(left,key.slice(6)),fieldValue(right,key.slice(6)))||Number(left.dataset.order)-Number(right.dataset.order);return 0;}
|
|
144
|
+
function applyHistory(){history.hidden=!showHistory.checked;}
|
|
145
|
+
function apply(){const term=search.value.trim().toLocaleLowerCase('en-US');const visible=cards.filter(card=>(state==='all'||card.dataset.state===state)&&(!term||card.dataset.search.includes(term))&&slotFilters.every(filter=>filter.value===''||fieldValue(card,filter.dataset.field)===filter.value)).sort(compareCards);const groups=new Map();for(const card of visible){const label=groupLabel(card);if(!groups.has(label))groups.set(label,[]);groups.get(label).push(card);}itemRoot.replaceChildren();for(const [label,members] of groups){const section=document.createElement('section');section.className='group';if(groupBy.value!=='none'){const heading=document.createElement('h2');heading.textContent=label+' ('+members.length+')';section.append(heading);}const grid=document.createElement('div');grid.className='card-grid';grid.append(...members);section.append(grid);itemRoot.append(section);}empty.hidden=visible.length!==0;}
|
|
146
|
+
function renderBody(card){const target=card.querySelector('.rendered-markdown');if(target.dataset.rendered==='1')return;const source=card.querySelector('script[type="text/markdown"]');target.innerHTML=window.renderMarkdown(JSON.parse(source.textContent));target.dataset.rendered='1';}
|
|
147
|
+
for(const card of cards){card.addEventListener('toggle',()=>{if(card.open)renderBody(card);});}
|
|
148
|
+
for(const button of document.querySelectorAll('[data-filter]')){button.addEventListener('click',()=>{state=button.dataset.filter;document.querySelectorAll('[data-filter]').forEach(node=>node.classList.toggle('active',node===button));apply();});}
|
|
149
|
+
search.addEventListener('input',apply);groupBy.addEventListener('change',apply);sortBy.addEventListener('change',apply);for(const filter of slotFilters)filter.addEventListener('change',apply);richness.addEventListener('change',()=>{document.body.dataset.richness=richness.value;});
|
|
150
|
+
showHistory.addEventListener('change',applyHistory);
|
|
151
|
+
document.getElementById('expand-all').addEventListener('click',()=>{for(const card of cards){if(card.isConnected){card.open=true;renderBody(card);}}});
|
|
152
|
+
document.getElementById('collapse-all').addEventListener('click',()=>{for(const card of cards)card.open=false;});
|
|
153
|
+
applyHistory();apply();`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function renderReportHtml(model, { logoDataUrl = null } = {}) {
|
|
157
|
+
const fieldNames = [...new Set(model.items.flatMap((item) => Object.keys(item.fields)))].sort();
|
|
158
|
+
const groupOptions = [
|
|
159
|
+
['none', 'No grouping'],
|
|
160
|
+
['readiness', 'Readiness'],
|
|
161
|
+
['status', 'Status'],
|
|
162
|
+
...fieldNames.map((name) => [`field:${name}`, fieldLabel(name)]),
|
|
163
|
+
];
|
|
164
|
+
const sortOptions = [
|
|
165
|
+
['default', 'Report order'],
|
|
166
|
+
['priority', 'Priority'],
|
|
167
|
+
['created', 'Created date'],
|
|
168
|
+
['title', 'Title'],
|
|
169
|
+
...fieldNames.map((name) => [`field:${name}`, fieldLabel(name)]),
|
|
170
|
+
];
|
|
171
|
+
const slotFilters = fieldNames.map((name) => {
|
|
172
|
+
const values = [...new Set(model.items
|
|
173
|
+
.map((item) => item.fields[name])
|
|
174
|
+
.filter((value) => value !== undefined)
|
|
175
|
+
.map(String))].sort();
|
|
176
|
+
return `<select class="slot-filter" data-field="${escapeHtml(name)}" aria-label="Filter by ${escapeHtml(fieldLabel(name))}"><option value="">All ${escapeHtml(fieldLabel(name))}</option>${values.map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('')}</select>`;
|
|
177
|
+
}).join('');
|
|
178
|
+
const logo = logoDataUrl === null ? '' : `<img class="logo" src="${escapeHtml(logoDataUrl)}" alt="">`;
|
|
179
|
+
const stats = [
|
|
180
|
+
['Total', model.stats.total],
|
|
181
|
+
['Open', model.stats.open],
|
|
182
|
+
['Terminal', model.stats.terminal],
|
|
183
|
+
['Ready', model.stats.ready],
|
|
184
|
+
['Blocked', model.stats.blocked],
|
|
185
|
+
['Ineligible', model.stats.ineligible],
|
|
186
|
+
];
|
|
187
|
+
const markdownRuntime = markdownBrowserSource().replaceAll('</script', '<\\/script');
|
|
188
|
+
const interactionRuntime = clientSource().replaceAll('</script', '<\\/script');
|
|
189
|
+
|
|
190
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"><title>${escapeHtml(model.title)}</title><style>${styleSource()}.history-toggle{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:pointer}.controls .history-toggle input{min-width:0;flex:none;margin:0;padding:0}</style></head><body data-richness="standard"><main class="shell">
|
|
191
|
+
<header class="masthead"><div class="identity">${logo}<div><p class="eyebrow">${escapeHtml(model.repository.name)}</p><h1>${escapeHtml(model.title)}</h1></div></div><div class="as-of">Ledger state<br><strong>${escapeHtml(model.asOf)}</strong></div></header>
|
|
192
|
+
<section class="stats" aria-label="Ledger summary">${stats.map(([label, value]) => `<div class="stat"><strong>${escapeHtml(value)}</strong><span>${label}</span></div>`).join('')}</section>
|
|
193
|
+
<section class="controls panel" aria-label="Report controls"><label class="sr-only" for="search">Search items</label><input id="search" type="search" placeholder="Search ID, number, title, or mapped fields"><select id="group-by" aria-label="Group items">${groupOptions.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).join('')}</select><select id="sort-by" aria-label="Sort items">${sortOptions.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).join('')}</select>${slotFilters}<select id="richness" aria-label="Detail level"><option value="basic">Basic</option><option value="standard" selected>Standard</option><option value="detailed">Detailed</option></select><label class="history-toggle"><input id="show-history" type="checkbox" checked>Show history</label><button class="filter active" data-filter="all" type="button">All</button><button class="filter" data-filter="ready" type="button">Ready</button><button class="filter" data-filter="blocked" type="button">Blocked</button><button class="filter" data-filter="ineligible" type="button">Ineligible</button><button type="button" id="expand-all">Expand all</button><button type="button" id="collapse-all">Collapse all</button></section>
|
|
194
|
+
<section><div class="section-heading"><div><p class="eyebrow">Current work</p><h2>Ledger items</h2></div><p class="muted">Priority, then created date, then immutable ID.</p></div><div id="items" class="items">${model.items.map(renderCard).join('')}</div><p id="empty" class="empty" hidden>No items match these filters.</p></section>
|
|
195
|
+
${renderSwarm(model.swarmBatches)}
|
|
196
|
+
<section id="history" class="panel"><div class="section-heading"><div><p class="eyebrow">History</p><h2>Terminal items</h2></div><p class="muted">Completed, killed, deferred, and archived.</p></div>${renderTerminalTable(model.terminalItems)}</section>
|
|
197
|
+
</main><script>${markdownRuntime}</script><script>${interactionRuntime}</script></body></html>`;
|
|
198
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
function escapeHtml(value) {
|
|
2
|
+
return String(value)
|
|
3
|
+
.replaceAll('&', '&')
|
|
4
|
+
.replaceAll('<', '<')
|
|
5
|
+
.replaceAll('>', '>')
|
|
6
|
+
.replaceAll('"', '"');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeHref(value) {
|
|
10
|
+
return /^https?:\/\//i.test(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatText(value) {
|
|
14
|
+
return escapeHtml(value)
|
|
15
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
16
|
+
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
17
|
+
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
18
|
+
.replace(/(^|[^A-Za-z0-9])_([^_]+)_(?=[^A-Za-z0-9]|$)/g, '$1<em>$2</em>');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderInline(value) {
|
|
22
|
+
const replacements = [];
|
|
23
|
+
const protect = (html) => {
|
|
24
|
+
replacements.push(html);
|
|
25
|
+
return `\u0000${replacements.length - 1}\u0000`;
|
|
26
|
+
};
|
|
27
|
+
let text = String(value)
|
|
28
|
+
.replace(/`([^`]+)`/g, (_match, code) => protect(`<code>${escapeHtml(code)}</code>`))
|
|
29
|
+
.replace(/\[([^\]]+)]\(([^()\s]*(?:\([^()\s]*\)[^()\s]*)*)\)/g, (_match, label, url) => {
|
|
30
|
+
const href = safeHref(url);
|
|
31
|
+
return protect(href === null
|
|
32
|
+
? `<span>${formatText(label)}</span>`
|
|
33
|
+
: `<a href="${escapeHtml(href)}" rel="noopener noreferrer">${formatText(label)}</a>`);
|
|
34
|
+
});
|
|
35
|
+
text = formatText(text);
|
|
36
|
+
return text.replace(/\u0000(\d+)\u0000/g, (_match, index) => replacements[Number(index)]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isTableSeparator(line) {
|
|
40
|
+
return line.includes('-') && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tableCells(line) {
|
|
44
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderTable(lines) {
|
|
48
|
+
const headings = tableCells(lines[0]);
|
|
49
|
+
const rows = lines.slice(2).map(tableCells);
|
|
50
|
+
return `<table><thead><tr>${headings.map((cell) => `<th>${renderInline(cell)}</th>`).join('')}</tr></thead>`
|
|
51
|
+
+ `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renderMarkdown(markdown) {
|
|
55
|
+
const lines = String(markdown)
|
|
56
|
+
.replace(/\r\n?/g, '\n')
|
|
57
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
58
|
+
.split('\n');
|
|
59
|
+
const output = [];
|
|
60
|
+
const paragraph = [];
|
|
61
|
+
const listStack = [];
|
|
62
|
+
let index = 0;
|
|
63
|
+
|
|
64
|
+
const flushParagraph = () => {
|
|
65
|
+
if (paragraph.length > 0) {
|
|
66
|
+
output.push(`<p>${renderInline(paragraph.join(' '))}</p>`);
|
|
67
|
+
paragraph.length = 0;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const closeLists = (indent = -1) => {
|
|
71
|
+
while (listStack.length > 0 && listStack.at(-1).indent >= indent) {
|
|
72
|
+
output.push(listStack.pop().type === 'ol' ? '</ol>' : '</ul>');
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
while (index < lines.length) {
|
|
77
|
+
const line = lines[index];
|
|
78
|
+
const fence = line.match(/^\s*```(.*)$/);
|
|
79
|
+
if (fence !== null) {
|
|
80
|
+
flushParagraph();
|
|
81
|
+
closeLists();
|
|
82
|
+
const language = fence[1].trim().replace(/[^\w-]/g, '');
|
|
83
|
+
const body = [];
|
|
84
|
+
index += 1;
|
|
85
|
+
while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
|
|
86
|
+
body.push(lines[index]);
|
|
87
|
+
index += 1;
|
|
88
|
+
}
|
|
89
|
+
if (index < lines.length) {
|
|
90
|
+
index += 1;
|
|
91
|
+
}
|
|
92
|
+
output.push(`<pre><code${language === '' ? '' : ` class="language-${language}"`}>${escapeHtml(body.join('\n'))}\n</code></pre>`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (line.includes('|') && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
|
|
96
|
+
flushParagraph();
|
|
97
|
+
closeLists();
|
|
98
|
+
const table = [line, lines[index + 1]];
|
|
99
|
+
index += 2;
|
|
100
|
+
while (index < lines.length && lines[index].includes('|') && lines[index].trim() !== '') {
|
|
101
|
+
table.push(lines[index]);
|
|
102
|
+
index += 1;
|
|
103
|
+
}
|
|
104
|
+
output.push(renderTable(table));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (line.trim() === '') {
|
|
108
|
+
flushParagraph();
|
|
109
|
+
closeLists();
|
|
110
|
+
index += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
114
|
+
if (heading !== null) {
|
|
115
|
+
flushParagraph();
|
|
116
|
+
closeLists();
|
|
117
|
+
const level = heading[1].length;
|
|
118
|
+
output.push(`<h${level}>${renderInline(heading[2].trim())}</h${level}>`);
|
|
119
|
+
index += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (/^\s*([-*_])\1\1[-*_\s]*$/.test(line)) {
|
|
123
|
+
flushParagraph();
|
|
124
|
+
closeLists();
|
|
125
|
+
output.push('<hr>');
|
|
126
|
+
index += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/^\s*>\s?/.test(line)) {
|
|
130
|
+
flushParagraph();
|
|
131
|
+
closeLists();
|
|
132
|
+
const quote = [];
|
|
133
|
+
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
|
134
|
+
quote.push(lines[index].replace(/^\s*>\s?/, ''));
|
|
135
|
+
index += 1;
|
|
136
|
+
}
|
|
137
|
+
output.push(`<blockquote><p>${renderInline(quote.join(' '))}</p></blockquote>`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const listItem = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
141
|
+
if (listItem !== null) {
|
|
142
|
+
flushParagraph();
|
|
143
|
+
const indent = listItem[1].length;
|
|
144
|
+
const type = /\d/.test(listItem[2]) ? 'ol' : 'ul';
|
|
145
|
+
closeLists(indent + 1);
|
|
146
|
+
const top = listStack.at(-1);
|
|
147
|
+
if (top === undefined || top.indent < indent) {
|
|
148
|
+
output.push(`<${type}>`);
|
|
149
|
+
listStack.push({ type, indent });
|
|
150
|
+
} else if (top.indent === indent && top.type !== type) {
|
|
151
|
+
output.push(`</${top.type}>`);
|
|
152
|
+
listStack.pop();
|
|
153
|
+
output.push(`<${type}>`);
|
|
154
|
+
listStack.push({ type, indent });
|
|
155
|
+
}
|
|
156
|
+
output.push(`<li>${renderInline(listItem[3])}</li>`);
|
|
157
|
+
index += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
paragraph.push(line.trim());
|
|
161
|
+
index += 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
flushParagraph();
|
|
165
|
+
closeLists();
|
|
166
|
+
return output.join('');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function markdownBrowserSource() {
|
|
170
|
+
return `${escapeHtml.toString()}\n${safeHref.toString()}\n${formatText.toString()}\n${renderInline.toString()}\n${isTableSeparator.toString()}\n${tableCells.toString()}\n${renderTable.toString()}\n${renderMarkdown.toString()}\nwindow.renderMarkdown = renderMarkdown;`;
|
|
171
|
+
}
|