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:
@@ -7,3 +7,4 @@ export * from './lib/models/mentions';
|
||||
export * from './lib/models/hierarchy';
|
||||
export * from './lib/models/views';
|
||||
export * from './lib/models/estimation';
|
||||
export * from './lib/models/swimlanes';
|
||||
|
||||
51
libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts
Normal file
51
libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
libs/web/tracker/domain/src/lib/models/swimlanes.ts
Normal file
72
libs/web/tracker/domain/src/lib/models/swimlanes.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user