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:
2026-10-08 15:04:11 +02:00
parent 50d3ffd1dd
commit 6aec774c92
16 changed files with 304 additions and 20 deletions

View File

@@ -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('');