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

@@ -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}`,
},