From 09bd8c19a3a93dcf093957ae1ef936732e396f64 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 15:09:21 +0200 Subject: [PATCH] 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 --- apps/api/openapi.json | 15 +- apps/api/schema.gql | 8 + apps/mcp/src/app/api/taskstream.schema.ts | 4 + docs/01-vision-general.md | 51 ++-- docs/03-api.md | 2 + .../src/lib/project/project-settings.spec.ts | 17 ++ .../src/lib/project/project-settings.ts | 17 ++ .../presenters/src/lib/common/settings.ts | 1 + .../presenters/src/lib/graphql/inputs.ts | 9 + .../presenters/src/lib/graphql/types.ts | 6 + .../src/lib/http/dto/requests.dto.ts | 12 + .../src/lib/http/dto/responses.dto.ts | 6 + libs/web/tracker/domain/src/index.ts | 1 + .../domain/src/lib/models/swimlanes.spec.ts | 51 ++++ .../domain/src/lib/models/swimlanes.ts | 72 ++++++ .../tracker/domain/src/lib/models/workflow.ts | 7 + .../src/lib/adapters/mappers.ts | 1 + .../src/lib/graphql/fragments.graphql | 1 + .../src/lib/graphql/generated.ts | 32 +++ .../presenters/src/lib/pages/backlog.page.ts | 7 +- .../presenters/src/lib/pages/board.page.scss | 46 ++++ .../presenters/src/lib/pages/board.page.ts | 242 +++++++++++++++--- .../presenters/src/lib/pages/settings.page.ts | 29 ++- 23 files changed, 563 insertions(+), 74 deletions(-) create mode 100644 libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/swimlanes.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c14e7ac..5b6e97a 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": [ diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 8a0464e..55eb7f4 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -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 } \ No newline at end of file diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 17bc158..063cc77 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -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 */ diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index ad373b1..8d9b3d9 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,31 +2,32 @@ ## 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 | -| 🧩 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`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 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) | -| 👥 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 | -| 🎲 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 | -| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | -| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | -| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | -| 📎 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 | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | -| 🏠 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 | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| 📈 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) | +| 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 | +| 🧩 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`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 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) | +| 👥 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 | +| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | +| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | +| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | +| 📎 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 | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | +| 🏠 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 | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 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** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index e9cb5d9..bd370a8 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -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 diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts index 3b9c9c4..88b7957 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts @@ -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(); diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.ts b/libs/api/tracker/domain/src/lib/project/project-settings.ts index 8510a10..ccd821c 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.ts @@ -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( diff --git a/libs/api/tracker/presenters/src/lib/common/settings.ts b/libs/api/tracker/presenters/src/lib/common/settings.ts index bb3d954..cafbd75 100644 --- a/libs/api/tracker/presenters/src/lib/common/settings.ts +++ b/libs/api/tracker/presenters/src/lib/common/settings.ts @@ -14,6 +14,7 @@ export interface SettingsPayload { color: string; requiresReason: boolean; archived: boolean; + wipLimit?: number | null; }[]; transitions: { from: string; to: string[] }[]; issueTypes: { diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 967faaf..6cd9e46 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -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() diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 22e48e5..56023b3 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -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') diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 21b8216..c166cb0 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -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 { diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 18ee3a1..6f78cf9 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -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 { diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 918419f..0ed92be 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -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'; diff --git a/libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts b/libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts new file mode 100644 index 0000000..7caed52 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts @@ -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); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/swimlanes.ts b/libs/web/tracker/domain/src/lib/models/swimlanes.ts new file mode 100644 index 0000000..d44a433 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/swimlanes.ts @@ -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(); + 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; +} diff --git a/libs/web/tracker/domain/src/lib/models/workflow.ts b/libs/web/tracker/domain/src/lib/models/workflow.ts index b4fb7d4..2c53d6c 100644 --- a/libs/web/tracker/domain/src/lib/models/workflow.ts +++ b/libs/web/tracker/domain/src/lib/models/workflow.ts @@ -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; } diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts index c02e1bd..0281a8b 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts @@ -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) => ({ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 4747af8..64011b0 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -28,6 +28,7 @@ fragment ProjectFields on Project { color requiresReason archived + wipLimit } transitions { from diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 725a131..49c8d0a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -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 }>; issueTypes: Array<{ @@ -892,6 +895,7 @@ export type MyProjectsQuery = { color: string; requiresReason: boolean; archived: boolean; + wipLimit: number | null; }>; transitions: Array<{ from: string; to: Array }>; issueTypes: Array<{ @@ -929,6 +933,7 @@ export type ProjectQuery = { color: string; requiresReason: boolean; archived: boolean; + wipLimit: number | null; }>; transitions: Array<{ from: string; to: Array }>; issueTypes: Array<{ @@ -1076,6 +1081,7 @@ export type PublicProjectsQuery = { color: string; requiresReason: boolean; archived: boolean; + wipLimit: number | null; }>; transitions: Array<{ from: string; to: Array }>; issueTypes: Array<{ @@ -1490,6 +1496,7 @@ export type ProjectChangedSubscription = { color: string; requiresReason: boolean; archived: boolean; + wipLimit: number | null; }>; transitions: Array<{ from: string; to: Array }>; issueTypes: Array<{ @@ -1530,6 +1537,7 @@ export type MyProjectsChangedSubscription = { color: string; requiresReason: boolean; archived: boolean; + wipLimit: number | null; }>; transitions: Array<{ from: string; to: Array }>; 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' }, + }, ], }, }, diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts index 278f0d0..b6d73ec 100644 --- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -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; diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.scss b/libs/web/tracker/presenters/src/lib/pages/board.page.scss index caa8cdf..7908854 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.scss @@ -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; +} diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index a37bd0a..ce57c1e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -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" /> + } @else { -
- @for (column of board.columns(); track column.status) { -
-
- {{ - statusLabel(column.status) - }} - {{ column.total }} - @if (pointsOf(column); as points) { - · {{ points }} pts - } - -
-
+ @for (column of board.columns(); track column.status) { +
- @for (issue of column.issues; track issue.id) { - + +
+ } +
+ } @else { + +
+
+ @for (column of board.columns(); track column.status) { +
+ - } @empty { -

Nada por aquí

- } -
-
+ + } +
+ @for (lane of lanes(); track lane.key) { +
+

+ {{ laneLabel(lane.key) }} + {{ lane.count }} +

+ +
+ @for (column of lane.columns; track column.status) { +
+ +
+ } +
+
+ } + + } + } + + +
+ {{ statusLabel(column.status) }} + {{ column.total }}{{ wipOf(column) ? ' / ' + wipOf(column) : '' }} + @if (pointsOf(column); as points) { + · {{ points }} pts + } + +
+
+ + +
+ @for (issue of column.issues; track issue.id) { + + } @empty { +

Nada por aquí

}
- } +
this.auth.user()?.id ?? null); private readonly createForm = @@ -406,6 +505,59 @@ export class BoardPage { ), ); + /* Swimlanes and WIP */ + protected readonly laneOptions: PiSelectFieldOption[] = [ + { 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(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: '' }); diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index e62436a..444de7e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -135,7 +135,8 @@ function idFrom(name: string): string {
    } +