@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/package.json +80 -0
  4. package/src/__tests__/activity.test.ts +139 -0
  5. package/src/__tests__/api.test.ts +74 -0
  6. package/src/__tests__/commands/board.test.ts +51 -0
  7. package/src/__tests__/commands/bulk.test.ts +51 -0
  8. package/src/__tests__/commands/column.test.ts +78 -0
  9. package/src/__tests__/commands/task.test.ts +144 -0
  10. package/src/__tests__/db.test.ts +327 -0
  11. package/src/__tests__/id.test.ts +19 -0
  12. package/src/__tests__/index.test.ts +75 -0
  13. package/src/__tests__/metrics.test.ts +64 -0
  14. package/src/__tests__/output.test.ts +39 -0
  15. package/src/activity.ts +73 -0
  16. package/src/api.ts +209 -0
  17. package/src/commands/board.ts +29 -0
  18. package/src/commands/bulk.ts +19 -0
  19. package/src/commands/column.ts +60 -0
  20. package/src/commands/task.ts +117 -0
  21. package/src/config.ts +29 -0
  22. package/src/db.ts +587 -0
  23. package/src/errors.ts +32 -0
  24. package/src/fixtures.ts +128 -0
  25. package/src/id.ts +8 -0
  26. package/src/index.ts +413 -0
  27. package/src/metrics.ts +98 -0
  28. package/src/output.ts +105 -0
  29. package/src/providers/capabilities.ts +25 -0
  30. package/src/providers/errors.ts +16 -0
  31. package/src/providers/index.ts +24 -0
  32. package/src/providers/linear-cache.ts +385 -0
  33. package/src/providers/linear-client.ts +329 -0
  34. package/src/providers/linear.ts +305 -0
  35. package/src/providers/local.ts +135 -0
  36. package/src/providers/types.ts +65 -0
  37. package/src/server.ts +91 -0
  38. package/src/types.ts +123 -0
  39. package/ui/dist/assets/index-DEnUD0fq.css +1 -0
  40. package/ui/dist/assets/index-DMRjw1nI.js +40 -0
  41. package/ui/dist/index.html +13 -0
