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

@@ -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';

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,
];
/** 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;

View File

@@ -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. */

View File

@@ -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[];