Skip to content

Commit f930da2

Browse files
committed
feat: add board labels for tasks
1 parent a61b68d commit f930da2

37 files changed

Lines changed: 701 additions & 241 deletions

DESIGN.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,12 @@ Color is rare and meaningful. The cyan accent is reserved for agent activity and
6161

6262
### Semantic Colors (both modes)
6363
- **Success:** #22C55E — task completed, positive status
64-
- **Warning:** #EAB308 — stale agent, medium priority
65-
- **Error:** #EF4444 — failures, urgent priority
64+
- **Warning:** #EAB308 — stale agent, warnings
65+
- **Error:** #EF4444 — failures, destructive actions
6666
- **Info:** same as accent (#22D3EE dark / #0891B2 light)
6767

68-
### Priority Colors
69-
- **Urgent:** #EF4444 (red)
70-
- **High:** #F97316 (orange)
71-
- **Medium:** #EAB308 (amber)
72-
- **Low:** #71717A dark / #A1A1AA light (gray)
68+
### Label Colors
69+
Board labels use user-selected hex colors with low-opacity backgrounds and restrained borders.
7370

7471
### Dark Mode Strategy
7572
Dark is the default. Light mode uses the same hue relationships with adjusted lightness for readability. Accent shifts from #22D3EE (bright cyan) to #0891B2 (deeper cyan) to maintain WCAG AA contrast on light backgrounds.

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ spec:
206206
boardId: <board-id>
207207
title: "Fix login redirect bug"
208208
description: "Users are sent to / after login instead of the page they came from."
209-
priority: high
210209
labels: [bug, auth]
211210
repo: https://github.com/org/repo
212211
assignTo: <agent-id>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
ALTER TABLE boards ADD COLUMN labels TEXT NOT NULL DEFAULT '[]';
2+
3+
UPDATE boards
4+
SET labels = COALESCE(
5+
(
6+
SELECT json_group_array(json_object('name', label, 'color', '#71717A', 'description', ''))
7+
FROM (
8+
SELECT DISTINCT json_each.value AS label
9+
FROM tasks, json_each(tasks.labels)
10+
WHERE tasks.board_id = boards.id
11+
AND json_each.value IS NOT NULL
12+
AND json_each.value != ''
13+
ORDER BY label
14+
)
15+
),
16+
'[]'
17+
);
18+
19+
ALTER TABLE tasks DROP COLUMN priority;

apps/web/server/boardRepo.ts

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Board, BoardType, BoardWithTasks, Task } from "@agent-kanban/shared";
1+
import type { Board, BoardLabel, BoardType, BoardWithTasks, Task } from "@agent-kanban/shared";
2+
import { HTTPException } from "hono/http-exception";
23
import { customAlphabet } from "nanoid";
34
import { seedBuiltinAgents } from "./agentRepo";
45
import { type D1, newId, parseJsonFields } from "./db";
@@ -7,6 +8,32 @@ const nanoidSlug = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 10);
78

89
import { computeBlocked } from "./taskDeps";
910

