feat(web): the roadmap shows dependencies on other projects

Tickets of other projects that block a dated epic, or that it depends
on, now show under «De otros proyectos» with a dashed bar, their arrows,
and the same warning when the dates clash («EPI-1 empieza el 25 oct,
pero BACK-1 (otro proyecto), que la bloquea, acaba el 5 nov»). Both
halves of a relation are read, since one recorded from the other project
only leaves its inward half here. The ones without dates, or out of
reach, are named under the chart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:12:37 +02:00
parent 79b8094011
commit c27aa4b1d7
4 changed files with 247 additions and 19 deletions

View File

@@ -1,6 +1,7 @@
import { IssueLinkType } from './enums';
import {
dependencyClash,
externalDependencies,
roadmapBar,
roadmapDependencies,
roadmapMonths,
@@ -74,3 +75,43 @@ describe('roadmap', () => {
expect(dependencyClash('2026-11-10', '2026-11-03')).toBe(true);
});
});
describe('dependencies on other projects', () => {
const link = (
type: IssueLinkType,
direction: 'OUTWARD' | 'INWARD',
issueId: string,
) => ({ type, direction, issueId, issueKey: issueId.toUpperCase() });
it('reads both halves of a relation with a ticket outside, once each', () => {
expect(
externalDependencies([
{
issue: { id: 'web' },
links: [
// API-1 blocks us (recorded on its side): our inward half.
link(IssueLinkType.BLOCKS, 'INWARD', 'api-1'),
// We depend on API-2: it goes first.
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'api-2'),
// We block MOB-1.
link(IssueLinkType.BLOCKS, 'OUTWARD', 'mob-1'),
// MOB-2 depends on us (recorded on its side).
link(IssueLinkType.DEPENDS_ON, 'INWARD', 'mob-2'),
link(IssueLinkType.RELATES_TO, 'OUTWARD', 'doc-1'),
link(IssueLinkType.BLOCKS, 'OUTWARD', 'mob-1'),
],
},
// Among the epics shown: not external.
{
issue: { id: 'web-2' },
links: [link(IssueLinkType.BLOCKS, 'OUTWARD', 'web')],
},
]).map((d) => `${d.before}>${d.after} (${d.externalKey})`),
).toEqual([
'api-1>web (API-1)',
'api-2>web (API-2)',
'web>mob-1 (MOB-1)',
'web>mob-2 (MOB-2)',
]);
});
});

View File

@@ -111,6 +111,51 @@ export function roadmapDependencies(
return out;
}
/** A dependency on a ticket outside the epics shown (another project). */
export interface ExternalDependency extends RoadmapDependency {
/** Id and key of the ticket outside. */
externalId: string;
externalKey: string;
}
/**
* «Bloquea» / «depende de» between these epics and tickets that are not
* among them (another project's): both directions, since the relation was
* maybe recorded from the other side and only its inward half is here.
*/
export function externalDependencies(
epics: { issue: { id: string }; links: IssueLink[] }[],
): ExternalDependency[] {
const ids = new Set(epics.map((e) => e.issue.id));
const seen = new Set<string>();
const out: ExternalDependency[] = [];
for (const epic of epics)
for (const link of epic.links) {
if (ids.has(link.issueId)) continue;
const outward = link.direction === 'OUTWARD';
// Who goes first: the one that blocks, or the one depended on.
const otherFirst =
link.type === IssueLinkType.BLOCKS
? !outward
: link.type === IssueLinkType.DEPENDS_ON
? outward
: null;
if (otherFirst === null) continue;
const pair = otherFirst
? { before: link.issueId, after: epic.issue.id }
: { before: epic.issue.id, after: link.issueId };
const key = `${pair.before}>${pair.after}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
...pair,
externalId: link.issueId,
externalKey: link.issueKey,
});
}
return out;
}
/** The epic that waits starts on or before the day the other one ends. */
export function dependencyClash(
beforeEnds: string,