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
+ Puntos terminados en cada sprint cerrado. + @if (pace().average !== null) { + Media de los últimos {{ paceWindow }}: + {{ pace().average }} puntos por sprint. + } +
+