11+
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
12+
13+
function parseBoard<T extends Board | BoardWithTasks>(board: T): T {
14+
return parseJsonFields(board, ["labels"] as (keyof T)[]);
15+
}
16+
17+
function normalizeLabel(label: BoardLabel): BoardLabel {
18+
if (!label || typeof label.name !== "string" || typeof label.color !== "string") {
19+
throw new HTTPException(400, { message: "Label name and color are required" });
20+
}
21+
const name = label.name.trim();
22+
const color = label.color.trim();
23+
if (!name) throw new HTTPException(400, { message: "Label name is required" });
24+
if (!HEX_COLOR.test(color)) throw new HTTPException(400, { message: "Label color must be a hex color like #22D3EE" });
25+
return { name, color, description: label.description?.trim() || "" };
26+
}
27+
28+
function normalizeLabels(labels: BoardLabel[]): BoardLabel[] {
29+
const seen = new Set<string>();
30+
return labels.map(normalizeLabel).map((label) => {
31+
if (seen.has(label.name)) throw new HTTPException(400, { message: `Duplicate label: ${label.name}` });
32+
seen.add(label.name);
33+
return label;
34+
});
35+
}
36+
1037
export async function createBoard(db: D1, ownerId: string, name: string, type: BoardType, description?: string): Promise<Board> {
1138
const id = newId();
1239
const now = new Date().toISOString();
@@ -17,16 +44,18 @@ export async function createBoard(db: D1, ownerId: string, name: string, type: B
1744

1845
await seedBuiltinAgents(db, ownerId);
1946

20-
return db.prepare("SELECT * FROM boards WHERE id = ?").bind(id).first<Board>() as Promise<Board>;
47+
const board = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(id).first<Board>();
48+
return parseBoard(board!);
2149
}
2250

2351
export async function listBoards(db: D1, ownerId: string): Promise<Board[]> {
2452
const result = await db.prepare("SELECT * FROM boards WHERE owner_id = ? ORDER BY created_at DESC").bind(ownerId).all<Board>();
25-
return result.results;
53+
return result.results.map(parseBoard);
2654
}
2755

2856
export async function getBoardByName(db: D1, ownerId: string, name: string): Promise<Board | null> {
29-
return db.prepare("SELECT * FROM boards WHERE owner_id = ? AND name = ?").bind(ownerId, name).first<Board>();
57+
const board = await db.prepare("SELECT * FROM boards WHERE owner_id = ? AND name = ?").bind(ownerId, name).first<Board>();
58+
return board ? parseBoard(board) : null;
3059
}
3160

3261
export async function getBoard(db: D1, boardId: string): Promise<BoardWithTasks | null> {
@@ -41,10 +70,7 @@ export async function getBoard(db: D1, boardId: string): Promise<BoardWithTasks
4170
WHERE t.board_id = ?
4271
ORDER BY
4372
CASE t.status WHEN 'todo' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'in_review' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
44-
CASE WHEN t.status = 'todo' THEN
45-
CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 2 END
46-
ELSE 0 END,
47-
CASE WHEN t.status = 'todo' THEN t.created_at END DESC,
73+
CASE WHEN t.status = 'todo' THEN t.position END ASC,
4874
CASE WHEN t.status != 'todo' THEN t.updated_at END DESC
4975
`)
5076
.bind(boardId)
@@ -58,17 +84,18 @@ export async function getBoard(db: D1, boardId: string): Promise<BoardWithTasks
5884
}
5985
}
6086

61-
return { ...board, tasks: tasks.results.map((t) => parseJsonFields(t, ["labels", "input"])) };
87+
return parseBoard({ ...board, tasks: tasks.results.map((t) => parseJsonFields(t, ["labels", "input"])) });
6288
}
6389

6490
export async function getDefaultBoard(db: D1, ownerId: string): Promise<Board | null> {
65-
return db.prepare("SELECT * FROM boards WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1").bind(ownerId).first<Board>();
91+
const board = await db.prepare("SELECT * FROM boards WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1").bind(ownerId).first<Board>();
92+
return board ? parseBoard(board) : null;
6693
}
6794

6895
export async function updateBoard(
6996
db: D1,
7097
boardId: string,
71-
updates: { name?: string; description?: string; visibility?: "private" | "public" },
98+
updates: { name?: string; description?: string; visibility?: "private" | "public"; labels?: BoardLabel[] },
7299
): Promise<Board | null> {
73100
const sets: string[] = [];
74101
const values: unknown[] = [];
@@ -91,7 +118,14 @@ export async function updateBoard(
91118
}
92119
}
93120
}
94-
if (sets.length === 0) return db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
121+
if (updates.labels !== undefined) {
122+
sets.push("labels = ?");
123+
values.push(JSON.stringify(normalizeLabels(updates.labels)));
124+
}
125+
if (sets.length === 0) {
126+
const board = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
127+
return board ? parseBoard(board) : null;
128+
}
95129

96130
sets.push("updated_at = ?");
97131
values.push(new Date().toISOString());
@@ -101,7 +135,68 @@ export async function updateBoard(
101135
.prepare(`UPDATE boards SET ${sets.join(", ")} WHERE id = ?`)
102136
.bind(...values)
103137
.run();
104-
return db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
138+
const board = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
139+
return board ? parseBoard(board) : null;
140+
}
141+
142+
export async function createBoardLabel(db: D1, boardId: string, input: BoardLabel): Promise<Board | null> {
143+
const board = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
144+
if (!board) return null;
145+
const labels = parseBoard(board).labels;
146+
const label = normalizeLabel(input);
147+
if (labels.some((existing) => existing.name === label.name)) throw new HTTPException(409, { message: `Label already exists: ${label.name}` });
148+
return updateBoard(db, boardId, { labels: [...labels, label] });
149+
}
150+
151+
export async function updateBoardLabel(db: D1, boardId: string, name: string, input: Partial<BoardLabel>): Promise<Board | null> {
152+
const board = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
153+
if (!board) return null;
154+
const labels = parseBoard(board).labels;
155+
const current = labels.find((label) => label.name === name);
156+
if (!current) throw new HTTPException(404, { message: `Label not found: ${name}` });
157+
const next = normalizeLabel({
158+
name: input.name ?? current.name,
159+
color: input.color ?? current.color,
160+
description: input.description ?? current.description,
161+
});
162+
if (next.name !== name && labels.some((label) => label.name === next.name)) {
163+
throw new HTTPException(409, { message: `Label already exists: ${next.name}` });
164+
}
165+
166+
const updatedLabels = labels.map((label) => (label.name === name ? next : label));
167+
await updateBoard(db, boardId, { labels: updatedLabels });
168+
169+
if (next.name !== name) {
170+
const tasks = await db
171+
.prepare("SELECT id, labels FROM tasks WHERE board_id = ? AND labels IS NOT NULL")
172+
.bind(boardId)
173+
.all<{ id: string; labels: string }>();
174+
const statements = tasks.results
175+
.map((task) => ({ id: task.id, labels: JSON.parse(task.labels) as string[] }))
176+
.filter((task) => task.labels.includes(name))
177+
.map((task) =>
178+
db
179+
.prepare("UPDATE tasks SET labels = ?, updated_at = ? WHERE id = ?")
180+
.bind(JSON.stringify(task.labels.map((label) => (label === name ? next.name : label))), new Date().toISOString(), task.id),
181+
);
182+
if (statements.length > 0) await db.batch(statements);
183+
}
184+
185+
const nextBoard = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
186+
return nextBoard ? parseBoard(nextBoard) : null;
187+
}
188+
189+
export async function deleteBoardLabel(db: D1, boardId: string, name: string): Promise<Board | null> {
190+
const board = await db.prepare("SELECT * FROM boards WHERE id = ?").bind(boardId).first<Board>();
191+
if (!board) return null;
192+
const labels = parseBoard(board).labels;
193+
if (!labels.some((label) => label.name === name)) throw new HTTPException(404, { message: `Label not found: ${name}` });
194+
const inUse = await db
195+
.prepare("SELECT 1 FROM tasks WHERE board_id = ? AND EXISTS (SELECT 1 FROM json_each(tasks.labels) WHERE json_each.value = ?) LIMIT 1")
196+
.bind(boardId, name)
197+
.first();
198+
if (inUse) throw new HTTPException(409, { message: `Label is in use: ${name}` });
199+
return updateBoard(db, boardId, { labels: labels.filter((label) => label.name !== name) });
105200
}
106201

107202
export async function getBoardBySlug(db: D1, slug: string): Promise<BoardWithTasks | null> {
@@ -116,16 +211,13 @@ export async function getBoardBySlug(db: D1, slug: string): Promise<BoardWithTas
116211
WHERE t.board_id = ?
117212
ORDER BY
118213
CASE t.status WHEN 'todo' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'in_review' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
119-
CASE WHEN t.status = 'todo' THEN
120-
CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 2 END
121-
ELSE 0 END,
122-
CASE WHEN t.status = 'todo' THEN t.created_at END DESC,
214+
CASE WHEN t.status = 'todo' THEN t.position END ASC,
123215
CASE WHEN t.status != 'todo' THEN t.updated_at END DESC
124216
`)
125217
.bind(board.id)
126218
.all<Task>();
127219

128-
return { ...board, tasks: tasks.results.map((t) => parseJsonFields(t, ["labels", "input"])) };
220+
return parseBoard({ ...board, tasks: tasks.results.map((t) => parseJsonFields(t, ["labels", "input"])) });
129221
}
130222

