feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
28
libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts
Normal file
28
libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StatusCategory } from './enums';
|
||||
import { checklistProgress, childProgress } from './hierarchy';
|
||||
import { IssueSummary } from './models';
|
||||
|
||||
const child = (statusCategory: StatusCategory) =>
|
||||
({ statusCategory }) as IssueSummary;
|
||||
|
||||
describe('hierarchy', () => {
|
||||
it('counts done and cancelled subtasks as finished', () => {
|
||||
expect(
|
||||
childProgress([
|
||||
child(StatusCategory.DONE),
|
||||
child(StatusCategory.CANCELLED),
|
||||
child(StatusCategory.IN_PROGRESS),
|
||||
]),
|
||||
).toMatchObject({ total: 3, finished: 2, percent: 67 });
|
||||
expect(childProgress([]).percent).toBe(0);
|
||||
});
|
||||
|
||||
it('counts ticked checklist steps', () => {
|
||||
expect(
|
||||
checklistProgress([
|
||||
{ id: 'a', text: 'a', done: true },
|
||||
{ id: 'b', text: 'b', done: false },
|
||||
]),
|
||||
).toEqual({ done: 1, total: 2 });
|
||||
});
|
||||
});
|
||||
32
libs/web/tracker/domain/src/lib/models/hierarchy.ts
Normal file
32
libs/web/tracker/domain/src/lib/models/hierarchy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChecklistItem, ChildProgress, IssueSummary } from './models';
|
||||
import { StatusCategory } from './enums';
|
||||
|
||||
const FINISHED: StatusCategory[] = [
|
||||
StatusCategory.DONE,
|
||||
StatusCategory.CANCELLED,
|
||||
];
|
||||
|
||||
/** Progress of the tickets hanging from one (story points are not loaded here). */
|
||||
export function childProgress(children: IssueSummary[]): ChildProgress {
|
||||
const finished = children.filter((c) =>
|
||||
FINISHED.includes(c.statusCategory),
|
||||
).length;
|
||||
return {
|
||||
total: children.length,
|
||||
finished,
|
||||
percent:
|
||||
children.length === 0
|
||||
? 0
|
||||
: Math.round((finished / children.length) * 100),
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** "3/5": ticked steps of a checklist. */
|
||||
export function checklistProgress(items: ChecklistItem[]): {
|
||||
done: number;
|
||||
total: number;
|
||||
} {
|
||||
return { done: items.filter((i) => i.done).length, total: items.length };
|
||||
}
|
||||
@@ -30,10 +30,12 @@ export interface CreateIssueInput {
|
||||
figmaUrl?: string | null;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export type UpdateIssueInput = Partial<
|
||||
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId'>
|
||||
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId' | 'parentId'>
|
||||
> & {
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
@@ -40,6 +40,36 @@ export interface IssueLink {
|
||||
issueKey: string;
|
||||
}
|
||||
|
||||
/** The ticket another one hangs from. */
|
||||
export interface IssueParent {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** A step inside a ticket that does not deserve a ticket of its own. */
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** How far the tickets hanging from one have got. */
|
||||
export interface ChildProgress {
|
||||
total: number;
|
||||
/** Done or cancelled. */
|
||||
finished: number;
|
||||
percent: number;
|
||||
storyPoints: number;
|
||||
finishedStoryPoints: number;
|
||||
}
|
||||
|
||||
/** A top-level ticket that groups others. */
|
||||
export interface Epic {
|
||||
issue: IssueSummary;
|
||||
progress: ChildProgress;
|
||||
}
|
||||
|
||||
/** What boards and lists show. */
|
||||
export interface IssueSummary {
|
||||
id: string;
|
||||
@@ -55,6 +85,8 @@ export interface IssueSummary {
|
||||
assignee: UserRef | null;
|
||||
release: { id: string; name: string } | null;
|
||||
labels: string[];
|
||||
/** The epic or ticket this one breaks down. */
|
||||
parent: IssueParent | null;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
@@ -88,6 +120,7 @@ export interface Issue extends IssueSummary {
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
checklist: ChecklistItem[];
|
||||
allowedTransitions: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Attachment,
|
||||
BoardColumn,
|
||||
Comment,
|
||||
Epic,
|
||||
CommandResult,
|
||||
Issue,
|
||||
IssuePage,
|
||||
@@ -89,6 +90,30 @@ export abstract class IssuesGateway {
|
||||
): Observable<CommandResult>;
|
||||
/** The signed-in person follows (or stops following) the ticket. */
|
||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||
/** Tickets hanging from `issueId`. */
|
||||
abstract children(
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
): Observable<IssueSummary[]>;
|
||||
/** Places the ticket under `parentId` (null: back to the top). */
|
||||
abstract setParent(
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract epics(projectId: string): Observable<Epic[]>;
|
||||
abstract addChecklistItem(
|
||||
issueId: string,
|
||||
text: string,
|
||||
): Observable<CommandResult>;
|
||||
abstract updateChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): Observable<CommandResult>;
|
||||
abstract removeChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class CommentsGateway {
|
||||
|
||||
Reference in New Issue
Block a user