@dougharness/cli 1.2.0 → 1.4.0
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/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
- package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
- package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
- package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
package/templates/board-app.js
CHANGED
|
@@ -48,12 +48,18 @@ footer{padding:0 24px 24px;font-size:12px;color:var(--ink-soft)}
|
|
|
48
48
|
#autopilot{margin:14px 24px 0;padding:10px 14px;background:var(--surface);border:1px solid var(--rule-strong)}
|
|
49
49
|
#autopilot[hidden]{display:none}
|
|
50
50
|
#autopilot h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;margin:0 0 6px}
|
|
51
|
+
#autopilot h3{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;color:var(--ink-soft);margin:8px 0 0}
|
|
51
52
|
#autopilot ul{list-style:none;margin:4px 0;padding:0;font-size:13px}
|
|
52
53
|
#autopilot li{display:flex;gap:8px;flex-wrap:wrap;align-items:baseline}
|
|
53
|
-
#autopilot .st{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);min-width:
|
|
54
|
+
#autopilot .st{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);min-width:1.5em}
|
|
54
55
|
#autopilot .st.running{color:var(--accent);font-weight:600}
|
|
55
56
|
#autopilot .st.stopped,#autopilot .st.interrupted{color:var(--c-decision)}
|
|
56
57
|
#autopilot .caps,#autopilot .filed{font-size:12px;color:var(--ink-soft);margin-top:4px}
|
|
58
|
+
#milestones{margin:14px 24px 0;padding:10px 14px;background:var(--surface);border:1px solid var(--rule-strong)}
|
|
59
|
+
#milestones h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;margin:0 0 6px}
|
|
60
|
+
#milestones ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;font-size:13px}
|
|
61
|
+
#milestones li{overflow-wrap:anywhere}
|
|
62
|
+
@media (max-width:720px){#milestones{margin:14px 12px 0}}
|
|
57
63
|
@media (max-width:720px){.board{grid-auto-columns:minmax(85vw,1fr)}}
|
|
58
64
|
`;
|
|
59
65
|
|
|
@@ -61,17 +67,91 @@ const FONTS = '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?fa
|
|
|
61
67
|
|
|
62
68
|
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
63
69
|
|
|
64
|
-
|
|
70
|
+
// WCAG relative luminance of a #rrggbb colour.
|
|
71
|
+
function relLuminance(hex) {
|
|
72
|
+
const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));
|
|
73
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// WCAG contrast ratio of two #rrggbb colours, in either order.
|
|
77
|
+
function contrastRatio(a, b) {
|
|
78
|
+
const la = relLuminance(a);
|
|
79
|
+
const lb = relLuminance(b);
|
|
80
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Black or white, whichever contrasts more with the label colour (the better is always >= 4.5:1).
|
|
84
|
+
function chipTextColor(bg) {
|
|
85
|
+
return contrastRatio(bg, "#000000") >= contrastRatio(bg, "#ffffff") ? "#000000" : "#ffffff";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Label name -> colour across a board's label groups; empty for a version 1 board.
|
|
89
|
+
function labelColors(labelGroups) {
|
|
90
|
+
const colors = {};
|
|
91
|
+
for (const g of labelGroups || []) for (const l of g.labels || []) colors[l.name] = l.color;
|
|
92
|
+
return colors;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const PRIORITY_LABELS = ["urgent", "high", "medium", "low"];
|
|
96
|
+
|
|
97
|
+
const READY_BAND_HINT = "Priority sets the order in Ready; a card moves only among cards of its own priority.";
|
|
98
|
+
const READY_COL_HINT = "by priority; reorder within a priority";
|
|
99
|
+
|
|
100
|
+
function hasPriorityGroup(labelGroups) {
|
|
101
|
+
return (labelGroups || []).some((g) => g.id === "priority");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A card's priority band: the lowest PRIORITY_LABELS index among its tags, none last.
|
|
105
|
+
function priorityRank(c) {
|
|
106
|
+
const idx = (c.tags || []).map((t) => PRIORITY_LABELS.indexOf(t)).filter((i) => i >= 0);
|
|
107
|
+
return idx.length ? Math.min.apply(null, idx) : PRIORITY_LABELS.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ready in the order `next` picks it: priority label rank (none last), then board order. Only a board with a
|
|
111
|
+
// priority group ranks; without one the order is record order. Never mutates its input.
|
|
112
|
+
function readyOrder(cards, labelGroups) {
|
|
113
|
+
if (!hasPriorityGroup(labelGroups)) return cards;
|
|
114
|
+
return cards.map((c, i) => ({ c, i, r: priorityRank(c) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.c);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What a placement next to targetId in `column` may do. "place": as asked. "refuse": a Ready card dropped among
|
|
118
|
+
// another priority band (Ready is sorted, so the move would snap back). "column": a card from another column
|
|
119
|
+
// dropped among another band; it joins Ready, and the sort picks its slot.
|
|
120
|
+
function priorityBandMove(cards, labelGroups, id, column, targetId) {
|
|
121
|
+
if (column !== "ready" || !hasPriorityGroup(labelGroups)) return "place";
|
|
122
|
+
const card = cards.find((c) => c.id === id);
|
|
123
|
+
const target = cards.find((c) => c.id === targetId);
|
|
124
|
+
if (!card || !target || priorityRank(card) === priorityRank(target)) return "place";
|
|
125
|
+
return card.column === "ready" ? "refuse" : "column";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The cards of a column in display order: Done newest first, Ready by priority, the rest in record order.
|
|
129
|
+
function orderedCards(col, cards, labelGroups) {
|
|
130
|
+
const mine = cards.filter((c) => c.column === col.id);
|
|
131
|
+
if (col.id === "done") return [...mine].reverse();
|
|
132
|
+
if (col.id === "ready") return readyOrder(mine, labelGroups);
|
|
133
|
+
return mine;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The Done column shows only its newest DONE_SHOWN cards until "show all"; the record keeps every card.
|
|
137
|
+
const DONE_SHOWN = 20;
|
|
138
|
+
|
|
139
|
+
function cardHtml(c, columns, colors) {
|
|
65
140
|
const deps = (c.deps || []).length ? `<div class="meta">after <code>${c.deps.map(esc).join("</code> <code>")}</code></div>` : "";
|
|
66
141
|
const opts = columns.map((col) => `<option value="${col.id}"${col.id === c.column ? " selected" : ""}>${esc(col.title)}</option>`).join("");
|
|
67
142
|
const chip = c.component ? `<span class="chip" style="color:var(--c-${esc(c.component)})">${esc(c.component)}</span>` : "";
|
|
68
143
|
// A hand-track card is a gated by-hand change; the chip says so at a glance.
|
|
69
144
|
const hand = c.track === "hand" ? `<span class="chip hand" title="hand track: a gated by-hand change">by hand</span>` : "";
|
|
70
|
-
const tags = c.tags && c.tags.length ? c.tags.map((t) =>
|
|
145
|
+
const tags = c.tags && c.tags.length ? c.tags.map((t) => {
|
|
146
|
+
const bg = colors && /^#[0-9a-fA-F]{6}$/.test(colors[t] || "") ? colors[t] : "";
|
|
147
|
+
return bg ? `<span class="chip tag" style="background:${bg};color:${chipTextColor(bg)};border-color:${bg}">${esc(t)}</span>` : `<span class="chip tag">${esc(t)}</span>`;
|
|
148
|
+
}).join("") : "";
|
|
71
149
|
const cls = c.class ? `<span class="chip class" title="class">${esc(c.class)}</span>` : "";
|
|
72
150
|
const compAttr = c.component ? ` data-component="${esc(c.component)}"` : "";
|
|
73
151
|
const tagsAttr = c.tags && c.tags.length ? ` data-tags="${esc(c.tags.join(" "))}"` : "";
|
|
74
|
-
|
|
152
|
+
const epicAttr = c.epic ? ` data-epic="${esc(c.epic)}"` : "";
|
|
153
|
+
const milestoneAttr = c.milestone ? ` data-milestone="${esc(c.milestone)}"` : "";
|
|
154
|
+
return `<article class="card" draggable="true" tabindex="0" data-id="${esc(c.id)}"${compAttr} data-column="${esc(c.column)}"${tagsAttr}${epicAttr}${milestoneAttr}>
|
|
75
155
|
<div class="top">${chip}${hand}${tags}${cls}<span class="size">${esc(c.size || "")}</span></div>
|
|
76
156
|
<div class="title">${esc(c.title)}</div>
|
|
77
157
|
<div class="meta"><code>${esc(c.id)}</code>${c.source ? ` · ${esc(c.source)}` : ""}</div>
|
|
@@ -81,16 +161,34 @@ ${c.goal ? `<details><summary>goal</summary><p class="goal">${esc(c.goal)}</p></
|
|
|
81
161
|
</article>`;
|
|
82
162
|
}
|
|
83
163
|
|
|
164
|
+
// The inner HTML of a column's .cards: its cards in display order. Collapsed Done keeps the newest DONE_SHOWN
|
|
165
|
+
// and ends with a "show all <total>" button; expanded Done, and every other column, renders all of them.
|
|
166
|
+
function columnCardsHtml(col, cards, columns, labelGroups, expanded) {
|
|
167
|
+
const ordered = orderedCards(col, cards, labelGroups);
|
|
168
|
+
const colors = labelColors(labelGroups);
|
|
169
|
+
const capped = col.id === "done" && !expanded && ordered.length > DONE_SHOWN;
|
|
170
|
+
const shown = capped ? ordered.slice(0, DONE_SHOWN) : ordered;
|
|
171
|
+
const html = shown.map((c) => cardHtml(c, columns, colors)).join("\n");
|
|
172
|
+
return capped ? `${html}\n<button type="button" class="show-all" data-show-all="done">show all ${ordered.length}</button>` : html;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// How many Done cards beyond the newest DONE_SHOWN pass the filters: the ones a collapsed column does not render.
|
|
176
|
+
function doneHiddenCount(cards, filters) {
|
|
177
|
+
const older = orderedCards({ id: "done" }, cards, []).slice(DONE_SHOWN);
|
|
178
|
+
return older.filter((c) => cardMatches(c, filters)).length;
|
|
179
|
+
}
|
|
180
|
+
|
|
84
181
|
// Done reads newest first: a card moves to Done long after it was added, so record order would bury the latest
|
|
85
|
-
// landing at the bottom of the longest column.
|
|
86
|
-
|
|
182
|
+
// landing at the bottom of the longest column. Ready is sorted by priority (readyOrder) on a board with a priority
|
|
183
|
+
// group; every other column, and Ready without one, keeps record order.
|
|
184
|
+
function columnHtml(col, cards, columns, labelGroups) {
|
|
87
185
|
const mine = cards.filter((c) => c.column === col.id);
|
|
88
|
-
const
|
|
89
|
-
const hint =
|
|
186
|
+
const suffix = col.id === "done" ? "newest first" : col.id === "ready" && hasPriorityGroup(labelGroups) ? READY_COL_HINT : "";
|
|
187
|
+
const hint = suffix ? `${col.hint || ""}${col.hint ? " · " : ""}${suffix}` : col.hint || "";
|
|
90
188
|
return `<section class="col" data-column="${esc(col.id)}">
|
|
91
189
|
<div class="col-head"><h2>${esc(col.title)}</h2><span class="n">${mine.length}</span></div>
|
|
92
190
|
<div class="col-hint">${esc(hint)}</div>
|
|
93
|
-
<div class="cards">${
|
|
191
|
+
<div class="cards">${columnCardsHtml(col, cards, columns, labelGroups, false)}</div>
|
|
94
192
|
</section>`;
|
|
95
193
|
}
|
|
96
194
|
|
|
@@ -178,6 +276,12 @@ function connectLive(opts) {
|
|
|
178
276
|
try { view = JSON.parse(e.data); } catch (_) {}
|
|
179
277
|
opts.onAutopilot(view);
|
|
180
278
|
});
|
|
279
|
+
es.addEventListener("rollup", (e) => {
|
|
280
|
+
if (source !== es || !opts.onRollup) return;
|
|
281
|
+
let roll = null;
|
|
282
|
+
try { roll = JSON.parse(e.data); } catch (_) {}
|
|
283
|
+
opts.onRollup(roll);
|
|
284
|
+
});
|
|
181
285
|
}
|
|
182
286
|
open();
|
|
183
287
|
|
|
@@ -201,7 +305,12 @@ function columnShape(board) {
|
|
|
201
305
|
|
|
202
306
|
// Sorted component list and sorted tag list, compared to decide whether the filters need a reload.
|
|
203
307
|
function componentAndTagShape(board) {
|
|
204
|
-
return JSON.stringify({ components: [...board.components].sort(), tags: tagOptions(board.cards).sort() });
|
|
308
|
+
return JSON.stringify({ components: [...board.components].sort(), tags: tagOptions(board.cards).sort(), labelGroups: board.labelGroups, epics: planIds(board.epics), milestones: planIds(board.milestones) });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// [id, title] of each epic or milestone, in record order; a change reloads the page (filter options, strip).
|
|
312
|
+
function planIds(entries) {
|
|
313
|
+
return (Array.isArray(entries) ? entries : []).map((e) => [e.id, e.title]);
|
|
205
314
|
}
|
|
206
315
|
|
|
207
316
|
// The header's counts-and-record line: how many cards, how many done, when the record was last
|
|
@@ -231,9 +340,7 @@ function applyLiveBoard(board, next, config, opts) {
|
|
|
231
340
|
for (const col of next.columns) {
|
|
232
341
|
const container = doc.querySelector('.col[data-column="' + CSS_escape(col.id) + '"] .cards');
|
|
233
342
|
if (!container) continue;
|
|
234
|
-
|
|
235
|
-
const ordered = col.id === "done" ? [...mine].reverse() : mine;
|
|
236
|
-
container.innerHTML = ordered.map((c) => cardHtml(c, next.columns)).join("\n");
|
|
343
|
+
container.innerHTML = columnCardsHtml(col, next.cards, next.columns, next.labelGroups, !!opts.doneExpanded);
|
|
237
344
|
}
|
|
238
345
|
const sub = doc.querySelector("header .sub");
|
|
239
346
|
if (sub) sub.innerHTML = headerSubHtml(next, config);
|
|
@@ -258,6 +365,14 @@ function cardMatches(card, filters) {
|
|
|
258
365
|
const tags = card.tags || [];
|
|
259
366
|
if (!tags.includes(tag)) return false;
|
|
260
367
|
}
|
|
368
|
+
const epic = (filters && filters.epic) || "";
|
|
369
|
+
const milestone = (filters && filters.milestone) || "";
|
|
370
|
+
if (epic && card.epic !== epic) return false;
|
|
371
|
+
if (milestone && card.milestone !== milestone) return false;
|
|
372
|
+
const groups = (filters && filters.groups) || {};
|
|
373
|
+
for (const id of Object.keys(groups)) {
|
|
374
|
+
if (groups[id] && !(card.tags || []).includes(groups[id])) return false;
|
|
375
|
+
}
|
|
261
376
|
return true;
|
|
262
377
|
}
|
|
263
378
|
|
|
@@ -265,17 +380,16 @@ function cardMatches(card, filters) {
|
|
|
265
380
|
// "" for no view, which leaves the panel hidden.
|
|
266
381
|
function autopilotHtml(view) {
|
|
267
382
|
if (!view || typeof view !== "object") return "";
|
|
268
|
-
const
|
|
383
|
+
const groups = Array.isArray(view.groups) ? view.groups : [];
|
|
269
384
|
const filed = Array.isArray(view.filed) ? view.filed : [];
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return `<li><span class="st ${esc(c.state)}">${esc(c.state)}</span><code>${esc(c.id)}</code>${c.title ? `<span>${esc(c.title)}</span>` : ""}${shape ? `<span class="meta">${esc(shape)}</span>` : ""}${c.detail ? `<span class="meta">${esc(c.detail)}</span>` : ""}</li>`;
|
|
385
|
+
const sections = groups.map((g) => {
|
|
386
|
+
const items = (Array.isArray(g.cards) ? g.cards : []).map((c) => `<li><span class="st ${esc(c.state)}">${esc(c.mark)}</span><code>${esc(c.id)}</code>${c.status ? `<span class="meta">${esc(c.status)}</span>` : ""}</li>`).join("");
|
|
387
|
+
return `<h3>${esc(g.title)}</h3><ul>${items}</ul>`;
|
|
274
388
|
}).join("");
|
|
275
389
|
const filedHtml = filed.length
|
|
276
390
|
? `<div class="filed">filed: ${filed.map((f) => `<code>${esc(f.id)}</code> (${esc(f.kind)}${f.from ? " from " + esc(f.from) : ""}) ${esc(f.position || "")}`).join("; ")}</div>`
|
|
277
391
|
: "";
|
|
278
|
-
return `<h2>${esc(view.header || "Autopilot")}</h2
|
|
392
|
+
return `<h2>${esc(view.header || "Autopilot")}</h2>${sections}<div class="caps">${esc(view.spend || "")}</div>${filedHtml}`;
|
|
279
393
|
}
|
|
280
394
|
|
|
281
395
|
// Shows a view in the #autopilot container, or hides the container for none.
|
|
@@ -288,12 +402,57 @@ function applyAutopilot(view, doc) {
|
|
|
288
402
|
el.hidden = html === "";
|
|
289
403
|
}
|
|
290
404
|
|
|
405
|
+
// One milestone's strip line: title, target, status, done out of total, weighted progress, and, when the roll-up
|
|
406
|
+
// measured it (docs/live-runs.md present), cost and wall with the Done-card coverage.
|
|
407
|
+
function milestoneText(m) {
|
|
408
|
+
const t = m.target;
|
|
409
|
+
const target = t ? [t.version, t.date].filter(Boolean).join(", ") : "";
|
|
410
|
+
const parts = [m.title];
|
|
411
|
+
if (target) parts.push("target " + target);
|
|
412
|
+
parts.push(m.status, `${m.done}/${m.total} done`, `${Math.round(m.progress * 100)}% weighted`);
|
|
413
|
+
const k = m.measured;
|
|
414
|
+
if (k && k.usd !== null && k.usd !== undefined) {
|
|
415
|
+
parts.push(`$${k.usd.toFixed(2)} over ${k.usdCards} of ${k.doneCards} Done cards`);
|
|
416
|
+
parts.push(`${k.wallMinutes} min wall over ${k.wallCards} of ${k.doneCards} Done cards`);
|
|
417
|
+
}
|
|
418
|
+
return parts.join(" · ");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function milestoneStripInner(milestones) {
|
|
422
|
+
return `<h2>Milestones</h2><ol>${milestones.map((m) => `<li class="milestone" data-milestone="${esc(m.id)}">${esc(milestoneText(m))}</li>`).join("")}</ol>`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The milestone strip; "" for a board with no milestones (a version 1 board).
|
|
426
|
+
function milestoneStripHtml(rollup) {
|
|
427
|
+
if (!rollup || !Array.isArray(rollup.milestones) || rollup.milestones.length === 0) return "";
|
|
428
|
+
return `<section id="milestones" aria-label="Milestones">${milestoneStripInner(rollup.milestones)}</section>\n`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Rewrites the strip from a pushed roll-up; does nothing without the element or a milestones array.
|
|
432
|
+
function applyRollup(rollup, doc) {
|
|
433
|
+
try {
|
|
434
|
+
doc = doc !== undefined ? doc : (typeof document !== "undefined" ? document : null);
|
|
435
|
+
const el = doc && doc.getElementById("milestones");
|
|
436
|
+
if (!el || !rollup || !Array.isArray(rollup.milestones)) return;
|
|
437
|
+
el.innerHTML = milestoneStripInner(rollup.milestones);
|
|
438
|
+
} catch (_) {}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function planFilterHtml(id, label, entries) {
|
|
442
|
+
if (!Array.isArray(entries) || entries.length === 0) return "";
|
|
443
|
+
return `<label for="${id}">${label}</label><select id="${id}"><option value="">all</option>${entries.map((e) => `<option value="${esc(e.id)}">${esc(e.title)}</option>`).join("")}</select>`;
|
|
444
|
+
}
|
|
445
|
+
|
|
291
446
|
// The complete document for a given board state; `doug board serve` renders it from the record on
|
|
292
447
|
// every request.
|
|
293
|
-
function renderDoc(board, appSource, config) {
|
|
448
|
+
function renderDoc(board, appSource, config, rollup) {
|
|
294
449
|
const comps = board.components.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
|
|
295
450
|
const tags = tagOptions(board.cards);
|
|
296
|
-
const
|
|
451
|
+
const hasGroups = Array.isArray(board.labelGroups);
|
|
452
|
+
const groupFilters = hasGroups
|
|
453
|
+
? board.labelGroups.map((g) => `<label for="group-filter-${esc(g.id)}">${esc(g.title)}</label><select id="group-filter-${esc(g.id)}" data-group="${esc(g.id)}"><option value="">all</option>${g.labels.map((l) => `<option value="${esc(l.name)}">${esc(l.name)}</option>`).join("")}</select>`).join("\n")
|
|
454
|
+
: "";
|
|
455
|
+
const tagFilter = hasGroups ? groupFilters : tags.length
|
|
297
456
|
? `<label for="tag-filter">tag</label><select id="tag-filter"><option value="">all</option>${tags.map((t) => `<option value="${esc(t)}">${esc(t)}</option>`).join("")}</select>`
|
|
298
457
|
: "";
|
|
299
458
|
const head = `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>Doug Board</title>\n${FONTS}\n<style>${CSS}</style>\n</head>\n<body>\n`;
|
|
@@ -302,14 +461,14 @@ function renderDoc(board, appSource, config) {
|
|
|
302
461
|
<div><h1>Doug Board</h1><div class="sub">${headerSubHtml(board, config)}</div></div>
|
|
303
462
|
<div class="tools">
|
|
304
463
|
<label for="filter">component</label><select id="filter"><option value="">all</option>${comps}</select>
|
|
305
|
-
${tagFilter ? tagFilter + "\n" : ""}<span class="status" id="filter-note"></span>
|
|
464
|
+
${tagFilter ? tagFilter + "\n" : ""}${planFilterHtml("epic-filter", "epic", board.epics)}${planFilterHtml("milestone-filter", "milestone", board.milestones)}<span class="status" id="filter-note"></span>
|
|
306
465
|
<button type="button" id="copy">Copy board JSON</button>
|
|
307
466
|
<span class="status" id="status"></span>
|
|
308
467
|
</div>
|
|
309
468
|
</header>
|
|
310
469
|
<section id="autopilot" aria-label="Autopilot" hidden></section>
|
|
311
|
-
<main class="board" id="board">
|
|
312
|
-
${board.columns.map((col) => columnHtml(col, board.cards, board.columns)).join("\n")}
|
|
470
|
+
${milestoneStripHtml(rollup)}<main class="board" id="board">
|
|
471
|
+
${board.columns.map((col) => columnHtml(col, board.cards, board.columns, board.labelGroups)).join("\n")}
|
|
313
472
|
</main>
|
|
314
473
|
${footerHtml(config)}
|
|
315
474
|
<script type="application/json" id="board-config">${JSON.stringify(config).replace(/</g, "\\u003c")}<\/script>
|
|
@@ -325,6 +484,8 @@ function app() {
|
|
|
325
484
|
const status = document.getElementById("status");
|
|
326
485
|
// In local mode the page stays read-only until `doug board serve` answers for it.
|
|
327
486
|
let readOnly = local;
|
|
487
|
+
let doneExpanded = false;
|
|
488
|
+
let currentFilters = {};
|
|
328
489
|
|
|
329
490
|
const say = (m) => { status.textContent = m; };
|
|
330
491
|
const setReadOnly = (why) => { readOnly = true; document.body.classList.add("ro"); say(why); };
|
|
@@ -352,6 +513,11 @@ function app() {
|
|
|
352
513
|
const card = board.cards.find((c) => c.id === id);
|
|
353
514
|
if (!card) return;
|
|
354
515
|
if (readOnly) { say("This view is read-only; the move was not saved."); return; }
|
|
516
|
+
if (column === "ready") {
|
|
517
|
+
const rule = priorityBandMove(board.cards, board.labelGroups, id, column, targetId);
|
|
518
|
+
if (rule === "refuse") { say(READY_BAND_HINT); return; }
|
|
519
|
+
if (rule === "column") { moveCard(id, column); return; }
|
|
520
|
+
}
|
|
355
521
|
if (card.column !== column) card.column = column;
|
|
356
522
|
const el = document.querySelector(`.card[data-id="${CSS_escape(id)}"]`);
|
|
357
523
|
const targetEl = document.querySelector(`.card[data-id="${CSS_escape(targetId)}"]`);
|
|
@@ -363,7 +529,9 @@ function app() {
|
|
|
363
529
|
|
|
364
530
|
function refreshCounts() {
|
|
365
531
|
for (const col of document.querySelectorAll(".col")) {
|
|
366
|
-
|
|
532
|
+
// A collapsed Done column does not render its older cards; add the ones the filters let through.
|
|
533
|
+
const extra = col.dataset.column === "done" && !doneExpanded ? doneHiddenCount(board.cards, currentFilters) : 0;
|
|
534
|
+
col.querySelector(".n").textContent = col.querySelectorAll(".card:not([hidden])").length + extra;
|
|
367
535
|
}
|
|
368
536
|
}
|
|
369
537
|
|
|
@@ -499,7 +667,27 @@ function app() {
|
|
|
499
667
|
// A tag itself may contain spaces (e.g. "needs review"), and data-tags joins every tag on the card
|
|
500
668
|
// with a single space, so a naive split cannot tell a two-word tag from two one-word tags. Reconstruct
|
|
501
669
|
// against the known tag options instead, matching the longest known tag first.
|
|
502
|
-
const
|
|
670
|
+
const epicFilter = document.getElementById("epic-filter");
|
|
671
|
+
const milestoneFilter = document.getElementById("milestone-filter");
|
|
672
|
+
for (const [sel, key] of [[epicFilter, "doug-board-epic-filter"], [milestoneFilter, "doug-board-milestone-filter"]]) {
|
|
673
|
+
if (!sel) continue;
|
|
674
|
+
try {
|
|
675
|
+
const saved = localStorage.getItem(key);
|
|
676
|
+
if (saved && Array.from(sel.options).some((o) => o.value === saved)) sel.value = saved;
|
|
677
|
+
} catch (_) {}
|
|
678
|
+
}
|
|
679
|
+
const groupSelects = [];
|
|
680
|
+
for (const g of board.labelGroups || []) {
|
|
681
|
+
const sel = document.getElementById("group-filter-" + g.id);
|
|
682
|
+
if (!sel) continue;
|
|
683
|
+
groupSelects.push({ id: g.id, sel: sel });
|
|
684
|
+
try {
|
|
685
|
+
const saved = localStorage.getItem("doug-board-group-filter-" + g.id);
|
|
686
|
+
if (saved && g.labels.some((l) => l.name === saved)) sel.value = saved;
|
|
687
|
+
} catch (_) {}
|
|
688
|
+
}
|
|
689
|
+
const knownTags = tagFilter ? Array.from(tagFilter.options).map((o) => o.value).filter((v) => v)
|
|
690
|
+
: (board.labelGroups || []).flatMap((g) => g.labels.map((l) => l.name));
|
|
503
691
|
const sortedKnownTags = [...knownTags].sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
504
692
|
const parseCardTags = (attr) => {
|
|
505
693
|
if (!attr) return [];
|
|
@@ -519,20 +707,41 @@ function app() {
|
|
|
519
707
|
const applyFilter = () => {
|
|
520
708
|
const component = filter.value;
|
|
521
709
|
const tag = tagFilter ? tagFilter.value : "";
|
|
710
|
+
const epic = epicFilter ? epicFilter.value : "";
|
|
711
|
+
const milestone = milestoneFilter ? milestoneFilter.value : "";
|
|
712
|
+
const groups = {};
|
|
713
|
+
for (const gs of groupSelects) groups[gs.id] = gs.sel.value;
|
|
714
|
+
const anyGroup = Object.keys(groups).some((k) => groups[k]);
|
|
715
|
+
currentFilters = { component: component, tag: tag, groups: groups, epic: epic, milestone: milestone };
|
|
522
716
|
for (const card of document.querySelectorAll(".card")) {
|
|
523
|
-
const shape = { component: card.dataset.component || "", tags: parseCardTags(card.dataset.tags) };
|
|
524
|
-
card.hidden = !cardMatches(shape,
|
|
717
|
+
const shape = { component: card.dataset.component || "", tags: parseCardTags(card.dataset.tags), epic: card.dataset.epic || "", milestone: card.dataset.milestone || "" };
|
|
718
|
+
card.hidden = !cardMatches(shape, currentFilters);
|
|
525
719
|
}
|
|
526
720
|
refreshCounts();
|
|
527
721
|
// A saved filter is easy to forget; say what it hides so the column counts are not mistaken for the board.
|
|
528
722
|
const all = document.querySelectorAll(".card").length;
|
|
529
723
|
const shown = document.querySelectorAll(".card:not([hidden])").length;
|
|
530
|
-
document.getElementById("filter-note").textContent = component || tag ? `showing ${shown} of ${all} cards` : "";
|
|
724
|
+
document.getElementById("filter-note").textContent = component || tag || anyGroup || epic || milestone ? `showing ${shown} of ${all} cards` : "";
|
|
531
725
|
try { localStorage.setItem("doug-board-filter", component); } catch (_) {}
|
|
532
726
|
if (tagFilter) { try { localStorage.setItem("doug-board-tag-filter", tag); } catch (_) {} }
|
|
727
|
+
if (epicFilter) { try { localStorage.setItem("doug-board-epic-filter", epic); } catch (_) {} }
|
|
728
|
+
if (milestoneFilter) { try { localStorage.setItem("doug-board-milestone-filter", milestone); } catch (_) {} }
|
|
729
|
+
for (const gs of groupSelects) { try { localStorage.setItem("doug-board-group-filter-" + gs.id, gs.sel.value); } catch (_) {} }
|
|
533
730
|
};
|
|
534
731
|
filter.addEventListener("change", applyFilter);
|
|
732
|
+
document.addEventListener("click", (e) => {
|
|
733
|
+
const btn = e.target && e.target.closest ? e.target.closest("[data-show-all]") : null;
|
|
734
|
+
if (!btn) return;
|
|
735
|
+
doneExpanded = true;
|
|
736
|
+
const container = document.querySelector('.col[data-column="done"] .cards');
|
|
737
|
+
const doneCol = board.columns.find((c) => c.id === "done");
|
|
738
|
+
if (container && doneCol) container.innerHTML = columnCardsHtml(doneCol, board.cards, board.columns, board.labelGroups, true);
|
|
739
|
+
applyFilter();
|
|
740
|
+
});
|
|
535
741
|
if (tagFilter) tagFilter.addEventListener("change", applyFilter);
|
|
742
|
+
if (epicFilter) epicFilter.addEventListener("change", applyFilter);
|
|
743
|
+
if (milestoneFilter) milestoneFilter.addEventListener("change", applyFilter);
|
|
744
|
+
for (const gs of groupSelects) gs.sel.addEventListener("change", applyFilter);
|
|
536
745
|
applyFilter();
|
|
537
746
|
|
|
538
747
|
document.getElementById("copy").addEventListener("click", async () => {
|
|
@@ -562,8 +771,9 @@ function app() {
|
|
|
562
771
|
setTimeout: window.setTimeout.bind(window),
|
|
563
772
|
clearTimeout: window.clearTimeout.bind(window),
|
|
564
773
|
onAutopilot: (view) => applyAutopilot(view),
|
|
774
|
+
onRollup: (r) => applyRollup(r),
|
|
565
775
|
onBoard: (next) => {
|
|
566
|
-
const updated = applyLiveBoard(board, next, config);
|
|
776
|
+
const updated = applyLiveBoard(board, next, config, { doneExpanded: doneExpanded });
|
|
567
777
|
if (updated) {
|
|
568
778
|
board = updated;
|
|
569
779
|
refreshCounts();
|