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:
@@ -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