diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 13e338a..ed4b9ce 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1437,5 +1437,8 @@ describe('task-stream (e2e)', () => { `/projects/${key}/issues?noEstimate=true&noArea=true`, ); expect(unestimated.body['total']).toBe(2); + expect(later.body['quality'].openBugsByPriority).toHaveLength(5); + expect(later.body['quality'].bugWeeks).toHaveLength(8); + expect(later.body['fields']).toEqual([]); }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 41ff4db..e7cb3b0 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -12316,6 +12316,166 @@ "staleDays" ] }, + "PriorityCountDto": { + "type": "object", + "properties": { + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/IssuePriority" + } + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "priority", + "count" + ] + }, + "BugWeekDto": { + "type": "object", + "properties": { + "week": { + "type": "string", + "format": "date", + "description": "Monday (UTC)" + }, + "created": { + "type": "number" + }, + "closed": { + "type": "number" + } + }, + "required": [ + "week", + "created", + "closed" + ] + }, + "QaByAreaDto": { + "type": "object", + "properties": { + "area": { + "type": "string", + "nullable": true, + "description": "Null: no area" + }, + "returned": { + "type": "number" + }, + "passed": { + "type": "number" + } + }, + "required": [ + "area", + "returned", + "passed" + ] + }, + "EscapedBugsDto": { + "type": "object", + "properties": { + "releaseId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "releasedAt": { + "type": "string" + }, + "bugs": { + "type": "number", + "description": "Bugs reported in the 14 days after" + } + }, + "required": [ + "releaseId", + "name", + "releasedAt", + "bugs" + ] + }, + "QualityDto": { + "type": "object", + "properties": { + "openBugsByPriority": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PriorityCountDto" + } + }, + "bugWeeks": { + "description": "Last 8 weeks, oldest first", + "type": "array", + "items": { + "$ref": "#/components/schemas/BugWeekDto" + } + }, + "qaByArea": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QaByAreaDto" + } + }, + "escaped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EscapedBugsDto" + } + } + }, + "required": [ + "openBugsByPriority", + "bugWeeks", + "qaByArea", + "escaped" + ] + }, + "OptionCountDto": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "count": { + "type": "number" + } + }, + "required": [ + "value", + "count" + ] + }, + "FieldBreakdownDto": { + "type": "object", + "properties": { + "fieldId": { + "type": "string" + }, + "counts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OptionCountDto" + } + }, + "none": { + "type": "number", + "description": "Open tickets with no value" + } + }, + "required": [ + "fieldId", + "counts", + "none" + ] + }, "ProjectInsightsDto": { "type": "object", "properties": { @@ -12367,6 +12527,16 @@ }, "backlog": { "$ref": "#/components/schemas/BacklogHealthDto" + }, + "quality": { + "$ref": "#/components/schemas/QualityDto" + }, + "fields": { + "description": "One per active list custom field", + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldBreakdownDto" + } } }, "required": [ @@ -12378,7 +12548,9 @@ "releases", "changes", "team", - "backlog" + "backlog", + "quality", + "fields" ] }, "ActivityDto": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index a81bbe9..31b6430 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -144,6 +144,15 @@ type BoardColumn { total: Int! } +"""Bugs created and closed in a week""" +type BugWeek { + closed: Int! + created: Int! + + """Monday (UTC), YYYY-MM-DD""" + week: String! +} + type BulkIssueResult { error: String issueId: ID! @@ -478,6 +487,23 @@ type Epic { progress: ChildProgress! } +"""Bugs reported in the 14 days after a release shipped""" +type EscapedBugs { + bugs: Int! + name: String! + releaseId: ID! + releasedAt: String! +} + +"""Open tickets per option of a list custom field""" +type FieldBreakdown { + counts: [OptionCount!]! + fieldId: String! + + """Open tickets with no value""" + none: Int! +} + type FlowSeries { counts: [Int!]! status: String! @@ -981,6 +1007,16 @@ type NotificationSettings { pushPublicKey: String } +type OptionCount { + count: Int! + value: String! +} + +type PriorityCount { + count: Int! + priority: IssuePriority! +} + type Project { """Takes bug reports and ideas from anyone (while public)""" acceptsProposals: Boolean! @@ -1068,7 +1104,11 @@ type ProjectInsights { """Only when asked with `since`""" changes: ChangesSince + + """One per active list custom field""" + fields: [FieldBreakdown!]! generatedAt: String! + quality: Quality! releases: [ReleaseForecast!]! risks: Risks! sprint: SprintPulse @@ -1138,12 +1178,29 @@ input PushSubscriptionInput { p256dh: String! } +"""QA verdicts per area (90 days)""" +type QaByArea { + """Null: no area""" + area: String + passed: Int! + returned: Int! +} + type QaStats { failed: Int! failureRate: Float passed: Int! } +"""Bugs (types with the bug icon) and QA""" +type Quality { + """Last 8 weeks, oldest first""" + bugWeeks: [BugWeek!]! + escaped: [EscapedBugs!]! + openBugsByPriority: [PriorityCount!]! + qaByArea: [QaByArea!]! +} + type Query { """Project activity feed (newest first)""" activity( diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 7387c83..453fa6c 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -2229,6 +2229,50 @@ export interface components { stale: number; staleDays: number; }; + PriorityCountDto: { + priority: components["schemas"]["IssuePriority"]; + count: number; + }; + BugWeekDto: { + /** + * Format: date + * @description Monday (UTC) + */ + week: string; + created: number; + closed: number; + }; + QaByAreaDto: { + /** @description Null: no area */ + area: string | null; + returned: number; + passed: number; + }; + EscapedBugsDto: { + /** Format: uuid */ + releaseId: string; + name: string; + releasedAt: string; + /** @description Bugs reported in the 14 days after */ + bugs: number; + }; + QualityDto: { + openBugsByPriority: components["schemas"]["PriorityCountDto"][]; + /** @description Last 8 weeks, oldest first */ + bugWeeks: components["schemas"]["BugWeekDto"][]; + qaByArea: components["schemas"]["QaByAreaDto"][]; + escaped: components["schemas"]["EscapedBugsDto"][]; + }; + OptionCountDto: { + value: string; + count: number; + }; + FieldBreakdownDto: { + fieldId: string; + counts: components["schemas"]["OptionCountDto"][]; + /** @description Open tickets with no value */ + none: number; + }; ProjectInsightsDto: { generatedAt: string; sprint: components["schemas"]["SprintPulseDto"] | null; @@ -2240,6 +2284,9 @@ export interface components { changes: components["schemas"]["ChangesSinceDto"] | null; team: components["schemas"]["TeamLoadDto"][]; backlog: components["schemas"]["BacklogHealthDto"]; + quality: components["schemas"]["QualityDto"]; + /** @description One per active list custom field */ + fields: components["schemas"]["FieldBreakdownDto"][]; }; ActivityDto: { id: string; diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index e015b97..19e3667 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -207,7 +207,7 @@ 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), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, and open tickets missing an estimate, an area or an assignee. With since, also what changed after it.", + "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), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) and open tickets per option of each list custom field. With since, also what changed after it.", paramsSchema: z.object({ project: projectRef, since: z diff --git a/docs/03-api.md b/docs/03-api.md index 1788579..2eec854 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -392,6 +392,8 @@ decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO | Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto | | Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar | | Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días | +| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases | +| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor | ## 3.2 GraphQL diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 2fca9d6..cbd16c8 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -280,22 +280,22 @@ visualización de la plataforma: ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | -| `/explorar` | Proyectos públicos (sin sesión) | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | -| `/p/:key/issues/:issueKey` | Ticket | -| `/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, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), equipo con sus puntos abiertos 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 | -| `/p/:key/members` | Miembros | +| Ruta | Página | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/auth/callback` | Retorno de Pireon (PKCE) | +| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | +| `/explorar` | Proyectos públicos (sin sesión) | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | +| `/p/:key/issues/:issueKey` | Ticket | +| `/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, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos 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 | +| `/p/:key/members` | Miembros | Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de diff --git a/docs/09-mcp.md b/docs/09-mcp.md index d5f2d2f..774e829 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -34,54 +34,54 @@ flowchart LR 57 herramientas, agrupadas por ámbito: -| Ámbito | Herramienta | Qué hace | -| ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuario | `whoami` | El usuario del token | -| | `search_users` | Buscar personas de Pireon (para asignar) | -| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | -| | `mark_notifications_read` | Marcar como leídas unas o todas | -| Proyectos | `list_my_projects` | Mis proyectos | -| | `list_public_projects` | Proyectos públicos | -| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) | -| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | -| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | -| | `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, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog y, con `since`, los cambios | -| | `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` | -| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | -| | `create_issue` | Crear un ticket (también colgado de una épica) | -| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | -| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | -| | `assign_issue` | Asignar o desasignar | -| | `set_issue_release` | Meter en una release o sacar | -| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | -| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | -| | `watch_issue` | Seguir un ticket o dejar de seguirlo | -| | `vote_issue` | Votar un ticket o quitar el voto | -| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | -| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | -| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | -| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | -| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | -| | `rank_issue` | Mover un ticket en el backlog | -| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | -| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | -| Sprints | `list_sprints` | Sprints de un proyecto | -| | `get_sprint` | Progreso, tickets y burndown | -| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | -| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | -| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | -| Releases | `list_releases` | Releases de un proyecto | -| | `upcoming_releases` | Próximas releases de mis proyectos | -| | `get_release` | Progreso, notas, tickets y burnup | -| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | -| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | -| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | -| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | +| Ámbito | Herramienta | Qué hace | +| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Usuario | `whoami` | El usuario del token | +| | `search_users` | Buscar personas de Pireon (para asignar) | +| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | +| | `mark_notifications_read` | Marcar como leídas unas o todas | +| Proyectos | `list_my_projects` | Mis proyectos | +| | `list_public_projects` | Proyectos públicos | +| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) | +| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | +| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | +| | `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, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios | +| | `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` | +| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | +| | `create_issue` | Crear un ticket (también colgado de una épica) | +| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | +| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | +| | `assign_issue` | Asignar o desasignar | +| | `set_issue_release` | Meter en una release o sacar | +| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | +| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | +| | `watch_issue` | Seguir un ticket o dejar de seguirlo | +| | `vote_issue` | Votar un ticket o quitar el voto | +| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | +| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | +| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | +| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | +| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | +| | `rank_issue` | Mover un ticket en el backlog | +| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | +| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | +| Sprints | `list_sprints` | Sprints de un proyecto | +| | `get_sprint` | Progreso, tickets y burndown | +| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | +| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | +| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | +| Releases | `list_releases` | Releases de un proyecto | +| | `upcoming_releases` | Próximas releases de mis proyectos | +| | `get_release` | Progreso, notas, tickets y burnup | +| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | +| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | +| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | +| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad, diff --git a/docs/10-futuro.md b/docs/10-futuro.md index b604ca8..727aa5d 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -31,14 +31,9 @@ cuando hay pistas, por dónde empezar. ## 10.3 Siguientes funcionalidades -### En curso, en este orden +### En curso -En el panel del proyecto: - -1. **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. -2. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por - navegador»). +Nada: la tanda está hecha (ver 10.5). ### Aceptadas, para después de esta tanda @@ -123,3 +118,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi y MCP (`get_project_insights`). - Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada. +- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área, + bugs tras cada release) y abiertos por opción de un campo de lista. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 4c25cc8..8dc94a3 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 b970b34..3b79ee1 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/insights.spec.ts b/libs/api/tracker/application/src/lib/analytics/insights.spec.ts index 70fc542..676bdde 100644 --- a/libs/api/tracker/application/src/lib/analytics/insights.spec.ts +++ b/libs/api/tracker/application/src/lib/analytics/insights.spec.ts @@ -1,5 +1,7 @@ import { + CustomFieldType, defaultProjectSettings, + IssuePriority, ReleaseReadModel, ReleaseStatus, IssueLinkType, @@ -302,4 +304,92 @@ describe('computeInsights', () => { staleDays: STALE_DAYS, }); }); + + it('weighs quality and splits open tickets by a list field', () => { + const settings = defaultProjectSettings(); + settings.customFields = [ + { + id: 'BROWSER', + name: 'Navegador', + type: CustomFieldType.SELECT, + options: ['Chrome', 'Safari'], + archived: false, + }, + ]; + const issues = [ + issue('B1', 'TODO', C.TODO, { + type: 'BUG', + priority: IssuePriority.HIGH, + createdAt: '2026-10-14T09:00:00Z', + customFields: { BROWSER: 'Safari' }, + }), + issue('B2', 'DONE', C.DONE, { + type: 'BUG', + area: 'FRONTEND', + createdAt: '2026-10-02T09:00:00Z', + }), + issue('T1', 'IN_PROGRESS', C.IN_PROGRESS, { + type: 'TASK', + area: 'FRONTEND', + customFields: { BROWSER: 'Safari' }, + }), + ]; + const timeline = [ + step('B2', '2026-10-05T09:00:00Z', 'IN_QA', C.QA), + step('B2', '2026-10-06T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS), + step('B2', '2026-10-07T09:00:00Z', 'IN_QA', C.QA), + step('B2', '2026-10-08T09:00:00Z', 'DONE', C.DONE), + ]; + const { quality, fields } = computeInsights({ + ...BASE, + settings, + issues, + timeline, + releases: [ + { + id: 'r', + name: '1.0', + status: ReleaseStatus.RELEASED, + releasedAt: '2026-10-01T00:00:00Z', + } as ReleaseReadModel, + ], + }); + expect(quality.openBugsByPriority[1]).toEqual({ + priority: IssuePriority.HIGH, + count: 1, + }); + expect(quality.bugWeeks).toHaveLength(8); + expect(quality.bugWeeks.at(-1)).toEqual({ + week: '2026-10-12', + created: 1, + closed: 0, + }); + expect(quality.bugWeeks.at(-2)).toEqual({ + week: '2026-10-05', + created: 0, + closed: 1, + }); + expect(quality.qaByArea).toEqual([ + { area: 'FRONTEND', returned: 1, passed: 1 }, + ]); + // B2 came 1 day after 1.0; B1 13 days after. + expect(quality.escaped).toEqual([ + { + releaseId: 'r', + name: '1.0', + releasedAt: '2026-10-01T00:00:00Z', + bugs: 2, + }, + ]); + expect(fields).toEqual([ + { + fieldId: 'BROWSER', + counts: [ + { value: 'Safari', count: 2 }, + { value: 'Chrome', count: 0 }, + ], + none: 0, + }, + ]); + }); }); diff --git a/libs/api/tracker/application/src/lib/analytics/insights.ts b/libs/api/tracker/application/src/lib/analytics/insights.ts index a6f59cd..a67243d 100644 --- a/libs/api/tracker/application/src/lib/analytics/insights.ts +++ b/libs/api/tracker/application/src/lib/analytics/insights.ts @@ -1,5 +1,9 @@ import { BacklogHealthReadModel, + CustomFieldType, + FieldBreakdownReadModel, + IssuePriority, + QualityReadModel, ChangesSinceReadModel, InsightIssueReadModel, IssueLinkType, @@ -18,7 +22,12 @@ import { TeamLoadReadModel, UserRefReadModel, } from '@task-stream/api/tracker/domain'; -import { computeProjectAnalytics, Step, stepsByIssue } from './analytics'; +import { + computeProjectAnalytics, + Step, + stepsByIssue, + weekOf, +} from './analytics'; /* * The dashboard's reading of a project: what needs attention now (risks), @@ -169,9 +178,124 @@ export function computeInsights( new Set(blocked.map((b) => b.id)), ), backlog: backlogHealth(open, now), + quality: quality(input.settings, live, steps, input.releases, now), + fields: fieldBreakdown(input.settings, open), }; } +const PRIORITIES = [ + IssuePriority.CRITICAL, + IssuePriority.HIGH, + IssuePriority.MEDIUM, + IssuePriority.LOW, + IssuePriority.LOWEST, +]; + +/** Bugs are the tickets whose type is drawn with the bug icon. */ +function quality( + settings: ProjectSettings, + issues: IssueReadModel[], + steps: Map, + releases: ReleaseReadModel[], + now: number, +): QualityReadModel { + const bugTypes = new Set( + settings.issueTypes.filter((t) => t.icon === 'bug').map((t) => t.id), + ); + const bugs = issues.filter((i) => bugTypes.has(i.type)); + const openBugs = bugs.filter((i) => !CLOSED.includes(i.statusCategory)); + + // Created and closed per week, the last 8. + const thisWeek = Date.parse(weekOf(now)); + const weeks = Array.from({ length: 8 }, (_, n) => + new Date(thisWeek - (7 - n) * 7 * DAY).toISOString().slice(0, 10), + ); + const tally = new Map(weeks.map((w) => [w, { created: 0, closed: 0 }])); + for (const bug of bugs) { + const created = tally.get(weekOf(Date.parse(bug.createdAt))); + if (created) created.created++; + const list = steps.get(bug.id) ?? []; + list.forEach((step, i) => { + if (i === 0 || !CLOSED.includes(step.category)) return; + if (CLOSED.includes(list[i - 1].category)) return; + const week = tally.get(weekOf(step.at)); + if (week) week.closed++; + }); + } + + // QA's verdicts per area, the last 90 days. + const from = now - 90 * DAY; + const byArea = new Map(); + for (const issue of issues) { + const list = steps.get(issue.id) ?? []; + list.forEach((step, i) => { + const before = list[i - 1]; + if (!before || step.at < from) return; + if (before.category !== StatusCategory.QA) return; + if (step.category === StatusCategory.QA) return; + if (step.category === StatusCategory.CANCELLED) return; + const row = byArea.get(issue.area) ?? { returned: 0, passed: 0 }; + if (step.category === StatusCategory.DONE) row.passed++; + else row.returned++; + byArea.set(issue.area, row); + }); + } + + // Bugs reported in the 14 days after each of the last shipped releases. + const escaped = releases + .filter((r) => r.status === ReleaseStatus.RELEASED && r.releasedAt) + .sort((a, b) => + (b.releasedAt as string).localeCompare(a.releasedAt as string), + ) + .slice(0, 3) + .map((r) => { + const shipped = Date.parse(r.releasedAt as string); + return { + releaseId: r.id, + name: r.name, + releasedAt: r.releasedAt as string, + bugs: bugs.filter((b) => { + const at = Date.parse(b.createdAt); + return at > shipped && at <= shipped + 14 * DAY; + }).length, + }; + }); + + return { + openBugsByPriority: PRIORITIES.map((priority) => ({ + priority, + count: openBugs.filter((b) => b.priority === priority).length, + })), + bugWeeks: weeks.map((week) => ({ week, ...tally.get(week)! })), + qaByArea: [...byArea.entries()] + .map(([area, row]) => ({ area, ...row })) + .sort((a, b) => b.returned - a.returned || b.passed - a.passed), + escaped, + }; +} + +/** Open tickets per option of each active list field. */ +function fieldBreakdown( + settings: ProjectSettings, + open: IssueReadModel[], +): FieldBreakdownReadModel[] { + return (settings.customFields ?? []) + .filter((f) => !f.archived && f.type === CustomFieldType.SELECT) + .map((field) => { + const values = open.map((i) => i.customFields?.[field.id] ?? null); + return { + fieldId: field.id, + counts: field.options + .map((value) => ({ + value, + count: values.filter((v) => v === value).length, + })) + .sort((a, b) => b.count - a.count), + none: values.filter((v) => v === null).length, + }; + }); +} + /** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */ function dailyThroughput(steps: Map, now: number): number[] { const today = utcDay(now); 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 index 3cb845a..427eafa 100644 --- 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 @@ -128,6 +128,48 @@ export class BacklogHealthReadModel { staleDays!: number; } +/** Bugs created and closed in one week (Monday, UTC). */ +export class BugWeekReadModel { + week!: string; + created!: number; + closed!: number; +} + +/** What QA sent back, and let through, per area (last 90 days). */ +export class QaByAreaReadModel { + /** Area id; null: tickets with no area. */ + area!: string | null; + returned!: number; + passed!: number; +} + +/** Bugs reported in the 14 days after a release shipped. */ +export class EscapedBugsReadModel { + releaseId!: string; + name!: string; + releasedAt!: string; + bugs!: number; +} + +/** Bugs (types drawn with the bug icon) and QA. */ +export class QualityReadModel { + /** Open bugs per priority (all five, most urgent first). */ + openBugsByPriority!: { priority: string; count: number }[]; + /** The last 8 weeks, oldest first. */ + bugWeeks!: BugWeekReadModel[]; + qaByArea!: QaByAreaReadModel[]; + /** The last three shipped releases, newest first. */ + escaped!: EscapedBugsReadModel[]; +} + +/** Open tickets per option of a list custom field. */ +export class FieldBreakdownReadModel { + fieldId!: string; + counts!: { value: string; count: number }[]; + /** Open tickets with no value in it. */ + none!: number; +} + /** What the project dashboard reads beyond the counts. */ export class ProjectInsightsReadModel { generatedAt!: string; @@ -143,4 +185,7 @@ export class ProjectInsightsReadModel { /** Members (and others with open tickets), busiest first. */ team!: TeamLoadReadModel[]; backlog!: BacklogHealthReadModel; + quality!: QualityReadModel; + /** One per active list (SELECT) custom field. */ + fields!: FieldBreakdownReadModel[]; } diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 92b000f..8b95871 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -928,6 +928,64 @@ export class BacklogHealthGql { @Field(() => Int) staleDays!: number; } +@ObjectType('PriorityCount') +export class PriorityCountGql { + @Field(() => IssuePriority) priority!: IssuePriority; + @Field(() => Int) count!: number; +} + +@ObjectType('BugWeek', { description: 'Bugs created and closed in a week' }) +export class BugWeekGql { + @Field({ description: 'Monday (UTC), YYYY-MM-DD' }) week!: string; + @Field(() => Int) created!: number; + @Field(() => Int) closed!: number; +} + +@ObjectType('QaByArea', { description: 'QA verdicts per area (90 days)' }) +export class QaByAreaGql { + @Field(() => String, { nullable: true, description: 'Null: no area' }) + area!: string | null; + @Field(() => Int) returned!: number; + @Field(() => Int) passed!: number; +} + +@ObjectType('EscapedBugs', { + description: 'Bugs reported in the 14 days after a release shipped', +}) +export class EscapedBugsGql { + @Field(() => ID) releaseId!: string; + @Field() name!: string; + @Field() releasedAt!: string; + @Field(() => Int) bugs!: number; +} + +@ObjectType('Quality', { + description: 'Bugs (types with the bug icon) and QA', +}) +export class QualityGql { + @Field(() => [PriorityCountGql]) openBugsByPriority!: PriorityCountGql[]; + @Field(() => [BugWeekGql], { description: 'Last 8 weeks, oldest first' }) + bugWeeks!: BugWeekGql[]; + @Field(() => [QaByAreaGql]) qaByArea!: QaByAreaGql[]; + @Field(() => [EscapedBugsGql]) escaped!: EscapedBugsGql[]; +} + +@ObjectType('OptionCount') +export class OptionCountGql { + @Field() value!: string; + @Field(() => Int) count!: number; +} + +@ObjectType('FieldBreakdown', { + description: 'Open tickets per option of a list custom field', +}) +export class FieldBreakdownGql { + @Field() fieldId!: string; + @Field(() => [OptionCountGql]) counts!: OptionCountGql[]; + @Field(() => Int, { description: 'Open tickets with no value' }) + none!: number; +} + @ObjectType('ProjectInsights', { description: 'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes', @@ -947,4 +1005,9 @@ export class ProjectInsightsGql { changes!: ChangesSinceGql | null; @Field(() => [TeamLoadGql]) team!: TeamLoadGql[]; @Field(() => BacklogHealthGql) backlog!: BacklogHealthGql; + @Field(() => QualityGql) quality!: QualityGql; + @Field(() => [FieldBreakdownGql], { + description: 'One per active list custom field', + }) + fields!: FieldBreakdownGql[]; } 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 856bb70..f6a390b 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 @@ -1006,6 +1006,56 @@ export class BacklogHealthDto { @ApiProperty() staleDays!: number; } +export class PriorityCountDto { + @ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' }) + priority!: IssuePriority; + @ApiProperty() count!: number; +} + +export class BugWeekDto { + @ApiProperty({ format: 'date', description: 'Monday (UTC)' }) week!: string; + @ApiProperty() created!: number; + @ApiProperty() closed!: number; +} + +export class QaByAreaDto { + @ApiProperty({ type: String, nullable: true, description: 'Null: no area' }) + area!: string | null; + @ApiProperty() returned!: number; + @ApiProperty() passed!: number; +} + +export class EscapedBugsDto { + @ApiProperty({ format: 'uuid' }) releaseId!: string; + @ApiProperty() name!: string; + @ApiProperty() releasedAt!: string; + @ApiProperty({ description: 'Bugs reported in the 14 days after' }) + bugs!: number; +} + +export class QualityDto { + @ApiProperty({ type: [PriorityCountDto] }) + openBugsByPriority!: PriorityCountDto[]; + @ApiProperty({ + type: [BugWeekDto], + description: 'Last 8 weeks, oldest first', + }) + bugWeeks!: BugWeekDto[]; + @ApiProperty({ type: [QaByAreaDto] }) qaByArea!: QaByAreaDto[]; + @ApiProperty({ type: [EscapedBugsDto] }) escaped!: EscapedBugsDto[]; +} + +export class OptionCountDto { + @ApiProperty() value!: string; + @ApiProperty() count!: number; +} + +export class FieldBreakdownDto { + @ApiProperty() fieldId!: string; + @ApiProperty({ type: [OptionCountDto] }) counts!: OptionCountDto[]; + @ApiProperty({ description: 'Open tickets with no value' }) none!: number; +} + export class ProjectInsightsDto { @ApiProperty() generatedAt!: string; @ApiProperty({ type: SprintPulseDto, nullable: true }) @@ -1022,4 +1072,10 @@ export class ProjectInsightsDto { changes!: ChangesSinceDto | null; @ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[]; @ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto; + @ApiProperty({ type: QualityDto }) quality!: QualityDto; + @ApiProperty({ + type: [FieldBreakdownDto], + description: 'One per active list custom field', + }) + fields!: FieldBreakdownDto[]; } diff --git a/libs/web/tracker/domain/src/lib/models/insights.ts b/libs/web/tracker/domain/src/lib/models/insights.ts index d007deb..3c9f8f4 100644 --- a/libs/web/tracker/domain/src/lib/models/insights.ts +++ b/libs/web/tracker/domain/src/lib/models/insights.ts @@ -1,3 +1,4 @@ +import { IssuePriority } from './enums'; import { UserRef } from './models'; /** A ticket the dashboard names, with the number that put it there. */ @@ -102,6 +103,29 @@ export interface BacklogHealth { staleDays: number; } +/** Bugs (types drawn with the bug icon) and QA. */ +export interface Quality { + openBugsByPriority: { priority: IssuePriority; count: number }[]; + /** The last 8 weeks (Monday), oldest first. */ + bugWeeks: { week: string; created: number; closed: number }[]; + /** QA's verdicts per area, the last 90 days (area null: none). */ + qaByArea: { area: string | null; returned: number; passed: number }[]; + /** Bugs reported in the 14 days after each of the last releases. */ + escaped: { + releaseId: string; + name: string; + releasedAt: string; + bugs: number; + }[]; +} + +/** Open tickets per option of a list custom field. */ +export interface FieldBreakdown { + fieldId: string; + counts: { value: string; count: number }[]; + none: number; +} + /** The dashboard's reading of a project beyond the counts. */ export interface ProjectInsights { generatedAt: string; @@ -114,4 +138,6 @@ export interface ProjectInsights { changes: ChangesSince | null; team: TeamLoad[]; backlog: BacklogHealth; + quality: Quality; + fields: FieldBreakdown[]; } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 25ac59e..c227b4e 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -1921,6 +1921,26 @@ export type ProjectInsightsQuery = { stale: number; staleDays: number; }; + quality: { + openBugsByPriority: Array<{ priority: IssuePriority; count: number }>; + bugWeeks: Array<{ week: string; created: number; closed: number }>; + qaByArea: Array<{ + area: string | null; + returned: number; + passed: number; + }>; + escaped: Array<{ + releaseId: string; + name: string; + releasedAt: string; + bugs: number; + }>; + }; + fields: Array<{ + fieldId: string; + none: number; + counts: Array<{ value: string; count: number }>; + }>; }; }; }; @@ -11456,6 +11476,136 @@ export const ProjectInsightsDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'quality' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'openBugsByPriority', + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'priority' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'count' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'bugWeeks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'week' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'created' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'closed' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'qaByArea' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'area' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'returned' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'passed' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'escaped' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'releaseId' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'releasedAt' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'bugs' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'fields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'fieldId' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'counts' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'value' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'count' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'none' }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index 2604225..8586262 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -254,6 +254,36 @@ query ProjectInsights($idOrKey: String!, $since: String) { stale staleDays } + quality { + openBugsByPriority { + priority + count + } + bugWeeks { + week + created + closed + } + qaByArea { + area + returned + passed + } + escaped { + releaseId + name + releasedAt + bugs + } + } + fields { + fieldId + counts { + value + count + } + none + } } } } 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 e84d0d7..0f5e8eb 100644 --- a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts @@ -32,6 +32,8 @@ import { ReleaseForecastComponent } from '../ui/insights/release-forecast'; import { ChangesSinceComponent } from '../ui/insights/changes-since'; import { BacklogHealthComponent } from '../ui/insights/backlog-health'; import { takeLastVisit } from '../ui/last-visit'; +import { QualityComponent } from '../ui/insights/quality'; +import { FieldBreakdownComponent } from '../ui/insights/field-breakdown'; /** "Seguimiento": the state of the whole project at a glance (live). */ @Component({ @@ -49,6 +51,8 @@ import { takeLastVisit } from '../ui/last-visit'; ReleaseForecastComponent, ChangesSinceComponent, BacklogHealthComponent, + QualityComponent, + FieldBreakdownComponent, ], providers: [provideInsightsFacade()], template: ` @@ -129,6 +133,16 @@ import { takeLastVisit } from '../ui/last-visit'; [workflow]="facade.workflow()" /> + + @if (reading.fields.length) { + + } } @else { diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/field-breakdown.ts b/libs/web/tracker/presenters/src/lib/ui/insights/field-breakdown.ts new file mode 100644 index 0000000..7ed3b64 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/field-breakdown.ts @@ -0,0 +1,112 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + linkedSignal, +} from '@angular/core'; +import { PiSelectFieldComponent, type PiSelectFieldOption } from '@pi/ui'; +import { FieldBreakdown, Workflow } from '@task-stream/web/tracker/domain'; + +/** Open tickets per option of a list custom field («bugs por navegador»). */ +@Component({ + selector: 'ts-field-breakdown', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiSelectFieldComponent], + host: { + class: 'ts-panel', + 'aria-labelledby': 'breakdown-title', + role: 'region', + }, + styleUrl: './insight-panel.scss', + template: ` +
+

Abiertos por {{ name() }}

+ @if (options().length > 1) { + + } +
+ @if (current(); as field) { +
    + @for (row of rows(); track row.value) { +
  • + {{ row.value }} + + {{ row.count }} +
  • + } +
  • + Sin valor + + {{ field.none }} +
  • +
+ } + `, + styles: ` + .ts-breakdown li { + grid-template-columns: minmax(6rem, 10rem) 1fr 2.5rem; + align-items: center; + } + .ts-breakdown .ts-days { + text-align: right; + } + .ts-breakdown__pick { + min-width: 10rem; + } + `, +}) +export class FieldBreakdownComponent { + readonly fields = input.required(); + readonly workflow = input.required(); + + protected readonly options = computed[]>(() => + this.fields().map((f) => ({ + value: f.fieldId, + label: this.nameOf(f.fieldId), + })), + ); + /** The field shown; the first one until another is picked. */ + protected readonly chosen = linkedSignal( + () => this.fields()[0]?.fieldId ?? null, + ); + protected readonly current = computed( + () => + this.fields().find((f) => f.fieldId === this.chosen()) ?? + this.fields()[0] ?? + null, + ); + protected readonly name = computed(() => { + const field = this.current(); + return field ? this.nameOf(field.fieldId).toLocaleLowerCase('es') : ''; + }); + private readonly max = computed(() => { + const field = this.current(); + return Math.max( + 1, + field?.none ?? 0, + ...(field?.counts.map((c) => c.count) ?? []), + ); + }); + protected readonly rows = computed(() => + (this.current()?.counts ?? []).map((c) => ({ + ...c, + share: (c.count / this.max()) * 100, + })), + ); + protected readonly noneShare = computed( + () => ((this.current()?.none ?? 0) / this.max()) * 100, + ); + + private nameOf(fieldId: string): string { + return this.workflow().customField(fieldId)?.name ?? fieldId; + } +} diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/quality.ts b/libs/web/tracker/presenters/src/lib/ui/insights/quality.ts new file mode 100644 index 0000000..8b6f215 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/insights/quality.ts @@ -0,0 +1,202 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { Quality, Workflow } from '@task-stream/web/tracker/domain'; +import { PRIORITY } from '../copy'; + +const short = (day: string) => + new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + }); + +/** Open bugs, how they come and go, what QA sends back and what escapes. */ +@Component({ + selector: 'ts-quality', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'ts-panel', + 'aria-labelledby': 'quality-title', + role: 'region', + }, + styleUrl: './insight-panel.scss', + template: ` +
+

Calidad

+ Bugs: los tipos con icono de bicho +
+ +

Bugs abiertos ({{ openBugs() }})

+
    + @for (row of priorities(); track row.priority) { +
  • + {{ row.label }} + + {{ row.count }} +
  • + } +
+ +

Últimas 8 semanas

+ +

+ Nuevos + Cerrados +

+ + @if (areas().length) { +

QA por área (90 días)

+
    + @for (row of areas(); track row.label) { +
  • + {{ row.label }} + + {{ row.returned }}/{{ row.returned + row.passed }} vueltos +
  • + } +
+ } + + @if (quality().escaped.length) { +

Bugs en los 14 días tras cada release

+
    + @for (row of quality().escaped; track row.releaseId) { +
  • + {{ row.name }} + {{ shipped(row.releasedAt) }} + {{ + row.bugs + }} +
  • + } +
+ } + `, + styles: ` + h3 { + margin: 1rem 0 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + &:first-of-type { + margin-top: 0; + } + } + .ts-quality__rows li { + grid-template-columns: minmax(6rem, 9rem) 1fr 5.5rem; + align-items: center; + } + .ts-quality__rows .ts-days { + text-align: right; + } + .ts-bar--warn { + --tone: var(--ts-c-red, #d14343); + } + .ts-quality__weeks { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 0.5rem; + height: 4rem; + } + .ts-quality__week { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 2px; + border-bottom: 1px solid var(--ts-line); + i { + width: 40%; + min-height: 1px; + border-radius: 2px 2px 0 0; + } + } + .is-created { + --tone: var(--ts-c-red, #d14343); + } + .is-closed { + --tone: var(--ts-c-green, #2e9e6a); + } + .ts-quality__week i { + background: var(--tone); + } + .ts-quality__legend { + display: flex; + gap: 1rem; + margin: 0.375rem 0 0; + color: var(--ts-muted); + font-size: 0.75rem; + span::before { + content: ''; + display: inline-block; + width: 0.5rem; + height: 0.5rem; + margin-right: 0.3rem; + border-radius: 2px; + background: var(--tone); + } + } + `, +}) +export class QualityComponent { + readonly quality = input.required(); + readonly workflow = input.required(); + + protected readonly openBugs = computed(() => + this.quality().openBugsByPriority.reduce((sum, r) => sum + r.count, 0), + ); + protected readonly priorities = computed(() => { + const max = Math.max( + 1, + ...this.quality().openBugsByPriority.map((r) => r.count), + ); + return this.quality().openBugsByPriority.map((r) => ({ + ...r, + label: PRIORITY[r.priority].label, + share: (r.count / max) * 100, + })); + }); + protected readonly weeks = computed(() => { + const list = this.quality().bugWeeks; + const max = Math.max(1, ...list.flatMap((w) => [w.created, w.closed])); + return list.map((w) => ({ + ...w, + createdShare: (w.created / max) * 100, + closedShare: (w.closed / max) * 100, + title: `Semana del ${short(w.week)}: ${w.created} nuevos, ${w.closed} cerrados`, + })); + }); + protected readonly weeksLabel = computed(() => + this.weeks() + .map((w) => w.title) + .join('. '), + ); + protected readonly areas = computed(() => { + const rows = this.quality().qaByArea; + return rows.map((r) => ({ + ...r, + label: r.area ? this.workflow().area(r.area).name : 'Sin área', + share: + r.returned + r.passed + ? (r.returned / (r.returned + r.passed)) * 100 + : 0, + })); + }); + + protected shipped(day: string): string { + return `publicada el ${short(day)}`; + } +}