diff --git a/src/db/queries.ts b/src/db/queries.ts index a1aaa80..94596e4 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3,6 +3,7 @@ import type { Team, Intent, IntentWithRelations, Claim, Signal, ConflictWarning, ContextPackage, TeamStatus, Overview, IntentStatus, IntentPriority, SignalType, + BoardIntent, BoardView, BoardStatus, } from '../types.js'; // ─── Teams ────────────────────────────────────────────── @@ -703,3 +704,77 @@ export async function getOverview(): Promise { blocked_intents: blockedRes.rows, }; } + +// ─── Board View ────────────────────────────────────────── + +export async function getBoard(teamId?: string): Promise { + // Main query: all non-draft intents LEFT JOIN active claims + const teamFilter = teamId ? `AND i.team_id = $1` : ''; + const params: unknown[] = teamId ? [teamId] : []; + + const intentsRes = await query<{ + id: string; + title: string; + priority: IntentPriority; + team_id: string | null; + status: IntentStatus; + claimed_by: string | null; + claim_id: string | null; + }>( + `SELECT i.id, i.title, i.priority, i.team_id, i.status, + c.claimed_by, c.id as claim_id + FROM intents i + LEFT JOIN claims c ON c.intent_id = i.id AND c.status = 'active' + WHERE i.status != 'draft' ${teamFilter} + ORDER BY i.priority, i.created_at DESC`, + params + ); + + // Secondary query: blocked dependencies (intent_id -> depends_on where dep is not done) + const blockedDepsRes = await query<{ intent_id: string; depends_on: string }>( + `SELECT d.intent_id, d.depends_on + FROM intent_dependencies d + JOIN intents dep ON dep.id = d.depends_on + JOIN intents i ON i.id = d.intent_id + WHERE dep.status != 'done' + AND i.status = 'blocked'` + ); + + // Build blocked_by lookup: intent_id -> [dependency IDs] + const blockedByMap = new Map(); + for (const row of blockedDepsRes.rows) { + const existing = blockedByMap.get(row.intent_id) ?? []; + existing.push(row.depends_on); + blockedByMap.set(row.intent_id, existing); + } + + // Group into columns + const columns: Record = { + open: [], claimed: [], blocked: [], done: [], cancelled: [], + }; + for (const row of intentsRes.rows) { + const card: BoardIntent = { + id: row.id, + title: row.title, + priority: row.priority, + team_id: row.team_id, + claimed_by: row.claimed_by ?? null, + claim_id: row.claim_id ?? null, + blocked_by: blockedByMap.get(row.id) ?? [], + }; + if (row.status in columns) { + columns[row.status as BoardStatus].push(card); + } + } + + // Build summary counts + const summary: Record = { + open: columns.open.length, + claimed: columns.claimed.length, + blocked: columns.blocked.length, + done: columns.done.length, + cancelled: columns.cancelled.length, + }; + + return { columns, summary }; +} diff --git a/src/tools/overview.ts b/src/tools/overview.ts index 0fb6b09..76b610e 100644 --- a/src/tools/overview.ts +++ b/src/tools/overview.ts @@ -48,4 +48,16 @@ export function registerOverviewTools(server: McpServer): void { return { content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }] }; } ); + + server.tool( + 'get_board', + 'Get kanban board view — all intents grouped by status column with active claims and blocked dependencies. Excludes drafts.', + { + team_id: z.string().optional().describe('Filter to a single team. Omit for cross-team board.'), + }, + async ({ team_id }) => { + const board = await db.getBoard(team_id); + return { content: [{ type: 'text', text: JSON.stringify(board, null, 2) }] }; + } + ); } diff --git a/src/types.ts b/src/types.ts index 6d0ff0c..36ded46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,3 +98,20 @@ export interface Overview { recently_completed: Intent[]; blocked_intents: Array; } + +export interface BoardIntent { + id: string; + title: string; + priority: IntentPriority; + team_id: string | null; + claimed_by: string | null; + claim_id: string | null; + blocked_by: string[]; +} + +export type BoardStatus = Exclude; + +export interface BoardView { + columns: Record; + summary: Record; +} diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 2bfb975..b8cf02a 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -12,10 +12,6 @@ describe('Team Status & Overview', () => { await seedTeam(); }); - afterAll(async () => { - await teardownTestDb(); - }); - it('returns team status with intents grouped by status', async () => { const intent = await seedOpenIntent({ title: 'Open task' }); const intent2 = await seedOpenIntent({ title: 'Claimed task' }); @@ -99,3 +95,109 @@ describe('Team Status & Overview', () => { expect(overview.recently_completed[0].title).toBe('Will complete'); }); }); + +describe('Board View', () => { + beforeAll(async () => { + await setupTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(); + await seedTeam(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + it('returns empty columns with zero counts when no intents exist', async () => { + const board = await db.getBoard(); + + expect(board.columns.open).toEqual([]); + expect(board.columns.claimed).toEqual([]); + expect(board.columns.blocked).toEqual([]); + expect(board.columns.done).toEqual([]); + expect(board.columns.cancelled).toEqual([]); + expect(board.summary.open).toBe(0); + expect(board.summary.claimed).toBe(0); + expect(board.summary.blocked).toBe(0); + expect(board.summary.done).toBe(0); + expect(board.summary.cancelled).toBe(0); + }); + + it('groups intents into correct status columns', async () => { + const openIntent = await seedOpenIntent({ title: 'Open task' }); + const claimedIntent = await seedOpenIntent({ title: 'Claimed task' }); + await db.claimWork({ intent_id: claimedIntent.id as string, claimed_by: 'alice' }); + + const board = await db.getBoard(); + + expect(board.columns.open).toHaveLength(1); + expect(board.columns.open[0].title).toBe('Open task'); + expect(board.columns.claimed).toHaveLength(1); + expect(board.columns.claimed[0].title).toBe('Claimed task'); + }); + + it('includes claimed_by and claim_id for claimed intents', async () => { + const intent = await seedOpenIntent({ title: 'In progress' }); + const { claim } = await db.claimWork({ + intent_id: intent.id as string, + claimed_by: 'pawel', + }); + + const board = await db.getBoard(); + + const claimedCard = board.columns.claimed[0]; + expect(claimedCard.claimed_by).toBe('pawel'); + expect(claimedCard.claim_id).toBe(claim.id); + }); + + it('includes blocked_by for blocked intents', async () => { + const blocker = await seedOpenIntent({ title: 'Blocker' }); + + await testQuery( + `INSERT INTO intents (title, created_by, team_id, status, priority, acceptance_criteria) + VALUES ('Blocked task', 'alice', 'backend', 'blocked', 'medium', '["Done"]') RETURNING *` + ); + const blockedRes = await testQuery( + `SELECT id FROM intents WHERE title = 'Blocked task'` + ); + const blockedId = blockedRes.rows[0].id; + await testQuery( + 'INSERT INTO intent_dependencies (intent_id, depends_on) VALUES ($1, $2)', + [blockedId, blocker.id] + ); + + const board = await db.getBoard(); + + expect(board.columns.blocked).toHaveLength(1); + expect(board.columns.blocked[0].blocked_by).toContain(blocker.id); + }); + + it('filters by team_id when provided', async () => { + await seedTeam('frontend', 'Frontend Team'); + await seedOpenIntent({ title: 'Backend task', team_id: 'backend' }); + await seedOpenIntent({ title: 'Frontend task', team_id: 'frontend' }); + + const board = await db.getBoard('frontend'); + + expect(board.columns.open).toHaveLength(1); + expect(board.columns.open[0].title).toBe('Frontend task'); + }); + + it('excludes drafts from all columns', async () => { + await testQuery( + `INSERT INTO intents (title, created_by, team_id, status, priority, acceptance_criteria) + VALUES ('Draft task', 'alice', 'backend', 'draft', 'medium', '["Done"]')` + ); + await seedOpenIntent({ title: 'Open task' }); + + const board = await db.getBoard(); + + const allCards = Object.values(board.columns).flat(); + expect(allCards.every(c => c.title !== 'Draft task')).toBe(true); + expect(board.columns.open).toHaveLength(1); + expect(board.columns).not.toHaveProperty('draft'); + expect(board.summary).not.toHaveProperty('draft'); + }); +});