feat: dashboard weighs quality and splits work by a list field

The insights reading adds quality (open bugs per priority, bugs created
and closed per week for 8 weeks, QA returns and passes per area over 90
days, bugs reported in the 14 days after each of the last three
releases) and, for each list custom field, open tickets per option. The
dashboard shows both; the field to split by can be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:06:11 +02:00
parent 0d158a2ed7
commit 76312c079d
22 changed files with 1264 additions and 74 deletions

View File

@@ -1437,5 +1437,8 @@ describe('task-stream (e2e)', () => {
`/projects/${key}/issues?noEstimate=true&noArea=true`,
);
expect(unestimated.body['total']).toBe(2);
expect(later.body['quality'].openBugsByPriority).toHaveLength(5);
expect(later.body['quality'].bugWeeks).toHaveLength(8);
expect(later.body['fields']).toEqual([]);
});
});

View File

@@ -12316,6 +12316,166 @@
"staleDays"
]
},
"PriorityCountDto": {
"type": "object",
"properties": {
"priority": {
"allOf": [
{
"$ref": "#/components/schemas/IssuePriority"
}
]
},
"count": {
"type": "number"
}
},
"required": [
"priority",
"count"
]
},
"BugWeekDto": {
"type": "object",
"properties": {
"week": {
"type": "string",
"format": "date",
"description": "Monday (UTC)"
},
"created": {
"type": "number"
},
"closed": {
"type": "number"
}
},
"required": [
"week",
"created",
"closed"
]
},
"QaByAreaDto": {
"type": "object",
"properties": {
"area": {
"type": "string",
"nullable": true,
"description": "Null: no area"
},
"returned": {
"type": "number"
},
"passed": {
"type": "number"
}
},
"required": [
"area",
"returned",
"passed"
]
},
"EscapedBugsDto": {
"type": "object",
"properties": {
"releaseId": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"releasedAt": {
"type": "string"
},
"bugs": {
"type": "number",
"description": "Bugs reported in the 14 days after"
}
},
"required": [
"releaseId",
"name",
"releasedAt",
"bugs"
]
},
"QualityDto": {
"type": "object",
"properties": {
"openBugsByPriority": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PriorityCountDto"
}
},
"bugWeeks": {
"description": "Last 8 weeks, oldest first",
"type": "array",
"items": {
"$ref": "#/components/schemas/BugWeekDto"
}
},
"qaByArea": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QaByAreaDto"
}
},
"escaped": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EscapedBugsDto"
}
}
},
"required": [
"openBugsByPriority",
"bugWeeks",
"qaByArea",
"escaped"
]
},
"OptionCountDto": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"count": {
"type": "number"
}
},
"required": [
"value",
"count"
]
},
"FieldBreakdownDto": {
"type": "object",
"properties": {
"fieldId": {
"type": "string"
},
"counts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OptionCountDto"
}
},
"none": {
"type": "number",
"description": "Open tickets with no value"
}
},
"required": [
"fieldId",
"counts",
"none"
]
},
"ProjectInsightsDto": {
"type": "object",
"properties": {
@@ -12367,6 +12527,16 @@
},
"backlog": {
"$ref": "#/components/schemas/BacklogHealthDto"
},
"quality": {
"$ref": "#/components/schemas/QualityDto"
},
"fields": {
"description": "One per active list custom field",
"type": "array",
"items": {
"$ref": "#/components/schemas/FieldBreakdownDto"
}
}
},
"required": [
@@ -12378,7 +12548,9 @@
"releases",
"changes",
"team",
"backlog"
"backlog",
"quality",
"fields"
]
},
"ActivityDto": {

View File

@@ -144,6 +144,15 @@ type BoardColumn {
total: Int!
}
"""Bugs created and closed in a week"""
type BugWeek {
closed: Int!
created: Int!
"""Monday (UTC), YYYY-MM-DD"""
week: String!
}
type BulkIssueResult {
error: String
issueId: ID!
@@ -478,6 +487,23 @@ type Epic {
progress: ChildProgress!
}
"""Bugs reported in the 14 days after a release shipped"""
type EscapedBugs {
bugs: Int!
name: String!
releaseId: ID!
releasedAt: String!
}
"""Open tickets per option of a list custom field"""
type FieldBreakdown {
counts: [OptionCount!]!
fieldId: String!
"""Open tickets with no value"""
none: Int!
}
type FlowSeries {
counts: [Int!]!
status: String!
@@ -981,6 +1007,16 @@ type NotificationSettings {
pushPublicKey: String
}
type OptionCount {
count: Int!
value: String!
}
type PriorityCount {
count: Int!
priority: IssuePriority!
}
type Project {
"""Takes bug reports and ideas from anyone (while public)"""
acceptsProposals: Boolean!
@@ -1068,7 +1104,11 @@ type ProjectInsights {
"""Only when asked with `since`"""
changes: ChangesSince
"""One per active list custom field"""
fields: [FieldBreakdown!]!
generatedAt: String!
quality: Quality!
releases: [ReleaseForecast!]!
risks: Risks!
sprint: SprintPulse
@@ -1138,12 +1178,29 @@ input PushSubscriptionInput {
p256dh: String!
}
"""QA verdicts per area (90 days)"""
type QaByArea {
"""Null: no area"""
area: String
passed: Int!
returned: Int!
}
type QaStats {
failed: Int!
failureRate: Float
passed: Int!
}
"""Bugs (types with the bug icon) and QA"""
type Quality {
"""Last 8 weeks, oldest first"""
bugWeeks: [BugWeek!]!
escaped: [EscapedBugs!]!
openBugsByPriority: [PriorityCount!]!
qaByArea: [QaByArea!]!
}
type Query {
"""Project activity feed (newest first)"""
activity(

View File

@@ -2229,6 +2229,50 @@ export interface components {
stale: number;
staleDays: number;
};
PriorityCountDto: {
priority: components["schemas"]["IssuePriority"];
count: number;
};
BugWeekDto: {
/**
* Format: date
* @description Monday (UTC)
*/
week: string;
created: number;
closed: number;
};
QaByAreaDto: {
/** @description Null: no area */
area: string | null;
returned: number;
passed: number;
};
EscapedBugsDto: {
/** Format: uuid */
releaseId: string;
name: string;
releasedAt: string;
/** @description Bugs reported in the 14 days after */
bugs: number;
};
QualityDto: {
openBugsByPriority: components["schemas"]["PriorityCountDto"][];
/** @description Last 8 weeks, oldest first */
bugWeeks: components["schemas"]["BugWeekDto"][];
qaByArea: components["schemas"]["QaByAreaDto"][];
escaped: components["schemas"]["EscapedBugsDto"][];
};
OptionCountDto: {
value: string;
count: number;
};
FieldBreakdownDto: {
fieldId: string;
counts: components["schemas"]["OptionCountDto"][];
/** @description Open tickets with no value */
none: number;
};
ProjectInsightsDto: {
generatedAt: string;
sprint: components["schemas"]["SprintPulseDto"] | null;
@@ -2240,6 +2284,9 @@ export interface components {
changes: components["schemas"]["ChangesSinceDto"] | null;
team: components["schemas"]["TeamLoadDto"][];
backlog: components["schemas"]["BacklogHealthDto"];
quality: components["schemas"]["QualityDto"];
/** @description One per active list custom field */
fields: components["schemas"]["FieldBreakdownDto"][];
};
ActivityDto: {
id: string;

View File

@@ -207,7 +207,7 @@ export class ProjectsResolver {
@Tool({
name: 'get_project_insights',
description:
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, and open tickets missing an estimate, an area or an assignee. With since, also what changed after it.",
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) and open tickets per option of each list custom field. With since, also what changed after it.",
paramsSchema: z.object({
project: projectRef,
since: z

View File

@@ -392,6 +392,8 @@ decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO
| Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar |
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
## 3.2 GraphQL

View File

@@ -281,7 +281,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: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
@@ -291,7 +291,7 @@ visualización de la plataforma:
| `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, 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/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
| `/p/:key/activity` | Actividad en vivo |

View File

@@ -35,7 +35,7 @@ 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) |
@@ -48,7 +48,7 @@ flowchart LR
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog y, con `since`, los cambios |
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios |
| | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |

View File

@@ -31,14 +31,9 @@ cuando hay pistas, por dónde empezar.
## 10.3 Siguientes funcionalidades
### En curso, en este orden
### En curso
En el panel del proyecto:
1. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
siguientes a cada release.
2. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
navegador»).
Nada: la tanda está hecha (ver 10.5).
### Aceptadas, para después de esta tanda
@@ -123,3 +118,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
y MCP (`get_project_insights`).
- Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área,
bugs tras cada release) y abiertos por opción de un campo de lista.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,7 @@
import {
CustomFieldType,
defaultProjectSettings,
IssuePriority,
ReleaseReadModel,
ReleaseStatus,
IssueLinkType,
@@ -302,4 +304,92 @@ describe('computeInsights', () => {
staleDays: STALE_DAYS,
});
});
it('weighs quality and splits open tickets by a list field', () => {
const settings = defaultProjectSettings();
settings.customFields = [
{
id: 'BROWSER',
name: 'Navegador',
type: CustomFieldType.SELECT,
options: ['Chrome', 'Safari'],
archived: false,
},
];
const issues = [
issue('B1', 'TODO', C.TODO, {
type: 'BUG',
priority: IssuePriority.HIGH,
createdAt: '2026-10-14T09:00:00Z',
customFields: { BROWSER: 'Safari' },
}),
issue('B2', 'DONE', C.DONE, {
type: 'BUG',
area: 'FRONTEND',
createdAt: '2026-10-02T09:00:00Z',
}),
issue('T1', 'IN_PROGRESS', C.IN_PROGRESS, {
type: 'TASK',
area: 'FRONTEND',
customFields: { BROWSER: 'Safari' },
}),
];
const timeline = [
step('B2', '2026-10-05T09:00:00Z', 'IN_QA', C.QA),
step('B2', '2026-10-06T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
step('B2', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
step('B2', '2026-10-08T09:00:00Z', 'DONE', C.DONE),
];
const { quality, fields } = computeInsights({
...BASE,
settings,
issues,
timeline,
releases: [
{
id: 'r',
name: '1.0',
status: ReleaseStatus.RELEASED,
releasedAt: '2026-10-01T00:00:00Z',
} as ReleaseReadModel,
],
});
expect(quality.openBugsByPriority[1]).toEqual({
priority: IssuePriority.HIGH,
count: 1,
});
expect(quality.bugWeeks).toHaveLength(8);
expect(quality.bugWeeks.at(-1)).toEqual({
week: '2026-10-12',
created: 1,
closed: 0,
});
expect(quality.bugWeeks.at(-2)).toEqual({
week: '2026-10-05',
created: 0,
closed: 1,
});
expect(quality.qaByArea).toEqual([
{ area: 'FRONTEND', returned: 1, passed: 1 },
]);
// B2 came 1 day after 1.0; B1 13 days after.
expect(quality.escaped).toEqual([
{
releaseId: 'r',
name: '1.0',
releasedAt: '2026-10-01T00:00:00Z',
bugs: 2,
},
]);
expect(fields).toEqual([
{
fieldId: 'BROWSER',
counts: [
{ value: 'Safari', count: 2 },
{ value: 'Chrome', count: 0 },
],
none: 0,
},
]);
});
});

View File

@@ -1,5 +1,9 @@
import {
BacklogHealthReadModel,
CustomFieldType,
FieldBreakdownReadModel,
IssuePriority,
QualityReadModel,
ChangesSinceReadModel,
InsightIssueReadModel,
IssueLinkType,
@@ -18,7 +22,12 @@ import {
TeamLoadReadModel,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
import {
computeProjectAnalytics,
Step,
stepsByIssue,
weekOf,
} from './analytics';
/*
* The dashboard's reading of a project: what needs attention now (risks),
@@ -169,9 +178,124 @@ export function computeInsights(
new Set(blocked.map((b) => b.id)),
),
backlog: backlogHealth(open, now),
quality: quality(input.settings, live, steps, input.releases, now),
fields: fieldBreakdown(input.settings, open),
};
}
const PRIORITIES = [
IssuePriority.CRITICAL,
IssuePriority.HIGH,
IssuePriority.MEDIUM,
IssuePriority.LOW,
IssuePriority.LOWEST,
];
/** Bugs are the tickets whose type is drawn with the bug icon. */
function quality(
settings: ProjectSettings,
issues: IssueReadModel[],
steps: Map<string, Step[]>,
releases: ReleaseReadModel[],
now: number,
): QualityReadModel {
const bugTypes = new Set(
settings.issueTypes.filter((t) => t.icon === 'bug').map((t) => t.id),
);
const bugs = issues.filter((i) => bugTypes.has(i.type));
const openBugs = bugs.filter((i) => !CLOSED.includes(i.statusCategory));
// Created and closed per week, the last 8.
const thisWeek = Date.parse(weekOf(now));
const weeks = Array.from({ length: 8 }, (_, n) =>
new Date(thisWeek - (7 - n) * 7 * DAY).toISOString().slice(0, 10),
);
const tally = new Map(weeks.map((w) => [w, { created: 0, closed: 0 }]));
for (const bug of bugs) {
const created = tally.get(weekOf(Date.parse(bug.createdAt)));
if (created) created.created++;
const list = steps.get(bug.id) ?? [];
list.forEach((step, i) => {
if (i === 0 || !CLOSED.includes(step.category)) return;
if (CLOSED.includes(list[i - 1].category)) return;
const week = tally.get(weekOf(step.at));
if (week) week.closed++;
});
}
// QA's verdicts per area, the last 90 days.
const from = now - 90 * DAY;
const byArea = new Map<string | null, { returned: number; passed: number }>();
for (const issue of issues) {
const list = steps.get(issue.id) ?? [];
list.forEach((step, i) => {
const before = list[i - 1];
if (!before || step.at < from) return;
if (before.category !== StatusCategory.QA) return;
if (step.category === StatusCategory.QA) return;
if (step.category === StatusCategory.CANCELLED) return;
const row = byArea.get(issue.area) ?? { returned: 0, passed: 0 };
if (step.category === StatusCategory.DONE) row.passed++;
else row.returned++;
byArea.set(issue.area, row);
});
}
// Bugs reported in the 14 days after each of the last shipped releases.
const escaped = releases
.filter((r) => r.status === ReleaseStatus.RELEASED && r.releasedAt)
.sort((a, b) =>
(b.releasedAt as string).localeCompare(a.releasedAt as string),
)
.slice(0, 3)
.map((r) => {
const shipped = Date.parse(r.releasedAt as string);
return {
releaseId: r.id,
name: r.name,
releasedAt: r.releasedAt as string,
bugs: bugs.filter((b) => {
const at = Date.parse(b.createdAt);
return at > shipped && at <= shipped + 14 * DAY;
}).length,
};
});
return {
openBugsByPriority: PRIORITIES.map((priority) => ({
priority,
count: openBugs.filter((b) => b.priority === priority).length,
})),
bugWeeks: weeks.map((week) => ({ week, ...tally.get(week)! })),
qaByArea: [...byArea.entries()]
.map(([area, row]) => ({ area, ...row }))
.sort((a, b) => b.returned - a.returned || b.passed - a.passed),
escaped,
};
}
/** Open tickets per option of each active list field. */
function fieldBreakdown(
settings: ProjectSettings,
open: IssueReadModel[],
): FieldBreakdownReadModel[] {
return (settings.customFields ?? [])
.filter((f) => !f.archived && f.type === CustomFieldType.SELECT)
.map((field) => {
const values = open.map((i) => i.customFields?.[field.id] ?? null);
return {
fieldId: field.id,
counts: field.options
.map((value) => ({
value,
count: values.filter((v) => v === value).length,
}))
.sort((a, b) => b.count - a.count),
none: values.filter((v) => v === null).length,
};
});
}
/** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */
function dailyThroughput(steps: Map<string, Step[]>, now: number): number[] {
const today = utcDay(now);

View File

@@ -128,6 +128,48 @@ export class BacklogHealthReadModel {
staleDays!: number;
}
/** Bugs created and closed in one week (Monday, UTC). */
export class BugWeekReadModel {
week!: string;
created!: number;
closed!: number;
}
/** What QA sent back, and let through, per area (last 90 days). */
export class QaByAreaReadModel {
/** Area id; null: tickets with no area. */
area!: string | null;
returned!: number;
passed!: number;
}
/** Bugs reported in the 14 days after a release shipped. */
export class EscapedBugsReadModel {
releaseId!: string;
name!: string;
releasedAt!: string;
bugs!: number;
}
/** Bugs (types drawn with the bug icon) and QA. */
export class QualityReadModel {
/** Open bugs per priority (all five, most urgent first). */
openBugsByPriority!: { priority: string; count: number }[];
/** The last 8 weeks, oldest first. */
bugWeeks!: BugWeekReadModel[];
qaByArea!: QaByAreaReadModel[];
/** The last three shipped releases, newest first. */
escaped!: EscapedBugsReadModel[];
}
/** Open tickets per option of a list custom field. */
export class FieldBreakdownReadModel {
fieldId!: string;
counts!: { value: string; count: number }[];
/** Open tickets with no value in it. */
none!: number;
}
/** What the project dashboard reads beyond the counts. */
export class ProjectInsightsReadModel {
generatedAt!: string;
@@ -143,4 +185,7 @@ export class ProjectInsightsReadModel {
/** Members (and others with open tickets), busiest first. */
team!: TeamLoadReadModel[];
backlog!: BacklogHealthReadModel;
quality!: QualityReadModel;
/** One per active list (SELECT) custom field. */
fields!: FieldBreakdownReadModel[];
}

View File

@@ -928,6 +928,64 @@ export class BacklogHealthGql {
@Field(() => Int) staleDays!: number;
}
@ObjectType('PriorityCount')
export class PriorityCountGql {
@Field(() => IssuePriority) priority!: IssuePriority;
@Field(() => Int) count!: number;
}
@ObjectType('BugWeek', { description: 'Bugs created and closed in a week' })
export class BugWeekGql {
@Field({ description: 'Monday (UTC), YYYY-MM-DD' }) week!: string;
@Field(() => Int) created!: number;
@Field(() => Int) closed!: number;
}
@ObjectType('QaByArea', { description: 'QA verdicts per area (90 days)' })
export class QaByAreaGql {
@Field(() => String, { nullable: true, description: 'Null: no area' })
area!: string | null;
@Field(() => Int) returned!: number;
@Field(() => Int) passed!: number;
}
@ObjectType('EscapedBugs', {
description: 'Bugs reported in the 14 days after a release shipped',
})
export class EscapedBugsGql {
@Field(() => ID) releaseId!: string;
@Field() name!: string;
@Field() releasedAt!: string;
@Field(() => Int) bugs!: number;
}
@ObjectType('Quality', {
description: 'Bugs (types with the bug icon) and QA',
})
export class QualityGql {
@Field(() => [PriorityCountGql]) openBugsByPriority!: PriorityCountGql[];
@Field(() => [BugWeekGql], { description: 'Last 8 weeks, oldest first' })
bugWeeks!: BugWeekGql[];
@Field(() => [QaByAreaGql]) qaByArea!: QaByAreaGql[];
@Field(() => [EscapedBugsGql]) escaped!: EscapedBugsGql[];
}
@ObjectType('OptionCount')
export class OptionCountGql {
@Field() value!: string;
@Field(() => Int) count!: number;
}
@ObjectType('FieldBreakdown', {
description: 'Open tickets per option of a list custom field',
})
export class FieldBreakdownGql {
@Field() fieldId!: string;
@Field(() => [OptionCountGql]) counts!: OptionCountGql[];
@Field(() => Int, { description: 'Open tickets with no value' })
none!: number;
}
@ObjectType('ProjectInsights', {
description:
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
@@ -947,4 +1005,9 @@ export class ProjectInsightsGql {
changes!: ChangesSinceGql | null;
@Field(() => [TeamLoadGql]) team!: TeamLoadGql[];
@Field(() => BacklogHealthGql) backlog!: BacklogHealthGql;
@Field(() => QualityGql) quality!: QualityGql;
@Field(() => [FieldBreakdownGql], {
description: 'One per active list custom field',
})
fields!: FieldBreakdownGql[];
}

View File

@@ -1006,6 +1006,56 @@ export class BacklogHealthDto {
@ApiProperty() staleDays!: number;
}
export class PriorityCountDto {
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
priority!: IssuePriority;
@ApiProperty() count!: number;
}
export class BugWeekDto {
@ApiProperty({ format: 'date', description: 'Monday (UTC)' }) week!: string;
@ApiProperty() created!: number;
@ApiProperty() closed!: number;
}
export class QaByAreaDto {
@ApiProperty({ type: String, nullable: true, description: 'Null: no area' })
area!: string | null;
@ApiProperty() returned!: number;
@ApiProperty() passed!: number;
}
export class EscapedBugsDto {
@ApiProperty({ format: 'uuid' }) releaseId!: string;
@ApiProperty() name!: string;
@ApiProperty() releasedAt!: string;
@ApiProperty({ description: 'Bugs reported in the 14 days after' })
bugs!: number;
}
export class QualityDto {
@ApiProperty({ type: [PriorityCountDto] })
openBugsByPriority!: PriorityCountDto[];
@ApiProperty({
type: [BugWeekDto],
description: 'Last 8 weeks, oldest first',
})
bugWeeks!: BugWeekDto[];
@ApiProperty({ type: [QaByAreaDto] }) qaByArea!: QaByAreaDto[];
@ApiProperty({ type: [EscapedBugsDto] }) escaped!: EscapedBugsDto[];
}
export class OptionCountDto {
@ApiProperty() value!: string;
@ApiProperty() count!: number;
}
export class FieldBreakdownDto {
@ApiProperty() fieldId!: string;
@ApiProperty({ type: [OptionCountDto] }) counts!: OptionCountDto[];
@ApiProperty({ description: 'Open tickets with no value' }) none!: number;
}
export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true })
@@ -1022,4 +1072,10 @@ export class ProjectInsightsDto {
changes!: ChangesSinceDto | null;
@ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[];
@ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto;
@ApiProperty({ type: QualityDto }) quality!: QualityDto;
@ApiProperty({
type: [FieldBreakdownDto],
description: 'One per active list custom field',
})
fields!: FieldBreakdownDto[];
}

View File

@@ -1,3 +1,4 @@
import { IssuePriority } from './enums';
import { UserRef } from './models';
/** A ticket the dashboard names, with the number that put it there. */
@@ -102,6 +103,29 @@ export interface BacklogHealth {
staleDays: number;
}
/** Bugs (types drawn with the bug icon) and QA. */
export interface Quality {
openBugsByPriority: { priority: IssuePriority; count: number }[];
/** The last 8 weeks (Monday), oldest first. */
bugWeeks: { week: string; created: number; closed: number }[];
/** QA's verdicts per area, the last 90 days (area null: none). */
qaByArea: { area: string | null; returned: number; passed: number }[];
/** Bugs reported in the 14 days after each of the last releases. */
escaped: {
releaseId: string;
name: string;
releasedAt: string;
bugs: number;
}[];
}
/** Open tickets per option of a list custom field. */
export interface FieldBreakdown {
fieldId: string;
counts: { value: string; count: number }[];
none: number;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
@@ -114,4 +138,6 @@ export interface ProjectInsights {
changes: ChangesSince | null;
team: TeamLoad[];
backlog: BacklogHealth;
quality: Quality;
fields: FieldBreakdown[];
}

View File

@@ -1921,6 +1921,26 @@ export type ProjectInsightsQuery = {
stale: number;
staleDays: number;
};
quality: {
openBugsByPriority: Array<{ priority: IssuePriority; count: number }>;
bugWeeks: Array<{ week: string; created: number; closed: number }>;
qaByArea: Array<{
area: string | null;
returned: number;
passed: number;
}>;
escaped: Array<{
releaseId: string;
name: string;
releasedAt: string;
bugs: number;
}>;
};
fields: Array<{
fieldId: string;
none: number;
counts: Array<{ value: string; count: number }>;
}>;
};
};
};
@@ -11456,6 +11476,136 @@ export const ProjectInsightsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'quality' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'openBugsByPriority',
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'priority' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugWeeks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'week' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'created' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'closed' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'qaByArea' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'area' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returned' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'passed' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'escaped' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releasedAt' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugs' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'fields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'fieldId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'counts' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'value' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'none' },
},
],
},
},
],
},
},

View File

@@ -254,6 +254,36 @@ query ProjectInsights($idOrKey: String!, $since: String) {
stale
staleDays
}
quality {
openBugsByPriority {
priority
count
}
bugWeeks {
week
created
closed
}
qaByArea {
area
returned
passed
}
escaped {
releaseId
name
releasedAt
bugs
}
}
fields {
fieldId
counts {
value
count
}
none
}
}
}
}

View File

@@ -32,6 +32,8 @@ import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
import { ChangesSinceComponent } from '../ui/insights/changes-since';
import { BacklogHealthComponent } from '../ui/insights/backlog-health';
import { takeLastVisit } from '../ui/last-visit';
import { QualityComponent } from '../ui/insights/quality';
import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
@@ -49,6 +51,8 @@ import { takeLastVisit } from '../ui/last-visit';
ReleaseForecastComponent,
ChangesSinceComponent,
BacklogHealthComponent,
QualityComponent,
FieldBreakdownComponent,
],
providers: [provideInsightsFacade()],
template: `
@@ -129,6 +133,16 @@ import { takeLastVisit } from '../ui/last-visit';
[workflow]="facade.workflow()"
/>
<ts-backlog-health [health]="reading.backlog" />
<ts-quality
[quality]="reading.quality"
[workflow]="facade.workflow()"
/>
@if (reading.fields.length) {
<ts-field-breakdown
[fields]="reading.fields"
[workflow]="facade.workflow()"
/>
}
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />

View File

@@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
} from '@angular/core';
import { PiSelectFieldComponent, type PiSelectFieldOption } from '@pi/ui';
import { FieldBreakdown, Workflow } from '@task-stream/web/tracker/domain';
/** Open tickets per option of a list custom field («bugs por navegador»). */
@Component({
selector: 'ts-field-breakdown',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSelectFieldComponent],
host: {
class: 'ts-panel',
'aria-labelledby': 'breakdown-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="breakdown-title">Abiertos por {{ name() }}</h2>
@if (options().length > 1) {
<pi-select-field
class="ts-breakdown__pick"
[options]="options()"
[(value)]="chosen"
/>
}
</header>
@if (current(); as field) {
<ul class="ts-issues ts-breakdown">
@for (row of rows(); track row.value) {
<li>
<span class="ts-title">{{ row.value }}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.count }}</span>
</li>
}
<li class="ts-muted">
<span class="ts-title">Sin valor</span>
<span class="ts-bar"
><span [style.width.%]="noneShare()"></span
></span>
<span class="ts-days">{{ field.none }}</span>
</li>
</ul>
}
`,
styles: `
.ts-breakdown li {
grid-template-columns: minmax(6rem, 10rem) 1fr 2.5rem;
align-items: center;
}
.ts-breakdown .ts-days {
text-align: right;
}
.ts-breakdown__pick {
min-width: 10rem;
}
`,
})
export class FieldBreakdownComponent {
readonly fields = input.required<FieldBreakdown[]>();
readonly workflow = input.required<Workflow>();
protected readonly options = computed<PiSelectFieldOption<string>[]>(() =>
this.fields().map((f) => ({
value: f.fieldId,
label: this.nameOf(f.fieldId),
})),
);
/** The field shown; the first one until another is picked. */
protected readonly chosen = linkedSignal<string | null>(
() => this.fields()[0]?.fieldId ?? null,
);
protected readonly current = computed(
() =>
this.fields().find((f) => f.fieldId === this.chosen()) ??
this.fields()[0] ??
null,
);
protected readonly name = computed(() => {
const field = this.current();
return field ? this.nameOf(field.fieldId).toLocaleLowerCase('es') : '';
});
private readonly max = computed(() => {
const field = this.current();
return Math.max(
1,
field?.none ?? 0,
...(field?.counts.map((c) => c.count) ?? []),
);
});
protected readonly rows = computed(() =>
(this.current()?.counts ?? []).map((c) => ({
...c,
share: (c.count / this.max()) * 100,
})),
);
protected readonly noneShare = computed(
() => ((this.current()?.none ?? 0) / this.max()) * 100,
);
private nameOf(fieldId: string): string {
return this.workflow().customField(fieldId)?.name ?? fieldId;
}
}

View File

@@ -0,0 +1,202 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Quality, Workflow } from '@task-stream/web/tracker/domain';
import { PRIORITY } from '../copy';
const short = (day: string) =>
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
/** Open bugs, how they come and go, what QA sends back and what escapes. */
@Component({
selector: 'ts-quality',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'quality-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="quality-title">Calidad</h2>
<span class="ts-muted">Bugs: los tipos con icono de bicho</span>
</header>
<h3>Bugs abiertos ({{ openBugs() }})</h3>
<ul class="ts-issues ts-quality__rows">
@for (row of priorities(); track row.priority) {
<li>
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
<span class="ts-days">{{ row.count }}</span>
</li>
}
</ul>
<h3>Últimas 8 semanas</h3>
<div class="ts-quality__weeks" role="img" [attr.aria-label]="weeksLabel()">
@for (week of weeks(); track week.week) {
<span class="ts-quality__week" [attr.title]="week.title">
<i class="is-created" [style.height.%]="week.createdShare"></i>
<i class="is-closed" [style.height.%]="week.closedShare"></i>
</span>
}
</div>
<p class="ts-quality__legend">
<span class="is-created">Nuevos</span>
<span class="is-closed">Cerrados</span>
</p>
@if (areas().length) {
<h3>QA por área (90 días)</h3>
<ul class="ts-issues ts-quality__rows">
@for (row of areas(); track row.label) {
<li>
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar ts-bar--warn"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days"
>{{ row.returned }}/{{ row.returned + row.passed }} vueltos</span
>
</li>
}
</ul>
}
@if (quality().escaped.length) {
<h3>Bugs en los 14 días tras cada release</h3>
<ul class="ts-issues">
@for (row of quality().escaped; track row.releaseId) {
<li>
<span class="ts-key">{{ row.name }}</span>
<span class="ts-title ts-muted">{{ shipped(row.releasedAt) }}</span>
<span class="ts-days" [class.ts-warn]="row.bugs > 0">{{
row.bugs
}}</span>
</li>
}
</ul>
}
`,
styles: `
h3 {
margin: 1rem 0 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
&:first-of-type {
margin-top: 0;
}
}
.ts-quality__rows li {
grid-template-columns: minmax(6rem, 9rem) 1fr 5.5rem;
align-items: center;
}
.ts-quality__rows .ts-days {
text-align: right;
}
.ts-bar--warn {
--tone: var(--ts-c-red, #d14343);
}
.ts-quality__weeks {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
height: 4rem;
}
.ts-quality__week {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
border-bottom: 1px solid var(--ts-line);
i {
width: 40%;
min-height: 1px;
border-radius: 2px 2px 0 0;
}
}
.is-created {
--tone: var(--ts-c-red, #d14343);
}
.is-closed {
--tone: var(--ts-c-green, #2e9e6a);
}
.ts-quality__week i {
background: var(--tone);
}
.ts-quality__legend {
display: flex;
gap: 1rem;
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.75rem;
span::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.3rem;
border-radius: 2px;
background: var(--tone);
}
}
`,
})
export class QualityComponent {
readonly quality = input.required<Quality>();
readonly workflow = input.required<Workflow>();
protected readonly openBugs = computed(() =>
this.quality().openBugsByPriority.reduce((sum, r) => sum + r.count, 0),
);
protected readonly priorities = computed(() => {
const max = Math.max(
1,
...this.quality().openBugsByPriority.map((r) => r.count),
);
return this.quality().openBugsByPriority.map((r) => ({
...r,
label: PRIORITY[r.priority].label,
share: (r.count / max) * 100,
}));
});
protected readonly weeks = computed(() => {
const list = this.quality().bugWeeks;
const max = Math.max(1, ...list.flatMap((w) => [w.created, w.closed]));
return list.map((w) => ({
...w,
createdShare: (w.created / max) * 100,
closedShare: (w.closed / max) * 100,
title: `Semana del ${short(w.week)}: ${w.created} nuevos, ${w.closed} cerrados`,
}));
});
protected readonly weeksLabel = computed(() =>
this.weeks()
.map((w) => w.title)
.join('. '),
);
protected readonly areas = computed(() => {
const rows = this.quality().qaByArea;
return rows.map((r) => ({
...r,
label: r.area ? this.workflow().area(r.area).name : 'Sin área',
share:
r.returned + r.passed
? (r.returned / (r.returned + r.passed)) * 100
: 0,
}));
});
protected shipped(day: string): string {
return `publicada el ${short(day)}`;
}
}