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

@@ -15,6 +15,7 @@
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | | 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |

View File

@@ -6,3 +6,4 @@ export * from './lib/ports/gateways';
export * from './lib/models/mentions'; export * from './lib/models/mentions';
export * from './lib/models/hierarchy'; export * from './lib/models/hierarchy';
export * from './lib/models/views'; export * from './lib/models/views';
export * from './lib/models/estimation';

View File

@@ -0,0 +1,37 @@
import { SprintStatus } from './enums';
import { velocity } from './estimation';
import { sumPoints } from './hierarchy';
import { Sprint } from './models';
const sprint = (
id: string,
startDate: string,
done: number,
status = SprintStatus.CLOSED,
) =>
({
id,
name: id,
startDate,
status,
progress: { storyPoints: done + 2, finishedStoryPoints: done },
}) as Sprint;
describe('estimation', () => {
it('averages the points finished in the last closed sprints', () => {
const { history, average } = velocity([
sprint('s4', '2026-10-26', 30, SprintStatus.ACTIVE),
sprint('s3', '2026-10-12', 21),
sprint('s1', '2026-09-14', 8),
sprint('s2', '2026-09-28', 13),
sprint('s0', '2026-09-01', 99),
]);
expect(history.map((h) => h.sprintId)).toEqual(['s0', 's1', 's2', 's3']);
expect(average).toBe(14);
expect(velocity([]).average).toBeNull();
});
it('counts unestimated tickets as zero', () => {
expect(sumPoints([{ storyPoints: 3 }, { storyPoints: null }, {}])).toBe(3);
});
});

View File

@@ -0,0 +1,40 @@
import { SprintStatus } from './enums';
import { Sprint } from './models';
/** The usual scale, so estimates stay comparable (the API takes 0-100). */
export const POINT_SCALE = [0, 1, 2, 3, 5, 8, 13, 21] as const;
export interface SprintVelocity {
sprintId: string;
name: string;
/** Points in it when it closed. */
committed: number;
/** Of those, finished (done or cancelled). */
done: number;
}
/**
* Points finished per closed sprint (oldest first) and the average of the
* last `window` of them: a team's pace, to plan the next one.
*/
export function velocity(
sprints: Sprint[],
window = 3,
): { history: SprintVelocity[]; average: number | null } {
const history = sprints
.filter((s) => s.status === SprintStatus.CLOSED)
.sort((a, b) => a.startDate.localeCompare(b.startDate))
.map((s) => ({
sprintId: s.id,
name: s.name,
committed: s.progress.storyPoints,
done: s.progress.finishedStoryPoints,
}));
const recent = history.slice(-window);
return {
history,
average: recent.length
? Math.round(recent.reduce((sum, s) => sum + s.done, 0) / recent.length)
: null,
};
}

View File

