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 + } +
Área + + Estimación +
sumPoints(column.issues); protected readonly typeOptions = computed[]>( () => this.project @@ -477,6 +495,7 @@ export class BoardPage { figmaUrl: '', priority: IssuePriority.MEDIUM as string, area: ALL as string, + storyPoints: '' as string, assigneeId: ALL as string, releaseId: ALL as string, }); @@ -648,6 +667,7 @@ export class BoardPage { figmaUrl: '', priority: IssuePriority.MEDIUM, area: ALL, + storyPoints: '', assigneeId: ALL, releaseId: this.releaseFilter() || ALL, }); @@ -669,6 +689,7 @@ export class BoardPage { : null, priority: v.priority as IssuePriority, area: v.area || null, + storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints), assigneeId: v.assigneeId || null, releaseId: v.releaseId || null, }); diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index f2658be..b5f4449 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -44,6 +44,7 @@ import { matches, mentionQuery, mentionSegments, + POINT_SCALE, ReleaseStatus, } from '@task-stream/web/tracker/domain'; import { @@ -51,6 +52,7 @@ import { initials, LINK, options, + pointOptions, PRIORITY, statusLook, timeAgo, @@ -248,7 +250,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; @if (facade.children().length) {
    @@ -618,6 +630,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; > Prioridad + + Estimación + + pointOptions(POINT_SCALE, this.facade.issue()?.storyPoints ?? null), + ); /** Active values, plus the ticket's own even if archived since. */ protected readonly typeOptions = computed[]>( () => { @@ -964,6 +987,7 @@ export class IssuePage { type?: string; priority?: IssuePriority; area?: string | null; + storyPoints?: number | null; }): void { this.facade.update(changes); } diff --git a/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts b/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts index 69c4685..9bf0ee1 100644 --- a/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts @@ -24,7 +24,11 @@ import { } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; 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'; const DAY = 86_400_000; @@ -175,6 +179,14 @@ const STATUS: Record = { >{{ sprint.progress.total }} tickets · {{ sprint.progress.storyPoints }} pts + @if (overCommitted(sprint)) { + Por encima de la velocidad media ({{ + pace().average + }} + pts) + } @if (project.canManage()) { @@ -224,6 +236,26 @@ const STATUS: Record = {
} + @if (pace().history.length) { +

Velocidad

+
+

+ Puntos terminados en cada sprint cerrado. + @if (pace().average !== null) { + Media de los últimos {{ paceWindow }}: + {{ pace().average }} puntos por sprint. + } +

+ +
+ } + @if (!project.sprints().length) { = { 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); } @@ -446,6 +490,40 @@ export class SprintsPage { : `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(() => [ + { + 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(null); protected readonly name = signal(''); diff --git a/libs/web/tracker/presenters/src/lib/ui/copy.ts b/libs/web/tracker/presenters/src/lib/ui/copy.ts index b53fd44..40928fa 100644 --- a/libs/web/tracker/presenters/src/lib/ui/copy.ts +++ b/libs/web/tracker/presenters/src/lib/ui/copy.ts @@ -168,6 +168,24 @@ export const RELEASE_STATUS: Record< [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 = ( record: Record, ) => diff --git a/libs/web/tracker/presenters/src/lib/ui/issue-card.ts b/libs/web/tracker/presenters/src/lib/ui/issue-card.ts index a89e9c9..3ac20cd 100644 --- a/libs/web/tracker/presenters/src/lib/ui/issue-card.ts +++ b/libs/web/tracker/presenters/src/lib/ui/issue-card.ts @@ -45,6 +45,11 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; >↑ {{ parent.key }} } + @if (issue().storyPoints !== null) { + {{ + issue().storyPoints + }} + } @if (issue().release; as release) { ◆ {{ release.name }}