diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 6b3f2a5..2a83f5d 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1366,4 +1366,53 @@ describe('task-stream (e2e)', () => { ); 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); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5c27ae0..91ee00f 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": { "get": { "operationId": "ProjectsController_activity", @@ -11804,6 +11887,264 @@ "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": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index a01e00b..df1c7b6 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -18,6 +18,13 @@ input AddChecklistItemInput { text: String! } +type Aging { + issues: [InsightIssue!]! + + """p85 cycle time of the last 90 days""" + thresholdDays: Float +} + type AreaOption { archived: Boolean! id: String! @@ -475,6 +482,19 @@ type GitRepository { 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 { """Statuses this ticket can move to next (project's workflow)""" allowedTransitions: [String!]! @@ -955,6 +975,11 @@ type Project { description: String id: ID! + """ + The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks + """ + insights: ProjectInsights! + """Anyone can read it, also without signing in""" isPublic: Boolean! key: String! @@ -996,6 +1021,15 @@ type ProjectChange { 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 { role: ProjectRole! user: UserRef! @@ -1221,6 +1255,21 @@ enum ReleaseStatus { 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 { criteria: SavedFilterCriteriaInput! name: String! @@ -1321,6 +1370,26 @@ type SprintProgress { 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 { id: ID! name: String! @@ -1344,6 +1413,15 @@ enum StatusCategory { TODO } +type StatusTime { + averageDays: Float + open: Int! + overLimit: Boolean! + samples: Int! + status: String! + wipLimit: Int +} + type StatusTransitions { from: String! to: [String!]! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 7426471..c2db6fb 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -285,6 +285,26 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -2100,6 +2120,72 @@ export interface components { throughput: components["schemas"]["ThroughputDto"][]; 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: { id: string; /** @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: { parameters: { query?: { diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index 9bfc957..e3148dc 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -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({ name: 'get_project_activity', description: 'Latest activity of a project (newest first).', diff --git a/docs/03-api.md b/docs/03-api.md index 408e089..2ec7719 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -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, 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 Las operaciones requieren `Authorization: Bearer ` (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 | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 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` | | 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` | diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 3e4345e..a49d6d9 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -291,7 +291,7 @@ visualización de la plataforma: | `/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/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/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 | diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 5d6483f..082fb92 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -48,6 +48,7 @@ flowchart LR | | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | | | `get_project_stats` | Cifras de seguimiento | | | `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 | | 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` | diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 6a3a461..4bedc9c 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -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. - **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). +- **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 renovarlo a mano cuando caduca. @@ -32,22 +35,16 @@ cuando hay pistas, por dónde empezar. 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. -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), +1. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo), con semáforo. -6. **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 +2. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones. +3. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de 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. -9. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días +4. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada. +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. -10. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por - navegador»). +6. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por + navegador»). ### 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 existían, en Sprints.) - 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`). diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 8620e64..9a3a9fe 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 264bef5..15f7312 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/analytics/analytics.ts b/libs/api/tracker/application/src/lib/analytics/analytics.ts index 3ff487c..5adfac1 100644 --- a/libs/api/tracker/application/src/lib/analytics/analytics.ts +++ b/libs/api/tracker/application/src/lib/analytics/analytics.ts @@ -19,7 +19,7 @@ import { const DAY = 86_400_000; const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA]; -interface Step { +export interface Step { at: number; status: string; 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. */ -function stepsByIssue( +export function stepsByIssue( issues: IssueReadModel[], timeline: IssueTimelineEntry[], ): Map { diff --git a/libs/api/tracker/application/src/lib/analytics/insights.spec.ts b/libs/api/tracker/application/src/lib/analytics/insights.spec.ts new file mode 100644 index 0000000..eac93c0 --- /dev/null +++ b/libs/api/tracker/application/src/lib/analytics/insights.spec.ts @@ -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 => + ({ + 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, + }); + }); +}); diff --git a/libs/api/tracker/application/src/lib/analytics/insights.ts b/libs/api/tracker/application/src/lib/analytics/insights.ts new file mode 100644 index 0000000..63b4bd1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/analytics/insights.ts @@ -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, + now: number, +): StatusTimeReadModel[] { + const from = now - 90 * DAY; + const stays = new Map(); + 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, + }; + }); +} diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index b51bdc3..f491032 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -11,6 +11,7 @@ import { import { IssuePriority, ProjectAnalyticsReadModel, + ProjectInsightsReadModel, ProjectReadModel, ProjectRole, ProjectSettings, @@ -34,6 +35,7 @@ import { IssueFilter } from './ports/find-issues.repository'; import { BoardColumn, GetBoardQuery } from './queries/get-board.query'; import { GetMyProjectsQuery } from './queries/get-my-projects.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 { GetPublicProjectsQuery } from './queries/get-public-projects.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). */ + /** Sprint under way, risks, aging and bottlenecks, for the dashboard. */ + insights( + viewerId: string | null, + projectId: string, + ): Promise { + return this.queryBus.execute( + new GetProjectInsightsQuery(viewerId, projectId), + ); + } + analytics( viewerId: string | null, projectId: string, diff --git a/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts new file mode 100644 index 0000000..6a7c75a --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts @@ -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 +{ + 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 { + 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(), + }); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-project-insights.query.ts b/libs/api/tracker/application/src/lib/queries/get-project-insights.query.ts new file mode 100644 index 0000000..d0591a3 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-project-insights.query.ts @@ -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 { + constructor( + /** Null: anonymous (public projects only). */ + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index cbb15c7..a12bae4 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -6,6 +6,7 @@ export * from './get-issue.query'; export * from './get-issues-by-ids.query'; export * from './get-my-projects.query'; export * from './get-project-analytics.query'; +export * from './get-project-insights.query'; export * from './get-project-stats.query'; export * from './get-release-burnup.query'; export * from './get-project.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index a2c1940..6371ca1 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -9,6 +9,7 @@ import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler'; import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler'; import { GetProjectQueryHandler } from './get-project.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 { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler'; import { GetReleaseQueryHandler } from './get-release.query-handler'; @@ -34,6 +35,7 @@ export const QUERY_HANDLERS = [ GetMyProjectsQueryHandler, GetProjectQueryHandler, GetProjectAnalyticsQueryHandler, + GetProjectInsightsQueryHandler, GetProjectStatsQueryHandler, GetReleaseBurnupQueryHandler, GetReleaseQueryHandler, diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts index 01a9130..ec48c63 100644 --- a/libs/api/tracker/domain/src/lib/read-models/index.ts +++ b/libs/api/tracker/domain/src/lib/read-models/index.ts @@ -9,3 +9,4 @@ export * from './analytics.read-model'; export * from './notification.read-model'; export * from './saved-filter.read-model'; export * from './sprint.read-model'; +export * from './insights.read-model'; diff --git a/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts new file mode 100644 index 0000000..462f18a --- /dev/null +++ b/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts @@ -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[]; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index 12a40ec..f528268 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -31,6 +31,7 @@ import { BoardColumnGql, CommandResultGql, ProjectAnalyticsGql, + ProjectInsightsGql, ProjectGql, ProjectSettingsGql, ProjectStatsGql, @@ -83,6 +84,17 @@ export class ProjectResolver { 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]) releases( @CurrentUser() me: User | undefined, diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index f1d1f6b..860e4ef 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -794,3 +794,87 @@ export class ReleaseBurnupGql { scope!: 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[]; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 9dfee2f..07d82b1 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -876,3 +876,78 @@ export class NotificationSettingsResponseDto { }) 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[]; +} diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index a0dcd33..aef4dde 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -47,6 +47,7 @@ import { BoardColumnDto, CommandResultDto, ProjectAnalyticsDto, + ProjectInsightsDto, ProjectDto, ProjectStatsDto, } 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') @ApiOptionalPireonAuth() @PROJECT diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 65d73d3..f747c91 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/facades/home.facade'; export * from './lib/facades/explore.facade'; export * from './lib/facades/inbox.facade'; export * from './lib/facades/epics.facade'; +export * from './lib/facades/insights.facade'; export * from './lib/facades/backlog.facade'; export * from './lib/facades/list.facade'; export * from './lib/facades/propose.facade'; diff --git a/libs/web/tracker/application/src/lib/events/insights.events.ts b/libs/web/tracker/application/src/lib/events/insights.events.ts new file mode 100644 index 0000000..e1524d1 --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/insights.events.ts @@ -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(), + loadFailed: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/insights.facade.ts b/libs/web/tracker/application/src/lib/facades/insights.facade.ts new file mode 100644 index 0000000..0f5dbf4 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/insights.facade.ts @@ -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]; +} diff --git a/libs/web/tracker/application/src/lib/stores/insights.store.ts b/libs/web/tracker/application/src/lib/stores/insights.store.ts new file mode 100644 index 0000000..0ef8d73 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/insights.store.ts @@ -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({ 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)), + }), + ), + ), + ), + ), + ), + }), + ), +); diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 260f3b8..c7ec65c 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/models/roadmap'; export * from './lib/models/shortcuts'; export * from './lib/models/due'; export * from './lib/models/calendar'; +export * from './lib/models/insights'; diff --git a/libs/web/tracker/domain/src/lib/models/insights.ts b/libs/web/tracker/domain/src/lib/models/insights.ts new file mode 100644 index 0000000..cc0d55e --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/insights.ts @@ -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[]; +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index bf486ab..12d366d 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -1,3 +1,4 @@ +import { ProjectInsights } from '../models/insights'; import { Observable } from 'rxjs'; import { Activity, @@ -63,6 +64,8 @@ export abstract class ProjectsGateway { abstract publicProjects(): Observable; abstract project(idOrKey: string): Observable; abstract stats(projectId: string): Observable; + /** Sprint under way, risks, aging and bottlenecks (the dashboard). */ + abstract insights(projectId: string): Observable; /** Statistics of the last `days` days. */ abstract analytics( projectId: string, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts index 1fe6887..bb1b38e 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts @@ -5,6 +5,7 @@ import { MemberInput, Project, ProjectAnalytics, + ProjectInsights, ProjectSettings, ProjectsGateway, ProjectStats, @@ -18,6 +19,7 @@ import { DeleteProjectDocument, MyProjectsDocument, ProjectAnalyticsDocument, + ProjectInsightsDocument, ProjectDocument, ProjectStatsDocument, PublicProjectsDocument, @@ -61,6 +63,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway { .pipe(map((d) => toStats(d.project.stats))); } + insights(projectId: string): Observable { + return this.gql + .query(ProjectInsightsDocument, { idOrKey: projectId }) + .pipe(map((d) => d.project.insights)); + } + analytics(projectId: string, days: number): Observable { return this.gql .query(ProjectAnalyticsDocument, { idOrKey: projectId, days }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index bf75d6c..2de7070 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -1757,6 +1757,107 @@ export type DeleteProjectMutation = { 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<{ idOrKey: string; }>; @@ -3275,6 +3376,58 @@ export const NotificationFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +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; export const SavedFilterFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -10753,6 +10906,336 @@ export const DeleteProjectDocument = { DeleteProjectMutation, 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 = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index db8c203..6aeb196 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -144,3 +144,72 @@ mutation DeleteProject($projectId: ID!, $confirmKey: String!) { ...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 + } + } + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts index 0298a4e..dc3616a 100644 --- a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts @@ -2,23 +2,47 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, + untracked, } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; -import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { + PiNoteComponent, + PiSkeletonComponent, + PiStatCardComponent, +} from '@pi/ui'; +import { + InsightsFacade, + ProjectFacade, + provideInsightsFacade, +} from '@task-stream/web/tracker/application'; import { ProjectRole, QA_FAILED, StatusCategory, } from '@task-stream/web/tracker/domain'; 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). */ @Component({ selector: 'ts-dashboard-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent], + imports: [ + RouterLink, + PiNoteComponent, + PiSkeletonComponent, + PiStatCardComponent, + SprintPulseComponent, + RisksComponent, + AgingComponent, + BottlenecksComponent, + ], + providers: [provideInsightsFacade()], template: ` @if (facade.stats(); as stats) {
@@ -66,6 +90,35 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy'; }
+ @if (insights.error(); as error) { + + } @else if (insights.insights(); as reading) { +
+ @if (reading.sprint; as sprint) { + + } + + + +
+ } @else { + + } +
@@ -232,6 +285,12 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy'; font-weight: 600; color: var(--ts-ink); } + .ts-insights, + .ts-insights-error, + .ts-insights-loading { + display: grid; + margin-bottom: 1.25rem; + } .ts-dashboard { display: grid; 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 { 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; private count(status: string): number { diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/aging.ts b/libs/web/tracker/presenters/src/lib/ui/insights/aging.ts new file mode 100644 index 0000000..831d75a --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/aging.ts @@ -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: ` +
+

