feat(web): dependencies between epics on the roadmap
Arrows from an epic to the ones that wait for it («bloquea» / «depende de»), drawn over the bars; dashed red, plus a note in words, when one starts before the epic blocking it ends. The next batch and the ideas kept or dropped are written down in docs/10-futuro. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,8 @@ export interface ChildProgress {
|
||||
export interface Epic {
|
||||
issue: IssueSummary;
|
||||
progress: ChildProgress;
|
||||
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
|
||||
links: IssueLink[];
|
||||
}
|
||||
|
||||
/** What a saved view narrows the board to; empty: everything. */
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { IssueLinkType } from './enums';
|
||||
import {
|
||||
dependencyClash,
|
||||
roadmapBar,
|
||||
roadmapDependencies,
|
||||
roadmapMonths,
|
||||
roadmapOffset,
|
||||
roadmapRange,
|
||||
@@ -30,4 +33,44 @@ describe('roadmap', () => {
|
||||
expect(roadmapOffset('2020-01-01', range)).toBe(0);
|
||||
expect(roadmapOffset('2030-01-01', range)).toBe(100);
|
||||
});
|
||||
|
||||
it('draws each dependency between epics once, first the one that blocks', () => {
|
||||
const link = (
|
||||
type: IssueLinkType,
|
||||
direction: 'OUTWARD' | 'INWARD',
|
||||
issueId: string,
|
||||
) => ({ type, direction, issueId, issueKey: issueId.toUpperCase() });
|
||||
const epics = [
|
||||
{
|
||||
issue: { id: 'a' },
|
||||
links: [
|
||||
link(IssueLinkType.BLOCKS, 'OUTWARD', 'b'),
|
||||
link(IssueLinkType.RELATES_TO, 'OUTWARD', 'c'),
|
||||
link(IssueLinkType.BLOCKS, 'OUTWARD', 'elsewhere'),
|
||||
],
|
||||
},
|
||||
{
|
||||
issue: { id: 'b' },
|
||||
links: [
|
||||
link(IssueLinkType.BLOCKS, 'INWARD', 'a'),
|
||||
// The same relation told the other way round.
|
||||
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'a'),
|
||||
],
|
||||
},
|
||||
{
|
||||
issue: { id: 'c' },
|
||||
links: [link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'b')],
|
||||
},
|
||||
];
|
||||
expect(roadmapDependencies(epics)).toEqual([
|
||||
{ before: 'a', after: 'b' },
|
||||
{ before: 'b', after: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags an epic that starts before the one it waits for ends', () => {
|
||||
expect(dependencyClash('2026-11-10', '2026-11-11')).toBe(false);
|
||||
expect(dependencyClash('2026-11-10', '2026-11-10')).toBe(true);
|
||||
expect(dependencyClash('2026-11-10', '2026-11-03')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { IssueLinkType } from './enums';
|
||||
import { IssueLink } from './models';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** Time span a roadmap shows (UTC midnights). */
|
||||
@@ -70,3 +73,48 @@ export function roadmapMonths(
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** An epic that has to finish before another one starts. */
|
||||
export interface RoadmapDependency {
|
||||
/** Id of the epic that goes first (it blocks the other). */
|
||||
before: string;
|
||||
/** Id of the epic that waits for it. */
|
||||
after: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The «bloquea» / «depende de» relations among these epics, once per pair:
|
||||
* A blocks B and B depends on A are the same arrow, A → B. Only outward
|
||||
* links are read (each relation is stored on both tickets).
|
||||
*/
|
||||
export function roadmapDependencies(
|
||||
epics: { issue: { id: string }; links: IssueLink[] }[],
|
||||
): RoadmapDependency[] {
|
||||
const ids = new Set(epics.map((e) => e.issue.id));
|
||||
const seen = new Set<string>();
|
||||
const out: RoadmapDependency[] = [];
|
||||
for (const epic of epics)
|
||||
for (const link of epic.links) {
|
||||
if (link.direction !== 'OUTWARD' || !ids.has(link.issueId)) continue;
|
||||
const pair =
|
||||
link.type === IssueLinkType.BLOCKS
|
||||
? { before: epic.issue.id, after: link.issueId }
|
||||
: link.type === IssueLinkType.DEPENDS_ON
|
||||
? { before: link.issueId, after: epic.issue.id }
|
||||
: null;
|
||||
if (!pair || pair.before === pair.after) continue;
|
||||
const key = `${pair.before}>${pair.after}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(pair);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The epic that waits starts on or before the day the other one ends. */
|
||||
export function dependencyClash(
|
||||
beforeEnds: string,
|
||||
afterStarts: string,
|
||||
): boolean {
|
||||
return at(afterStarts) <= at(beforeEnds);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user