feat: dashboard reads the sprint, the risks, aging and bottlenecks

A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
  needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
  and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:55:52 +02:00
parent 86e313efaa
commit fe03c5bda8
41 changed files with 2774 additions and 20 deletions

View File

@@ -1366,4 +1366,53 @@ describe('task-stream (e2e)', () => {
); );
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]); expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
}); });
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const late = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late',
dueDate: '2020-01-01',
})
).body['key'];
const waits = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Waits',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${waits}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${late}/links`, {
type: 'BLOCKS',
targetIssue: waits,
});
const insights = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
);
expect(insights.body['sprint']).toBeNull();
expect(insights.body['risks'].overdue[0]).toMatchObject({
key: late,
dueDate: '2020-01-01',
});
expect(insights.body['risks'].blocked[0].key).toBe(waits);
expect(
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
).toContain('IN_PROGRESS');
const { project } = await gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
{ k: key },
);
expect(project.insights.risks.overdueCount).toBe(1);
});
}); });

View File

@@ -1435,6 +1435,89 @@
] ]
} }
}, },
"/api/projects/{project}/insights": {
"get": {
"description": "Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.",
"operationId": "ProjectsController_insights",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectInsightsDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
},
{}
],
"summary": "The dashboard",
"tags": [
"Projects"
]
}
},
"/api/projects/{project}/activity": { "/api/projects/{project}/activity": {
"get": { "get": {
"operationId": "ProjectsController_activity", "operationId": "ProjectsController_activity",
@@ -11804,6 +11887,264 @@
"qa" "qa"
] ]
}, },
"SprintPulseDto": {
"type": "object",
"properties": {
"sprintId": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"startDate": {
"type": "string",
"format": "date"
},
"endDate": {
"type": "string",
"format": "date"
},
"totalDays": {
"type": "number"
},
"daysLeft": {
"type": "number",
"description": "Days left after today"
},
"tickets": {
"type": "number"
},
"doneTickets": {
"type": "number"
},
"points": {
"type": "number"
},
"donePoints": {
"type": "number"
},
"pacePerDay": {
"type": "number",
"nullable": true
},
"neededPerDay": {
"type": "number",
"nullable": true
},
"behind": {
"type": "boolean",
"description": "At this pace, what is open would not be closed"
}
},
"required": [
"sprintId",
"name",
"startDate",
"endDate",
"totalDays",
"daysLeft",
"tickets",
"doneTickets",
"points",
"donePoints",
"pacePerDay",
"neededPerDay",
"behind"
]
},
"InsightIssueDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"key": {
"type": "string",
"example": "WEB-12"
},
"title": {
"type": "string"
},
"status": {
"type": "string",
"description": "Status id"
},
"assignee": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/UserRefDto"
}
]
},
"days": {
"type": "number",
"description": "Days in its status (aging, stuck) or past / to its due date"
},
"dueDate": {
"type": "string",
"nullable": true,
"format": "date"
}
},
"required": [
"id",
"key",
"title",
"status",
"assignee",
"days",
"dueDate"
]
},
"RisksDto": {
"type": "object",
"properties": {
"overdue": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"overdueCount": {
"type": "number"
},
"dueSoon": {
"description": "Due within 7 days",
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"dueSoonCount": {
"type": "number"
},
"blocked": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"blockedCount": {
"type": "number"
},
"stuck": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"stuckCount": {
"type": "number"
},
"stuckDays": {
"type": "number"
}
},
"required": [
"overdue",
"overdueCount",
"dueSoon",
"dueSoonCount",
"blocked",
"blockedCount",
"stuck",
"stuckCount",
"stuckDays"
]
},
"AgingDto": {
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"thresholdDays": {
"type": "number",
"nullable": true,
"description": "p85 cycle time of the last 90 days"
}
},
"required": [
"issues",
"thresholdDays"
]
},
"StatusTimeDto": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"averageDays": {
"type": "number",
"nullable": true
},
"samples": {
"type": "number"
},
"open": {
"type": "number"
},
"wipLimit": {
"type": "number",
"nullable": true
},
"overLimit": {
"type": "boolean"
}
},
"required": [
"status",
"averageDays",
"samples",
"open",
"wipLimit",
"overLimit"
]
},
"ProjectInsightsDto": {
"type": "object",
"properties": {
"generatedAt": {
"type": "string"
},
"sprint": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/SprintPulseDto"
}
]
},
"risks": {
"$ref": "#/components/schemas/RisksDto"
},
"aging": {
"$ref": "#/components/schemas/AgingDto"
},
"bottlenecks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatusTimeDto"
}
}
},
"required": [
"generatedAt",
"sprint",
"risks",
"aging",
"bottlenecks"
]
},
"ActivityDto": { "ActivityDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -18,6 +18,13 @@ input AddChecklistItemInput {
text: String! text: String!
} }
type Aging {
issues: [InsightIssue!]!
"""p85 cycle time of the last 90 days"""
thresholdDays: Float
}
type AreaOption { type AreaOption {
archived: Boolean! archived: Boolean!
id: String! id: String!
@@ -475,6 +482,19 @@ type GitRepository {
secret: String secret: String
} }
"""A ticket the dashboard names, with the number that put it there"""
type InsightIssue {
assignee: UserRef
"""Days in its status (aging, stuck) or past / to its due date"""
days: Float!
dueDate: String
id: ID!
key: String!
status: String!
title: String!
}
type Issue { type Issue {
"""Statuses this ticket can move to next (project's workflow)""" """Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]! allowedTransitions: [String!]!
@@ -955,6 +975,11 @@ type Project {
description: String description: String
id: ID! id: ID!
"""
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
"""
insights: ProjectInsights!
"""Anyone can read it, also without signing in""" """Anyone can read it, also without signing in"""
isPublic: Boolean! isPublic: Boolean!
key: String! key: String!
@@ -996,6 +1021,15 @@ type ProjectChange {
version: Int! version: Int!
} }
"""The dashboard: sprint under way, risks, aging and bottlenecks"""
type ProjectInsights {
aging: Aging!
bottlenecks: [StatusTime!]!
generatedAt: String!
risks: Risks!
sprint: SprintPulse
}
type ProjectMember { type ProjectMember {
role: ProjectRole! role: ProjectRole!
user: UserRef! user: UserRef!
@@ -1221,6 +1255,21 @@ enum ReleaseStatus {
RELEASED RELEASED
} }
"""Tickets to look at now (worst first)"""
type Risks {
blocked: [InsightIssue!]!
blockedCount: Int!
"""Due within 7 days"""
dueSoon: [InsightIssue!]!
dueSoonCount: Int!
overdue: [InsightIssue!]!
overdueCount: Int!
stuck: [InsightIssue!]!
stuckCount: Int!
stuckDays: Int!
}
input SaveFilterInput { input SaveFilterInput {
criteria: SavedFilterCriteriaInput! criteria: SavedFilterCriteriaInput!
name: String! name: String!
@@ -1321,6 +1370,26 @@ type SprintProgress {
total: Int! total: Int!
} }
"""The sprint under way: time gone against work done"""
type SprintPulse {
"""At this pace, what is open would not be closed"""
behind: Boolean!
"""Days left after today"""
daysLeft: Int!
donePoints: Int!
doneTickets: Int!
endDate: String!
name: String!
neededPerDay: Float
pacePerDay: Float
points: Int!
sprintId: ID!
startDate: String!
tickets: Int!
totalDays: Int!
}
type SprintRef { type SprintRef {
id: ID! id: ID!
name: String! name: String!
@@ -1344,6 +1413,15 @@ enum StatusCategory {
TODO TODO
} }
type StatusTime {
averageDays: Float
open: Int!
overLimit: Boolean!
samples: Int!
status: String!
wipLimit: Int
}
type StatusTransitions { type StatusTransitions {
from: String! from: String!
to: [String!]! to: [String!]!

View File

@@ -285,6 +285,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/projects/{project}/insights": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* The dashboard
* @description Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.
*/
get: operations["ProjectsController_insights"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/activity": { "/api/projects/{project}/activity": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2100,6 +2120,72 @@ export interface components {
throughput: components["schemas"]["ThroughputDto"][]; throughput: components["schemas"]["ThroughputDto"][];
qa: components["schemas"]["QaStatsDto"]; qa: components["schemas"]["QaStatsDto"];
}; };
SprintPulseDto: {
/** Format: uuid */
sprintId: string;
name: string;
/** Format: date */
startDate: string;
/** Format: date */
endDate: string;
totalDays: number;
/** @description Days left after today */
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
/** @description At this pace, what is open would not be closed */
behind: boolean;
};
InsightIssueDto: {
/** Format: uuid */
id: string;
/** @example WEB-12 */
key: string;
title: string;
/** @description Status id */
status: string;
assignee: components["schemas"]["UserRefDto"] | null;
/** @description Days in its status (aging, stuck) or past / to its due date */
days: number;
/** Format: date */
dueDate: string | null;
};
RisksDto: {
overdue: components["schemas"]["InsightIssueDto"][];
overdueCount: number;
/** @description Due within 7 days */
dueSoon: components["schemas"]["InsightIssueDto"][];
dueSoonCount: number;
blocked: components["schemas"]["InsightIssueDto"][];
blockedCount: number;
stuck: components["schemas"]["InsightIssueDto"][];
stuckCount: number;
stuckDays: number;
};
AgingDto: {
issues: components["schemas"]["InsightIssueDto"][];
/** @description p85 cycle time of the last 90 days */
thresholdDays: number | null;
};
StatusTimeDto: {
status: string;
averageDays: number | null;
samples: number;
open: number;
wipLimit: number | null;
overLimit: boolean;
};
ProjectInsightsDto: {
generatedAt: string;
sprint: components["schemas"]["SprintPulseDto"] | null;
risks: components["schemas"]["RisksDto"];
aging: components["schemas"]["AgingDto"];
bottlenecks: components["schemas"]["StatusTimeDto"][];
};
ActivityDto: { ActivityDto: {
id: string; id: string;
/** @example IssueStatusChanged */ /** @example IssueStatusChanged */
@@ -3882,6 +3968,64 @@ export interface operations {
}; };
}; };
}; };
ProjectsController_insights: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectInsightsDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
ProjectsController_activity: { ProjectsController_activity: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -204,6 +204,23 @@ export class ProjectsResolver {
); );
} }
@Tool({
name: 'get_project_insights',
description:
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time) and the average days in each in-progress and QA status, with WIP limits.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
insights({ project }: { project: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/insights', {
params: { path: { project } },
}),
),
);
}
@Tool({ @Tool({
name: 'get_project_activity', name: 'get_project_activity',
description: 'Latest activity of a project (newest first).', description: 'Latest activity of a project (newest first).',

View File

@@ -379,6 +379,16 @@ puras (`application/src/lib/analytics`):
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa, Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
reconstruye el read model (ver [Despliegue](07-despliegue.md)). reconstruye el read model (ver [Despliegue](07-despliegue.md)).
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
decir qué mirar ahora (`analytics/insights.ts`):
| Bloque | Qué da |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
## 3.2 GraphQL ## 3.2 GraphQL
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
@@ -621,7 +631,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | | Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |

View File

@@ -291,7 +291,7 @@ visualización de la plataforma:
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/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/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 |
| `/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, sprint en curso (¿llega?), tickets en riesgo, antigüedad de lo que está en curso, cuellos de botella, equipo (cada persona enlaza a su tablero) y tipos. Se recarga solo con cada cambio |
| `/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 |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |

View File

@@ -48,6 +48,7 @@ flowchart LR
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | | | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento | | | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | | | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_insights` | El panel: sprint en curso, tickets en riesgo, antigüedad y cuellos de botella |
| | `get_project_activity` | Actividad reciente | | | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |

View File

@@ -23,6 +23,9 @@ cuando hay pistas, por dónde empezar.
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir. ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado. - **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos). - **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
- **Escrituras a la vez en un ticket**: si una saga (por ejemplo, apuntar como seguidor a quien se asigna) escribe en
el mismo ticket justo cuando alguien lo cambia, la petición de esa persona recibe un 409 y hay que repetirla. Con la
máquina muy cargada salta en las pruebas e2e de vez en cuando. Reintentar sola la orden ante un `ConcurrencyError`.
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y - **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
renovarlo a mano cuando caduca. renovarlo a mano cuando caduca.
@@ -32,22 +35,16 @@ cuando hay pistas, por dónde empezar.
En el panel del proyecto: En el panel del proyecto:
1. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega. 1. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
2. **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.
3. **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).
4. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
5. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
con semáforo. con semáforo.
6. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones. 2. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
7. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de 3. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
sobrecarga y sus bloqueados. sobrecarga y sus bloqueados.
8. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada. 4. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
9. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días 5. **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. siguientes a cada release.
10. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por 6. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
navegador»). navegador»).
### Aceptadas, para después de esta tanda ### Aceptadas, para después de esta tanda
@@ -127,3 +124,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
existían, en Sprints.) existían, en Sprints.)
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases. - Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
y MCP (`get_project_insights`).

Binary file not shown.

Binary file not shown.

View File

@@ -19,7 +19,7 @@ import {
const DAY = 86_400_000; const DAY = 86_400_000;
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA]; const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
interface Step { export interface Step {
at: number; at: number;
status: string; status: string;
category: StatusCategory; category: StatusCategory;
@@ -63,7 +63,7 @@ function weeks(from: number, to: number): string[] {
} }
/** Status steps per ticket; tickets with no timeline get one from their creation. */ /** Status steps per ticket; tickets with no timeline get one from their creation. */
function stepsByIssue( export function stepsByIssue(
issues: IssueReadModel[], issues: IssueReadModel[],
timeline: IssueTimelineEntry[], timeline: IssueTimelineEntry[],
): Map<string, Step[]> { ): Map<string, Step[]> {

View File

@@ -0,0 +1,177 @@
import {
defaultProjectSettings,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
SprintReadModel,
SprintStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import { computeInsights, STUCK_DAYS } from './insights';
const NOW = new Date('2026-10-15T12:00:00Z');
const bob = { id: 'bob', name: 'Bob' };
const issue = (
id: string,
status: string,
category: C,
extra: Partial<IssueReadModel> = {},
): IssueReadModel =>
({
id,
key: id,
title: `Ticket ${id}`,
status,
statusCategory: category,
assignee: bob,
reporter: bob,
sprint: null,
storyPoints: null,
dueDate: null,
links: [],
trashedAt: null,
createdAt: '2026-09-01T09:00:00Z',
updatedAt: '2026-10-14T09:00:00Z',
...extra,
}) as IssueReadModel;
let seq = 0;
const step = (
issueId: string,
at: string,
status: string,
category: C,
): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'status',
status,
category,
assigneeId: 'bob',
});
const sprint: SprintReadModel = {
id: 's',
projectId: 'p',
name: 'Sprint 7',
goal: null,
startDate: '2026-10-12',
endDate: '2026-10-25',
status: SprintStatus.ACTIVE,
startedAt: '2026-10-12T08:00:00Z',
closedAt: null,
createdAt: '2026-10-01T08:00:00Z',
version: 2,
};
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
describe('computeInsights', () => {
it('names what is late, due soon, blocked and stuck', () => {
const issues = [
issue('LATE', 'TODO', C.TODO, { dueDate: '2026-10-10' }),
issue('SOON', 'TODO', C.TODO, { dueDate: '2026-10-20' }),
issue('LATER', 'TODO', C.TODO, { dueDate: '2026-11-30' }),
issue('DONE_LATE', 'DONE', C.DONE, { dueDate: '2026-10-01' }),
issue('WAITS', 'TODO', C.TODO, {
links: [
{
type: IssueLinkType.BLOCKS,
direction: 'INWARD',
issueId: 'SLOW',
issueKey: 'SLOW',
},
],
}),
issue('SLOW', 'IN_PROGRESS', C.IN_PROGRESS),
issue('FRESH', 'IN_QA', C.QA),
];
const timeline = [
step('SLOW', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('FRESH', '2026-10-14T10:00:00Z', 'IN_QA', C.QA),
];
const { risks, aging } = computeInsights({
issues,
timeline,
settings: defaultProjectSettings(),
activeSprint: null,
now: NOW,
});
expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]);
expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]);
expect(risks.blocked.map((i) => i.key)).toEqual(['WAITS']);
expect(risks.stuck.map((i) => [i.key, i.days])).toEqual([['SLOW', 14]]);
expect(risks.stuckDays).toBe(STUCK_DAYS);
// Everything under way, the longest first.
expect(aging.issues.map((i) => [i.key, i.days])).toEqual([
['SLOW', 14],
['FRESH', 1],
]);
});
it('measures the sprint under way against the calendar', () => {
const issues = [
issue('A', 'DONE', C.DONE, { ...inSprint, storyPoints: 3 }),
issue('B', 'IN_PROGRESS', C.IN_PROGRESS, { ...inSprint, storyPoints: 8 }),
issue('C', 'TODO', C.TODO, { ...inSprint, storyPoints: 5 }),
issue('X', 'CANCELLED', C.CANCELLED, { ...inSprint, storyPoints: 13 }),
];
const { sprint: pulse } = computeInsights({
issues,
timeline: [],
settings: defaultProjectSettings(),
activeSprint: sprint,
now: NOW,
});
expect(pulse).toMatchObject({
totalDays: 14,
daysLeft: 10,
tickets: 3,
doneTickets: 1,
points: 16,
donePoints: 3,
// 3 points in 3 days; 13 left for 11 days.
pacePerDay: 1,
neededPerDay: 1.2,
behind: true,
});
});
it('finds where tickets wait, and columns over their limit', () => {
const settings = defaultProjectSettings();
settings.statuses.find((s) => s.id === 'IN_REVIEW')!.wipLimit = 1;
const issues = [
issue('A', 'DONE', C.DONE),
issue('B', 'IN_REVIEW', C.IN_PROGRESS),
issue('C', 'IN_REVIEW', C.IN_PROGRESS),
];
const timeline = [
step('A', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('A', '2026-10-03T10:00:00Z', 'IN_REVIEW', C.IN_PROGRESS),
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
];
const rows = computeInsights({
issues,
timeline,
settings,
activeSprint: null,
now: NOW,
}).bottlenecks;
expect(rows.map((r) => r.status)).toEqual([
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
'QA_FAILED',
]);
expect(rows[0]).toMatchObject({ averageDays: 2, samples: 1, open: 0 });
expect(rows[1]).toMatchObject({
averageDays: 4,
open: 2,
wipLimit: 1,
overLimit: true,
});
});
});

View File

@@ -0,0 +1,227 @@
import {
InsightIssueReadModel,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
ProjectInsightsReadModel,
ProjectSettings,
SprintPulseReadModel,
SprintReadModel,
StatusCategory,
StatusTimeReadModel,
} from '@task-stream/api/tracker/domain';
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
/*
* The dashboard's reading of a project: what needs attention now (risks),
* how long the work under way has been where it is (aging) and where it
* waits (bottlenecks). Pure functions over the read model and the
* timeline, like the statistics.
*/
const DAY = 86_400_000;
/** Days without moving that make a ticket under way «stuck». */
export const STUCK_DAYS = 7;
/** How many tickets each list names. */
const LIST = 8;
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
export interface InsightsInput {
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
settings: ProjectSettings;
activeSprint: SprintReadModel | null;
now: Date;
}
const utcDay = (time: number) =>
Date.UTC(
new Date(time).getUTCFullYear(),
new Date(time).getUTCMonth(),
new Date(time).getUTCDate(),
);
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
const round1 = (n: number) => Math.round(n * 10) / 10;
export function computeInsights(
input: InsightsInput,
): ProjectInsightsReadModel {
const now = input.now.getTime();
const today = utcDay(now);
const live = input.issues.filter((i) => !i.trashedAt);
const open = live.filter((i) => !CLOSED.includes(i.statusCategory));
const steps = stepsByIssue(live, input.timeline);
/** When it got to its current status. */
const sinceOf = (issue: IssueReadModel) => {
const list = steps.get(issue.id) ?? [];
const last = list[list.length - 1];
return last && last.status === issue.status
? last.at
: Date.parse(issue.updatedAt);
};
const line = (
issue: IssueReadModel,
days: number,
): InsightIssueReadModel => ({
id: issue.id,
key: issue.key,
title: issue.title,
status: issue.status,
assignee: issue.assignee,
days,
dueDate: issue.dueDate,
});
// --- Risks ---
const due = open
.filter((i) => i.dueDate)
.map((i) => ({
issue: i,
days: (dayIndex(i.dueDate as string) - today) / DAY,
}));
const overdue = due
.filter((d) => d.days < 0)
.sort((a, b) => a.days - b.days)
.map((d) => line(d.issue, -d.days));
const dueSoon = due
.filter((d) => d.days >= 0 && d.days <= 7)
.sort((a, b) => a.days - b.days)
.map((d) => line(d.issue, d.days));
const openIds = new Set(open.map((i) => i.id));
const blocked = open
.filter((i) =>
i.links.some(
(l) =>
openIds.has(l.issueId) &&
((l.type === IssueLinkType.BLOCKS && l.direction === 'INWARD') ||
(l.type === IssueLinkType.DEPENDS_ON && l.direction === 'OUTWARD')),
),
)
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
.sort((a, b) => b.days - a.days);
const underWay = open
.filter((i) => FLOW.includes(i.statusCategory))
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
.sort((a, b) => b.days - a.days);
const stuck = underWay.filter((i) => i.days >= STUCK_DAYS);
// --- Aging: against the cycle time of the last 90 days ---
const analytics = computeProjectAnalytics({
issues: live,
timeline: input.timeline,
statusOrder: input.settings.statuses.map((s) => s.id),
from: new Date(now - 89 * DAY),
to: input.now,
});
return {
generatedAt: input.now.toISOString(),
sprint: input.activeSprint
? sprintPulse(input.activeSprint, live, now)
: null,
risks: {
overdue: overdue.slice(0, LIST),
overdueCount: overdue.length,
dueSoon: dueSoon.slice(0, LIST),
dueSoonCount: dueSoon.length,
blocked: blocked.slice(0, LIST),
blockedCount: blocked.length,
stuck: stuck.slice(0, LIST),
stuckCount: stuck.length,
stuckDays: STUCK_DAYS,
},
aging: {
issues: underWay.slice(0, 15),
thresholdDays: analytics.cycleTime.p85Days,
},
bottlenecks: bottlenecks(input.settings, open, steps, now),
};
}
function sprintPulse(
sprint: SprintReadModel,
issues: IssueReadModel[],
now: number,
): SprintPulseReadModel {
const inSprint = issues.filter(
(i) =>
i.sprint?.id === sprint.id &&
i.statusCategory !== StatusCategory.CANCELLED,
);
const done = inSprint.filter((i) => i.statusCategory === StatusCategory.DONE);
const points = (list: IssueReadModel[]) =>
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
const start = dayIndex(sprint.startDate);
const end = dayIndex(sprint.endDate);
const today = utcDay(now);
const totalDays = Math.round((end - start) / DAY) + 1;
const daysLeft = Math.round((end - today) / DAY);
// Days already worked: today counts once it is half gone.
const elapsed = Math.min(
totalDays,
Math.max(0, Math.round((today - start) / DAY)),
);
const total = points(inSprint);
const closed = points(done);
const remaining = total - closed;
const pace = elapsed > 0 ? round1(closed / elapsed) : null;
const left = Math.max(daysLeft + 1, 0);
const needed =
remaining > 0 ? (left > 0 ? round1(remaining / left) : null) : null;
return {
sprintId: sprint.id,
name: sprint.name,
startDate: sprint.startDate,
endDate: sprint.endDate,
totalDays,
daysLeft,
tickets: inSprint.length,
doneTickets: done.length,
points: total,
donePoints: closed,
pacePerDay: pace,
neededPerDay: needed,
behind:
remaining > 0 &&
(left === 0 || (pace !== null && pace * left < remaining)),
};
}
/** Average stay in each in-progress and QA status (stays that ended lately). */
function bottlenecks(
settings: ProjectSettings,
open: IssueReadModel[],
steps: Map<string, Step[]>,
now: number,
): StatusTimeReadModel[] {
const from = now - 90 * DAY;
const stays = new Map<string, number[]>();
for (const list of steps.values())
list.forEach((step, i) => {
const next = list[i + 1];
if (!next || next.at < from) return;
stays.set(step.status, [
...(stays.get(step.status) ?? []),
next.at - step.at,
]);
});
return settings.statuses
.filter((s) => !s.archived && FLOW.includes(s.category))
.map((s) => {
const measured = stays.get(s.id) ?? [];
const count = open.filter((i) => i.status === s.id).length;
const limit = s.wipLimit ?? null;
return {
status: s.id,
averageDays: measured.length
? round1(measured.reduce((a, b) => a + b, 0) / measured.length / DAY)
: null,
samples: measured.length,
open: count,
wipLimit: limit,
overLimit: limit !== null && count > limit,
};
});
}

View File

@@ -11,6 +11,7 @@ import {
import { import {
IssuePriority, IssuePriority,
ProjectAnalyticsReadModel, ProjectAnalyticsReadModel,
ProjectInsightsReadModel,
ProjectReadModel, ProjectReadModel,
ProjectRole, ProjectRole,
ProjectSettings, ProjectSettings,
@@ -34,6 +35,7 @@ import { IssueFilter } from './ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './queries/get-board.query'; import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query'; import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query'; import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query'; import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetPublicProjectsQuery } from './queries/get-public-projects.query'; import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
import { GetProjectQuery } from './queries/get-project.query'; import { GetProjectQuery } from './queries/get-project.query';
@@ -321,6 +323,16 @@ export class ProjectsService {
} }
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */ /** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
/** Sprint under way, risks, aging and bottlenecks, for the dashboard. */
insights(
viewerId: string | null,
projectId: string,
): Promise<ProjectInsightsReadModel> {
return this.queryBus.execute(
new GetProjectInsightsQuery(viewerId, projectId),
);
}
analytics( analytics(
viewerId: string | null, viewerId: string | null,
projectId: string, projectId: string,

View File

@@ -0,0 +1,44 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectInsightsReadModel,
} from '@task-stream/api/tracker/domain';
import { computeInsights } from '../analytics/insights';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetProjectInsightsQuery } from './get-project-insights.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetProjectInsightsQuery)
export class GetProjectInsightsQueryHandler
implements IQueryHandler<GetProjectInsightsQuery, ProjectInsightsReadModel>
{
constructor(
private readonly access: ReadAccess,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
) {}
async execute(
query: GetProjectInsightsQuery,
): Promise<ProjectInsightsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
const [project, issues, timeline, activeSprint] = await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),
this.findTimeline.findByProject(query.projectId),
this.findSprints.findActive(query.projectId),
]);
return computeInsights({
issues,
timeline,
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
activeSprint,
now: new Date(),
});
}
}

View File

@@ -0,0 +1,13 @@
import { Query } from '@nestjs/cqrs';
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
/** The dashboard's reading of a project: sprint, risks, aging, bottlenecks. */
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -6,6 +6,7 @@ export * from './get-issue.query';
export * from './get-issues-by-ids.query'; export * from './get-issues-by-ids.query';
export * from './get-my-projects.query'; export * from './get-my-projects.query';
export * from './get-project-analytics.query'; export * from './get-project-analytics.query';
export * from './get-project-insights.query';
export * from './get-project-stats.query'; export * from './get-project-stats.query';
export * from './get-release-burnup.query'; export * from './get-release-burnup.query';
export * from './get-project.query'; export * from './get-project.query';

View File

@@ -9,6 +9,7 @@ import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler'; import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
import { GetProjectQueryHandler } from './get-project.query-handler'; import { GetProjectQueryHandler } from './get-project.query-handler';
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler'; import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler'; import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler'; import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
import { GetReleaseQueryHandler } from './get-release.query-handler'; import { GetReleaseQueryHandler } from './get-release.query-handler';
@@ -34,6 +35,7 @@ export const QUERY_HANDLERS = [
GetMyProjectsQueryHandler, GetMyProjectsQueryHandler,
GetProjectQueryHandler, GetProjectQueryHandler,
GetProjectAnalyticsQueryHandler, GetProjectAnalyticsQueryHandler,
GetProjectInsightsQueryHandler,
GetProjectStatsQueryHandler, GetProjectStatsQueryHandler,
GetReleaseBurnupQueryHandler, GetReleaseBurnupQueryHandler,
GetReleaseQueryHandler, GetReleaseQueryHandler,

View File

@@ -9,3 +9,4 @@ export * from './analytics.read-model';
export * from './notification.read-model'; export * from './notification.read-model';
export * from './saved-filter.read-model'; export * from './saved-filter.read-model';
export * from './sprint.read-model'; export * from './sprint.read-model';
export * from './insights.read-model';

View File

@@ -0,0 +1,81 @@
import { UserRefReadModel } from './user-ref.read-model';
/** A ticket named by the dashboard, with the number that put it there. */
export class InsightIssueReadModel {
id!: string;
key!: string;
title!: string;
/** Status id of the project's settings. */
status!: string;
assignee!: UserRefReadModel | null;
/** Days in its current status (aging, stuck) or past/to its due date. */
days!: number;
dueDate!: string | null;
}
/** The sprint under way: time gone against work done. */
export class SprintPulseReadModel {
sprintId!: string;
name!: string;
startDate!: string;
endDate!: string;
/** Days of the sprint, both ends included. */
totalDays!: number;
/** Days left after today (0 on the last day; negative once it is over). */
daysLeft!: number;
tickets!: number;
doneTickets!: number;
points!: number;
donePoints!: number;
/** Points closed per day so far (null on the first day). */
pacePerDay!: number | null;
/** Points per day still needed to finish (null when nothing is left). */
neededPerDay!: number | null;
/** At this pace, what is open would not be closed in time. */
behind!: boolean;
}
/** Tickets to look at now. Lists are the worst first, a few at most. */
export class RiskReadModel {
overdue!: InsightIssueReadModel[];
overdueCount!: number;
/** Due within the next 7 days. */
dueSoon!: InsightIssueReadModel[];
dueSoonCount!: number;
/** Waiting for an open ticket that blocks them (or they depend on). */
blocked!: InsightIssueReadModel[];
blockedCount!: number;
/** In progress or in QA for `stuckDays` or more without moving. */
stuck!: InsightIssueReadModel[];
stuckCount!: number;
stuckDays!: number;
}
/** How long the work under way has been where it is. */
export class AgingReadModel {
issues!: InsightIssueReadModel[];
/** 85% of the tickets finished lately took at most this (cycle time). */
thresholdDays!: number | null;
}
/** Time tickets spend in a status of the flow, and how many are there. */
export class StatusTimeReadModel {
status!: string;
/** Average stay of the tickets that left it in the last 90 days. */
averageDays!: number | null;
/** Stays measured. */
samples!: number;
open!: number;
wipLimit!: number | null;
overLimit!: boolean;
}
/** What the project dashboard reads beyond the counts. */
export class ProjectInsightsReadModel {
generatedAt!: string;
sprint!: SprintPulseReadModel | null;
risks!: RiskReadModel;
aging!: AgingReadModel;
/** In-progress and QA statuses, in board order. */
bottlenecks!: StatusTimeReadModel[];
}

View File

@@ -31,6 +31,7 @@ import {
BoardColumnGql, BoardColumnGql,
CommandResultGql, CommandResultGql,
ProjectAnalyticsGql, ProjectAnalyticsGql,
ProjectInsightsGql,
ProjectGql, ProjectGql,
ProjectSettingsGql, ProjectSettingsGql,
ProjectStatsGql, ProjectStatsGql,
@@ -83,6 +84,17 @@ export class ProjectResolver {
return this.projects.analytics(viewerOf(me), project.id, days); return this.projects.analytics(viewerOf(me), project.id, days);
} }
@ResolveField(() => ProjectInsightsGql, {
description:
'The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks',
})
insights(
@CurrentUser() me: User | undefined,
@Parent() project: ProjectReadModel,
) {
return this.projects.insights(viewerOf(me), project.id);
}
@ResolveField(() => [ReleaseGql]) @ResolveField(() => [ReleaseGql])
releases( releases(
@CurrentUser() me: User | undefined, @CurrentUser() me: User | undefined,

View File

@@ -794,3 +794,87 @@ export class ReleaseBurnupGql {
scope!: number[]; scope!: number[];
@Field(() => [Int]) done!: number[]; @Field(() => [Int]) done!: number[];
} }
@ObjectType('InsightIssue', {
description:
'A ticket the dashboard names, with the number that put it there',
})
export class InsightIssueGql {
@Field(() => ID) id!: string;
@Field() key!: string;
@Field() title!: string;
@Field() status!: string;
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@Field(() => Float, {
description: 'Days in its status (aging, stuck) or past / to its due date',
})
days!: number;
@Field(() => String, { nullable: true }) dueDate!: string | null;
}
@ObjectType('SprintPulse', {
description: 'The sprint under way: time gone against work done',
})
export class SprintPulseGql {
@Field(() => ID) sprintId!: string;
@Field() name!: string;
@Field() startDate!: string;
@Field() endDate!: string;
@Field(() => Int) totalDays!: number;
@Field(() => Int, { description: 'Days left after today' })
daysLeft!: number;
@Field(() => Int) tickets!: number;
@Field(() => Int) doneTickets!: number;
@Field(() => Int) points!: number;
@Field(() => Int) donePoints!: number;
@Field(() => Float, { nullable: true }) pacePerDay!: number | null;
@Field(() => Float, { nullable: true }) neededPerDay!: number | null;
@Field({ description: 'At this pace, what is open would not be closed' })
behind!: boolean;
}
@ObjectType('Risks', { description: 'Tickets to look at now (worst first)' })
export class RiskGql {
@Field(() => [InsightIssueGql]) overdue!: InsightIssueGql[];
@Field(() => Int) overdueCount!: number;
@Field(() => [InsightIssueGql], { description: 'Due within 7 days' })
dueSoon!: InsightIssueGql[];
@Field(() => Int) dueSoonCount!: number;
@Field(() => [InsightIssueGql]) blocked!: InsightIssueGql[];
@Field(() => Int) blockedCount!: number;
@Field(() => [InsightIssueGql]) stuck!: InsightIssueGql[];
@Field(() => Int) stuckCount!: number;
@Field(() => Int) stuckDays!: number;
}
@ObjectType('Aging')
export class AgingGql {
@Field(() => [InsightIssueGql]) issues!: InsightIssueGql[];
@Field(() => Float, {
nullable: true,
description: 'p85 cycle time of the last 90 days',
})
thresholdDays!: number | null;
}
@ObjectType('StatusTime')
export class StatusTimeGql {
@Field() status!: string;
@Field(() => Float, { nullable: true }) averageDays!: number | null;
@Field(() => Int) samples!: number;
@Field(() => Int) open!: number;
@Field(() => Int, { nullable: true }) wipLimit!: number | null;
@Field() overLimit!: boolean;
}
@ObjectType('ProjectInsights', {
description: 'The dashboard: sprint under way, risks, aging and bottlenecks',
})
export class ProjectInsightsGql {
@Field() generatedAt!: string;
@Field(() => SprintPulseGql, { nullable: true })
sprint!: SprintPulseGql | null;
@Field(() => RiskGql) risks!: RiskGql;
@Field(() => AgingGql) aging!: AgingGql;
@Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[];
}

View File

@@ -876,3 +876,78 @@ export class NotificationSettingsResponseDto {
}) })
pushPublicKey!: string | null; pushPublicKey!: string | null;
} }
export class InsightIssueDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'WEB-12' }) key!: string;
@ApiProperty() title!: string;
@ApiProperty({ description: 'Status id' }) status!: string;
@ApiProperty({ type: UserRefDto, nullable: true })
assignee!: UserRefDto | null;
@ApiProperty({
description: 'Days in its status (aging, stuck) or past / to its due date',
})
days!: number;
@ApiProperty({ type: String, nullable: true, format: 'date' })
dueDate!: string | null;
}
export class SprintPulseDto {
@ApiProperty({ format: 'uuid' }) sprintId!: string;
@ApiProperty() name!: string;
@ApiProperty({ format: 'date' }) startDate!: string;
@ApiProperty({ format: 'date' }) endDate!: string;
@ApiProperty() totalDays!: number;
@ApiProperty({ description: 'Days left after today' }) daysLeft!: number;
@ApiProperty() tickets!: number;
@ApiProperty() doneTickets!: number;
@ApiProperty() points!: number;
@ApiProperty() donePoints!: number;
@ApiProperty({ type: Number, nullable: true }) pacePerDay!: number | null;
@ApiProperty({ type: Number, nullable: true }) neededPerDay!: number | null;
@ApiProperty({
description: 'At this pace, what is open would not be closed',
})
behind!: boolean;
}
export class RisksDto {
@ApiProperty({ type: [InsightIssueDto] }) overdue!: InsightIssueDto[];
@ApiProperty() overdueCount!: number;
@ApiProperty({ type: [InsightIssueDto], description: 'Due within 7 days' })
dueSoon!: InsightIssueDto[];
@ApiProperty() dueSoonCount!: number;
@ApiProperty({ type: [InsightIssueDto] }) blocked!: InsightIssueDto[];
@ApiProperty() blockedCount!: number;
@ApiProperty({ type: [InsightIssueDto] }) stuck!: InsightIssueDto[];
@ApiProperty() stuckCount!: number;
@ApiProperty() stuckDays!: number;
}
export class AgingDto {
@ApiProperty({ type: [InsightIssueDto] }) issues!: InsightIssueDto[];
@ApiProperty({
type: Number,
nullable: true,
description: 'p85 cycle time of the last 90 days',
})
thresholdDays!: number | null;
}
export class StatusTimeDto {
@ApiProperty() status!: string;
@ApiProperty({ type: Number, nullable: true }) averageDays!: number | null;
@ApiProperty() samples!: number;
@ApiProperty() open!: number;
@ApiProperty({ type: Number, nullable: true }) wipLimit!: number | null;
@ApiProperty() overLimit!: boolean;
}
export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true })
sprint!: SprintPulseDto | null;
@ApiProperty({ type: RisksDto }) risks!: RisksDto;
@ApiProperty({ type: AgingDto }) aging!: AgingDto;
@ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[];
}

View File

@@ -47,6 +47,7 @@ import {
BoardColumnDto, BoardColumnDto,
CommandResultDto, CommandResultDto,
ProjectAnalyticsDto, ProjectAnalyticsDto,
ProjectInsightsDto,
ProjectDto, ProjectDto,
ProjectStatsDto, ProjectStatsDto,
} from './dto/responses.dto'; } from './dto/responses.dto';
@@ -243,6 +244,26 @@ export class ProjectsController {
); );
} }
@Get(':project/insights')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: 'The dashboard',
description:
'Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.',
})
@ApiOkResponse({ type: ProjectInsightsDto })
@ApiReadErrors()
async insights(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
return this.projects.insights(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
);
}
@Get(':project/activity') @Get(':project/activity')
@ApiOptionalPireonAuth() @ApiOptionalPireonAuth()
@PROJECT @PROJECT

View File

@@ -9,6 +9,7 @@ export * from './lib/facades/home.facade';
export * from './lib/facades/explore.facade'; export * from './lib/facades/explore.facade';
export * from './lib/facades/inbox.facade'; export * from './lib/facades/inbox.facade';
export * from './lib/facades/epics.facade'; export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade';
export * from './lib/facades/backlog.facade'; export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade'; export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade'; export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,18 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { ProjectInsights } from '@task-stream/web/tracker/domain';
export const insightsPageEvents = eventGroup({
source: 'Dashboard Page',
events: {
opened: type<{ projectId: string }>(),
},
});
export const insightsApiEvents = eventGroup({
source: 'Insights API',
events: {
loaded: type<ProjectInsights>(),
loadFailed: type<string>(),
},
});

View File

@@ -0,0 +1,26 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { insightsPageEvents } from '../events/insights.events';
import { InsightsStore } from '../stores/insights.store';
/** "Seguimiento": sprint under way, risks, aging and bottlenecks. */
@Injectable()
export class InsightsFacade {
private readonly store = inject(InsightsStore);
private readonly dispatch = injectDispatch(insightsPageEvents);
readonly insights = this.store.insights;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly error = this.store.error;
/** Loads the project's reading and keeps it up to date. */
open(projectId: string): void {
this.dispatch.opened({ projectId });
}
}
/** Store + facade of the dashboard, for its route component. */
export function provideInsightsFacade(): Provider[] {
return [InsightsStore, InsightsFacade];
}

View File

@@ -0,0 +1,74 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
ProjectInsights,
ProjectsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { debounceTime, merge, startWith, switchMap } from 'rxjs';
import {
insightsApiEvents,
insightsPageEvents,
} from '../events/insights.events';
import { describeError } from '../notifications';
interface InsightsState {
insights: ProjectInsights | null;
loaded: boolean;
error: string | null;
}
/** The dashboard's reading of a project, live. Scoped to the dashboard. */
export const InsightsStore = signalStore(
withState<InsightsState>({ insights: null, loaded: false, error: null }),
withReducer(
on(insightsPageEvents.opened, () => ({ loaded: false, error: null })),
on(insightsApiEvents.loaded, ({ payload }) => ({
insights: payload,
loaded: true,
error: null,
})),
on(insightsApiEvents.loadFailed, ({ payload }) => ({
loaded: true,
error: payload,
})),
),
withEventHandlers(
(
_store,
events = inject(Events),
projects = inject(ProjectsGateway),
realtime = inject(TrackerRealtimeGateway),
) => ({
// Any ticket or sprint may change the reading: reload, calmly.
load$: events.on(insightsPageEvents.opened).pipe(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId),
realtime.sprintChanges(payload.projectId),
realtime.reconnects(),
).pipe(
debounceTime(800),
startWith(null),
switchMap(() =>
projects.insights(payload.projectId).pipe(
mapResponse({
next: (insights) => insightsApiEvents.loaded(insights),
error: (error) =>
insightsApiEvents.loadFailed(describeError(error)),
}),
),
),
),
),
),
}),
),
);

View File

@@ -12,3 +12,4 @@ export * from './lib/models/roadmap';
export * from './lib/models/shortcuts'; export * from './lib/models/shortcuts';
export * from './lib/models/due'; export * from './lib/models/due';
export * from './lib/models/calendar'; export * from './lib/models/calendar';
export * from './lib/models/insights';

View File

@@ -0,0 +1,65 @@
import { UserRef } from './models';
/** A ticket the dashboard names, with the number that put it there. */
export interface InsightIssue {
id: string;
key: string;
title: string;
/** Status id of the project's settings. */
status: string;
assignee: UserRef | null;
/** Days in its status (aging, stuck) or past / to its due date. */
days: number;
dueDate: string | null;
}
/** The sprint under way: time gone against work done. */
export interface SprintPulse {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
/** Days left after today (negative once it is over). */
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
/** At this pace, what is open would not be closed in time. */
behind: boolean;
}
/** Tickets to look at now (worst first, a few of each). */
export interface Risks {
overdue: InsightIssue[];
overdueCount: number;
dueSoon: InsightIssue[];
dueSoonCount: number;
blocked: InsightIssue[];
blockedCount: number;
stuck: InsightIssue[];
stuckCount: number;
stuckDays: number;
}
/** Time tickets spend in a status of the flow, and how many are there. */
export interface StatusTime {
status: string;
averageDays: number | null;
samples: number;
open: number;
wipLimit: number | null;
overLimit: boolean;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
sprint: SprintPulse | null;
risks: Risks;
aging: { issues: InsightIssue[]; thresholdDays: number | null };
bottlenecks: StatusTime[];
}

View File

@@ -1,3 +1,4 @@
import { ProjectInsights } from '../models/insights';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
Activity, Activity,
@@ -63,6 +64,8 @@ export abstract class ProjectsGateway {
abstract publicProjects(): Observable<Project[]>; abstract publicProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>; abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>; abstract stats(projectId: string): Observable<ProjectStats>;
/** Sprint under way, risks, aging and bottlenecks (the dashboard). */
abstract insights(projectId: string): Observable<ProjectInsights>;
/** Statistics of the last `days` days. */ /** Statistics of the last `days` days. */
abstract analytics( abstract analytics(
projectId: string, projectId: string,

View File

@@ -5,6 +5,7 @@ import {
MemberInput, MemberInput,
Project, Project,
ProjectAnalytics, ProjectAnalytics,
ProjectInsights,
ProjectSettings, ProjectSettings,
ProjectsGateway, ProjectsGateway,
ProjectStats, ProjectStats,
@@ -18,6 +19,7 @@ import {
DeleteProjectDocument, DeleteProjectDocument,
MyProjectsDocument, MyProjectsDocument,
ProjectAnalyticsDocument, ProjectAnalyticsDocument,
ProjectInsightsDocument,
ProjectDocument, ProjectDocument,
ProjectStatsDocument, ProjectStatsDocument,
PublicProjectsDocument, PublicProjectsDocument,
@@ -61,6 +63,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats))); .pipe(map((d) => toStats(d.project.stats)));
} }
insights(projectId: string): Observable<ProjectInsights> {
return this.gql
.query(ProjectInsightsDocument, { idOrKey: projectId })
.pipe(map((d) => d.project.insights));
}
analytics(projectId: string, days: number): Observable<ProjectAnalytics> { analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
return this.gql return this.gql
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days }) .query(ProjectAnalyticsDocument, { idOrKey: projectId, days })

View File

@@ -1757,6 +1757,107 @@ export type DeleteProjectMutation = {
deleteProject: { id: string; key: string | null; version: number }; deleteProject: { id: string; key: string | null; version: number };
}; };
export type InsightIssueFieldsFragment = {
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
};
export type ProjectInsightsQueryVariables = Exact<{
idOrKey: string;
}>;
export type ProjectInsightsQuery = {
project: {
id: string;
insights: {
generatedAt: string;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
} | null;
risks: {
overdueCount: number;
dueSoonCount: number;
blockedCount: number;
stuckCount: number;
stuckDays: number;
overdue: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
dueSoon: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
blocked: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
stuck: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
};
aging: {
thresholdDays: number | null;
issues: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
};
bottlenecks: Array<{
status: string;
averageDays: number | null;
samples: number;
open: number;
wipLimit: number | null;
overLimit: boolean;
}>;
};
};
};
export type ReleasesQueryVariables = Exact<{ export type ReleasesQueryVariables = Exact<{
idOrKey: string; idOrKey: string;
}>; }>;
@@ -3275,6 +3376,58 @@ export const NotificationFieldsFragmentDoc = {
}, },
], ],
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>; } as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
export const InsightIssueFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'InsightIssueFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'InsightIssue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<InsightIssueFieldsFragment, unknown>;
export const SavedFilterFieldsFragmentDoc = { export const SavedFilterFieldsFragmentDoc = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -10753,6 +10906,336 @@ export const DeleteProjectDocument = {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables DeleteProjectMutationVariables
>; >;
export const ProjectInsightsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'ProjectInsights' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'idOrKey' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'project' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'idOrKey' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'idOrKey' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'insights' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'generatedAt' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'risks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'overdue' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overdueCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'dueSoon' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'dueSoonCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blocked' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blockedCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stuck' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stuckCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stuckDays' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'aging' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issues' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'thresholdDays' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bottlenecks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'status' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'averageDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'samples' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'open' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overLimit' },
},
],
},
},
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'InsightIssueFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'InsightIssue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
],
},
},
],
} as unknown as DocumentNode<
ProjectInsightsQuery,
ProjectInsightsQueryVariables
>;
export const ReleasesDocument = { export const ReleasesDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

@@ -144,3 +144,72 @@ mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
...CommandResultFields ...CommandResultFields
} }
} }
fragment InsightIssueFields on InsightIssue {
id
key
title
status
assignee {
...UserRefFields
}
days
dueDate
}
query ProjectInsights($idOrKey: String!) {
project(idOrKey: $idOrKey) {
id
insights {
generatedAt
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
risks {
overdue {
...InsightIssueFields
}
overdueCount
dueSoon {
...InsightIssueFields
}
dueSoonCount
blocked {
...InsightIssueFields
}
blockedCount
stuck {
...InsightIssueFields
}
stuckCount
stuckDays
}
aging {
issues {
...InsightIssueFields
}
thresholdDays
}
bottlenecks {
status
averageDays
samples
open
wipLimit
overLimit
}
}
}
}

View File

@@ -2,23 +2,47 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
effect,
inject, inject,
untracked,
} from '@angular/core'; } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; import {
import { ProjectFacade } from '@task-stream/web/tracker/application'; PiNoteComponent,
PiSkeletonComponent,
PiStatCardComponent,
} from '@pi/ui';
import {
InsightsFacade,
ProjectFacade,
provideInsightsFacade,
} from '@task-stream/web/tracker/application';
import { import {
ProjectRole, ProjectRole,
QA_FAILED, QA_FAILED,
StatusCategory, StatusCategory,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy'; import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
import { AgingComponent } from '../ui/insights/aging';
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
import { RisksComponent } from '../ui/insights/risks';
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
/** "Seguimiento": the state of the whole project at a glance (live). */ /** "Seguimiento": the state of the whole project at a glance (live). */
@Component({ @Component({
selector: 'ts-dashboard-page', selector: 'ts-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent], imports: [
RouterLink,
PiNoteComponent,
PiSkeletonComponent,
PiStatCardComponent,
SprintPulseComponent,
RisksComponent,
AgingComponent,
BottlenecksComponent,
],
providers: [provideInsightsFacade()],
template: ` template: `
@if (facade.stats(); as stats) { @if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen"> <section class="ts-kpis" aria-label="Resumen">
@@ -66,6 +90,35 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
} }
</section> </section>
@if (insights.error(); as error) {
<pi-note
class="ts-insights-error"
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo leer el estado del proyecto"
[text]="error"
/>
} @else if (insights.insights(); as reading) {
<div class="ts-dashboard ts-insights">
@if (reading.sprint; as sprint) {
<ts-sprint-pulse [sprint]="sprint" />
}
<ts-risks [risks]="reading.risks" />
<ts-aging
[issues]="reading.aging.issues"
[thresholdDays]="reading.aging.thresholdDays"
/>
<ts-bottlenecks
[statuses]="reading.bottlenecks"
[workflow]="facade.workflow()"
/>
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
}
<div class="ts-dashboard"> <div class="ts-dashboard">
<section class="ts-panel" aria-labelledby="team-title"> <section class="ts-panel" aria-labelledby="team-title">
<header class="ts-panel__head"> <header class="ts-panel__head">
@@ -232,6 +285,12 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
font-weight: 600; font-weight: 600;
color: var(--ts-ink); color: var(--ts-ink);
} }
.ts-insights,
.ts-insights-error,
.ts-insights-loading {
display: grid;
margin-bottom: 1.25rem;
}
.ts-dashboard { .ts-dashboard {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
@@ -363,6 +422,14 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
}) })
export class DashboardPage { export class DashboardPage {
protected readonly facade = inject(ProjectFacade); protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade);
constructor() {
effect(() => {
const projectId = this.facade.project()?.id;
if (projectId) untracked(() => this.insights.open(projectId));
});
}
protected readonly initialsOf = initials; protected readonly initialsOf = initials;
private count(status: string): number { private count(status: string): number {

View File

@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { InsightIssue } from '@task-stream/web/tracker/domain';
/**
* Each ticket under way by the days it has been in its status, against the
* project's usual: 85% of the tickets finished lately took at most that.
*/
@Component({
selector: 'ts-aging',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: { class: 'ts-panel', 'aria-labelledby': 'aging-title', role: 'region' },
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="aging-title">Antigüedad de lo que está en curso</h2>
<span class="ts-muted">{{ legend() }}</span>
</header>
@if (issues().length) {
<ul class="ts-issues ts-aging">
@for (issue of issues(); track issue.id) {
<li [class.is-old]="isOld(issue)">
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
issue.key
}}</a>
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
issue.title
}}</a>
<span class="ts-bar" [attr.title]="issue.days + ' días'">
<span [style.width.%]="share(issue.days)"></span>
@if (threshold() !== null) {
<i
class="ts-aging__mark"
[style.left.%]="share(threshold()!)"
></i>
}
</span>
<span class="ts-days">{{ issue.days }} d</span>
</li>
}
</ul>
} @else {
<p class="ts-empty">Nada en curso ahora mismo.</p>
}
`,
styles: `
.ts-aging li {
grid-template-columns: auto minmax(0, 1fr) minmax(4rem, 7rem) 2.5rem;
align-items: center;
}
.ts-aging .ts-days {
text-align: right;
}
.ts-aging li.is-old {
--tone: var(--ts-c-red, #d14343);
}
.ts-bar {
overflow: visible;
}
.ts-aging__mark {
position: absolute;
top: -0.1875rem;
bottom: -0.1875rem;
width: 2px;
background: var(--ts-ink-soft);
}
`,
})
export class AgingComponent {
readonly issues = input.required<InsightIssue[]>();
/** p85 cycle time; none until something took at least part of a day. */
readonly thresholdDays = input<number | null>(null);
protected readonly threshold = computed(() => {
const days = this.thresholdDays();
return days && days > 0 ? days : null;
});
private readonly max = computed(() =>
Math.max(1, this.threshold() ?? 0, ...this.issues().map((i) => i.days)),
);
protected share(days: number): number {
return Math.min(100, (days / this.max()) * 100);
}
protected isOld(issue: InsightIssue): boolean {
const t = this.threshold();
return t !== null && issue.days > t;
}
protected readonly legend = computed(() => {
const t = this.threshold();
return t === null
? 'Días en su estado actual'
: `La raya: ${t.toLocaleString('es')} días, lo que tarda el 85% de los tickets`;
});
}

View File

@@ -0,0 +1,105 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { StatusTime, Workflow } from '@task-stream/web/tracker/domain';
import { statusLook } from '../copy';
/** Where tickets wait: average days in each status of the flow. */
@Component({
selector: 'ts-bottlenecks',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'bottlenecks-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="bottlenecks-title">Cuellos de botella</h2>
<span class="ts-muted">Días de media en cada estado (90 días)</span>
</header>
@if (rows().length) {
<ul class="ts-issues ts-necks">
@for (row of rows(); track row.status) {
<li [style.--tone]="row.tone" [class.is-slowest]="row.slowest">
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.average }}</span>
<span
class="ts-days"
[class.ts-warn]="row.overLimit"
[attr.title]="
row.wipLimit !== null
? 'Hay ' + row.open + ' con un límite de ' + row.wipLimit
: 'Hay ' + row.open + ' ahora'
"
>{{ row.open
}}{{ row.wipLimit !== null ? '/' + row.wipLimit : '' }}</span
>
</li>
}
</ul>
@if (slowest(); as slowest) {
<p class="ts-empty ts-necks__note">
Donde más se paran: <strong>{{ slowest }}</strong
>.
</p>
}
} @else {
<p class="ts-empty">El flujo no tiene estados en curso o de QA.</p>
}
`,
styles: `
.ts-necks {
gap: 0.5rem;
li {
grid-template-columns: minmax(6rem, 9rem) 1fr 3.5rem 2.5rem;
align-items: center;
}
.ts-days {
text-align: right;
}
li.is-slowest .ts-title {
font-weight: 600;
}
}
.ts-necks__note {
margin-top: 0.875rem;
}
`,
})
export class BottlenecksComponent {
readonly statuses = input.required<StatusTime[]>();
readonly workflow = input.required<Workflow>();
protected readonly rows = computed(() => {
const list = this.statuses();
const max = Math.max(1, ...list.map((s) => s.averageDays ?? 0));
const slowest = Math.max(...list.map((s) => s.averageDays ?? 0));
return list.map((s) => {
const look = statusLook(this.workflow(), s.status);
return {
...s,
label: look.label,
tone: look.tone,
share: ((s.averageDays ?? 0) / max) * 100,
average:
s.averageDays === null
? '—'
: `${s.averageDays.toLocaleString('es')} d`,
slowest: slowest > 0 && s.averageDays === slowest,
};
});
});
protected readonly slowest = computed(() => {
const row = this.rows().find((r) => r.slowest);
return row ? `${row.label} (${row.average})` : null;
});
}

View File

@@ -0,0 +1,94 @@
// Shared by the dashboard's insight panels (each host is a .ts-panel).
:host {
display: block;
padding: 1.5rem;
}
.ts-panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-issues {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: baseline;
gap: 0.5rem;
margin: 0;
font-size: 0.875rem;
}
a {
color: inherit;
text-decoration: none;
&:hover {
color: var(--ts-brand);
}
}
}
.ts-key {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-days {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-ink-soft);
white-space: nowrap;
}
.ts-bar {
position: relative;
height: 0.625rem;
border-radius: 0.25rem;
background: var(--ts-sunken);
overflow: hidden;
> span {
display: block;
height: 100%;
border-radius: 0.25rem;
background: var(--tone, var(--ts-brand));
}
}
.ts-warn {
color: var(--ts-c-red, #d14343);
}
.ts-empty {
margin: 0;
color: var(--ts-muted);
font-size: 0.875rem;
}

View File

@@ -0,0 +1,114 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { InsightIssue, Risks } from '@task-stream/web/tracker/domain';
const plural = (n: number, one: string, many: string) =>
`${n} ${n === 1 ? one : many}`;
/** Overdue, due soon, blocked and stuck: what to look at now. */
@Component({
selector: 'ts-risks',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: { class: 'ts-panel', 'aria-labelledby': 'risks-title', role: 'region' },
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="risks-title">Tickets en riesgo</h2>
<span class="ts-muted">Lo que conviene mirar hoy</span>
</header>
@for (group of groups(); track group.title) {
@if (group.count) {
<section class="ts-risk">
<h3 [class.ts-warn]="group.warn">
{{ group.title }} <span class="ts-days">{{ group.count }}</span>
</h3>
<ul class="ts-issues">
@for (issue of group.issues; track issue.id) {
<li>
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
issue.key
}}</a>
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
issue.title
}}</a>
<span class="ts-days">{{ group.days(issue) }}</span>
</li>
}
</ul>
@if (group.count > group.issues.length) {
<p class="ts-empty">
y {{ group.count - group.issues.length }} más
</p>
}
</section>
}
}
@if (!total()) {
<p class="ts-empty">Nada vencido, bloqueado ni atascado.</p>
}
`,
styles: `
.ts-risk + .ts-risk {
margin-top: 1rem;
}
h3 {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin: 0 0 0.375rem;
font-size: 0.875rem;
font-weight: 600;
}
`,
})
export class RisksComponent {
readonly risks = input.required<Risks>();
protected readonly total = computed(() => {
const r = this.risks();
return r.overdueCount + r.dueSoonCount + r.blockedCount + r.stuckCount;
});
protected readonly groups = computed(() => {
const r = this.risks();
return [
{
title: 'Vencidos',
warn: true,
issues: r.overdue,
count: r.overdueCount,
days: (i: InsightIssue) => `hace ${plural(i.days, 'día', 'días')}`,
},
{
title: 'Vencen esta semana',
warn: false,
issues: r.dueSoon,
count: r.dueSoonCount,
days: (i: InsightIssue) =>
i.days === 0 ? 'hoy' : i.days === 1 ? 'mañana' : `en ${i.days} días`,
},
{
title: 'Bloqueados',
warn: false,
issues: r.blocked,
count: r.blockedCount,
days: (i: InsightIssue) =>
i.days ? plural(i.days, 'día', 'días') : 'hoy',
},
{
title: `Atascados (${r.stuckDays} días o más sin moverse)`,
warn: false,
issues: r.stuck,
count: r.stuckCount,
days: (i: InsightIssue) =>
i.days ? plural(i.days, 'día', 'días') : 'hoy',
},
];
});
}

View File

@@ -0,0 +1,133 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { SprintPulse } from '@task-stream/web/tracker/domain';
const short = (day: string) =>
new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
const decimal = (n: number) =>
n.toLocaleString('es', { maximumFractionDigits: 1 });
/** The sprint under way: days left, points done and whether it gets there. */
@Component({
selector: 'ts-sprint-pulse',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: { class: 'ts-panel', 'aria-labelledby': 'pulse-title', role: 'region' },
styleUrl: './insight-panel.scss',
template: `
@let s = sprint();
<header class="ts-panel__head">
<h2 id="pulse-title">{{ s.name }}</h2>
<span class="ts-muted">{{ when() }}</span>
</header>
<p class="ts-pulse__days">
<strong>{{ daysText() }}</strong>
@if (s.behind) {
<span class="ts-pulse__flag">A este ritmo no llega</span>
} @else if (s.points && s.donePoints === s.points) {
<span class="ts-pulse__flag is-ok">Todo hecho</span>
}
</p>
<div class="ts-pulse__bars">
<span class="ts-pulse__label">Tiempo</span>
<span class="ts-bar"><span [style.width.%]="timeShare()"></span></span>
<span class="ts-days">{{ timeShare() }}%</span>
<span class="ts-pulse__label">Puntos</span>
<span class="ts-bar" [class.is-behind]="s.behind"
><span [style.width.%]="pointShare()"></span
></span>
<span class="ts-days">{{ s.donePoints }}/{{ s.points }}</span>
</div>
<p class="ts-empty">
{{ s.doneTickets }} de {{ s.tickets }} tickets terminados.
@if (paceText(); as pace) {
{{ pace }}
}
<a routerLink="../sprints">Ver el sprint →</a>
</p>
`,
styles: `
.ts-pulse__days {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin: 0 0 1rem;
strong {
font-size: 1.5rem;
}
}
.ts-pulse__flag {
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d14343) 14%, transparent);
color: var(--ts-c-red, #d14343);
font-size: 0.8125rem;
font-weight: 600;
&.is-ok {
background: color-mix(
in srgb,
var(--ts-c-green, #2e9e6a) 14%,
transparent
);
color: var(--ts-c-green, #2e9e6a);
}
}
.ts-pulse__bars {
display: grid;
grid-template-columns: 4rem 1fr auto;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 1rem;
}
.ts-pulse__label {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-bar.is-behind {
--tone: var(--ts-c-red, #d14343);
}
a {
color: var(--ts-brand);
text-decoration: none;
white-space: nowrap;
}
`,
})
export class SprintPulseComponent {
readonly sprint = input.required<SprintPulse>();
protected readonly when = computed(
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
);
protected readonly daysText = computed(() => {
const left = this.sprint().daysLeft;
if (left < 0)
return 'Terminó hace ' + -left + (left === -1 ? ' día' : ' días');
if (left === 0) return 'Último día';
return `Quedan ${left} ${left === 1 ? 'día' : 'días'}`;
});
protected readonly timeShare = computed(() => {
const s = this.sprint();
const gone = s.totalDays - Math.max(s.daysLeft, 0);
return Math.round((Math.min(gone, s.totalDays) / s.totalDays) * 100);
});
protected readonly pointShare = computed(() => {
const s = this.sprint();
return s.points ? Math.round((s.donePoints / s.points) * 100) : 0;
});
protected readonly paceText = computed(() => {
const s = this.sprint();
if (s.pacePerDay === null || s.neededPerDay === null) return '';
return `Se cierran ${decimal(s.pacePerDay)} pts al día; hacen falta ${decimal(s.neededPerDay)}.`;
});
}