yarlo-plugin-board 0.1.1 → 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.
@@ -0,0 +1,21 @@
1
+ import { Hono } from "hono";
2
+ import type { YarloPluginContext } from "yarlo-types";
3
+ export type BoardTaskJson = {
4
+ id: string;
5
+ title: string;
6
+ status: unknown;
7
+ priority: unknown;
8
+ labels: unknown;
9
+ assignee: unknown;
10
+ due_date: unknown;
11
+ estimate: unknown;
12
+ updated_at: string;
13
+ };
14
+ /**
15
+ * Builds the Hono app for the local board (HTML UI + JSON API).
16
+ *
17
+ * @param ctx - Plugin context with task operations and config
18
+ * @returns Configured Hono instance
19
+ */
20
+ export declare function createBoardApp(ctx: YarloPluginContext): Hono;
21
+ //# sourceMappingURL=board-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"board-server.d.ts","sourceRoot":"","sources":["../source/board-server.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAQ,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAO5D,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAuBF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,kBAAkB,GAAG,IAAI,CAiE5D"}
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import { getStatusColumnOptions } from "./status-field.js";
7
+ const patchBodySchema = z.object({
8
+ status: z.string().min(1),
9
+ });
10
+ /**
11
+ * Maps a task to a JSON-safe shape for the board UI (no file paths).
12
+ *
13
+ * @param task - Task from yarlo storage
14
+ * @returns Serializable board card payload
15
+ */
16
+ function serializeBoardTask(task) {
17
+ const f = task.fields;
18
+ return {
19
+ id: task.id,
20
+ title: task.title,
21
+ status: f.status ?? null,
22
+ priority: f.priority ?? null,
23
+ labels: f.labels ?? null,
24
+ assignee: f.assignee ?? null,
25
+ due_date: f.due_date ?? null,
26
+ estimate: f.estimate ?? null,
27
+ updated_at: task.updated_at,
28
+ };
29
+ }
30
+ /**
31
+ * Builds the Hono app for the local board (HTML UI + JSON API).
32
+ *
33
+ * @param ctx - Plugin context with task operations and config
34
+ * @returns Configured Hono instance
35
+ */
36
+ export function createBoardApp(ctx) {
37
+ const app = new Hono();
38
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
39
+ app.get("/", async (c) => {
40
+ const htmlPath = path.join(moduleDir, "board-ui.html");
41
+ const html = await fs.readFile(htmlPath, "utf-8");
42
+ return c.html(html);
43
+ });
44
+ app.get("/api/board", async (c) => {
45
+ const columns = getStatusColumnOptions(ctx.config);
46
+ const tasks = await ctx.tasks.list();
47
+ return c.json({
48
+ columns,
49
+ tasks: tasks.map(serializeBoardTask),
50
+ });
51
+ });
52
+ app.patch("/api/tasks/:id", async (c) => {
53
+ const id = c.req.param("id");
54
+ let body;
55
+ try {
56
+ body = await c.req.json();
57
+ }
58
+ catch {
59
+ return c.json({ error: "Invalid JSON body" }, 400);
60
+ }
61
+ const parsed = patchBodySchema.safeParse(body);
62
+ if (!parsed.success) {
63
+ return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
64
+ }
65
+ const columns = getStatusColumnOptions(ctx.config);
66
+ if (!columns.includes(parsed.data.status)) {
67
+ return c.json({
68
+ error: `Invalid status: must be one of: ${columns.join(", ")}`,
69
+ }, 400);
70
+ }
71
+ try {
72
+ const task = await ctx.tasks.update(id, {
73
+ fields: { status: parsed.data.status },
74
+ });
75
+ return c.json({ task: serializeBoardTask(task) });
76
+ }
77
+ catch (e) {
78
+ const msg = e instanceof Error ? e.message : String(e);
79
+ if (msg.includes("not found") ||
80
+ msg.includes("Ambiguous ID") ||
81
+ msg.includes("Ambiguous")) {
82
+ return c.json({ error: msg }, 404);
83
+ }
84
+ return c.json({ error: msg }, 500);
85
+ }
86
+ });
87
+ return app;
88
+ }
89
+ //# sourceMappingURL=board-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"board-server.js","sourceRoot":"","sources":["../source/board-server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,384 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>yarlo board</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --bg: #0f1419;
13
+ --surface: #1a222c;
14
+ --border: #2d3a47;
15
+ --text: #e7ecf1;
16
+ --muted: #8b9aab;
17
+ --accent: #3d9ee5;
18
+ --accent-dim: #2a6a9a;
19
+ --card: #232d38;
20
+ --danger: #e85d5d;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ font-family: "DM Sans", system-ui, sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ background-image:
30
+ radial-gradient(ellipse 120% 80% at 10% -20%, rgba(61, 158, 229, 0.12), transparent),
31
+ radial-gradient(ellipse 80% 60% at 100% 100%, rgba(45, 58, 71, 0.4), transparent);
32
+ }
33
+ header {
34
+ padding: 1.25rem 1.5rem;
35
+ border-bottom: 1px solid var(--border);
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ flex-wrap: wrap;
40
+ gap: 0.75rem;
41
+ }
42
+ header h1 {
43
+ margin: 0;
44
+ font-size: 1.15rem;
45
+ font-weight: 700;
46
+ letter-spacing: -0.02em;
47
+ }
48
+ header h1 span { color: var(--accent); font-weight: 600; }
49
+ .toolbar {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 0.75rem;
53
+ }
54
+ button#refresh {
55
+ font-family: inherit;
56
+ font-size: 0.85rem;
57
+ padding: 0.45rem 0.9rem;
58
+ border-radius: 6px;
59
+ border: 1px solid var(--border);
60
+ background: var(--surface);
61
+ color: var(--text);
62
+ cursor: pointer;
63
+ }
64
+ button#refresh:hover { border-color: var(--accent-dim); color: var(--accent); }
65
+ #status {
66
+ font-family: "JetBrains Mono", monospace;
67
+ font-size: 0.75rem;
68
+ color: var(--muted);
69
+ }
70
+ #status.error { color: var(--danger); }
71
+ .board {
72
+ display: flex;
73
+ gap: 1rem;
74
+ padding: 1.25rem 1.5rem 2rem;
75
+ overflow-x: auto;
76
+ align-items: flex-start;
77
+ }
78
+ .column {
79
+ flex: 0 0 280px;
80
+ min-height: 200px;
81
+ background: var(--surface);
82
+ border: 1px solid var(--border);
83
+ border-radius: 10px;
84
+ display: flex;
85
+ flex-direction: column;
86
+ max-height: calc(100vh - 120px);
87
+ }
88
+ .column.orphan {
89
+ border-style: dashed;
90
+ opacity: 0.95;
91
+ }
92
+ .column-header {
93
+ padding: 0.75rem 1rem;
94
+ font-size: 0.8rem;
95
+ font-weight: 600;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.06em;
98
+ color: var(--muted);
99
+ border-bottom: 1px solid var(--border);
100
+ display: flex;
101
+ justify-content: space-between;
102
+ align-items: center;
103
+ }
104
+ .column-header .count {
105
+ font-family: "JetBrains Mono", monospace;
106
+ font-size: 0.7rem;
107
+ background: var(--bg);
108
+ padding: 0.15rem 0.45rem;
109
+ border-radius: 4px;
110
+ }
111
+ .column-body {
112
+ padding: 0.65rem;
113
+ overflow-y: auto;
114
+ flex: 1;
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 0.5rem;
118
+ }
119
+ .column-body.drag-over {
120
+ outline: 2px dashed var(--accent);
121
+ outline-offset: -4px;
122
+ border-radius: 6px;
123
+ }
124
+ .card {
125
+ background: var(--card);
126
+ border: 1px solid var(--border);
127
+ border-radius: 8px;
128
+ padding: 0.65rem 0.75rem;
129
+ cursor: grab;
130
+ transition: border-color 0.15s, transform 0.1s;
131
+ }
132
+ .card:active { cursor: grabbing; }
133
+ .card:hover { border-color: var(--accent-dim); }
134
+ .card.dragging { opacity: 0.45; }
135
+ .card-title {
136
+ font-size: 0.9rem;
137
+ font-weight: 600;
138
+ line-height: 1.35;
139
+ margin: 0 0 0.45rem;
140
+ }
141
+ .badges {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ gap: 0.35rem;
145
+ }
146
+ .badge {
147
+ font-family: "JetBrains Mono", monospace;
148
+ font-size: 0.65rem;
149
+ padding: 0.2rem 0.4rem;
150
+ border-radius: 4px;
151
+ background: var(--bg);
152
+ color: var(--muted);
153
+ }
154
+ .badge.priority-high, .badge.priority-urgent { color: #ffb088; }
155
+ .badge.priority-medium { color: #c9b87c; }
156
+ #loading, #empty {
157
+ padding: 2rem 1.5rem;
158
+ text-align: center;
159
+ color: var(--muted);
160
+ }
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <header>
165
+ <h1><span>yarlo</span> board</h1>
166
+ <div class="toolbar">
167
+ <span id="status"></span>
168
+ <button type="button" id="refresh">Refresh</button>
169
+ </div>
170
+ </header>
171
+ <div id="loading">Loading tasks…</div>
172
+ <div id="board" class="board" hidden></div>
173
+ <div id="empty" hidden>No tasks yet. Use <code>yarlo add</code> to create one.</div>
174
+
175
+ <script>
176
+ (function () {
177
+ const ORPHAN_KEY = "__orphan__";
178
+ const loadingEl = document.getElementById("loading");
179
+ const boardEl = document.getElementById("board");
180
+ const emptyEl = document.getElementById("empty");
181
+ const statusEl = document.getElementById("status");
182
+ const refreshBtn = document.getElementById("refresh");
183
+
184
+ let columns = [];
185
+ let tasks = [];
186
+
187
+ function setStatus(msg, isError) {
188
+ statusEl.textContent = msg || "";
189
+ statusEl.classList.toggle("error", !!isError);
190
+ }
191
+
192
+ function priorityClass(p) {
193
+ if (!p || typeof p !== "string") return "";
194
+ return "priority-" + p.replace(/_/g, "-");
195
+ }
196
+
197
+ function formatColumnTitle(key) {
198
+ if (key === ORPHAN_KEY) return "Other";
199
+ return String(key).replace(/_/g, " ");
200
+ }
201
+
202
+ function bucketTasks(cols, taskList) {
203
+ const colSet = new Set(cols);
204
+ const buckets = new Map();
205
+ cols.forEach(function (c) { buckets.set(c, []); });
206
+ const orphans = [];
207
+ taskList.forEach(function (t) {
208
+ const s = t.status;
209
+ if (typeof s === "string" && colSet.has(s)) {
210
+ buckets.get(s).push(t);
211
+ } else {
212
+ orphans.push(t);
213
+ }
214
+ });
215
+ return { buckets, orphans };
216
+ }
217
+
218
+ function renderCard(task) {
219
+ const card = document.createElement("article");
220
+ card.className = "card";
221
+ card.draggable = true;
222
+ card.dataset.taskId = task.id;
223
+ card.dataset.status = typeof task.status === "string" ? task.status : "";
224
+
225
+ const title = document.createElement("h2");
226
+ title.className = "card-title";
227
+ title.textContent = task.title || "(no title)";
228
+
229
+ const badges = document.createElement("div");
230
+ badges.className = "badges";
231
+ if (task.priority) {
232
+ const b = document.createElement("span");
233
+ b.className = "badge " + priorityClass(task.priority);
234
+ b.textContent = String(task.priority);
235
+ badges.appendChild(b);
236
+ }
237
+ if (Array.isArray(task.labels) && task.labels.length) {
238
+ task.labels.forEach(function (lb) {
239
+ const b = document.createElement("span");
240
+ b.className = "badge";
241
+ b.textContent = String(lb);
242
+ badges.appendChild(b);
243
+ });
244
+ }
245
+ if (task.assignee) {
246
+ const b = document.createElement("span");
247
+ b.className = "badge";
248
+ b.textContent = "@" + String(task.assignee);
249
+ badges.appendChild(b);
250
+ }
251
+
252
+ card.appendChild(title);
253
+ if (badges.children.length) card.appendChild(badges);
254
+
255
+ card.addEventListener("dragstart", function (e) {
256
+ card.classList.add("dragging");
257
+ e.dataTransfer.setData("text/plain", task.id);
258
+ e.dataTransfer.effectAllowed = "move";
259
+ });
260
+ card.addEventListener("dragend", function () {
261
+ card.classList.remove("dragging");
262
+ });
263
+
264
+ return card;
265
+ }
266
+
267
+ function renderColumn(key, taskList) {
268
+ const col = document.createElement("section");
269
+ col.className = "column" + (key === ORPHAN_KEY ? " orphan" : "");
270
+ col.dataset.columnKey = key;
271
+
272
+ const head = document.createElement("div");
273
+ head.className = "column-header";
274
+ const label = document.createElement("span");
275
+ label.textContent = formatColumnTitle(key);
276
+ const count = document.createElement("span");
277
+ count.className = "count";
278
+ count.textContent = String(taskList.length);
279
+ head.appendChild(label);
280
+ head.appendChild(count);
281
+
282
+ const body = document.createElement("div");
283
+ body.className = "column-body";
284
+ taskList.forEach(function (t) {
285
+ body.appendChild(renderCard(t));
286
+ });
287
+
288
+ col.appendChild(head);
289
+ col.appendChild(body);
290
+
291
+ body.addEventListener("dragover", function (e) {
292
+ if (key === ORPHAN_KEY) return;
293
+ e.preventDefault();
294
+ e.dataTransfer.dropEffect = "move";
295
+ body.classList.add("drag-over");
296
+ });
297
+ body.addEventListener("dragleave", function () {
298
+ body.classList.remove("drag-over");
299
+ });
300
+ body.addEventListener("drop", function (e) {
301
+ body.classList.remove("drag-over");
302
+ if (key === ORPHAN_KEY) return;
303
+ e.preventDefault();
304
+ const id = e.dataTransfer.getData("text/plain");
305
+ if (!id) return;
306
+ moveTask(id, key);
307
+ });
308
+
309
+ return col;
310
+ }
311
+
312
+ function render() {
313
+ boardEl.innerHTML = "";
314
+ const { buckets, orphans } = bucketTasks(columns, tasks);
315
+
316
+ columns.forEach(function (colKey) {
317
+ boardEl.appendChild(renderColumn(colKey, buckets.get(colKey) || []));
318
+ });
319
+ if (orphans.length > 0) {
320
+ boardEl.appendChild(renderColumn(ORPHAN_KEY, orphans));
321
+ }
322
+
323
+ const total = tasks.length;
324
+ if (total === 0) {
325
+ boardEl.hidden = true;
326
+ emptyEl.hidden = false;
327
+ } else {
328
+ boardEl.hidden = false;
329
+ emptyEl.hidden = true;
330
+ }
331
+ }
332
+
333
+ async function moveTask(id, newStatus) {
334
+ setStatus("Saving…");
335
+ try {
336
+ const res = await fetch("/api/tasks/" + encodeURIComponent(id), {
337
+ method: "PATCH",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({ status: newStatus }),
340
+ });
341
+ const data = await res.json().catch(function () { return {}; });
342
+ if (!res.ok) {
343
+ setStatus(data.error || res.statusText, true);
344
+ return;
345
+ }
346
+ const updated = data.task;
347
+ if (updated && updated.id) {
348
+ tasks = tasks.map(function (t) {
349
+ return t.id === updated.id ? updated : t;
350
+ });
351
+ }
352
+ setStatus("Saved");
353
+ render();
354
+ } catch (err) {
355
+ setStatus(err.message || "Request failed", true);
356
+ }
357
+ }
358
+
359
+ async function load() {
360
+ setStatus("");
361
+ loadingEl.hidden = false;
362
+ boardEl.hidden = true;
363
+ emptyEl.hidden = true;
364
+ try {
365
+ const res = await fetch("/api/board");
366
+ const data = await res.json();
367
+ if (!res.ok) throw new Error(data.error || res.statusText);
368
+ columns = data.columns || [];
369
+ tasks = data.tasks || [];
370
+ loadingEl.hidden = true;
371
+ render();
372
+ setStatus(columns.length + " columns · " + tasks.length + " tasks");
373
+ } catch (err) {
374
+ loadingEl.hidden = true;
375
+ setStatus(err.message || "Failed to load", true);
376
+ }
377
+ }
378
+
379
+ refreshBtn.addEventListener("click", load);
380
+ load();
381
+ })();
382
+ </script>
383
+ </body>
384
+ </html>
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,QAAA,MAAM,MAAM,EAAE,WAcb,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA6C/C,QAAA,MAAM,MAAM,EAAE,WAwCb,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/dist/index.js CHANGED
@@ -1,14 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import { serve } from "@hono/node-server";
3
+ import { createBoardApp } from "./board-server.js";
4
+ const DEFAULT_PORT = 3847;
5
+ /**
6
+ * Parses `--port <n>` from plugin CLI args.
7
+ *
8
+ * @param args - Arguments after `yarlo board`
9
+ * @returns Valid TCP port
10
+ */
11
+ function parsePort(args) {
12
+ const idx = args.indexOf("--port");
13
+ if (idx >= 0 && args[idx + 1]) {
14
+ const n = Number.parseInt(args[idx + 1], 10);
15
+ if (!Number.isNaN(n) && n > 0 && n < 65536) {
16
+ return n;
17
+ }
18
+ }
19
+ return DEFAULT_PORT;
20
+ }
21
+ /**
22
+ * Opens the given URL in the system default browser.
23
+ *
24
+ * @param url - HTTP URL to open
25
+ */
26
+ function openBrowser(url) {
27
+ const platform = process.platform;
28
+ if (platform === "darwin") {
29
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
30
+ return;
31
+ }
32
+ if (platform === "win32") {
33
+ spawn("cmd", ["/c", "start", "", url], {
34
+ detached: true,
35
+ stdio: "ignore",
36
+ windowsHide: true,
37
+ }).unref();
38
+ return;
39
+ }
40
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
41
+ }
1
42
  const plugin = {
2
43
  name: "board",
3
- version: "0.1.1",
44
+ version: "0.2.0",
4
45
  description: "Kanban board view for yarlo tasks",
5
46
  commands: [
6
47
  {
7
48
  name: "board",
8
49
  description: "Open the task board in your browser",
9
- async run(_args, _ctx) {
10
- console.log("Board plugin - coming soon!");
11
- console.log("This will open a local web UI for managing tasks.");
50
+ async run(args, ctx) {
51
+ const port = parsePort(args);
52
+ const app = createBoardApp(ctx);
53
+ const server = serve({
54
+ fetch: app.fetch,
55
+ port,
56
+ hostname: "127.0.0.1",
57
+ }, (info) => {
58
+ const url = `http://127.0.0.1:${info.port}/`;
59
+ console.log(`Board running at ${url}`);
60
+ console.log("Press Ctrl+C to stop.");
61
+ openBrowser(url);
62
+ });
63
+ const shutdown = () => {
64
+ server.close(() => {
65
+ process.exit(0);
66
+ });
67
+ };
68
+ process.once("SIGINT", shutdown);
69
+ process.once("SIGTERM", shutdown);
70
+ await new Promise(() => {
71
+ /* keep process alive until signals */
72
+ });
12
73
  },
13
74
  },
14
75
  ],
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,GAAgB;IAC1B,IAAI,EAAE,OAAO;IACb,OAAO,EAAE,OAAO;IAChB,WAAW,EAAE,mCAAmC;IAChD,QAAQ,EAAE;QACR;YACE,IAAI,EAAE,OAAO;YACb,WAAW,EAAE,qCAAqC;YAClD,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI;gBACnB,OAAO,CAAC,GAAG,CAAC,6BAA6B,CAAC,CAAC;gBAC3C,OAAO,CAAC,GAAG,CAAC,mDAAmD,CAAC,CAAC;YACnE,CAAC;SACF;KACF;CACF,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { YarloConfig } from "yarlo-types";
2
+ /**
3
+ * Returns ordered status column keys for the Kanban board from `config.fields`.
4
+ *
5
+ * @param config - Loaded yarlo project configuration
6
+ * @returns Option list for the `status` single_select field, or sensible defaults
7
+ */
8
+ export declare function getStatusColumnOptions(config: YarloConfig): string[];
9
+ //# sourceMappingURL=status-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-field.d.ts","sourceRoot":"","sources":["../source/status-field.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAU/C;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CAWpE"}
@@ -0,0 +1,23 @@
1
+ const DEFAULT_STATUS_OPTIONS = [
2
+ "backlog",
3
+ "todo",
4
+ "in_progress",
5
+ "done",
6
+ "cancelled",
7
+ ];
8
+ /**
9
+ * Returns ordered status column keys for the Kanban board from `config.fields`.
10
+ *
11
+ * @param config - Loaded yarlo project configuration
12
+ * @returns Option list for the `status` single_select field, or sensible defaults
13
+ */
14
+ export function getStatusColumnOptions(config) {
15
+ const def = config.fields.find((f) => f.name === "status");
16
+ if (def?.type === "single_select" &&
17
+ Array.isArray(def.options) &&
18
+ def.options.length > 0) {
19
+ return [...def.options];
20
+ }
21
+ return [...DEFAULT_STATUS_OPTIONS];
22
+ }
23
+ //# sourceMappingURL=status-field.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-field.js","sourceRoot":"","sources":["../source/status-field.ts"],"names":[],"mappings":"AAEA,MAAM,sBAAsB,GAAG;IAC7B,SAAS;IACT,MAAM;IACN,aAAa;IACb,MAAM;IACN,WAAW;CACH,CAAC;AAEX;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC;IAC3D,IACE,GAAG,EAAE,IAAI,KAAK,eAAe;QAC7B,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;QAC1B,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EACtB,CAAC;QACD,OAAO,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC;IAC1B,CAAC;IAED,OAAO,CAAC,GAAG,sBAAsB,CAAC,CAAC;AACrC,CAAC"}
package/package.json CHANGED
@@ -1,24 +1,28 @@
1
1
  {
2
2
  "name": "yarlo-plugin-board",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "exports": {
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js"
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
11
12
  }
12
13
  },
13
14
  "dependencies": {
15
+ "@hono/node-server": "^1.13.0",
14
16
  "hono": "^4.7.0",
15
- "yarlo-types": "0.1.1"
17
+ "zod": "^3.24.0",
18
+ "yarlo-types": "0.1.2"
16
19
  },
17
20
  "devDependencies": {
21
+ "@types/node": "^22.0.0",
18
22
  "typescript": "^5.7.0"
19
23
  },
20
24
  "scripts": {
21
- "build": "tsc",
25
+ "build": "tsc && cp source/board-ui.html dist/board-ui.html",
22
26
  "dev": "tsc --watch"
23
27
  }
24
28
  }
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import type { Task, YarloPluginContext } from "yarlo-types";
7
+ import { getStatusColumnOptions } from "./status-field.js";
8
+
9
+ const patchBodySchema = z.object({
10
+ status: z.string().min(1),
11
+ });
12
+
13
+ export type BoardTaskJson = {
14
+ id: string;
15
+ title: string;
16
+ status: unknown;
17
+ priority: unknown;
18
+ labels: unknown;
19
+ assignee: unknown;
20
+ due_date: unknown;
21
+ estimate: unknown;
22
+ updated_at: string;
23
+ };
24
+
25
+ /**
26
+ * Maps a task to a JSON-safe shape for the board UI (no file paths).
27
+ *
28
+ * @param task - Task from yarlo storage
29
+ * @returns Serializable board card payload
30
+ */
31
+ function serializeBoardTask(task: Task): BoardTaskJson {
32
+ const f = task.fields;
33
+ return {
34
+ id: task.id,
35
+ title: task.title,
36
+ status: f.status ?? null,
37
+ priority: f.priority ?? null,
38
+ labels: f.labels ?? null,
39
+ assignee: f.assignee ?? null,
40
+ due_date: f.due_date ?? null,
41
+ estimate: f.estimate ?? null,
42
+ updated_at: task.updated_at,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Builds the Hono app for the local board (HTML UI + JSON API).
48
+ *
49
+ * @param ctx - Plugin context with task operations and config
50
+ * @returns Configured Hono instance
51
+ */
52
+ export function createBoardApp(ctx: YarloPluginContext): Hono {
53
+ const app = new Hono();
54
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
55
+
56
+ app.get("/", async (c) => {
57
+ const htmlPath = path.join(moduleDir, "board-ui.html");
58
+ const html = await fs.readFile(htmlPath, "utf-8");
59
+ return c.html(html);
60
+ });
61
+
62
+ app.get("/api/board", async (c) => {
63
+ const columns = getStatusColumnOptions(ctx.config);
64
+ const tasks = await ctx.tasks.list();
65
+ return c.json({
66
+ columns,
67
+ tasks: tasks.map(serializeBoardTask),
68
+ });
69
+ });
70
+
71
+ app.patch("/api/tasks/:id", async (c) => {
72
+ const id = c.req.param("id");
73
+ let body: unknown;
74
+ try {
75
+ body = await c.req.json();
76
+ } catch {
77
+ return c.json({ error: "Invalid JSON body" }, 400);
78
+ }
79
+
80
+ const parsed = patchBodySchema.safeParse(body);
81
+ if (!parsed.success) {
82
+ return c.json(
83
+ { error: "Validation failed", details: parsed.error.flatten() },
84
+ 400,
85
+ );
86
+ }
87
+
88
+ const columns = getStatusColumnOptions(ctx.config);
89
+ if (!columns.includes(parsed.data.status)) {
90
+ return c.json(
91
+ {
92
+ error: `Invalid status: must be one of: ${columns.join(", ")}`,
93
+ },
94
+ 400,
95
+ );
96
+ }
97
+
98
+ try {
99
+ const task = await ctx.tasks.update(id, {
100
+ fields: { status: parsed.data.status },
101
+ });
102
+ return c.json({ task: serializeBoardTask(task) });
103
+ } catch (e) {
104
+ const msg = e instanceof Error ? e.message : String(e);
105
+ if (
106
+ msg.includes("not found") ||
107
+ msg.includes("Ambiguous ID") ||
108
+ msg.includes("Ambiguous")
109
+ ) {
110
+ return c.json({ error: msg }, 404);
111
+ }
112
+ return c.json({ error: msg }, 500);
113
+ }
114
+ });
115
+
116
+ return app;
117
+ }
@@ -0,0 +1,384 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>yarlo board</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --bg: #0f1419;
13
+ --surface: #1a222c;
14
+ --border: #2d3a47;
15
+ --text: #e7ecf1;
16
+ --muted: #8b9aab;
17
+ --accent: #3d9ee5;
18
+ --accent-dim: #2a6a9a;
19
+ --card: #232d38;
20
+ --danger: #e85d5d;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ font-family: "DM Sans", system-ui, sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ background-image:
30
+ radial-gradient(ellipse 120% 80% at 10% -20%, rgba(61, 158, 229, 0.12), transparent),
31
+ radial-gradient(ellipse 80% 60% at 100% 100%, rgba(45, 58, 71, 0.4), transparent);
32
+ }
33
+ header {
34
+ padding: 1.25rem 1.5rem;
35
+ border-bottom: 1px solid var(--border);
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ flex-wrap: wrap;
40
+ gap: 0.75rem;
41
+ }
42
+ header h1 {
43
+ margin: 0;
44
+ font-size: 1.15rem;
45
+ font-weight: 700;
46
+ letter-spacing: -0.02em;
47
+ }
48
+ header h1 span { color: var(--accent); font-weight: 600; }
49
+ .toolbar {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 0.75rem;
53
+ }
54
+ button#refresh {
55
+ font-family: inherit;
56
+ font-size: 0.85rem;
57
+ padding: 0.45rem 0.9rem;
58
+ border-radius: 6px;
59
+ border: 1px solid var(--border);
60
+ background: var(--surface);
61
+ color: var(--text);
62
+ cursor: pointer;
63
+ }
64
+ button#refresh:hover { border-color: var(--accent-dim); color: var(--accent); }
65
+ #status {
66
+ font-family: "JetBrains Mono", monospace;
67
+ font-size: 0.75rem;
68
+ color: var(--muted);
69
+ }
70
+ #status.error { color: var(--danger); }
71
+ .board {
72
+ display: flex;
73
+ gap: 1rem;
74
+ padding: 1.25rem 1.5rem 2rem;
75
+ overflow-x: auto;
76
+ align-items: flex-start;
77
+ }
78
+ .column {
79
+ flex: 0 0 280px;
80
+ min-height: 200px;
81
+ background: var(--surface);
82
+ border: 1px solid var(--border);
83
+ border-radius: 10px;
84
+ display: flex;
85
+ flex-direction: column;
86
+ max-height: calc(100vh - 120px);
87
+ }
88
+ .column.orphan {
89
+ border-style: dashed;
90
+ opacity: 0.95;
91
+ }
92
+ .column-header {
93
+ padding: 0.75rem 1rem;
94
+ font-size: 0.8rem;
95
+ font-weight: 600;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.06em;
98
+ color: var(--muted);
99
+ border-bottom: 1px solid var(--border);
100
+ display: flex;
101
+ justify-content: space-between;
102
+ align-items: center;
103
+ }
104
+ .column-header .count {
105
+ font-family: "JetBrains Mono", monospace;
106
+ font-size: 0.7rem;
107
+ background: var(--bg);
108
+ padding: 0.15rem 0.45rem;
109
+ border-radius: 4px;
110
+ }
111
+ .column-body {
112
+ padding: 0.65rem;
113
+ overflow-y: auto;
114
+ flex: 1;
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 0.5rem;
118
+ }
119
+ .column-body.drag-over {
120
+ outline: 2px dashed var(--accent);
121
+ outline-offset: -4px;
122
+ border-radius: 6px;
123
+ }
124
+ .card {
125
+ background: var(--card);
126
+ border: 1px solid var(--border);
127
+ border-radius: 8px;
128
+ padding: 0.65rem 0.75rem;
129
+ cursor: grab;
130
+ transition: border-color 0.15s, transform 0.1s;
131
+ }
132
+ .card:active { cursor: grabbing; }
133
+ .card:hover { border-color: var(--accent-dim); }
134
+ .card.dragging { opacity: 0.45; }
135
+ .card-title {
136
+ font-size: 0.9rem;
137
+ font-weight: 600;
138
+ line-height: 1.35;
139
+ margin: 0 0 0.45rem;
140
+ }
141
+ .badges {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ gap: 0.35rem;
145
+ }
146
+ .badge {
147
+ font-family: "JetBrains Mono", monospace;
148
+ font-size: 0.65rem;
149
+ padding: 0.2rem 0.4rem;
150
+ border-radius: 4px;
151
+ background: var(--bg);
152
+ color: var(--muted);
153
+ }
154
+ .badge.priority-high, .badge.priority-urgent { color: #ffb088; }
155
+ .badge.priority-medium { color: #c9b87c; }
156
+ #loading, #empty {
157
+ padding: 2rem 1.5rem;
158
+ text-align: center;
159
+ color: var(--muted);
160
+ }
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <header>
165
+ <h1><span>yarlo</span> board</h1>
166
+ <div class="toolbar">
167
+ <span id="status"></span>
168
+ <button type="button" id="refresh">Refresh</button>
169
+ </div>
170
+ </header>
171
+ <div id="loading">Loading tasks…</div>
172
+ <div id="board" class="board" hidden></div>
173
+ <div id="empty" hidden>No tasks yet. Use <code>yarlo add</code> to create one.</div>
174
+
175
+ <script>
176
+ (function () {
177
+ const ORPHAN_KEY = "__orphan__";
178
+ const loadingEl = document.getElementById("loading");
179
+ const boardEl = document.getElementById("board");
180
+ const emptyEl = document.getElementById("empty");
181
+ const statusEl = document.getElementById("status");
182
+ const refreshBtn = document.getElementById("refresh");
183
+
184
+ let columns = [];
185
+ let tasks = [];
186
+
187
+ function setStatus(msg, isError) {
188
+ statusEl.textContent = msg || "";
189
+ statusEl.classList.toggle("error", !!isError);
190
+ }
191
+
192
+ function priorityClass(p) {
193
+ if (!p || typeof p !== "string") return "";
194
+ return "priority-" + p.replace(/_/g, "-");
195
+ }
196
+
197
+ function formatColumnTitle(key) {
198
+ if (key === ORPHAN_KEY) return "Other";
199
+ return String(key).replace(/_/g, " ");
200
+ }
201
+
202
+ function bucketTasks(cols, taskList) {
203
+ const colSet = new Set(cols);
204
+ const buckets = new Map();
205
+ cols.forEach(function (c) { buckets.set(c, []); });
206
+ const orphans = [];
207
+ taskList.forEach(function (t) {
208
+ const s = t.status;
209
+ if (typeof s === "string" && colSet.has(s)) {
210
+ buckets.get(s).push(t);
211
+ } else {
212
+ orphans.push(t);
213
+ }
214
+ });
215
+ return { buckets, orphans };
216
+ }
217
+
218
+ function renderCard(task) {
219
+ const card = document.createElement("article");
220
+ card.className = "card";
221
+ card.draggable = true;
222
+ card.dataset.taskId = task.id;
223
+ card.dataset.status = typeof task.status === "string" ? task.status : "";
224
+
225
+ const title = document.createElement("h2");
226
+ title.className = "card-title";
227
+ title.textContent = task.title || "(no title)";
228
+
229
+ const badges = document.createElement("div");
230
+ badges.className = "badges";
231
+ if (task.priority) {
232
+ const b = document.createElement("span");
233
+ b.className = "badge " + priorityClass(task.priority);
234
+ b.textContent = String(task.priority);
235
+ badges.appendChild(b);
236
+ }
237
+ if (Array.isArray(task.labels) && task.labels.length) {
238
+ task.labels.forEach(function (lb) {
239
+ const b = document.createElement("span");
240
+ b.className = "badge";
241
+ b.textContent = String(lb);
242
+ badges.appendChild(b);
243
+ });
244
+ }
245
+ if (task.assignee) {
246
+ const b = document.createElement("span");
247
+ b.className = "badge";
248
+ b.textContent = "@" + String(task.assignee);
249
+ badges.appendChild(b);
250
+ }
251
+
252
+ card.appendChild(title);
253
+ if (badges.children.length) card.appendChild(badges);
254
+
255
+ card.addEventListener("dragstart", function (e) {
256
+ card.classList.add("dragging");
257
+ e.dataTransfer.setData("text/plain", task.id);
258
+ e.dataTransfer.effectAllowed = "move";
259
+ });
260
+ card.addEventListener("dragend", function () {
261
+ card.classList.remove("dragging");
262
+ });
263
+
264
+ return card;
265
+ }
266
+
267
+ function renderColumn(key, taskList) {
268
+ const col = document.createElement("section");
269
+ col.className = "column" + (key === ORPHAN_KEY ? " orphan" : "");
270
+ col.dataset.columnKey = key;
271
+
272
+ const head = document.createElement("div");
273
+ head.className = "column-header";
274
+ const label = document.createElement("span");
275
+ label.textContent = formatColumnTitle(key);
276
+ const count = document.createElement("span");
277
+ count.className = "count";
278
+ count.textContent = String(taskList.length);
279
+ head.appendChild(label);
280
+ head.appendChild(count);
281
+
282
+ const body = document.createElement("div");
283
+ body.className = "column-body";
284
+ taskList.forEach(function (t) {
285
+ body.appendChild(renderCard(t));
286
+ });
287
+
288
+ col.appendChild(head);
289
+ col.appendChild(body);
290
+
291
+ body.addEventListener("dragover", function (e) {
292
+ if (key === ORPHAN_KEY) return;
293
+ e.preventDefault();
294
+ e.dataTransfer.dropEffect = "move";
295
+ body.classList.add("drag-over");
296
+ });
297
+ body.addEventListener("dragleave", function () {
298
+ body.classList.remove("drag-over");
299
+ });
300
+ body.addEventListener("drop", function (e) {
301
+ body.classList.remove("drag-over");
302
+ if (key === ORPHAN_KEY) return;
303
+ e.preventDefault();
304
+ const id = e.dataTransfer.getData("text/plain");
305
+ if (!id) return;
306
+ moveTask(id, key);
307
+ });
308
+
309
+ return col;
310
+ }
311
+
312
+ function render() {
313
+ boardEl.innerHTML = "";
314
+ const { buckets, orphans } = bucketTasks(columns, tasks);
315
+
316
+ columns.forEach(function (colKey) {
317
+ boardEl.appendChild(renderColumn(colKey, buckets.get(colKey) || []));
318
+ });
319
+ if (orphans.length > 0) {
320
+ boardEl.appendChild(renderColumn(ORPHAN_KEY, orphans));
321
+ }
322
+
323
+ const total = tasks.length;
324
+ if (total === 0) {
325
+ boardEl.hidden = true;
326
+ emptyEl.hidden = false;
327
+ } else {
328
+ boardEl.hidden = false;
329
+ emptyEl.hidden = true;
330
+ }
331
+ }
332
+
333
+ async function moveTask(id, newStatus) {
334
+ setStatus("Saving…");
335
+ try {
336
+ const res = await fetch("/api/tasks/" + encodeURIComponent(id), {
337
+ method: "PATCH",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({ status: newStatus }),
340
+ });
341
+ const data = await res.json().catch(function () { return {}; });
342
+ if (!res.ok) {
343
+ setStatus(data.error || res.statusText, true);
344
+ return;
345
+ }
346
+ const updated = data.task;
347
+ if (updated && updated.id) {
348
+ tasks = tasks.map(function (t) {
349
+ return t.id === updated.id ? updated : t;
350
+ });
351
+ }
352
+ setStatus("Saved");
353
+ render();
354
+ } catch (err) {
355
+ setStatus(err.message || "Request failed", true);
356
+ }
357
+ }
358
+
359
+ async function load() {
360
+ setStatus("");
361
+ loadingEl.hidden = false;
362
+ boardEl.hidden = true;
363
+ emptyEl.hidden = true;
364
+ try {
365
+ const res = await fetch("/api/board");
366
+ const data = await res.json();
367
+ if (!res.ok) throw new Error(data.error || res.statusText);
368
+ columns = data.columns || [];
369
+ tasks = data.tasks || [];
370
+ loadingEl.hidden = true;
371
+ render();
372
+ setStatus(columns.length + " columns · " + tasks.length + " tasks");
373
+ } catch (err) {
374
+ loadingEl.hidden = true;
375
+ setStatus(err.message || "Failed to load", true);
376
+ }
377
+ }
378
+
379
+ refreshBtn.addEventListener("click", load);
380
+ load();
381
+ })();
382
+ </script>
383
+ </body>
384
+ </html>
package/source/index.ts CHANGED
@@ -1,16 +1,87 @@
1
+ import { spawn } from "node:child_process";
2
+ import { serve } from "@hono/node-server";
1
3
  import type { YarloPlugin } from "yarlo-types";
4
+ import { createBoardApp } from "./board-server.js";
5
+
6
+ const DEFAULT_PORT = 3847;
7
+
8
+ /**
9
+ * Parses `--port <n>` from plugin CLI args.
10
+ *
11
+ * @param args - Arguments after `yarlo board`
12
+ * @returns Valid TCP port
13
+ */
14
+ function parsePort(args: string[]): number {
15
+ const idx = args.indexOf("--port");
16
+ if (idx >= 0 && args[idx + 1]) {
17
+ const n = Number.parseInt(args[idx + 1]!, 10);
18
+ if (!Number.isNaN(n) && n > 0 && n < 65536) {
19
+ return n;
20
+ }
21
+ }
22
+
23
+ return DEFAULT_PORT;
24
+ }
25
+
26
+ /**
27
+ * Opens the given URL in the system default browser.
28
+ *
29
+ * @param url - HTTP URL to open
30
+ */
31
+ function openBrowser(url: string): void {
32
+ const platform = process.platform;
33
+ if (platform === "darwin") {
34
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
35
+ return;
36
+ }
37
+ if (platform === "win32") {
38
+ spawn("cmd", ["/c", "start", "", url], {
39
+ detached: true,
40
+ stdio: "ignore",
41
+ windowsHide: true,
42
+ }).unref();
43
+ return;
44
+ }
45
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
46
+ }
2
47
 
3
48
  const plugin: YarloPlugin = {
4
49
  name: "board",
5
- version: "0.1.1",
50
+ version: "0.2.0",
6
51
  description: "Kanban board view for yarlo tasks",
7
52
  commands: [
8
53
  {
9
54
  name: "board",
10
55
  description: "Open the task board in your browser",
11
- async run(_args, _ctx) {
12
- console.log("Board plugin - coming soon!");
13
- console.log("This will open a local web UI for managing tasks.");
56
+ async run(args, ctx) {
57
+ const port = parsePort(args);
58
+ const app = createBoardApp(ctx);
59
+ const server = serve(
60
+ {
61
+ fetch: app.fetch,
62
+ port,
63
+ hostname: "127.0.0.1",
64
+ },
65
+ (info) => {
66
+ const url = `http://127.0.0.1:${info.port}/`;
67
+ console.log(`Board running at ${url}`);
68
+ console.log("Press Ctrl+C to stop.");
69
+ openBrowser(url);
70
+ },
71
+ );
72
+
73
+ const shutdown = (): void => {
74
+ server.close(() => {
75
+ process.exit(0);
76
+ });
77
+ };
78
+
79
+ process.once("SIGINT", shutdown);
80
+ process.once("SIGTERM", shutdown);
81
+
82
+ await new Promise<void>(() => {
83
+ /* keep process alive until signals */
84
+ });
14
85
  },
15
86
  },
16
87
  ],
@@ -0,0 +1,28 @@
1
+ import type { YarloConfig } from "yarlo-types";
2
+
3
+ const DEFAULT_STATUS_OPTIONS = [
4
+ "backlog",
5
+ "todo",
6
+ "in_progress",
7
+ "done",
8
+ "cancelled",
9
+ ] as const;
10
+
11
+ /**
12
+ * Returns ordered status column keys for the Kanban board from `config.fields`.
13
+ *
14
+ * @param config - Loaded yarlo project configuration
15
+ * @returns Option list for the `status` single_select field, or sensible defaults
16
+ */
17
+ export function getStatusColumnOptions(config: YarloConfig): string[] {
18
+ const def = config.fields.find((f) => f.name === "status");
19
+ if (
20
+ def?.type === "single_select" &&
21
+ Array.isArray(def.options) &&
22
+ def.options.length > 0
23
+ ) {
24
+ return [...def.options];
25
+ }
26
+
27
+ return [...DEFAULT_STATUS_OPTIONS];
28
+ }