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:
37
libs/web/tracker/domain/src/lib/models/estimation.spec.ts
Normal file
37
libs/web/tracker/domain/src/lib/models/estimation.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
libs/web/tracker/domain/src/lib/models/estimation.ts
Normal file
40
libs/web/tracker/domain/src/lib/models/estimation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user