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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user