@@ -0,0 +1,128 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { Priority } from './types.ts'
3
+ import { addTask, moveTask, updateTask, listTasks } from './db.ts'
4
+
5
+ interface FixtureTask {
6
+ title: string
7
+ description?: string
8
+ column: string
9
+ priority: Priority
10
+ assignee?: string
11
+ project?: string
12
+ }
13
+
14
+ export const FIXTURE_TASKS: FixtureTask[] = [
15
+ // recurring
16
+ {
17
+ title: 'Daily standup notes',
18
+ description: 'Capture blockers and progress each morning',
19
+ column: 'recurring',
20
+ priority: 'medium',
21
+ assignee: 'Alex',
22
+ },
23
+ {
24
+ title: 'Weekly metrics review',
25
+ description: 'Review board throughput and cycle time every Friday',
26
+ column: 'recurring',
27
+ priority: 'low',
28
+ assignee: 'BuildBot',
29
+ project: 'Platform',
30
+ },
31
+
32
+ // backlog
33
+ {
34
+ title: 'Add search functionality',
35
+ description: 'Full-text search across task titles and descriptions',
36
+ column: 'backlog',
37
+ priority: 'high',
38
+ assignee: 'Alex',
39
+ project: 'Platform',
40
+ },
41
+ {
42
+ title: 'Write API docs',
43
+ description: 'Document all CLI commands with examples',
44
+ column: 'backlog',
45
+ priority: 'medium',
46
+ assignee: 'BuildBot',
47
+ },
48
+ {
49
+ title: 'Refactor error handling',
50
+ description: 'Consolidate error codes and improve user-facing messages',
51
+ column: 'backlog',
52
+ priority: 'low',
53
+ project: 'Platform',
54
+ },
55
+
56
+ // in-progress
57
+ {
58
+ title: 'Implement board export',
59
+ description: 'Export board state to JSON and CSV formats',
60
+ column: 'in-progress',
61
+ priority: 'high',
62
+ assignee: 'Alex',
63
+ project: 'Platform',
64
+ },
65
+ {
66
+ title: 'Fix column reorder bug',
67
+ description: 'Columns shift incorrectly when moving to position 0',
68
+ column: 'in-progress',
69
+ priority: 'high',
70
+ assignee: 'BuildBot',
71
+ },
72
+
73
+ // review
74
+ {
75
+ title: 'Add bulk delete command',
76
+ description: 'Allow deleting multiple tasks by ID or filter',
77
+ column: 'review',
78
+ priority: 'medium',
79
+ assignee: 'Alex',
80
+ project: 'Platform',
81
+ },
82
+
83
+ // done
84
+ {
85
+ title: 'Set up CI pipeline',
86
+ description: 'GitHub Actions for lint, typecheck, and test on every PR',
87
+ column: 'done',
88
+ priority: 'high',
89
+ assignee: 'BuildBot',
90
+ project: 'Platform',
91
+ },
92
+ {
93
+ title: 'Add activity logging',
94
+ description: 'Track task creates, moves, updates, and deletes',
95
+ column: 'done',
96
+ priority: 'medium',
97
+ assignee: 'Alex',
98
+ },
99
+ ]
100
+
101
+ export function seedFixtures(db: Database): { taskCount: number; movedCount: number } {
102
+ let movedCount = 0
103
+
104
+ for (const fixture of FIXTURE_TASKS) {
105
+ // Create all tasks in backlog first (addTask defaults to backlog)
106
+ const task = addTask(db, fixture.title, {
107
+ description: fixture.description,
108
+ priority: fixture.priority,
109
+ assignee: fixture.assignee,
110
+ project: fixture.project,
111
+ })
112
+
113
+ // Move to target column if not already in backlog
114
+ if (fixture.column !== 'backlog') {
115
+ moveTask(db, task.id, fixture.column)
116
+ movedCount++
117
+ }
118
+ }
119
+
120
+ // Escalate the bug to urgent — generates a realistic "prioritized" activity entry
121
+ const inProgress = listTasks(db, { column: 'in-progress' })
122
+ const bug = inProgress.find((t) => t.title === 'Fix column reorder bug')
123
+ if (bug) {
124
+ updateTask(db, bug.id, { priority: 'urgent' })
125
+ }
126
+
127
+ return { taskCount: FIXTURE_TASKS.length, movedCount }
128
+ }
package/src/id.ts ADDED
@@ -0,0 +1,8 @@
1
+ export function generateId(prefix: 't' | 'c' | 'a' | 'ct'): string {
2
+ const bytes = new Uint8Array(5)
3
+ crypto.getRandomValues(bytes)
4
+ let num = 0n
5
+ for (const b of bytes) num = (num << 8n) | BigInt(b)
6
+ const chars = num.toString(36).slice(0, 8).padStart(8, '0')
7
+ return `${prefix}_${chars}`
8
+ }
package/src/index.ts ADDED
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from 'node:util'
4
+ import { Database } from 'bun:sqlite'
5
+ import { KanbanError, ErrorCode } from './errors.ts'
6
+ import { formatOutput, error, success } from './output.ts'
7
+ import { openDb, getDbPath, initSchema, migrateSchema, seedDefaultColumns } from './db.ts'
8
+ import { boardInit, boardReset } from './commands/board.ts'
9
+ import {
10
+ columnAdd,
11
+ columnDelete,
12
+ columnList,
13
+ columnRename,
14
+ columnReorder,
15
+ } from './commands/column.ts'
16
+ import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk.ts'
17
+ import { getConfigPath, loadConfig, saveConfig } from './config.ts'
18
+ import type { CliOutput, Priority } from './types.ts'
19
+ import { createProvider } from './providers/index.ts'
20
+ import { unsupportedOperation } from './providers/errors.ts'
21
+
22
+ interface ParsedArgs {
23
+ values: Record<string, unknown>
24
+ positionals: string[]
25
+ }
26
+
27
+ function parseCliArgs(argv: string[]): ParsedArgs {
28
+ return parseArgs({
29
+ args: argv,
30
+ options: {
31
+ pretty: { type: 'boolean', default: false },
32
+ db: { type: 'string' },
33
+ help: { type: 'boolean', short: 'h', default: false },
34
+ d: { type: 'string' },
35
+ c: { type: 'string' },
36
+ p: { type: 'string' },
37
+ a: { type: 'string' },
38
+ m: { type: 'string' },
39
+ l: { type: 'string' },
40
+ sort: { type: 'string' },
41
+ title: { type: 'string' },
42
+ position: { type: 'string' },
43
+ color: { type: 'string' },
44
+ project: { type: 'string' },
45
+ role: { type: 'string' },
46
+ },
47
+ strict: false,
48
+ allowPositionals: true,
49
+ })
50
+ }
51
+
52
+ function requireLocalProvider(providerType: string, feature: string): void {
53
+ if (providerType === 'linear') unsupportedOperation(`${feature} is only available in local mode`)
54
+ }
55
+
56
+ async function routeTask(
57
+ provider: ReturnType<typeof createProvider>,
58
+ action: string | undefined,
59
+ positionals: string[],
60
+ values: Record<string, unknown>,
61
+ ): Promise<CliOutput> {
62
+ switch (action) {
63
+ case 'add': {
64
+ const title = positionals[2]
65
+ if (!title) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task title is required')
66
+ return success(
67
+ await provider.createTask({
68
+ title,
69
+ description: values.d as string | undefined,
70
+ column: values.c as string | undefined,
71
+ priority: values.p as Priority | undefined,
72
+ assignee: values.a as string | undefined,
73
+ project: values.project as string | undefined,
74
+ metadata: values.m as string | undefined,
75
+ }),
76
+ )
77
+ }
78
+ case 'list':
79
+ return success(
80
+ await provider.listTasks({
81
+ column: values.c as string | undefined,
82
+ priority: values.p as string | undefined,
83
+ assignee: values.a as string | undefined,
84
+ project: values.project as string | undefined,
85
+ limit: values.l ? parseInt(values.l as string, 10) : undefined,
86
+ sort: values.sort as string | undefined,
87
+ }),
88
+ )
89
+ case 'view': {
90
+ const id = positionals[2]
91
+ if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
92
+ return success(await provider.getTask(id))
93
+ }
94
+ case 'update': {
95
+ const id = positionals[2]
96
+ if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
97
+ return success(
98
+ await provider.updateTask(id, {
99
+ title: values.title as string | undefined,
100
+ description: values.d as string | undefined,
101
+ priority: values.p as Priority | undefined,
102
+ assignee: values.a as string | undefined,
103
+ project: values.project as string | undefined,
104
+ metadata: values.m as string | undefined,
105
+ }),
106
+ )
107
+ }
108
+ case 'delete': {
109
+ const id = positionals[2]
110
+ if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
111
+ return success(await provider.deleteTask(id))
112
+ }
113
+ case 'move': {
114
+ const id = positionals[2]
115
+ const column = positionals[3]
116
+ if (!id || !column) {
117
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task move <id> <column>')
118
+ }
119
+ return success(await provider.moveTask(id, column))
120
+ }
121
+ case 'assign': {
122
+ const id = positionals[2]
123
+ const assignee = positionals[3]
124
+ if (!id || assignee === undefined) {
125
+ throw new KanbanError(
126
+ ErrorCode.MISSING_ARGUMENT,
127
+ 'Usage: kanban task assign <id> <assignee>',
128
+ )
129
+ }
130
+ return success(await provider.updateTask(id, { assignee }))
131
+ }
132
+ case 'prioritize': {
133
+ const id = positionals[2]
134
+ const priority = positionals[3]
135
+ if (!id || !priority) {
136
+ throw new KanbanError(
137
+ ErrorCode.MISSING_ARGUMENT,
138
+ 'Usage: kanban task prioritize <id> <level>',
139
+ )
140
+ }
141
+ return success(await provider.updateTask(id, { priority: priority as Priority }))
142
+ }
143
+ default:
144
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown task command '${action}'`)
145
+ }
146
+ }
147
+
148
+ function routeColumn(
149
+ db: Database,
150
+ providerType: string,
151
+ action: string | undefined,
152
+ positionals: string[],
153
+ values: Record<string, unknown>,
154
+ ): CliOutput {
155
+ requireLocalProvider(providerType, 'Column commands')
156
+ switch (action) {
157
+ case 'add':
158
+ return columnAdd(db, {
159
+ name: positionals[2],
160
+ position: values.position as string | undefined,
161
+ color: values.color as string | undefined,
162
+ })
163
+ case 'list':
164
+ return columnList(db)
165
+ case 'rename':
166
+ return columnRename(db, { idOrName: positionals[2], newName: positionals[3] })
167
+ case 'reorder':
168
+ return columnReorder(db, { idOrName: positionals[2], position: positionals[3] })
169
+ case 'delete':
170
+ return columnDelete(db, { idOrName: positionals[2] })
171
+ default:
172
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown column command '${action}'`)
173
+ }
174
+ }
175
+
176
+ function routeBulk(
177
+ db: Database,
178
+ providerType: string,
179
+ action: string | undefined,
180
+ positionals: string[],
181
+ ): CliOutput {
182
+ requireLocalProvider(providerType, 'Bulk commands')
183
+ switch (action) {
184
+ case 'move-all':
185
+ return bulkMoveAllCmd(db, { from: positionals[2], to: positionals[3] })
186
+ case 'clear-done':
187
+ return bulkClearDoneCmd(db)
188
+ default:
189
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown bulk command '${action}'`)
190
+ }
191
+ }
192
+
193
+ async function routeConfig(
194
+ provider: ReturnType<typeof createProvider>,
195
+ dbPath: string,
196
+ action: string | undefined,
197
+ positionals: string[],
198
+ values: Record<string, unknown>,
199
+ ): Promise<CliOutput> {
200
+ if (provider.type === 'linear') {
201
+ if (action === 'show' || action === undefined) {
202
+ return success(await provider.getConfig())
203
+ }
204
+ unsupportedOperation('Config mutation is only available in local mode')
205
+ }
206
+
207
+ const configPath = getConfigPath(dbPath)
208
+ const config = loadConfig(dbPath)
209
+
210
+ switch (action) {
211
+ case 'show':
212
+ case undefined:
213
+ return success(await provider.getConfig())
214
+ case 'set-member': {
215
+ const name = positionals[2]
216
+ if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Member name is required')
217
+ const role =
218
+ (values.role as string | undefined) === 'agent' ? ('agent' as const) : ('human' as const)
219
+ const existing = config.members.findIndex((member) => member.name === name)
220
+ if (existing >= 0) {
221
+ config.members[existing] = { name, role }
222
+ } else {
223
+ config.members.push({ name, role })
224
+ }
225
+ saveConfig(configPath, config)
226
+ return success({ message: `Member '${name}' set as ${role}` })
227
+ }
228
+ case 'remove-member': {
229
+ const name = positionals[2]
230
+ if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Member name is required')
231
+ config.members = config.members.filter((member) => member.name !== name)
232
+ saveConfig(configPath, config)
233
+ return success({ message: `Member '${name}' removed` })
234
+ }
235
+ case 'add-project': {
236
+ const name = positionals[2]
237
+ if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Project name is required')
238
+ if (!config.projects.includes(name)) {
239
+ config.projects.push(name)
240
+ saveConfig(configPath, config)
241
+ }
242
+ return success({ message: `Project '${name}' added` })
243
+ }
244
+ case 'remove-project': {
245
+ const name = positionals[2]
246
+ if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Project name is required')
247
+ config.projects = config.projects.filter((project) => project !== name)
248
+ saveConfig(configPath, config)
249
+ return success({ message: `Project '${name}' removed` })
250
+ }
251
+ default:
252
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown config command '${action}'`)
253
+ }
254
+ }
255
+
256
+ async function routeBoard(
257
+ db: Database,
258
+ provider: ReturnType<typeof createProvider>,
259
+ action: string | undefined,
260
+ ): Promise<CliOutput> {
261
+ switch (action) {
262
+ case 'init':
263
+ requireLocalProvider(provider.type, 'Board initialization')
264
+ return boardInit(db)
265
+ case 'view':
266
+ case undefined:
267
+ if (provider.type === 'local') {
268
+ initSchema(db)
269
+ seedDefaultColumns(db)
270
+ }
271
+ return success(await provider.getBoard())
272
+ case 'reset':
273
+ requireLocalProvider(provider.type, 'Board reset')
274
+ return boardReset(db)
275
+ default:
276
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown board command '${action}'`)
277
+ }
278
+ }
279
+
280
+ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: number }> {
281
+ const { values, positionals } = parseCliArgs(argv)
282
+ if (values.help) {
283
+ return { output: { ok: true, data: { message: HELP_TEXT } }, exitCode: 0 }
284
+ }
285
+
286
+ const dbPath = (values.db as string | undefined) ?? getDbPath()
287
+ const db = openDb(dbPath)
288
+ migrateSchema(db)
289
+
290
+ try {
291
+ const provider = createProvider(db, dbPath)
292
+ const group = positionals[0]
293
+ const action = positionals[1]
294
+
295
+ if (!group) {
296
+ return { output: await routeBoard(db, provider, undefined), exitCode: 0 }
297
+ }
298
+
299
+ let output: CliOutput
300
+ switch (group) {
301
+ case 'board':
302
+ output = await routeBoard(db, provider, action)
303
+ break
304
+ case 'task':
305
+ output = await routeTask(provider, action, positionals, values)
306
+ break
307
+ case 'column':
308
+ output = routeColumn(db, provider.type, action, positionals, values)
309
+ break
310
+ case 'bulk':
311
+ output = routeBulk(db, provider.type, action, positionals)
312
+ break
313
+ case 'config':
314
+ output = await routeConfig(provider, dbPath, action, positionals, values)
315
+ break
316
+ default:
317
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown command group '${group}'`)
318
+ }
319
+
320
+ return { output, exitCode: 0 }
321
+ } finally {
322
+ db.close()
323
+ }
324
+ }
325
+
326
+ const HELP_TEXT = `kanban - Agent-friendly kanban board CLI
327
+
328
+ Usage: kanban [command] [options]
329
+
330
+ Commands:
331
+ board init Initialize a new board
332
+ board view View full board (default)
333
+ board reset Reset board to defaults
334
+
335
+ task add <title> Add a task [-d desc] [-c column] [-p priority] [-a assignee] [--project name] [-m json]
336
+ task list List tasks [-c column] [-p priority] [-a assignee] [--project name] [-l limit] [--sort field]
337
+ task view <id> View task details
338
+ task update <id> Update task [--title] [-d] [-p] [-a] [--project name] [-m]
339
+ task delete <id> Delete a task
340
+ task move <id> <column> Move task to column
341
+ task assign <id> <user> Assign task
342
+ task prioritize <id> <lvl> Set priority
343
+
344
+ column add <name> Add column [--position n] [--color hex]
345
+ column list List columns
346
+ column rename <id> <name> Rename column
347
+ column reorder <id> <pos> Reorder column
348
+ column delete <id> Delete empty column
349
+
350
+ bulk move-all <from> <to> Move all tasks between columns
351
+ bulk clear-done Delete all tasks in 'done'
352
+
353
+ config show Show board config
354
+ config set-member <name> Add/update member [--role human|agent]
355
+ config remove-member <name> Remove member
356
+ config add-project <name> Add project
357
+ config remove-project <name> Remove project
358
+
359
+ serve Start web dashboard [--port 3000]
360
+
361
+ Options:
362
+ --pretty Human-readable output (default: JSON)
363
+ --db <path> Database path (default: local ./.kanban if present, else ~/.kanban if present, else create ./.kanban)
364
+ --project <n> Filter/set project
365
+ -h, --help Show this help`
366
+
367
+ if (import.meta.main) {
368
+ const argv = process.argv.slice(2)
369
+
370
+ if (argv[0] === 'serve') {
371
+ const portIdx = argv.indexOf('--port')
372
+ const port =
373
+ portIdx !== -1
374
+ ? parseInt(argv[portIdx + 1]!, 10)
375
+ : parseInt(process.env['PORT'] || '3000', 10)
376
+
377
+ const { values } = parseArgs({
378
+ args: argv,
379
+ options: { db: { type: 'string' }, port: { type: 'string' } },
380
+ strict: false,
381
+ allowPositionals: true,
382
+ })
383
+
384
+ const dbPath = (values.db as string | undefined) ?? getDbPath()
385
+ const db = openDb(dbPath)
386
+ migrateSchema(db)
387
+ const provider = createProvider(db, dbPath)
388
+ const { startServer } = await import('./server.ts')
389
+ startServer(provider, port)
390
+ } else {
391
+ let exitCode = 0
392
+ const pretty = argv.includes('--pretty')
393
+
394
+ try {
395
+ const result = await run(argv)
396
+ exitCode = result.exitCode
397
+ console.info(formatOutput(result.output, pretty))
398
+ } catch (err) {
399
+ if (err instanceof KanbanError) {
400
+ exitCode = 1
401
+ console.error(formatOutput(error(err.code, err.message), pretty))
402
+ } else {
403
+ exitCode = 2
404
+ const msg = err instanceof Error ? err.message : String(err)
405
+ console.error(formatOutput(error(ErrorCode.INTERNAL_ERROR, msg), pretty))
406
+ }
407
+ }
408
+
409
+ process.exit(exitCode)
410
+ }
411
+ }
412
+
413
+ export { run }
package/src/metrics.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import type { ActivityEntry, BoardMetrics } from './types.ts'
3
+
4
+ function getDistinctTaskFieldValues(db: Database, field: 'assignee' | 'project'): string[] {
5
+ return (
6
+ db
7
+ .query(`SELECT DISTINCT ${field} as value FROM tasks WHERE ${field} != '' ORDER BY ${field}`)
8
+ .all() as { value: string }[]
9
+ ).map((row) => row.value)
10
+ }
11
+
12
+ function getCount(db: Database, sql: string): number {
13
+ return (db.query(sql).get() as { count: number }).count
14
+ }
15
+
16
+ export function getDiscoveredAssignees(db: Database): string[] {
17
+ return getDistinctTaskFieldValues(db, 'assignee')
18
+ }
19
+
20
+ export function getDiscoveredProjects(db: Database): string[] {
21
+ return getDistinctTaskFieldValues(db, 'project')
22
+ }
23
+
24
+ export function getBoardMetrics(db: Database): BoardMetrics {
25
+ const tasksByColumn = db
26
+ .query(
27
+ `SELECT c.name as column_name, COUNT(t.id) as count
28
+ FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
29
+ GROUP BY c.id ORDER BY c.position`,
30
+ )
31
+ .all() as { column_name: string; count: number }[]
32
+
33
+ const tasksByPriority = db
34
+ .query(
35
+ `SELECT priority, COUNT(*) as count FROM tasks
36
+ GROUP BY priority ORDER BY CASE priority
37
+ WHEN 'urgent' THEN 0 WHEN 'high' THEN 1
38
+ WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`,
39
+ )
40
+ .all() as { priority: string; count: number }[]
41
+
42
+ const totalTasks = getCount(db, 'SELECT COUNT(*) as count FROM tasks')
43
+
44
+ const completedTasks = getCount(
45
+ db,
46
+ `SELECT COUNT(*) as count FROM tasks t
47
+ JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'done'`,
48
+ )
49
+
50
+ const avgResult = db
51
+ .query(
52
+ `SELECT AVG(
53
+ (julianday(ct.exited_at) - julianday(first_enter.entered_at)) * 24
54
+ ) as avg_hours
55
+ FROM column_time_tracking ct
56
+ JOIN columns c ON ct.column_id = c.id
57
+ JOIN (
58
+ SELECT task_id, MIN(entered_at) as entered_at
59
+ FROM column_time_tracking GROUP BY task_id
60
+ ) first_enter ON first_enter.task_id = ct.task_id
61
+ WHERE LOWER(c.name) = 'done' AND ct.exited_at IS NOT NULL`,
62
+ )
63
+ .get() as { avg_hours: number | null }
64
+
65
+ const recentActivity = db
66
+ .query('SELECT * FROM activity_log ORDER BY timestamp DESC, rowid DESC LIMIT 20')
67
+ .all() as ActivityEntry[]
68
+
69
+ const tasksCreatedThisWeek = getCount(
70
+ db,
71
+ "SELECT COUNT(*) as count FROM tasks WHERE created_at >= datetime('now', '-7 days')",
72
+ )
73
+
74
+ const inProgressCount = getCount(
75
+ db,
76
+ `SELECT COUNT(*) as count FROM tasks t
77
+ JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'in-progress'`,
78
+ )
79
+
80
+ const completionPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
81
+
82
+ const assignees = getDiscoveredAssignees(db)
83
+ const projects = getDiscoveredProjects(db)
84
+
85
+ return {
86
+ tasksByColumn,
87
+ tasksByPriority,
88
+ totalTasks,
89
+ completedTasks,
90
+ avgCompletionHours: avgResult.avg_hours,
91
+ recentActivity,
92
+ tasksCreatedThisWeek,
93
+ inProgressCount,
94
+ completionPercent,
95
+ assignees,
96
+ projects,
97
+ }
98
+ }