feat: estimate tickets and see the team's velocity
Story points can be set on the ticket page and when creating a ticket, and show on cards, column headers, subtasks and the backlog. The Sprints tab charts the points finished per closed sprint and warns when a planned sprint holds more than the recent average. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
untracked,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
ProjectFacade,
|
||||
provideBacklogFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { IssueSummary } from '@task-stream/web/tracker/domain';
|
||||
import { IssueSummary, sumPoints } from '@task-stream/web/tracker/domain';
|
||||
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
|
||||
/** /p/:key/backlog — what is still to start, in the order the team takes it. */
|
||||
@@ -43,6 +44,11 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra
|
||||
para reordenar; las columnas del tablero siguen este mismo orden.
|
||||
</p>
|
||||
@if (facade.loaded() && facade.issues().length) {
|
||||
<span class="ts-muted ts-mono"
|
||||
>{{ points() }} pts estimados · {{ unestimated() }} sin estimar</span
|
||||
>
|
||||
}
|
||||
@if (facade.loaded() && facade.total() > facade.issues().length) {
|
||||
<span class="ts-muted ts-mono"
|
||||
>{{ facade.issues().length }} de {{ facade.total() }}</span
|
||||
@@ -92,6 +98,9 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
<span class="ts-backlog__priority ts-muted">{{
|
||||
priorityLabel(issue)
|
||||
}}</span>
|
||||
<span class="ts-backlog__points ts-mono" title="Estimación">{{
|
||||
issue.storyPoints ?? '—'
|
||||
}}</span>
|
||||
<span
|
||||
class="ts-backlog__who"
|
||||
[title]="issue.assignee?.name ?? 'Sin asignar'"
|
||||
@@ -138,7 +147,10 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
}
|
||||
.ts-backlog__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem auto auto minmax(0, 1fr) auto auto 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;
|
||||
@@ -151,6 +163,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
@media (max-width: 720px) {
|
||||
grid-template-columns: 2rem auto minmax(0, 1fr) auto;
|
||||
.ts-backlog__type,
|
||||
.ts-backlog__points,
|
||||
.ts-pill,
|
||||
.ts-backlog__priority,
|
||||
.ts-backlog__who {
|
||||
@@ -180,6 +193,10 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
.ts-backlog__priority {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-backlog__points {
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
.ts-backlog__who {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
@@ -205,6 +222,10 @@ export class BacklogPage {
|
||||
protected readonly facade = inject(BacklogFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
protected readonly initialsOf = initials;
|
||||
protected readonly points = computed(() => sumPoints(this.facade.issues()));
|
||||
protected readonly unestimated = computed(
|
||||
() => this.facade.issues().filter((i) => i.storyPoints === null).length,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
||||
@@ -131,3 +131,8 @@
|
||||
outline-offset: -4px;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.ts-column__points {
|
||||
font-weight: 400;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@ import {
|
||||
boardFilterOf,
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
POINT_SCALE,
|
||||
ReleaseStatus,
|
||||
SavedFilter,
|
||||
sumPoints,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { IssueCardComponent } from '../ui/issue-card';
|
||||
import { options, PRIORITY, statusLook } from '../ui/copy';
|
||||
import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
|
||||
|
||||
const ALL = '';
|
||||
/** "Sin asignar" in the person filter. */
|
||||
@@ -186,7 +188,14 @@ const NOBODY = '__nobody__';
|
||||
<span class="ts-column__name">{{
|
||||
statusLabel(column.status)
|
||||
}}</span>
|
||||
<span class="ts-column__count">{{ column.total }}</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"
|
||||
@@ -254,6 +263,12 @@ const NOBODY = '__nobody__';
|
||||
<pi-select-field [formField]="f.area" [options]="areaOptions()">
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[formField]="f.storyPoints"
|
||||
[options]="pointsOptions"
|
||||
>
|
||||
<pi-label>Estimación</pi-label>
|
||||
</pi-select-field>
|
||||
</div>
|
||||
<div class="ts-form__row">
|
||||
<pi-select-field
|
||||
@@ -461,6 +476,9 @@ export class BoardPage {
|
||||
],
|
||||
);
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly pointsOptions = pointOptions(POINT_SCALE);
|
||||
/** Estimated points of the cards loaded in a column. */
|
||||
protected pointsOf = (column: BoardColumn) => sumPoints(column.issues);
|
||||
protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
|
||||
() =>
|
||||
this.project
|
||||
@@ -477,6 +495,7 @@ export class BoardPage {
|
||||
figmaUrl: '',
|
||||
priority: IssuePriority.MEDIUM as string,
|
||||
area: ALL as string,
|
||||
storyPoints: '' as string,
|
||||
assigneeId: ALL as string,
|
||||
releaseId: ALL as string,
|
||||
});
|
||||
@@ -648,6 +667,7 @@ export class BoardPage {
|
||||
figmaUrl: '',
|
||||
priority: IssuePriority.MEDIUM,
|
||||
area: ALL,
|
||||
storyPoints: '',
|
||||
assigneeId: ALL,
|
||||
releaseId: this.releaseFilter() || ALL,
|
||||
});
|
||||
@@ -669,6 +689,7 @@ export class BoardPage {
|
||||
: null,
|
||||
priority: v.priority as IssuePriority,
|
||||
area: v.area || null,
|
||||
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
|
||||
assigneeId: v.assigneeId || null,
|
||||
releaseId: v.releaseId || null,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
matches,
|
||||
mentionQuery,
|
||||
mentionSegments,
|
||||
POINT_SCALE,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
initials,
|
||||
LINK,
|
||||
options,
|
||||
pointOptions,
|
||||
PRIORITY,
|
||||
statusLook,
|
||||
timeAgo,
|
||||
@@ -248,7 +250,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</h2>
|
||||
@if (facade.children().length) {
|
||||
<pi-progress-bar
|
||||
[label]="facade.childProgress().percent + '% terminado'"
|
||||
[label]="
|
||||
facade.childProgress().percent +
|
||||
'% terminado' +
|
||||
(facade.childProgress().storyPoints
|
||||
? ' · ' +
|
||||
facade.childProgress().finishedStoryPoints +
|
||||
' de ' +
|
||||
facade.childProgress().storyPoints +
|
||||
' puntos'
|
||||
: '')
|
||||
"
|
||||
[value]="facade.childProgress().percent"
|
||||
/>
|
||||
<ul class="ts-children">
|
||||
@@ -618,6 +630,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
<pi-label>Prioridad</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="pointsOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.storyPoints === null ? '' : '' + issue.storyPoints"
|
||||
(valueChange)="update({ storyPoints: $event ? +$event : null })"
|
||||
>
|
||||
<pi-label>Estimación</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="areaOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
@@ -801,6 +821,9 @@ export class IssuePage {
|
||||
);
|
||||
});
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly pointsOptions = computed(() =>
|
||||
pointOptions(POINT_SCALE, this.facade.issue()?.storyPoints ?? null),
|
||||
);
|
||||
/** Active values, plus the ticket's own even if archived since. */
|
||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => {
|
||||
@@ -964,6 +987,7 @@ export class IssuePage {
|
||||
type?: string;
|
||||
priority?: IssuePriority;
|
||||
area?: string | null;
|
||||
storyPoints?: number | null;
|
||||
}): void {
|
||||
this.facade.update(changes);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { Sprint, SprintStatus } from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
Sprint,
|
||||
SprintStatus,
|
||||
velocity,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
@@ -175,6 +179,14 @@ const STATUS: Record<SprintStatus, string> = {
|
||||
>{{ sprint.progress.total }} tickets ·
|
||||
{{ sprint.progress.storyPoints }} pts</span
|
||||
>
|
||||
@if (overCommitted(sprint)) {
|
||||
<span class="ts-sprint__warn"
|
||||
>Por encima de la velocidad media ({{
|
||||
pace().average
|
||||
}}
|
||||
pts)</span
|
||||
>
|
||||
}
|
||||
</button>
|
||||
@if (project.canManage()) {
|
||||
<span class="ts-sprint__row-actions">
|
||||
@@ -224,6 +236,26 @@ const STATUS: Record<SprintStatus, string> = {
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (pace().history.length) {
|
||||
<h2 class="ts-sprints__title">Velocidad</h2>
|
||||
<section class="ts-panel ts-velocity" aria-label="Velocidad">
|
||||
<p class="ts-muted">
|
||||
Puntos terminados en cada sprint cerrado.
|
||||
@if (pace().average !== null) {
|
||||
Media de los últimos {{ paceWindow }}:
|
||||
<strong>{{ pace().average }} puntos</strong> por sprint.
|
||||
}
|
||||
</p>
|
||||
<ts-series-chart
|
||||
axisLabel="Sprint"
|
||||
[labels]="velocityLabels()"
|
||||
[series]="velocitySeries()"
|
||||
[summary]="velocitySummary()"
|
||||
[height]="200"
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (!project.sprints().length) {
|
||||
<pi-empty-state
|
||||
icon="flag"
|
||||
@@ -367,6 +399,18 @@ const STATUS: Record<SprintStatus, string> = {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ts-velocity {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.125rem;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.ts-sprint__warn {
|
||||
color: var(--ts-c-amber, var(--ts-muted));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-pill[data-status='ACTIVE'] {
|
||||
--tone: var(--ts-brand);
|
||||
}
|
||||
@@ -446,6 +490,40 @@ export class SprintsPage {
|
||||
: `Burndown del sprint: quedan ${open} tickets por terminar`;
|
||||
});
|
||||
|
||||
/* Velocity */
|
||||
protected readonly paceWindow = 3;
|
||||
protected readonly pace = computed(() =>
|
||||
velocity(this.project.sprints(), this.paceWindow),
|
||||
);
|
||||
protected readonly velocityLabels = computed(() =>
|
||||
this.pace().history.map((h) => h.name),
|
||||
);
|
||||
protected readonly velocitySeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
label: 'Comprometidos',
|
||||
values: this.pace().history.map((h) => h.committed),
|
||||
color: 'var(--ts-muted)',
|
||||
dashed: true,
|
||||
},
|
||||
{
|
||||
label: 'Terminados',
|
||||
values: this.pace().history.map((h) => h.done),
|
||||
color: 'var(--ts-viz-1)',
|
||||
area: true,
|
||||
},
|
||||
]);
|
||||
protected readonly velocitySummary = computed(() => {
|
||||
const { history, average } = this.pace();
|
||||
return `Velocidad de ${history.length} sprints cerrados${
|
||||
average === null ? '' : `: media de ${average} puntos`
|
||||
}`;
|
||||
});
|
||||
/** A planned sprint holding more than the team usually finishes. */
|
||||
protected overCommitted = (sprint: Sprint) => {
|
||||
const average = this.pace().average;
|
||||
return average !== null && sprint.progress.storyPoints > average;
|
||||
};
|
||||
|
||||
/* Plan / edit */
|
||||
protected readonly editing = signal<Sprint | null>(null);
|
||||
protected readonly name = signal('');
|
||||
|
||||
Reference in New Issue
Block a user