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:
@@ -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,50 +215,110 @@ const NOBODY = '__nobody__';
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ts-board" cdkDropListGroup>
|
||||
@for (column of board.columns(); track column.status) {
|
||||
<section
|
||||
class="ts-column"
|
||||
[attr.data-status]="column.status"
|
||||
[style.--tone]="statusTone(column.status)"
|
||||
>
|
||||
<header class="ts-column__header">
|
||||
<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)"
|
||||
@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)"
|
||||
>
|
||||
@for (issue of column.issues; track issue.id) {
|
||||
<ts-issue-card
|
||||
cdkDrag
|
||||
[cdkDragData]="issue"
|
||||
[issue]="issue"
|
||||
[workflow]="project.workflow()"
|
||||
<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 }"
|
||||
/>
|
||||
} @empty {
|
||||
<p class="ts-column__empty">Nada por aquí</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</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"
|
||||
[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>
|
||||
}
|
||||
</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