feat: work-in-progress limits and swimlanes on the board

Each status can carry a WIP limit in the project settings; the board shows
it next to the count, marks a column that goes over it and says so when a
card is dropped there. The board can also be grouped in rows by person,
epic, priority or type, remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:09:21 +02:00
parent 6aec774c92
commit 09bd8c19a3
23 changed files with 563 additions and 74 deletions

View File

@@ -0,0 +1,51 @@
import { IssuePriority } from './enums';
import { BoardColumn, IssueSummary } from './models';
import { overWip, swimlanes } from './swimlanes';
const card = (id: string, assignee: string | null, parent: string | null) =>
({
id,
assignee: assignee ? { id: assignee, name: assignee } : null,
parent: parent ? { id: parent, key: parent, title: parent } : null,
priority: IssuePriority.MEDIUM,
type: 'TASK',
}) as IssueSummary;
const board: BoardColumn[] = [
{
status: 'TODO',
total: 3,
issues: [
card('a', 'ana', 'e1'),
card('b', null, null),
card('c', 'bob', 'e1'),
],
},
{ status: 'DONE', total: 1, issues: [card('d', 'ana', null)] },
];
describe('swimlanes', () => {
it('splits the board by person, nobody last', () => {
const lanes = swimlanes(board, 'assignee');
expect(lanes.map((l) => [l.key, l.count])).toEqual([
['ana', 2],
['bob', 1],
['', 1],
]);
expect(lanes[0].columns.map((c) => c.issues.map((i) => i.id))).toEqual([
['a'],
['d'],
]);
});
it('groups by epic and keeps one row when not grouping', () => {
expect(swimlanes(board, 'epic').map((l) => l.key)).toEqual(['e1', '']);
expect(swimlanes(board, 'none')).toHaveLength(1);
});
it('flags a column over its WIP limit', () => {
expect(overWip(4, 3)).toBe(true);
expect(overWip(3, 3)).toBe(false);
expect(overWip(9, null)).toBe(false);
});
});

View File

@@ -0,0 +1,72 @@
import { BoardColumn, IssueSummary } from './models';
/** How the board can be split in rows. */
export type SwimlaneMode = 'none' | 'assignee' | 'priority' | 'type' | 'epic';
export interface Swimlane {
/** Stable key of the row (`''` for "nobody", "no epic"...). */
key: string;
/** Same columns as the board, each with only this row's cards. */
columns: BoardColumn[];
count: number;
}
/** Key of the row a card belongs to. */
export function laneKeyOf(issue: IssueSummary, mode: SwimlaneMode): string {
switch (mode) {
case 'assignee':
return issue.assignee?.id ?? '';
case 'priority':
return issue.priority;
case 'type':
return issue.type;
case 'epic':
return issue.parent?.id ?? '';
default:
return '';
}
}
/**
* The board's columns split in rows. `order` ranks keys (lower first);
* cards keep their order, and the empty key (nobody, no epic) goes last.
*/
export function swimlanes(
columns: BoardColumn[],
mode: SwimlaneMode,
order: (key: string) => number = () => 0,
): Swimlane[] {
if (mode === 'none')
return [
{
key: '',
columns,
count: columns.reduce((sum, c) => sum + c.issues.length, 0),
},
];
const keys = new Set<string>();
for (const column of columns)
for (const issue of column.issues) keys.add(laneKeyOf(issue, mode));
return [...keys]
.sort((a, b) =>
a === '' ? 1 : b === '' ? -1 : order(a) - order(b) || a.localeCompare(b),
)
.map((key) => {
const lane = columns.map((column) => {
const issues = column.issues.filter(
(issue) => laneKeyOf(issue, mode) === key,
);
return { ...column, issues, total: issues.length };
});
return {
key,
columns: lane,
count: lane.reduce((sum, c) => sum + c.issues.length, 0),
};
});
}
/** Whether a column holds more than its limit (`total` counts all of it). */
export function overWip(total: number, limit: number | null): boolean {
return limit !== null && total > limit;
}

View File

@@ -42,6 +42,8 @@ export interface WorkflowStatus {
requiresReason: boolean;
/** No new tickets or transitions into it; existing tickets keep it. */
archived: boolean;
/** Most tickets the column should hold (null/absent: no limit). */
wipLimit?: number | null;
}
export interface StatusTransitions {
@@ -156,6 +158,11 @@ export class Workflow {
return this.settings.areas.filter((a) => !a.archived);
}
/** Most tickets the status should hold (null: no limit). */
wipLimit(id: string): number | null {
return this.status(id).wipLimit ?? null;
}
requiresReason(id: string): boolean {
return this.status(id).requiresReason;
}