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:
@@ -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 |
|
| 🗑️ 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 |
|
| ☑️ 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 |
|
| ⌨️ 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 |
|
| 🗳️ 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`) |
|
| 🌐 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 |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ visualización de la plataforma:
|
|||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket |
|
| `/p/:key/issues/:issueKey` | Ticket |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/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/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/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 |
|
| `/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 |
|
||||||
|
|||||||
@@ -30,26 +30,48 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
### En curso, en este orden
|
### En curso, en este orden
|
||||||
|
|
||||||
4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista
|
1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
|
||||||
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
|
|
||||||
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
|
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,
|
2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos
|
||||||
automatizaciones y etiquetas.
|
personalizados, 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
|
3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
|
||||||
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
|
|
||||||
para archivar.
|
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
|
Y en el panel del proyecto:
|
||||||
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
|
4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
||||||
bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana
|
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
||||||
movió WEB-12 a En revisión», con enlace.
|
curso), cada grupo con enlace a la lista filtrada.
|
||||||
12. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
|
6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
||||||
autorización y admitir el registro dinámico de clientes.
|
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
|
### 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.
|
- **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,
|
- **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.
|
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.
|
- **Posponer un ticket**: «recuérdamelo el jueves».
|
||||||
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
- **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
|
## 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.
|
- **Registro de horas** dedicadas a cada ticket.
|
||||||
- **Objetivos u OKR** por encima de las épicas.
|
- **Objetivos u OKR** por encima de las épicas.
|
||||||
- **App móvil**: la web ya se adapta a pantallas estrechas.
|
- **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
|
## 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
|
- 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
|
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).
|
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.
@@ -233,6 +233,7 @@ describe('ProjectFacade', () => {
|
|||||||
transitions: [],
|
transitions: [],
|
||||||
issueTypes: [],
|
issueTypes: [],
|
||||||
areas: [],
|
areas: [],
|
||||||
|
customFields: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
project.saveSettings(settings);
|
project.saveSettings(settings);
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export interface ChildProgress {
|
|||||||
export interface Epic {
|
export interface Epic {
|
||||||
issue: IssueSummary;
|
issue: IssueSummary;
|
||||||
progress: ChildProgress;
|
progress: ChildProgress;
|
||||||
|
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
|
||||||
|
links: IssueLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a saved view narrows the board to; empty: everything. */
|
/** What a saved view narrows the board to; empty: everything. */
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { IssueLinkType } from './enums';
|
||||||
import {
|
import {
|
||||||
|
dependencyClash,
|
||||||
roadmapBar,
|
roadmapBar,
|
||||||
|
roadmapDependencies,
|
||||||
roadmapMonths,
|
roadmapMonths,
|
||||||
roadmapOffset,
|
roadmapOffset,
|
||||||
roadmapRange,
|
roadmapRange,
|
||||||
@@ -30,4 +33,44 @@ describe('roadmap', () => {
|
|||||||
expect(roadmapOffset('2020-01-01', range)).toBe(0);
|
expect(roadmapOffset('2020-01-01', range)).toBe(0);
|
||||||
expect(roadmapOffset('2030-01-01', range)).toBe(100);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { IssueLinkType } from './enums';
|
||||||
|
import { IssueLink } from './models';
|
||||||
|
|
||||||
const DAY = 86_400_000;
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
/** Time span a roadmap shows (UTC midnights). */
|
/** Time span a roadmap shows (UTC midnights). */
|
||||||
@@ -70,3 +73,48 @@ export function roadmapMonths(
|
|||||||
}
|
}
|
||||||
return months;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ import {
|
|||||||
UpdateIssueDocument,
|
UpdateIssueDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
import { toCommandResult, toIssue, toIssueSummary } from './mappers';
|
import {
|
||||||
|
toCommandResult,
|
||||||
|
toIssue,
|
||||||
|
toIssueLink,
|
||||||
|
toIssueSummary,
|
||||||
|
} from './mappers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GraphqlIssuesGateway extends IssuesGateway {
|
export class GraphqlIssuesGateway extends IssuesGateway {
|
||||||
@@ -214,6 +219,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
d.epics.map((epic) => ({
|
d.epics.map((epic) => ({
|
||||||
issue: toIssueSummary(epic.issue),
|
issue: toIssueSummary(epic.issue),
|
||||||
progress: { ...epic.progress },
|
progress: { ...epic.progress },
|
||||||
|
links: epic.issue.links.map((l) => toIssueLink(l)),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Comment,
|
Comment,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
Issue,
|
Issue,
|
||||||
|
IssueLink,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
Project,
|
Project,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
@@ -39,6 +40,12 @@ export const toProject = (p: ProjectFieldsFragment): Project => ({
|
|||||||
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
|
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
|
||||||
as<IssueSummary>(i);
|
as<IssueSummary>(i);
|
||||||
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(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 toComment = (c: CommentFieldsFragment): Comment => c;
|
||||||
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
|
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
|
||||||
export const toCommandResult = (
|
export const toCommandResult = (
|
||||||
|
|||||||
@@ -1115,6 +1115,12 @@ export type EpicsQuery = {
|
|||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
trashedAt: string | null;
|
trashedAt: string | null;
|
||||||
|
links: Array<{
|
||||||
|
type: IssueLinkType;
|
||||||
|
direction: LinkDirection;
|
||||||
|
issueId: string;
|
||||||
|
issueKey: string;
|
||||||
|
}>;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -6783,6 +6789,31 @@ export const EpicsDocument = {
|
|||||||
kind: 'FragmentSpread',
|
kind: 'FragmentSpread',
|
||||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
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' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ query Epics($projectId: ID!) {
|
|||||||
epics(projectId: $projectId) {
|
epics(projectId: $projectId) {
|
||||||
issue {
|
issue {
|
||||||
...IssueSummaryFields
|
...IssueSummaryFields
|
||||||
|
links {
|
||||||
|
type
|
||||||
|
direction
|
||||||
|
issueId
|
||||||
|
issueKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
progress {
|
progress {
|
||||||
total
|
total
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { VoteComponent } from '../ui/vote';
|
import { VoteComponent } from '../ui/vote';
|
||||||
import {
|
import {
|
||||||
|
afterRenderEffect,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
|
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
|
||||||
@@ -16,9 +21,11 @@ import {
|
|||||||
provideEpicsFacade,
|
provideEpicsFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
|
dependencyClash,
|
||||||
Epic,
|
Epic,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
roadmapBar,
|
roadmapBar,
|
||||||
|
roadmapDependencies,
|
||||||
roadmapMonths,
|
roadmapMonths,
|
||||||
roadmapOffset,
|
roadmapOffset,
|
||||||
roadmapRange,
|
roadmapRange,
|
||||||
@@ -51,7 +58,8 @@ const shortDay = (day: string) =>
|
|||||||
<p class="ts-muted">
|
<p class="ts-muted">
|
||||||
Las épicas en el tiempo, con los sprints y las releases como
|
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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,7 +75,22 @@ const shortDay = (day: string) =>
|
|||||||
} @else if (!epics.loaded()) {
|
} @else if (!epics.loaded()) {
|
||||||
<pi-skeleton shape="block" height="16rem" />
|
<pi-skeleton shape="block" height="16rem" />
|
||||||
} @else {
|
} @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">
|
<div class="ts-roadmap__row ts-roadmap__months" role="row">
|
||||||
<span class="ts-roadmap__label" role="columnheader"></span>
|
<span class="ts-roadmap__label" role="columnheader"></span>
|
||||||
<div class="ts-roadmap__track" role="columnheader">
|
<div class="ts-roadmap__track" role="columnheader">
|
||||||
@@ -141,6 +164,7 @@ const shortDay = (day: string) =>
|
|||||||
<div class="ts-roadmap__track" role="cell">
|
<div class="ts-roadmap__track" role="cell">
|
||||||
<a
|
<a
|
||||||
class="ts-roadmap__bar"
|
class="ts-roadmap__bar"
|
||||||
|
[attr.data-epic]="row.epic.issue.id"
|
||||||
[style.left.%]="row.left"
|
[style.left.%]="row.left"
|
||||||
[style.width.%]="row.width"
|
[style.width.%]="row.width"
|
||||||
[style.--tone]="statusTone(row.epic)"
|
[style.--tone]="statusTone(row.epic)"
|
||||||
@@ -153,6 +177,53 @@ const shortDay = (day: string) =>
|
|||||||
</div>
|
</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
|
<span
|
||||||
class="ts-roadmap__today"
|
class="ts-roadmap__today"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -190,6 +261,10 @@ const shortDay = (day: string) =>
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
pi-note {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
.ts-roadmap {
|
.ts-roadmap {
|
||||||
--label: 18rem;
|
--label: 18rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -296,6 +371,30 @@ const shortDay = (day: string) =>
|
|||||||
outline-offset: 1px;
|
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 {
|
.ts-roadmap__today {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
@@ -359,21 +458,73 @@ export class RoadmapPage {
|
|||||||
protected readonly todayAt = computed(() =>
|
protected readonly todayAt = computed(() =>
|
||||||
roadmapOffset(today(), this.range()),
|
roadmapOffset(today(), this.range()),
|
||||||
);
|
);
|
||||||
protected readonly epicRows = computed(() =>
|
protected readonly epicRows = computed(() => {
|
||||||
this.dated()
|
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) => {
|
.map((epic) => {
|
||||||
// One date only: a single-day bar on it.
|
// One date only: a single-day bar on it.
|
||||||
const start = (epic.issue.startDate ?? epic.issue.dueDate) as string;
|
const start = (epic.issue.startDate ?? epic.issue.dueDate) as string;
|
||||||
const end = (epic.issue.dueDate ?? epic.issue.startDate) as string;
|
const end = (epic.issue.dueDate ?? epic.issue.startDate) as string;
|
||||||
|
const after = waitsFor.get(epic.issue.id);
|
||||||
return {
|
return {
|
||||||
epic,
|
epic,
|
||||||
start,
|
start,
|
||||||
|
end,
|
||||||
...roadmapBar(start, end, this.range()),
|
...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(() =>
|
protected readonly sprintBands = computed(() =>
|
||||||
this.project.sprints().map((s) => ({
|
this.project.sprints().map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -404,6 +555,45 @@ export class RoadmapPage {
|
|||||||
const projectId = this.project.project()?.id;
|
const projectId = this.project.project()?.id;
|
||||||
if (projectId) untracked(() => this.epics.open(projectId));
|
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;
|
protected monthLabel = monthName;
|
||||||
|
|||||||
Reference in New Issue
Block a user