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:
@@ -7754,6 +7754,11 @@
|
||||
"archived": {
|
||||
"type": "boolean",
|
||||
"description": "No new tickets or transitions into it"
|
||||
},
|
||||
"wipLimit": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Work-in-progress limit (a warning, not a lock)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7762,7 +7767,8 @@
|
||||
"category",
|
||||
"color",
|
||||
"requiresReason",
|
||||
"archived"
|
||||
"archived",
|
||||
"wipLimit"
|
||||
]
|
||||
},
|
||||
"StatusTransitionsDto": {
|
||||
@@ -8067,6 +8073,13 @@
|
||||
"archived": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"wipLimit": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"minimum": 1,
|
||||
"maximum": 999,
|
||||
"description": "Work-in-progress limit (null: none)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1009,6 +1009,11 @@ type WorkflowStatus {
|
||||
|
||||
"""Entering it needs a reason"""
|
||||
requiresReason: Boolean!
|
||||
|
||||
"""
|
||||
Work-in-progress limit: most tickets the column should hold (a warning, not a lock)
|
||||
"""
|
||||
wipLimit: Int
|
||||
}
|
||||
|
||||
input WorkflowStatusInput {
|
||||
@@ -1018,4 +1023,7 @@ input WorkflowStatusInput {
|
||||
id: String!
|
||||
name: String!
|
||||
requiresReason: Boolean! = false
|
||||
|
||||
"""Work-in-progress limit (null: none)"""
|
||||
wipLimit: Int
|
||||
}
|
||||
@@ -1160,6 +1160,8 @@ export interface components {
|
||||
requiresReason: boolean;
|
||||
/** @description No new tickets or transitions into it */
|
||||
archived: boolean;
|
||||
/** @description Work-in-progress limit (a warning, not a lock) */
|
||||
wipLimit: number | null;
|
||||
};
|
||||
StatusTransitionsDto: {
|
||||
/** @example IN_QA */
|
||||
@@ -1270,6 +1272,8 @@ export interface components {
|
||||
requiresReason: boolean;
|
||||
/** @default false */
|
||||
archived: boolean;
|
||||
/** @description Work-in-progress limit (null: none) */
|
||||
wipLimit?: number | null;
|
||||
};
|
||||
StatusTransitionsBodyDto: {
|
||||
/** @example IN_QA */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
@@ -15,6 +15,7 @@
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
|
||||
@@ -157,6 +157,8 @@ stateDiagram-v2
|
||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
||||
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
||||
|
||||
@@ -99,6 +99,23 @@ describe('Project settings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps work-in-progress limits, from 1 to 999', () => {
|
||||
const project = newProject();
|
||||
const limited = defaultProjectSettings();
|
||||
limited.statuses[2].wipLimit = 3;
|
||||
project.updateSettings('owner', limited);
|
||||
expect(project.workflow.wipLimit(limited.statuses[2].id)).toBe(3);
|
||||
expect(project.workflow.wipLimit('TODO')).toBeNull();
|
||||
// Saving the same again is no change.
|
||||
const before = project.getUncommittedEvents().length;
|
||||
project.updateSettings('owner', structuredClone(limited));
|
||||
expect(project.getUncommittedEvents()).toHaveLength(before);
|
||||
|
||||
const zero = defaultProjectSettings();
|
||||
zero.statuses[2].wipLimit = 0;
|
||||
expect(() => project.updateSettings('owner', zero)).toThrow(/WIP/);
|
||||
});
|
||||
|
||||
it('rejects settings that would break the board', () => {
|
||||
const project = newProject();
|
||||
const noStart = defaultProjectSettings();
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface WorkflowStatus {
|
||||
requiresReason: boolean;
|
||||
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
||||
archived: boolean;
|
||||
/**
|
||||
* Work-in-progress limit: how many tickets the column should hold at most
|
||||
* (null: no limit). A warning, not a lock: the board flags a full column.
|
||||
*/
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
export interface IssueTypeOption {
|
||||
@@ -193,6 +198,11 @@ export class ProjectWorkflow {
|
||||
return this.settings.statuses.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Most tickets the status should hold (null: no limit). */
|
||||
wipLimit(id: string): number | null {
|
||||
return this.status(id)?.wipLimit ?? null;
|
||||
}
|
||||
|
||||
/** Name to show, also for ids no longer configured. */
|
||||
statusName(id: string): string {
|
||||
return (
|
||||
@@ -287,6 +297,8 @@ export function validateProjectSettings(
|
||||
color: s.color,
|
||||
requiresReason: !!s.requiresReason,
|
||||
archived: !!s.archived,
|
||||
// Only when set: settings saved unchanged must compare equal.
|
||||
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
||||
}));
|
||||
const issueTypes = next.issueTypes.map((t) => ({
|
||||
id: normId(t.id),
|
||||
@@ -326,6 +338,11 @@ export function validateProjectSettings(
|
||||
SETTING_COLORS.includes(s.color),
|
||||
`Color no válido en el estado ${s.name}`,
|
||||
);
|
||||
assertThat(
|
||||
s.wipLimit === undefined ||
|
||||
(Number.isInteger(s.wipLimit) && s.wipLimit >= 1 && s.wipLimit <= 999),
|
||||
`El límite WIP del estado ${s.name} tiene que ser un número entre 1 y 999`,
|
||||
);
|
||||
}
|
||||
for (const t of issueTypes) {
|
||||
assertThat(
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SettingsPayload {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit?: number | null;
|
||||
}[];
|
||||
transitions: { from: string; to: string[] }[];
|
||||
issueTypes: {
|
||||
|
||||
@@ -425,6 +425,15 @@ export class WorkflowStatusInput {
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Work-in-progress limit (null: none)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(999)
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -33,6 +33,12 @@ export class WorkflowStatusGql {
|
||||
requiresReason!: boolean;
|
||||
@Field({ description: 'No new tickets or transitions into it' })
|
||||
archived!: boolean;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Work-in-progress limit: most tickets the column should hold (a warning, not a lock)',
|
||||
})
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('StatusTransitions')
|
||||
|
||||
@@ -142,6 +142,18 @@ export class WorkflowStatusBodyDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
minimum: 1,
|
||||
maximum: 999,
|
||||
description: 'Work-in-progress limit (null: none)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(999)
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
export class StatusTransitionsBodyDto {
|
||||
|
||||
@@ -39,6 +39,12 @@ export class WorkflowStatusDto {
|
||||
requiresReason!: boolean;
|
||||
@ApiProperty({ description: 'No new tickets or transitions into it' })
|
||||
archived!: boolean;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Work-in-progress limit (a warning, not a lock)',
|
||||
})
|
||||
wipLimit!: number | null;
|
||||
}
|
||||
|
||||
export class StatusTransitionsDto {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function toSettingsInput(
|
||||
color: s.color,
|
||||
requiresReason: s.requiresReason,
|
||||
archived: s.archived,
|
||||
wipLimit: s.wipLimit ?? null,
|
||||
})),
|
||||
transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })),
|
||||
issueTypes: settings.issueTypes.map((t) => ({
|
||||
|
||||
@@ -28,6 +28,7 @@ fragment ProjectFields on Project {
|
||||
color
|
||||
requiresReason
|
||||
archived
|
||||
wipLimit
|
||||
}
|
||||
transitions {
|
||||
from
|
||||
|
||||
@@ -239,6 +239,8 @@ export type WorkflowStatusInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
requiresReason?: boolean;
|
||||
/** Work-in-progress limit (null: none) */
|
||||
wipLimit?: number | null | undefined;
|
||||
};
|
||||
|
||||
export type UserRefFieldsFragment = { id: string; name: string };
|
||||
@@ -261,6 +263,7 @@ export type ProjectFieldsFragment = {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit: number | null;
|
||||
}>;
|
||||
transitions: Array<{ from: string; to: Array<string> }>;
|
||||
issueTypes: Array<{
|
||||
@@ -892,6 +895,7 @@ export type MyProjectsQuery = {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit: number | null;
|
||||
}>;
|
||||
transitions: Array<{ from: string; to: Array<string> }>;
|
||||
issueTypes: Array<{
|
||||
@@ -929,6 +933,7 @@ export type ProjectQuery = {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit: number | null;
|
||||
}>;
|
||||
transitions: Array<{ from: string; to: Array<string> }>;
|
||||
issueTypes: Array<{
|
||||
@@ -1076,6 +1081,7 @@ export type PublicProjectsQuery = {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit: number | null;
|
||||
}>;
|
||||
transitions: Array<{ from: string; to: Array<string> }>;
|
||||
issueTypes: Array<{
|
||||
@@ -1490,6 +1496,7 @@ export type ProjectChangedSubscription = {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit: number | null;
|
||||
}>;
|
||||
transitions: Array<{ from: string; to: Array<string> }>;
|
||||
issueTypes: Array<{
|
||||
@@ -1530,6 +1537,7 @@ export type MyProjectsChangedSubscription = {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit: number | null;
|
||||
}>;
|
||||
transitions: Array<{ from: string; to: Array<string> }>;
|
||||
issueTypes: Array<{
|
||||
@@ -1648,6 +1656,10 @@ export const ProjectFieldsFragmentDoc = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -5664,6 +5676,10 @@ export const MyProjectsDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -5865,6 +5881,10 @@ export const ProjectDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -6917,6 +6937,10 @@ export const PublicProjectsDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -9221,6 +9245,10 @@ export const ProjectChangedDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -9409,6 +9437,10 @@ export const MyProjectsChangedDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -147,10 +147,9 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
}
|
||||
.ts-backlog__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem auto auto minmax(
|
||||
0,
|
||||
1fr
|
||||
) auto auto 2rem auto auto;
|
||||
grid-template-columns:
|
||||
2rem auto auto minmax(0, 1fr)
|
||||
auto auto 2rem auto auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.875rem;
|
||||
|
||||
@@ -136,3 +136,49 @@
|
||||
font-weight: 400;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
.ts-column.is-over {
|
||||
box-shadow: inset 0 3px 0 var(--ts-c-red, var(--ts-ink));
|
||||
|
||||
.ts-column__count {
|
||||
color: var(--ts-c-red, var(--ts-ink));
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grouped board: heads and rows scroll together. */
|
||||
.ts-lanes {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
|
||||
.ts-board {
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-board--heads {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.ts-column--head {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-lane {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ts-lane__title {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ts-column--lane .ts-column__list {
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
type SegmentedControlOption,
|
||||
} from '@pi/ui';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
BoardFacade,
|
||||
ProjectFacade,
|
||||
@@ -42,15 +43,44 @@ import {
|
||||
boardFilterOf,
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
overWip,
|
||||
POINT_SCALE,
|
||||
ReleaseStatus,
|
||||
SavedFilter,
|
||||
sumPoints,
|
||||
SwimlaneMode,
|
||||
swimlanes,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { IssueCardComponent } from '../ui/issue-card';
|
||||
import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
|
||||
import {
|
||||
options,
|
||||
pointOptions,
|
||||
PRIORITY,
|
||||
PRIORITY_LEVEL,
|
||||
statusLook,
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
|
||||
const ALL = '';
|
||||
const LANE_KEY = 'ts.board.lanes';
|
||||
|
||||
/** The grouping last chosen on this browser (a convenience, not a setting). */
|
||||
function storedLaneMode(): SwimlaneMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(LANE_KEY);
|
||||
return (['assignee', 'epic', 'priority', 'type'] as const).includes(
|
||||
stored as never,
|
||||
)
|
||||
? (stored as SwimlaneMode)
|
||||
: 'none';
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const statusLookType = (workflow: Workflow, id: string) =>
|
||||
typeLook(workflow, id).label;
|
||||
/** "Sin asignar" in the person filter. */
|
||||
const NOBODY = '__nobody__';
|
||||
|
||||
@@ -63,6 +93,7 @@ const NOBODY = '__nobody__';
|
||||
CdkDrag,
|
||||
FormField,
|
||||
IssueCardComponent,
|
||||
NgTemplateOutlet,
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSegmentedControlComponent,
|
||||
@@ -139,6 +170,13 @@ const NOBODY = '__nobody__';
|
||||
[options]="priorityFilterOptions"
|
||||
[(value)]="priorityFilter"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Agrupar por"
|
||||
[options]="laneOptions"
|
||||
[value]="laneMode()"
|
||||
(valueChange)="setLaneMode($event)"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Sprint"
|
||||
@@ -177,19 +215,79 @@ const NOBODY = '__nobody__';
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (laneMode() === 'none') {
|
||||
<div class="ts-board" cdkDropListGroup>
|
||||
@for (column of board.columns(); track column.status) {
|
||||
<section
|
||||
class="ts-column"
|
||||
[class.is-over]="isOver(column)"
|
||||
[attr.data-status]="column.status"
|
||||
[style.--tone]="statusTone(column.status)"
|
||||
>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="columnHead"
|
||||
[ngTemplateOutletContext]="{ column }"
|
||||
/>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="columnList"
|
||||
[ngTemplateOutletContext]="{ column, lane: '' }"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- One scroll for heads and rows, so the columns stay aligned. -->
|
||||
<div class="ts-lanes">
|
||||
<div class="ts-board ts-board--heads">
|
||||
@for (column of board.columns(); track column.status) {
|
||||
<section
|
||||
class="ts-column ts-column--head"
|
||||
[class.is-over]="isOver(column)"
|
||||
[style.--tone]="statusTone(column.status)"
|
||||
>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="columnHead"
|
||||
[ngTemplateOutletContext]="{ column }"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@for (lane of lanes(); track lane.key) {
|
||||
<section class="ts-lane" [attr.aria-label]="laneLabel(lane.key)">
|
||||
<h3 class="ts-lane__title">
|
||||
{{ laneLabel(lane.key) }}
|
||||
<span class="ts-mono ts-muted">{{ lane.count }}</span>
|
||||
</h3>
|
||||
<!-- Cards move between columns of their own row only. -->
|
||||
<div class="ts-board" cdkDropListGroup>
|
||||
@for (column of lane.columns; track column.status) {
|
||||
<section
|
||||
class="ts-column ts-column--lane"
|
||||
[attr.data-status]="column.status"
|
||||
[style.--tone]="statusTone(column.status)"
|
||||
>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="columnList"
|
||||
[ngTemplateOutletContext]="{ column, lane: lane.key }"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-template #columnHead let-column="column">
|
||||
<header class="ts-column__header">
|
||||
<span class="ts-column__name">{{
|
||||
statusLabel(column.status)
|
||||
}}</span>
|
||||
<span class="ts-column__count"
|
||||
>{{ column.total }}
|
||||
<span class="ts-column__name">{{ statusLabel(column.status) }}</span>
|
||||
<span
|
||||
class="ts-column__count"
|
||||
[attr.title]="
|
||||
wipOf(column) ? 'Límite WIP: ' + wipOf(column) + ' tickets' : null
|
||||
"
|
||||
>{{ column.total }}{{ wipOf(column) ? ' / ' + wipOf(column) : '' }}
|
||||
@if (pointsOf(column); as points) {
|
||||
<span class="ts-column__points" title="Puntos estimados"
|
||||
>· {{ points }} pts</span
|
||||
@@ -197,10 +295,13 @@ const NOBODY = '__nobody__';
|
||||
}
|
||||
</span>
|
||||
</header>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #columnList let-column="column" let-lane="lane">
|
||||
<div
|
||||
class="ts-column__list"
|
||||
cdkDropList
|
||||
[id]="column.status"
|
||||
[id]="lane + '|' + column.status"
|
||||
[cdkDropListData]="column"
|
||||
[cdkDropListDisabled]="!project.canContribute()"
|
||||
[cdkDropListEnterPredicate]="canEnter"
|
||||
@@ -217,10 +318,7 @@ const NOBODY = '__nobody__';
|
||||
<p class="ts-column__empty">Nada por aquí</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
@@ -362,6 +460,7 @@ export class BoardPage {
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
private readonly me = computed(() => this.auth.user()?.id ?? null);
|
||||
private readonly createForm =
|
||||
@@ -406,6 +505,59 @@ export class BoardPage {
|
||||
),
|
||||
);
|
||||
|
||||
/* Swimlanes and WIP */
|
||||
protected readonly laneOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: 'none', label: 'Sin agrupar' },
|
||||
{ value: 'assignee', label: 'Agrupar por persona' },
|
||||
{ value: 'epic', label: 'Agrupar por épica' },
|
||||
{ value: 'priority', label: 'Agrupar por prioridad' },
|
||||
{ value: 'type', label: 'Agrupar por tipo' },
|
||||
];
|
||||
protected readonly laneMode = signal<SwimlaneMode>(storedLaneMode());
|
||||
protected readonly lanes = computed(() =>
|
||||
swimlanes(this.board.columns(), this.laneMode(), (key) =>
|
||||
this.laneMode() === 'priority'
|
||||
? -PRIORITY_LEVEL[key as IssuePriority]
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
protected laneLabel(key: string): string {
|
||||
switch (this.laneMode()) {
|
||||
case 'assignee':
|
||||
return key
|
||||
? (this.project.members().find((m) => m.user.id === key)?.user.name ??
|
||||
'Antiguo miembro')
|
||||
: 'Sin asignar';
|
||||
case 'epic': {
|
||||
if (!key) return 'Sin épica';
|
||||
const card = this.board
|
||||
.columns()
|
||||
.flatMap((c) => c.issues)
|
||||
.find((i) => i.parent?.id === key);
|
||||
return card?.parent ? `${card.parent.key} · ${card.parent.title}` : key;
|
||||
}
|
||||
case 'priority':
|
||||
return PRIORITY[key as IssuePriority]?.label ?? key;
|
||||
case 'type':
|
||||
return statusLookType(this.project.workflow(), key);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
protected setLaneMode(mode: string | null): void {
|
||||
const next = (mode || 'none') as SwimlaneMode;
|
||||
this.laneMode.set(next);
|
||||
try {
|
||||
localStorage.setItem(LANE_KEY, next);
|
||||
} catch {
|
||||
// Private mode: the choice lasts until the page closes.
|
||||
}
|
||||
}
|
||||
protected wipOf = (column: BoardColumn) =>
|
||||
this.project.workflow().wipLimit(column.status);
|
||||
protected isOver = (column: BoardColumn) =>
|
||||
overWip(column.total, this.wipOf(column));
|
||||
|
||||
/* Saved views */
|
||||
protected readonly viewName = signal('');
|
||||
protected readonly viewShared = signal(false);
|
||||
@@ -582,6 +734,12 @@ export class BoardPage {
|
||||
if (event.previousContainer === event.container) return;
|
||||
const to = event.container.data.status;
|
||||
const issueId = event.item.data.id;
|
||||
const target = this.board.columns().find((c) => c.status === to);
|
||||
const limit = this.project.workflow().wipLimit(to);
|
||||
if (target && overWip(target.total + 1, limit))
|
||||
this.toast.info(
|
||||
`«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`,
|
||||
);
|
||||
if (this.project.workflow().requiresReason(to)) {
|
||||
this.pendingMove = { issueId, to };
|
||||
this.reasonModel.set({ text: '' });
|
||||
|
||||
@@ -135,7 +135,8 @@ function idFrom(name: string): string {
|
||||
</header>
|
||||
<div class="ts-rows-head ts-status-grid" aria-hidden="true">
|
||||
<span></span><span>Nombre</span><span>Categoría</span
|
||||
><span>Color</span><span>Pide motivo</span><span></span>
|
||||
><span>Color</span><span>Límite WIP</span><span>Pide motivo</span
|
||||
><span></span>
|
||||
</div>
|
||||
<ol
|
||||
class="ts-rows"
|
||||
@@ -198,6 +199,21 @@ function idFrom(name: string): string {
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<input
|
||||
class="ts-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
placeholder="—"
|
||||
[attr.aria-label]="'Límite WIP de ' + status.name"
|
||||
title="Cuántos tickets debería tener la columna como mucho (vacío: sin límite)"
|
||||
[value]="status.wipLimit ?? ''"
|
||||
(change)="
|
||||
edit('statuses', i, {
|
||||
wipLimit: wipFrom($any($event.target).value),
|
||||
})
|
||||
"
|
||||
/>
|
||||
<label class="ts-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -693,7 +709,10 @@ function idFrom(name: string): string {
|
||||
}
|
||||
.ts-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(10rem, 1fr) 9rem 8rem 5.5rem 6.5rem;
|
||||
grid-template-columns: 1.25rem minmax(
|
||||
10rem,
|
||||
1fr
|
||||
) 9rem 8rem 5.5rem 5.5rem 6.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -996,6 +1015,12 @@ export class SettingsPage {
|
||||
?.to.includes(to);
|
||||
}
|
||||
|
||||
/** '' (or anything below 1) clears the limit. */
|
||||
protected wipFrom(value: string): number | null {
|
||||
const limit = Math.round(Number(value));
|
||||
return Number.isFinite(limit) && limit >= 1 ? Math.min(limit, 999) : null;
|
||||
}
|
||||
|
||||
protected edit(section: Section, index: number, changes: object): void {
|
||||
this.patch((d) => Object.assign(d[section][index], changes));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user