Skip to content

Commit f11bfc1

Browse files
Merge pull request #277 from frankframework/fix/improve-roadmap-design
Fix/improve roadmap design
2 parents 8c9fe25 + cd2e44e commit f11bfc1

12 files changed

Lines changed: 863 additions & 742 deletions

File tree

src/main/frontend/src/app/pages/release-roadmap/milestone-row/milestone-row.component.spec.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DatePipe } from '@angular/common';
33
import { SimpleChanges } from '@angular/core';
44
import { MilestoneRowComponent } from './milestone-row.component';
55
import { Milestone } from '../../../services/milestone.service';
6-
import { Issue, IssuePriority } from '../../../services/issue.service';
6+
import { Issue } from '../../../services/issue.service';
77
import { GitHubStates } from '../../../app.service';
88
import { IssueBarComponent } from '../issue-bar/issue-bar.component';
99
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@@ -177,20 +177,5 @@ describe('MilestoneRowComponent', () => {
177177

178178
expect(component.trackCount).toBeGreaterThan(1);
179179
});
180-
181-
it('should sort issues by priority (critical first) before layout', () => {
182-
const lowPrio: Issue = { ...MOCK_OPEN_ISSUE, id: 'low', issuePriority: { name: 'Low Prio' } as IssuePriority };
183-
const critPrio: Issue = {
184-
...MOCK_OPEN_ISSUE,
185-
id: 'crit',
186-
issuePriority: { name: 'Critical Prio' } as IssuePriority,
187-
};
188-
initializeComponent(MOCK_MILESTONE, [lowPrio, critPrio]);
189-
190-
const critIndex = component.positionedIssues.findIndex((p) => p.issue.id === 'crit');
191-
const lowIndex = component.positionedIssues.findIndex((p) => p.issue.id === 'low');
192-
193-
expect(critIndex).toBeLessThan(lowIndex);
194-
});
195180
});
196181
});

src/main/frontend/src/app/pages/release-roadmap/milestone-row/milestone-row.component.ts

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
22
import { CommonModule, DatePipe } from '@angular/common';
33
import { IssueBarComponent } from '../issue-bar/issue-bar.component';
44
import { Milestone } from '../../../services/milestone.service';
5-
import { Issue, IssuePriority } from '../../../services/issue.service';
5+
import { Issue } from '../../../services/issue.service';
66
import { GitHubStates } from '../../../app.service';
77

88
interface PositionedIssue {
@@ -113,11 +113,6 @@ export class MilestoneRowComponent implements OnChanges {
113113
}
114114
}
115115

116-
for (const issues of quarterMap.values()) {
117-
issues.open = this.getSortedIssues(issues.open);
118-
issues.closed = this.getSortedIssues(issues.closed);
119-
}
120-
121116
return quarterMap;
122117
}
123118

@@ -177,8 +172,7 @@ export class MilestoneRowComponent implements OnChanges {
177172
return { positionedIssues: [], trackCount: 0 };
178173
}
179174

180-
const trackCount = this.estimateTrackCount(issues, window);
181-
const issuesByTrack = this.distributeIssuesRoundRobin(issues, trackCount);
175+
const issuesByTrack = this.distributeIssuesWithBinPacking(issues, window);
182176
const positionedIssues: PositionedIssue[] = [];
183177

184178
for (const [trackIndex, trackIssues] of issuesByTrack.entries()) {
@@ -188,6 +182,7 @@ export class MilestoneRowComponent implements OnChanges {
188182
(sum, issue) => sum + this.getIssueDurationMsWithMinWidth(issue),
189183
0,
190184
);
185+
191186
const totalWhitespace = window.end - window.start - totalIssueDuration;
192187
const gapSize = totalWhitespace > 0 ? totalWhitespace / (trackIssues.length + 1) : 0;
193188
let cursor = window.start + gapSize;
@@ -214,44 +209,42 @@ export class MilestoneRowComponent implements OnChanges {
214209
};
215210
}
216211

