Skip to content

Commit 39db878

Browse files
committed
feat(web): split board settings and add metric badges
1 parent acc301c commit 39db878

27 files changed

Lines changed: 1054 additions & 469 deletions

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"rehype-highlight": "^7.0.2",
4545
"remark-gfm": "^4.0.1",
4646
"shadcn": "^4.2.0",
47+
"sonner": "^2.0.7",
4748
"tailwind-merge": "^3.5.0",
4849
"tw-animate-css": "^1.4.0",
4950
"tw-shimmer": "^0.4.10",

apps/web/server/routes.ts

Lines changed: 84 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "./boardRepo";
4141
import { createBoardSSEResponse, createPublicBoardSSEResponse } from "./boardSSE";
4242
import { cliVersionMiddleware } from "./cliVersion";
43+
import type { D1 } from "./db";
4344
import { addAgentEmail, getGithubToken, removeAgentEmail, syncGpgKey } from "./githubService";
4445
import { getArmoredPrivateKey, getRootKeyInfo, getRootPublicKey, getSubkeyIds } from "./gpgKeyRepo";
4546
import { createLogger } from "./logger";
@@ -257,45 +258,8 @@ api.get("/api/share/:slug/badge.svg", async (c) => {
257258
const board = await getBoardBySlug(c.env.DB, c.req.param("slug"));
258259
if (!board) throw new HTTPException(404, { message: "Board not found" });
259260

260-
const counts = { todo: 0, in_progress: 0, in_review: 0, done: 0 };
261-
for (const t of board.tasks) {
262-
if (t.status === "todo") counts.todo++;
263-
else if (t.status === "in_progress") counts.in_progress++;
264-
else if (t.status === "in_review") counts.in_review++;
265-
else if (t.status === "done") counts.done++;
266-
}
267-
268-
function escapeXml(s: string): string {
269-
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
270-
}
271-
272-
const label = escapeXml(board.name);
273-
const value = escapeXml(`${counts.todo} todo · ${counts.in_progress} active · ${counts.in_review} review · ${counts.done} done`);
274-
275-
const labelWidth = Math.max(label.length * 7 + 16, 60);
276-
const valueWidth = value.length * 6.5 + 16;
277-
const totalWidth = labelWidth + valueWidth;
278-
279-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20">
280-
<linearGradient id="s" x2="0" y2="100%">
281-
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
282-
<stop offset="1" stop-opacity=".1"/>
283-
</linearGradient>
284-
<clipPath id="r">
285-
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
286-
</clipPath>
287-
<g clip-path="url(#r)">
288-
<rect width="${labelWidth}" height="20" fill="#1e293b"/>
289-
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="#0891b2"/>
290-
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
291-
</g>
292-
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
293-
<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
294-
<text x="${labelWidth / 2}" y="14">${label}</text>
295-
<text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${value}</text>
296-
<text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
297-
</g>
298-
</svg>`;
261+
const badge = await getShareBadge(c.env.DB, board.id, board.owner_id, c.req.query("type"));
262+
const svg = renderMetricBadge("AK", badge.value);
299263

300264
return new Response(svg, {
301265
headers: {
@@ -1097,6 +1061,87 @@ function escapeHtml(s: string): string {
10971061
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10981062
}
10991063

1064+
type ShareBadgeType = "agents" | "tasks" | "tokens";
1065+
1066+
const SHARE_BADGE_TYPES = new Set<ShareBadgeType>(["agents", "tasks", "tokens"]);
1067+
1068+
async function getShareBadge(db: D1, boardId: string, ownerId: string, type: string | undefined): Promise<{ value: string }> {
1069+
const badgeType = SHARE_BADGE_TYPES.has(type as ShareBadgeType) ? (type as ShareBadgeType) : "agents";
1070+
if (badgeType === "agents") return { value: `${await countOwnerAgents(db, ownerId)} agents` };
1071+
if (badgeType === "tasks") return { value: `${await countDoneTasks(db, boardId)} tasks` };
1072+
return { value: `${formatMetric(await sumOwnerTokens(db, ownerId))} tokens` };
1073+
}
1074+
1075+
async function countOwnerAgents(db: D1, ownerId: string): Promise<number> {
1076+
const row = await db
1077+
.prepare("SELECT COUNT(*) as count FROM agents WHERE owner_id = ? AND COALESCE(version, 'latest') = 'latest'")
1078+
.bind(ownerId)
1079+
.first<{ count: number }>();
1080+
return row?.count ?? 0;
1081+
}
1082+
1083+
async function countDoneTasks(db: D1, boardId: string): Promise<number> {
1084+
const row = await db.prepare("SELECT COUNT(*) as count FROM tasks WHERE board_id = ? AND status = 'done'").bind(boardId).first<{ count: number }>();
1085+
return row?.count ?? 0;
1086+
}
1087+
1088+
async function sumOwnerTokens(db: D1, ownerId: string): Promise<number> {
1089+
const row = await db
1090+
.prepare(`
1091+
SELECT COALESCE(SUM(s.input_tokens + s.output_tokens + s.cache_read_tokens + s.cache_creation_tokens), 0) as tokens
1092+
FROM agent_sessions s
1093+
JOIN agents a ON a.id = s.agent_id
1094+
WHERE a.owner_id = ?
1095+
`)
1096+
.bind(ownerId)
1097+
.first<{ tokens: number }>();
1098+
return row?.tokens ?? 0;
1099+
}
1100+
1101+
function formatMetric(value: number): string {
1102+
if (value >= 1_000_000_000) return `${trimMetric(value / 1_000_000_000)}B`;
1103+
if (value >= 1_000_000) return `${trimMetric(value / 1_000_000)}M`;
1104+
if (value >= 1_000) return `${trimMetric(value / 1_000)}K`;
1105+
return String(value);
1106+
}
1107+
1108+
function trimMetric(value: number): string {
1109+
return value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "");
1110+
}
1111+
1112+
function renderMetricBadge(label: string, value: string): string {
1113+
const safeLabel = escapeXml(label);
1114+
const safeValue = escapeXml(value);
1115+
const labelWidth = Math.max(safeLabel.length * 7 + 16, 32);
1116+
const valueWidth = Math.max(safeValue.length * 6.5 + 16, 64);
1117+
const totalWidth = labelWidth + valueWidth;
1118+
1119+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20">
1120+
<linearGradient id="s" x2="0" y2="100%">
1121+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
1122+
<stop offset="1" stop-opacity=".1"/>
1123+
</linearGradient>
1124+
<clipPath id="r">
1125+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
1126+
</clipPath>
1127+
<g clip-path="url(#r)">
1128+
<rect width="${labelWidth}" height="20" fill="#18181b"/>
1129+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="#0891b2"/>
1130+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
1131+
</g>
1132+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
1133+
<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${safeLabel}</text>
1134+
<text x="${labelWidth / 2}" y="14">${safeLabel}</text>
1135+
<text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${safeValue}</text>
1136+
<text x="${labelWidth + valueWidth / 2}" y="14">${safeValue}</text>
1137+
</g>
1138+
</svg>`;
1139+
}
1140+
1141+
function escapeXml(s: string): string {
1142+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1143+
}
1144+
11001145
function agentEmail(username: string): string {
11011146
return `${username}@mails.agent-kanban.dev`;
11021147
}

apps/web/src/App.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import { AdminDashboardPage } from "./routes/admin/AdminDashboardPage";
1111
import { AdminLayout } from "./routes/admin/AdminLayout";
1212
import { AdminMachinesPage } from "./routes/admin/AdminMachinesPage";
1313
import { AdminUsersPage } from "./routes/admin/AdminUsersPage";
14+
import { BoardLabelsPage } from "./routes/BoardLabelsPage";
1415
import { BoardPage } from "./routes/BoardPage";
1516
import { BoardRedirect } from "./routes/BoardRedirect";
17+
import { BoardSettingsPage } from "./routes/BoardSettingsPage";
1618
import { LandingPage } from "./routes/LandingPage";
1719
import { MachineDetailPage } from "./routes/MachineDetailPage";
1820
import { MachinesPage } from "./routes/MachinesPage";
@@ -81,6 +83,22 @@ export function App() {
8183
</ProtectedRoute>
8284
}
8385
/>
86+
<Route
87+
path="/boards/:boardId/settings"
88+
element={
89+
<ProtectedRoute>
90+
<BoardSettingsPage />
91+
</ProtectedRoute>
92+
}
93+
/>
94+
<Route
95+
path="/boards/:boardId/labels"
96+
element={
97+
<ProtectedRoute>
98+
<BoardLabelsPage />
99+
</ProtectedRoute>
100+
}
101+
/>
84102
<Route
85103
path="/machines"
86104
element={
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { BoardLabel } from "@agent-kanban/shared";
2+
import { Shuffle } from "lucide-react";
3+
import { useEffect, useState } from "react";
4+
import { LabelChip } from "./LabelChip";
5+
import { Button } from "./ui/button";
6+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
7+
import { Input } from "./ui/input";
8+
import { Label } from "./ui/label";
9+
10+
export type LabelFormMode = "create" | "edit";
11+
12+
const LABEL_COLORS = ["#22D3EE", "#22C55E", "#EAB308", "#EF4444", "#A78BFA", "#F97316", "#38BDF8", "#F472B6"];
13+
14+
function randomLabelColor() {
15+
return LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
16+
}
17+
18+
interface LabelFormDialogProps {
19+
mode: LabelFormMode;
20+
open: boolean;
21+
initialLabel: BoardLabel | null;
22+
pending: boolean;
23+
error: string | null;
24+
onClose: () => void;
25+
onSubmit: (input: BoardLabel) => void;
26+
}
27+
28+
export function LabelFormDialog(props: LabelFormDialogProps) {
29+
const { mode, open, initialLabel, pending, error, onClose, onSubmit } = props;
30+
const [name, setName] = useState("");
31+
const [color, setColor] = useState("#71717A");
32+
const [description, setDescription] = useState("");
33+
34+
useEffect(() => {
35+
setName(initialLabel?.name ?? "");
36+
setColor(initialLabel?.color ?? "#71717A");
37+
setDescription(initialLabel?.description ?? "");
38+
}, [initialLabel, open]);
39+
40+
function submit() {
41+
onSubmit({ name: name.trim(), color, description: description.trim() });
42+
}
43+
44+
return (
45+
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
46+
<DialogContent className="sm:max-w-md">
47+
<DialogHeader>
48+
<DialogTitle>{mode === "create" ? "Add label" : "Edit label"}</DialogTitle>
49+
<DialogDescription className="sr-only">Configure board label name, color, and description</DialogDescription>
50+
</DialogHeader>
51+
52+
<LabelFormFields name={name} color={color} description={description} onName={setName} onColor={setColor} onDescription={setDescription} />
53+
{error && <p className="text-xs text-error">{error}</p>}
54+
<div className="flex">
55+
<LabelChip name={name.trim() || "label-name"} color={color} description={description.trim()} />
56+
</div>
57+
58+
<DialogFooter className="flex-col sm:flex-row">
59+
<Button variant="outline" onClick={onClose}>
60+
Cancel
61+
</Button>
62+
<Button onClick={submit} disabled={pending || !name.trim()}>
63+
{pending ? "Saving..." : mode === "create" ? "Add label" : "Save"}
64+
</Button>
65+
</DialogFooter>
66+
</DialogContent>
67+
</Dialog>
68+
);
69+
}
70+
71+
interface LabelFormFieldsProps {
72+
name: string;
73+
color: string;
74+
description: string;
75+
onName: (value: string) => void;
76+
onColor: (value: string) => void;
77+
onDescription: (value: string) => void;
78+
}
79+
80+
function LabelFormFields(props: LabelFormFieldsProps) {
81+
const { name, color, description, onName, onColor, onDescription } = props;
82+
83+
return (
84+
<div className="space-y-3">
85+
<div className="space-y-1.5">
86+
<Label className="text-xs text-content-tertiary" htmlFor="label-name">
87+
Label name
88+
</Label>
89+
<Input id="label-name" value={name} onChange={(event) => onName(event.target.value)} />
90+
</div>
91+
92+
<div className="grid gap-3 sm:grid-cols-[auto_1fr]">
93+
<div className="space-y-1.5">
94+
<Label className="text-xs text-content-tertiary" htmlFor="label-color">
95+
Label color
96+
</Label>
97+
<div className="flex items-center gap-2">
98+
<Input
99+
id="label-color"
100+
type="color"
101+
value={color}
102+
onChange={(event) => onColor(event.target.value)}
103+
className="h-9 w-14 cursor-pointer p-1"
104+
/>
105+
<Button type="button" variant="outline" size="icon-sm" aria-label="Random color" onClick={() => onColor(randomLabelColor())}>
106+
<Shuffle className="size-3.5" />
107+
</Button>
108+
</div>
109+
</div>
110+
111+
<div className="space-y-1.5">
112+
<Label className="text-xs text-content-tertiary" htmlFor="label-description">
113+
Label description
114+
</Label>
115+
<Input id="label-description" value={description} onChange={(event) => onDescription(event.target.value)} />
116+
</div>
117+
</div>
118+
</div>
119+
);
120+
}
121+
122+
interface DeleteLabelDialogProps {
123+
labelName: string | null;
124+
pending: boolean;
125+
error: string | null;
126+
onClose: () => void;
127+
onConfirm: () => void;
128+
}
129+
130+
export function DeleteLabelDialog({ labelName, pending, error, onClose, onConfirm }: DeleteLabelDialogProps) {
131+
return (
132+
<Dialog open={!!labelName} onOpenChange={(open) => !open && onClose()}>
133+
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
134+
<DialogHeader>
135+
<DialogTitle>Delete label</DialogTitle>
136+
<DialogDescription>Delete {labelName ? `"${labelName}"` : "this label"} from this board and remove it from all tasks.</DialogDescription>
137+
</DialogHeader>
138+
{error && <p className="text-xs text-error">{error}</p>}
139+
<DialogFooter className="flex-col sm:flex-row">
140+
<Button variant="outline" onClick={onClose}>
141+
Cancel
142+
</Button>
143+
<Button variant="destructive" onClick={onConfirm} disabled={pending}>
144+
{pending ? "Deleting..." : "Delete"}
145+
</Button>
146+
</DialogFooter>
147+
</DialogContent>
148+
</Dialog>
149+
);
150+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useLocation, useNavigate } from "react-router-dom";
2+
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
3+
4+
interface BoardSettingsNavProps {
5+
boardId: string;
6+
}
7+
8+
const items = [
9+
{ path: "settings", label: "Settings" },
10+
{ path: "labels", label: "Labels" },
11+
];
12+
13+
export function BoardSettingsNav({ boardId }: BoardSettingsNavProps) {
14+
const location = useLocation();
15+
const navigate = useNavigate();
16+
const value = items.find((item) => location.pathname === `/boards/${boardId}/${item.path}`)?.path ?? "settings";
17+
18+
return (
19+
<Tabs value={value} onValueChange={(nextValue) => navigate(`/boards/${boardId}/${nextValue}`)}>
20+
<TabsList variant="line" aria-label="Board settings sections" className="border-b border-border">
21+
{items.map((item) => (
22+
<TabsTrigger key={item.path} value={item.path} aria-current={value === item.path ? "page" : undefined} className="px-3 text-xs">
23+
{item.label}
24+
</TabsTrigger>
25+
))}
26+
</TabsList>
27+
</Tabs>
28+
);
29+
}

0 commit comments

Comments
 (0)