@andypai/agent-kanban 0.1.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/LICENSE +21 -0
- package/README.md +306 -0
- package/package.json +80 -0
- package/src/__tests__/activity.test.ts +139 -0
- package/src/__tests__/api.test.ts +74 -0
- package/src/__tests__/commands/board.test.ts +51 -0
- package/src/__tests__/commands/bulk.test.ts +51 -0
- package/src/__tests__/commands/column.test.ts +78 -0
- package/src/__tests__/commands/task.test.ts +144 -0
- package/src/__tests__/db.test.ts +327 -0
- package/src/__tests__/id.test.ts +19 -0
- package/src/__tests__/index.test.ts +75 -0
- package/src/__tests__/metrics.test.ts +64 -0
- package/src/__tests__/output.test.ts +39 -0
- package/src/activity.ts +73 -0
- package/src/api.ts +209 -0
- package/src/commands/board.ts +29 -0
- package/src/commands/bulk.ts +19 -0
- package/src/commands/column.ts +60 -0
- package/src/commands/task.ts +117 -0
- package/src/config.ts +29 -0
- package/src/db.ts +587 -0
- package/src/errors.ts +32 -0
- package/src/fixtures.ts +128 -0
- package/src/id.ts +8 -0
- package/src/index.ts +413 -0
- package/src/metrics.ts +98 -0
- package/src/output.ts +105 -0
- package/src/providers/capabilities.ts +25 -0
- package/src/providers/errors.ts +16 -0
- package/src/providers/index.ts +24 -0
- package/src/providers/linear-cache.ts +385 -0
- package/src/providers/linear-client.ts +329 -0
- package/src/providers/linear.ts +305 -0
- package/src/providers/local.ts +135 -0
- package/src/providers/types.ts +65 -0
- package/src/server.ts +91 -0
- package/src/types.ts +123 -0
- package/ui/dist/assets/index-DEnUD0fq.css +1 -0
- package/ui/dist/assets/index-DMRjw1nI.js +40 -0
- package/ui/dist/index.html +13 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { listActivity } from '../activity.ts'
|
|
3
|
+
import { getConfigPath, loadConfig, saveConfig } from '../config.ts'
|
|
4
|
+
import {
|
|
5
|
+
addTask,
|
|
6
|
+
deleteTask,
|
|
7
|
+
getBoardView,
|
|
8
|
+
getDbPath,
|
|
9
|
+
getTask,
|
|
10
|
+
listColumns,
|
|
11
|
+
listTasks,
|
|
12
|
+
moveTask,
|
|
13
|
+
updateTask,
|
|
14
|
+
} from '../db.ts'
|
|
15
|
+
import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics.ts'
|
|
16
|
+
import type { BoardBootstrap, BoardConfig, Task } from '../types.ts'
|
|
17
|
+
import { LOCAL_CAPABILITIES } from './capabilities.ts'
|
|
18
|
+
import type {
|
|
19
|
+
CreateTaskInput,
|
|
20
|
+
KanbanProvider,
|
|
21
|
+
ProviderContext,
|
|
22
|
+
TaskListFilters,
|
|
23
|
+
UpdateTaskInput,
|
|
24
|
+
} from './types.ts'
|
|
25
|
+
|
|
26
|
+
function buildLocalConfig(
|
|
27
|
+
db: Database,
|
|
28
|
+
dbPath: string,
|
|
29
|
+
discoveredAssignees = getDiscoveredAssignees(db),
|
|
30
|
+
discoveredProjects = getDiscoveredProjects(db),
|
|
31
|
+
): BoardConfig {
|
|
32
|
+
return {
|
|
33
|
+
...loadConfig(dbPath),
|
|
34
|
+
provider: 'local',
|
|
35
|
+
discoveredAssignees,
|
|
36
|
+
discoveredProjects,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function enrichTask(task: Task): Task {
|
|
41
|
+
return {
|
|
42
|
+
...task,
|
|
43
|
+
providerId: task.id,
|
|
44
|
+
externalRef: task.id,
|
|
45
|
+
url: null,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class LocalProvider implements KanbanProvider {
|
|
50
|
+
readonly type = 'local' as const
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly db: Database,
|
|
54
|
+
private readonly dbPath = getDbPath(),
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
async getContext(): Promise<ProviderContext> {
|
|
58
|
+
return {
|
|
59
|
+
provider: this.type,
|
|
60
|
+
capabilities: LOCAL_CAPABILITIES,
|
|
61
|
+
team: null,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getBootstrap(): Promise<BoardBootstrap> {
|
|
66
|
+
const metrics = getBoardMetrics(this.db)
|
|
67
|
+
return {
|
|
68
|
+
provider: this.type,
|
|
69
|
+
capabilities: LOCAL_CAPABILITIES,
|
|
70
|
+
board: await this.getBoard(),
|
|
71
|
+
config: buildLocalConfig(this.db, this.dbPath, metrics.assignees, metrics.projects),
|
|
72
|
+
metrics,
|
|
73
|
+
activity: listActivity(this.db, { limit: 50 }),
|
|
74
|
+
team: null,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getBoard() {
|
|
79
|
+
const board = getBoardView(this.db)
|
|
80
|
+
return {
|
|
81
|
+
columns: board.columns.map((column) => ({
|
|
82
|
+
...column,
|
|
83
|
+
tasks: column.tasks.map(enrichTask),
|
|
84
|
+
})),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async listColumns() {
|
|
89
|
+
return listColumns(this.db)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async listTasks(filters: TaskListFilters = {}) {
|
|
93
|
+
return listTasks(this.db, filters).map(enrichTask)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getTask(idOrRef: string) {
|
|
97
|
+
return enrichTask(getTask(this.db, idOrRef))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async createTask(input: CreateTaskInput) {
|
|
101
|
+
return enrichTask(addTask(this.db, input.title, input))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async updateTask(idOrRef: string, input: UpdateTaskInput) {
|
|
105
|
+
return enrichTask(updateTask(this.db, idOrRef, input))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async moveTask(idOrRef: string, column: string) {
|
|
109
|
+
return enrichTask(moveTask(this.db, idOrRef, column))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async deleteTask(idOrRef: string) {
|
|
113
|
+
return enrichTask(deleteTask(this.db, idOrRef))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async getActivity(limit?: number, taskId?: string) {
|
|
117
|
+
return listActivity(this.db, { limit, taskId })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async getMetrics() {
|
|
121
|
+
return getBoardMetrics(this.db)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getConfig(): Promise<BoardConfig> {
|
|
125
|
+
return buildLocalConfig(this.db, this.dbPath)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async patchConfig(input: Partial<BoardConfig>) {
|
|
129
|
+
const config = loadConfig(this.dbPath)
|
|
130
|
+
if (input.members) config.members = input.members
|
|
131
|
+
if (input.projects) config.projects = input.projects
|
|
132
|
+
saveConfig(getConfigPath(this.dbPath), config)
|
|
133
|
+
return this.getConfig()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActivityEntry,
|
|
3
|
+
BoardBootstrap,
|
|
4
|
+
BoardConfig,
|
|
5
|
+
BoardMetrics,
|
|
6
|
+
BoardView,
|
|
7
|
+
Column,
|
|
8
|
+
Priority,
|
|
9
|
+
ProviderCapabilities,
|
|
10
|
+
ProviderTeamInfo,
|
|
11
|
+
Task,
|
|
12
|
+
} from '../types.ts'
|
|
13
|
+
|
|
14
|
+
export interface TaskListFilters {
|
|
15
|
+
column?: string
|
|
16
|
+
priority?: string
|
|
17
|
+
assignee?: string
|
|
18
|
+
project?: string
|
|
19
|
+
limit?: number
|
|
20
|
+
sort?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreateTaskInput {
|
|
24
|
+
title: string
|
|
25
|
+
description?: string
|
|
26
|
+
column?: string
|
|
27
|
+
priority?: Priority
|
|
28
|
+
assignee?: string
|
|
29
|
+
project?: string
|
|
30
|
+
metadata?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UpdateTaskInput {
|
|
34
|
+
title?: string
|
|
35
|
+
description?: string
|
|
36
|
+
priority?: Priority
|
|
37
|
+
assignee?: string
|
|
38
|
+
project?: string
|
|
39
|
+
metadata?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProviderContext {
|
|
43
|
+
provider: 'local' | 'linear'
|
|
44
|
+
capabilities: ProviderCapabilities
|
|
45
|
+
team: ProviderTeamInfo | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface KanbanProvider {
|
|
49
|
+
readonly type: 'local' | 'linear'
|
|
50
|
+
|
|
51
|
+
getContext(): Promise<ProviderContext>
|
|
52
|
+
getBootstrap(): Promise<BoardBootstrap>
|
|
53
|
+
getBoard(): Promise<BoardView>
|
|
54
|
+
listColumns(): Promise<Column[]>
|
|
55
|
+
listTasks(filters?: TaskListFilters): Promise<Task[]>
|
|
56
|
+
getTask(idOrRef: string): Promise<Task>
|
|
57
|
+
createTask(input: CreateTaskInput): Promise<Task>
|
|
58
|
+
updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task>
|
|
59
|
+
moveTask(idOrRef: string, column: string): Promise<Task>
|
|
60
|
+
deleteTask(idOrRef: string): Promise<Task>
|
|
61
|
+
getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]>
|
|
62
|
+
getMetrics(): Promise<BoardMetrics>
|
|
63
|
+
getConfig(): Promise<BoardConfig>
|
|
64
|
+
patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig>
|
|
65
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { handleRequest } from './api.ts'
|
|
4
|
+
import type { ServerWebSocket } from 'bun'
|
|
5
|
+
import type { KanbanProvider } from './providers/types.ts'
|
|
6
|
+
|
|
7
|
+
const wsClients = new Set<ServerWebSocket<unknown>>()
|
|
8
|
+
const CORS_HEADERS = {
|
|
9
|
+
'Access-Control-Allow-Origin': '*',
|
|
10
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
11
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function broadcast(data: unknown): void {
|
|
15
|
+
const msg = JSON.stringify(data)
|
|
16
|
+
for (const ws of wsClients) {
|
|
17
|
+
ws.send(msg)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function applyCorsHeaders(response: Response): void {
|
|
22
|
+
for (const [header, value] of Object.entries(CORS_HEADERS)) {
|
|
23
|
+
response.headers.set(header, value)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function startServer(provider: KanbanProvider, port: number): void {
|
|
28
|
+
const distDir = join(import.meta.dir, '..', 'ui', 'dist')
|
|
29
|
+
const hasStatic = existsSync(distDir)
|
|
30
|
+
|
|
31
|
+
Bun.serve({
|
|
32
|
+
port,
|
|
33
|
+
websocket: {
|
|
34
|
+
open(ws) {
|
|
35
|
+
wsClients.add(ws)
|
|
36
|
+
},
|
|
37
|
+
close(ws) {
|
|
38
|
+
wsClients.delete(ws)
|
|
39
|
+
},
|
|
40
|
+
message() {
|
|
41
|
+
/* server-push only */
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
async fetch(req, server) {
|
|
45
|
+
const url = new URL(req.url)
|
|
46
|
+
|
|
47
|
+
// Handle OPTIONS preflight first (before /api routing)
|
|
48
|
+
if (req.method === 'OPTIONS') {
|
|
49
|
+
return new Response(null, { headers: CORS_HEADERS })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// WebSocket upgrade
|
|
53
|
+
if (url.pathname === '/ws') {
|
|
54
|
+
const upgraded = server.upgrade(req)
|
|
55
|
+
if (upgraded) return undefined as unknown as Response
|
|
56
|
+
return new Response('WebSocket upgrade failed', { status: 400 })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (url.pathname === '/api/health') {
|
|
60
|
+
const context = await provider.getContext()
|
|
61
|
+
return Response.json({
|
|
62
|
+
ok: true,
|
|
63
|
+
data: { status: 'running', wsClients: wsClients.size, provider: context.provider },
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (url.pathname.startsWith('/api/')) {
|
|
68
|
+
const result = await handleRequest(provider, req)
|
|
69
|
+
applyCorsHeaders(result.response)
|
|
70
|
+
if (result.mutated && result.response.ok) {
|
|
71
|
+
broadcast({ type: 'refresh' })
|
|
72
|
+
}
|
|
73
|
+
return result.response
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (hasStatic) {
|
|
77
|
+
const filePath = join(distDir, url.pathname === '/' ? 'index.html' : url.pathname)
|
|
78
|
+
const file = Bun.file(filePath)
|
|
79
|
+
if (await file.exists()) return new Response(file)
|
|
80
|
+
return new Response(Bun.file(join(distDir, 'index.html')))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return new Response('Dashboard not built. Run: cd ui && bun run build', {
|
|
84
|
+
status: 503,
|
|
85
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
console.info(`Dashboard running at http://localhost:${port}`)
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
|
2
|
+
|
|
3
|
+
export interface Column {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
position: number
|
|
7
|
+
color: string | null
|
|
8
|
+
created_at: string
|
|
9
|
+
updated_at: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Task {
|
|
13
|
+
id: string
|
|
14
|
+
providerId?: string
|
|
15
|
+
externalRef?: string | null
|
|
16
|
+
url?: string | null
|
|
17
|
+
title: string
|
|
18
|
+
description: string
|
|
19
|
+
column_id: string
|
|
20
|
+
position: number
|
|
21
|
+
priority: Priority
|
|
22
|
+
assignee: string
|
|
23
|
+
project: string
|
|
24
|
+
metadata: string
|
|
25
|
+
created_at: string
|
|
26
|
+
updated_at: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TaskWithColumn extends Task {
|
|
30
|
+
column_name: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BoardView {
|
|
34
|
+
columns: (Column & { tasks: Task[] })[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CliResult<T = unknown> {
|
|
38
|
+
ok: true
|
|
39
|
+
data: T
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CliError {
|
|
43
|
+
ok: false
|
|
44
|
+
error: { code: string; message: string }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CliOutput<T = unknown> = CliResult<T> | CliError
|
|
48
|
+
|
|
49
|
+
export type ActivityAction =
|
|
50
|
+
| 'created'
|
|
51
|
+
| 'moved'
|
|
52
|
+
| 'updated'
|
|
53
|
+
| 'deleted'
|
|
54
|
+
| 'assigned'
|
|
55
|
+
| 'prioritized'
|
|
56
|
+
|
|
57
|
+
export interface ActivityEntry {
|
|
58
|
+
id: string
|
|
59
|
+
task_id: string
|
|
60
|
+
action: ActivityAction
|
|
61
|
+
field_changed: string | null
|
|
62
|
+
old_value: string | null
|
|
63
|
+
new_value: string | null
|
|
64
|
+
timestamp: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ColumnTimeEntry {
|
|
68
|
+
id: string
|
|
69
|
+
task_id: string
|
|
70
|
+
column_id: string
|
|
71
|
+
entered_at: string
|
|
72
|
+
exited_at: string | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BoardConfig {
|
|
76
|
+
members: { name: string; role: 'human' | 'agent' }[]
|
|
77
|
+
projects: string[]
|
|
78
|
+
provider?: 'local' | 'linear'
|
|
79
|
+
discoveredAssignees?: string[]
|
|
80
|
+
discoveredProjects?: string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BoardMetrics {
|
|
84
|
+
tasksByColumn: { column_name: string; count: number }[]
|
|
85
|
+
tasksByPriority: { priority: string; count: number }[]
|
|
86
|
+
totalTasks: number
|
|
87
|
+
completedTasks: number
|
|
88
|
+
avgCompletionHours: number | null
|
|
89
|
+
recentActivity: ActivityEntry[]
|
|
90
|
+
tasksCreatedThisWeek: number
|
|
91
|
+
inProgressCount: number
|
|
92
|
+
completionPercent: number
|
|
93
|
+
assignees: string[]
|
|
94
|
+
projects: string[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ProviderCapabilities {
|
|
98
|
+
taskCreate: boolean
|
|
99
|
+
taskUpdate: boolean
|
|
100
|
+
taskMove: boolean
|
|
101
|
+
taskDelete: boolean
|
|
102
|
+
activity: boolean
|
|
103
|
+
metrics: boolean
|
|
104
|
+
columnCrud: boolean
|
|
105
|
+
bulk: boolean
|
|
106
|
+
configEdit: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ProviderTeamInfo {
|
|
110
|
+
id: string
|
|
111
|
+
key: string
|
|
112
|
+
name: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface BoardBootstrap {
|
|
116
|
+
provider: 'local' | 'linear'
|
|
117
|
+
capabilities: ProviderCapabilities
|
|
118
|
+
board: BoardView
|
|
119
|
+
config: BoardConfig
|
|
120
|
+
metrics: BoardMetrics | null
|
|
121
|
+
activity: ActivityEntry[]
|
|
122
|
+
team: ProviderTeamInfo | null
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--bg-primary: #0f1117;--bg-secondary: #1a1d27;--bg-card: #1e2130;--bg-hover: #252838;--border: #2a2d3a;--border-light: #353849;--text-primary: rgba(255, 255, 255, .92);--text-secondary: #9ca3af;--text-muted: #6b7280;--accent: #6366f1;--accent-hover: #818cf8;--priority-urgent: #ef4444;--priority-high: #f97316;--priority-medium: #eab308;--priority-low: #22c55e;--col-recurring: #6b7280;--col-backlog: #f59e0b;--col-in-progress: #3b82f6;--col-review: #8b5cf6;--col-done: #22c55e;--section-radius: 10px}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);min-height:100vh}#root{width:100%;max-width:1800px;margin:0 auto;padding:16px 24px}.header{padding:12px 0 16px;border-bottom:1px solid var(--border);margin-bottom:16px}.headerTop{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.header h1{font-size:20px;font-weight:700;letter-spacing:-.02em}.header h1 span{color:var(--accent)}.newTaskBtn{background:var(--accent);color:#fff;border:none;padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}.newTaskBtn:hover{background:var(--accent-hover)}.statsBar{display:flex;gap:12px;margin-bottom:14px}.statCard{background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 16px;display:flex;align-items:baseline;gap:8px}.statValue{font-size:20px;font-weight:700;color:var(--accent)}.statLabel{font-size:12px;color:var(--text-muted)}.filterBar{display:flex;align-items:center;justify-content:space-between;gap:12px}.filterGroup{display:flex;gap:6px;align-items:center}.filterBtn{background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-secondary);padding:5px 14px;border-radius:20px;font-size:12px;cursor:pointer;transition:all .15s}.filterBtn:hover{border-color:var(--border-light);color:var(--text-primary)}.filterBtn.active{background:var(--accent);border-color:var(--accent);color:#fff}.projectDropdown{background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-secondary);padding:5px 28px 5px 14px;border-radius:20px;font-size:12px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.projectDropdown:focus{outline:none;border-color:var(--accent)}.board{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;min-height:calc(100vh - 220px)}.column{flex:1;min-width:240px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--section-radius);display:flex;flex-direction:column}.columnHeader{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}.columnDot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.columnName{font-size:13px;font-weight:600;text-transform:capitalize;flex:1}.columnCount{background:var(--border);color:var(--text-secondary);padding:2px 8px;border-radius:10px;font-size:11px;min-width:22px;text-align:center}.columnAddBtn{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;line-height:1;padding:0 2px;transition:color .15s}.columnAddBtn:hover{color:var(--accent)}.columnBody{padding:8px;display:flex;flex-direction:column;gap:6px;min-height:60px;flex:1}.emptyColumn{display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px;flex:1}.taskCard{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 14px;cursor:pointer;transition:border-color .15s,background .15s}.taskCard:hover{border-color:var(--border-light);background:var(--bg-hover)}.taskCard.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.taskCardHeader{display:flex;align-items:flex-start;gap:8px;margin-bottom:6px}.taskTitle{font-size:13px;font-weight:500;line-height:1.4;flex:1}.taskDescription{font-size:12px;color:var(--text-muted);line-height:1.4;margin-bottom:10px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.taskFooter{display:flex;align-items:center;justify-content:space-between;gap:8px}.taskFooterLeft{display:flex;align-items:center;gap:8px;min-width:0}.assigneeAvatar{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#fff;flex-shrink:0}.assigneeName{font-size:12px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.projectTag{font-size:11px;padding:2px 8px;border-radius:4px;background:#6366f126;color:var(--accent);white-space:nowrap}.timestamp{font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0}.priorityDot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:4px}.priorityDot.urgent{background:var(--priority-urgent)}.priorityDot.high{background:var(--priority-high)}.priorityDot.medium{background:var(--priority-medium)}.priorityDot.low{background:var(--priority-low)}.taskDetailOverlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0006;z-index:9}.taskDetail{position:fixed;top:0;right:0;width:420px;height:100vh;background:var(--bg-secondary);border-left:1px solid var(--border);padding:24px;overflow-y:auto;z-index:10}.taskDetail .closeBtn{position:absolute;top:16px;right:16px;background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:4px 10px;border-radius:6px;font-size:14px}.taskDetail .closeBtn:hover{border-color:var(--accent);color:var(--text-primary)}.detailTitle{font-size:18px;font-weight:600;margin-bottom:20px;padding-right:40px;line-height:1.4}.detailField{margin-bottom:16px}.detailLabel{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px}.detailValue{font-size:14px}.detailActions{display:flex;gap:8px;margin-top:24px;padding-top:16px;border-top:1px solid var(--border)}.detailSelect{background:var(--bg-card);border:1px solid var(--border);color:var(--text-primary);padding:6px 12px;border-radius:6px;font-size:13px;flex:1}.detailSelect:focus{outline:none;border-color:var(--accent)}.deleteBtn{background:#ef44441a;border:1px solid rgba(239,68,68,.3);color:#ef4444;padding:6px 16px;border-radius:6px;font-size:13px;cursor:pointer;transition:all .15s}.deleteBtn:hover{background:#ef444433;border-color:#ef444480}.modalOverlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;display:flex;align-items:center;justify-content:center;z-index:20}.modal{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:28px;width:480px;max-width:90vw;max-height:90vh;overflow-y:auto}.modal h2{font-size:18px;font-weight:600;margin-bottom:20px}.formField{margin-bottom:16px}.formLabel{display:block;font-size:12px;color:var(--text-secondary);margin-bottom:6px;font-weight:500}.formInput{width:100%;background:var(--bg-card);border:1px solid var(--border);color:var(--text-primary);padding:8px 12px;border-radius:6px;font-size:14px;font-family:inherit}.formInput:focus{outline:none;border-color:var(--accent)}textarea.formInput{resize:vertical;min-height:80px}select.formInput{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:28px;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.formRow{display:grid;grid-template-columns:1fr 1fr;gap:12px}.modalActions{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}.btnSecondary{background:var(--bg-card);border:1px solid var(--border);color:var(--text-secondary);padding:8px 18px;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s}.btnSecondary:hover{border-color:var(--border-light);color:var(--text-primary)}.btnPrimary{background:var(--accent);border:none;color:#fff;padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}.btnPrimary:hover{background:var(--accent-hover)}.btnPrimary:disabled{opacity:.5;cursor:not-allowed}.appLayout{display:grid;grid-template-columns:1fr;gap:0}.loading{display:flex;align-items:center;justify-content:center;padding:40px;color:var(--text-secondary)}.errorBanner{background:#ef44441a;border:1px solid rgba(239,68,68,.3);color:#ef4444;padding:10px 16px;border-radius:8px;font-size:13px;margin-bottom:12px}.wsIndicator{width:8px;height:8px;border-radius:50%;display:inline-block}.wsIndicator.connected{background:var(--col-done)}.wsIndicator.disconnected{background:var(--text-muted)}
|