@@ -6,23 +6,26 @@ const FINISHED: StatusCategory[] = [
StatusCategory.CANCELLED, StatusCategory.CANCELLED,
]; ];
/** Progress of the tickets hanging from one (story points are not loaded here). */ /** Progress of the tickets hanging from one. */
export function childProgress(children: IssueSummary[]): ChildProgress { export function childProgress(children: IssueSummary[]): ChildProgress {
const finished = children.filter((c) => const done = children.filter((c) => FINISHED.includes(c.statusCategory));
FINISHED.includes(c.statusCategory),
).length;
return { return {
total: children.length, total: children.length,
finished, finished: done.length,
percent: percent:
children.length === 0 children.length === 0
? 0 ? 0
: Math.round((finished / children.length) * 100), : Math.round((done.length / children.length) * 100),
storyPoints: 0, storyPoints: sumPoints(children),
finishedStoryPoints: 0, finishedStoryPoints: sumPoints(done),
}; };
} }
/** Estimated points of some tickets (unestimated ones count 0). */
export function sumPoints(issues: { storyPoints?: number | null }[]): number {
return issues.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
}
/** "3/5": ticked steps of a checklist. */ /** "3/5": ticked steps of a checklist. */
export function checklistProgress(items: ChecklistItem[]): { export function checklistProgress(items: ChecklistItem[]): {
done: number; done: number;

View File

@@ -28,6 +28,7 @@ export interface CreateIssueInput {
priority?: IssuePriority; priority?: IssuePriority;
labels?: string[]; labels?: string[];
figmaUrl?: string | null; figmaUrl?: string | null;
storyPoints?: number | null;
assigneeId?: string | null; assigneeId?: string | null;
releaseId?: string | null; releaseId?: string | null;
/** Epic or ticket the new one breaks down. */ /** Epic or ticket the new one breaks down. */

View File

@@ -114,6 +114,8 @@ export interface IssueSummary {
parent: IssueParent | null; parent: IssueParent | null;
/** Place in the backlog and in its board column (ascending, as text). */ /** Place in the backlog and in its board column (ascending, as text). */
rank: string; rank: string;
/** Estimate; null when nobody estimated it. */
storyPoints: number | null;
commentCount: number; commentCount: number;
updatedAt: string; updatedAt: string;
version: number; version: number;
@@ -141,7 +143,6 @@ export interface Issue extends IssueSummary {
description: string | null; description: string | null;
reporter: UserRef; reporter: UserRef;
figmaUrl: string | null; figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null; dueDate: string | null;
links: IssueLink[]; links: IssueLink[];
attachments: Attachment[]; attachments: Attachment[];

View File

@@ -79,13 +79,13 @@ fragment IssueSummaryFields on Issue {
title title
} }
rank rank
storyPoints
} }
fragment IssueFields on Issue { fragment IssueFields on Issue {
...IssueSummaryFields ...IssueSummaryFields
description description
figmaUrl figmaUrl
storyPoints
dueDate dueDate
createdAt createdAt
allowedTransitions allowedTransitions

View File

@@ -289,6 +289,7 @@ export type IssueSummaryFieldsFragment = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -298,7 +299,6 @@ export type IssueSummaryFieldsFragment = {
export type IssueFieldsFragment = { export type IssueFieldsFragment = {
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null; dueDate: string | null;
createdAt: string; createdAt: string;
allowedTransitions: Array<string>; allowedTransitions: Array<string>;
@@ -316,6 +316,7 @@ export type IssueFieldsFragment = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
reporter: { id: string; name: string }; reporter: { id: string; name: string };
links: Array<{ links: Array<{
type: IssueLinkType; type: IssueLinkType;
@@ -409,6 +410,7 @@ export type BoardQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -426,7 +428,6 @@ export type IssueQuery = {
issue: { issue: {
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null; dueDate: string | null;
createdAt: string; createdAt: string;
allowedTransitions: Array<string>; allowedTransitions: Array<string>;
@@ -444,6 +445,7 @@ export type IssueQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
reporter: { id: string; name: string }; reporter: { id: string; name: string };
links: Array<{ links: Array<{
type: IssueLinkType; type: IssueLinkType;
@@ -490,6 +492,7 @@ export type SearchIssuesQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -642,6 +645,7 @@ export type MyIssuesQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -671,6 +675,7 @@ export type IssueChildrenQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -700,6 +705,7 @@ export type EpicsQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -771,6 +777,7 @@ export type BacklogQuery = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -1396,6 +1403,7 @@ export type IssueChangedSubscription = {
updatedAt: string; updatedAt: string;
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null;
assignee: { id: string; name: string } | null; assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null; release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null; sprint: { id: string; name: string } | null;
@@ -1783,6 +1791,7 @@ export const IssueSummaryFieldsFragmentDoc = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -1822,7 +1831,6 @@ export const IssueFieldsFragmentDoc = {
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ {
@@ -1997,6 +2005,7 @@ export const IssueFieldsFragmentDoc = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -2658,6 +2667,7 @@ export const BoardDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -2801,6 +2811,7 @@ export const IssueDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -2820,7 +2831,6 @@ export const IssueDocument = {
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ {
@@ -3063,6 +3073,7 @@ export const SearchIssuesDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -4290,6 +4301,7 @@ export const MyIssuesDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -4447,6 +4459,7 @@ export const IssueChildrenDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -4622,6 +4635,7 @@ export const EpicsDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -5101,6 +5115,7 @@ export const BacklogDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },
@@ -8748,6 +8763,7 @@ export const IssueChangedDocument = {
}, },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
], ],
}, },
}, },

View File

@@ -2,6 +2,7 @@ import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
effect, effect,
inject, inject,
untracked, untracked,
@@ -19,7 +20,7 @@ import {
ProjectFacade, ProjectFacade,
provideBacklogFacade, provideBacklogFacade,
} from '@task-stream/web/tracker/application'; } 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'; import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
/** /p/:key/backlog — what is still to start, in the order the team takes it. */ /** /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 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. para reordenar; las columnas del tablero siguen este mismo orden.
</p> </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) { @if (facade.loaded() && facade.total() > facade.issues().length) {
<span class="ts-muted ts-mono" <span class="ts-muted ts-mono"
>{{ facade.issues().length }} de {{ facade.total() }}</span >{{ 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">{{ <span class="ts-backlog__priority ts-muted">{{
priorityLabel(issue) priorityLabel(issue)
}}</span> }}</span>
<span class="ts-backlog__points ts-mono" title="Estimación">{{
issue.storyPoints ?? '—'
}}</span>
<span <span
class="ts-backlog__who" class="ts-backlog__who"
[title]="issue.assignee?.name ?? 'Sin asignar'" [title]="issue.assignee?.name ?? 'Sin asignar'"
@@ -138,7 +147,10 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
} }
.ts-backlog__row { .ts-backlog__row {
display: grid; 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; gap: 0.625rem;
align-items: center; align-items: center;
padding: 0.375rem 0.875rem; padding: 0.375rem 0.875rem;
@@ -151,6 +163,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
@media (max-width: 720px) { @media (max-width: 720px) {
grid-template-columns: 2rem auto minmax(0, 1fr) auto; grid-template-columns: 2rem auto minmax(0, 1fr) auto;
.ts-backlog__type, .ts-backlog__type,
.ts-backlog__points,
.ts-pill, .ts-pill,
.ts-backlog__priority, .ts-backlog__priority,
.ts-backlog__who { .ts-backlog__who {
@@ -180,6 +193,10 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
.ts-backlog__priority { .ts-backlog__priority {
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.ts-backlog__points {
font-size: 0.8125rem;
text-align: right;
}
.ts-backlog__who { .ts-backlog__who {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
@@ -205,6 +222,10 @@ export class BacklogPage {
protected readonly facade = inject(BacklogFacade); protected readonly facade = inject(BacklogFacade);
protected readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
protected readonly initialsOf = initials; 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() { constructor() {
effect(() => { effect(() => {

View File

@@ -131,3 +131,8 @@
outline-offset: -4px; outline-offset: -4px;
border-radius: 0.625rem; border-radius: 0.625rem;
} }
.ts-column__points {
font-weight: 400;
color: var(--ts-muted);
}

View File

@@ -42,11 +42,13 @@ import {
boardFilterOf, boardFilterOf,
IssuePriority, IssuePriority,
IssueSummary, IssueSummary,
POINT_SCALE,
ReleaseStatus, ReleaseStatus,
SavedFilter, SavedFilter,
sumPoints,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card'; import { IssueCardComponent } from '../ui/issue-card';
import { options, PRIORITY, statusLook } from '../ui/copy'; import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
const ALL = ''; const ALL = '';
/** "Sin asignar" in the person filter. */ /** "Sin asignar" in the person filter. */
@@ -186,7 +188,14 @@ const NOBODY = '__nobody__';
<span class="ts-column__name">{{ <span class="ts-column__name">{{
statusLabel(column.status) statusLabel(column.status)
}}</span> }}</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> </header>
<div <div
class="ts-column__list" class="ts-column__list"
@@ -254,6 +263,12 @@ const NOBODY = '__nobody__';
<pi-select-field [formField]="f.area" [options]="areaOptions()"> <pi-select-field [formField]="f.area" [options]="areaOptions()">
<pi-label>Área</pi-label> <pi-label>Área</pi-label>
</pi-select-field> </pi-select-field>
<pi-select-field
[formField]="f.storyPoints"
[options]="pointsOptions"
>
<pi-label>Estimación</pi-label>
</pi-select-field>
</div> </div>
<div class="ts-form__row"> <div class="ts-form__row">
<pi-select-field <pi-select-field
@@ -461,6 +476,9 @@ export class BoardPage {
], ],
); );
protected readonly priorityOptions = options(PRIORITY); 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>[]>( protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
() => () =>
this.project this.project
@@ -477,6 +495,7 @@ export class BoardPage {
figmaUrl: '', figmaUrl: '',
priority: IssuePriority.MEDIUM as string, priority: IssuePriority.MEDIUM as string,
area: ALL as string, area: ALL as string,
storyPoints: '' as string,
assigneeId: ALL as string, assigneeId: ALL as string,
releaseId: ALL as string, releaseId: ALL as string,
}); });
@@ -648,6 +667,7 @@ export class BoardPage {
figmaUrl: '', figmaUrl: '',
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
area: ALL, area: ALL,
storyPoints: '',
assigneeId: ALL, assigneeId: ALL,
releaseId: this.releaseFilter() || ALL, releaseId: this.releaseFilter() || ALL,
}); });
@@ -669,6 +689,7 @@ export class BoardPage {
: null, : null,
priority: v.priority as IssuePriority, priority: v.priority as IssuePriority,
area: v.area || null, area: v.area || null,
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
assigneeId: v.assigneeId || null, assigneeId: v.assigneeId || null,
releaseId: v.releaseId || null, releaseId: v.releaseId || null,
}); });

