diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index 191a19c..467e7ff 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -186,7 +186,11 @@
- + @if (isEpicIssue) { +
{{ epicEstimatedTime !== null ? epicEstimatedTime : '—' }}
+ } @else { + + }
@if (!isEpicIssue) {
diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index b6d00d6..19588c6 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -580,6 +580,39 @@ describe('IssueDetail — existing issue', () => { }); }); + describe('epicEstimatedTime', () => { + beforeEach(() => { + (component as any).issue.type = 'Epic'; + (component as any).issue.name = 'Test Epic'; + }); + + it('returns null when there are no child issues', () => { + expect((component as any).epicEstimatedTime).toBeNull(); + }); + + it('returns null when all children have null estimatedTime', () => { + store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: null })); + expect((component as any).epicEstimatedTime).toBeNull(); + }); + + it('returns the sum of children estimatedTime', () => { + store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: 8 })); + store.upsert(makeIssue({ id: 201, epic: 'Test Epic', estimatedTime: 4 })); + expect((component as any).epicEstimatedTime).toBe(12); + }); + + it('ignores children with null estimatedTime in the sum', () => { + store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: 8 })); + store.upsert(makeIssue({ id: 201, epic: 'Test Epic', estimatedTime: null })); + expect((component as any).epicEstimatedTime).toBe(8); + }); + + it('includes children linked via dependsOnIds', () => { + store.upsert(makeIssue({ id: 200, dependsOnIds: [1], estimatedTime: 6 })); + expect((component as any).epicEstimatedTime).toBe(6); + }); + }); + describe('create-in-epic flow', () => { beforeEach(() => { (component as any).issue.type = 'Epic'; diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 1eb35b3..c75737b 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -150,6 +150,13 @@ export class IssueDetail { this.issue.type = value; } + protected get epicEstimatedTime(): number | null { + const times = this.composedIssues + .filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null) + .map((i) => i.estimatedTime); + return times.length === 0 ? null : times.reduce((a, b) => a + b, 0); + } + protected get epicIssues(): IssueEntity[] { return this.issues().filter((issue) => issue.type === 'Epic'); } diff --git a/src/app/milestones/milestone-detail/milestone-detail.html b/src/app/milestones/milestone-detail/milestone-detail.html index 9e7169b..9c786d1 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.html +++ b/src/app/milestones/milestone-detail/milestone-detail.html @@ -94,6 +94,10 @@ {{ progress }}%
+
+ +
{{ totalEstimatedTime !== null ? totalEstimatedTime : '—' }}
+
} diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts index 670e7b5..fd114cc 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.spec.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -580,6 +580,69 @@ describe('MilestoneDetail', () => { expect((component as any).milestone.description).toContain('![image]'); }); }); + + describe('totalEstimatedTime', () => { + it('returns null when no linked issues', () => { + issuesStore.seed([]); + (component as any).milestone.issueIds = []; + expect((component as any).totalEstimatedTime).toBeNull(); + }); + + it('returns null when all linked issues have null estimatedTime', () => { + issuesStore.seed([ + makeIssue({ id: 1, estimatedTime: null }), + makeIssue({ id: 2, estimatedTime: null }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).totalEstimatedTime).toBeNull(); + }); + + it('returns the sum of estimatedTime for non-Epic issues', () => { + issuesStore.seed([ + makeIssue({ id: 1, estimatedTime: 8 }), + makeIssue({ id: 2, estimatedTime: 4 }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).totalEstimatedTime).toBe(12); + }); + + it('ignores null estimatedTime in the sum', () => { + issuesStore.seed([ + makeIssue({ id: 1, estimatedTime: 8 }), + makeIssue({ id: 2, estimatedTime: null }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).totalEstimatedTime).toBe(8); + }); + + it('uses the Epic own estimatedTime, not its children', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 10 }), + makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }), + makeIssue({ id: 3, epic: 'My Epic', estimatedTime: 3 }), + ]); + (component as any).milestone.issueIds = [1]; + expect((component as any).totalEstimatedTime).toBe(10); + }); + + it('returns null for an Epic with null estimatedTime', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: null }), + makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }), + ]); + (component as any).milestone.issueIds = [1]; + expect((component as any).totalEstimatedTime).toBeNull(); + }); + + it('mixes Epics and plain issues correctly', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 8 }), + makeIssue({ id: 3, type: 'Story', estimatedTime: 6 }), + ]); + (component as any).milestone.issueIds = [1, 3]; + expect((component as any).totalEstimatedTime).toBe(14); + }); + }); }); describe('MilestoneDetail — new route', () => { diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index e6b8131..410a413 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -110,6 +110,13 @@ export class MilestoneDetail { return tasks; } + protected get totalEstimatedTime(): number | null { + const times = this.linkedIssues + .filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null) + .map((i) => i.estimatedTime); + return times.length === 0 ? null : times.reduce((a, b) => a + b, 0); + } + protected get progress(): number { if (this.linkedIssues.length === 0) return 0; return Math.round(