Files
task-stream/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts
Josep Piguillem 6aec774c92 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>
2026-10-08 15:04:11 +02:00

650 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiDateRangeFieldComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiProgressBarComponent,
PiSegmentedControlComponent,
PiSelectFieldComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
Sprint,
SprintStatus,
velocity,
} from '@task-stream/web/tracker/domain';
import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
const DAY = 86_400_000;
const BACKLOG = '';
/** `YYYY-MM-DD` of a local date (what the date picker gives). */
const isoDay = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const localDate = (day: string) => new Date(`${day}T00:00:00`);
const label = (day: string, month: 'short' | 'long' = 'short') =>
localDate(day).toLocaleDateString('es', { day: 'numeric', month });
const STATUS: Record<SprintStatus, string> = {
[SprintStatus.ACTIVE]: 'En curso',
[SprintStatus.PLANNED]: 'Planificado',
[SprintStatus.CLOSED]: 'Cerrado',
};
/** /p/:key/sprints — plan the iterations, follow the one under way, close it. */
@Component({
selector: 'ts-sprints-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
SeriesChartComponent,
PiActionButtonComponent,
PiDateRangeFieldComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiProgressBarComponent,
PiSegmentedControlComponent,
PiSelectFieldComponent,
PiTextareaComponent,
PiTextFieldComponent,
],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Iteraciones de unas semanas. Mete tickets desde su ficha; al cerrar un
sprint, lo que quede pasa al siguiente o vuelve al backlog.
</p>
@if (project.canManage()) {
<pi-action-button
color="primary"
text="Planificar sprint"
(execute)="openCreate()"
/>
}
</div>
@if (focus(); as sprint) {
<section
class="ts-sprint ts-sprint--focus ts-panel"
[attr.data-status]="sprint.status"
aria-labelledby="ts-sprint-focus"
>
<header class="ts-sprint__head">
<h2 id="ts-sprint-focus">{{ sprint.name }}</h2>
<span class="ts-pill" [attr.data-status]="sprint.status">{{
statusLabel(sprint)
}}</span>
<span class="ts-muted">{{ when(sprint) }}</span>
</header>
@if (sprint.goal) {
<p class="ts-sprint__goal">🎯 {{ sprint.goal }}</p>
}
<pi-progress-bar
[label]="progressLabel(sprint)"
[value]="sprint.progress.percent"
/>
@if (project.burndown()?.sprintId === sprint.id) {
<div class="ts-sprint__chart">
<pi-segmented-control
ariaLabel="Medir en"
[options]="units"
[(value)]="unit"
/>
<ts-series-chart
axisLabel="Día"
[labels]="burndownLabels()"
[longLabels]="burndownLongLabels()"
[series]="burndownSeries()"
[marker]="todayMarker()"
[summary]="burndownSummary()"
/>
</div>
}
<div class="ts-sprint__actions">
<a
class="ts-plain-link"
routerLink="../board"
[queryParams]="{ sprint: sprint.id }"
>Ver en el tablero →</a
>
<span class="ts-spacer"></span>
@if (project.canManage()) {
@switch (sprint.status) {
@case ('ACTIVE') {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Editar"
(execute)="openEdit(sprint)"
/>
<pi-action-button
size="small"
color="primary"
text="Cerrar sprint"
(execute)="openClose(sprint)"
/>
}
@case ('PLANNED') {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Editar"
(execute)="openEdit(sprint)"
/>
<pi-action-button
size="small"
color="primary"
text="Empezar"
[disabled]="!!project.activeSprint()"
(execute)="project.startSprint(sprint.id)"
/>
}
}
}
</div>
</section>
}
@if (planned().length) {
<h2 class="ts-sprints__title">Planificados</h2>
<ul class="ts-sprints">
@for (sprint of planned(); track sprint.id) {
<li class="ts-sprint ts-panel" [class.is-focus]="isFocus(sprint)">
<button
type="button"
class="ts-sprint__pick"
(click)="show(sprint)"
>
<strong>{{ sprint.name }}</strong>
<span class="ts-muted">{{ when(sprint) }}</span>
<span class="ts-mono"
>{{ 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">
<pi-action-button
size="small"
color="grey"
appearance="ghost"
text="Borrar"
(execute)="project.deleteSprint(sprint.id)"
/>
<pi-action-button
size="small"
color="primary"
appearance="outline"
text="Empezar"
[disabled]="!!project.activeSprint()"
(execute)="project.startSprint(sprint.id)"
/>
</span>
}
</li>
}
</ul>
}
@if (closed().length) {
<h2 class="ts-sprints__title">Cerrados</h2>
<ul class="ts-sprints">
@for (sprint of closed(); track sprint.id) {
<li class="ts-sprint ts-panel" [class.is-focus]="isFocus(sprint)">
<button
type="button"
class="ts-sprint__pick"
(click)="show(sprint)"
>
<strong>{{ sprint.name }}</strong>
<span class="ts-muted">{{ when(sprint) }}</span>
<span class="ts-mono"
>{{ sprint.progress.finished }}/{{
sprint.progress.total
}}
terminados</span
>
</button>
</li>
}
</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"
title="Sin sprints"
description="Planifica el primero: un nombre, unas fechas y un objetivo. Luego mete en él los tickets."
/>
}
<ng-template #sprintForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); save()"
novalidate
>
<pi-text-field placeholder="Sprint 14" [(value)]="name">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<pi-date-range-field
startPlaceholder="Empieza"
endPlaceholder="Acaba"
[(value)]="range"
>
<pi-label [isRequired]="true">Fechas</pi-label>
</pi-date-range-field>
<pi-textarea
placeholder="Qué tiene que quedar hecho al final"
[(value)]="goal"
>
<pi-label>Objetivo</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
[text]="editing() ? 'Guardar' : 'Planificar'"
[disabled]="!canSave()"
(execute)="save()"
/>
</form>
</ng-template>
<ng-template #closeForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); close()"
novalidate
>
<p class="ts-muted">
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
este sprint como registro de lo hecho.
</p>
<pi-select-field [options]="carryOverOptions()" [(value)]="carryOverTo">
<pi-label>Lo pendiente va a</pi-label>
</pi-select-field>
<pi-action-button
size="w100"
color="primary"
text="Cerrar sprint"
(execute)="close()"
/>
</form>
</ng-template>
`,
styles: `
.ts-sprint {
display: grid;
gap: 0.875rem;
padding: 1rem 1.125rem;
border-left: 3px solid var(--ts-line-strong);
&[data-status='ACTIVE'] {
border-left-color: var(--ts-brand);
}
&.is-focus {
border-left-color: var(--ts-brand);
}
}
.ts-sprint--focus {
margin-bottom: 1.5rem;
}
.ts-sprint__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.75rem;
h2 {
margin: 0;
font-size: 1.25rem;
}
}
.ts-sprint__goal {
margin: 0;
}
.ts-sprint__chart {
display: grid;
gap: 0.5rem;
pi-segmented-control {
justify-self: end;
}
}
.ts-sprint__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.ts-sprints__title {
margin: 1.25rem 0 0.625rem;
font-size: 0.875rem;
color: var(--ts-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ts-sprints {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
.ts-sprint {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
padding: 0.625rem 0.875rem;
}
}
.ts-sprint__pick {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.875rem;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:hover strong {
color: var(--ts-brand);
}
}
.ts-sprint__row-actions {
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);
}
`,
})
export class SprintsPage {
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly sprintForm =
viewChild.required<TemplateRef<unknown>>('sprintForm');
private readonly closeForm =
viewChild.required<TemplateRef<unknown>>('closeForm');
/** The sprint picked from the lists; by default the one under way. */
private readonly picked = signal<string | null>(null);
protected readonly focus = computed(() => {
const sprints = this.project.sprints();
return (
sprints.find((s) => s.id === this.picked()) ??
this.project.activeSprint() ??
sprints.find((s) => s.status === SprintStatus.PLANNED) ??
null
);
});
protected readonly planned = computed(() =>
this.project.sprints().filter((s) => s.status === SprintStatus.PLANNED),
);
protected readonly closed = computed(() =>
this.project.sprints().filter((s) => s.status === SprintStatus.CLOSED),
);
/* Burndown */
protected readonly units = [
{ value: 'tickets', label: 'Tickets' },
{ value: 'points', label: 'Puntos' },
];
protected readonly unit = signal<string>('tickets');
protected readonly burndownLabels = computed(
() => this.project.burndown()?.dates.map((d) => label(d)) ?? [],
);
protected readonly burndownLongLabels = computed(
() => this.project.burndown()?.dates.map((d) => label(d, 'long')) ?? [],
);
protected readonly burndownSeries = computed<ChartSeries[]>(() => {
const burndown = this.project.burndown();
if (!burndown) return [];
const points = this.unit() === 'points';
const remaining = points ? burndown.remainingPoints : burndown.remaining;
// The ideal line starts from what was open on the first day, in the unit shown.
const first = remaining[0] ?? 0;
const span = Math.max(burndown.dates.length - 1, 1);
return [
{
label: 'Ideal',
values: burndown.dates.map(
(_, i) => Math.round((first * (span - i) * 10) / span) / 10,
),
color: 'var(--ts-muted)',
dashed: true,
},
{
label: points ? 'Puntos pendientes' : 'Tickets pendientes',
values: remaining,
color: 'var(--ts-viz-1)',
},
];
});
protected readonly todayMarker = computed(() => {
const index = this.project.burndown()?.dates.indexOf(isoDay(new Date()));
return index === undefined || index === -1 ? null : { index, label: 'Hoy' };
});
protected readonly burndownSummary = computed(() => {
const burndown = this.project.burndown();
const open = last(burndown?.remaining.filter((v) => v !== null) ?? []);
return open === undefined
? 'Burndown del sprint'
: `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('');
protected readonly goal = signal('');
protected readonly range = signal<{ start: Date | null; end: Date | null }>({
start: null,
end: null,
});
protected readonly canSave = computed(
() => !!this.name().trim() && !!this.range().start && !!this.range().end,
);
/* Close */
private closing: Sprint | null = null;
protected readonly unfinished = signal(0);
protected readonly carryOverTo = signal<string | null>(BACKLOG);
protected readonly carryOverOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: BACKLOG, label: 'El backlog' },
...this.planned().map((s) => ({
value: s.id,
label: `${s.name} (${label(s.startDate)})`,
})),
],
);
constructor() {
// Load the burndown of whatever sprint is in focus.
effect(() => {
const sprint = this.focus();
if (sprint && sprint.status !== SprintStatus.PLANNED)
untracked(() => this.project.loadBurndown(sprint.id));
});
}
protected statusLabel = (sprint: Sprint) => STATUS[sprint.status];
protected isFocus = (sprint: Sprint) => this.focus()?.id === sprint.id;
/** "12 oct – 23 oct · quedan 5 días". */
protected when(sprint: Sprint): string {
const dates = `${label(sprint.startDate)} – ${label(sprint.endDate)}`;
if (sprint.status !== SprintStatus.ACTIVE) return dates;
const left = Math.ceil(
(localDate(sprint.endDate).getTime() + DAY - Date.now()) / DAY,
);
if (left < 0) return `${dates} · pasado de fecha`;
return `${dates} · ${left === 1 ? 'queda 1 día' : `quedan ${left} días`}`;
}
protected progressLabel(sprint: Sprint): string {
const { finished, total, finishedStoryPoints, storyPoints } =
sprint.progress;
const points = storyPoints
? ` · ${finishedStoryPoints} de ${storyPoints} puntos`
: '';
return `${finished} de ${total} tickets terminados${points}`;
}
protected show(sprint: Sprint): void {
this.picked.set(sprint.id);
}
protected openCreate(): void {
this.editing.set(null);
const lastEnd = last(
this.project
.sprints()
.map((s) => s.endDate)
.sort(),
);
// Right after the last one, two weeks long.
const start = lastEnd
? new Date(localDate(lastEnd).getTime() + DAY)
: new Date();
this.name.set(`Sprint ${this.project.sprints().length + 1}`);
this.goal.set('');
this.range.set({
start,
end: new Date(start.getTime() + 13 * DAY),
});
this.drawer.open('Planificar sprint', this.sprintForm());
}
protected openEdit(sprint: Sprint): void {
this.editing.set(sprint);
this.name.set(sprint.name);
this.goal.set(sprint.goal ?? '');
this.range.set({
start: localDate(sprint.startDate),
end: localDate(sprint.endDate),
});
this.drawer.open(`Editar ${sprint.name}`, this.sprintForm());
}
protected save(): void {
const { start, end } = this.range();
if (!this.canSave() || !start || !end) return;
const details = {
name: this.name().trim(),
goal: this.goal().trim() || null,
startDate: isoDay(start),
endDate: isoDay(end),
};
const sprint = this.editing();
if (sprint) this.project.updateSprint({ sprintId: sprint.id, ...details });
else this.project.createSprint(details);
this.drawer.closeAll();
}
protected openClose(sprint: Sprint): void {
this.closing = sprint;
this.unfinished.set(sprint.progress.total - sprint.progress.finished);
this.carryOverTo.set(this.planned()[0]?.id ?? BACKLOG);
this.drawer.open(`Cerrar ${sprint.name}`, this.closeForm());
}
protected close(): void {
if (!this.closing) return;
this.project.closeSprint(this.closing.id, this.carryOverTo() || null);
this.closing = null;
this.drawer.closeAll();
}
}