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:
2026-10-15 10:00:14 +02:00
parent f7e850068b
commit 21bc598590
14 changed files with 397 additions and 28 deletions

View File

@@ -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. */

View File

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

View File

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