View File

@@ -44,6 +44,7 @@ import {
matches, matches,
mentionQuery, mentionQuery,
mentionSegments, mentionSegments,
POINT_SCALE,
ReleaseStatus, ReleaseStatus,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { import {
@@ -51,6 +52,7 @@ import {
initials, initials,
LINK, LINK,
options, options,
pointOptions,
PRIORITY, PRIORITY,
statusLook, statusLook,
timeAgo, timeAgo,
@@ -248,7 +250,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</h2> </h2>
@if (facade.children().length) { @if (facade.children().length) {
<pi-progress-bar <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" [value]="facade.childProgress().percent"
/> />
<ul class="ts-children"> <ul class="ts-children">
@@ -618,6 +630,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
> >
<pi-label>Prioridad</pi-label> <pi-label>Prioridad</pi-label>
</pi-select-field> </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 <pi-select-field
[options]="areaOptions()" [options]="areaOptions()"
[disabled]="!project.canContribute()" [disabled]="!project.canContribute()"
@@ -801,6 +821,9 @@ export class IssuePage {
); );
}); });
protected readonly priorityOptions = options(PRIORITY); 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. */ /** Active values, plus the ticket's own even if archived since. */
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>( protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
() => { () => {
@@ -964,6 +987,7 @@ export class IssuePage {
type?: string; type?: string;
priority?: IssuePriority; priority?: IssuePriority;
area?: string | null; area?: string | null;
storyPoints?: number | null;
}): void { }): void {
this.facade.update(changes); this.facade.update(changes);
} }

View File

@@ -24,7 +24,11 @@ import {
} from '@pi/ui'; } from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui'; import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application'; 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'; import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
const DAY = 86_400_000; const DAY = 86_400_000;
@@ -175,6 +179,14 @@ const STATUS: Record<SprintStatus, string> = {
>{{ sprint.progress.total }} tickets · >{{ sprint.progress.total }} tickets ·
{{ sprint.progress.storyPoints }} pts</span {{ sprint.progress.storyPoints }} pts</span
> >
@if (overCommitted(sprint)) {
<span class="ts-sprint__warn"
>Por encima de la velocidad media ({{
pace().average
}}
pts)</span
>
}
</button> </button>
@if (project.canManage()) { @if (project.canManage()) {
<span class="ts-sprint__row-actions"> <span class="ts-sprint__row-actions">
@@ -224,6 +236,26 @@ const STATUS: Record<SprintStatus, string> = {
</ul> </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) { @if (!project.sprints().length) {
<pi-empty-state <pi-empty-state
icon="flag" icon="flag"
@@ -367,6 +399,18 @@ const STATUS: Record<SprintStatus, string> = {
display: inline-flex; display: inline-flex;
gap: 0.25rem; 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'] { .ts-pill[data-status='ACTIVE'] {
--tone: var(--ts-brand); --tone: var(--ts-brand);
} }
@@ -446,6 +490,40 @@ export class SprintsPage {
: `Burndown del sprint: quedan ${open} tickets por terminar`; : `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 */ /* Plan / edit */
protected readonly editing = signal<Sprint | null>(null); protected readonly editing = signal<Sprint | null>(null);
protected readonly name = signal(''); protected readonly name = signal('');

View File

@@ -168,6 +168,24 @@ export const RELEASE_STATUS: Record<
[ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' }, [ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' },
}; };
/** "Sin estimar" + the usual scale (+ `current` if it is off the scale). */
export function pointOptions(
scale: readonly number[],
current: number | null = null,
): { value: string; label: string }[] {
const values = [...scale];
if (current !== null && !values.includes(current)) values.push(current);
return [
{ value: '', label: 'Sin estimar' },
...values
.sort((a, b) => a - b)
.map((v) => ({
value: String(v),
label: v === 1 ? '1 punto' : `${v} puntos`,
})),
];
}
export const options = <T extends string>( export const options = <T extends string>(
record: Record<T, string | { label: string }>, record: Record<T, string | { label: string }>,
) => ) =>

View File

@@ -45,6 +45,11 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
>↑ {{ parent.key }}</span >↑ {{ parent.key }}</span
> >
} }
@if (issue().storyPoints !== null) {
<span class="ts-card__points" title="Estimación">{{
issue().storyPoints
}}</span>
}
@if (issue().release; as release) { @if (issue().release; as release) {
<span class="ts-card__meta" title="Release" <span class="ts-card__meta" title="Release"
>◆ {{ release.name }}</span >◆ {{ release.name }}</span
@@ -73,6 +78,18 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
:host { :host {
display: block; display: block;
} }
.ts-card__points {
display: inline-grid;
place-items: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--ts-line);
color: var(--ts-ink-soft);
font-size: 0.6875rem;
font-weight: 600;
}
.ts-card { .ts-card {
--type: var(--ts-muted); --type: var(--ts-muted);
position: relative; position: relative;