217-
private estimateTrackCount(issues: Issue[], window: PlanningWindow): number {
218-
const windowDurationMs = window.end - window.start;
219-
if (windowDurationMs <= 0) return issues.length || 1;
220-
221-
const totalDurationWithGaps = issues.reduce(
222-
(sum, issue) => sum + this.getIssueDurationMsWithMinWidth(issue) + this.GAP_MS,
223-
-this.GAP_MS,
224-
);
225-
226-
return Math.max(1, Math.ceil(totalDurationWithGaps / windowDurationMs));
227-
}
228-
229-
private distributeIssuesRoundRobin(issues: Issue[], trackCount: number): Map<number, Issue[]> {
212+
private distributeIssuesWithBinPacking(issues: Issue[], window: PlanningWindow): Map<number, Issue[]> {
230213
const issuesByTrack = new Map<number, Issue[]>();
231-
if (trackCount === 0) return issuesByTrack;
214+
const windowDurationMs = window.end - window.start;
215+
const trackCapacities = new Map<number, number>();
216+
let currentTrack = 0;
217+
218+
for (const issue of issues) {
219+
const issueDuration = this.getIssueDurationMsWithMinWidth(issue);
220+
const spaceNeeded = issueDuration + this.GAP_MS;
221+
222+
let placed = false;
223+
for (let trackIndex = 0; trackIndex <= currentTrack; trackIndex++) {
224+
const trackUsed = trackCapacities.get(trackIndex) ?? 0;
225+
const trackRemaining = windowDurationMs - trackUsed;
226+
227+
if (trackRemaining >= spaceNeeded) {
228+
if (!issuesByTrack.has(trackIndex)) {
229+
issuesByTrack.set(trackIndex, []);
230+
}
231+
issuesByTrack.get(trackIndex)!.push(issue);
232+
trackCapacities.set(trackIndex, trackUsed + spaceNeeded);
233+
placed = true;
234+
break;
235+
}
236+
}
232237

233-
for (let index = 0; index < trackCount; index++) issuesByTrack.set(index, []);
234-
for (const [index, issue] of issues.entries()) issuesByTrack.get(index % trackCount)!.push(issue);
238+
if (!placed) {
239+
currentTrack++;
240+
issuesByTrack.set(currentTrack, [issue]);
241+
trackCapacities.set(currentTrack, spaceNeeded);
242+
}
243+
}
235244

236245
return issuesByTrack;
237246
}
238247

239-
private getSortedIssues(issues: Issue[]): Issue[] {
240-
const priorityOrder: Record<string, number> = { critical: 1, high: 2, medium: 3, low: 4, no: 5 };
241-
242-
return [...issues].sort((a, b) => {
243-
const priorityA = priorityOrder[this.getPriorityKey(a.issuePriority)] ?? 5;
244-
const priorityB = priorityOrder[this.getPriorityKey(b.issuePriority)] ?? 5;
245-
if (priorityA !== priorityB) return priorityA - priorityB;
246-
247-
const pointsA = a.points ?? this.DEFAULT_POINTS;
248-
const pointsB = b.points ?? this.DEFAULT_POINTS;
249-
if (pointsA !== pointsB) return pointsB - pointsA;
250-
251-
return b.number - a.number;
252-
});
253-
}
254-
255248
private calculateBarPosition(startDate: Date, durationDays: number): Record<string, string> {
256249
const startDays = (startDate.getTime() - this.timelineStartDate.getTime()) / (1000 * 3600 * 24);
257250
const leftPercentage = (startDays / this.totalTimelineDays) * 100;
@@ -290,14 +283,4 @@ export class MilestoneRowComponent implements OnChanges {
290283
const total = this.milestone.openIssueCount + this.milestone.closedIssueCount;
291284
this.progressPercentage = total === 0 ? 0 : Math.round((this.milestone.closedIssueCount / total) * 100);
292285
}
293-
294-
private getPriorityKey(priority: IssuePriority | undefined | null): string {
295-
if (!priority?.name) return 'no';
296-
const lowerCaseName = priority.name.toLowerCase();
297-
const keys = ['critical', 'high', 'medium', 'low'];
298-
for (const key of keys) {
299-
if (lowerCaseName.includes(key)) return key;
300-
}
301-
return 'no';
302-
}
303286
}

src/main/java/org/frankframework/insights/common/configuration/properties/CorsProperties.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
package org.frankframework.insights.common.configuration.properties;
22

3+
import java.util.List;
34
import lombok.Getter;
45
import lombok.Setter;
56
import org.springframework.boot.context.properties.ConfigurationProperties;
67

7-
import java.util.List;
8-
98
@ConfigurationProperties(prefix = "cors.allowed")
109
@Getter
1110
@Setter

src/main/java/org/frankframework/insights/issue/IssueService.java

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
@Slf4j
3737
public class IssueService {
3838
private static final String ISSUE_TYPE_EPIC_NAME = "Epic";
39+
private static final double DEFAULT_POINTS = 3.0;
3940

4041
private final GitHubClient gitHubClient;
4142
private final Mapper mapper;
@@ -241,8 +242,9 @@ private Set<Issue> saveIssues(Set<Issue> issues) {
241242
*/
242243
public Set<IssueResponse> getIssuesByReleaseId(String releaseId) throws ReleaseNotFoundException {
243244
Release release = releaseService.checkIfReleaseExists(releaseId);
244-
Set<Issue> rootIssues = issueRepository.findIssuesByReleaseId(release.getId());
245-
return buildIssueResponseTree(rootIssues);
245+
Set<Issue> allIssues = issueRepository.findIssuesByReleaseId(release.getId());
246+
Set<Issue> rootIssues = filterRootIssues(allIssues);
247+
return buildIssueResponseTree(rootIssues);
246248
}
247249

248250
/**
@@ -253,7 +255,8 @@ public Set<IssueResponse> getIssuesByReleaseId(String releaseId) throws ReleaseN
253255
*/
254256
public Set<IssueResponse> getIssuesByMilestoneId(String milestoneId) throws MilestoneNotFoundException {
255257
Milestone milestone = milestoneService.checkIfMilestoneExists(milestoneId);
256-
Set<Issue> rootIssues = issueRepository.findDistinctByMilestoneId(milestone.getId());
258+
Set<Issue> allIssues = issueRepository.findDistinctByMilestoneId(milestone.getId());
259+
Set<Issue> rootIssues = filterRootIssues(allIssues);
257260
return buildIssueResponseTree(rootIssues);
258261
}
259262

@@ -263,7 +266,24 @@ public Set<IssueResponse> getIssuesByMilestoneId(String milestoneId) throws Mile
263266
*/
264267
public Set<IssueResponse> getFutureEpicIssues() {
265268
Set<Issue> futureEpicIssues = issueRepository.findIssuesByIssueTypeNameAndMilestoneIsNull(ISSUE_TYPE_EPIC_NAME);
266-
return buildIssueResponseTree(futureEpicIssues);
269+
return buildIssueResponseTreeWithoutFiltering(futureEpicIssues);
270+
}
271+
272+
/**
273+
* Filters out issues that are sub-issues of other issues in the set.
274+
* @param issues the set of all issues
275+
* @return a set of root issues (issues that are not sub-issues of other issues in the set)
276+
*/
277+
private Set<Issue> filterRootIssues(Set<Issue> issues) {
278+
Set<String> allSubIssueIds = issues.stream()
279+
.filter(issue -> issue.getSubIssues() != null)
280+
.flatMap(issue -> issue.getSubIssues().stream())
281+
.map(Issue::getId)
282+
.collect(Collectors.toSet());
283+
284+
return issues.stream()
285+
.filter(issue -> !allSubIssueIds.contains(issue.getId()))
286+
.collect(Collectors.toSet());
267287
}
268288

269289
/**
@@ -272,6 +292,20 @@ public Set<IssueResponse> getFutureEpicIssues() {
272292
* @return a set of IssueResponse objects representing the root issues and their sub-issues, with labels included
273293
*/
274294
private Set<IssueResponse> buildIssueResponseTree(Set<Issue> rootIssues) {
295+
Set<String> allIds = collectAllIssueIdsRecursively(rootIssues);
296+
Map<String, Set<LabelResponse>> labelsMap = fetchLabelsForIssueIds(allIds);
297+
return rootIssues.stream()
298+
.map(issue -> mapIssueTreeWithLabels(issue, labelsMap))
299+
.filter(this::hasRelevantLabelsRecursively)
300+
.collect(Collectors.toSet());
301+
}
302+
303+
/**
304+
* Builds a tree of IssueResponse objects without filtering by labels.
305+
* @param rootIssues the set of root issues to build the tree from
306+
* @return a set of IssueResponse objects representing the root issues and their sub-issues, with labels included
307+
*/
308+
private Set<IssueResponse> buildIssueResponseTreeWithoutFiltering(Set<Issue> rootIssues) {
275309
Set<String> allIds = collectAllIssueIdsRecursively(rootIssues);
276310
Map<String, Set<LabelResponse>> labelsMap = fetchLabelsForIssueIds(allIds);
277311
return rootIssues.stream()
@@ -309,6 +343,7 @@ private Stream<String> flattenIssueIds(Issue issue) {
309343
private Map<String, Set<LabelResponse>> fetchLabelsForIssueIds(Set<String> issueIds) {
310344
Set<IssueLabel> labels = issueLabelRepository.findAllByIssue_IdIn(new ArrayList<>(issueIds));
311345
return labels.stream()
346+
.filter(l -> labelService.isLabelIncluded(l.getLabel()))
312347
.collect(Collectors.groupingBy(
313348
l -> l.getIssue().getId(),
314349
Collectors.mapping(l -> mapper.toDTO(l.getLabel(), LabelResponse.class), Collectors.toSet())));
@@ -328,6 +363,10 @@ private IssueResponse mapIssueTreeWithLabels(Issue issue, Map<String, Set<LabelR
328363

329364
response.setLabels(labelsMap.getOrDefault(issue.getId(), Set.of()));
330365
response.setSubIssues(mapSubIssuesToResponses(issue, labelsMap));
366+
367+
double totalPoints = calculateTotalPoints(issue, response);
368+
response.setPoints(totalPoints);
369+
331370
return response;
332371
}
333372

@@ -374,12 +413,49 @@ private Set<IssueResponse> mapSubIssuesToResponses(Issue issue, Map<String, Set<
374413
if (issue.getSubIssues() != null && !issue.getSubIssues().isEmpty()) {
375414
return issue.getSubIssues().stream()
376415
.map(sub -> mapIssueTreeWithLabels(sub, labelsMap))
416+
.filter(this::hasRelevantLabelsRecursively)
377417
.collect(Collectors.toSet());
378418
} else {
379419
return Set.of();
380420
}
381421
}
382422

423+
/**
424+
* Checks if an issue or any of its sub-issues have relevant labels.
425+
* @param issueResponse the issue response to check
426+
* @return true if the issue or any of its sub-issues have at least one relevant label
427+
*/
428+
private boolean hasRelevantLabelsRecursively(IssueResponse issueResponse) {
429+
if (issueResponse.getLabels() != null && !issueResponse.getLabels().isEmpty()) {
430+
return true;
431+
}
432+
if (issueResponse.getSubIssues() != null
433+
&& !issueResponse.getSubIssues().isEmpty()) {
434+
return issueResponse.getSubIssues().stream().anyMatch(this::hasRelevantLabelsRecursively);
435+
}
436+
return false;
437+
}
438+
439+
/**
440+
* Calculates the total points for an issue, including its own points and all sub-issue points.
441+
* Uses DEFAULT_POINTS (3.0) for issues without assigned points.
442+
* @param issue the issue entity
443+
* @param response the issue response with mapped sub-issues
444+
* @return the total points for the issue and all its sub-issues
445+
*/
446+
private double calculateTotalPoints(Issue issue, IssueResponse response) {
447+
double issuePoints = issue.getPoints() != null ? issue.getPoints() : DEFAULT_POINTS;
448+
449+
if (response.getSubIssues() != null && !response.getSubIssues().isEmpty()) {
450+
double subIssuesPoints = response.getSubIssues().stream()
451+
.mapToDouble(IssueResponse::getPoints)
452+
.sum();
453+
return issuePoints + subIssuesPoints;
454+
}
455+
456+
return issuePoints;
457+
}
458+
383459
/**
384460
* Get all issues from the database
385461
* @return a map of issue id to issue

src/main/java/org/frankframework/insights/label/LabelService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,13 @@ private void saveLabels(Set<Label> labels) {
164164
List<Label> savedLabels = labelRepository.saveAll(labels);
165165
log.info("Successfully saved {} labels", savedLabels.size());
166166
}
167+
168+
/**
169+
* Checks if a label is included based on its color.
170+
* @param label the label to check
171+
* @return true if the label's color is in the included labels list, false otherwise
172+
*/
173+
public boolean isLabelIncluded(Label label) {
174+
return label != null && includedLabels.contains(label.getColor().toUpperCase());
175+
}
167176
}

0 commit comments

Comments
 (0)