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

@@ -1,12 +1,17 @@
import { AuthFacade } from '@task-stream/web/identity/application';
import { VoteComponent } from '../ui/vote';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
HostListener,
inject,
signal,
untracked,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
@@ -16,9 +21,11 @@ import {
provideEpicsFacade,
} from '@task-stream/web/tracker/application';
import {
dependencyClash,
Epic,
ReleaseStatus,
roadmapBar,
roadmapDependencies,
roadmapMonths,
roadmapOffset,
roadmapRange,
@@ -51,7 +58,8 @@ const shortDay = (day: string) =>
<p class="ts-muted">
Las épicas en el tiempo, con los sprints y las releases como
referencias. Las fechas de una épica se ponen en su ficha («Empieza» y
«Fecha límite»).
«Fecha límite»); las flechas van de una épica a las que la esperan
(relaciones «bloquea» y «depende de»).
</p>
</div>
@@ -67,7 +75,22 @@ const shortDay = (day: string) =>
} @else if (!epics.loaded()) {
<pi-skeleton shape="block" height="16rem" />
} @else {
<div class="ts-roadmap ts-panel" role="table" aria-label="Hoja de ruta">
@if (clashes().length) {
<pi-note
gap="xs"
status="pending"
appearance="fill"
type="status"
title="Fechas que no encajan"
[text]="clashText()"
/>
}
<div
#chart
class="ts-roadmap ts-panel"
role="table"
aria-label="Hoja de ruta"
>
<div class="ts-roadmap__row ts-roadmap__months" role="row">
<span class="ts-roadmap__label" role="columnheader"></span>
<div class="ts-roadmap__track" role="columnheader">
@@ -141,6 +164,7 @@ const shortDay = (day: string) =>
<div class="ts-roadmap__track" role="cell">
<a
class="ts-roadmap__bar"
[attr.data-epic]="row.epic.issue.id"
[style.left.%]="row.left"
[style.width.%]="row.width"
[style.--tone]="statusTone(row.epic)"
@@ -153,6 +177,53 @@ const shortDay = (day: string) =>
</div>
}
@if (arrows().length) {
<!-- «Bloquea» / «depende de»: told in each bar's title too. -->
<svg
class="ts-roadmap__arrows"
aria-hidden="true"
[attr.width]="canvas().width"
[attr.height]="canvas().height"
>
<defs>
<marker
id="ts-roadmap-arrow"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 z" class="ts-roadmap__head" />
</marker>
<marker
id="ts-roadmap-arrow-clash"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 z" class="ts-roadmap__head is-clash" />
</marker>
</defs>
@for (arrow of arrows(); track arrow.id) {
<path
class="ts-roadmap__arrow"
[class.is-clash]="arrow.clash"
[attr.d]="arrow.d"
[attr.marker-end]="
arrow.clash
? 'url(#ts-roadmap-arrow-clash)'
: 'url(#ts-roadmap-arrow)'
"
/>
}
</svg>
}
<span
class="ts-roadmap__today"
aria-hidden="true"
@@ -190,6 +261,10 @@ const shortDay = (day: string) =>
}
`,
styles: `
pi-note {
display: block;
margin-bottom: 0.75rem;
}
.ts-roadmap {
--label: 18rem;
position: relative;
@@ -296,6 +371,30 @@ const shortDay = (day: string) =>
outline-offset: 1px;
}
}
.ts-roadmap__arrows {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.ts-roadmap__arrow {
fill: none;
stroke: var(--ts-muted);
stroke-width: 1.5;
opacity: 0.8;
&.is-clash {
stroke: var(--ts-c-red, var(--ts-brand));
stroke-dasharray: 4 3;
opacity: 1;
}
}
.ts-roadmap__head {
fill: var(--ts-muted);
&.is-clash {
fill: var(--ts-c-red, var(--ts-brand));
}
}
.ts-roadmap__today {
position: absolute;
top: 0.5rem;
@@ -359,21 +458,73 @@ export class RoadmapPage {
protected readonly todayAt = computed(() =>
roadmapOffset(today(), this.range()),
);
protected readonly epicRows = computed(() =>
this.dated()
protected readonly epicRows = computed(() => {
const keys = new Map(this.dated().map((e) => [e.issue.id, e.issue.key]));
const waitsFor = new Map<string, string[]>();
for (const d of roadmapDependencies(this.dated()))
waitsFor.set(d.after, [
...(waitsFor.get(d.after) ?? []),
keys.get(d.before) as string,
]);
return this.dated()
.map((epic) => {
// One date only: a single-day bar on it.
const start = (epic.issue.startDate ?? epic.issue.dueDate) as string;
const end = (epic.issue.dueDate ?? epic.issue.startDate) as string;
const after = waitsFor.get(epic.issue.id);
return {
epic,
start,
end,
...roadmapBar(start, end, this.range()),
title: `${epic.issue.key} · ${shortDay(start)} – ${shortDay(end)} · ${epic.progress.percent}% terminado`,
title: `${epic.issue.key} · ${shortDay(start)} – ${shortDay(end)} · ${epic.progress.percent}% terminado${after ? ` · depende de ${after.join(', ')}` : ''}`,
};
})
.sort((a, b) => a.start.localeCompare(b.start)),
.sort((a, b) => a.start.localeCompare(b.start));
});
/** Dependencies between dated epics, 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
? [
{
...d,
before,
after,
clash: dependencyClash(before.end, after.start),
},
]
: [];
});
});
protected readonly clashes = computed(() =>
this.dependencies().filter((d) => d.clash),
);
protected readonly clashText = computed(() =>
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)}.`,
)
.join(' '),
);
/* Arrows: drawn over the bars, measured once they are on screen. */
private readonly chart = viewChild<ElementRef<HTMLElement>>('chart');
private readonly resized = signal(0);
protected readonly canvas = signal({ width: 0, height: 0 });
protected readonly arrows = signal<
{ id: string; d: string; clash: boolean }[]
>([]);
@HostListener('window:resize')
protected onResize(): void {
this.resized.update((n) => n + 1);
}
protected readonly sprintBands = computed(() =>
this.project.sprints().map((s) => ({
id: s.id,
@@ -404,6 +555,45 @@ export class RoadmapPage {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.epics.open(projectId));
});
afterRenderEffect(() => {
this.resized();
const dependencies = this.dependencies();
const host = this.chart()?.nativeElement;
if (!host || !dependencies.length) {
if (untracked(this.arrows).length) this.arrows.set([]);
return;
}
const box = host.getBoundingClientRect();
const bar = (id: string) => {
const rect = host
.querySelector(`[data-epic="${id}"]`)
?.getBoundingClientRect();
return rect
? {
left: rect.left - box.left + host.scrollLeft,
right: rect.right - box.left + host.scrollLeft,
middle: rect.top - box.top + rect.height / 2,
}
: null;
};
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);
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}`,
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}`,
},
];
}),
);
});
}
protected monthLabel = monthName;