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:
@@ -311,7 +311,7 @@ visualización de la plataforma:
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión |
|
||||
| `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/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/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. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
|
||||
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||
| `/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), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||
|
||||
@@ -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