feat: dashboard maps the activity, follows a ticket's way and the proposals
Three more blocks on the project dashboard, also by REST, GraphQL and MCP: - Activity: what moved each day of the last 12 weeks, in a heatmap, with the busiest weekday and the longest lull. - A ticket's way: for the tickets done in the last 90 days, where the time from created to done went, status by status (waiting included), and how much of it was work (flow efficiency). - The public's proposals: received, waiting, under way, done and discarded, and the most voted still unanswered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1415,6 +1415,16 @@ describe('task-stream (e2e)', () => {
|
||||
);
|
||||
expect(project.insights.risks.overdueCount).toBe(1);
|
||||
|
||||
// Activity per day (today's creations among it), the journey, no proposals.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(insights.body['heatmap']).toHaveLength(84);
|
||||
expect(
|
||||
insights.body['heatmap'].find((d: { day: string }) => d.day === today)
|
||||
?.count,
|
||||
).toBeGreaterThan(0);
|
||||
expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] });
|
||||
expect(insights.body['proposals']).toBeNull();
|
||||
|
||||
// What changed since a moment, and what the backlog lacks.
|
||||
const since = new Date(Date.now() - 60_000).toISOString();
|
||||
const later = await rest(
|
||||
|
||||
@@ -13403,6 +13403,142 @@
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"ActivityDayDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"day": {
|
||||
"type": "string",
|
||||
"example": "2026-10-15"
|
||||
},
|
||||
"count": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"day",
|
||||
"count"
|
||||
]
|
||||
},
|
||||
"JourneyStepDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Status id of the project settings"
|
||||
},
|
||||
"days": {
|
||||
"type": "number",
|
||||
"description": "Average days a closed ticket spent in it"
|
||||
},
|
||||
"share": {
|
||||
"type": "number",
|
||||
"description": "Its part of the whole lead time, 0-100"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"days",
|
||||
"share"
|
||||
]
|
||||
},
|
||||
"JourneyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"samples": {
|
||||
"type": "number",
|
||||
"description": "Tickets done in the last 90 days"
|
||||
},
|
||||
"leadDays": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"flowEfficiency": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Part of the lead time in an in-progress status, 0-100"
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/JourneyStepDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"samples",
|
||||
"leadDays",
|
||||
"flowEfficiency",
|
||||
"steps"
|
||||
]
|
||||
},
|
||||
"WaitingProposalDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"votes": {
|
||||
"type": "number"
|
||||
},
|
||||
"days": {
|
||||
"type": "number",
|
||||
"description": "Days since it came in"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"key",
|
||||
"title",
|
||||
"votes",
|
||||
"days"
|
||||
]
|
||||
},
|
||||
"ProposalsFunnelDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"received": {
|
||||
"type": "number"
|
||||
},
|
||||
"receivedLately": {
|
||||
"type": "number",
|
||||
"description": "In the last 30 days"
|
||||
},
|
||||
"waiting": {
|
||||
"type": "number",
|
||||
"description": "Open, not started, in no sprint nor release"
|
||||
},
|
||||
"underWay": {
|
||||
"type": "number"
|
||||
},
|
||||
"done": {
|
||||
"type": "number"
|
||||
},
|
||||
"discarded": {
|
||||
"type": "number"
|
||||
},
|
||||
"topWaiting": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WaitingProposalDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"received",
|
||||
"receivedLately",
|
||||
"waiting",
|
||||
"underWay",
|
||||
"done",
|
||||
"discarded",
|
||||
"topWaiting"
|
||||
]
|
||||
},
|
||||
"ProjectInsightsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13464,6 +13600,26 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FieldBreakdownDto"
|
||||
}
|
||||
},
|
||||
"heatmap": {
|
||||
"description": "The last 12 weeks from a Monday, oldest first (84 days)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ActivityDayDto"
|
||||
}
|
||||
},
|
||||
"journey": {
|
||||
"$ref": "#/components/schemas/JourneyDto"
|
||||
},
|
||||
"proposals": {
|
||||
"nullable": true,
|
||||
"description": "Null while the project has never had a proposal",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProposalsFunnelDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -13477,7 +13633,10 @@
|
||||
"team",
|
||||
"backlog",
|
||||
"quality",
|
||||
"fields"
|
||||
"fields",
|
||||
"heatmap",
|
||||
"journey",
|
||||
"proposals"
|
||||
]
|
||||
},
|
||||
"ActivityDto": {
|
||||
|
||||
@@ -13,6 +13,14 @@ type Activity {
|
||||
type: String!
|
||||
}
|
||||
|
||||
"""How much happened on one day"""
|
||||
type ActivityDay {
|
||||
count: Int!
|
||||
|
||||
"""YYYY-MM-DD"""
|
||||
day: String!
|
||||
}
|
||||
|
||||
input AddChecklistItemInput {
|
||||
issueId: ID!
|
||||
text: String!
|
||||
@@ -815,6 +823,28 @@ input IssueTypeOptionInput {
|
||||
template: IssueTemplateInput
|
||||
}
|
||||
|
||||
"""
|
||||
Where the time of the tickets done in the last 90 days went, created to done
|
||||
"""
|
||||
type Journey {
|
||||
"""Part of the lead time in an in-progress status, 0-100"""
|
||||
flowEfficiency: Int
|
||||
leadDays: Float
|
||||
samples: Int!
|
||||
steps: [JourneyStep!]!
|
||||
}
|
||||
|
||||
type JourneyStep {
|
||||
"""Average days a closed ticket spent in it"""
|
||||
days: Float!
|
||||
|
||||
"""Its part of the whole lead time, 0-100"""
|
||||
share: Int!
|
||||
|
||||
"""Status id of the project settings"""
|
||||
status: String!
|
||||
}
|
||||
|
||||
enum LinkDirection {
|
||||
INWARD
|
||||
OUTWARD
|
||||
@@ -1283,6 +1313,13 @@ type ProjectInsights {
|
||||
"""One per active list custom field"""
|
||||
fields: [FieldBreakdown!]!
|
||||
generatedAt: String!
|
||||
|
||||
"""The last 12 weeks from a Monday, oldest first (84 days)"""
|
||||
heatmap: [ActivityDay!]!
|
||||
journey: Journey!
|
||||
|
||||
"""Null while the project has never had a proposal"""
|
||||
proposals: ProposalsFunnel
|
||||
quality: Quality!
|
||||
releases: [ReleaseForecast!]!
|
||||
risks: Risks!
|
||||
@@ -1361,6 +1398,23 @@ type ProjectStats {
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""The public's proposals, from received to done or discarded"""
|
||||
type ProposalsFunnel {
|
||||
discarded: Int!
|
||||
done: Int!
|
||||
received: Int!
|
||||
|
||||
"""In the last 30 days"""
|
||||
receivedLately: Int!
|
||||
|
||||
"""Most voted waiting, at most 5"""
|
||||
topWaiting: [WaitingProposal!]!
|
||||
underWay: Int!
|
||||
|
||||
"""Open, not started, in no sprint nor release"""
|
||||
waiting: Int!
|
||||
}
|
||||
|
||||
"""A browser's Web Push subscription (PushSubscription.toJSON())"""
|
||||
input PushSubscriptionInput {
|
||||
auth: String!
|
||||
@@ -1914,6 +1968,15 @@ type UserRef {
|
||||
name: String!
|
||||
}
|
||||
|
||||
type WaitingProposal {
|
||||
"""Days since it came in"""
|
||||
days: Int!
|
||||
id: ID!
|
||||
key: String!
|
||||
title: String!
|
||||
votes: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Where the project tells another system what happened (signed POST, HMAC-SHA256)
|
||||
"""
|
||||
|
||||
@@ -2441,6 +2441,46 @@ export interface components {
|
||||
/** @description Open tickets with no value */
|
||||
none: number;
|
||||
};
|
||||
ActivityDayDto: {
|
||||
/** @example 2026-10-15 */
|
||||
day: string;
|
||||
count: number;
|
||||
};
|
||||
JourneyStepDto: {
|
||||
/** @description Status id of the project settings */
|
||||
status: string;
|
||||
/** @description Average days a closed ticket spent in it */
|
||||
days: number;
|
||||
/** @description Its part of the whole lead time, 0-100 */
|
||||
share: number;
|
||||
};
|
||||
JourneyDto: {
|
||||
/** @description Tickets done in the last 90 days */
|
||||
samples: number;
|
||||
leadDays: number | null;
|
||||
/** @description Part of the lead time in an in-progress status, 0-100 */
|
||||
flowEfficiency: number | null;
|
||||
steps: components["schemas"]["JourneyStepDto"][];
|
||||
};
|
||||
WaitingProposalDto: {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
votes: number;
|
||||
/** @description Days since it came in */
|
||||
days: number;
|
||||
};
|
||||
ProposalsFunnelDto: {
|
||||
received: number;
|
||||
/** @description In the last 30 days */
|
||||
receivedLately: number;
|
||||
/** @description Open, not started, in no sprint nor release */
|
||||
waiting: number;
|
||||
underWay: number;
|
||||
done: number;
|
||||
discarded: number;
|
||||
topWaiting: components["schemas"]["WaitingProposalDto"][];
|
||||
};
|
||||
ProjectInsightsDto: {
|
||||
generatedAt: string;
|
||||
sprint: components["schemas"]["SprintPulseDto"] | null;
|
||||
@@ -2455,6 +2495,11 @@ export interface components {
|
||||
quality: components["schemas"]["QualityDto"];
|
||||
/** @description One per active list custom field */
|
||||
fields: components["schemas"]["FieldBreakdownDto"][];
|
||||
/** @description The last 12 weeks from a Monday, oldest first (84 days) */
|
||||
heatmap: components["schemas"]["ActivityDayDto"][];
|
||||
journey: components["schemas"]["JourneyDto"];
|
||||
/** @description Null while the project has never had a proposal */
|
||||
proposals: components["schemas"]["ProposalsFunnelDto"] | null;
|
||||
};
|
||||
ActivityDto: {
|
||||
id: string;
|
||||
|
||||
@@ -253,7 +253,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, 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.",
|
||||
"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) open tickets per option of each list custom field, activity per day over the last 12 weeks, where the time of the tickets done lately went from created to done (days and share per status, and the part spent at work: flow efficiency) and, if the public has proposed anything, the proposals funnel (received, waiting, under way, done, discarded, most voted waiting). With since, also what changed after it.",
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
since: z
|
||||
|
||||
@@ -452,6 +452,9 @@ decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO
|
||||
| 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 |
|
||||
| Actividad | Movimientos por día (todo lo de la actividad) de las últimas 12 semanas, desde un lunes: 84 días, en `heatmap` |
|
||||
| Recorrido | De los tickets terminados en 90 días: días de media de creado a hecho, días y parte del total en cada estado por el que pasaron (también la espera en Backlog o Por hacer) y `flowEfficiency`, la parte en un estado «en curso» |
|
||||
| Propuestas | Si el público ha propuesto algo (tickets con la etiqueta «propuesta»): recibidas (y en 30 días), esperando (sin empezar ni planificar), en marcha, hechas y descartadas, y las 5 más votadas que esperan; si no, `null` |
|
||||
|
||||
## 3.2 GraphQL
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ 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: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar». Debajo, mis proyectos con su actividad |
|
||||
@@ -308,7 +308,7 @@ visualización de la plataforma:
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, 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/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, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor, el embudo de propuestas del público (si las hay), 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), con un menú de secciones (`?section=`): General (visibilidad, propuestas), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
|
||||
@@ -35,7 +35,7 @@ flowchart LR
|
||||
60 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) |
|
||||
@@ -50,7 +50,7 @@ flowchart LR
|
||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||
| | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas |
|
||||
| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger |
|
||||
| | `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_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas 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); también por prioridad, solo bugs o vencidos |
|
||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||
|
||||
@@ -125,3 +125,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora,
|
||||
la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad,
|
||||
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).
|
||||
- Panel del proyecto, cuarta parte: actividad de 12 semanas en un mapa de calor, el recorrido de un ticket (dónde se
|
||||
va el tiempo de creado a hecho, espera incluida, y la parte que es trabajo) y el embudo de propuestas del público
|
||||
con las más votadas sin respuesta.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,7 @@ import {
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeInsights,
|
||||
HEATMAP_DAYS,
|
||||
InsightsInput,
|
||||
simulateDays,
|
||||
STALE_DAYS,
|
||||
@@ -392,4 +393,76 @@ describe('computeInsights', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps 12 weeks of activity, the whole way of done tickets and the proposals', () => {
|
||||
const proposal = { labels: ['propuesta'] };
|
||||
const issues = [
|
||||
// Done: 2 days waiting in TODO, 3 at work, 1 in QA.
|
||||
issue('A', 'DONE', C.DONE, { createdAt: '2026-10-01T10:00:00Z' }),
|
||||
issue('P1', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 2,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P2', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 5,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P3', 'TODO', C.TODO, { ...proposal, ...inSprint }),
|
||||
issue('P4', 'DONE', C.DONE, proposal),
|
||||
issue('P5', 'CANCELLED', C.CANCELLED, proposal),
|
||||
];
|
||||
const timeline = [
|
||||
step('A', '2026-10-01T10:00:00Z', 'TODO', C.TODO),
|
||||
step('A', '2026-10-03T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
step('A', '2026-10-06T10:00:00Z', 'IN_QA', C.QA),
|
||||
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const insights = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
activity: [
|
||||
{ day: '2026-10-15', count: 3 },
|
||||
{ day: '2026-10-14', count: 1 },
|
||||
{ day: '2026-01-01', count: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.heatmap).toHaveLength(HEATMAP_DAYS);
|
||||
// From the Monday 11 weeks back to this week's Sunday.
|
||||
expect(insights.heatmap[0].day).toBe('2026-07-27');
|
||||
expect(insights.heatmap.at(-1)?.day).toBe('2026-10-18');
|
||||
expect(insights.heatmap.find((d) => d.day === '2026-10-15')?.count).toBe(3);
|
||||
|
||||
expect(insights.journey).toEqual({
|
||||
samples: 1,
|
||||
leadDays: 6,
|
||||
flowEfficiency: 50,
|
||||
steps: [
|
||||
{ status: 'TODO', days: 2, share: 33 },
|
||||
{ status: 'IN_PROGRESS', days: 3, share: 50 },
|
||||
{ status: 'IN_QA', days: 1, share: 17 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.proposals).toMatchObject({
|
||||
received: 5,
|
||||
waiting: 2,
|
||||
underWay: 1,
|
||||
done: 1,
|
||||
discarded: 1,
|
||||
});
|
||||
expect(insights.proposals?.topWaiting.map((p) => [p.key, p.votes])).toEqual(
|
||||
[
|
||||
['P2', 5],
|
||||
['P1', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('has no proposals block while nobody has proposed anything', () => {
|
||||
expect(
|
||||
computeInsights({ issues: [], timeline: [], ...BASE }).proposals,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
ActivityDayReadModel,
|
||||
BacklogHealthReadModel,
|
||||
JourneyReadModel,
|
||||
ProposalsFunnelReadModel,
|
||||
CustomFieldType,
|
||||
FieldBreakdownReadModel,
|
||||
IssuePriority,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
stepsByIssue,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
|
||||
/*
|
||||
* The dashboard's reading of a project: what needs attention now (risks),
|
||||
@@ -61,6 +65,8 @@ export interface InsightsInput {
|
||||
since: Date | null;
|
||||
/** Times the viewer was mentioned in the project since then. */
|
||||
mentions: number;
|
||||
/** Activity events per day (`YYYY-MM-DD`), the last 12 weeks or more. */
|
||||
activity?: { day: string; count: number }[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
@@ -72,6 +78,8 @@ const utcDay = (time: number) =>
|
||||
);
|
||||
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
/** Hundredths of a day (about a quarter of an hour): short stays still show. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export function computeInsights(
|
||||
input: InsightsInput,
|
||||
@@ -180,6 +188,127 @@ export function computeInsights(
|
||||
backlog: backlogHealth(open, now),
|
||||
quality: quality(input.settings, live, steps, input.releases, now),
|
||||
fields: fieldBreakdown(input.settings, open),
|
||||
heatmap: heatmap(input.activity ?? [], now),
|
||||
journey: journey(input.settings, live, steps, now),
|
||||
proposals: proposalsFunnel(live, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Days shown by the heatmap: 12 weeks. */
|
||||
export const HEATMAP_DAYS = 84;
|
||||
|
||||
/** The first day of the heatmap: the Monday 11 weeks before this week's. */
|
||||
export function heatmapStart(now: number): string {
|
||||
return new Date(Date.parse(weekOf(now)) - 11 * 7 * DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** 12 weeks of activity, a day each, zero where nothing happened. */
|
||||
function heatmap(
|
||||
activity: { day: string; count: number }[],
|
||||
now: number,
|
||||
): ActivityDayReadModel[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of activity)
|
||||
counts.set(a.day, (counts.get(a.day) ?? 0) + a.count);
|
||||
const start = Date.parse(heatmapStart(now));
|
||||
return Array.from({ length: HEATMAP_DAYS }, (_, n) => {
|
||||
const day = new Date(start + n * DAY).toISOString().slice(0, 10);
|
||||
return { day, count: counts.get(day) ?? 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the time of a closed ticket goes, from created to done, status by
|
||||
* status (waiting included), for the tickets done in the last 90 days.
|
||||
*/
|
||||
function journey(
|
||||
settings: ProjectSettings,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): JourneyReadModel {
|
||||
const from = now - 90 * DAY;
|
||||
const spent = new Map<string, number>();
|
||||
let lead = 0;
|
||||
let working = 0;
|
||||
let samples = 0;
|
||||
for (const issue of issues) {
|
||||
if (issue.statusCategory !== StatusCategory.DONE) continue;
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
// The last arrival at a done status, from somewhere else.
|
||||
let end = -1;
|
||||
list.forEach((step, i) => {
|
||||
if (
|
||||
i > 0 &&
|
||||
step.category === StatusCategory.DONE &&
|
||||
list[i - 1].category !== StatusCategory.DONE
|
||||
)
|
||||
end = i;
|
||||
});
|
||||
if (end < 0 || list[end].at < from) continue;
|
||||
samples++;
|
||||
lead += list[end].at - list[0].at;
|
||||
for (let i = 0; i < end; i++) {
|
||||
const stay = list[i + 1].at - list[i].at;
|
||||
spent.set(list[i].status, (spent.get(list[i].status) ?? 0) + stay);
|
||||
if (list[i].category === StatusCategory.IN_PROGRESS) working += stay;
|
||||
}
|
||||
}
|
||||
const order = settings.statuses.map((s) => s.id);
|
||||
const rank = (id: string) =>
|
||||
order.includes(id) ? order.indexOf(id) : order.length;
|
||||
return {
|
||||
samples,
|
||||
leadDays: samples ? round2(lead / samples / DAY) : null,
|
||||
flowEfficiency: lead ? Math.round((working / lead) * 100) : null,
|
||||
steps: [...spent.entries()]
|
||||
.filter(([, time]) => time > 0)
|
||||
.sort(([a], [b]) => rank(a) - rank(b))
|
||||
.map(([status, time]) => ({
|
||||
status,
|
||||
days: round2(time / samples / DAY),
|
||||
share: Math.round((time / lead) * 100),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The public's proposals, from received to done or discarded. */
|
||||
function proposalsFunnel(
|
||||
issues: IssueReadModel[],
|
||||
now: number,
|
||||
): ProposalsFunnelReadModel | null {
|
||||
const proposals = issues.filter((i) =>
|
||||
(i.labels ?? []).includes(PROPOSAL_LABEL),
|
||||
);
|
||||
if (!proposals.length) return null;
|
||||
const isWaiting = (i: IssueReadModel) =>
|
||||
i.statusCategory === StatusCategory.TODO && !i.sprint && !i.release;
|
||||
const open = proposals.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
const waiting = open.filter(isWaiting);
|
||||
const age = (i: IssueReadModel) =>
|
||||
Math.floor((now - Date.parse(i.createdAt)) / DAY);
|
||||
return {
|
||||
received: proposals.length,
|
||||
receivedLately: proposals.filter((i) => age(i) < 30).length,
|
||||
waiting: waiting.length,
|
||||
underWay: open.length - waiting.length,
|
||||
done: proposals.filter((i) => i.statusCategory === StatusCategory.DONE)
|
||||
.length,
|
||||
discarded: proposals.filter(
|
||||
(i) => i.statusCategory === StatusCategory.CANCELLED,
|
||||
).length,
|
||||
topWaiting: waiting
|
||||
.sort((a, b) => (b.votes ?? 0) - (a.votes ?? 0) || age(b) - age(a))
|
||||
.slice(0, 5)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
key: i.key,
|
||||
title: i.title,
|
||||
votes: i.votes ?? 0,
|
||||
days: age(i),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
ProjectInsightsReadModel,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights } from '../analytics/insights';
|
||||
import { computeInsights, heatmapStart } from '../analytics/insights';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
@@ -27,13 +28,15 @@ export class GetProjectInsightsQueryHandler
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly notifications: NotificationsRepository,
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetProjectInsightsQuery,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline, sprints, releases, mentions] =
|
||||
const now = new Date();
|
||||
const [project, issues, timeline, sprints, releases, mentions, activity] =
|
||||
await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
@@ -41,6 +44,10 @@ export class GetProjectInsightsQueryHandler
|
||||
this.findSprints.findByProject(query.projectId),
|
||||
this.findReleases.findByProject(query.projectId),
|
||||
this.mentionsSince(query),
|
||||
this.findActivity.dailyCounts(
|
||||
[query.projectId],
|
||||
heatmapStart(now.getTime()),
|
||||
),
|
||||
]);
|
||||
return computeInsights({
|
||||
issues,
|
||||
@@ -53,7 +60,8 @@ export class GetProjectInsightsQueryHandler
|
||||
members: project?.members ?? [],
|
||||
since: query.since,
|
||||
mentions,
|
||||
now: new Date(),
|
||||
activity,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,60 @@ export class FieldBreakdownReadModel {
|
||||
none!: number;
|
||||
}
|
||||
|
||||
/** How much happened on one day (every event of the activity feed). */
|
||||
export class ActivityDayReadModel {
|
||||
/** `YYYY-MM-DD`. */
|
||||
day!: string;
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** Where a closed ticket's time goes, from created to done. */
|
||||
export class JourneyStepReadModel {
|
||||
/** Status id of the project's settings. */
|
||||
status!: string;
|
||||
/** Average days a closed ticket spent in it. */
|
||||
days!: number;
|
||||
/** Its part of the whole lead time, 0–100. */
|
||||
share!: number;
|
||||
}
|
||||
|
||||
/** The whole way of the tickets closed lately (90 days). */
|
||||
export class JourneyReadModel {
|
||||
/** Tickets measured: closed as done in the last 90 days. */
|
||||
samples!: number;
|
||||
/** Average days from created to done. */
|
||||
leadDays!: number | null;
|
||||
/** Part of that time in an in-progress status (work, not waiting), 0–100. */
|
||||
flowEfficiency!: number | null;
|
||||
/** In board order, only the statuses they went through. */
|
||||
steps!: JourneyStepReadModel[];
|
||||
}
|
||||
|
||||
/** A proposal waiting for an answer, with its votes. */
|
||||
export class WaitingProposalReadModel {
|
||||
id!: string;
|
||||
key!: string;
|
||||
title!: string;
|
||||
votes!: number;
|
||||
/** Days since it came in. */
|
||||
days!: number;
|
||||
}
|
||||
|
||||
/** The public's proposals (tickets labelled «propuesta»), from in to out. */
|
||||
export class ProposalsFunnelReadModel {
|
||||
received!: number;
|
||||
/** In the last 30 days. */
|
||||
receivedLately!: number;
|
||||
/** Open, not started, in no sprint nor release. */
|
||||
waiting!: number;
|
||||
/** Open and taken up (started, or planned in a sprint or release). */
|
||||
underWay!: number;
|
||||
done!: number;
|
||||
discarded!: number;
|
||||
/** The most voted of those waiting, at most five. */
|
||||
topWaiting!: WaitingProposalReadModel[];
|
||||
}
|
||||
|
||||
/** What the project dashboard reads beyond the counts. */
|
||||
export class ProjectInsightsReadModel {
|
||||
generatedAt!: string;
|
||||
@@ -189,6 +243,11 @@ export class ProjectInsightsReadModel {
|
||||
quality!: QualityReadModel;
|
||||
/** One per active list (SELECT) custom field. */
|
||||
fields!: FieldBreakdownReadModel[];
|
||||
/** The last 12 weeks, oldest first, starting on a Monday (84 days). */
|
||||
heatmap!: ActivityDayReadModel[];
|
||||
journey!: JourneyReadModel;
|
||||
/** Null while the project has never had a proposal. */
|
||||
proposals!: ProposalsFunnelReadModel | null;
|
||||
}
|
||||
|
||||
/** One of my projects at a glance, for the portfolio. */
|
||||
|
||||
@@ -1038,6 +1038,67 @@ export class FieldBreakdownGql {
|
||||
none!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ActivityDay', { description: 'How much happened on one day' })
|
||||
export class ActivityDayGql {
|
||||
@Field({ description: 'YYYY-MM-DD' }) day!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('JourneyStep')
|
||||
export class JourneyStepGql {
|
||||
@Field({ description: 'Status id of the project settings' }) status!: string;
|
||||
@Field(() => Float, {
|
||||
description: 'Average days a closed ticket spent in it',
|
||||
})
|
||||
days!: number;
|
||||
@Field(() => Int, { description: 'Its part of the whole lead time, 0-100' })
|
||||
share!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Journey', {
|
||||
description:
|
||||
'Where the time of the tickets done in the last 90 days went, created to done',
|
||||
})
|
||||
export class JourneyGql {
|
||||
@Field(() => Int) samples!: number;
|
||||
@Field(() => Float, { nullable: true }) leadDays!: number | null;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Part of the lead time in an in-progress status, 0-100',
|
||||
})
|
||||
flowEfficiency!: number | null;
|
||||
@Field(() => [JourneyStepGql]) steps!: JourneyStepGql[];
|
||||
}
|
||||
|
||||
@ObjectType('WaitingProposal')
|
||||
export class WaitingProposalGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() key!: string;
|
||||
@Field() title!: string;
|
||||
@Field(() => Int) votes!: number;
|
||||
@Field(() => Int, { description: 'Days since it came in' }) days!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ProposalsFunnel', {
|
||||
description: "The public's proposals, from received to done or discarded",
|
||||
})
|
||||
export class ProposalsFunnelGql {
|
||||
@Field(() => Int) received!: number;
|
||||
@Field(() => Int, { description: 'In the last 30 days' })
|
||||
receivedLately!: number;
|
||||
@Field(() => Int, {
|
||||
description: 'Open, not started, in no sprint nor release',
|
||||
})
|
||||
waiting!: number;
|
||||
@Field(() => Int) underWay!: number;
|
||||
@Field(() => Int) done!: number;
|
||||
@Field(() => Int) discarded!: number;
|
||||
@Field(() => [WaitingProposalGql], {
|
||||
description: 'Most voted waiting, at most 5',
|
||||
})
|
||||
topWaiting!: WaitingProposalGql[];
|
||||
}
|
||||
|
||||
@ObjectType('ProjectInsights', {
|
||||
description:
|
||||
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
|
||||
@@ -1062,6 +1123,16 @@ export class ProjectInsightsGql {
|
||||
description: 'One per active list custom field',
|
||||
})
|
||||
fields!: FieldBreakdownGql[];
|
||||
@Field(() => [ActivityDayGql], {
|
||||
description: 'The last 12 weeks from a Monday, oldest first (84 days)',
|
||||
})
|
||||
heatmap!: ActivityDayGql[];
|
||||
@Field(() => JourneyGql) journey!: JourneyGql;
|
||||
@Field(() => ProposalsFunnelGql, {
|
||||
nullable: true,
|
||||
description: 'Null while the project has never had a proposal',
|
||||
})
|
||||
proposals!: ProposalsFunnelGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('Presence', {
|
||||
|
||||
@@ -1117,6 +1117,53 @@ export class FieldBreakdownDto {
|
||||
@ApiProperty({ description: 'Open tickets with no value' }) none!: number;
|
||||
}
|
||||
|
||||
export class ActivityDayDto {
|
||||
@ApiProperty({ example: '2026-10-15' }) day!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class JourneyStepDto {
|
||||
@ApiProperty({ description: 'Status id of the project settings' })
|
||||
status!: string;
|
||||
@ApiProperty({ description: 'Average days a closed ticket spent in it' })
|
||||
days!: number;
|
||||
@ApiProperty({ description: 'Its part of the whole lead time, 0-100' })
|
||||
share!: number;
|
||||
}
|
||||
|
||||
export class JourneyDto {
|
||||
@ApiProperty({ description: 'Tickets done in the last 90 days' })
|
||||
samples!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) leadDays!: number | null;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Part of the lead time in an in-progress status, 0-100',
|
||||
})
|
||||
flowEfficiency!: number | null;
|
||||
@ApiProperty({ type: [JourneyStepDto] }) steps!: JourneyStepDto[];
|
||||
}
|
||||
|
||||
export class WaitingProposalDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty() votes!: number;
|
||||
@ApiProperty({ description: 'Days since it came in' }) days!: number;
|
||||
}
|
||||
|
||||
export class ProposalsFunnelDto {
|
||||
@ApiProperty() received!: number;
|
||||
@ApiProperty({ description: 'In the last 30 days' }) receivedLately!: number;
|
||||
@ApiProperty({ description: 'Open, not started, in no sprint nor release' })
|
||||
waiting!: number;
|
||||
@ApiProperty() underWay!: number;
|
||||
@ApiProperty() done!: number;
|
||||
@ApiProperty() discarded!: number;
|
||||
@ApiProperty({ type: [WaitingProposalDto] })
|
||||
topWaiting!: WaitingProposalDto[];
|
||||
}
|
||||
|
||||
export class ProjectInsightsDto {
|
||||
@ApiProperty() generatedAt!: string;
|
||||
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||
@@ -1139,6 +1186,18 @@ export class ProjectInsightsDto {
|
||||
description: 'One per active list custom field',
|
||||
})
|
||||
fields!: FieldBreakdownDto[];
|
||||
@ApiProperty({
|
||||
type: [ActivityDayDto],
|
||||
description: 'The last 12 weeks from a Monday, oldest first (84 days)',
|
||||
})
|
||||
heatmap!: ActivityDayDto[];
|
||||
@ApiProperty({ type: JourneyDto }) journey!: JourneyDto;
|
||||
@ApiProperty({
|
||||
type: ProposalsFunnelDto,
|
||||
nullable: true,
|
||||
description: 'Null while the project has never had a proposal',
|
||||
})
|
||||
proposals!: ProposalsFunnelDto | null;
|
||||
}
|
||||
|
||||
export class PortfolioProjectDto {
|
||||
|
||||
@@ -126,6 +126,33 @@ export interface FieldBreakdown {
|
||||
none: number;
|
||||
}
|
||||
|
||||
/** Where a closed ticket's time goes, status by status. */
|
||||
export interface Journey {
|
||||
/** Tickets done in the last 90 days. */
|
||||
samples: number;
|
||||
leadDays: number | null;
|
||||
/** Part of the lead time at work (an in-progress status), 0–100. */
|
||||
flowEfficiency: number | null;
|
||||
steps: { status: string; days: number; share: number }[];
|
||||
}
|
||||
|
||||
/** The public's proposals, from received to done or discarded. */
|
||||
export interface ProposalsFunnel {
|
||||
received: number;
|
||||
receivedLately: number;
|
||||
waiting: number;
|
||||
underWay: number;
|
||||
done: number;
|
||||
discarded: number;
|
||||
topWaiting: {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
votes: number;
|
||||
days: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** The dashboard's reading of a project beyond the counts. */
|
||||
export interface ProjectInsights {
|
||||
generatedAt: string;
|
||||
@@ -140,6 +167,11 @@ export interface ProjectInsights {
|
||||
backlog: BacklogHealth;
|
||||
quality: Quality;
|
||||
fields: FieldBreakdown[];
|
||||
/** 12 weeks from a Monday, a day each (84). */
|
||||
heatmap: { day: string; count: number }[];
|
||||
journey: Journey;
|
||||
/** Null while nobody has proposed anything. */
|
||||
proposals: ProposalsFunnel | null;
|
||||
}
|
||||
|
||||
/** One of my active projects at a glance. */
|
||||
|
||||
@@ -2059,6 +2059,28 @@ export type ProjectInsightsQuery = {
|
||||
none: number;
|
||||
counts: Array<{ value: string; count: number }>;
|
||||
}>;
|
||||
heatmap: Array<{ day: string; count: number }>;
|
||||
journey: {
|
||||
samples: number;
|
||||
leadDays: number | null;
|
||||
flowEfficiency: number | null;
|
||||
steps: Array<{ status: string; days: number; share: number }>;
|
||||
};
|
||||
proposals: {
|
||||
received: number;
|
||||
receivedLately: number;
|
||||
waiting: number;
|
||||
underWay: number;
|
||||
done: number;
|
||||
discarded: number;
|
||||
topWaiting: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
votes: number;
|
||||
days: number;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -12397,6 +12419,127 @@ export const ProjectInsightsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'heatmap' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'count' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'journey' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'samples' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'leadDays' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'flowEfficiency' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'steps' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'status' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'days' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'share' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'proposals' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'received' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'receivedLately' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'waiting' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'underWay' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'done' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'discarded' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'topWaiting' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'key' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'votes' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'days' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -296,6 +296,35 @@ query ProjectInsights($idOrKey: String!, $since: String) {
|
||||
}
|
||||
none
|
||||
}
|
||||
heatmap {
|
||||
day
|
||||
count
|
||||
}
|
||||
journey {
|
||||
samples
|
||||
leadDays
|
||||
flowEfficiency
|
||||
steps {
|
||||
status
|
||||
days
|
||||
share
|
||||
}
|
||||
}
|
||||
proposals {
|
||||
received
|
||||
receivedLately
|
||||
waiting
|
||||
underWay
|
||||
done
|
||||
discarded
|
||||
topWaiting {
|
||||
id
|
||||
key
|
||||
title
|
||||
votes
|
||||
days
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ 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';
|
||||
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
|
||||
import { JourneyComponent } from '../ui/insights/journey';
|
||||
import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
@@ -53,6 +56,9 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
|
||||
BacklogHealthComponent,
|
||||
QualityComponent,
|
||||
FieldBreakdownComponent,
|
||||
ActivityHeatmapComponent,
|
||||
JourneyComponent,
|
||||
ProposalsFunnelComponent,
|
||||
],
|
||||
providers: [provideInsightsFacade()],
|
||||
template: `
|
||||
@@ -132,11 +138,19 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
|
||||
[statuses]="reading.bottlenecks"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
<ts-journey
|
||||
[journey]="reading.journey"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
<ts-activity-heatmap [days]="reading.heatmap" />
|
||||
<ts-backlog-health [health]="reading.backlog" />
|
||||
<ts-quality
|
||||
[quality]="reading.quality"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
@if (reading.proposals; as proposals) {
|
||||
<ts-proposals-funnel [funnel]="proposals" />
|
||||
}
|
||||
@if (reading.fields.length) {
|
||||
<ts-field-breakdown
|
||||
[fields]="reading.fields"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
const WEEKDAYS = [
|
||||
'lunes',
|
||||
'martes',
|
||||
'miércoles',
|
||||
'jueves',
|
||||
'viernes',
|
||||
'sábado',
|
||||
'domingo',
|
||||
];
|
||||
const MONTHS = [
|
||||
'ene',
|
||||
'feb',
|
||||
'mar',
|
||||
'abr',
|
||||
'may',
|
||||
'jun',
|
||||
'jul',
|
||||
'ago',
|
||||
'sep',
|
||||
'oct',
|
||||
'nov',
|
||||
'dic',
|
||||
];
|
||||
|
||||
/** 12 weeks of the project's activity, a square a day: lulls and rushes at a glance. */
|
||||
@Component({
|
||||
selector: 'ts-activity-heatmap',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'heatmap-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="heatmap-title">Actividad</h2>
|
||||
<span class="ts-muted">Movimientos por día, últimas 12 semanas</span>
|
||||
</header>
|
||||
<div class="ts-heat">
|
||||
<span class="ts-heat__corner"></span>
|
||||
@for (week of weeks(); track week.start) {
|
||||
<span class="ts-heat__month">{{ week.month }}</span>
|
||||
}
|
||||
@for (row of [0, 1, 2, 3, 4, 5, 6]; track row) {
|
||||
<span class="ts-heat__weekday">{{
|
||||
row % 2 === 0 ? weekdayInitial(row) : ''
|
||||
}}</span>
|
||||
@for (week of weeks(); track week.start) {
|
||||
@if (week.days[row]; as day) {
|
||||
<span
|
||||
class="ts-heat__day"
|
||||
[attr.data-level]="day.level"
|
||||
[class.is-future]="day.future"
|
||||
[attr.title]="day.title"
|
||||
></span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="ts-empty ts-heat__note">
|
||||
@if (total()) {
|
||||
{{ total() }} {{ total() === 1 ? 'movimiento' : 'movimientos' }} · el
|
||||
día con más vida, el {{ busiest() }}
|
||||
@if (quiet() >= 3) {
|
||||
· parón más largo: {{ quiet() }} días
|
||||
}
|
||||
} @else {
|
||||
Nada se ha movido en estas 12 semanas.
|
||||
}
|
||||
</p>
|
||||
`,
|
||||
styles: `
|
||||
.ts-heat {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem repeat(12, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-heat__month,
|
||||
.ts-heat__weekday {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-heat__month {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.ts-heat__day {
|
||||
aspect-ratio: 1;
|
||||
max-height: 1.375rem;
|
||||
border-radius: 3px;
|
||||
background: var(--ts-sunken);
|
||||
&[data-level='1'] {
|
||||
background: color-mix(in srgb, var(--ts-brand) 25%, var(--ts-sunken));
|
||||
}
|
||||
&[data-level='2'] {
|
||||
background: color-mix(in srgb, var(--ts-brand) 50%, var(--ts-sunken));
|
||||
}
|
||||
&[data-level='3'] {
|
||||
background: color-mix(in srgb, var(--ts-brand) 75%, var(--ts-sunken));
|
||||
}
|
||||
&[data-level='4'] {
|
||||
background: var(--ts-brand);
|
||||
}
|
||||
&.is-future {
|
||||
background: transparent;
|
||||
outline: 1px dashed var(--ts-line);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
.ts-heat__note {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ActivityHeatmapComponent {
|
||||
/** 84 days from a Monday, oldest first. */
|
||||
readonly days = input.required<{ day: string; count: number }[]>();
|
||||
/** Today (`YYYY-MM-DD`), to grey out the rest of this week. */
|
||||
readonly today = input<string>(new Date().toISOString().slice(0, 10));
|
||||
|
||||
protected readonly total = computed(() =>
|
||||
this.days().reduce((sum, d) => sum + d.count, 0),
|
||||
);
|
||||
|
||||
/** Squares coloured by quarters of the busiest day. */
|
||||
protected readonly weeks = computed(() => {
|
||||
const days = this.days();
|
||||
const max = Math.max(1, ...days.map((d) => d.count));
|
||||
const weeks: {
|
||||
start: string;
|
||||
month: string;
|
||||
days: { level: number; future: boolean; title: string }[];
|
||||
}[] = [];
|
||||
for (let w = 0; w * 7 < days.length; w++) {
|
||||
const week = days.slice(w * 7, w * 7 + 7);
|
||||
const first = week[0].day;
|
||||
// The month's name over the week it starts in (and over the first one).
|
||||
const month =
|
||||
w === 0 || week.some((d) => d.day.endsWith('-01'))
|
||||
? MONTHS[
|
||||
Number(
|
||||
(week.find((d) => d.day.endsWith('-01')) ?? week[0]).day.slice(
|
||||
5,
|
||||
7,
|
||||
),
|
||||
) - 1
|
||||
]
|
||||
: '';
|
||||
weeks.push({
|
||||
start: first,
|
||||
month,
|
||||
days: week.map((d) => {
|
||||
const future = d.day > this.today();
|
||||
return {
|
||||
future,
|
||||
level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0,
|
||||
title: future
|
||||
? ''
|
||||
: `${d.day.slice(8)}/${d.day.slice(5, 7)}: ${d.count} ${d.count === 1 ? 'movimiento' : 'movimientos'}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
});
|
||||
|
||||
protected readonly busiest = computed(() => {
|
||||
const byWeekday = Array<number>(7).fill(0);
|
||||
this.days().forEach((d, i) => (byWeekday[i % 7] += d.count));
|
||||
return WEEKDAYS[byWeekday.indexOf(Math.max(...byWeekday))];
|
||||
});
|
||||
|
||||
/** The longest run of days without anything moving, from the first that had something up to today. */
|
||||
protected readonly quiet = computed(() => {
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
const days = this.days();
|
||||
const first = days.findIndex((d) => d.count > 0);
|
||||
for (const d of first < 0 ? [] : days.slice(first)) {
|
||||
if (d.day > this.today()) break;
|
||||
run = d.count ? 0 : run + 1;
|
||||
longest = Math.max(longest, run);
|
||||
}
|
||||
return longest;
|
||||
});
|
||||
|
||||
protected weekdayInitial(row: number): string {
|
||||
return ['L', 'M', 'X', 'J', 'V', 'S', 'D'][row];
|
||||
}
|
||||
}
|
||||
146
libs/web/tracker/presenters/src/lib/ui/insights/journey.ts
Normal file
146
libs/web/tracker/presenters/src/lib/ui/insights/journey.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { Journey, Workflow } from '@task-stream/web/tracker/domain';
|
||||
import { statusLook } from '../copy';
|
||||
|
||||
/**
|
||||
* Where a ticket's time goes from created to done, waiting included: one
|
||||
* bar split by status, and how much of it was actual work.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-journey',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'journey-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="journey-title">El recorrido de un ticket</h2>
|
||||
<span class="ts-muted">De creado a hecho, los cerrados en 90 días</span>
|
||||
</header>
|
||||
@if (journey().leadDays !== null && steps().length) {
|
||||
<p class="ts-journey__lead">
|
||||
<strong>{{ days(journey().leadDays!) }}</strong> de media ·
|
||||
<strong>{{ journey().flowEfficiency }} %</strong> trabajando, el resto
|
||||
esperando
|
||||
</p>
|
||||
<div class="ts-journey__bar" role="img" [attr.aria-label]="summary()">
|
||||
@for (step of steps(); track step.status) {
|
||||
<span
|
||||
[style.--tone]="step.tone"
|
||||
[style.flex-grow]="step.share || 1"
|
||||
[attr.title]="
|
||||
step.label + ': ' + days(step.days) + ' (' + step.share + ' %)'
|
||||
"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
<ul class="ts-issues ts-journey__legend">
|
||||
@for (step of steps(); track step.status) {
|
||||
<li [style.--tone]="step.tone" [class.is-longest]="step.longest">
|
||||
<span class="ts-journey__dot" aria-hidden="true"></span>
|
||||
<span class="ts-title">{{ step.label }}</span>
|
||||
<span class="ts-days"
|
||||
>{{ days(step.days) }} · {{ step.share }} %</span
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="ts-empty ts-journey__note">
|
||||
Medido en {{ journey().samples }}
|
||||
{{ journey().samples === 1 ? 'ticket' : 'tickets' }}.
|
||||
@if (longest(); as longest) {
|
||||
Donde más tiempo se va: <strong>{{ longest }}</strong
|
||||
>.
|
||||
}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="ts-empty">
|
||||
Aún no se ha terminado ningún ticket en los últimos 90 días.
|
||||
</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-journey__lead {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.ts-journey__bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
span {
|
||||
min-width: 3px;
|
||||
background: var(--tone);
|
||||
}
|
||||
}
|
||||
.ts-journey__legend {
|
||||
margin-top: 0.875rem;
|
||||
gap: 0.375rem;
|
||||
li {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
li.is-longest .ts-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.ts-journey__dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--tone);
|
||||
}
|
||||
.ts-journey__note {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class JourneyComponent {
|
||||
readonly journey = input.required<Journey>();
|
||||
readonly workflow = input.required<Workflow>();
|
||||
|
||||
protected readonly steps = computed(() => {
|
||||
const steps = this.journey().steps;
|
||||
const most = Math.max(0, ...steps.map((s) => s.share));
|
||||
return steps.map((s) => {
|
||||
const look = statusLook(this.workflow(), s.status);
|
||||
return {
|
||||
...s,
|
||||
label: look.label,
|
||||
tone: look.tone,
|
||||
longest: s.share === most,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly longest = computed(() => {
|
||||
const step = this.steps().find((s) => s.longest);
|
||||
return step ? `${step.label} (${step.share} %)` : null;
|
||||
});
|
||||
|
||||
protected readonly summary = computed(() =>
|
||||
this.steps()
|
||||
.map((s) => `${s.label} ${s.share} %`)
|
||||
.join(', '),
|
||||
);
|
||||
|
||||
/** Days, or hours when under one (or «menos de 1 h»). */
|
||||
protected days(n: number): string {
|
||||
if (n < 1) {
|
||||
const hours = Math.round(n * 24);
|
||||
return hours < 1 ? 'menos de 1 h' : `${hours} h`;
|
||||
}
|
||||
const rounded = Math.round(n * 10) / 10;
|
||||
return `${rounded.toLocaleString('es')} ${rounded === 1 ? 'día' : 'días'}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ProposalsFunnel } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** The public's proposals: how many came, where they are, and the most voted still unanswered. */
|
||||
@Component({
|
||||
selector: 'ts-proposals-funnel',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'proposals-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="proposals-title">Propuestas del público</h2>
|
||||
<span class="ts-muted"
|
||||
>{{ funnel().receivedLately }} en los últimos 30 días</span
|
||||
>
|
||||
</header>
|
||||
<ul class="ts-issues ts-funnel">
|
||||
@for (stage of stages(); track stage.label) {
|
||||
<li [style.--tone]="stage.tone">
|
||||
<span class="ts-title">{{ stage.label }}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="stage.width"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ stage.count }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (funnel().topWaiting.length) {
|
||||
<h3 class="ts-funnel__subtitle">Las más votadas sin respuesta</h3>
|
||||
<ul class="ts-issues">
|
||||
@for (p of funnel().topWaiting; track p.id) {
|
||||
<li>
|
||||
<a class="ts-key" [routerLink]="['../issues', p.key]">{{
|
||||
p.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', p.key]">{{
|
||||
p.title
|
||||
}}</a>
|
||||
<span class="ts-days" [attr.title]="p.days + ' días esperando'"
|
||||
>▲ {{ p.votes }} · {{ p.days }} d</span
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (funnel().received) {
|
||||
<p class="ts-empty ts-funnel__note">Ninguna espera respuesta.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-funnel {
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
grid-template-columns: minmax(6rem, 8rem) 1fr 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.ts-funnel__subtitle {
|
||||
margin: 1.125rem 0 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ts-funnel__note {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ProposalsFunnelComponent {
|
||||
readonly funnel = input.required<ProposalsFunnel>();
|
||||
|
||||
protected readonly stages = computed(() => {
|
||||
const f = this.funnel();
|
||||
const max = Math.max(1, f.received);
|
||||
return [
|
||||
{ label: 'Recibidas', count: f.received, tone: 'var(--ts-brand)' },
|
||||
{ label: 'Esperando', count: f.waiting, tone: 'var(--ts-c-amber)' },
|
||||
{ label: 'En marcha', count: f.underWay, tone: 'var(--ts-c-blue)' },
|
||||
{ label: 'Hechas', count: f.done, tone: 'var(--ts-c-green)' },
|
||||
{ label: 'Descartadas', count: f.discarded, tone: 'var(--ts-c-slate)' },
|
||||
].map((s) => ({ ...s, width: (s.count / max) * 100 }));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user