Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -703,3 +704,77 @@ export async function getOverview(): Promise<Overview> {
blocked_intents: blockedRes.rows,
};
}

// ─── Board View ──────────────────────────────────────────

export async function getBoard(teamId?: string): Promise<BoardView> {
// 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<string, string[]>();
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<BoardStatus, BoardIntent[]> = {
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<BoardStatus, number> = {
open: columns.open.length,
claimed: columns.claimed.length,
blocked: columns.blocked.length,
done: columns.done.length,
cancelled: columns.cancelled.length,
};

return { columns, summary };
}
12 changes: 12 additions & 0 deletions src/tools/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }] };
}
);
}
17 changes: 17 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,20 @@ export interface Overview {
recently_completed: Intent[];
blocked_intents: Array<Intent & { blocked_by: string[] }>;
}

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<IntentStatus, 'draft'>;

export interface BoardView {
columns: Record<BoardStatus, BoardIntent[]>;
summary: Record<BoardStatus, number>;
}
110 changes: 106 additions & 4 deletions tests/overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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');
});
});
Loading