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:
@@ -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)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
import {
|
||||
dependencyClash,
|
||||
Epic,
|
||||
externalDependencies,
|
||||
Issue,
|
||||
IssuesGateway,
|
||||
ReleaseStatus,
|
||||
roadmapBar,
|
||||
roadmapDependencies,
|
||||
@@ -85,6 +88,9 @@ const shortDay = (day: string) =>
|
||||
[text]="clashText()"
|
||||
/>
|
||||
}
|
||||
@if (outsideNote(); as note) {
|
||||
<p class="ts-muted ts-roadmap__outside-note">{{ note }}</p>
|
||||
}
|
||||
<div
|
||||
#chart
|
||||
class="ts-roadmap ts-panel"
|
||||
@@ -177,6 +183,41 @@ const shortDay = (day: string) =>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (externalRows().length) {
|
||||
<div class="ts-roadmap__row" role="row">
|
||||
<span
|
||||
class="ts-roadmap__label ts-roadmap__group ts-muted"
|
||||
role="rowheader"
|
||||
>De otros proyectos</span
|
||||
>
|
||||
<div class="ts-roadmap__track" role="cell"></div>
|
||||
</div>
|
||||
@for (row of externalRows(); track row.id) {
|
||||
<div class="ts-roadmap__row" role="row">
|
||||
<div
|
||||
class="ts-roadmap__label ts-roadmap__label--epic"
|
||||
role="rowheader"
|
||||
>
|
||||
<a [routerLink]="['/p', row.projectKey, 'issues', row.key]">
|
||||
<span class="ts-key">{{ row.key }}</span>
|
||||
{{ row.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="ts-roadmap__track" role="cell">
|
||||
<a
|
||||
class="ts-roadmap__bar ts-roadmap__bar--outside"
|
||||
[attr.data-epic]="row.id"
|
||||
[style.left.%]="row.left"
|
||||
[style.width.%]="row.width"
|
||||
[routerLink]="['/p', row.projectKey, 'issues', row.key]"
|
||||
[title]="row.title"
|
||||
><span class="visually-hidden">{{ row.title }}</span></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (arrows().length) {
|
||||
<!-- «Bloquea» / «depende de»: told in each bar's title too. -->
|
||||
<svg
|
||||
@@ -371,6 +412,20 @@ const shortDay = (day: string) =>
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
.ts-roadmap__bar--outside {
|
||||
border: 2px dashed var(--ts-line-strong);
|
||||
background: var(--ts-sunken) !important;
|
||||
}
|
||||
.ts-roadmap__group {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ts-roadmap__outside-note {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-roadmap__arrows {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -444,9 +499,59 @@ export class RoadmapPage {
|
||||
protected readonly undated = computed(() =>
|
||||
this.epics.epics().filter((e) => !e.issue.startDate && !e.issue.dueDate),
|
||||
);
|
||||
/* Tickets of other projects that dated epics block or wait for. */
|
||||
private readonly issues = inject(IssuesGateway);
|
||||
private readonly externals = computed(() =>
|
||||
externalDependencies(this.dated()),
|
||||
);
|
||||
/** Read once each: the ticket, or null when it cannot be read. */
|
||||
private readonly outside = signal<ReadonlyMap<string, Issue | null>>(
|
||||
new Map(),
|
||||
);
|
||||
protected readonly externalRows = computed(() => {
|
||||
const range = this.range();
|
||||
const shown = new Set<string>();
|
||||
return this.externals().flatMap((d) => {
|
||||
const issue = this.outside().get(d.externalId);
|
||||
if (!issue || shown.has(issue.id)) return [];
|
||||
const start = issue.startDate ?? issue.dueDate;
|
||||
const end = issue.dueDate ?? issue.startDate;
|
||||
if (!start || !end) return [];
|
||||
shown.add(issue.id);
|
||||
return [
|
||||
{
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
name: issue.title,
|
||||
projectKey: issue.key.slice(0, issue.key.lastIndexOf('-')),
|
||||
start,
|
||||
end,
|
||||
...roadmapBar(start, end, range),
|
||||
title: `${issue.key} (otro proyecto) · ${shortDay(start)} – ${shortDay(end)}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
/** The ones outside that cannot be placed: no dates, or out of reach. */
|
||||
protected readonly outsideNote = computed(() => {
|
||||
const missing = new Map<string, string>();
|
||||
for (const d of this.externals()) {
|
||||
if (!this.outside().has(d.externalId)) continue;
|
||||
const issue = this.outside().get(d.externalId);
|
||||
if (!issue) missing.set(d.externalKey, `${d.externalKey} (sin acceso)`);
|
||||
else if (!issue.startDate && !issue.dueDate)
|
||||
missing.set(d.externalKey, `${d.externalKey} (sin fechas)`);
|
||||
}
|
||||
return missing.size
|
||||
? `Relaciones con otros proyectos que no salen en el gráfico: ${[...missing.values()].join(', ')}.`
|
||||
: null;
|
||||
});
|
||||
private readonly range = computed(() =>
|
||||
roadmapRange(
|
||||
[
|
||||
...[...this.outside().values()].flatMap((i) =>
|
||||
i ? [i.startDate, i.dueDate] : [],
|
||||
),
|
||||
...this.dated().flatMap((e) => [e.issue.startDate, e.issue.dueDate]),
|
||||
...this.project.sprints().flatMap((s) => [s.startDate, s.endDate]),
|
||||
...this.project.releases().map((r) => r.targetDate),
|
||||
@@ -483,23 +588,45 @@ export class RoadmapPage {
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
});
|
||||
|
||||
/** Dependencies between dated epics, with whether their dates clash. */
|
||||
/**
|
||||
* Dependencies between dated epics, and with dated tickets of other
|
||||
* projects, with whether their dates clash.
|
||||
*/
|
||||
private readonly dependencies = computed(() => {
|
||||
const rows = new Map(this.epicRows().map((r) => [r.epic.issue.id, r]));
|
||||
return roadmapDependencies(this.dated()).flatMap((d) => {
|
||||
const before = rows.get(d.before);
|
||||
const after = rows.get(d.after);
|
||||
return before && after
|
||||
? [
|
||||
const rows = new Map<
|
||||
string,
|
||||
{ id: string; key: string; start: string; end: string; outside: boolean }
|
||||
>([
|
||||
...this.epicRows().map(
|
||||
(r) =>
|
||||
[
|
||||
r.epic.issue.id,
|
||||
{
|
||||
...d,
|
||||
before,
|
||||
after,
|
||||
clash: dependencyClash(before.end, after.start),
|
||||
id: r.epic.issue.id,
|
||||
key: r.epic.issue.key,
|
||||
start: r.start,
|
||||
end: r.end,
|
||||
outside: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
] as const,
|
||||
),
|
||||
...this.externalRows().map(
|
||||
(r) =>
|
||||
[
|
||||
r.id,
|
||||
{ id: r.id, key: r.key, start: r.start, end: r.end, outside: true },
|
||||
] as const,
|
||||
),
|
||||
]);
|
||||
return [...roadmapDependencies(this.dated()), ...this.externals()].flatMap(
|
||||
(d) => {
|
||||
const before = rows.get(d.before);
|
||||
const after = rows.get(d.after);
|
||||
return before && after
|
||||
? [{ before, after, clash: dependencyClash(before.end, after.start) }]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
});
|
||||
protected readonly clashes = computed(() =>
|
||||
this.dependencies().filter((d) => d.clash),
|
||||
@@ -508,7 +635,7 @@ export class RoadmapPage {
|
||||
this.clashes()
|
||||
.map(
|
||||
({ before, after }) =>
|
||||
`${after.epic.issue.key} empieza el ${shortDay(after.start)}, pero ${before.epic.issue.key}, que la bloquea, acaba el ${shortDay(before.end)}.`,
|
||||
`${after.key}${after.outside ? ' (otro proyecto)' : ''} empieza el ${shortDay(after.start)}, pero ${before.key}${before.outside ? ' (otro proyecto)' : ''}, que la bloquea, acaba el ${shortDay(before.end)}.`,
|
||||
)
|
||||
.join(' '),
|
||||
);
|
||||
@@ -555,6 +682,21 @@ export class RoadmapPage {
|
||||
const projectId = this.project.project()?.id;
|
||||
if (projectId) untracked(() => this.epics.open(projectId));
|
||||
});
|
||||
// The tickets outside, read once each (null: out of reach).
|
||||
effect(() => {
|
||||
const wanted = this.externals().filter(
|
||||
(d) => !untracked(this.outside).has(d.externalId),
|
||||
);
|
||||
for (const d of wanted)
|
||||
untracked(() =>
|
||||
this.issues.issue(d.externalKey).subscribe({
|
||||
next: (issue) =>
|
||||
this.outside.update((m) => new Map(m).set(d.externalId, issue)),
|
||||
error: () =>
|
||||
this.outside.update((m) => new Map(m).set(d.externalId, null)),
|
||||
}),
|
||||
);
|
||||
});
|
||||
afterRenderEffect(() => {
|
||||
this.resized();
|
||||
const dependencies = this.dependencies();
|
||||
@@ -579,14 +721,14 @@ export class RoadmapPage {
|
||||
this.canvas.set({ width: host.scrollWidth, height: host.scrollHeight });
|
||||
this.arrows.set(
|
||||
dependencies.flatMap((d) => {
|
||||
const from = bar(d.before.epic.issue.id);
|
||||
const to = bar(d.after.epic.issue.id);
|
||||
const from = bar(d.before.id);
|
||||
const to = bar(d.after.id);
|
||||
if (!from || !to) return [];
|
||||
// A curve out of the end of one bar into the start of the other.
|
||||
const bend = Math.max(24, (to.left - from.right) / 2);
|
||||
return [
|
||||
{
|
||||
id: `${d.before.epic.issue.id}>${d.after.epic.issue.id}`,
|
||||
id: `${d.before.id}>${d.after.id}`,
|
||||
clash: d.clash,
|
||||
d: `M ${from.right} ${from.middle} C ${from.right + bend} ${from.middle}, ${to.left - bend} ${to.middle}, ${to.left - 1} ${to.middle}`,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user