From 6aec774c92b9bb193d4ff86995475ed23bedaafa Mon Sep 17 00:00:00 2001
From: Josep Piguillem
Date: Thu, 8 Oct 2026 15:04:11 +0200
Subject: [PATCH] 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
---
docs/01-vision-general.md | 1 +
libs/web/tracker/domain/src/index.ts | 1 +
.../domain/src/lib/models/estimation.spec.ts | 37 +++++++++
.../domain/src/lib/models/estimation.ts | 40 ++++++++++
.../domain/src/lib/models/hierarchy.ts | 19 +++--
.../tracker/domain/src/lib/models/inputs.ts | 1 +
.../tracker/domain/src/lib/models/models.ts | 3 +-
.../src/lib/graphql/fragments.graphql | 2 +-
.../src/lib/graphql/generated.ts | 24 +++++-
.../presenters/src/lib/pages/backlog.page.ts | 25 +++++-
.../presenters/src/lib/pages/board.page.scss | 5 ++
.../presenters/src/lib/pages/board.page.ts | 25 +++++-
.../presenters/src/lib/pages/issue.page.ts | 26 +++++-
.../presenters/src/lib/pages/sprints.page.ts | 80 ++++++++++++++++++-
.../web/tracker/presenters/src/lib/ui/copy.ts | 18 +++++
.../presenters/src/lib/ui/issue-card.ts | 17 ++++
16 files changed, 304 insertions(+), 20 deletions(-)
create mode 100644 libs/web/tracker/domain/src/lib/models/estimation.spec.ts
create mode 100644 libs/web/tracker/domain/src/lib/models/estimation.ts
diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md
index cf6c53a..ad373b1 100644
--- a/docs/01-vision-general.md
+++ b/docs/01-vision-general.md
@@ -15,6 +15,7 @@
| 👥 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 |
| 🔎 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 |
| 📋 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 |
diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts
index b8f04d1..918419f 100644
--- a/libs/web/tracker/domain/src/index.ts
+++ b/libs/web/tracker/domain/src/index.ts
@@ -6,3 +6,4 @@ export * from './lib/ports/gateways';
export * from './lib/models/mentions';
export * from './lib/models/hierarchy';
export * from './lib/models/views';
+export * from './lib/models/estimation';
diff --git a/libs/web/tracker/domain/src/lib/models/estimation.spec.ts b/libs/web/tracker/domain/src/lib/models/estimation.spec.ts
new file mode 100644
index 0000000..2a1c00e
--- /dev/null
+++ b/libs/web/tracker/domain/src/lib/models/estimation.spec.ts
@@ -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);
+ });
+});
diff --git a/libs/web/tracker/domain/src/lib/models/estimation.ts b/libs/web/tracker/domain/src/lib/models/estimation.ts
new file mode 100644
index 0000000..7883835
--- /dev/null
+++ b/libs/web/tracker/domain/src/lib/models/estimation.ts
@@ -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,
+ };
+}
diff --git a/libs/web/tracker/domain/src/lib/models/hierarchy.ts b/libs/web/tracker/domain/src/lib/models/hierarchy.ts
index 605a8f2..10028df 100644
--- a/libs/web/tracker/domain/src/lib/models/hierarchy.ts
+++ b/libs/web/tracker/domain/src/lib/models/hierarchy.ts
@@ -6,23 +6,26 @@ const FINISHED: StatusCategory[] = [
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 {
- const finished = children.filter((c) =>
- FINISHED.includes(c.statusCategory),
- ).length;
+ const done = children.filter((c) => FINISHED.includes(c.statusCategory));
return {
total: children.length,
- finished,
+ finished: done.length,
percent:
children.length === 0
? 0
- : Math.round((finished / children.length) * 100),
- storyPoints: 0,
- finishedStoryPoints: 0,
+ : Math.round((done.length / children.length) * 100),
+ storyPoints: sumPoints(children),
+ 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. */
export function checklistProgress(items: ChecklistItem[]): {
done: number;
diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts
index 0513f16..b4e334f 100644
--- a/libs/web/tracker/domain/src/lib/models/inputs.ts
+++ b/libs/web/tracker/domain/src/lib/models/inputs.ts
@@ -28,6 +28,7 @@ export interface CreateIssueInput {
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
+ storyPoints?: number | null;
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts
index bbe788f..61b3d00 100644
--- a/libs/web/tracker/domain/src/lib/models/models.ts
+++ b/libs/web/tracker/domain/src/lib/models/models.ts
@@ -114,6 +114,8 @@ export interface IssueSummary {
parent: IssueParent | null;
/** Place in the backlog and in its board column (ascending, as text). */
rank: string;
+ /** Estimate; null when nobody estimated it. */
+ storyPoints: number | null;
commentCount: number;
updatedAt: string;
version: number;
@@ -141,7 +143,6 @@ export interface Issue extends IssueSummary {
description: string | null;
reporter: UserRef;
figmaUrl: string | null;
- storyPoints: number | null;
dueDate: string | null;
links: IssueLink[];
attachments: Attachment[];
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
index 2572800..4747af8 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
@@ -79,13 +79,13 @@ fragment IssueSummaryFields on Issue {
title
}
rank
+ storyPoints
}
fragment IssueFields on Issue {
...IssueSummaryFields
description
figmaUrl
- storyPoints
dueDate
createdAt
allowedTransitions
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
index 0537503..725a131 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
@@ -289,6 +289,7 @@ export type IssueSummaryFieldsFragment = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -298,7 +299,6 @@ export type IssueSummaryFieldsFragment = {
export type IssueFieldsFragment = {
description: string | null;
figmaUrl: string | null;
- storyPoints: number | null;
dueDate: string | null;
createdAt: string;
allowedTransitions: Array;
@@ -316,6 +316,7 @@ export type IssueFieldsFragment = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -409,6 +410,7 @@ export type BoardQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -426,7 +428,6 @@ export type IssueQuery = {
issue: {
description: string | null;
figmaUrl: string | null;
- storyPoints: number | null;
dueDate: string | null;
createdAt: string;
allowedTransitions: Array;
@@ -444,6 +445,7 @@ export type IssueQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -490,6 +492,7 @@ export type SearchIssuesQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -642,6 +645,7 @@ export type MyIssuesQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -671,6 +675,7 @@ export type IssueChildrenQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -700,6 +705,7 @@ export type EpicsQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -771,6 +777,7 @@ export type BacklogQuery = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -1396,6 +1403,7 @@ export type IssueChangedSubscription = {
updatedAt: string;
version: number;
rank: string;
+ storyPoints: number | null;
assignee: { id: string; name: string } | null;
release: { 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: 'storyPoints' } },
],
},
},
@@ -1822,7 +1831,6 @@ export const IssueFieldsFragmentDoc = {
},
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ 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: 'createdAt' } },
{
@@ -1997,6 +2005,7 @@ export const IssueFieldsFragmentDoc = {
},
},
{ 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: 'storyPoints' } },
],
},
},
@@ -2801,6 +2811,7 @@ export const IssueDocument = {
},
},
{ 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: 'figmaUrl' } },
- { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ 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: 'storyPoints' } },
],
},
},
@@ -4290,6 +4301,7 @@ export const MyIssuesDocument = {
},
},
{ 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: 'storyPoints' } },
],
},
},
@@ -4622,6 +4635,7 @@ export const EpicsDocument = {
},
},
{ 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: 'storyPoints' } },
],
},
},
@@ -8748,6 +8763,7 @@ export const IssueChangedDocument = {
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
],
},
},
diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
index ecf8cbb..278f0d0 100644
--- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
@@ -2,6 +2,7 @@ import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
+ computed,
effect,
inject,
untracked,
@@ -19,7 +20,7 @@ import {
ProjectFacade,
provideBacklogFacade,
} 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';
/** /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
para reordenar; las columnas del tablero siguen este mismo orden.
+ @if (facade.loaded() && facade.issues().length) {
+ {{ points() }} pts estimados · {{ unestimated() }} sin estimar
+ }
@if (facade.loaded() && facade.total() > facade.issues().length) {
{{ facade.issues().length }} de {{ facade.total() }}{{
priorityLabel(issue)
}}
+ {{
+ issue.storyPoints ?? '—'
+ }}
sumPoints(this.facade.issues()));
+ protected readonly unestimated = computed(
+ () => this.facade.issues().filter((i) => i.storyPoints === null).length,
+ );
constructor() {
effect(() => {
diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.scss b/libs/web/tracker/presenters/src/lib/pages/board.page.scss
index 7c8322c..caa8cdf 100644
--- a/libs/web/tracker/presenters/src/lib/pages/board.page.scss
+++ b/libs/web/tracker/presenters/src/lib/pages/board.page.scss
@@ -131,3 +131,8 @@
outline-offset: -4px;
border-radius: 0.625rem;
}
+
+.ts-column__points {
+ font-weight: 400;
+ color: var(--ts-muted);
+}
diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts
index 8b30497..a37bd0a 100644
--- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts
@@ -42,11 +42,13 @@ import {
boardFilterOf,
IssuePriority,
IssueSummary,
+ POINT_SCALE,
ReleaseStatus,
SavedFilter,
+ sumPoints,
} from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card';
-import { options, PRIORITY, statusLook } from '../ui/copy';
+import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
const ALL = '';
/** "Sin asignar" in the person filter. */
@@ -186,7 +188,14 @@ const NOBODY = '__nobody__';
{{
statusLabel(column.status)
}}
- {{ column.total }}
+ {{ column.total }}
+ @if (pointsOf(column); as points) {
+ · {{ points }} pts
+ }
+