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>
650 lines
19 KiB
TypeScript
650 lines
19 KiB
TypeScript
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();
|
||
}
|
||
}
|