diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 2a83f5d..13e338a 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1414,5 +1414,28 @@ describe('task-stream (e2e)', () => { { k: key }, ); expect(project.insights.risks.overdueCount).toBe(1); + + // What changed since a moment, and what the backlog lacks. + const since = new Date(Date.now() - 60_000).toISOString(); + const later = await rest( + 'token-ana', + 'GET', + `/projects/${key}/insights?since=${encodeURIComponent(since)}`, + ); + expect(later.body['changes']).toMatchObject({ since, createdCount: 2 }); + expect(later.body['backlog']).toMatchObject({ + open: 2, + unestimated: 2, + unassigned: 2, + }); + expect( + later.body['team'].map((t: { user: { id: string } }) => t.user.id), + ).toContain('101'); + const unestimated = await rest( + 'token-ana', + 'GET', + `/projects/${key}/issues?noEstimate=true&noArea=true`, + ); + expect(unestimated.body['total']).toBe(2); }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 91ee00f..41ff4db 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1449,6 +1449,16 @@ "example": "WEB", "type": "string" } + }, + { + "name": "since", + "required": false, + "in": "query", + "description": "ISO instant (e.g. your last visit): the answer also tells what changed after it", + "schema": { + "example": "2026-10-14T08:00:00Z", + "type": "string" + } } ], "responses": { @@ -2507,6 +2517,33 @@ "type": "string" } }, + { + "name": "noEstimate", + "required": false, + "in": "query", + "description": "Only tickets nobody estimated", + "schema": { + "type": "boolean" + } + }, + { + "name": "noArea", + "required": false, + "in": "query", + "description": "Only tickets with no area", + "schema": { + "type": "boolean" + } + }, + { + "name": "updatedBefore", + "required": false, + "in": "query", + "description": "Only tickets not updated since this instant (ISO)", + "schema": { + "type": "string" + } + }, { "name": "field", "required": false, @@ -12109,6 +12146,176 @@ "overLimit" ] }, + "ReleaseForecastDto": { + "type": "object", + "properties": { + "releaseId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "targetDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "total": { + "type": "number" + }, + "finished": { + "type": "number" + }, + "likelyDate": { + "type": "string", + "nullable": true, + "format": "date", + "description": "Half of the simulated futures finish by then" + }, + "safeDate": { + "type": "string", + "nullable": true, + "format": "date", + "description": "85% finish by then" + }, + "verdict": { + "type": "string", + "enum": [ + "DONE", + "NO_DATE", + "UNKNOWN", + "ON_TRACK", + "AT_RISK", + "LATE" + ] + } + }, + "required": [ + "releaseId", + "name", + "targetDate", + "total", + "finished", + "likelyDate", + "safeDate", + "verdict" + ] + }, + "ChangesSinceDto": { + "type": "object", + "properties": { + "since": { + "type": "string" + }, + "created": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InsightIssueDto" + } + }, + "createdCount": { + "type": "number" + }, + "finished": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InsightIssueDto" + } + }, + "finishedCount": { + "type": "number" + }, + "returned": { + "description": "Sent back from QA", + "type": "array", + "items": { + "$ref": "#/components/schemas/InsightIssueDto" + } + }, + "returnedCount": { + "type": "number" + }, + "mentions": { + "type": "number", + "description": "Times you were mentioned here" + } + }, + "required": [ + "since", + "created", + "createdCount", + "finished", + "finishedCount", + "returned", + "returnedCount", + "mentions" + ] + }, + "TeamLoadDto": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserRefDto" + }, + "openTickets": { + "type": "number" + }, + "openPoints": { + "type": "number" + }, + "blocked": { + "type": "number" + }, + "pointsPerSprint": { + "type": "number", + "nullable": true, + "description": "Points closed per sprint, last three closed sprints" + }, + "overloaded": { + "type": "boolean" + } + }, + "required": [ + "user", + "openTickets", + "openPoints", + "blocked", + "pointsPerSprint", + "overloaded" + ] + }, + "BacklogHealthDto": { + "type": "object", + "properties": { + "open": { + "type": "number" + }, + "unestimated": { + "type": "number" + }, + "noArea": { + "type": "number" + }, + "unassigned": { + "type": "number" + }, + "stale": { + "type": "number" + }, + "staleDays": { + "type": "number" + } + }, + "required": [ + "open", + "unestimated", + "noArea", + "unassigned", + "stale", + "staleDays" + ] + }, "ProjectInsightsDto": { "type": "object", "properties": { @@ -12135,6 +12342,31 @@ "items": { "$ref": "#/components/schemas/StatusTimeDto" } + }, + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseForecastDto" + } + }, + "changes": { + "nullable": true, + "description": "Only when asked with `since`", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ChangesSinceDto" + } + ] + }, + "team": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamLoadDto" + } + }, + "backlog": { + "$ref": "#/components/schemas/BacklogHealthDto" } }, "required": [ @@ -12142,7 +12374,11 @@ "sprint", "risks", "aging", - "bottlenecks" + "bottlenecks", + "releases", + "changes", + "team", + "backlog" ] }, "ActivityDto": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index df1c7b6..a81bbe9 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -129,6 +129,15 @@ type AutomationTriggerSpec { status: String } +type BacklogHealth { + noArea: Int! + open: Int! + stale: Int! + staleDays: Int! + unassigned: Int! + unestimated: Int! +} + type BoardColumn { issues: [Issue!]! status: String! @@ -184,6 +193,22 @@ input ChangeIssueStatusInput { status: String! } +"""What happened after a moment""" +type ChangesSince { + created: [InsightIssue!]! + createdCount: Int! + finished: [InsightIssue!]! + finishedCount: Int! + + """Times I was mentioned here""" + mentions: Int! + + """Sent back from QA""" + returned: [InsightIssue!]! + returnedCount: Int! + since: String! +} + """A step inside a ticket that does not deserve a ticket of its own""" type ChecklistItem { done: Boolean! @@ -598,6 +623,12 @@ input IssueFilterInput { dueTo: String labels: [String!] + """Only tickets with no area""" + noArea: Boolean + + """Only tickets nobody estimated""" + noEstimate: Boolean + """Only tickets in no sprint""" noSprint: Boolean @@ -615,6 +646,9 @@ input IssueFilterInput { types: [String!] unassigned: Boolean + """Only tickets not updated since this instant (ISO)""" + updatedBefore: String + """Only the tickets I follow (none when anonymous)""" watching: Boolean } @@ -978,7 +1012,10 @@ type Project { """ The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks """ - insights: ProjectInsights! + insights( + """ISO instant (e.g. my last visit): also tell what changed after it""" + since: String + ): ProjectInsights! """Anyone can read it, also without signing in""" isPublic: Boolean! @@ -1021,13 +1058,21 @@ type ProjectChange { version: Int! } -"""The dashboard: sprint under way, risks, aging and bottlenecks""" +""" +The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes +""" type ProjectInsights { aging: Aging! + backlog: BacklogHealth! bottlenecks: [StatusTime!]! + + """Only when asked with `since`""" + changes: ChangesSince generatedAt: String! + releases: [ReleaseForecast!]! risks: Risks! sprint: SprintPulse + team: [TeamLoad!]! } type ProjectMember { @@ -1237,6 +1282,24 @@ type ReleaseChange { version: Int! } +"""When a planned release would be finished, at the real pace""" +type ReleaseForecast { + finished: Int! + + """Half of the simulated futures finish by then""" + likelyDate: String + name: String! + releaseId: ID! + + """85% finish by then""" + safeDate: String + targetDate: String + total: Int! + + """DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE""" + verdict: String! +} + type ReleaseProgress { byStatus: [CountByStatus!]! finished: Int! @@ -1450,6 +1513,17 @@ type Subscription { sprintChanged(projectId: ID!): SprintChange! } +type TeamLoad { + blocked: Int! + openPoints: Int! + openTickets: Int! + overloaded: Boolean! + + """Points closed per sprint, last three closed sprints""" + pointsPerSprint: Float + user: UserRef! +} + type Throughput { count: Int! user: UserRef diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index c2db6fb..7387c83 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -2179,12 +2179,67 @@ export interface components { wipLimit: number | null; overLimit: boolean; }; + ReleaseForecastDto: { + /** Format: uuid */ + releaseId: string; + name: string; + /** Format: date */ + targetDate: string | null; + total: number; + finished: number; + /** + * Format: date + * @description Half of the simulated futures finish by then + */ + likelyDate: string | null; + /** + * Format: date + * @description 85% finish by then + */ + safeDate: string | null; + /** @enum {string} */ + verdict: "DONE" | "NO_DATE" | "UNKNOWN" | "ON_TRACK" | "AT_RISK" | "LATE"; + }; + ChangesSinceDto: { + since: string; + created: components["schemas"]["InsightIssueDto"][]; + createdCount: number; + finished: components["schemas"]["InsightIssueDto"][]; + finishedCount: number; + /** @description Sent back from QA */ + returned: components["schemas"]["InsightIssueDto"][]; + returnedCount: number; + /** @description Times you were mentioned here */ + mentions: number; + }; + TeamLoadDto: { + user: components["schemas"]["UserRefDto"]; + openTickets: number; + openPoints: number; + blocked: number; + /** @description Points closed per sprint, last three closed sprints */ + pointsPerSprint: number | null; + overloaded: boolean; + }; + BacklogHealthDto: { + open: number; + unestimated: number; + noArea: number; + unassigned: number; + stale: number; + staleDays: number; + }; ProjectInsightsDto: { generatedAt: string; sprint: components["schemas"]["SprintPulseDto"] | null; risks: components["schemas"]["RisksDto"]; aging: components["schemas"]["AgingDto"]; bottlenecks: components["schemas"]["StatusTimeDto"][]; + releases: components["schemas"]["ReleaseForecastDto"][]; + /** @description Only when asked with `since` */ + changes: components["schemas"]["ChangesSinceDto"] | null; + team: components["schemas"]["TeamLoadDto"][]; + backlog: components["schemas"]["BacklogHealthDto"]; }; ActivityDto: { id: string; @@ -3970,7 +4025,10 @@ export interface operations { }; ProjectsController_insights: { parameters: { - query?: never; + query?: { + /** @description ISO instant (e.g. your last visit): the answer also tells what changed after it */ + since?: string; + }; header?: never; path: { /** @description Project id or key */ @@ -4579,6 +4637,12 @@ export interface operations { dueFrom?: string; /** @description Due on or before this day */ dueTo?: string; + /** @description Only tickets nobody estimated */ + noEstimate?: boolean; + /** @description Only tickets with no area */ + noArea?: boolean; + /** @description Only tickets not updated since this instant (ISO) */ + updatedBefore?: string; /** @description Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several */ field?: string[]; /** @description RECENT (default): last updated first. RANK: backlog order. */ diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index e3148dc..e015b97 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -207,15 +207,26 @@ 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 }), + "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.", + paramsSchema: z.object({ + project: projectRef, + since: z + .string() + .optional() + .describe( + 'ISO instant: also tell what was created, finished or sent back from QA after it', + ), + }), annotations: READ, }) - insights({ project }: { project: string }, ctx: McpContext) { + insights( + { project, since }: { project: string; since?: string }, + ctx: McpContext, + ) { return toolResult(() => this.api.data( this.api.as(ctx).GET('/api/projects/{project}/insights', { - params: { path: { project } }, + params: { path: { project }, query: { since } }, }), ), ); diff --git a/docs/03-api.md b/docs/03-api.md index 2ec7719..1788579 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -380,14 +380,18 @@ Los tickets anteriores a esta proyección solo tienen su creación y su estado a 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`): +decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO): -| 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 | +| 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 | +| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. `ON_TRACK`, `AT_RISK`, `LATE`, `NO_DATE`, `UNKNOWN` (sin ritmo aún) o `DONE` | +| 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 | ## 3.2 GraphQL diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index a49d6d9..2fca9d6 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, 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 | -| `/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`), 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 082fb92..d5f2d2f 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, 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` | -| | `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 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 4bedc9c..b604ca8 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -35,15 +35,9 @@ cuando hay pistas, por dónde empezar. En el panel del proyecto: -1. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo), - con semáforo. -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. -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 +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. -6. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por +2. **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 +121,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - 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`). +- 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. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 9a3a9fe..4c25cc8 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 15f7312..b970b34 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 eac93c0..70fc542 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 { defaultProjectSettings, + ReleaseReadModel, + ReleaseStatus, IssueLinkType, IssueReadModel, IssueTimelineEntry, @@ -7,7 +9,13 @@ import { SprintStatus, StatusCategory as C, } from '@task-stream/api/tracker/domain'; -import { computeInsights, STUCK_DAYS } from './insights'; +import { + computeInsights, + InsightsInput, + simulateDays, + STALE_DAYS, + STUCK_DAYS, +} from './insights'; const NOW = new Date('2026-10-15T12:00:00Z'); const bob = { id: 'bob', name: 'Bob' }; @@ -68,6 +76,24 @@ const sprint: SprintReadModel = { }; const inSprint = { sprint: { id: 's', name: 'Sprint 7' } }; +const BASE: Omit = { + settings: defaultProjectSettings(), + activeSprint: null, + sprints: [], + releases: [], + members: [], + since: null, + mentions: 0, + now: NOW, +}; +const release = (id: string, targetDate: string | null): ReleaseReadModel => + ({ + id, + name: id, + targetDate, + status: ReleaseStatus.PLANNED, + }) as ReleaseReadModel; + describe('computeInsights', () => { it('names what is late, due soon, blocked and stuck', () => { const issues = [ @@ -95,9 +121,8 @@ describe('computeInsights', () => { const { risks, aging } = computeInsights({ issues, timeline, + ...BASE, 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]]); @@ -121,9 +146,9 @@ describe('computeInsights', () => { const { sprint: pulse } = computeInsights({ issues, timeline: [], + ...BASE, settings: defaultProjectSettings(), activeSprint: sprint, - now: NOW, }); expect(pulse).toMatchObject({ totalDays: 14, @@ -155,9 +180,8 @@ describe('computeInsights', () => { const rows = computeInsights({ issues, timeline, + ...BASE, settings, - activeSprint: null, - now: NOW, }).bottlenecks; expect(rows.map((r) => r.status)).toEqual([ 'IN_PROGRESS', @@ -174,4 +198,108 @@ describe('computeInsights', () => { overLimit: true, }); }); + + it('forecasts planned releases at the pace of the last weeks', () => { + // Two tickets finished a day, every day of the last six weeks. + expect(simulateDays(10, [2, 2, 2])).toEqual({ likely: 5, safe: 5 }); + expect(simulateDays(3, [0, 0])).toBeNull(); + expect(simulateDays(0, [])).toEqual({ likely: 0, safe: 0 }); + + const done = Array.from({ length: 12 }, (_, n) => `D${n}`); + const issues = [ + ...done.map((id) => issue(id, 'DONE', C.DONE)), + ...['A1', 'A2', 'A3'].map((id) => + issue(id, 'TODO', C.TODO, { release: { id: 'a', name: 'a' } }), + ), + ...['B1', 'B2', 'B3'].map((id) => + issue(id, 'TODO', C.TODO, { release: { id: 'b', name: 'b' } }), + ), + ]; + // One finished every 3.5 days or so: 12 in the last 42 days. + const timeline = done.map((id, n) => + step( + id, + new Date(NOW.getTime() - (n * 3 + 1) * 86_400_000).toISOString(), + 'DONE', + C.DONE, + ), + ); + const { releases } = computeInsights({ + ...BASE, + issues, + timeline, + releases: [release('b', '2026-10-20'), release('a', '2026-12-31')], + }); + // Nearer target first; the later one waits for it. + expect(releases.map((r) => [r.releaseId, r.verdict])).toEqual([ + ['b', 'LATE'], + ['a', 'ON_TRACK'], + ]); + expect(releases[1].likelyDate! > releases[0].likelyDate!).toBe(true); + }); + + it('tells what changed since a moment, and who carries what', () => { + const ana = { id: 'ana', name: 'Ana' }; + const issues = [ + issue('NEW', 'TODO', C.TODO, { + createdAt: '2026-10-15T08:00:00Z', + assignee: ana, + storyPoints: 8, + }), + issue('CLOSED', 'DONE', C.DONE, { + sprint: { id: 'old', name: 'Old' }, + storyPoints: 5, + }), + issue('BACK', 'QA_FAILED', C.IN_PROGRESS, { storyPoints: 2 }), + issue('STALE', 'TODO', C.TODO, { + assignee: null, + area: 'FRONTEND', + updatedAt: '2026-01-01T00:00:00Z', + }), + ]; + const timeline = [ + step('CLOSED', '2026-10-10T09:00:00Z', 'IN_QA', C.QA), + step('CLOSED', '2026-10-15T09:00:00Z', 'DONE', C.DONE), + step('BACK', '2026-10-10T09:00:00Z', 'IN_QA', C.QA), + step('BACK', '2026-10-15T10:00:00Z', 'QA_FAILED', C.IN_PROGRESS), + ]; + const reading = computeInsights({ + ...BASE, + issues, + timeline, + since: new Date('2026-10-15T07:00:00Z'), + mentions: 2, + members: [{ user: ana, role: 'MEMBER' as never }], + sprints: [ + { id: 'old', closedAt: '2026-10-11T00:00:00Z' } as SprintReadModel, + ], + }); + expect(reading.changes).toMatchObject({ + createdCount: 1, + finishedCount: 1, + returnedCount: 1, + mentions: 2, + }); + expect(reading.changes?.returned[0].key).toBe('BACK'); + // Bob closed 5 points in the one closed sprint and holds 2 open. + expect( + reading.team.map((t) => [ + t.user.id, + t.openPoints, + t.pointsPerSprint, + t.overloaded, + ]), + ).toEqual([ + ['ana', 8, 0, false], + ['bob', 2, 5, false], + ]); + expect(reading.backlog).toEqual({ + open: 3, + unestimated: 1, + noArea: 2, + unassigned: 1, + stale: 1, + staleDays: STALE_DAYS, + }); + }); }); diff --git a/libs/api/tracker/application/src/lib/analytics/insights.ts b/libs/api/tracker/application/src/lib/analytics/insights.ts index 63b4bd1..a6f59cd 100644 --- a/libs/api/tracker/application/src/lib/analytics/insights.ts +++ b/libs/api/tracker/application/src/lib/analytics/insights.ts @@ -1,14 +1,22 @@ import { + BacklogHealthReadModel, + ChangesSinceReadModel, InsightIssueReadModel, IssueLinkType, IssueReadModel, IssueTimelineEntry, ProjectInsightsReadModel, + ProjectMemberReadModel, ProjectSettings, + ReleaseForecastReadModel, + ReleaseReadModel, + ReleaseStatus, SprintPulseReadModel, SprintReadModel, StatusCategory, StatusTimeReadModel, + TeamLoadReadModel, + UserRefReadModel, } from '@task-stream/api/tracker/domain'; import { computeProjectAnalytics, Step, stepsByIssue } from './analytics'; @@ -24,6 +32,10 @@ const DAY = 86_400_000; export const STUCK_DAYS = 7; /** How many tickets each list names. */ const LIST = 8; +/** Days not updated that make an open ticket «stale». */ +export const STALE_DAYS = 90; +/** Days of finished work the forecasts draw their pace from. */ +export const PACE_DAYS = 42; const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA]; const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED]; @@ -32,6 +44,14 @@ export interface InsightsInput { timeline: IssueTimelineEntry[]; settings: ProjectSettings; activeSprint: SprintReadModel | null; + /** Every sprint of the project (closed ones give each person's pace). */ + sprints: SprintReadModel[]; + releases: ReleaseReadModel[]; + members: ProjectMemberReadModel[]; + /** The viewer's last visit, to tell what changed since. */ + since: Date | null; + /** Times the viewer was mentioned in the project since then. */ + mentions: number; now: Date; } @@ -137,6 +157,245 @@ export function computeInsights( thresholdDays: analytics.cycleTime.p85Days, }, bottlenecks: bottlenecks(input.settings, open, steps, now), + releases: forecasts(input.releases, live, steps, now), + changes: input.since + ? changesSince(input.since.getTime(), live, steps, line, input.mentions) + : null, + team: teamLoad( + input.members, + open, + live, + input.sprints, + new Set(blocked.map((b) => b.id)), + ), + backlog: backlogHealth(open, now), + }; +} + +/** 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); + const counts = new Array(PACE_DAYS).fill(0); + for (const list of steps.values()) + list.forEach((step, i) => { + const before = list[i - 1]; + if (step.category !== StatusCategory.DONE) return; + if (before && before.category === StatusCategory.DONE) return; + const ago = Math.floor((today - utcDay(step.at)) / DAY); + if (ago >= 0 && ago < PACE_DAYS) counts[ago]++; + }); + return counts; +} + +/** Small seeded generator: the same data always gives the same forecast. */ +function random(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Days to finish `remaining` tickets, drawing each day's throughput from + * the days measured (Monte Carlo): the 50th and 85th percentiles. + */ +export function simulateDays( + remaining: number, + samples: number[], + runs = 500, +): { likely: number; safe: number } | null { + if (remaining <= 0) return { likely: 0, safe: 0 }; + if (!samples.some((n) => n > 0)) return null; + const next = random(remaining * 7919 + samples.length); + const results: number[] = []; + for (let run = 0; run < runs; run++) { + let done = 0; + let days = 0; + while (done < remaining && days < 3 * 365) { + done += samples[Math.floor(next() * samples.length)]; + days++; + } + results.push(days); + } + results.sort((a, b) => a - b); + return { + likely: results[Math.floor(0.5 * runs)], + safe: results[Math.ceil(0.85 * runs) - 1], + }; +} + +/** + * Planned releases in target-date order. The team finishes them one after + * another, so each one's forecast counts what is left in the earlier ones. + */ +function forecasts( + releases: ReleaseReadModel[], + issues: IssueReadModel[], + steps: Map, + now: number, +): ReleaseForecastReadModel[] { + const pace = dailyThroughput(steps, now); + const today = utcDay(now); + const dayAfter = (days: number) => + new Date(today + days * DAY).toISOString().slice(0, 10); + let ahead = 0; + return releases + .filter((r) => r.status === ReleaseStatus.PLANNED) + .sort( + (a, b) => + (a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') || + a.name.localeCompare(b.name), + ) + .map((release) => { + const own = issues.filter( + (i) => + i.release?.id === release.id && + i.statusCategory !== StatusCategory.CANCELLED, + ); + const finished = own.filter( + (i) => i.statusCategory === StatusCategory.DONE, + ).length; + const left = own.length - finished; + ahead += left; + const days = left > 0 ? simulateDays(ahead, pace) : null; + const likelyDate = days ? dayAfter(days.likely) : null; + const safeDate = days ? dayAfter(days.safe) : null; + const verdict: ReleaseForecastReadModel['verdict'] = + left === 0 + ? 'DONE' + : !days + ? 'UNKNOWN' + : !release.targetDate + ? 'NO_DATE' + : (safeDate as string) <= release.targetDate + ? 'ON_TRACK' + : (likelyDate as string) <= release.targetDate + ? 'AT_RISK' + : 'LATE'; + return { + releaseId: release.id, + name: release.name, + targetDate: release.targetDate, + total: own.length, + finished, + likelyDate, + safeDate, + verdict, + }; + }); +} + +/** Created, finished and sent back from QA after `since`. */ +function changesSince( + since: number, + issues: IssueReadModel[], + steps: Map, + line: (issue: IssueReadModel, days: number) => InsightIssueReadModel, + mentions: number, +): ChangesSinceReadModel { + const created = issues + .filter((i) => Date.parse(i.createdAt) > since) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const finished: IssueReadModel[] = []; + const returned: IssueReadModel[] = []; + for (const issue of issues) { + const list = steps.get(issue.id) ?? []; + const recent = list.filter((s, i) => s.at > since && i > 0); + if ( + issue.statusCategory === StatusCategory.DONE && + recent.some((s) => s.category === StatusCategory.DONE) + ) + finished.push(issue); + if ( + list.some( + (s, i) => + s.at > since && + i > 0 && + list[i - 1].category === StatusCategory.QA && + !CLOSED.includes(s.category) && + s.category !== StatusCategory.QA, + ) + ) + returned.push(issue); + } + return { + since: new Date(since).toISOString(), + created: created.slice(0, LIST).map((i) => line(i, 0)), + createdCount: created.length, + finished: finished.slice(0, LIST).map((i) => line(i, 0)), + finishedCount: finished.length, + returned: returned.slice(0, LIST).map((i) => line(i, 0)), + returnedCount: returned.length, + mentions, + }; +} + +const pointsOf = (list: IssueReadModel[]) => + list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0); + +/** Open work per person against the points they closed per sprint lately. */ +function teamLoad( + members: ProjectMemberReadModel[], + open: IssueReadModel[], + issues: IssueReadModel[], + sprints: SprintReadModel[], + blockedIds: Set, +): TeamLoadReadModel[] { + const people = new Map(); + for (const m of members) people.set(m.user.id, m.user); + for (const i of open) if (i.assignee) people.set(i.assignee.id, i.assignee); + const lastClosed = sprints + .filter((s) => s.closedAt) + .sort((a, b) => (b.closedAt as string).localeCompare(a.closedAt as string)) + .slice(0, 3) + .map((s) => s.id); + return [...people.values()] + .map((user) => { + const mine = open.filter((i) => i.assignee?.id === user.id); + const closed = issues.filter( + (i) => + i.assignee?.id === user.id && + i.statusCategory === StatusCategory.DONE && + !!i.sprint && + lastClosed.includes(i.sprint.id), + ); + const perSprint = lastClosed.length + ? round1(pointsOf(closed) / lastClosed.length) + : null; + const openPoints = pointsOf(mine); + return { + user, + openTickets: mine.length, + openPoints, + blocked: mine.filter((i) => blockedIds.has(i.id)).length, + pointsPerSprint: perSprint, + overloaded: !!perSprint && openPoints > perSprint, + }; + }) + .sort( + (a, b) => + b.openPoints - a.openPoints || + b.openTickets - a.openTickets || + a.user.name.localeCompare(b.user.name, 'es'), + ); +} + +function backlogHealth( + open: IssueReadModel[], + now: number, +): BacklogHealthReadModel { + const staleBefore = now - STALE_DAYS * DAY; + return { + open: open.length, + unestimated: open.filter((i) => i.storyPoints === null).length, + noArea: open.filter((i) => !i.area).length, + unassigned: open.filter((i) => !i.assignee).length, + stale: open.filter((i) => Date.parse(i.updatedAt) < staleBefore).length, + staleDays: STALE_DAYS, }; } diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index e5d5e75..35682c5 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -33,6 +33,12 @@ export interface IssueFilter { dueTo?: string; /** Only tickets in no sprint (wins over `sprintId`). */ noSprint?: boolean; + /** Only tickets nobody estimated. */ + noEstimate?: boolean; + /** Only tickets with no area. */ + noArea?: boolean; + /** Only tickets not updated since this instant (ISO). */ + updatedBefore?: string; /** Custom field id → the value it must hold. */ customFields?: Record; /** Only the ones the viewer follows (the query handler turns it into `watcherId`). */ diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index f491032..4f41394 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -323,13 +323,14 @@ 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. */ + /** The dashboard's reading; with `since`, what changed after it too. */ insights( viewerId: string | null, projectId: string, + since: Date | null = null, ): Promise { return this.queryBus.execute( - new GetProjectInsightsQuery(viewerId, projectId), + new GetProjectInsightsQuery(viewerId, projectId, since), ); } 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 index 6a7c75a..fc93cc0 100644 --- 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 @@ -1,11 +1,15 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { DEFAULT_PROJECT_SETTINGS, + NotificationKind, ProjectInsightsReadModel, + SprintStatus, } 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 { FindReleasesRepository } from '../ports/find-releases.repository'; +import { NotificationsRepository } from '../ports/notifications.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { FindTimelineRepository } from '../ports/find-timeline.repository'; import { GetProjectInsightsQuery } from './get-project-insights.query'; @@ -21,24 +25,51 @@ export class GetProjectInsightsQueryHandler private readonly findIssues: FindIssuesRepository, private readonly findTimeline: FindTimelineRepository, private readonly findSprints: FindSprintsRepository, + private readonly findReleases: FindReleasesRepository, + private readonly notifications: NotificationsRepository, ) {} 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), - ]); + const [project, issues, timeline, sprints, releases, mentions] = + await Promise.all([ + this.findProjects.findById(query.projectId), + this.findIssues.findByProject(query.projectId), + this.findTimeline.findByProject(query.projectId), + this.findSprints.findByProject(query.projectId), + this.findReleases.findByProject(query.projectId), + this.mentionsSince(query), + ]); return computeInsights({ issues, timeline, settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS, - activeSprint, + activeSprint: + sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null, + sprints, + releases, + members: project?.members ?? [], + since: query.since, + mentions, now: new Date(), }); } + + /** The viewer's mentions in this project after `since` (latest 200). */ + private async mentionsSince(query: GetProjectInsightsQuery): Promise { + if (!query.since || !query.viewerId) return 0; + const since = query.since.toISOString(); + const latest = await this.notifications.findForUser(query.viewerId, { + unreadOnly: false, + limit: 200, + }); + return latest.filter( + (n) => + n.projectId === query.projectId && + n.kind === NotificationKind.MENTIONED && + n.createdAt > since, + ).length; + } } 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 index d0591a3..51225f8 100644 --- 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 @@ -1,12 +1,17 @@ import { Query } from '@nestjs/cqrs'; import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain'; -/** The dashboard's reading of a project: sprint, risks, aging, bottlenecks. */ +/** + * The dashboard's reading of a project: sprint, risks, aging, bottlenecks, + * release forecasts, team load, backlog health and, with `since`, what + * changed after it. + */ export class GetProjectInsightsQuery extends Query { constructor( /** Null: anonymous (public projects only). */ public readonly viewerId: string | null, public readonly projectId: string, + public readonly since: Date | null = null, ) { super(); } 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 462f18a..3cb845a 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 @@ -70,6 +70,64 @@ export class StatusTimeReadModel { overLimit!: boolean; } +/** When a planned release would be finished, at the team's real pace. */ +export class ReleaseForecastReadModel { + releaseId!: string; + name!: string; + targetDate!: string | null; + /** Tickets in it (cancelled ones aside). */ + total!: number; + finished!: number; + /** Half of the simulated futures finish by then (null: no pace yet). */ + likelyDate!: string | null; + /** 85% of them do. */ + safeDate!: string | null; + /** + * DONE: nothing left. NO_DATE: no target to compare. UNKNOWN: nothing + * finished lately to measure a pace. ON_TRACK: 85% in time. AT_RISK: half + * in time. LATE: less than half. + */ + verdict!: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE'; +} + +/** What has happened since a moment (the viewer's last visit). */ +export class ChangesSinceReadModel { + since!: string; + created!: InsightIssueReadModel[]; + createdCount!: number; + finished!: InsightIssueReadModel[]; + finishedCount!: number; + /** Sent back from QA. */ + returned!: InsightIssueReadModel[]; + returnedCount!: number; + /** Times the viewer was mentioned in this project. */ + mentions!: number; +} + +/** One person's open work against what they usually close. */ +export class TeamLoadReadModel { + user!: UserRefReadModel; + openTickets!: number; + openPoints!: number; + /** Their open tickets waiting for another open one. */ + blocked!: number; + /** Points they closed per sprint, over the last three closed sprints. */ + pointsPerSprint!: number | null; + /** More open points than they usually close in a sprint. */ + overloaded!: boolean; +} + +/** Open tickets missing what planning needs. */ +export class BacklogHealthReadModel { + open!: number; + unestimated!: number; + noArea!: number; + unassigned!: number; + /** Not updated in `staleDays` days. */ + stale!: number; + staleDays!: number; +} + /** What the project dashboard reads beyond the counts. */ export class ProjectInsightsReadModel { generatedAt!: string; @@ -78,4 +136,11 @@ export class ProjectInsightsReadModel { aging!: AgingReadModel; /** In-progress and QA statuses, in board order. */ bottlenecks!: StatusTimeReadModel[]; + /** Planned releases, by target date. */ + releases!: ReleaseForecastReadModel[]; + /** Null unless asked with a `since`. */ + changes!: ChangesSinceReadModel | null; + /** Members (and others with open tickets), busiest first. */ + team!: TeamLoadReadModel[]; + backlog!: BacklogHealthReadModel; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 5da81c9..246268a 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -450,6 +450,9 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { ...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}), }; // Only field ids: a key never becomes an operator. + if (filter.noEstimate) query['storyPoints'] = null; + if (filter.noArea) query['area'] = null; + if (filter.updatedBefore) query['updatedAt'] = { $lt: filter.updatedBefore }; for (const [field, value] of Object.entries(filter.customFields ?? {})) if (/^[A-Z][A-Z0-9_]{0,29}$/.test(field) && typeof value === 'string') query[`customFields.${field}`] = value; diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index ead9379..d4b2c5e 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -568,6 +568,21 @@ export class IssueFilterInput { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) dueTo?: string; + @Field({ nullable: true, description: 'Only tickets nobody estimated' }) + @IsOptional() + @IsBoolean() + noEstimate?: boolean; + @Field({ nullable: true, description: 'Only tickets with no area' }) + @IsOptional() + @IsBoolean() + noArea?: boolean; + @Field({ + nullable: true, + description: 'Only tickets not updated since this instant (ISO)', + }) + @IsOptional() + @IsISO8601() + updatedBefore?: string; @Field(() => [CustomFieldValueInput], { nullable: true, description: 'Only tickets holding these custom values', 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 f528268..f89c7ff 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -91,8 +91,20 @@ export class ProjectResolver { insights( @CurrentUser() me: User | undefined, @Parent() project: ProjectReadModel, + @Args('since', { + type: () => String, + nullable: true, + description: + 'ISO instant (e.g. my last visit): also tell what changed after it', + }) + since?: string | null, ) { - return this.projects.insights(viewerOf(me), project.id); + const after = since ? new Date(since) : null; + return this.projects.insights( + viewerOf(me), + project.id, + after && !Number.isNaN(after.getTime()) ? after : null, + ); } @ResolveField(() => [ReleaseGql]) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 860e4ef..92b000f 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -867,8 +867,70 @@ export class StatusTimeGql { @Field() overLimit!: boolean; } +@ObjectType('ReleaseForecast', { + description: 'When a planned release would be finished, at the real pace', +}) +export class ReleaseForecastGql { + @Field(() => ID) releaseId!: string; + @Field() name!: string; + @Field(() => String, { nullable: true }) targetDate!: string | null; + @Field(() => Int) total!: number; + @Field(() => Int) finished!: number; + @Field(() => String, { + nullable: true, + description: 'Half of the simulated futures finish by then', + }) + likelyDate!: string | null; + @Field(() => String, { nullable: true, description: '85% finish by then' }) + safeDate!: string | null; + @Field({ + description: + 'DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE', + }) + verdict!: string; +} + +@ObjectType('ChangesSince', { description: 'What happened after a moment' }) +export class ChangesSinceGql { + @Field() since!: string; + @Field(() => [InsightIssueGql]) created!: InsightIssueGql[]; + @Field(() => Int) createdCount!: number; + @Field(() => [InsightIssueGql]) finished!: InsightIssueGql[]; + @Field(() => Int) finishedCount!: number; + @Field(() => [InsightIssueGql], { description: 'Sent back from QA' }) + returned!: InsightIssueGql[]; + @Field(() => Int) returnedCount!: number; + @Field(() => Int, { description: 'Times I was mentioned here' }) + mentions!: number; +} + +@ObjectType('TeamLoad') +export class TeamLoadGql { + @Field(() => UserRefGql) user!: UserRefGql; + @Field(() => Int) openTickets!: number; + @Field(() => Int) openPoints!: number; + @Field(() => Int) blocked!: number; + @Field(() => Float, { + nullable: true, + description: 'Points closed per sprint, last three closed sprints', + }) + pointsPerSprint!: number | null; + @Field() overloaded!: boolean; +} + +@ObjectType('BacklogHealth') +export class BacklogHealthGql { + @Field(() => Int) open!: number; + @Field(() => Int) unestimated!: number; + @Field(() => Int) noArea!: number; + @Field(() => Int) unassigned!: number; + @Field(() => Int) stale!: number; + @Field(() => Int) staleDays!: number; +} + @ObjectType('ProjectInsights', { - description: 'The dashboard: sprint under way, risks, aging and bottlenecks', + description: + 'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes', }) export class ProjectInsightsGql { @Field() generatedAt!: string; @@ -877,4 +939,12 @@ export class ProjectInsightsGql { @Field(() => RiskGql) risks!: RiskGql; @Field(() => AgingGql) aging!: AgingGql; @Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[]; + @Field(() => [ReleaseForecastGql]) releases!: ReleaseForecastGql[]; + @Field(() => ChangesSinceGql, { + nullable: true, + description: 'Only when asked with `since`', + }) + changes!: ChangesSinceGql | null; + @Field(() => [TeamLoadGql]) team!: TeamLoadGql[]; + @Field(() => BacklogHealthGql) backlog!: BacklogHealthGql; } diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index ae37385..8a00608 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -330,6 +330,17 @@ export class AnalyticsQueryDto { days = 90; } +export class InsightsQueryDto { + @ApiPropertyOptional({ + description: + 'ISO instant (e.g. your last visit): the answer also tells what changed after it', + example: '2026-10-14T08:00:00Z', + }) + @IsOptional() + @IsISO8601() + since?: string; +} + export class ChangeMemberRoleDto { @ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' }) @IsEnum(ProjectRole) @@ -609,6 +620,25 @@ export class IssueSearchQueryDto extends PageQueryDto { @Matches(/^\d{4}-\d{2}-\d{2}$/) dueTo?: string; + @ApiPropertyOptional({ description: 'Only tickets nobody estimated' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + noEstimate?: boolean; + + @ApiPropertyOptional({ description: 'Only tickets with no area' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + noArea?: boolean; + + @ApiPropertyOptional({ + description: 'Only tickets not updated since this instant (ISO)', + }) + @IsOptional() + @IsISO8601() + updatedBefore?: string; + @ApiPropertyOptional({ type: [String], example: ['BROWSER:Safari'], 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 07d82b1..856bb70 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 @@ -943,6 +943,69 @@ export class StatusTimeDto { @ApiProperty() overLimit!: boolean; } +export class ReleaseForecastDto { + @ApiProperty({ format: 'uuid' }) releaseId!: string; + @ApiProperty() name!: string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + targetDate!: string | null; + @ApiProperty() total!: number; + @ApiProperty() finished!: number; + @ApiProperty({ + type: String, + nullable: true, + format: 'date', + description: 'Half of the simulated futures finish by then', + }) + likelyDate!: string | null; + @ApiProperty({ + type: String, + nullable: true, + format: 'date', + description: '85% finish by then', + }) + safeDate!: string | null; + @ApiProperty({ + enum: ['DONE', 'NO_DATE', 'UNKNOWN', 'ON_TRACK', 'AT_RISK', 'LATE'], + }) + verdict!: string; +} + +export class ChangesSinceDto { + @ApiProperty() since!: string; + @ApiProperty({ type: [InsightIssueDto] }) created!: InsightIssueDto[]; + @ApiProperty() createdCount!: number; + @ApiProperty({ type: [InsightIssueDto] }) finished!: InsightIssueDto[]; + @ApiProperty() finishedCount!: number; + @ApiProperty({ type: [InsightIssueDto], description: 'Sent back from QA' }) + returned!: InsightIssueDto[]; + @ApiProperty() returnedCount!: number; + @ApiProperty({ description: 'Times you were mentioned here' }) + mentions!: number; +} + +export class TeamLoadDto { + @ApiProperty({ type: UserRefDto }) user!: UserRefDto; + @ApiProperty() openTickets!: number; + @ApiProperty() openPoints!: number; + @ApiProperty() blocked!: number; + @ApiProperty({ + type: Number, + nullable: true, + description: 'Points closed per sprint, last three closed sprints', + }) + pointsPerSprint!: number | null; + @ApiProperty() overloaded!: boolean; +} + +export class BacklogHealthDto { + @ApiProperty() open!: number; + @ApiProperty() unestimated!: number; + @ApiProperty() noArea!: number; + @ApiProperty() unassigned!: number; + @ApiProperty() stale!: number; + @ApiProperty() staleDays!: number; +} + export class ProjectInsightsDto { @ApiProperty() generatedAt!: string; @ApiProperty({ type: SprintPulseDto, nullable: true }) @@ -950,4 +1013,13 @@ export class ProjectInsightsDto { @ApiProperty({ type: RisksDto }) risks!: RisksDto; @ApiProperty({ type: AgingDto }) aging!: AgingDto; @ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[]; + @ApiProperty({ type: [ReleaseForecastDto] }) releases!: ReleaseForecastDto[]; + @ApiProperty({ + type: ChangesSinceDto, + nullable: true, + description: 'Only when asked with `since`', + }) + changes!: ChangesSinceDto | null; + @ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[]; + @ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto; } diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 5899547..b77402f 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -171,6 +171,9 @@ export class IssuesController { watching: query.watching, dueFrom: query.dueFrom, dueTo: query.dueTo, + noEstimate: query.noEstimate, + noArea: query.noArea, + updatedBefore: query.updatedBefore, customFields: query.field?.length ? Object.fromEntries( query.field.map((f) => [ 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 aef4dde..24d2097 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -35,6 +35,7 @@ import { ActivityQueryDto, AddMemberDto, AnalyticsQueryDto, + InsightsQueryDto, BoardQueryDto, ChangeMemberRoleDto, CreateProjectDto, @@ -257,10 +258,12 @@ export class ProjectsController { async insights( @CurrentUser() me: User | undefined, @Param('project') project: string, + @Query() query: InsightsQueryDto, ) { return this.projects.insights( viewerOf(me), await this.refs.projectId(viewerOf(me), project), + query.since ? new Date(query.since) : null, ); } diff --git a/libs/web/tracker/application/src/lib/events/insights.events.ts b/libs/web/tracker/application/src/lib/events/insights.events.ts index e1524d1..4824155 100644 --- a/libs/web/tracker/application/src/lib/events/insights.events.ts +++ b/libs/web/tracker/application/src/lib/events/insights.events.ts @@ -5,7 +5,8 @@ import { ProjectInsights } from '@task-stream/web/tracker/domain'; export const insightsPageEvents = eventGroup({ source: 'Dashboard Page', events: { - opened: type<{ projectId: string }>(), + /** `since`: my last visit, to tell what changed after it. */ + opened: type<{ projectId: string; since: string | null }>(), }, }); diff --git a/libs/web/tracker/application/src/lib/facades/insights.facade.ts b/libs/web/tracker/application/src/lib/facades/insights.facade.ts index 0f5dbf4..47c308c 100644 --- a/libs/web/tracker/application/src/lib/facades/insights.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/insights.facade.ts @@ -14,9 +14,12 @@ export class InsightsFacade { 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 }); + /** + * Loads the project's reading and keeps it up to date; with `since` (my + * last visit), it also tells what changed after it. + */ + open(projectId: string, since: string | null = null): void { + this.dispatch.opened({ projectId, since }); } } diff --git a/libs/web/tracker/application/src/lib/stores/insights.store.ts b/libs/web/tracker/application/src/lib/stores/insights.store.ts index 0ef8d73..75ac597 100644 --- a/libs/web/tracker/application/src/lib/stores/insights.store.ts +++ b/libs/web/tracker/application/src/lib/stores/insights.store.ts @@ -58,7 +58,7 @@ export const InsightsStore = signalStore( debounceTime(800), startWith(null), switchMap(() => - projects.insights(payload.projectId).pipe( + projects.insights(payload.projectId, payload.since).pipe( mapResponse({ next: (insights) => insightsApiEvents.loaded(insights), error: (error) => diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index c631070..0dfb847 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -103,6 +103,11 @@ export interface IssueListFilter { unassigned?: boolean; releaseId?: string; sprintId?: string; + /** Only tickets nobody estimated / with no area. */ + noEstimate?: boolean; + noArea?: boolean; + /** Only tickets not updated since this instant (ISO). */ + updatedBefore?: string; /** Only tickets holding these custom values. */ customFields?: CustomFieldValue[]; limit: number; diff --git a/libs/web/tracker/domain/src/lib/models/insights.ts b/libs/web/tracker/domain/src/lib/models/insights.ts index cc0d55e..d007deb 100644 --- a/libs/web/tracker/domain/src/lib/models/insights.ts +++ b/libs/web/tracker/domain/src/lib/models/insights.ts @@ -55,6 +55,53 @@ export interface StatusTime { overLimit: boolean; } +/** When a planned release would be finished, at the team's real pace. */ +export interface ReleaseForecast { + releaseId: string; + name: string; + targetDate: string | null; + total: number; + finished: number; + /** Half of the simulated futures finish by then. */ + likelyDate: string | null; + /** 85% of them do. */ + safeDate: string | null; + verdict: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE'; +} + +/** What happened after a moment (my last visit). */ +export interface ChangesSince { + since: string; + created: InsightIssue[]; + createdCount: number; + finished: InsightIssue[]; + finishedCount: number; + /** Sent back from QA. */ + returned: InsightIssue[]; + returnedCount: number; + mentions: number; +} + +/** One person's open work against what they usually close per sprint. */ +export interface TeamLoad { + user: UserRef; + openTickets: number; + openPoints: number; + blocked: number; + pointsPerSprint: number | null; + overloaded: boolean; +} + +/** Open tickets missing what planning needs. */ +export interface BacklogHealth { + open: number; + unestimated: number; + noArea: number; + unassigned: number; + stale: number; + staleDays: number; +} + /** The dashboard's reading of a project beyond the counts. */ export interface ProjectInsights { generatedAt: string; @@ -62,4 +109,9 @@ export interface ProjectInsights { risks: Risks; aging: { issues: InsightIssue[]; thresholdDays: number | null }; bottlenecks: StatusTime[]; + releases: ReleaseForecast[]; + /** Only when asked with a `since`. */ + changes: ChangesSince | null; + team: TeamLoad[]; + backlog: BacklogHealth; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 12d366d..9f46857 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -64,8 +64,11 @@ 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; + /** The dashboard; with `since`, also what changed after it. */ + abstract insights( + projectId: string, + since: string | null, + ): 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 bb1b38e..8121b62 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 @@ -63,10 +63,13 @@ export class GraphqlProjectsGateway extends ProjectsGateway { .pipe(map((d) => toStats(d.project.stats))); } - insights(projectId: string): Observable { + insights( + projectId: string, + since: string | null, + ): Observable { return this.gql - .query(ProjectInsightsDocument, { idOrKey: projectId }) - .pipe(map((d) => d.project.insights)); + .query(ProjectInsightsDocument, { idOrKey: projectId, since }) + .pipe(map((d) => d.project.insights as ProjectInsights)); } analytics(projectId: string, days: number): Observable { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 2de7070..25ac59e 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -178,6 +178,10 @@ export type IssueFilterInput = { /** Due on or before this day (YYYY-MM-DD) */ dueTo?: string | null | undefined; labels?: Array | null | undefined; + /** Only tickets with no area */ + noArea?: boolean | null | undefined; + /** Only tickets nobody estimated */ + noEstimate?: boolean | null | undefined; /** Only tickets in no sprint */ noSprint?: boolean | null | undefined; /** Only the tickets hanging from this one */ @@ -192,6 +196,8 @@ export type IssueFilterInput = { statuses?: Array | null | undefined; types?: Array | null | undefined; unassigned?: boolean | null | undefined; + /** Only tickets not updated since this instant (ISO) */ + updatedBefore?: string | null | undefined; /** Only the tickets I follow (none when anonymous) */ watching?: boolean | null | undefined; }; @@ -1769,6 +1775,7 @@ export type InsightIssueFieldsFragment = { export type ProjectInsightsQueryVariables = Exact<{ idOrKey: string; + since?: string | null | undefined; }>; export type ProjectInsightsQuery = { @@ -1854,6 +1861,66 @@ export type ProjectInsightsQuery = { wipLimit: number | null; overLimit: boolean; }>; + releases: Array<{ + releaseId: string; + name: string; + targetDate: string | null; + total: number; + finished: number; + likelyDate: string | null; + safeDate: string | null; + verdict: string; + }>; + changes: { + since: string; + createdCount: number; + finishedCount: number; + returnedCount: number; + mentions: number; + created: Array<{ + id: string; + key: string; + title: string; + status: string; + days: number; + dueDate: string | null; + assignee: { id: string; name: string } | null; + }>; + finished: Array<{ + id: string; + key: string; + title: string; + status: string; + days: number; + dueDate: string | null; + assignee: { id: string; name: string } | null; + }>; + returned: Array<{ + id: string; + key: string; + title: string; + status: string; + days: number; + dueDate: string | null; + assignee: { id: string; name: string } | null; + }>; + } | null; + team: Array<{ + openTickets: number; + openPoints: number; + blocked: number; + pointsPerSprint: number | null; + overloaded: boolean; + user: { id: string; name: string }; + }>; + backlog: { + open: number; + unestimated: number; + noArea: number; + unassigned: number; + stale: number; + staleDays: number; + }; }; }; }; @@ -10928,6 +10995,14 @@ export const ProjectInsightsDocument = { }, }, }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'since' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -10952,6 +11027,16 @@ export const ProjectInsightsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'insights' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'since' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'since' }, + }, + }, + ], selectionSet: { kind: 'SelectionSet', selections: [ @@ -11175,6 +11260,202 @@ export const ProjectInsightsDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'releases' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'releaseId' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'targetDate' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'total' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'finished' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'likelyDate' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'safeDate' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'verdict' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'changes' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'since' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'created' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: 'InsightIssueFields', + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdCount' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'finished' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: 'InsightIssueFields', + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'finishedCount' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'returned' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: 'InsightIssueFields', + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'returnedCount' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'mentions' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'team' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: 'UserRefFields', + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'openTickets' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'openPoints' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blocked' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'pointsPerSprint' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'overloaded' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'backlog' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'open' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'unestimated' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'noArea' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'unassigned' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'stale' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'staleDays' }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index 6aeb196..2604225 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -157,10 +157,10 @@ fragment InsightIssueFields on InsightIssue { dueDate } -query ProjectInsights($idOrKey: String!) { +query ProjectInsights($idOrKey: String!, $since: String) { project(idOrKey: $idOrKey) { id - insights { + insights(since: $since) { generatedAt sprint { sprintId @@ -210,6 +210,50 @@ query ProjectInsights($idOrKey: String!) { wipLimit overLimit } + releases { + releaseId + name + targetDate + total + finished + likelyDate + safeDate + verdict + } + changes { + since + created { + ...InsightIssueFields + } + createdCount + finished { + ...InsightIssueFields + } + finishedCount + returned { + ...InsightIssueFields + } + returnedCount + mentions + } + team { + user { + ...UserRefFields + } + openTickets + openPoints + blocked + pointsPerSprint + overloaded + } + backlog { + open + unestimated + noArea + unassigned + stale + staleDays + } } } } 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 dc3616a..e84d0d7 100644 --- a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts @@ -21,12 +21,17 @@ import { ProjectRole, QA_FAILED, StatusCategory, + TeamLoad, } 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'; +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'; /** "Seguimiento": the state of the whole project at a glance (live). */ @Component({ @@ -41,6 +46,9 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse'; RisksComponent, AgingComponent, BottlenecksComponent, + ReleaseForecastComponent, + ChangesSinceComponent, + BacklogHealthComponent, ], providers: [provideInsightsFacade()], template: ` @@ -102,10 +110,16 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse'; /> } @else if (insights.insights(); as reading) {
+ @if (reading.changes; as changes) { + + } @if (reading.sprint; as sprint) { } + @if (reading.releases.length) { + + } +
} @else { @@ -155,6 +170,12 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse'; } @else { Ya no es miembro } + @if (loadOf().get(person.id); as load) { + @if (load.overloaded) { + Sobrecarga + } + {{ loadText(load) }} + }