131223
export async function deleteBoard(db: D1, boardId: string): Promise<boolean> {

apps/web/server/routes.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,18 @@ import {
2626
import { closeSession, createSession, listSessions, reopenSession, updateSessionUsage } from "./agentSessionRepo";
2727
import { authMiddleware } from "./auth";
2828
import { createAuth } from "./betterAuth";
29-
import { createBoard, deleteBoard, getBoard, getBoardByName, getBoardBySlug, listBoards, updateBoard } from "./boardRepo";
29+
import {
30+
createBoard,
31+
createBoardLabel,
32+
deleteBoard,
33+
deleteBoardLabel,
34+
getBoard,
35+
getBoardByName,
36+
getBoardBySlug,
37+
listBoards,
38+
updateBoard,
39+
updateBoardLabel,
40+
} from "./boardRepo";
3041
import { createBoardSSEResponse, createPublicBoardSSEResponse } from "./boardSSE";
3142
import { cliVersionMiddleware } from "./cliVersion";
3243
import { addAgentEmail, getGithubToken, removeAgentEmail, syncGpgKey } from "./githubService";
@@ -230,7 +241,6 @@ api.get("/api/share/:slug", async (c) => {
230241
seq: t.seq,
231242
title: t.title,
232243
status: t.status,
233-
priority: t.priority,
234244
labels: t.labels,
235245
repository_name: t.repository_name,
236246
agent_name: t.agent_name,
@@ -960,12 +970,32 @@ api.get("/api/boards/:id", async (c) => {
960970
});
961971

962972
api.patch("/api/boards/:id", async (c) => {
963-
const body = await c.req.json<{ name?: string; description?: string; visibility?: "private" | "public" }>();
973+
const body = await c.req.json<{ name?: string; description?: string; visibility?: "private" | "public"; labels?: any[] }>();
964974
const board = await updateBoard(c.env.DB, c.req.param("id"), body);
965975
if (!board) throw new HTTPException(404, { message: "Board not found" });
966976
return c.json(board);
967977
});
968978

979+
api.post("/api/boards/:id/labels", async (c) => {
980+
const body = await c.req.json<{ name: string; color: string; description?: string }>();
981+
const board = await createBoardLabel(c.env.DB, c.req.param("id"), { name: body.name, color: body.color, description: body.description || "" });
982+
if (!board) throw new HTTPException(404, { message: "Board not found" });
983+
return c.json(board, 201);
984+
});
985+
986+
api.patch("/api/boards/:id/labels/:name", async (c) => {
987+
const body = await c.req.json<{ name?: string; color?: string; description?: string }>();
988+
const board = await updateBoardLabel(c.env.DB, c.req.param("id"), c.req.param("name"), body);
989+
if (!board) throw new HTTPException(404, { message: "Board not found" });
990+
return c.json(board);
991+
});
992+
993+
api.delete("/api/boards/:id/labels/:name", async (c) => {
994+
const board = await deleteBoardLabel(c.env.DB, c.req.param("id"), c.req.param("name"));
995+
if (!board) throw new HTTPException(404, { message: "Board not found" });
996+
return c.json(board);
997+
});
998+
969999
api.delete("/api/boards/:id", async (c) => {
9701000
const deleted = await deleteBoard(c.env.DB, c.req.param("id"));
9711001
if (!deleted) throw new HTTPException(404, { message: "Board not found" });

0 commit comments

Comments
 (0)