@hjr15/blaze-board 0.2.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/AGENTS.md +141 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +38 -0
- package/scripts/cli.mjs +31 -0
- package/scripts/commit-or-queue.mjs +22 -0
- package/scripts/commit-runner.mjs +40 -0
- package/scripts/config.mjs +135 -0
- package/scripts/edit-runner.mjs +18 -0
- package/scripts/edit.mjs +85 -0
- package/scripts/event-bus.mjs +16 -0
- package/scripts/log-runner.mjs +38 -0
- package/scripts/log.mjs +44 -0
- package/scripts/loops/groomer.mjs +215 -0
- package/scripts/migrate/audit.mjs +92 -0
- package/scripts/migrate/jira-client.mjs +26 -0
- package/scripts/migrate/jira-import.mjs +96 -0
- package/scripts/migrate/map.mjs +110 -0
- package/scripts/migrate/merge.mjs +103 -0
- package/scripts/migrate/normalize.mjs +60 -0
- package/scripts/migrate/report.mjs +67 -0
- package/scripts/migrate/restructure.mjs +49 -0
- package/scripts/migrate-runner.mjs +51 -0
- package/scripts/model/.gitkeep +0 -0
- package/scripts/model/ids.mjs +32 -0
- package/scripts/model/index.mjs +63 -0
- package/scripts/model/move-plan.mjs +25 -0
- package/scripts/model/rollup.mjs +55 -0
- package/scripts/model/rules.mjs +64 -0
- package/scripts/model/schema.mjs +30 -0
- package/scripts/model/ticket.mjs +136 -0
- package/scripts/model/time.mjs +38 -0
- package/scripts/model/workflows.mjs +54 -0
- package/scripts/move-runner.mjs +18 -0
- package/scripts/move.mjs +56 -0
- package/scripts/new-runner.mjs +43 -0
- package/scripts/new.mjs +54 -0
- package/scripts/pending-ledger.mjs +36 -0
- package/scripts/reconcile.mjs +181 -0
- package/scripts/reindex.mjs +22 -0
- package/scripts/resolve-runner.mjs +17 -0
- package/scripts/resolve.mjs +21 -0
- package/scripts/rollup-runner.mjs +53 -0
- package/scripts/serve-commit.mjs +18 -0
- package/scripts/serve.mjs +658 -0
- package/scripts/supervisor.mjs +192 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// serve.mjs — a tiny, zero-dependency dashboard for the file-based tracker.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/serve.mjs # serves http://localhost:<cfg.port>
|
|
5
|
+
// PORT=8080 node scripts/serve.mjs # custom port
|
|
6
|
+
//
|
|
7
|
+
// Reads the markdown tickets fresh on every request, so editing a file in your
|
|
8
|
+
// IDE and refreshing shows the change. The page also auto-reloads within a few
|
|
9
|
+
// seconds when any ticket file changes (it polls a cheap content hash), but
|
|
10
|
+
// never reloads while the files are untouched — so it won't fight you mid-read.
|
|
11
|
+
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { join, dirname, basename } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { loadConfig, listProjects, resolveRoots } from "./config.mjs";
|
|
19
|
+
import { walkTickets, buildIndex } from "./model/index.mjs";
|
|
20
|
+
import { rollUp } from "./model/rollup.mjs";
|
|
21
|
+
import { formatMinutes } from "./model/time.mjs";
|
|
22
|
+
import { WORKFLOWS } from "./model/workflows.mjs";
|
|
23
|
+
import { PRIORITIES } from "./model/schema.mjs";
|
|
24
|
+
import { applyMove } from "./move.mjs";
|
|
25
|
+
import { applyResolve } from "./resolve.mjs";
|
|
26
|
+
import { applyLog } from "./log.mjs";
|
|
27
|
+
import { applyEdit, applyToggleAc } from "./edit.mjs";
|
|
28
|
+
import { commitFile } from "./serve-commit.mjs";
|
|
29
|
+
|
|
30
|
+
const cfg = loadConfig({ root: resolveRoots().dataRoot });
|
|
31
|
+
|
|
32
|
+
const PORT = Number(process.env.PORT) || cfg.port;
|
|
33
|
+
|
|
34
|
+
const PRIORITY_ORDER = { highest: 0, high: 1, medium: 2, low: 3, lowest: 4, none: 5, urgent: 0 };
|
|
35
|
+
|
|
36
|
+
export const CSRF = randomUUID();
|
|
37
|
+
|
|
38
|
+
function readJson(req) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let data = "", size = 0, settled = false;
|
|
41
|
+
req.on("data", (c) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
size += c.length;
|
|
44
|
+
if (size > 256 * 1024) {
|
|
45
|
+
settled = true;
|
|
46
|
+
req.destroy();
|
|
47
|
+
reject(new Error("too large"));
|
|
48
|
+
} else {
|
|
49
|
+
data += c;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
|
|
53
|
+
req.on("error", (e) => { if (!settled) reject(e); });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function aheadCount(root) {
|
|
58
|
+
const r = spawnSync("git", ["-C", root, "rev-list", "--count", "@{u}..HEAD"], { encoding: "utf8" });
|
|
59
|
+
return r.status === 0 ? Number(r.stdout.trim()) || 0 : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The canonical column order = the union of every workflow's statuses, in
|
|
63
|
+
// declaration order, deduped. (delivery, then goal-only, then risk-only.)
|
|
64
|
+
const STATUS_ORDER = [...new Set(Object.values(WORKFLOWS).flatMap((w) => w.statuses))];
|
|
65
|
+
|
|
66
|
+
const title = (s) => s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
67
|
+
|
|
68
|
+
// Pure board model: read every ticket under projectsDir, optionally filter to one
|
|
69
|
+
// project, and group into status columns. Read-only (the editable board is Phase 6).
|
|
70
|
+
export function boardModel(projectsDir, { project = "all" } = {}) {
|
|
71
|
+
const all = [...walkTickets(projectsDir)].map((t) => ({
|
|
72
|
+
file: basename(t.file), meta: t.frontmatter, body: t.body,
|
|
73
|
+
status: t.status, project: t.frontmatter.project,
|
|
74
|
+
}));
|
|
75
|
+
const projectsCount = all.reduce((acc, t) => {
|
|
76
|
+
acc[t.project] = (acc[t.project] || 0) + 1; return acc;
|
|
77
|
+
}, {});
|
|
78
|
+
const rows = project === "all" ? all : all.filter((t) => t.project === project);
|
|
79
|
+
|
|
80
|
+
const byStatus = new Map();
|
|
81
|
+
for (const t of rows) {
|
|
82
|
+
if (!byStatus.has(t.status)) byStatus.set(t.status, []);
|
|
83
|
+
byStatus.get(t.status).push(t);
|
|
84
|
+
}
|
|
85
|
+
const statuses = [
|
|
86
|
+
...STATUS_ORDER.filter((s) => byStatus.has(s)),
|
|
87
|
+
...[...byStatus.keys()].filter((s) => !STATUS_ORDER.includes(s)),
|
|
88
|
+
];
|
|
89
|
+
const columns = statuses.map((dir) => ({
|
|
90
|
+
dir, label: title(dir),
|
|
91
|
+
tickets: byStatus.get(dir).sort((a, b) => {
|
|
92
|
+
const pa = PRIORITY_ORDER[a.meta.priority] ?? 6, pb = PRIORITY_ORDER[b.meta.priority] ?? 6;
|
|
93
|
+
return pa - pb || String(a.meta.id || "").localeCompare(String(b.meta.id || ""));
|
|
94
|
+
}),
|
|
95
|
+
}));
|
|
96
|
+
const rollup = rollUp(buildIndex(projectsDir));
|
|
97
|
+
return { selected: project, projects: projectsCount, columns, total: rows.length, rollup };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A cheap hash of all ticket files' size+mtime, for the auto-reload poll.
|
|
101
|
+
export function contentHash() {
|
|
102
|
+
let h = 0;
|
|
103
|
+
const projectsDir = resolveRoots().projectsDir;
|
|
104
|
+
const stack = [projectsDir];
|
|
105
|
+
while (stack.length) {
|
|
106
|
+
const dir = stack.pop();
|
|
107
|
+
let entries = [];
|
|
108
|
+
try { entries = readdirSync(dir); } catch { continue; }
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
const p = join(dir, e);
|
|
111
|
+
let s; try { s = statSync(p); } catch { continue; }
|
|
112
|
+
if (s.isDirectory()) { stack.push(p); continue; }
|
|
113
|
+
const sig = `${p}:${s.size}:${s.mtimeMs}`;
|
|
114
|
+
for (let i = 0; i < sig.length; i++) h = (h * 31 + sig.charCodeAt(i)) | 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return String(h);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- render -------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
const esc = (s) =>
|
|
123
|
+
String(s ?? "").replace(
|
|
124
|
+
/[&<>"]/g,
|
|
125
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Minimal markdown for the ticket body: headings, lists, checkboxes, bold, code.
|
|
129
|
+
// AC checkboxes (under `## Acceptance Criteria`) are live: they carry data-ac-index
|
|
130
|
+
// matching the ordinal used by applyToggleAc (0-based, AC section only).
|
|
131
|
+
// Checkboxes outside the AC section remain disabled.
|
|
132
|
+
function mdLite(src) {
|
|
133
|
+
const lines = esc(src).split("\n");
|
|
134
|
+
const out = [];
|
|
135
|
+
let inList = false;
|
|
136
|
+
let inAc = false; // true while inside the ## Acceptance Criteria section
|
|
137
|
+
let acIndex = 0; // ordinal counter — AC checkboxes only, mirrors applyToggleAc
|
|
138
|
+
const closeList = () => {
|
|
139
|
+
if (inList) {
|
|
140
|
+
out.push("</ul>");
|
|
141
|
+
inList = false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
const t = line.trim();
|
|
146
|
+
if (/^#{1,6}\s/.test(t)) {
|
|
147
|
+
closeList();
|
|
148
|
+
// Mirror applyToggleAc: inAc = true on "## Acceptance Criteria", false on any other heading
|
|
149
|
+
inAc = /^#{1,6}\s+acceptance criteria\s*$/i.test(t);
|
|
150
|
+
out.push(`<h4>${inline(t.replace(/^#{1,6}\s/, ""))}</h4>`);
|
|
151
|
+
} else if (/^- \[[ xX]\]\s/.test(t)) {
|
|
152
|
+
if (!inList) {
|
|
153
|
+
out.push('<ul class="md">');
|
|
154
|
+
inList = true;
|
|
155
|
+
}
|
|
156
|
+
const checked = /^- \[[xX]\]/.test(t);
|
|
157
|
+
const text = t.replace(/^- \[[ xX]\]\s/, "");
|
|
158
|
+
if (inAc) {
|
|
159
|
+
out.push(
|
|
160
|
+
`<li class="task"><input type="checkbox" data-ac-index="${acIndex++}" ${checked ? "checked" : ""}> ${inline(text)}</li>`,
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
out.push(
|
|
164
|
+
`<li class="task"><input type="checkbox" disabled ${checked ? "checked" : ""}> ${inline(text)}</li>`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
} else if (/^- \s*/.test(t)) {
|
|
168
|
+
if (!inList) {
|
|
169
|
+
out.push('<ul class="md">');
|
|
170
|
+
inList = true;
|
|
171
|
+
}
|
|
172
|
+
out.push(`<li>${inline(t.replace(/^- \s*/, ""))}</li>`);
|
|
173
|
+
} else if (t === "") {
|
|
174
|
+
closeList();
|
|
175
|
+
} else {
|
|
176
|
+
closeList();
|
|
177
|
+
out.push(`<p>${inline(t)}</p>`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
closeList();
|
|
181
|
+
return out.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const inline = (s) =>
|
|
185
|
+
s
|
|
186
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
187
|
+
.replace(/`(.+?)`/g, "<code>$1</code>");
|
|
188
|
+
|
|
189
|
+
// Render the `pr:` frontmatter field ("#843 — https://…/pull/843") as a link.
|
|
190
|
+
function prLink(pr) {
|
|
191
|
+
if (!pr) return "";
|
|
192
|
+
const url = (pr.match(/https?:\/\/\S+/) || [])[0];
|
|
193
|
+
const num = (pr.match(/#(\d+)/) || [])[1];
|
|
194
|
+
if (!url) return "";
|
|
195
|
+
return `<a class="prlink" href="${esc(url)}" target="_blank" rel="noopener">🔗 PR${num ? ` #${esc(num)}` : ""}</a>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build the dot-separated meta line as HTML pieces (text escaped, links raw).
|
|
199
|
+
function metaPieces(m) {
|
|
200
|
+
return [
|
|
201
|
+
m.assignee && m.assignee !== "unassigned" ? `@${esc(m.assignee)}` : "",
|
|
202
|
+
m.estimate ? esc(formatMinutes(m.estimate)) : "",
|
|
203
|
+
m.parent ? `↳ ${esc(m.parent)}` : "",
|
|
204
|
+
m.project ? esc(m.project) : "",
|
|
205
|
+
prLink(m.pr),
|
|
206
|
+
].filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function card(t, rollup) {
|
|
210
|
+
const m = t.meta;
|
|
211
|
+
const prio = m.priority || "none";
|
|
212
|
+
const labels = (m.labels || [])
|
|
213
|
+
.map((l) => `<span class="label">${esc(l)}</span>`)
|
|
214
|
+
.join("");
|
|
215
|
+
const meta = metaPieces(m).join(" · ");
|
|
216
|
+
const ru = rollup && rollup.get(m.id);
|
|
217
|
+
const isParent = m.type === "goal" || m.type === "epic";
|
|
218
|
+
const rolled = (isParent && ru && (ru.rolled_estimate || ru.rolled_worklog))
|
|
219
|
+
? `<div class="rollup">Σ ${esc(formatMinutes(ru.rolled_estimate) || "0m")} est · ${esc(formatMinutes(ru.rolled_worklog) || "0m")} logged</div>`
|
|
220
|
+
: "";
|
|
221
|
+
return `
|
|
222
|
+
<details class="card prio-${esc(prio)}" draggable="true" data-id="${esc(m.id || t.file)}">
|
|
223
|
+
<summary>
|
|
224
|
+
<div class="card-top">
|
|
225
|
+
<span class="id">${esc(m.id || t.file)}</span>
|
|
226
|
+
<span class="badges">
|
|
227
|
+
<span class="prio prio-${esc(prio)}">${esc(prio)}</span>
|
|
228
|
+
${m.type ? `<span class="type">${esc(m.type)}</span>` : ""}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="title">${esc(m.title || t.file)}</div>
|
|
232
|
+
${labels ? `<div class="labels">${labels}</div>` : ""}
|
|
233
|
+
${meta ? `<div class="cardmeta">${meta}</div>` : ""}
|
|
234
|
+
${rolled}
|
|
235
|
+
<div class="editmeta" data-ticket="${esc(m.id)}">
|
|
236
|
+
<span class="editable" data-edit="priority" data-value="${esc(prio)}">${esc(prio)}</span>
|
|
237
|
+
<span class="editable" data-edit="assignee" data-value="${esc(m.assignee || "")}">@${esc(m.assignee || "unassigned")}</span>
|
|
238
|
+
<span class="editable" data-edit="estimate" data-value="${esc(m.estimate || "")}">${esc(formatMinutes(m.estimate) || "—")}</span>
|
|
239
|
+
</div>
|
|
240
|
+
</summary>
|
|
241
|
+
<div class="body" data-ticket="${esc(m.id)}">${mdLite(t.body)}</div>
|
|
242
|
+
</details>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A compact one-line row for the List view (Linear-style). Same expandable body.
|
|
246
|
+
function row(t) {
|
|
247
|
+
const m = t.meta;
|
|
248
|
+
const prio = m.priority || "none";
|
|
249
|
+
const labels = (m.labels || [])
|
|
250
|
+
.map((l) => `<span class="label">${esc(l)}</span>`)
|
|
251
|
+
.join("");
|
|
252
|
+
const meta = metaPieces(m).join(" · ");
|
|
253
|
+
return `
|
|
254
|
+
<details class="row prio-${esc(prio)}" draggable="true" data-id="${esc(m.id || t.file)}">
|
|
255
|
+
<summary>
|
|
256
|
+
<span class="rcaret">▸</span>
|
|
257
|
+
<span class="id">${esc(m.id || t.file)}</span>
|
|
258
|
+
<span class="rtitle">${esc(m.title || t.file)}</span>
|
|
259
|
+
<span class="rbadges">
|
|
260
|
+
${labels}
|
|
261
|
+
<span class="prio prio-${esc(prio)}">${esc(prio)}</span>
|
|
262
|
+
${m.type ? `<span class="type">${esc(m.type)}</span>` : ""}
|
|
263
|
+
</span>
|
|
264
|
+
${meta ? `<span class="rmeta">${meta}</span>` : ""}
|
|
265
|
+
</summary>
|
|
266
|
+
<div class="body" data-ticket="${esc(m.id)}">${mdLite(t.body)}</div>
|
|
267
|
+
</details>`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function pageHtml({ project = "all", afterHeader = "", beforeBodyEnd = "", projectsDir: _pDir } = {}) {
|
|
271
|
+
const m = boardModel(_pDir ?? resolveRoots().projectsDir, { project });
|
|
272
|
+
const { columns: cols, total, projects, selected, rollup } = m;
|
|
273
|
+
const columnsHtml = cols
|
|
274
|
+
.map(
|
|
275
|
+
(c) => `
|
|
276
|
+
<section class="col" data-status="${esc(c.dir)}">
|
|
277
|
+
<header class="colhead">
|
|
278
|
+
<span class="colname">${esc(c.label)}</span>
|
|
279
|
+
<span class="count">${c.tickets.length}</span>
|
|
280
|
+
</header>
|
|
281
|
+
<div class="cards">
|
|
282
|
+
${c.tickets.map((t) => card(t, rollup)).join("") || '<div class="empty">—</div>'}
|
|
283
|
+
</div>
|
|
284
|
+
</section>`,
|
|
285
|
+
)
|
|
286
|
+
.join("");
|
|
287
|
+
|
|
288
|
+
// List view ordering: derived from the rendered columns (already status-ordered).
|
|
289
|
+
const LIST_ORDER = cols.map((c) => c.dir);
|
|
290
|
+
const groupsHtml = LIST_ORDER
|
|
291
|
+
.map((dir) => cols.find((c) => c.dir === dir))
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.filter((c) => c.dir !== "in-review" || c.tickets.length > 0)
|
|
294
|
+
.map(
|
|
295
|
+
(c) => `
|
|
296
|
+
<details class="group" open data-group="${esc(c.dir)}" data-status="${esc(c.dir)}">
|
|
297
|
+
<summary class="grouphead">
|
|
298
|
+
<span class="gcaret">▸</span>
|
|
299
|
+
<span class="colname">${esc(c.label)}</span>
|
|
300
|
+
<span class="count">${c.tickets.length}</span>
|
|
301
|
+
</summary>
|
|
302
|
+
<div class="rows">
|
|
303
|
+
${c.tickets.map(row).join("") || '<div class="empty">No tickets</div>'}
|
|
304
|
+
</div>
|
|
305
|
+
</details>`,
|
|
306
|
+
)
|
|
307
|
+
.join("");
|
|
308
|
+
|
|
309
|
+
return `<!doctype html>
|
|
310
|
+
<html lang="en" data-view="board">
|
|
311
|
+
<head>
|
|
312
|
+
<meta charset="utf-8">
|
|
313
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
314
|
+
<title>${cfg.boardTitle}</title>
|
|
315
|
+
<script>
|
|
316
|
+
window.__csrf = "${CSRF}";
|
|
317
|
+
// Set the saved view before paint so there's no flash of the wrong layout.
|
|
318
|
+
try { document.documentElement.dataset.view = localStorage.getItem("tracker.view") || "board"; } catch {}
|
|
319
|
+
</script>
|
|
320
|
+
<style>
|
|
321
|
+
:root {
|
|
322
|
+
color-scheme: dark;
|
|
323
|
+
--blaze-red: #FF3B1F;
|
|
324
|
+
--blaze-orange: #FF7A00;
|
|
325
|
+
--blaze-amber: #FFC107;
|
|
326
|
+
--charcoal: #0F172A;
|
|
327
|
+
--neutral: #F6F7F9;
|
|
328
|
+
}
|
|
329
|
+
* { box-sizing: border-box; }
|
|
330
|
+
body {
|
|
331
|
+
margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
332
|
+
background: var(--charcoal); color: var(--neutral);
|
|
333
|
+
}
|
|
334
|
+
header.top {
|
|
335
|
+
position: sticky; top: 0; z-index: 5; display: flex; align-items: baseline;
|
|
336
|
+
gap: 12px; padding: 14px 20px; background: #0F172Aee;
|
|
337
|
+
border-bottom: 1px solid #21262d; backdrop-filter: blur(6px);
|
|
338
|
+
}
|
|
339
|
+
header.top h1 { font-size: 15px; margin: 0; letter-spacing: .3px; }
|
|
340
|
+
header.top .sub { color: #7d8590; font-size: 12px; }
|
|
341
|
+
.board {
|
|
342
|
+
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 1fr);
|
|
343
|
+
gap: 12px; padding: 16px 20px; overflow-x: auto; align-items: start;
|
|
344
|
+
}
|
|
345
|
+
.col { background: #161b22; border: 1px solid #21262d; border-radius: 10px; }
|
|
346
|
+
.colhead {
|
|
347
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
348
|
+
padding: 10px 12px; border-bottom: 1px solid #21262d;
|
|
349
|
+
font-weight: 600; font-size: 12px; text-transform: uppercase;
|
|
350
|
+
letter-spacing: .5px; color: #adbac7;
|
|
351
|
+
}
|
|
352
|
+
.count { color: #7d8590; font-weight: 600; }
|
|
353
|
+
.cards { display: flex; flex-direction: column; gap: 8px; padding: 10px; }
|
|
354
|
+
.empty { color: #444c56; text-align: center; padding: 14px 0; }
|
|
355
|
+
.card {
|
|
356
|
+
background: #1c2128; border: 1px solid #2d333b; border-left: 3px solid #444c56;
|
|
357
|
+
border-radius: 8px; padding: 9px 11px; cursor: pointer;
|
|
358
|
+
}
|
|
359
|
+
.card[open] { background: #20262e; }
|
|
360
|
+
.card summary { list-style: none; }
|
|
361
|
+
.card summary::-webkit-details-marker { display: none; }
|
|
362
|
+
.card-top { display: flex; justify-content: space-between; align-items: center; }
|
|
363
|
+
.id { color: #7d8590; font-size: 11px; font-weight: 600; font-family: ui-monospace, monospace; }
|
|
364
|
+
.title { margin-top: 3px; font-weight: 500; }
|
|
365
|
+
.badges { display: flex; gap: 5px; }
|
|
366
|
+
.prio, .type, .label {
|
|
367
|
+
font-size: 10px; padding: 1px 6px; border-radius: 999px; font-weight: 600;
|
|
368
|
+
text-transform: uppercase; letter-spacing: .3px;
|
|
369
|
+
}
|
|
370
|
+
.type { background: #30363d; color: #adbac7; }
|
|
371
|
+
.labels { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
372
|
+
.label { background: #21314a; color: #79c0ff; text-transform: none; letter-spacing: 0; }
|
|
373
|
+
.cardmeta { margin-top: 6px; color: #7d8590; font-size: 11px; }
|
|
374
|
+
.rollup { color: var(--blaze-amber); font-size: 11px; font-weight: 600; margin-top: 2px; }
|
|
375
|
+
.editmeta { margin-top: 6px; display: flex; gap: 8px; flex-wrap: wrap; font-size: 11px; }
|
|
376
|
+
.editable { color: #adbac7; border-bottom: 1px dotted #444c56; cursor: text; }
|
|
377
|
+
.editable:hover { color: var(--neutral); }
|
|
378
|
+
.prio.prio-urgent { background: #4b1113; color: var(--blaze-red); }
|
|
379
|
+
.prio.prio-high { background: #4a2410; color: var(--blaze-orange); }
|
|
380
|
+
.prio.prio-medium { background: #4a3a0c; color: var(--blaze-amber); }
|
|
381
|
+
.prio.prio-low { background: #30363d; color: #adbac7; }
|
|
382
|
+
.prio.prio-none { background: #30363d; color: #7d8590; }
|
|
383
|
+
.card.prio-urgent { border-left-color: var(--blaze-red); }
|
|
384
|
+
.card.prio-high { border-left-color: var(--blaze-orange); }
|
|
385
|
+
.card.prio-medium { border-left-color: var(--blaze-amber); }
|
|
386
|
+
.body {
|
|
387
|
+
margin-top: 10px; padding-top: 10px; border-top: 1px solid #2d333b;
|
|
388
|
+
color: #c9d1d9; font-size: 13px;
|
|
389
|
+
}
|
|
390
|
+
.body h4 { margin: 10px 0 4px; font-size: 12px; text-transform: uppercase; color: #adbac7; letter-spacing: .4px; }
|
|
391
|
+
.body p { margin: 4px 0; }
|
|
392
|
+
.body ul.md { margin: 4px 0; padding-left: 18px; }
|
|
393
|
+
.body li.task { list-style: none; margin-left: -18px; }
|
|
394
|
+
.body code { background: #2d333b; padding: 1px 4px; border-radius: 4px; font-size: 12px; }
|
|
395
|
+
|
|
396
|
+
/* ---- view toggle ---- */
|
|
397
|
+
.viewtoggle { display: flex; gap: 2px; padding: 2px; background: #161b22; border: 1px solid #21262d; border-radius: 8px; }
|
|
398
|
+
.viewtoggle .pill {
|
|
399
|
+
appearance: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; font-weight: 600;
|
|
400
|
+
padding: 4px 12px; border-radius: 6px; color: #7d8590; background: transparent; transition: color .12s, background .12s;
|
|
401
|
+
}
|
|
402
|
+
.viewtoggle .pill:hover { color: #adbac7; }
|
|
403
|
+
.viewtoggle .pill.on { color: var(--charcoal); background: var(--blaze-orange); }
|
|
404
|
+
|
|
405
|
+
/* ---- view switching ---- */
|
|
406
|
+
html[data-view="board"] .list { display: none; }
|
|
407
|
+
html[data-view="list"] .board { display: none; }
|
|
408
|
+
|
|
409
|
+
/* ---- list view ---- */
|
|
410
|
+
.list { display: flex; flex-direction: column; gap: 8px; padding: 16px 20px; width: 100%; }
|
|
411
|
+
.group { background: #161b22; border: 1px solid #21262d; border-radius: 10px; overflow: hidden; }
|
|
412
|
+
.grouphead {
|
|
413
|
+
display: flex; align-items: center; gap: 8px; padding: 9px 12px; cursor: pointer;
|
|
414
|
+
font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #adbac7;
|
|
415
|
+
list-style: none; user-select: none;
|
|
416
|
+
}
|
|
417
|
+
.grouphead::-webkit-details-marker { display: none; }
|
|
418
|
+
.grouphead:hover { background: #1c2128; }
|
|
419
|
+
.gcaret, .rcaret { color: #7d8590; font-size: 10px; transition: transform .15s; display: inline-block; }
|
|
420
|
+
.group[open] > .grouphead .gcaret { transform: rotate(90deg); }
|
|
421
|
+
.grouphead .count { margin-left: auto; }
|
|
422
|
+
.rows { display: flex; flex-direction: column; border-top: 1px solid #21262d; }
|
|
423
|
+
.rows .empty { color: #444c56; padding: 12px; text-align: left; }
|
|
424
|
+
.row {
|
|
425
|
+
border-bottom: 1px solid #21262d; border-left: 3px solid #444c56;
|
|
426
|
+
}
|
|
427
|
+
.row:last-child { border-bottom: 0; }
|
|
428
|
+
.row[open] { background: #1c2128; }
|
|
429
|
+
.row > summary {
|
|
430
|
+
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer;
|
|
431
|
+
list-style: none; user-select: none;
|
|
432
|
+
}
|
|
433
|
+
.row > summary::-webkit-details-marker { display: none; }
|
|
434
|
+
.row:hover { background: #1c2128; }
|
|
435
|
+
.row[open] > summary .rcaret { transform: rotate(90deg); }
|
|
436
|
+
.row .rtitle {
|
|
437
|
+
flex: 1; min-width: 0; font-weight: 500; color: var(--neutral);
|
|
438
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
439
|
+
}
|
|
440
|
+
.row .rbadges { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; }
|
|
441
|
+
.row .rmeta { color: #7d8590; font-size: 11px; white-space: nowrap; }
|
|
442
|
+
.prlink { color: #58a6ff; text-decoration: none; font-weight: 600; }
|
|
443
|
+
.prlink:hover { text-decoration: underline; }
|
|
444
|
+
.row > .body { margin: 0 12px 12px 12px; }
|
|
445
|
+
.row.prio-urgent { border-left-color: var(--blaze-red); }
|
|
446
|
+
.row.prio-high { border-left-color: var(--blaze-orange); }
|
|
447
|
+
.row.prio-medium { border-left-color: var(--blaze-amber); }
|
|
448
|
+
#live { color: var(--blaze-orange); }
|
|
449
|
+
.proj { color: #7d8590; text-decoration: none; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
|
|
450
|
+
.proj:hover { color: #adbac7; }
|
|
451
|
+
.proj.on { color: var(--charcoal); background: var(--blaze-amber); }
|
|
452
|
+
@media (max-width: 640px) {
|
|
453
|
+
.row .rmeta, .row .rbadges .label { display: none; }
|
|
454
|
+
}
|
|
455
|
+
#toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: #4b1113;
|
|
456
|
+
color: var(--neutral); border: 1px solid var(--blaze-red); padding: 8px 14px; border-radius: 8px;
|
|
457
|
+
font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 20; max-width: 80vw; }
|
|
458
|
+
#toast.show { opacity: 1; }
|
|
459
|
+
.card[draggable="true"], .row[draggable="true"] { cursor: grab; }
|
|
460
|
+
.col.drop-hover, .group.drop-hover { outline: 2px dashed var(--blaze-orange); outline-offset: -2px; }
|
|
461
|
+
</style>
|
|
462
|
+
</head>
|
|
463
|
+
<body>
|
|
464
|
+
<div id="toast" role="status"></div>
|
|
465
|
+
<header class="top">
|
|
466
|
+
<h1>${cfg.boardTitle}</h1>
|
|
467
|
+
<span class="sub">${total} tickets · ${cols.filter((c) => ["todo","in-progress","in-review"].includes(c.dir)).reduce((n,c)=>n+c.tickets.length,0)} in flight</span>
|
|
468
|
+
${["all", ...Object.keys(projects)].map((k) =>
|
|
469
|
+
`<a class="proj ${k === selected ? "on" : ""}" href="${k === "all" ? "/" : "/?project=" + esc(k)}">${k === "all" ? "All" : esc(k)}${k === "all" ? "" : ` <span class="count">${projects[k]}</span>`}</a>`
|
|
470
|
+
).join("")}
|
|
471
|
+
<div class="viewtoggle" role="group" aria-label="View" style="margin-left:auto">
|
|
472
|
+
<button type="button" class="pill" data-view="board">Board</button>
|
|
473
|
+
<button type="button" class="pill" data-view="list">List</button>
|
|
474
|
+
</div>
|
|
475
|
+
<button type="button" id="reconcileBtn" class="pill" style="background:#161b22;border:1px solid #21262d;border-radius:6px;color:#adbac7;cursor:pointer;font:inherit;font-size:12px;font-weight:600;padding:4px 12px">Reconcile (dry-run)</button>
|
|
476
|
+
<span class="sub" id="live">live</span>
|
|
477
|
+
<span class="sub" id="sync"></span>
|
|
478
|
+
</header>
|
|
479
|
+
${afterHeader}
|
|
480
|
+
<div class="board">${columnsHtml}</div>
|
|
481
|
+
<div class="list">${groupsHtml}</div>
|
|
482
|
+
<script>
|
|
483
|
+
// View toggle (Board / List), persisted to localStorage.
|
|
484
|
+
const VIEW_KEY = "tracker.view";
|
|
485
|
+
function applyView(v) {
|
|
486
|
+
document.documentElement.dataset.view = v;
|
|
487
|
+
document.querySelectorAll(".viewtoggle .pill").forEach((b) =>
|
|
488
|
+
b.classList.toggle("on", b.dataset.view === v));
|
|
489
|
+
try { localStorage.setItem(VIEW_KEY, v); } catch {}
|
|
490
|
+
}
|
|
491
|
+
document.querySelectorAll(".viewtoggle .pill").forEach((b) =>
|
|
492
|
+
b.addEventListener("click", () => applyView(b.dataset.view)));
|
|
493
|
+
applyView(document.documentElement.dataset.view || "board");
|
|
494
|
+
</script>
|
|
495
|
+
<script>
|
|
496
|
+
// Poll a cheap content hash; reload only when ticket files actually change.
|
|
497
|
+
// Also updates the sync badge with unsynced-commits count.
|
|
498
|
+
let last = null;
|
|
499
|
+
async function poll() {
|
|
500
|
+
try {
|
|
501
|
+
const h = await (await fetch("/api/hash")).text();
|
|
502
|
+
if (last !== null && h !== last) location.reload();
|
|
503
|
+
last = h;
|
|
504
|
+
document.getElementById("live").textContent = "live";
|
|
505
|
+
const s = await (await fetch("/api/sync")).json();
|
|
506
|
+
document.getElementById("sync").textContent = s.ahead > 0 ? "⇧ " + s.ahead + " ahead" : "";
|
|
507
|
+
} catch {
|
|
508
|
+
document.getElementById("live").textContent = "offline";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
poll();
|
|
512
|
+
setInterval(poll, 3000);
|
|
513
|
+
</script>
|
|
514
|
+
<script>
|
|
515
|
+
const CSRF = window.__csrf;
|
|
516
|
+
function toast(msg) {
|
|
517
|
+
const el = document.getElementById("toast");
|
|
518
|
+
el.textContent = msg; el.classList.add("show");
|
|
519
|
+
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove("show"), 4000);
|
|
520
|
+
}
|
|
521
|
+
async function blazePost(path, body) {
|
|
522
|
+
try {
|
|
523
|
+
const res = await fetch(path, { method: "POST",
|
|
524
|
+
headers: { "content-type": "application/json", "x-blaze-csrf": CSRF }, body: JSON.stringify(body) });
|
|
525
|
+
if (res.ok) { location.reload(); return true; }
|
|
526
|
+
const j = await res.json().catch(() => ({ errors: [res.statusText] }));
|
|
527
|
+
toast((j.errors || ["error"]).join("; ")); return false;
|
|
528
|
+
} catch (e) { toast("network error: " + e.message); return false; }
|
|
529
|
+
}
|
|
530
|
+
// Drag-to-transition: we never move the DOM ourselves — success reloads,
|
|
531
|
+
// failure leaves the card where it was (automatic snap-back).
|
|
532
|
+
let dragId = null, dragSourceStatus = null;
|
|
533
|
+
document.addEventListener("dragstart", (e) => {
|
|
534
|
+
const c = e.target.closest("[data-id]"); if (!c) return;
|
|
535
|
+
dragId = c.dataset.id; e.dataTransfer.effectAllowed = "move";
|
|
536
|
+
const col = c.closest("[data-status]");
|
|
537
|
+
dragSourceStatus = col ? col.dataset.status : null;
|
|
538
|
+
});
|
|
539
|
+
for (const zone of document.querySelectorAll("[data-status]")) {
|
|
540
|
+
zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drop-hover"); });
|
|
541
|
+
zone.addEventListener("dragleave", () => zone.classList.remove("drop-hover"));
|
|
542
|
+
zone.addEventListener("drop", (e) => {
|
|
543
|
+
e.preventDefault(); zone.classList.remove("drop-hover");
|
|
544
|
+
if (dragId && dragSourceStatus !== zone.dataset.status) blazePost("/api/move", { id: dragId, to: zone.dataset.status });
|
|
545
|
+
dragId = null; dragSourceStatus = null;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const PRIORITIES = ${JSON.stringify(PRIORITIES)};
|
|
549
|
+
function blazeEdit(span) {
|
|
550
|
+
const field = span.dataset.edit, id = span.closest("[data-ticket]").dataset.ticket;
|
|
551
|
+
const cur = span.dataset.value || "";
|
|
552
|
+
let input;
|
|
553
|
+
if (field === "priority") {
|
|
554
|
+
input = document.createElement("select");
|
|
555
|
+
const opts = PRIORITIES.includes(cur) ? PRIORITIES : [cur, ...PRIORITIES];
|
|
556
|
+
input.innerHTML = opts.map((p) => "<option" + (p === cur ? " selected" : "") + ">" + p + "</option>").join("");
|
|
557
|
+
} else { input = document.createElement("input"); input.value = cur; input.size = 10; }
|
|
558
|
+
span.replaceWith(input); input.focus();
|
|
559
|
+
let done = false;
|
|
560
|
+
const commit = () => {
|
|
561
|
+
if (done) return; done = true;
|
|
562
|
+
const v = input.value.trim();
|
|
563
|
+
if (v === cur) { location.reload(); return; }
|
|
564
|
+
blazePost("/api/edit", { id, patch: { [field]: v } });
|
|
565
|
+
};
|
|
566
|
+
input.addEventListener("blur", commit);
|
|
567
|
+
input.addEventListener("keydown", (e) => { if (e.key === "Enter") input.blur(); if (e.key === "Escape") { done = true; location.reload(); } });
|
|
568
|
+
}
|
|
569
|
+
document.addEventListener("click", (e) => {
|
|
570
|
+
const span = e.target.closest(".editable"); if (span) { e.preventDefault(); e.stopPropagation(); blazeEdit(span); }
|
|
571
|
+
});
|
|
572
|
+
document.addEventListener("change", (e) => {
|
|
573
|
+
const cb = e.target.closest("input[type=checkbox][data-ac-index]"); if (!cb) return;
|
|
574
|
+
const id = cb.closest("[data-ticket]").dataset.ticket;
|
|
575
|
+
blazePost("/api/ac", { id, index: Number(cb.dataset.acIndex), checked: cb.checked });
|
|
576
|
+
});
|
|
577
|
+
document.getElementById("reconcileBtn")?.addEventListener("click", async () => {
|
|
578
|
+
const j = await (await fetch("/api/reconcile-preview")).json();
|
|
579
|
+
const lines = (j.changes || []).map((c) => c.id + ": " + c.from + " → " + c.to);
|
|
580
|
+
toast(lines.length ? lines.length + " code-bound move(s) — apply via 'blaze reconcile --apply'" : "no code-bound changes");
|
|
581
|
+
});
|
|
582
|
+
</script>
|
|
583
|
+
${beforeBodyEnd}
|
|
584
|
+
</body>
|
|
585
|
+
</html>`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---- server factory ---------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
export function startServer({ projectsDir = resolveRoots().projectsDir, root = resolveRoots().dataRoot, port = PORT, host = process.env.HOST || "127.0.0.1" } = {}) {
|
|
591
|
+
return createServer(async (req, res) => {
|
|
592
|
+
const u = new URL(req.url, "http://localhost");
|
|
593
|
+
const json = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
594
|
+
|
|
595
|
+
if (req.method === "GET" && u.pathname === "/api/hash") {
|
|
596
|
+
res.writeHead(200, { "content-type": "text/plain" }); res.end(contentHash()); return;
|
|
597
|
+
}
|
|
598
|
+
if (req.method === "GET" && u.pathname === "/api/sync") return json(200, { ahead: aheadCount(root) });
|
|
599
|
+
if (req.method === "GET" && u.pathname === "/api/reconcile-preview") {
|
|
600
|
+
const { reconcile } = await import("./reconcile.mjs");
|
|
601
|
+
const r = reconcile({ fetch: false, commit: false, push: false, dryRun: true, root, projectsDir });
|
|
602
|
+
return json(200, { changes: r.changes || [] });
|
|
603
|
+
}
|
|
604
|
+
if (req.method === "GET" && u.pathname === "/") {
|
|
605
|
+
const project = u.searchParams.get("project") || "all";
|
|
606
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
607
|
+
res.end(pageHtml({ project })); return;
|
|
608
|
+
}
|
|
609
|
+
if (req.method === "POST") {
|
|
610
|
+
if (req.headers["x-blaze-csrf"] !== CSRF) return json(403, { errors: ["bad csrf token"] });
|
|
611
|
+
|
|
612
|
+
let payload;
|
|
613
|
+
try { payload = await readJson(req); } catch { return json(400, { errors: ["bad json body"] }); }
|
|
614
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
615
|
+
// in-place ops only — use the inline path for ops that rename (see /api/move)
|
|
616
|
+
const done = (r, msg, extra = {}) => {
|
|
617
|
+
if (!r.ok) return json(422, { errors: r.errors });
|
|
618
|
+
const c = commitFile(root, r.file, msg);
|
|
619
|
+
if (!c.ok) return json(500, { errors: [`written but commit failed (status ${c.status})`] });
|
|
620
|
+
return json(200, { ok: true, ...extra });
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
if (u.pathname === "/api/move") {
|
|
624
|
+
const r = applyMove(projectsDir, payload.id, payload.to, { today });
|
|
625
|
+
if (!r.ok) return json(422, { errors: r.errors });
|
|
626
|
+
const extraFiles = (r.fromFile && r.fromFile !== r.file) ? [r.fromFile] : [];
|
|
627
|
+
const c = commitFile(root, r.file, `${payload.id}: ${r.from ?? "?"} → ${payload.to}`, extraFiles);
|
|
628
|
+
if (!c.ok) return json(500, { errors: [`written but commit failed (status ${c.status})`] });
|
|
629
|
+
return json(200, { ok: true, resolution: r.resolution });
|
|
630
|
+
}
|
|
631
|
+
if (u.pathname === "/api/edit") {
|
|
632
|
+
const r = applyEdit(projectsDir, payload.id, payload.patch || {}, { today });
|
|
633
|
+
return done(r, `${payload.id}: edit ${Object.keys(payload.patch || {}).join(",")}`);
|
|
634
|
+
}
|
|
635
|
+
if (u.pathname === "/api/resolve") {
|
|
636
|
+
const r = applyResolve(projectsDir, payload.id, payload.resolution, { today });
|
|
637
|
+
return done(r, `${payload.id}: resolve ${payload.resolution}`);
|
|
638
|
+
}
|
|
639
|
+
if (u.pathname === "/api/log") {
|
|
640
|
+
const r = applyLog(projectsDir, payload.id, payload.minutes, { note: payload.note ?? null, today });
|
|
641
|
+
return done(r, `${payload.id}: log ${payload.minutes}m`);
|
|
642
|
+
}
|
|
643
|
+
if (u.pathname === "/api/ac") {
|
|
644
|
+
const r = applyToggleAc(projectsDir, payload.id, { index: payload.index, checked: payload.checked }, { today });
|
|
645
|
+
return done(r, `${payload.id}: ac[${payload.index}]=${payload.checked ? "x" : " "}`);
|
|
646
|
+
}
|
|
647
|
+
return json(404, { errors: ["not found"] });
|
|
648
|
+
}
|
|
649
|
+
res.writeHead(404, { "content-type": "text/plain" }); res.end("not found");
|
|
650
|
+
}).listen(port, host);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---- standalone entry -------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
656
|
+
const server = startServer();
|
|
657
|
+
server.on("listening", () => console.log(`${cfg.boardTitle} board → http://localhost:${server.address().port}`));
|
|
658
|
+
}
|