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

@@ -7754,6 +7754,11 @@
"archived": { "archived": {
"type": "boolean", "type": "boolean",
"description": "No new tickets or transitions into it" "description": "No new tickets or transitions into it"
},
"wipLimit": {
"type": "number",
"nullable": true,
"description": "Work-in-progress limit (a warning, not a lock)"
} }
}, },
"required": [ "required": [
@@ -7762,7 +7767,8 @@
"category", "category",
"color", "color",
"requiresReason", "requiresReason",
"archived" "archived",
"wipLimit"
] ]
}, },
"StatusTransitionsDto": { "StatusTransitionsDto": {
@@ -8067,6 +8073,13 @@
"archived": { "archived": {
"type": "boolean", "type": "boolean",
"default": false "default": false
},
"wipLimit": {
"type": "number",
"nullable": true,
"minimum": 1,
"maximum": 999,
"description": "Work-in-progress limit (null: none)"
} }
}, },
"required": [ "required": [

View File

@@ -1009,6 +1009,11 @@ type WorkflowStatus {
"""Entering it needs a reason""" """Entering it needs a reason"""
requiresReason: Boolean! requiresReason: Boolean!
"""
Work-in-progress limit: most tickets the column should hold (a warning, not a lock)
"""
wipLimit: Int
} }
input WorkflowStatusInput { input WorkflowStatusInput {
@@ -1018,4 +1023,7 @@ input WorkflowStatusInput {
id: String! id: String!
name: String! name: String!
requiresReason: Boolean! = false requiresReason: Boolean! = false
"""Work-in-progress limit (null: none)"""
wipLimit: Int
} }

View File

@@ -1160,6 +1160,8 @@ export interface components {
requiresReason: boolean; requiresReason: boolean;
/** @description No new tickets or transitions into it */ /** @description No new tickets or transitions into it */
archived: boolean; archived: boolean;
/** @description Work-in-progress limit (a warning, not a lock) */
wipLimit: number | null;
}; };
StatusTransitionsDto: { StatusTransitionsDto: {
/** @example IN_QA */ /** @example IN_QA */
@@ -1270,6 +1272,8 @@ export interface components {
requiresReason: boolean; requiresReason: boolean;
/** @default false */ /** @default false */
archived: boolean; archived: boolean;
/** @description Work-in-progress limit (null: none) */
wipLimit?: number | null;
}; };
StatusTransitionsBodyDto: { StatusTransitionsBodyDto: {
/** @example IN_QA */ /** @example IN_QA */

View File

@@ -2,31 +2,32 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 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 | | 🌍 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 | | 🗑️ 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 |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | | 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | | 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 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 | | 💬 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 | | 🔎 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 |
| 🎲 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 | | 🚦 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 |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 🎲 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 |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | | 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por

View File

@@ -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 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 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. 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. - **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 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 sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige

View File

@@ -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', () => { it('rejects settings that would break the board', () => {
const project = newProject(); const project = newProject();
const noStart = defaultProjectSettings(); const noStart = defaultProjectSettings();

View File

@@ -61,6 +61,11 @@ export interface WorkflowStatus {
requiresReason: boolean; requiresReason: boolean;
/** Archived: no new tickets or transitions into it; existing ones keep it. */ /** Archived: no new tickets or transitions into it; existing ones keep it. */
archived: boolean; 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 { export interface IssueTypeOption {
@@ -193,6 +198,11 @@ export class ProjectWorkflow {
return this.settings.statuses.find((s) => s.id === id); 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. */ /** Name to show, also for ids no longer configured. */
statusName(id: string): string { statusName(id: string): string {
return ( return (
@@ -287,6 +297,8 @@ export function validateProjectSettings(
color: s.color, color: s.color,
requiresReason: !!s.requiresReason, requiresReason: !!s.requiresReason,
archived: !!s.archived, archived: !!s.archived,
// Only when set: settings saved unchanged must compare equal.
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
})); }));
const issueTypes = next.issueTypes.map((t) => ({ const issueTypes = next.issueTypes.map((t) => ({
id: normId(t.id), id: normId(t.id),
@@ -326,6 +338,11 @@ export function validateProjectSettings(
SETTING_COLORS.includes(s.color), SETTING_COLORS.includes(s.color),
`Color no válido en el estado ${s.name}`, `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) { for (const t of issueTypes) {
assertThat( assertThat(

View File

@@ -14,6 +14,7 @@ export interface SettingsPayload {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit?: number | null;
}[]; }[];
transitions: { from: string; to: string[] }[]; transitions: { from: string; to: string[] }[];
issueTypes: { issueTypes: {

View File

@@ -425,6 +425,15 @@ export class WorkflowStatusInput {
@Field() @IsString() color!: string; @Field() @IsString() color!: string;
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean; @Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
@Field({ defaultValue: false }) @IsBoolean() archived!: 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() @InputType()

View File

@@ -33,6 +33,12 @@ export class WorkflowStatusGql {
requiresReason!: boolean; requiresReason!: boolean;
@Field({ description: 'No new tickets or transitions into it' }) @Field({ description: 'No new tickets or transitions into it' })
archived!: boolean; 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') @ObjectType('StatusTransitions')

View File

@@ -142,6 +142,18 @@ export class WorkflowStatusBodyDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
archived = false; 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 { export class StatusTransitionsBodyDto {

View File

@@ -39,6 +39,12 @@ export class WorkflowStatusDto {
requiresReason!: boolean; requiresReason!: boolean;
@ApiProperty({ description: 'No new tickets or transitions into it' }) @ApiProperty({ description: 'No new tickets or transitions into it' })
archived!: boolean; archived!: boolean;
@ApiProperty({
type: Number,
nullable: true,
description: 'Work-in-progress limit (a warning, not a lock)',
})
wipLimit!: number | null;
} }
export class StatusTransitionsDto { export class StatusTransitionsDto {

View File

@@ -7,3 +7,4 @@ export * from './lib/models/mentions';
export * from './lib/models/hierarchy'; export * from './lib/models/hierarchy';
export * from './lib/models/views'; export * from './lib/models/views';
export * from './lib/models/estimation'; export * from './lib/models/estimation';
export * from './lib/models/swimlanes';

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

View File

@@ -77,6 +77,7 @@ export function toSettingsInput(
color: s.color, color: s.color,
requiresReason: s.requiresReason, requiresReason: s.requiresReason,
archived: s.archived, archived: s.archived,
wipLimit: s.wipLimit ?? null,
})), })),
transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })), transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })),
issueTypes: settings.issueTypes.map((t) => ({ issueTypes: settings.issueTypes.map((t) => ({

View File

@@ -28,6 +28,7 @@ fragment ProjectFields on Project {
color color
requiresReason requiresReason
archived archived
wipLimit
} }
transitions { transitions {
from from

View File

@@ -239,6 +239,8 @@ export type WorkflowStatusInput = {
id: string; id: string;
name: string; name: string;
requiresReason?: boolean; requiresReason?: boolean;
/** Work-in-progress limit (null: none) */
wipLimit?: number | null | undefined;
}; };
export type UserRefFieldsFragment = { id: string; name: string }; export type UserRefFieldsFragment = { id: string; name: string };
@@ -261,6 +263,7 @@ export type ProjectFieldsFragment = {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit: number | null;
}>; }>;
transitions: Array<{ from: string; to: Array<string> }>; transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{ issueTypes: Array<{
@@ -892,6 +895,7 @@ export type MyProjectsQuery = {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit: number | null;
}>; }>;
transitions: Array<{ from: string; to: Array<string> }>; transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{ issueTypes: Array<{
@@ -929,6 +933,7 @@ export type ProjectQuery = {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit: number | null;
}>; }>;
transitions: Array<{ from: string; to: Array<string> }>; transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{ issueTypes: Array<{
@@ -1076,6 +1081,7 @@ export type PublicProjectsQuery = {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit: number | null;
}>; }>;
transitions: Array<{ from: string; to: Array<string> }>; transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{ issueTypes: Array<{
@@ -1490,6 +1496,7 @@ export type ProjectChangedSubscription = {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit: number | null;
}>; }>;
transitions: Array<{ from: string; to: Array<string> }>; transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{ issueTypes: Array<{
@@ -1530,6 +1537,7 @@ export type MyProjectsChangedSubscription = {
color: string; color: string;
requiresReason: boolean; requiresReason: boolean;
archived: boolean; archived: boolean;
wipLimit: number | null;
}>; }>;
transitions: Array<{ from: string; to: Array<string> }>; transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{ issueTypes: Array<{
@@ -1648,6 +1656,10 @@ export const ProjectFieldsFragmentDoc = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'archived' }, name: { kind: 'Name', value: 'archived' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
], ],
}, },
}, },
@@ -5664,6 +5676,10 @@ export const MyProjectsDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'archived' }, name: { kind: 'Name', value: 'archived' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
], ],
}, },
}, },
@@ -5865,6 +5881,10 @@ export const ProjectDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'archived' }, name: { kind: 'Name', value: 'archived' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
], ],
}, },
}, },
@@ -6917,6 +6937,10 @@ export const PublicProjectsDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'archived' }, name: { kind: 'Name', value: 'archived' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
], ],
}, },
}, },
@@ -9221,6 +9245,10 @@ export const ProjectChangedDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'archived' }, name: { kind: 'Name', value: 'archived' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
], ],
}, },
}, },
@@ -9409,6 +9437,10 @@ export const MyProjectsChangedDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'archived' }, name: { kind: 'Name', value: 'archived' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
], ],
}, },
}, },

View File

@@ -147,10 +147,9 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
} }
.ts-backlog__row { .ts-backlog__row {
display: grid; display: grid;
grid-template-columns: 2rem auto auto minmax( grid-template-columns:
0, 2rem auto auto minmax(0, 1fr)
1fr auto auto 2rem auto auto;
) auto auto 2rem auto auto;
gap: 0.625rem; gap: 0.625rem;
align-items: center; align-items: center;
padding: 0.375rem 0.875rem; padding: 0.375rem 0.875rem;

View File

@@ -136,3 +136,49 @@
font-weight: 400; font-weight: 400;
color: var(--ts-muted); 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;
}

View File

@@ -16,6 +16,7 @@ import {
untracked, untracked,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { form, FormField, maxLength, required } from '@angular/forms/signals'; import { form, FormField, maxLength, required } from '@angular/forms/signals';
import { import {
PiActionButtonComponent, PiActionButtonComponent,
@@ -30,7 +31,7 @@ import {
type SegmentedControlOption, type SegmentedControlOption,
} from '@pi/ui'; } from '@pi/ui';
import { AuthFacade } from '@task-stream/web/identity/application'; 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 { import {
BoardFacade, BoardFacade,
ProjectFacade, ProjectFacade,
@@ -42,15 +43,44 @@ import {
boardFilterOf, boardFilterOf,
IssuePriority, IssuePriority,
IssueSummary, IssueSummary,
overWip,
POINT_SCALE, POINT_SCALE,
ReleaseStatus, ReleaseStatus,
SavedFilter, SavedFilter,
sumPoints, sumPoints,
SwimlaneMode,
swimlanes,
Workflow,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card'; 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 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. */ /** "Sin asignar" in the person filter. */
const NOBODY = '__nobody__'; const NOBODY = '__nobody__';
@@ -63,6 +93,7 @@ const NOBODY = '__nobody__';
CdkDrag, CdkDrag,
FormField, FormField,
IssueCardComponent, IssueCardComponent,
NgTemplateOutlet,
PiActionButtonComponent, PiActionButtonComponent,
PiSelectFieldComponent, PiSelectFieldComponent,
PiSegmentedControlComponent, PiSegmentedControlComponent,
@@ -139,6 +170,13 @@ const NOBODY = '__nobody__';
[options]="priorityFilterOptions" [options]="priorityFilterOptions"
[(value)]="priorityFilter" [(value)]="priorityFilter"
/> />
<pi-select-field
class="ts-filter"
placeholder="Agrupar por"
[options]="laneOptions"
[value]="laneMode()"
(valueChange)="setLaneMode($event)"
/>
<pi-select-field <pi-select-field
class="ts-filter" class="ts-filter"
placeholder="Sprint" placeholder="Sprint"
@@ -177,50 +215,110 @@ const NOBODY = '__nobody__';
} }
</div> </div>
} @else { } @else {
<div class="ts-board" cdkDropListGroup> @if (laneMode() === 'none') {
@for (column of board.columns(); track column.status) { <div class="ts-board" cdkDropListGroup>
<section @for (column of board.columns(); track column.status) {
class="ts-column" <section
[attr.data-status]="column.status" class="ts-column"
[style.--tone]="statusTone(column.status)" [class.is-over]="isOver(column)"
> [attr.data-status]="column.status"
<header class="ts-column__header"> [style.--tone]="statusTone(column.status)"
<span class="ts-column__name">{{
statusLabel(column.status)
}}</span>
<span class="ts-column__count"
>{{ column.total }}
@if (pointsOf(column); as points) {
<span class="ts-column__points" title="Puntos estimados"
>· {{ points }} pts</span
>
}
</span>
</header>
<div
class="ts-column__list"
cdkDropList
[id]="column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
[cdkDropListEnterPredicate]="canEnter"
(cdkDropListDropped)="drop($event)"
> >
@for (issue of column.issues; track issue.id) { <ng-container
<ts-issue-card [ngTemplateOutlet]="columnHead"
cdkDrag [ngTemplateOutletContext]="{ column }"
[cdkDragData]="issue" />
[issue]="issue" <ng-container
[workflow]="project.workflow()" [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 }"
/> />
} @empty { </section>
<p class="ts-column__empty">Nada por aquí</p> }
} </div>
</div> @for (lane of lanes(); track lane.key) {
</section> <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"
[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
>
}
</span>
</header>
</ng-template>
<ng-template #columnList let-column="column" let-lane="lane">
<div
class="ts-column__list"
cdkDropList
[id]="lane + '|' + column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
[cdkDropListEnterPredicate]="canEnter"
(cdkDropListDropped)="drop($event)"
>
@for (issue of column.issues; track issue.id) {
<ts-issue-card
cdkDrag
[cdkDragData]="issue"
[issue]="issue"
[workflow]="project.workflow()"
/>
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
} }
</div> </div>
} </ng-template>
<ng-template #createForm> <ng-template #createForm>
<form <form
@@ -362,6 +460,7 @@ export class BoardPage {
protected readonly board = inject(BoardFacade); protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService); private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService);
private readonly auth = inject(AuthFacade); private readonly auth = inject(AuthFacade);
private readonly me = computed(() => this.auth.user()?.id ?? null); private readonly me = computed(() => this.auth.user()?.id ?? null);
private readonly createForm = 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 */ /* Saved views */
protected readonly viewName = signal(''); protected readonly viewName = signal('');
protected readonly viewShared = signal(false); protected readonly viewShared = signal(false);
@@ -582,6 +734,12 @@ export class BoardPage {
if (event.previousContainer === event.container) return; if (event.previousContainer === event.container) return;
const to = event.container.data.status; const to = event.container.data.status;
const issueId = event.item.data.id; 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)) { if (this.project.workflow().requiresReason(to)) {
this.pendingMove = { issueId, to }; this.pendingMove = { issueId, to };
this.reasonModel.set({ text: '' }); this.reasonModel.set({ text: '' });

View File

@@ -135,7 +135,8 @@ function idFrom(name: string): string {
</header> </header>
<div class="ts-rows-head ts-status-grid" aria-hidden="true"> <div class="ts-rows-head ts-status-grid" aria-hidden="true">
<span></span><span>Nombre</span><span>Categoría</span <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> </div>
<ol <ol
class="ts-rows" class="ts-rows"
@@ -198,6 +199,21 @@ function idFrom(name: string): string {
</option> </option>
} }
</select> </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"> <label class="ts-check">
<input <input
type="checkbox" type="checkbox"
@@ -693,7 +709,10 @@ function idFrom(name: string): string {
} }
.ts-status-grid { .ts-status-grid {
display: 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; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -996,6 +1015,12 @@ export class SettingsPage {
?.to.includes(to); ?.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 { protected edit(section: Section, index: number, changes: object): void {
this.patch((d) => Object.assign(d[section][index], changes)); this.patch((d) => Object.assign(d[section][index], changes));
} }