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:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View 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 });
});
});

View 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 };
}

View File

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

View File

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

View File

@@ -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 {