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

@@ -25,7 +25,7 @@
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |

View File

@@ -289,6 +289,7 @@ visualización de la plataforma:
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |

View File

@@ -30,26 +30,48 @@ cuando hay pistas, por dónde empezar.
### En curso, en este orden
4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista
de calendario, para ver qué retrasa a qué.
5. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
6. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas,
automatizaciones y etiquetas.
7. **Sincronizar con issues de Gitea o GitHub**: hoy los commits y PR se enlazan a tickets; además, que un issue del
repositorio cree o actualice un ticket, y al revés.
8. **Vistas guardadas en «Mi trabajo»**: montar la portada con filtros guardados de varios proyectos («Bugs críticos de
todos mis proyectos», «Lo que revisa QA esta semana»…).
9. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos
personalizados, automatizaciones y etiquetas.
3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
para archivar.
10. **Asistente en la web** (la base ya está en el MCP): resumir un ticket largo con sus comentarios; al crear un
ticket, sugerir tipo, prioridad y área y avisar si se parece a otro; redactar las notas de versión a partir del
borrador. Pendiente de decidir la clave de la API de Claude y su coste.
11. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran
bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana
movió WEB-12 a En revisión», con enlace.
12. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
autorización y admitir el registro dinámico de clientes.
Y en el panel del proyecto:
4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
curso), cada grupo con enlace a la lista filtrada.
6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
proyecto (p85 del tiempo de ciclo).
7. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
8. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
con semáforo.
9. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
10. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
sobrecarga y sus bloqueados.
11. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
siguientes a cada release.
13. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
navegador»).
### Aceptadas, para después de esta tanda
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
- **Vistas guardadas en «Mi trabajo»**: filtros guardados de varios proyectos («Bugs críticos de todos mis
proyectos»).
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
vista.
- **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin).
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
integración continua.
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
- **Presencia**: ver quién está mirando o editando el mismo ticket.
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
- **Archivar un proyecto**: queda en solo lectura en lugar de borrarlo.
### Otras ideas apuntadas, sin decidir
@@ -57,8 +79,13 @@ cuando hay pistas, por dónde empezar.
- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo.
- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas,
comentarios. Necesita un buzón de entrada.
- **Tiempo en cada estado**: cuánto pasa un ticket en «En revisión» o «En QA», y dónde se atascan.
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
- **Posponer un ticket**: «recuérdamelo el jueves».
- **Reunión diaria**: qué cerró cada uno ayer, qué tiene hoy y qué le bloquea, sacado del historial.
- **Requisitos al cambiar de estado**: campos obligatorios para entrar en un estado (estimación para «En curso»…).
- **Plazos por prioridad**: «lo crítico, en 24 h», con aviso antes de vencer.
- **Matriz impacto/esfuerzo** para ordenar el backlog.
- **Planning poker** y **retrospectiva del sprint** (las acciones se convierten en tickets).
- **Avisar a quien propuso** cuando su propuesta se hace o se descarta.
## 10.4 Descartado por ahora
@@ -67,6 +94,11 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- **Registro de horas** dedicadas a cada ticket.
- **Objetivos u OKR** por encima de las épicas.
- **App móvil**: la web ya se adapta a pantallas estrechas.
- **Asistente en la web** (resumir tickets, sugerir tipo, prioridad y área, redactar notas de versión): el MCP ya deja
hacerlo desde un cliente de IA.
- **Avisos a Discord, Slack o Telegram** con mensajes legibles: los webhooks salientes siguen ahí para quien los
necesite.
- **Conector de claude.ai** (OAuth del protocolo en el MCP y registro dinámico de clientes en Pireon).
## 10.5 Hecho desde la primera versión de esta lista
@@ -96,3 +128,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- Campos personalizados por proyecto (texto, número, fecha o lista de opciones): se definen en Configuración, se
editan en la ficha, se ven bajo el título en la lista y se filtran por los de opciones (también por GraphQL, REST y
MCP).
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
una empieza antes de que acabe la que la bloquea.

Binary file not shown.

Binary file not shown.

View File

