feat: dashboard reads the sprint, the risks, aging and bottlenecks

A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
  needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
  and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:55:52 +02:00
parent 86e313efaa
commit fe03c5bda8
41 changed files with 2774 additions and 20 deletions

View File

@@ -1366,4 +1366,53 @@ describe('task-stream (e2e)', () => {
);
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
});
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const late = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late',
dueDate: '2020-01-01',
})
).body['key'];
const waits = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Waits',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${waits}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${late}/links`, {
type: 'BLOCKS',
targetIssue: waits,
});
const insights = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
);
expect(insights.body['sprint']).toBeNull();
expect(insights.body['risks'].overdue[0]).toMatchObject({
key: late,
dueDate: '2020-01-01',
});
expect(insights.body['risks'].blocked[0].key).toBe(waits);
expect(
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
).toContain('IN_PROGRESS');
const { project } = await gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
{ k: key },
);
expect(project.insights.risks.overdueCount).toBe(1);
});
});

View File

@@ -1435,6 +1435,89 @@
]
}
},
"/api/projects/{project}/insights": {
"get": {
"description": "Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.",
"operationId": "ProjectsController_insights",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectInsightsDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
},
{}
],
"summary": "The dashboard",
"tags": [
"Projects"
]
}
},
"/api/projects/{project}/activity": {
"get": {
"operationId": "ProjectsController_activity",
@@ -11804,6 +11887,264 @@
"qa"
]
},
"SprintPulseDto": {
"type": "object",
"properties": {
"sprintId": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"startDate": {
"type": "string",
"format": "date"
},
"endDate": {
"type": "string",
"format": "date"
},
"totalDays": {
"type": "number"
},
"daysLeft": {
"type": "number",
"description": "Days left after today"
},
"tickets": {
"type": "number"
},
"doneTickets": {
"type": "number"
},
"points": {
"type": "number"
},
"donePoints": {
"type": "number"
},
"pacePerDay": {
"type": "number",
"nullable": true
},
"neededPerDay": {
"type": "number",
"nullable": true
},
"behind": {
"type": "boolean",
"description": "At this pace, what is open would not be closed"
}
},
"required": [
"sprintId",
"name",
"startDate",
"endDate",
"totalDays",
"daysLeft",
"tickets",
"doneTickets",
"points",
"donePoints",
"pacePerDay",
"neededPerDay",
"behind"
]
},
"InsightIssueDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"key": {
"type": "string",
"example": "WEB-12"
},
"title": {
"type": "string"
},
"status": {
"type": "string",
"description": "Status id"
},
"assignee": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/UserRefDto"
}
]
},
"days": {
"type": "number",
"description": "Days in its status (aging, stuck) or past / to its due date"
},
"dueDate": {
"type": "string",
"nullable": true,
"format": "date"
}
},
"required": [
"id",
"key",
"title",
"status",
"assignee",
"days",
"dueDate"
]
},
"RisksDto": {
"type": "object",
"properties": {
"overdue": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"overdueCount": {
"type": "number"
},
"dueSoon": {
"description": "Due within 7 days",
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"dueSoonCount": {
"type": "number"
},
"blocked": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"blockedCount": {
"type": "number"
},
"stuck": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"stuckCount": {
"type": "number"
},
"stuckDays": {
"type": "number"
}
},
"required": [
"overdue",
"overdueCount",
"dueSoon",
"dueSoonCount",
"blocked",
"blockedCount",
"stuck",
"stuckCount",
"stuckDays"
]
},
"AgingDto": {
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"thresholdDays": {
"type": "number",
"nullable": true,
"description": "p85 cycle time of the last 90 days"
}
},
"required": [
"issues",
"thresholdDays"
]
},
"StatusTimeDto": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"averageDays": {
"type": "number",
"nullable": true
},
"samples": {
"type": "number"
},
"open": {
"type": "number"
},
"wipLimit": {
"type": "number",
"nullable": true
},
"overLimit": {
"type": "boolean"
}
},
"required": [
"status",
"averageDays",
"samples",
"open",
"wipLimit",
"overLimit"
]
},
"ProjectInsightsDto": {
"type": "object",
"properties": {
"generatedAt": {
"type": "string"
},
"sprint": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/SprintPulseDto"
}
]
},
"risks": {
"$ref": "#/components/schemas/RisksDto"
},
"aging": {
"$ref": "#/components/schemas/AgingDto"
},
"bottlenecks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatusTimeDto"
}
}
},
"required": [
"generatedAt",
"sprint",
"risks",
"aging",
"bottlenecks"
]
},
"ActivityDto": {
"type": "object",
"properties": {

View File

@@ -18,6 +18,13 @@ input AddChecklistItemInput {
text: String!
}
type Aging {
issues: [InsightIssue!]!
"""p85 cycle time of the last 90 days"""
thresholdDays: Float
}
type AreaOption {
archived: Boolean!
id: String!
@@ -475,6 +482,19 @@ type GitRepository {
secret: String
}
"""A ticket the dashboard names, with the number that put it there"""
type InsightIssue {
assignee: UserRef
"""Days in its status (aging, stuck) or past / to its due date"""
days: Float!
dueDate: String
id: ID!
key: String!
status: String!
title: String!
}
type Issue {
"""Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]!
@@ -955,6 +975,11 @@ type Project {
description: String
id: ID!
"""
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
"""
insights: ProjectInsights!
"""Anyone can read it, also without signing in"""
isPublic: Boolean!
key: String!
@@ -996,6 +1021,15 @@ type ProjectChange {
version: Int!
}
"""The dashboard: sprint under way, risks, aging and bottlenecks"""
type ProjectInsights {
aging: Aging!
bottlenecks: [StatusTime!]!
generatedAt: String!
risks: Risks!
sprint: SprintPulse
}
type ProjectMember {
role: ProjectRole!
user: UserRef!
@@ -1221,6 +1255,21 @@ enum ReleaseStatus {
RELEASED
}
"""Tickets to look at now (worst first)"""
type Risks {
blocked: [InsightIssue!]!
blockedCount: Int!
"""Due within 7 days"""
dueSoon: [InsightIssue!]!
dueSoonCount: Int!
overdue: [InsightIssue!]!
overdueCount: Int!
stuck: [InsightIssue!]!
stuckCount: Int!
stuckDays: Int!
}
input SaveFilterInput {
criteria: SavedFilterCriteriaInput!
name: String!
@@ -1321,6 +1370,26 @@ type SprintProgress {
total: Int!
}
"""The sprint under way: time gone against work done"""
type SprintPulse {
"""At this pace, what is open would not be closed"""
behind: Boolean!
"""Days left after today"""
daysLeft: Int!
donePoints: Int!
doneTickets: Int!
endDate: String!
name: String!
neededPerDay: Float
pacePerDay: Float
points: Int!
sprintId: ID!
startDate: String!
tickets: Int!
totalDays: Int!
}
type SprintRef {
id: ID!
name: String!
@@ -1344,6 +1413,15 @@ enum StatusCategory {
TODO
}
type StatusTime {
averageDays: Float
open: Int!
overLimit: Boolean!
samples: Int!
status: String!
wipLimit: Int
}
type StatusTransitions {
from: String!
to: [String!]!

View File

@@ -285,6 +285,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects/{project}/insights": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* The dashboard
* @description Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.
*/
get: operations["ProjectsController_insights"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/activity": {
parameters: {
query?: never;
@@ -2100,6 +2120,72 @@ export interface components {
throughput: components["schemas"]["ThroughputDto"][];
qa: components["schemas"]["QaStatsDto"];
};
SprintPulseDto: {
/** Format: uuid */
sprintId: string;
name: string;
/** Format: date */
startDate: string;
/** Format: date */
endDate: string;
totalDays: number;
/** @description Days left after today */
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
/** @description At this pace, what is open would not be closed */
behind: boolean;
};
InsightIssueDto: {
/** Format: uuid */
id: string;
/** @example WEB-12 */
key: string;
title: string;
/** @description Status id */
status: string;
assignee: components["schemas"]["UserRefDto"] | null;
/** @description Days in its status (aging, stuck) or past / to its due date */
days: number;
/** Format: date */
dueDate: string | null;
};
RisksDto: {
overdue: components["schemas"]["InsightIssueDto"][];
overdueCount: number;
/** @description Due within 7 days */
dueSoon: components["schemas"]["InsightIssueDto"][];
dueSoonCount: number;
blocked: components["schemas"]["InsightIssueDto"][];
blockedCount: number;
stuck: components["schemas"]["InsightIssueDto"][];
stuckCount: number;
stuckDays: number;
};
AgingDto: {
issues: components["schemas"]["InsightIssueDto"][];
/** @description p85 cycle time of the last 90 days */
thresholdDays: number | null;
};
StatusTimeDto: {
status: string;
averageDays: number | null;
samples: number;
open: number;
wipLimit: number | null;
overLimit: boolean;
};
ProjectInsightsDto: {
generatedAt: string;
sprint: components["schemas"]["SprintPulseDto"] | null;
risks: components["schemas"]["RisksDto"];
aging: components["schemas"]["AgingDto"];
bottlenecks: components["schemas"]["StatusTimeDto"][];
};
ActivityDto: {
id: string;
/** @example IssueStatusChanged */
@@ -3882,6 +3968,64 @@ export interface operations {
};
};
};
ProjectsController_insights: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectInsightsDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
ProjectsController_activity: {
parameters: {
query?: {

View File

@@ -204,6 +204,23 @@ export class ProjectsResolver {
);
}
@Tool({
name: 'get_project_insights',
description:
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time) and the average days in each in-progress and QA status, with WIP limits.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
insights({ project }: { project: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/insights', {
params: { path: { project } },
}),
),
);
}
@Tool({
name: 'get_project_activity',
description: 'Latest activity of a project (newest first).',