yarlo-plugin-board 0.1.2 → 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/dist/board-server.d.ts +21 -0
- package/dist/board-server.d.ts.map +1 -0
- package/dist/board-server.js +89 -0
- package/dist/board-server.js.map +1 -0
- package/dist/board-ui.html +384 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -4
- package/dist/index.js.map +1 -1
- package/dist/status-field.d.ts +9 -0
- package/dist/status-field.d.ts.map +1 -0
- package/dist/status-field.js +23 -0
- package/dist/status-field.js.map +1 -0
- package/package.json +5 -2
- package/source/board-server.ts +117 -0
- package/source/board-ui.html +384 -0
- package/source/index.ts +75 -4
- package/source/status-field.ts +28 -0
|
@@ -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":"AAAA,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAC1B,CAAC,CAAC;AAcH;;;;;GAKG;AACH,SAAS,kBAAkB,CAAC,IAAU;IACpC,MAAM,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;IACtB,OAAO;QACL,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,IAAI;QACxB,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;QAC5B,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,IAAI;QACxB,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;QAC5B,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;QAC5B,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;QAC5B,UAAU,EAAE,IAAI,CAAC,UAAU;KAC5B,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,GAAuB;IACpD,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IACvB,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAE/D,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QACvB,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,eAAe,CAAC,CAAC;QACvD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAClD,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QAChC,MAAM,OAAO,GAAG,sBAAsB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnD,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACrC,OAAO,CAAC,CAAC,IAAI,CAAC;YACZ,OAAO;YACP,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,kBAAkB,CAAC;SACrC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,KAAK,CAAC,gBAAgB,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QACtC,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,IAAa,CAAC;QAClB,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAC5B,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,GAAG,CAAC,CAAC;QACrD,CAAC;QAED,MAAM,MAAM,GAAG,eAAe,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC/C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YACpB,OAAO,CAAC,CAAC,IAAI,CACX,EAAE,KAAK,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,EAC/D,GAAG,CACJ,CAAC;QACJ,CAAC;QAED,MAAM,OAAO,GAAG,sBAAsB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnD,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1C,OAAO,CAAC,CAAC,IAAI,CACX;gBACE,KAAK,EAAE,mCAAmC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;aAC/D,EACD,GAAG,CACJ,CAAC;QACJ,CAAC;QAED,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE;gBACtC,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE;aACvC,CAAC,CAAC;YACH,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACpD,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,MAAM,GAAG,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YACvD,IACE,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC;gBACzB,GAAG,CAAC,QAAQ,CAAC,cAAc,CAAC;gBAC5B,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,EACzB,CAAC;gBACD,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,GAAG,CAAC,CAAC;YACrC,CAAC;YACD,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,GAAG,CAAC,CAAC;QACrC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -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/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"
|
|
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.
|
|
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(
|
|
10
|
-
|
|
11
|
-
|
|
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":"
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yarlo-plugin-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
+
"@hono/node-server": "^1.13.0",
|
|
15
16
|
"hono": "^4.7.0",
|
|
17
|
+
"zod": "^3.24.0",
|
|
16
18
|
"yarlo-types": "0.1.2"
|
|
17
19
|
},
|
|
18
20
|
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
19
22
|
"typescript": "^5.7.0"
|
|
20
23
|
},
|
|
21
24
|
"scripts": {
|
|
22
|
-
"build": "tsc",
|
|
25
|
+
"build": "tsc && cp source/board-ui.html dist/board-ui.html",
|
|
23
26
|
"dev": "tsc --watch"
|
|
24
27
|
}
|
|
25
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.
|
|
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(
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|