@@ -233,6 +233,7 @@ describe('ProjectFacade', () => {
transitions: [],
issueTypes: [],
areas: [],
customFields: [],
};
project.saveSettings(settings);

View File

@@ -71,6 +71,8 @@ export interface ChildProgress {
export interface Epic {
issue: IssueSummary;
progress: ChildProgress;
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
links: IssueLink[];
}
/** What a saved view narrows the board to; empty: everything. */

View File

@@ -1,5 +1,8 @@
import { IssueLinkType } from './enums';
import {
dependencyClash,
roadmapBar,
roadmapDependencies,
roadmapMonths,
roadmapOffset,
roadmapRange,
@@ -30,4 +33,44 @@ describe('roadmap', () => {
expect(roadmapOffset('2020-01-01', range)).toBe(0);
expect(roadmapOffset('2030-01-01', range)).toBe(100);
});
it('draws each dependency between epics once, first the one that blocks', () => {
const link = (
type: IssueLinkType,
direction: 'OUTWARD' | 'INWARD',
issueId: string,
) => ({ type, direction, issueId, issueKey: issueId.toUpperCase() });
const epics = [
{
issue: { id: 'a' },
links: [
link(IssueLinkType.BLOCKS, 'OUTWARD', 'b'),
link(IssueLinkType.RELATES_TO, 'OUTWARD', 'c'),
link(IssueLinkType.BLOCKS, 'OUTWARD', 'elsewhere'),
],
},
{
issue: { id: 'b' },
links: [
link(IssueLinkType.BLOCKS, 'INWARD', 'a'),
// The same relation told the other way round.
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'a'),
],
},
{
issue: { id: 'c' },
links: [link(IssueLinkType.DEPENDS_ON, 'OUTWARD', 'b')],
},
];
expect(roadmapDependencies(epics)).toEqual([
{ before: 'a', after: 'b' },
{ before: 'b', after: 'c' },
]);
});
it('flags an epic that starts before the one it waits for ends', () => {
expect(dependencyClash('2026-11-10', '2026-11-11')).toBe(false);
expect(dependencyClash('2026-11-10', '2026-11-10')).toBe(true);
expect(dependencyClash('2026-11-10', '2026-11-03')).toBe(true);
});
});

View File

@@ -1,3 +1,6 @@
import { IssueLinkType } from './enums';
import { IssueLink } from './models';
const DAY = 86_400_000;
/** Time span a roadmap shows (UTC midnights). */
@@ -70,3 +73,48 @@ export function roadmapMonths(
}
return months;
}
/** An epic that has to finish before another one starts. */
export interface RoadmapDependency {
/** Id of the epic that goes first (it blocks the other). */
before: string;
/** Id of the epic that waits for it. */
after: string;
}
/**
* The «bloquea» / «depende de» relations among these epics, once per pair:
* A blocks B and B depends on A are the same arrow, A → B. Only outward
* links are read (each relation is stored on both tickets).
*/
export function roadmapDependencies(
epics: { issue: { id: string }; links: IssueLink[] }[],
): RoadmapDependency[] {
const ids = new Set(epics.map((e) => e.issue.id));
const seen = new Set<string>();
const out: RoadmapDependency[] = [];
for (const epic of epics)
for (const link of epic.links) {
if (link.direction !== 'OUTWARD' || !ids.has(link.issueId)) continue;
const pair =
link.type === IssueLinkType.BLOCKS
? { before: epic.issue.id, after: link.issueId }
: link.type === IssueLinkType.DEPENDS_ON
? { before: link.issueId, after: epic.issue.id }
: null;
if (!pair || pair.before === pair.after) continue;
const key = `${pair.before}>${pair.after}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(pair);
}
return out;
}
/** The epic that waits starts on or before the day the other one ends. */
export function dependencyClash(
beforeEnds: string,
afterStarts: string,
): boolean {
return at(afterStarts) <= at(beforeEnds);
}

View File

@@ -50,7 +50,12 @@ import {
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toIssue, toIssueSummary } from './mappers';
import {
toCommandResult,
toIssue,
toIssueLink,
toIssueSummary,
} from './mappers';
@Injectable()
export class GraphqlIssuesGateway extends IssuesGateway {
@@ -214,6 +219,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
d.epics.map((epic) => ({
issue: toIssueSummary(epic.issue),
progress: { ...epic.progress },
links: epic.issue.links.map((l) => toIssueLink(l)),
})),
),
);

View File

@@ -3,6 +3,7 @@ import type {
Comment,
CommandResult,
Issue,
IssueLink,
IssueSummary,
Project,
ProjectSettings,
@@ -39,6 +40,12 @@ export const toProject = (p: ProjectFieldsFragment): Project => ({
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
as<IssueSummary>(i);
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(i);
export const toIssueLink = (l: {
type: string;
direction: string;
issueId: string;
issueKey: string;
}): IssueLink => as<IssueLink>({ ...l });
export const toComment = (c: CommentFieldsFragment): Comment => c;
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
export const toCommandResult = (

View File

@@ -1115,6 +1115,12 @@ export type EpicsQuery = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
links: Array<{
type: IssueLinkType;
direction: LinkDirection;
issueId: string;
issueKey: string;
}>;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -6783,6 +6789,31 @@ export const EpicsDocument = {
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'links' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'type' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'direction' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issueId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issueKey' },
},
],
},
},
],
},
},

View File

@@ -163,6 +163,12 @@ query Epics($projectId: ID!) {
epics(projectId: $projectId) {
issue {
...IssueSummaryFields
links {
type
direction
issueId
issueKey
}
}
progress {
total

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;