Antigüedad de lo que está en curso

+ {{ legend() }} +
+ @if (issues().length) { + + } @else { +

Nada en curso ahora mismo.

+ } + `, + 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(); + /** p85 cycle time; none until something took at least part of a day. */ + readonly thresholdDays = input(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`; + }); +} diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts b/libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts new file mode 100644 index 0000000..cf44250 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts @@ -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: ` +
+

Cuellos de botella

+ Días de media en cada estado (90 días) +
+ @if (rows().length) { +
    + @for (row of rows(); track row.status) { +
  • + {{ row.label }} + + {{ row.average }} + {{ row.open + }}{{ row.wipLimit !== null ? '/' + row.wipLimit : '' }} +
  • + } +
+ @if (slowest(); as slowest) { +

+ Donde más se paran: {{ slowest }}. +

+ } + } @else { +

El flujo no tiene estados en curso o de QA.

+ } + `, + 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(); + readonly workflow = input.required(); + + 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; + }); +} diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/insight-panel.scss b/libs/web/tracker/presenters/src/lib/ui/insights/insight-panel.scss new file mode 100644 index 0000000..8ba7689 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/insight-panel.scss @@ -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; +} diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/risks.ts b/libs/web/tracker/presenters/src/lib/ui/insights/risks.ts new file mode 100644 index 0000000..54062fc --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/risks.ts @@ -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: ` +
+

Tickets en riesgo

+ Lo que conviene mirar hoy +
+ @for (group of groups(); track group.title) { + @if (group.count) { +
+

+ {{ group.title }} {{ group.count }} +

+ + @if (group.count > group.issues.length) { +

+ y {{ group.count - group.issues.length }} más +

+ } +
+ } + } + @if (!total()) { +

Nada vencido, bloqueado ni atascado.

+ } + `, + 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(); + + 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', + }, + ]; + }); +} diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts b/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts new file mode 100644 index 0000000..1d6089a --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts @@ -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(); +
+

{{ s.name }}

+ {{ when() }} +
+

+ {{ daysText() }} + @if (s.behind) { + A este ritmo no llega + } @else if (s.points && s.donePoints === s.points) { + Todo hecho + } +

+
+ Tiempo + + {{ timeShare() }}% + Puntos + + {{ s.donePoints }}/{{ s.points }} +
+

+ {{ s.doneTickets }} de {{ s.tickets }} tickets terminados. + @if (paceText(); as pace) { + {{ pace }} + } + Ver el sprint → +

+ `, + 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(); + + 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)}.`; + }); +}