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:
@@ -1366,4 +1366,53 @@ describe('task-stream (e2e)', () => {
|
|||||||
);
|
);
|
||||||
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": {
|
"/api/projects/{project}/activity": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ProjectsController_activity",
|
"operationId": "ProjectsController_activity",
|
||||||
@@ -11804,6 +11887,264 @@
|
|||||||
"qa"
|
"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": {
|
"ActivityDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ input AddChecklistItemInput {
|
|||||||
text: String!
|
text: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Aging {
|
||||||
|
issues: [InsightIssue!]!
|
||||||
|
|
||||||
|
"""p85 cycle time of the last 90 days"""
|
||||||
|
thresholdDays: Float
|
||||||
|
}
|
||||||
|
|
||||||
type AreaOption {
|
type AreaOption {
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
id: String!
|
id: String!
|
||||||
@@ -475,6 +482,19 @@ type GitRepository {
|
|||||||
secret: String
|
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 {
|
type Issue {
|
||||||
"""Statuses this ticket can move to next (project's workflow)"""
|
"""Statuses this ticket can move to next (project's workflow)"""
|
||||||
allowedTransitions: [String!]!
|
allowedTransitions: [String!]!
|
||||||
@@ -955,6 +975,11 @@ type Project {
|
|||||||
description: String
|
description: String
|
||||||
id: ID!
|
id: ID!
|
||||||
|
|
||||||
|
"""
|
||||||
|
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
|
||||||
|
"""
|
||||||
|
insights: ProjectInsights!
|
||||||
|
|
||||||
"""Anyone can read it, also without signing in"""
|
"""Anyone can read it, also without signing in"""
|
||||||
isPublic: Boolean!
|
isPublic: Boolean!
|
||||||
key: String!
|
key: String!
|
||||||
@@ -996,6 +1021,15 @@ type ProjectChange {
|
|||||||
version: Int!
|
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 {
|
type ProjectMember {
|
||||||
role: ProjectRole!
|
role: ProjectRole!
|
||||||
user: UserRef!
|
user: UserRef!
|
||||||
@@ -1221,6 +1255,21 @@ enum ReleaseStatus {
|
|||||||
RELEASED
|
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 {
|
input SaveFilterInput {
|
||||||
criteria: SavedFilterCriteriaInput!
|
criteria: SavedFilterCriteriaInput!
|
||||||
name: String!
|
name: String!
|
||||||
@@ -1321,6 +1370,26 @@ type SprintProgress {
|
|||||||
total: Int!
|
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 {
|
type SprintRef {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
@@ -1344,6 +1413,15 @@ enum StatusCategory {
|
|||||||
TODO
|
TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusTime {
|
||||||
|
averageDays: Float
|
||||||
|
open: Int!
|
||||||
|
overLimit: Boolean!
|
||||||
|
samples: Int!
|
||||||
|
status: String!
|
||||||
|
wipLimit: Int
|
||||||
|
}
|
||||||
|
|
||||||
type StatusTransitions {
|
type StatusTransitions {
|
||||||
from: String!
|
from: String!
|
||||||
to: [String!]!
|
to: [String!]!
|
||||||
|
|||||||
@@ -285,6 +285,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/projects/{project}/activity": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2100,6 +2120,72 @@ export interface components {
|
|||||||
throughput: components["schemas"]["ThroughputDto"][];
|
throughput: components["schemas"]["ThroughputDto"][];
|
||||||
qa: components["schemas"]["QaStatsDto"];
|
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: {
|
ActivityDto: {
|
||||||
id: string;
|
id: string;
|
||||||
/** @example IssueStatusChanged */
|
/** @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: {
|
ProjectsController_activity: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -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({
|
@Tool({
|
||||||
name: 'get_project_activity',
|
name: 'get_project_activity',
|
||||||
description: 'Latest activity of a project (newest first).',
|
description: 'Latest activity of a project (newest first).',
|
||||||
|
|||||||
@@ -379,6 +379,16 @@ puras (`application/src/lib/analytics`):
|
|||||||
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
||||||
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||||
|
|
||||||
|
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
||||||
|
decir qué mirar ahora (`analytics/insights.ts`):
|
||||||
|
|
||||||
|
| Bloque | Qué da |
|
||||||
|
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
|
||||||
|
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
|
||||||
|
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
|
||||||
|
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
|
||||||
|
|
||||||
## 3.2 GraphQL
|
## 3.2 GraphQL
|
||||||
|
|
||||||
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
||||||
@@ -621,7 +631,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
||||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ visualización de la plataforma:
|
|||||||
| `/p/:key/releases` | Releases |
|
| `/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/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/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, equipo (cada persona enlaza a su tablero) y tipos |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, sprint en curso (¿llega?), tickets en riesgo, antigüedad de lo que está en curso, cuellos de botella, equipo (cada persona enlaza a su tablero) y tipos. Se recarga solo con cada cambio |
|
||||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
| `/p/:key/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/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ flowchart LR
|
|||||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||||
| | `get_project_stats` | Cifras de seguimiento |
|
| | `get_project_stats` | Cifras de seguimiento |
|
||||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
|
| | `get_project_insights` | El panel: sprint en curso, tickets en riesgo, antigüedad y cuellos de botella |
|
||||||
| | `get_project_activity` | Actividad reciente |
|
| | `get_project_activity` | Actividad reciente |
|
||||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
| 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` |
|
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ cuando hay pistas, por dónde empezar.
|
|||||||
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
|
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
|
||||||
- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
|
- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
|
||||||
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
|
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
|
||||||
|
- **Escrituras a la vez en un ticket**: si una saga (por ejemplo, apuntar como seguidor a quien se asigna) escribe en
|
||||||
|
el mismo ticket justo cuando alguien lo cambia, la petición de esa persona recibe un 409 y hay que repetirla. Con la
|
||||||
|
máquina muy cargada salta en las pruebas e2e de vez en cuando. Reintentar sola la orden ante un `ConcurrencyError`.
|
||||||
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
|
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
|
||||||
renovarlo a mano cuando caduca.
|
renovarlo a mano cuando caduca.
|
||||||
|
|
||||||
@@ -32,22 +35,16 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
En el panel del proyecto:
|
En el panel del proyecto:
|
||||||
|
|
||||||
1. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
1. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
||||||
2. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
|
||||||
curso), cada grupo con enlace a la lista filtrada.
|
|
||||||
3. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
|
||||||
proyecto (p85 del tiempo de ciclo).
|
|
||||||
4. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
|
|
||||||
5. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
|
||||||
con semáforo.
|
con semáforo.
|
||||||
6. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
2. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
||||||
7. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
3. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
||||||
sobrecarga y sus bloqueados.
|
sobrecarga y sus bloqueados.
|
||||||
8. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
4. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
||||||
9. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
5. **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.
|
siguientes a cada release.
|
||||||
10. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
6. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
||||||
navegador»).
|
navegador»).
|
||||||
|
|
||||||
### Aceptadas, para después de esta tanda
|
### Aceptadas, para después de esta tanda
|
||||||
|
|
||||||
@@ -127,3 +124,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
||||||
existían, en Sprints.)
|
existían, en Sprints.)
|
||||||
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
|
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
|
||||||
|
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
|
||||||
|
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
|
||||||
|
y MCP (`get_project_insights`).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -19,7 +19,7 @@ import {
|
|||||||
const DAY = 86_400_000;
|
const DAY = 86_400_000;
|
||||||
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
||||||
|
|
||||||
interface Step {
|
export interface Step {
|
||||||
at: number;
|
at: number;
|
||||||
status: string;
|
status: string;
|
||||||
category: StatusCategory;
|
category: StatusCategory;
|
||||||
@@ -63,7 +63,7 @@ function weeks(from: number, to: number): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Status steps per ticket; tickets with no timeline get one from their creation. */
|
/** Status steps per ticket; tickets with no timeline get one from their creation. */
|
||||||
function stepsByIssue(
|
export function stepsByIssue(
|
||||||
issues: IssueReadModel[],
|
issues: IssueReadModel[],
|
||||||
timeline: IssueTimelineEntry[],
|
timeline: IssueTimelineEntry[],
|
||||||
): Map<string, Step[]> {
|
): Map<string, Step[]> {
|
||||||
|
|||||||
177
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
177
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
defaultProjectSettings,
|
||||||
|
IssueLinkType,
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
SprintReadModel,
|
||||||
|
SprintStatus,
|
||||||
|
StatusCategory as C,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeInsights, STUCK_DAYS } from './insights';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-10-15T12:00:00Z');
|
||||||
|
const bob = { id: 'bob', name: 'Bob' };
|
||||||
|
|
||||||
|
const issue = (
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
category: C,
|
||||||
|
extra: Partial<IssueReadModel> = {},
|
||||||
|
): IssueReadModel =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
key: id,
|
||||||
|
title: `Ticket ${id}`,
|
||||||
|
status,
|
||||||
|
statusCategory: category,
|
||||||
|
assignee: bob,
|
||||||
|
reporter: bob,
|
||||||
|
sprint: null,
|
||||||
|
storyPoints: null,
|
||||||
|
dueDate: null,
|
||||||
|
links: [],
|
||||||
|
trashedAt: null,
|
||||||
|
createdAt: '2026-09-01T09:00:00Z',
|
||||||
|
updatedAt: '2026-10-14T09:00:00Z',
|
||||||
|
...extra,
|
||||||
|
}) as IssueReadModel;
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const step = (
|
||||||
|
issueId: string,
|
||||||
|
at: string,
|
||||||
|
status: string,
|
||||||
|
category: C,
|
||||||
|
): IssueTimelineEntry => ({
|
||||||
|
id: `${issueId}:${++seq}`,
|
||||||
|
issueId,
|
||||||
|
projectId: 'p',
|
||||||
|
at,
|
||||||
|
kind: 'status',
|
||||||
|
status,
|
||||||
|
category,
|
||||||
|
assigneeId: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sprint: SprintReadModel = {
|
||||||
|
id: 's',
|
||||||
|
projectId: 'p',
|
||||||
|
name: 'Sprint 7',
|
||||||
|
goal: null,
|
||||||
|
startDate: '2026-10-12',
|
||||||
|
endDate: '2026-10-25',
|
||||||
|
status: SprintStatus.ACTIVE,
|
||||||
|
startedAt: '2026-10-12T08:00:00Z',
|
||||||
|
closedAt: null,
|
||||||
|
createdAt: '2026-10-01T08:00:00Z',
|
||||||
|
version: 2,
|
||||||
|
};
|
||||||
|
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
|
||||||
|
|
||||||
|
describe('computeInsights', () => {
|
||||||
|
it('names what is late, due soon, blocked and stuck', () => {
|
||||||
|
const issues = [
|
||||||
|
issue('LATE', 'TODO', C.TODO, { dueDate: '2026-10-10' }),
|
||||||
|
issue('SOON', 'TODO', C.TODO, { dueDate: '2026-10-20' }),
|
||||||
|
issue('LATER', 'TODO', C.TODO, { dueDate: '2026-11-30' }),
|
||||||
|
issue('DONE_LATE', 'DONE', C.DONE, { dueDate: '2026-10-01' }),
|
||||||
|
issue('WAITS', 'TODO', C.TODO, {
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
type: IssueLinkType.BLOCKS,
|
||||||
|
direction: 'INWARD',
|
||||||
|
issueId: 'SLOW',
|
||||||
|
issueKey: 'SLOW',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
issue('SLOW', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||||
|
issue('FRESH', 'IN_QA', C.QA),
|
||||||
|
];
|
||||||
|
const timeline = [
|
||||||
|
step('SLOW', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||||
|
step('FRESH', '2026-10-14T10:00:00Z', 'IN_QA', C.QA),
|
||||||
|
];
|
||||||
|
const { risks, aging } = computeInsights({
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
settings: defaultProjectSettings(),
|
||||||
|
activeSprint: null,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]);
|
||||||
|
expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]);
|
||||||
|
expect(risks.blocked.map((i) => i.key)).toEqual(['WAITS']);
|
||||||
|
expect(risks.stuck.map((i) => [i.key, i.days])).toEqual([['SLOW', 14]]);
|
||||||
|
expect(risks.stuckDays).toBe(STUCK_DAYS);
|
||||||
|
// Everything under way, the longest first.
|
||||||
|
expect(aging.issues.map((i) => [i.key, i.days])).toEqual([
|
||||||
|
['SLOW', 14],
|
||||||
|
['FRESH', 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures the sprint under way against the calendar', () => {
|
||||||
|
const issues = [
|
||||||
|
issue('A', 'DONE', C.DONE, { ...inSprint, storyPoints: 3 }),
|
||||||
|
issue('B', 'IN_PROGRESS', C.IN_PROGRESS, { ...inSprint, storyPoints: 8 }),
|
||||||
|
issue('C', 'TODO', C.TODO, { ...inSprint, storyPoints: 5 }),
|
||||||
|
issue('X', 'CANCELLED', C.CANCELLED, { ...inSprint, storyPoints: 13 }),
|
||||||
|
];
|
||||||
|
const { sprint: pulse } = computeInsights({
|
||||||
|
issues,
|
||||||
|
timeline: [],
|
||||||
|
settings: defaultProjectSettings(),
|
||||||
|
activeSprint: sprint,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(pulse).toMatchObject({
|
||||||
|
totalDays: 14,
|
||||||
|
daysLeft: 10,
|
||||||
|
tickets: 3,
|
||||||
|
doneTickets: 1,
|
||||||
|
points: 16,
|
||||||
|
donePoints: 3,
|
||||||
|
// 3 points in 3 days; 13 left for 11 days.
|
||||||
|
pacePerDay: 1,
|
||||||
|
neededPerDay: 1.2,
|
||||||
|
behind: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds where tickets wait, and columns over their limit', () => {
|
||||||
|
const settings = defaultProjectSettings();
|
||||||
|
settings.statuses.find((s) => s.id === 'IN_REVIEW')!.wipLimit = 1;
|
||||||
|
const issues = [
|
||||||
|
issue('A', 'DONE', C.DONE),
|
||||||
|
issue('B', 'IN_REVIEW', C.IN_PROGRESS),
|
||||||
|
issue('C', 'IN_REVIEW', C.IN_PROGRESS),
|
||||||
|
];
|
||||||
|
const timeline = [
|
||||||
|
step('A', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||||
|
step('A', '2026-10-03T10:00:00Z', 'IN_REVIEW', C.IN_PROGRESS),
|
||||||
|
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
|
||||||
|
];
|
||||||
|
const rows = computeInsights({
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
settings,
|
||||||
|
activeSprint: null,
|
||||||
|
now: NOW,
|
||||||
|
}).bottlenecks;
|
||||||
|
expect(rows.map((r) => r.status)).toEqual([
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'IN_REVIEW',
|
||||||
|
'READY_FOR_QA',
|
||||||
|
'IN_QA',
|
||||||
|
'QA_FAILED',
|
||||||
|
]);
|
||||||
|
expect(rows[0]).toMatchObject({ averageDays: 2, samples: 1, open: 0 });
|
||||||
|
expect(rows[1]).toMatchObject({
|
||||||
|
averageDays: 4,
|
||||||
|
open: 2,
|
||||||
|
wipLimit: 1,
|
||||||
|
overLimit: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
227
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
227
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
InsightIssueReadModel,
|
||||||
|
IssueLinkType,
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
ProjectInsightsReadModel,
|
||||||
|
ProjectSettings,
|
||||||
|
SprintPulseReadModel,
|
||||||
|
SprintReadModel,
|
||||||
|
StatusCategory,
|
||||||
|
StatusTimeReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The dashboard's reading of a project: what needs attention now (risks),
|
||||||
|
* how long the work under way has been where it is (aging) and where it
|
||||||
|
* waits (bottlenecks). Pure functions over the read model and the
|
||||||
|
* timeline, like the statistics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
/** Days without moving that make a ticket under way «stuck». */
|
||||||
|
export const STUCK_DAYS = 7;
|
||||||
|
/** How many tickets each list names. */
|
||||||
|
const LIST = 8;
|
||||||
|
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
||||||
|
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||||
|
|
||||||
|
export interface InsightsInput {
|
||||||
|
issues: IssueReadModel[];
|
||||||
|
timeline: IssueTimelineEntry[];
|
||||||
|
settings: ProjectSettings;
|
||||||
|
activeSprint: SprintReadModel | null;
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcDay = (time: number) =>
|
||||||
|
Date.UTC(
|
||||||
|
new Date(time).getUTCFullYear(),
|
||||||
|
new Date(time).getUTCMonth(),
|
||||||
|
new Date(time).getUTCDate(),
|
||||||
|
);
|
||||||
|
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
|
||||||
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
export function computeInsights(
|
||||||
|
input: InsightsInput,
|
||||||
|
): ProjectInsightsReadModel {
|
||||||
|
const now = input.now.getTime();
|
||||||
|
const today = utcDay(now);
|
||||||
|
const live = input.issues.filter((i) => !i.trashedAt);
|
||||||
|
const open = live.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||||
|
const steps = stepsByIssue(live, input.timeline);
|
||||||
|
|
||||||
|
/** When it got to its current status. */
|
||||||
|
const sinceOf = (issue: IssueReadModel) => {
|
||||||
|
const list = steps.get(issue.id) ?? [];
|
||||||
|
const last = list[list.length - 1];
|
||||||
|
return last && last.status === issue.status
|
||||||
|
? last.at
|
||||||
|
: Date.parse(issue.updatedAt);
|
||||||
|
};
|
||||||
|
const line = (
|
||||||
|
issue: IssueReadModel,
|
||||||
|
days: number,
|
||||||
|
): InsightIssueReadModel => ({
|
||||||
|
id: issue.id,
|
||||||
|
key: issue.key,
|
||||||
|
title: issue.title,
|
||||||
|
status: issue.status,
|
||||||
|
assignee: issue.assignee,
|
||||||
|
days,
|
||||||
|
dueDate: issue.dueDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Risks ---
|
||||||
|
const due = open
|
||||||
|
.filter((i) => i.dueDate)
|
||||||
|
.map((i) => ({
|
||||||
|
issue: i,
|
||||||
|
days: (dayIndex(i.dueDate as string) - today) / DAY,
|
||||||
|
}));
|
||||||
|
const overdue = due
|
||||||
|
.filter((d) => d.days < 0)
|
||||||
|
.sort((a, b) => a.days - b.days)
|
||||||
|
.map((d) => line(d.issue, -d.days));
|
||||||
|
const dueSoon = due
|
||||||
|
.filter((d) => d.days >= 0 && d.days <= 7)
|
||||||
|
.sort((a, b) => a.days - b.days)
|
||||||
|
.map((d) => line(d.issue, d.days));
|
||||||
|
const openIds = new Set(open.map((i) => i.id));
|
||||||
|
const blocked = open
|
||||||
|
.filter((i) =>
|
||||||
|
i.links.some(
|
||||||
|
(l) =>
|
||||||
|
openIds.has(l.issueId) &&
|
||||||
|
((l.type === IssueLinkType.BLOCKS && l.direction === 'INWARD') ||
|
||||||
|
(l.type === IssueLinkType.DEPENDS_ON && l.direction === 'OUTWARD')),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
|
||||||
|
.sort((a, b) => b.days - a.days);
|
||||||
|
const underWay = open
|
||||||
|
.filter((i) => FLOW.includes(i.statusCategory))
|
||||||
|
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
|
||||||
|
.sort((a, b) => b.days - a.days);
|
||||||
|
const stuck = underWay.filter((i) => i.days >= STUCK_DAYS);
|
||||||
|
|
||||||
|
// --- Aging: against the cycle time of the last 90 days ---
|
||||||
|
const analytics = computeProjectAnalytics({
|
||||||
|
issues: live,
|
||||||
|
timeline: input.timeline,
|
||||||
|
statusOrder: input.settings.statuses.map((s) => s.id),
|
||||||
|
from: new Date(now - 89 * DAY),
|
||||||
|
to: input.now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: input.now.toISOString(),
|
||||||
|
sprint: input.activeSprint
|
||||||
|
? sprintPulse(input.activeSprint, live, now)
|
||||||
|
: null,
|
||||||
|
risks: {
|
||||||
|
overdue: overdue.slice(0, LIST),
|
||||||
|
overdueCount: overdue.length,
|
||||||
|
dueSoon: dueSoon.slice(0, LIST),
|
||||||
|
dueSoonCount: dueSoon.length,
|
||||||
|
blocked: blocked.slice(0, LIST),
|
||||||
|
blockedCount: blocked.length,
|
||||||
|
stuck: stuck.slice(0, LIST),
|
||||||
|
stuckCount: stuck.length,
|
||||||
|
stuckDays: STUCK_DAYS,
|
||||||
|
},
|
||||||
|
aging: {
|
||||||
|
issues: underWay.slice(0, 15),
|
||||||
|
thresholdDays: analytics.cycleTime.p85Days,
|
||||||
|
},
|
||||||
|
bottlenecks: bottlenecks(input.settings, open, steps, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sprintPulse(
|
||||||
|
sprint: SprintReadModel,
|
||||||
|
issues: IssueReadModel[],
|
||||||
|
now: number,
|
||||||
|
): SprintPulseReadModel {
|
||||||
|
const inSprint = issues.filter(
|
||||||
|
(i) =>
|
||||||
|
i.sprint?.id === sprint.id &&
|
||||||
|
i.statusCategory !== StatusCategory.CANCELLED,
|
||||||
|
);
|
||||||
|
const done = inSprint.filter((i) => i.statusCategory === StatusCategory.DONE);
|
||||||
|
const points = (list: IssueReadModel[]) =>
|
||||||
|
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
|
||||||
|
const start = dayIndex(sprint.startDate);
|
||||||
|
const end = dayIndex(sprint.endDate);
|
||||||
|
const today = utcDay(now);
|
||||||
|
const totalDays = Math.round((end - start) / DAY) + 1;
|
||||||
|
const daysLeft = Math.round((end - today) / DAY);
|
||||||
|
// Days already worked: today counts once it is half gone.
|
||||||
|
const elapsed = Math.min(
|
||||||
|
totalDays,
|
||||||
|
Math.max(0, Math.round((today - start) / DAY)),
|
||||||
|
);
|
||||||
|
const total = points(inSprint);
|
||||||
|
const closed = points(done);
|
||||||
|
const remaining = total - closed;
|
||||||
|
const pace = elapsed > 0 ? round1(closed / elapsed) : null;
|
||||||
|
const left = Math.max(daysLeft + 1, 0);
|
||||||
|
const needed =
|
||||||
|
remaining > 0 ? (left > 0 ? round1(remaining / left) : null) : null;
|
||||||
|
return {
|
||||||
|
sprintId: sprint.id,
|
||||||
|
name: sprint.name,
|
||||||
|
startDate: sprint.startDate,
|
||||||
|
endDate: sprint.endDate,
|
||||||
|
totalDays,
|
||||||
|
daysLeft,
|
||||||
|
tickets: inSprint.length,
|
||||||
|
doneTickets: done.length,
|
||||||
|
points: total,
|
||||||
|
donePoints: closed,
|
||||||
|
pacePerDay: pace,
|
||||||
|
neededPerDay: needed,
|
||||||
|
behind:
|
||||||
|
remaining > 0 &&
|
||||||
|
(left === 0 || (pace !== null && pace * left < remaining)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average stay in each in-progress and QA status (stays that ended lately). */
|
||||||
|
function bottlenecks(
|
||||||
|
settings: ProjectSettings,
|
||||||
|
open: IssueReadModel[],
|
||||||
|
steps: Map<string, Step[]>,
|
||||||
|
now: number,
|
||||||
|
): StatusTimeReadModel[] {
|
||||||
|
const from = now - 90 * DAY;
|
||||||
|
const stays = new Map<string, number[]>();
|
||||||
|
for (const list of steps.values())
|
||||||
|
list.forEach((step, i) => {
|
||||||
|
const next = list[i + 1];
|
||||||
|
if (!next || next.at < from) return;
|
||||||
|
stays.set(step.status, [
|
||||||
|
...(stays.get(step.status) ?? []),
|
||||||
|
next.at - step.at,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
return settings.statuses
|
||||||
|
.filter((s) => !s.archived && FLOW.includes(s.category))
|
||||||
|
.map((s) => {
|
||||||
|
const measured = stays.get(s.id) ?? [];
|
||||||
|
const count = open.filter((i) => i.status === s.id).length;
|
||||||
|
const limit = s.wipLimit ?? null;
|
||||||
|
return {
|
||||||
|
status: s.id,
|
||||||
|
averageDays: measured.length
|
||||||
|
? round1(measured.reduce((a, b) => a + b, 0) / measured.length / DAY)
|
||||||
|
: null,
|
||||||
|
samples: measured.length,
|
||||||
|
open: count,
|
||||||
|
wipLimit: limit,
|
||||||
|
overLimit: limit !== null && count > limit,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
ProjectAnalyticsReadModel,
|
ProjectAnalyticsReadModel,
|
||||||
|
ProjectInsightsReadModel,
|
||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
@@ -34,6 +35,7 @@ import { IssueFilter } from './ports/find-issues.repository';
|
|||||||
import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
|
import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
|
||||||
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
||||||
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
||||||
|
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
|
||||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||||
import { GetProjectQuery } from './queries/get-project.query';
|
import { GetProjectQuery } from './queries/get-project.query';
|
||||||
@@ -321,6 +323,16 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||||
|
/** Sprint under way, risks, aging and bottlenecks, for the dashboard. */
|
||||||
|
insights(
|
||||||
|
viewerId: string | null,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ProjectInsightsReadModel> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetProjectInsightsQuery(viewerId, projectId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
analytics(
|
analytics(
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
|
ProjectInsightsReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeInsights } from '../analytics/insights';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
|
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||||
|
import { GetProjectInsightsQuery } from './get-project-insights.query';
|
||||||
|
import { ReadAccess } from './read-access';
|
||||||
|
|
||||||
|
@QueryHandler(GetProjectInsightsQuery)
|
||||||
|
export class GetProjectInsightsQueryHandler
|
||||||
|
implements IQueryHandler<GetProjectInsightsQuery, ProjectInsightsReadModel>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
private readonly findProjects: FindProjectsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly findTimeline: FindTimelineRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetProjectInsightsQuery,
|
||||||
|
): Promise<ProjectInsightsReadModel> {
|
||||||
|
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||||
|
const [project, issues, timeline, activeSprint] = await Promise.all([
|
||||||
|
this.findProjects.findById(query.projectId),
|
||||||
|
this.findIssues.findByProject(query.projectId),
|
||||||
|
this.findTimeline.findByProject(query.projectId),
|
||||||
|
this.findSprints.findActive(query.projectId),
|
||||||
|
]);
|
||||||
|
return computeInsights({
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||||
|
activeSprint,
|
||||||
|
now: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Query } from '@nestjs/cqrs';
|
||||||
|
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/** The dashboard's reading of a project: sprint, risks, aging, bottlenecks. */
|
||||||
|
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
|
||||||
|
constructor(
|
||||||
|
/** Null: anonymous (public projects only). */
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly projectId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export * from './get-issue.query';
|
|||||||
export * from './get-issues-by-ids.query';
|
export * from './get-issues-by-ids.query';
|
||||||
export * from './get-my-projects.query';
|
export * from './get-my-projects.query';
|
||||||
export * from './get-project-analytics.query';
|
export * from './get-project-analytics.query';
|
||||||
|
export * from './get-project-insights.query';
|
||||||
export * from './get-project-stats.query';
|
export * from './get-project-stats.query';
|
||||||
export * from './get-release-burnup.query';
|
export * from './get-release-burnup.query';
|
||||||
export * from './get-project.query';
|
export * from './get-project.query';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
|
|||||||
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
|
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
|
||||||
import { GetProjectQueryHandler } from './get-project.query-handler';
|
import { GetProjectQueryHandler } from './get-project.query-handler';
|
||||||
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
||||||
|
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
||||||
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
||||||
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
||||||
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
||||||
@@ -34,6 +35,7 @@ export const QUERY_HANDLERS = [
|
|||||||
GetMyProjectsQueryHandler,
|
GetMyProjectsQueryHandler,
|
||||||
GetProjectQueryHandler,
|
GetProjectQueryHandler,
|
||||||
GetProjectAnalyticsQueryHandler,
|
GetProjectAnalyticsQueryHandler,
|
||||||
|
GetProjectInsightsQueryHandler,
|
||||||
GetProjectStatsQueryHandler,
|
GetProjectStatsQueryHandler,
|
||||||
GetReleaseBurnupQueryHandler,
|
GetReleaseBurnupQueryHandler,
|
||||||
GetReleaseQueryHandler,
|
GetReleaseQueryHandler,
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './analytics.read-model';
|
|||||||
export * from './notification.read-model';
|
export * from './notification.read-model';
|
||||||
export * from './saved-filter.read-model';
|
export * from './saved-filter.read-model';
|
||||||
export * from './sprint.read-model';
|
export * from './sprint.read-model';
|
||||||
|
export * from './insights.read-model';
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { UserRefReadModel } from './user-ref.read-model';
|
||||||
|
|
||||||
|
/** A ticket named by the dashboard, with the number that put it there. */
|
||||||
|
export class InsightIssueReadModel {
|
||||||
|
id!: string;
|
||||||
|
key!: string;
|
||||||
|
title!: string;
|
||||||
|
/** Status id of the project's settings. */
|
||||||
|
status!: string;
|
||||||
|
assignee!: UserRefReadModel | null;
|
||||||
|
/** Days in its current status (aging, stuck) or past/to its due date. */
|
||||||
|
days!: number;
|
||||||
|
dueDate!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sprint under way: time gone against work done. */
|
||||||
|
export class SprintPulseReadModel {
|
||||||
|
sprintId!: string;
|
||||||
|
name!: string;
|
||||||
|
startDate!: string;
|
||||||
|
endDate!: string;
|
||||||
|
/** Days of the sprint, both ends included. */
|
||||||
|
totalDays!: number;
|
||||||
|
/** Days left after today (0 on the last day; negative once it is over). */
|
||||||
|
daysLeft!: number;
|
||||||
|
tickets!: number;
|
||||||
|
doneTickets!: number;
|
||||||
|
points!: number;
|
||||||
|
donePoints!: number;
|
||||||
|
/** Points closed per day so far (null on the first day). */
|
||||||
|
pacePerDay!: number | null;
|
||||||
|
/** Points per day still needed to finish (null when nothing is left). */
|
||||||
|
neededPerDay!: number | null;
|
||||||
|
/** At this pace, what is open would not be closed in time. */
|
||||||
|
behind!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets to look at now. Lists are the worst first, a few at most. */
|
||||||
|
export class RiskReadModel {
|
||||||
|
overdue!: InsightIssueReadModel[];
|
||||||
|
overdueCount!: number;
|
||||||
|
/** Due within the next 7 days. */
|
||||||
|
dueSoon!: InsightIssueReadModel[];
|
||||||
|
dueSoonCount!: number;
|
||||||
|
/** Waiting for an open ticket that blocks them (or they depend on). */
|
||||||
|
blocked!: InsightIssueReadModel[];
|
||||||
|
blockedCount!: number;
|
||||||
|
/** In progress or in QA for `stuckDays` or more without moving. */
|
||||||
|
stuck!: InsightIssueReadModel[];
|
||||||
|
stuckCount!: number;
|
||||||
|
stuckDays!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the work under way has been where it is. */
|
||||||
|
export class AgingReadModel {
|
||||||
|
issues!: InsightIssueReadModel[];
|
||||||
|
/** 85% of the tickets finished lately took at most this (cycle time). */
|
||||||
|
thresholdDays!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time tickets spend in a status of the flow, and how many are there. */
|
||||||
|
export class StatusTimeReadModel {
|
||||||
|
status!: string;
|
||||||
|
/** Average stay of the tickets that left it in the last 90 days. */
|
||||||
|
averageDays!: number | null;
|
||||||
|
/** Stays measured. */
|
||||||
|
samples!: number;
|
||||||
|
open!: number;
|
||||||
|
wipLimit!: number | null;
|
||||||
|
overLimit!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the project dashboard reads beyond the counts. */
|
||||||
|
export class ProjectInsightsReadModel {
|
||||||
|
generatedAt!: string;
|
||||||
|
sprint!: SprintPulseReadModel | null;
|
||||||
|
risks!: RiskReadModel;
|
||||||
|
aging!: AgingReadModel;
|
||||||
|
/** In-progress and QA statuses, in board order. */
|
||||||
|
bottlenecks!: StatusTimeReadModel[];
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
BoardColumnGql,
|
BoardColumnGql,
|
||||||
CommandResultGql,
|
CommandResultGql,
|
||||||
ProjectAnalyticsGql,
|
ProjectAnalyticsGql,
|
||||||
|
ProjectInsightsGql,
|
||||||
ProjectGql,
|
ProjectGql,
|
||||||
ProjectSettingsGql,
|
ProjectSettingsGql,
|
||||||
ProjectStatsGql,
|
ProjectStatsGql,
|
||||||
@@ -83,6 +84,17 @@ export class ProjectResolver {
|
|||||||
return this.projects.analytics(viewerOf(me), project.id, days);
|
return this.projects.analytics(viewerOf(me), project.id, days);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => ProjectInsightsGql, {
|
||||||
|
description:
|
||||||
|
'The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks',
|
||||||
|
})
|
||||||
|
insights(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Parent() project: ProjectReadModel,
|
||||||
|
) {
|
||||||
|
return this.projects.insights(viewerOf(me), project.id);
|
||||||
|
}
|
||||||
|
|
||||||
@ResolveField(() => [ReleaseGql])
|
@ResolveField(() => [ReleaseGql])
|
||||||
releases(
|
releases(
|
||||||
@CurrentUser() me: User | undefined,
|
@CurrentUser() me: User | undefined,
|
||||||
|
|||||||
@@ -794,3 +794,87 @@ export class ReleaseBurnupGql {
|
|||||||
scope!: number[];
|
scope!: number[];
|
||||||
@Field(() => [Int]) done!: number[];
|
@Field(() => [Int]) done!: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('InsightIssue', {
|
||||||
|
description:
|
||||||
|
'A ticket the dashboard names, with the number that put it there',
|
||||||
|
})
|
||||||
|
export class InsightIssueGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field() key!: string;
|
||||||
|
@Field() title!: string;
|
||||||
|
@Field() status!: string;
|
||||||
|
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
|
||||||
|
@Field(() => Float, {
|
||||||
|
description: 'Days in its status (aging, stuck) or past / to its due date',
|
||||||
|
})
|
||||||
|
days!: number;
|
||||||
|
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SprintPulse', {
|
||||||
|
description: 'The sprint under way: time gone against work done',
|
||||||
|
})
|
||||||
|
export class SprintPulseGql {
|
||||||
|
@Field(() => ID) sprintId!: string;
|
||||||
|
@Field() name!: string;
|
||||||
|
@Field() startDate!: string;
|
||||||
|
@Field() endDate!: string;
|
||||||
|
@Field(() => Int) totalDays!: number;
|
||||||
|
@Field(() => Int, { description: 'Days left after today' })
|
||||||
|
daysLeft!: number;
|
||||||
|
@Field(() => Int) tickets!: number;
|
||||||
|
@Field(() => Int) doneTickets!: number;
|
||||||
|
@Field(() => Int) points!: number;
|
||||||
|
@Field(() => Int) donePoints!: number;
|
||||||
|
@Field(() => Float, { nullable: true }) pacePerDay!: number | null;
|
||||||
|
@Field(() => Float, { nullable: true }) neededPerDay!: number | null;
|
||||||
|
@Field({ description: 'At this pace, what is open would not be closed' })
|
||||||
|
behind!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Risks', { description: 'Tickets to look at now (worst first)' })
|
||||||
|
export class RiskGql {
|
||||||
|
@Field(() => [InsightIssueGql]) overdue!: InsightIssueGql[];
|
||||||
|
@Field(() => Int) overdueCount!: number;
|
||||||
|
@Field(() => [InsightIssueGql], { description: 'Due within 7 days' })
|
||||||
|
dueSoon!: InsightIssueGql[];
|
||||||
|
@Field(() => Int) dueSoonCount!: number;
|
||||||
|
@Field(() => [InsightIssueGql]) blocked!: InsightIssueGql[];
|
||||||
|
@Field(() => Int) blockedCount!: number;
|
||||||
|
@Field(() => [InsightIssueGql]) stuck!: InsightIssueGql[];
|
||||||
|
@Field(() => Int) stuckCount!: number;
|
||||||
|
@Field(() => Int) stuckDays!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Aging')
|
||||||
|
export class AgingGql {
|
||||||
|
@Field(() => [InsightIssueGql]) issues!: InsightIssueGql[];
|
||||||
|
@Field(() => Float, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'p85 cycle time of the last 90 days',
|
||||||
|
})
|
||||||
|
thresholdDays!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('StatusTime')
|
||||||
|
export class StatusTimeGql {
|
||||||
|
@Field() status!: string;
|
||||||
|
@Field(() => Float, { nullable: true }) averageDays!: number | null;
|
||||||
|
@Field(() => Int) samples!: number;
|
||||||
|
@Field(() => Int) open!: number;
|
||||||
|
@Field(() => Int, { nullable: true }) wipLimit!: number | null;
|
||||||
|
@Field() overLimit!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('ProjectInsights', {
|
||||||
|
description: 'The dashboard: sprint under way, risks, aging and bottlenecks',
|
||||||
|
})
|
||||||
|
export class ProjectInsightsGql {
|
||||||
|
@Field() generatedAt!: string;
|
||||||
|
@Field(() => SprintPulseGql, { nullable: true })
|
||||||
|
sprint!: SprintPulseGql | null;
|
||||||
|
@Field(() => RiskGql) risks!: RiskGql;
|
||||||
|
@Field(() => AgingGql) aging!: AgingGql;
|
||||||
|
@Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -876,3 +876,78 @@ export class NotificationSettingsResponseDto {
|
|||||||
})
|
})
|
||||||
pushPublicKey!: string | null;
|
pushPublicKey!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InsightIssueDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ example: 'WEB-12' }) key!: string;
|
||||||
|
@ApiProperty() title!: string;
|
||||||
|
@ApiProperty({ description: 'Status id' }) status!: string;
|
||||||
|
@ApiProperty({ type: UserRefDto, nullable: true })
|
||||||
|
assignee!: UserRefDto | null;
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Days in its status (aging, stuck) or past / to its due date',
|
||||||
|
})
|
||||||
|
days!: number;
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||||
|
dueDate!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SprintPulseDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) sprintId!: string;
|
||||||
|
@ApiProperty() name!: string;
|
||||||
|
@ApiProperty({ format: 'date' }) startDate!: string;
|
||||||
|
@ApiProperty({ format: 'date' }) endDate!: string;
|
||||||
|
@ApiProperty() totalDays!: number;
|
||||||
|
@ApiProperty({ description: 'Days left after today' }) daysLeft!: number;
|
||||||
|
@ApiProperty() tickets!: number;
|
||||||
|
@ApiProperty() doneTickets!: number;
|
||||||
|
@ApiProperty() points!: number;
|
||||||
|
@ApiProperty() donePoints!: number;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) pacePerDay!: number | null;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) neededPerDay!: number | null;
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'At this pace, what is open would not be closed',
|
||||||
|
})
|
||||||
|
behind!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RisksDto {
|
||||||
|
@ApiProperty({ type: [InsightIssueDto] }) overdue!: InsightIssueDto[];
|
||||||
|
@ApiProperty() overdueCount!: number;
|
||||||
|
@ApiProperty({ type: [InsightIssueDto], description: 'Due within 7 days' })
|
||||||
|
dueSoon!: InsightIssueDto[];
|
||||||
|
@ApiProperty() dueSoonCount!: number;
|
||||||
|
@ApiProperty({ type: [InsightIssueDto] }) blocked!: InsightIssueDto[];
|
||||||
|
@ApiProperty() blockedCount!: number;
|
||||||
|
@ApiProperty({ type: [InsightIssueDto] }) stuck!: InsightIssueDto[];
|
||||||
|
@ApiProperty() stuckCount!: number;
|
||||||
|
@ApiProperty() stuckDays!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AgingDto {
|
||||||
|
@ApiProperty({ type: [InsightIssueDto] }) issues!: InsightIssueDto[];
|
||||||
|
@ApiProperty({
|
||||||
|
type: Number,
|
||||||
|
nullable: true,
|
||||||
|
description: 'p85 cycle time of the last 90 days',
|
||||||
|
})
|
||||||
|
thresholdDays!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StatusTimeDto {
|
||||||
|
@ApiProperty() status!: string;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) averageDays!: number | null;
|
||||||
|
@ApiProperty() samples!: number;
|
||||||
|
@ApiProperty() open!: number;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) wipLimit!: number | null;
|
||||||
|
@ApiProperty() overLimit!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectInsightsDto {
|
||||||
|
@ApiProperty() generatedAt!: string;
|
||||||
|
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||||
|
sprint!: SprintPulseDto | null;
|
||||||
|
@ApiProperty({ type: RisksDto }) risks!: RisksDto;
|
||||||
|
@ApiProperty({ type: AgingDto }) aging!: AgingDto;
|
||||||
|
@ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
BoardColumnDto,
|
BoardColumnDto,
|
||||||
CommandResultDto,
|
CommandResultDto,
|
||||||
ProjectAnalyticsDto,
|
ProjectAnalyticsDto,
|
||||||
|
ProjectInsightsDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
ProjectStatsDto,
|
ProjectStatsDto,
|
||||||
} from './dto/responses.dto';
|
} from './dto/responses.dto';
|
||||||
@@ -243,6 +244,26 @@ export class ProjectsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':project/insights')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '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.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: ProjectInsightsDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async insights(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('project') project: string,
|
||||||
|
) {
|
||||||
|
return this.projects.insights(
|
||||||
|
viewerOf(me),
|
||||||
|
await this.refs.projectId(viewerOf(me), project),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':project/activity')
|
@Get(':project/activity')
|
||||||
@ApiOptionalPireonAuth()
|
@ApiOptionalPireonAuth()
|
||||||
@PROJECT
|
@PROJECT
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from './lib/facades/home.facade';
|
|||||||
export * from './lib/facades/explore.facade';
|
export * from './lib/facades/explore.facade';
|
||||||
export * from './lib/facades/inbox.facade';
|
export * from './lib/facades/inbox.facade';
|
||||||
export * from './lib/facades/epics.facade';
|
export * from './lib/facades/epics.facade';
|
||||||
|
export * from './lib/facades/insights.facade';
|
||||||
export * from './lib/facades/backlog.facade';
|
export * from './lib/facades/backlog.facade';
|
||||||
export * from './lib/facades/list.facade';
|
export * from './lib/facades/list.facade';
|
||||||
export * from './lib/facades/propose.facade';
|
export * from './lib/facades/propose.facade';
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { type } from '@ngrx/signals';
|
||||||
|
import { eventGroup } from '@ngrx/signals/events';
|
||||||
|
import { ProjectInsights } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
export const insightsPageEvents = eventGroup({
|
||||||
|
source: 'Dashboard Page',
|
||||||
|
events: {
|
||||||
|
opened: type<{ projectId: string }>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insightsApiEvents = eventGroup({
|
||||||
|
source: 'Insights API',
|
||||||
|
events: {
|
||||||
|
loaded: type<ProjectInsights>(),
|
||||||
|
loadFailed: type<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { inject, Injectable, Provider } from '@angular/core';
|
||||||
|
import { injectDispatch } from '@ngrx/signals/events';
|
||||||
|
import { insightsPageEvents } from '../events/insights.events';
|
||||||
|
import { InsightsStore } from '../stores/insights.store';
|
||||||
|
|
||||||
|
/** "Seguimiento": sprint under way, risks, aging and bottlenecks. */
|
||||||
|
@Injectable()
|
||||||
|
export class InsightsFacade {
|
||||||
|
private readonly store = inject(InsightsStore);
|
||||||
|
private readonly dispatch = injectDispatch(insightsPageEvents);
|
||||||
|
|
||||||
|
readonly insights = this.store.insights;
|
||||||
|
/** False until the first load: show skeletons, not empty states. */
|
||||||
|
readonly loaded = this.store.loaded;
|
||||||
|
readonly error = this.store.error;
|
||||||
|
|
||||||
|
/** Loads the project's reading and keeps it up to date. */
|
||||||
|
open(projectId: string): void {
|
||||||
|
this.dispatch.opened({ projectId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store + facade of the dashboard, for its route component. */
|
||||||
|
export function provideInsightsFacade(): Provider[] {
|
||||||
|
return [InsightsStore, InsightsFacade];
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { mapResponse } from '@ngrx/operators';
|
||||||
|
import { signalStore, withState } from '@ngrx/signals';
|
||||||
|
import {
|
||||||
|
Events,
|
||||||
|
on,
|
||||||
|
withEventHandlers,
|
||||||
|
withReducer,
|
||||||
|
} from '@ngrx/signals/events';
|
||||||
|
import {
|
||||||
|
ProjectInsights,
|
||||||
|
ProjectsGateway,
|
||||||
|
TrackerRealtimeGateway,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { debounceTime, merge, startWith, switchMap } from 'rxjs';
|
||||||
|
import {
|
||||||
|
insightsApiEvents,
|
||||||
|
insightsPageEvents,
|
||||||
|
} from '../events/insights.events';
|
||||||
|
import { describeError } from '../notifications';
|
||||||
|
|
||||||
|
interface InsightsState {
|
||||||
|
insights: ProjectInsights | null;
|
||||||
|
loaded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard's reading of a project, live. Scoped to the dashboard. */
|
||||||
|
export const InsightsStore = signalStore(
|
||||||
|
withState<InsightsState>({ insights: null, loaded: false, error: null }),
|
||||||
|
withReducer(
|
||||||
|
on(insightsPageEvents.opened, () => ({ loaded: false, error: null })),
|
||||||
|
on(insightsApiEvents.loaded, ({ payload }) => ({
|
||||||
|
insights: payload,
|
||||||
|
loaded: true,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
on(insightsApiEvents.loadFailed, ({ payload }) => ({
|
||||||
|
loaded: true,
|
||||||
|
error: payload,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
withEventHandlers(
|
||||||
|
(
|
||||||
|
_store,
|
||||||
|
events = inject(Events),
|
||||||
|
projects = inject(ProjectsGateway),
|
||||||
|
realtime = inject(TrackerRealtimeGateway),
|
||||||
|
) => ({
|
||||||
|
// Any ticket or sprint may change the reading: reload, calmly.
|
||||||
|
load$: events.on(insightsPageEvents.opened).pipe(
|
||||||
|
switchMap(({ payload }) =>
|
||||||
|
merge(
|
||||||
|
realtime.issueChanges(payload.projectId),
|
||||||
|
realtime.sprintChanges(payload.projectId),
|
||||||
|
realtime.reconnects(),
|
||||||
|
).pipe(
|
||||||
|
debounceTime(800),
|
||||||
|
startWith(null),
|
||||||
|
switchMap(() =>
|
||||||
|
projects.insights(payload.projectId).pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: (insights) => insightsApiEvents.loaded(insights),
|
||||||
|
error: (error) =>
|
||||||
|
insightsApiEvents.loadFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -12,3 +12,4 @@ export * from './lib/models/roadmap';
|
|||||||
export * from './lib/models/shortcuts';
|
export * from './lib/models/shortcuts';
|
||||||
export * from './lib/models/due';
|
export * from './lib/models/due';
|
||||||
export * from './lib/models/calendar';
|
export * from './lib/models/calendar';
|
||||||
|
export * from './lib/models/insights';
|
||||||
|
|||||||
65
libs/web/tracker/domain/src/lib/models/insights.ts
Normal file
65
libs/web/tracker/domain/src/lib/models/insights.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { UserRef } from './models';
|
||||||
|
|
||||||
|
/** A ticket the dashboard names, with the number that put it there. */
|
||||||
|
export interface InsightIssue {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
/** Status id of the project's settings. */
|
||||||
|
status: string;
|
||||||
|
assignee: UserRef | null;
|
||||||
|
/** Days in its status (aging, stuck) or past / to its due date. */
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sprint under way: time gone against work done. */
|
||||||
|
export interface SprintPulse {
|
||||||
|
sprintId: string;
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalDays: number;
|
||||||
|
/** Days left after today (negative once it is over). */
|
||||||
|
daysLeft: number;
|
||||||
|
tickets: number;
|
||||||
|
doneTickets: number;
|
||||||
|
points: number;
|
||||||
|
donePoints: number;
|
||||||
|
pacePerDay: number | null;
|
||||||
|
neededPerDay: number | null;
|
||||||
|
/** At this pace, what is open would not be closed in time. */
|
||||||
|
behind: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets to look at now (worst first, a few of each). */
|
||||||
|
export interface Risks {
|
||||||
|
overdue: InsightIssue[];
|
||||||
|
overdueCount: number;
|
||||||
|
dueSoon: InsightIssue[];
|
||||||
|
dueSoonCount: number;
|
||||||
|
blocked: InsightIssue[];
|
||||||
|
blockedCount: number;
|
||||||
|
stuck: InsightIssue[];
|
||||||
|
stuckCount: number;
|
||||||
|
stuckDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time tickets spend in a status of the flow, and how many are there. */
|
||||||
|
export interface StatusTime {
|
||||||
|
status: string;
|
||||||
|
averageDays: number | null;
|
||||||
|
samples: number;
|
||||||
|
open: number;
|
||||||
|
wipLimit: number | null;
|
||||||
|
overLimit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard's reading of a project beyond the counts. */
|
||||||
|
export interface ProjectInsights {
|
||||||
|
generatedAt: string;
|
||||||
|
sprint: SprintPulse | null;
|
||||||
|
risks: Risks;
|
||||||
|
aging: { issues: InsightIssue[]; thresholdDays: number | null };
|
||||||
|
bottlenecks: StatusTime[];
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ProjectInsights } from '../models/insights';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -63,6 +64,8 @@ export abstract class ProjectsGateway {
|
|||||||
abstract publicProjects(): Observable<Project[]>;
|
abstract publicProjects(): Observable<Project[]>;
|
||||||
abstract project(idOrKey: string): Observable<Project>;
|
abstract project(idOrKey: string): Observable<Project>;
|
||||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
abstract stats(projectId: string): Observable<ProjectStats>;
|
||||||
|
/** Sprint under way, risks, aging and bottlenecks (the dashboard). */
|
||||||
|
abstract insights(projectId: string): Observable<ProjectInsights>;
|
||||||
/** Statistics of the last `days` days. */
|
/** Statistics of the last `days` days. */
|
||||||
abstract analytics(
|
abstract analytics(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MemberInput,
|
MemberInput,
|
||||||
Project,
|
Project,
|
||||||
ProjectAnalytics,
|
ProjectAnalytics,
|
||||||
|
ProjectInsights,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ProjectStats,
|
ProjectStats,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
DeleteProjectDocument,
|
DeleteProjectDocument,
|
||||||
MyProjectsDocument,
|
MyProjectsDocument,
|
||||||
ProjectAnalyticsDocument,
|
ProjectAnalyticsDocument,
|
||||||
|
ProjectInsightsDocument,
|
||||||
ProjectDocument,
|
ProjectDocument,
|
||||||
ProjectStatsDocument,
|
ProjectStatsDocument,
|
||||||
PublicProjectsDocument,
|
PublicProjectsDocument,
|
||||||
@@ -61,6 +63,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
|||||||
.pipe(map((d) => toStats(d.project.stats)));
|
.pipe(map((d) => toStats(d.project.stats)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insights(projectId: string): Observable<ProjectInsights> {
|
||||||
|
return this.gql
|
||||||
|
.query(ProjectInsightsDocument, { idOrKey: projectId })
|
||||||
|
.pipe(map((d) => d.project.insights));
|
||||||
|
}
|
||||||
|
|
||||||
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
|
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days })
|
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days })
|
||||||
|
|||||||
@@ -1757,6 +1757,107 @@ export type DeleteProjectMutation = {
|
|||||||
deleteProject: { id: string; key: string | null; version: number };
|
deleteProject: { id: string; key: string | null; version: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InsightIssueFieldsFragment = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectInsightsQueryVariables = Exact<{
|
||||||
|
idOrKey: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ProjectInsightsQuery = {
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
insights: {
|
||||||
|
generatedAt: string;
|
||||||
|
sprint: {
|
||||||
|
sprintId: string;
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalDays: number;
|
||||||
|
daysLeft: number;
|
||||||
|
tickets: number;
|
||||||
|
doneTickets: number;
|
||||||
|
points: number;
|
||||||
|
donePoints: number;
|
||||||
|
pacePerDay: number | null;
|
||||||
|
neededPerDay: number | null;
|
||||||
|
behind: boolean;
|
||||||
|
} | null;
|
||||||
|
risks: {
|
||||||
|
overdueCount: number;
|
||||||
|
dueSoonCount: number;
|
||||||
|
blockedCount: number;
|
||||||
|
stuckCount: number;
|
||||||
|
stuckDays: number;
|
||||||
|
overdue: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
}>;
|
||||||
|
dueSoon: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
}>;
|
||||||
|
blocked: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
}>;
|
||||||
|
stuck: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
aging: {
|
||||||
|
thresholdDays: number | null;
|
||||||
|
issues: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
days: number;
|
||||||
|
dueDate: string | null;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
bottlenecks: Array<{
|
||||||
|
status: string;
|
||||||
|
averageDays: number | null;
|
||||||
|
samples: number;
|
||||||
|
open: number;
|
||||||
|
wipLimit: number | null;
|
||||||
|
overLimit: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ReleasesQueryVariables = Exact<{
|
export type ReleasesQueryVariables = Exact<{
|
||||||
idOrKey: string;
|
idOrKey: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -3275,6 +3376,58 @@ export const NotificationFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
|
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
|
||||||
|
export const InsightIssueFieldsFragmentDoc = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'InsightIssueFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'InsightIssue' },
|
||||||
|
},
|
||||||
|
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: 'status' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'assignee' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<InsightIssueFieldsFragment, unknown>;
|
||||||
export const SavedFilterFieldsFragmentDoc = {
|
export const SavedFilterFieldsFragmentDoc = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -10753,6 +10906,336 @@ export const DeleteProjectDocument = {
|
|||||||
DeleteProjectMutation,
|
DeleteProjectMutation,
|
||||||
DeleteProjectMutationVariables
|
DeleteProjectMutationVariables
|
||||||
>;
|
>;
|
||||||
|
export const ProjectInsightsDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'ProjectInsights' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'idOrKey' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'project' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'idOrKey' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'idOrKey' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'insights' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'generatedAt' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sprint' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sprintId' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'startDate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'endDate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'totalDays' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'daysLeft' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'tickets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'doneTickets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'points' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'donePoints' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'pacePerDay' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'neededPerDay' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'behind' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'risks' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'overdue' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: {
|
||||||
|
kind: 'Name',
|
||||||
|
value: 'InsightIssueFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'overdueCount' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'dueSoon' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: {
|
||||||
|
kind: 'Name',
|
||||||
|
value: 'InsightIssueFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'dueSoonCount' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'blocked' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: {
|
||||||
|
kind: 'Name',
|
||||||
|
value: 'InsightIssueFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'blockedCount' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'stuck' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: {
|
||||||
|
kind: 'Name',
|
||||||
|
value: 'InsightIssueFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'stuckCount' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'stuckDays' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'aging' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issues' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: {
|
||||||
|
kind: 'Name',
|
||||||
|
value: 'InsightIssueFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'thresholdDays' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'bottlenecks' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'status' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'averageDays' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'samples' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'open' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'overLimit' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'InsightIssueFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'InsightIssue' },
|
||||||
|
},
|
||||||
|
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: 'status' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'assignee' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
ProjectInsightsQuery,
|
||||||
|
ProjectInsightsQueryVariables
|
||||||
|
>;
|
||||||
export const ReleasesDocument = {
|
export const ReleasesDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -144,3 +144,72 @@ mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
|
|||||||
...CommandResultFields
|
...CommandResultFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fragment InsightIssueFields on InsightIssue {
|
||||||
|
id
|
||||||
|
key
|
||||||
|
title
|
||||||
|
status
|
||||||
|
assignee {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
days
|
||||||
|
dueDate
|
||||||
|
}
|
||||||
|
|
||||||
|
query ProjectInsights($idOrKey: String!) {
|
||||||
|
project(idOrKey: $idOrKey) {
|
||||||
|
id
|
||||||
|
insights {
|
||||||
|
generatedAt
|
||||||
|
sprint {
|
||||||
|
sprintId
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
totalDays
|
||||||
|
daysLeft
|
||||||
|
tickets
|
||||||
|
doneTickets
|
||||||
|
points
|
||||||
|
donePoints
|
||||||
|
pacePerDay
|
||||||
|
neededPerDay
|
||||||
|
behind
|
||||||
|
}
|
||||||
|
risks {
|
||||||
|
overdue {
|
||||||
|
...InsightIssueFields
|
||||||
|
}
|
||||||
|
overdueCount
|
||||||
|
dueSoon {
|
||||||
|
...InsightIssueFields
|
||||||
|
}
|
||||||
|
dueSoonCount
|
||||||
|
blocked {
|
||||||
|
...InsightIssueFields
|
||||||
|
}
|
||||||
|
blockedCount
|
||||||
|
stuck {
|
||||||
|
...InsightIssueFields
|
||||||
|
}
|
||||||
|
stuckCount
|
||||||
|
stuckDays
|
||||||
|
}
|
||||||
|
aging {
|
||||||
|
issues {
|
||||||
|
...InsightIssueFields
|
||||||
|
}
|
||||||
|
thresholdDays
|
||||||
|
}
|
||||||
|
bottlenecks {
|
||||||
|
status
|
||||||
|
averageDays
|
||||||
|
samples
|
||||||
|
open
|
||||||
|
wipLimit
|
||||||
|
overLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,23 +2,47 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
|
import {
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
PiNoteComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
PiStatCardComponent,
|
||||||
|
} from '@pi/ui';
|
||||||
|
import {
|
||||||
|
InsightsFacade,
|
||||||
|
ProjectFacade,
|
||||||
|
provideInsightsFacade,
|
||||||
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
QA_FAILED,
|
QA_FAILED,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||||
|
import { AgingComponent } from '../ui/insights/aging';
|
||||||
|
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
|
||||||
|
import { RisksComponent } from '../ui/insights/risks';
|
||||||
|
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||||
|
|
||||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-dashboard-page',
|
selector: 'ts-dashboard-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
PiNoteComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
PiStatCardComponent,
|
||||||
|
SprintPulseComponent,
|
||||||
|
RisksComponent,
|
||||||
|
AgingComponent,
|
||||||
|
BottlenecksComponent,
|
||||||
|
],
|
||||||
|
providers: [provideInsightsFacade()],
|
||||||
template: `
|
template: `
|
||||||
@if (facade.stats(); as stats) {
|
@if (facade.stats(); as stats) {
|
||||||
<section class="ts-kpis" aria-label="Resumen">
|
<section class="ts-kpis" aria-label="Resumen">
|
||||||
@@ -66,6 +90,35 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (insights.error(); as error) {
|
||||||
|
<pi-note
|
||||||
|
class="ts-insights-error"
|
||||||
|
gap="xs"
|
||||||
|
status="error"
|
||||||
|
appearance="fill"
|
||||||
|
type="status"
|
||||||
|
title="No se pudo leer el estado del proyecto"
|
||||||
|
[text]="error"
|
||||||
|
/>
|
||||||
|
} @else if (insights.insights(); as reading) {
|
||||||
|
<div class="ts-dashboard ts-insights">
|
||||||
|
@if (reading.sprint; as sprint) {
|
||||||
|
<ts-sprint-pulse [sprint]="sprint" />
|
||||||
|
}
|
||||||
|
<ts-risks [risks]="reading.risks" />
|
||||||
|
<ts-aging
|
||||||
|
[issues]="reading.aging.issues"
|
||||||
|
[thresholdDays]="reading.aging.thresholdDays"
|
||||||
|
/>
|
||||||
|
<ts-bottlenecks
|
||||||
|
[statuses]="reading.bottlenecks"
|
||||||
|
[workflow]="facade.workflow()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
|
||||||
|
}
|
||||||
|
|
||||||
<div class="ts-dashboard">
|
<div class="ts-dashboard">
|
||||||
<section class="ts-panel" aria-labelledby="team-title">
|
<section class="ts-panel" aria-labelledby="team-title">
|
||||||
<header class="ts-panel__head">
|
<header class="ts-panel__head">
|
||||||
@@ -232,6 +285,12 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ts-ink);
|
color: var(--ts-ink);
|
||||||
}
|
}
|
||||||
|
.ts-insights,
|
||||||
|
.ts-insights-error,
|
||||||
|
.ts-insights-loading {
|
||||||
|
display: grid;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
.ts-dashboard {
|
.ts-dashboard {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||||
@@ -363,6 +422,14 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
|||||||
})
|
})
|
||||||
export class DashboardPage {
|
export class DashboardPage {
|
||||||
protected readonly facade = inject(ProjectFacade);
|
protected readonly facade = inject(ProjectFacade);
|
||||||
|
protected readonly insights = inject(InsightsFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const projectId = this.facade.project()?.id;
|
||||||
|
if (projectId) untracked(() => this.insights.open(projectId));
|
||||||
|
});
|
||||||
|
}
|
||||||
protected readonly initialsOf = initials;
|
protected readonly initialsOf = initials;
|
||||||
|
|
||||||
private count(status: string): number {
|
private count(status: string): number {
|
||||||
|
|||||||
103
libs/web/tracker/presenters/src/lib/ui/insights/aging.ts
Normal file
103
libs/web/tracker/presenters/src/lib/ui/insights/aging.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { InsightIssue } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each ticket under way by the days it has been in its status, against the
|
||||||
|
* project's usual: 85% of the tickets finished lately took at most that.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-aging',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: { class: 'ts-panel', 'aria-labelledby': 'aging-title', role: 'region' },
|
||||||
|
styleUrl: './insight-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="aging-title">Antigüedad de lo que está en curso</h2>
|
||||||
|
<span class="ts-muted">{{ legend() }}</span>
|
||||||
|
</header>
|
||||||
|
@if (issues().length) {
|
||||||
|
<ul class="ts-issues ts-aging">
|
||||||
|
@for (issue of issues(); track issue.id) {
|
||||||
|
<li [class.is-old]="isOld(issue)">
|
||||||
|
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||||
|
issue.key
|
||||||
|
}}</a>
|
||||||
|
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||||
|
issue.title
|
||||||
|
}}</a>
|
||||||
|
<span class="ts-bar" [attr.title]="issue.days + ' días'">
|
||||||
|
<span [style.width.%]="share(issue.days)"></span>
|
||||||
|
@if (threshold() !== null) {
|
||||||
|
<i
|
||||||
|
class="ts-aging__mark"
|
||||||
|
[style.left.%]="share(threshold()!)"
|
||||||
|
></i>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="ts-days">{{ issue.days }} d</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="ts-empty">Nada en curso ahora mismo.</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-aging li {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) minmax(4rem, 7rem) 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ts-aging .ts-days {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ts-aging li.is-old {
|
||||||
|
--tone: var(--ts-c-red, #d14343);
|
||||||
|
}
|
||||||
|
.ts-bar {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.ts-aging__mark {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.1875rem;
|
||||||
|
bottom: -0.1875rem;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--ts-ink-soft);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AgingComponent {
|
||||||
|
readonly issues = input.required<InsightIssue[]>();
|
||||||
|
/** p85 cycle time; none until something took at least part of a day. */
|
||||||
|
readonly thresholdDays = input<number | null>(null);
|
||||||
|
protected readonly threshold = computed(() => {
|
||||||
|
const days = this.thresholdDays();
|
||||||
|
return days && days > 0 ? days : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly max = computed(() =>
|
||||||
|
Math.max(1, this.threshold() ?? 0, ...this.issues().map((i) => i.days)),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected share(days: number): number {
|
||||||
|
return Math.min(100, (days / this.max()) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isOld(issue: InsightIssue): boolean {
|
||||||
|
const t = this.threshold();
|
||||||
|
return t !== null && issue.days > t;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly legend = computed(() => {
|
||||||
|
const t = this.threshold();
|
||||||
|
return t === null
|
||||||
|
? 'Días en su estado actual'
|
||||||
|
: `La raya: ${t.toLocaleString('es')} días, lo que tarda el 85% de los tickets`;
|
||||||
|
});
|
||||||
|
}
|
||||||
105
libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts
Normal file
105
libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { StatusTime, Workflow } from '@task-stream/web/tracker/domain';
|
||||||
|
import { statusLook } from '../copy';
|
||||||
|
|
||||||
|
/** Where tickets wait: average days in each status of the flow. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-bottlenecks',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
'aria-labelledby': 'bottlenecks-title',
|
||||||
|
role: 'region',
|
||||||
|
},
|
||||||
|
styleUrl: './insight-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="bottlenecks-title">Cuellos de botella</h2>
|
||||||
|
<span class="ts-muted">Días de media en cada estado (90 días)</span>
|
||||||
|
</header>
|
||||||
|
@if (rows().length) {
|
||||||
|
<ul class="ts-issues ts-necks">
|
||||||
|
@for (row of rows(); track row.status) {
|
||||||
|
<li [style.--tone]="row.tone" [class.is-slowest]="row.slowest">
|
||||||
|
<span class="ts-title">{{ row.label }}</span>
|
||||||
|
<span class="ts-bar"
|
||||||
|
><span [style.width.%]="row.share"></span
|
||||||
|
></span>
|
||||||
|
<span class="ts-days">{{ row.average }}</span>
|
||||||
|
<span
|
||||||
|
class="ts-days"
|
||||||
|
[class.ts-warn]="row.overLimit"
|
||||||
|
[attr.title]="
|
||||||
|
row.wipLimit !== null
|
||||||
|
? 'Hay ' + row.open + ' con un límite de ' + row.wipLimit
|
||||||
|
: 'Hay ' + row.open + ' ahora'
|
||||||
|
"
|
||||||
|
>{{ row.open
|
||||||
|
}}{{ row.wipLimit !== null ? '/' + row.wipLimit : '' }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (slowest(); as slowest) {
|
||||||
|
<p class="ts-empty ts-necks__note">
|
||||||
|
Donde más se paran: <strong>{{ slowest }}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p class="ts-empty">El flujo no tiene estados en curso o de QA.</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-necks {
|
||||||
|
gap: 0.5rem;
|
||||||
|
li {
|
||||||
|
grid-template-columns: minmax(6rem, 9rem) 1fr 3.5rem 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ts-days {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
li.is-slowest .ts-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-necks__note {
|
||||||
|
margin-top: 0.875rem;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BottlenecksComponent {
|
||||||
|
readonly statuses = input.required<StatusTime[]>();
|
||||||
|
readonly workflow = input.required<Workflow>();
|
||||||
|
|
||||||
|
protected readonly rows = computed(() => {
|
||||||
|
const list = this.statuses();
|
||||||
|
const max = Math.max(1, ...list.map((s) => s.averageDays ?? 0));
|
||||||
|
const slowest = Math.max(...list.map((s) => s.averageDays ?? 0));
|
||||||
|
return list.map((s) => {
|
||||||
|
const look = statusLook(this.workflow(), s.status);
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
label: look.label,
|
||||||
|
tone: look.tone,
|
||||||
|
share: ((s.averageDays ?? 0) / max) * 100,
|
||||||
|
average:
|
||||||
|
s.averageDays === null
|
||||||
|
? '—'
|
||||||
|
: `${s.averageDays.toLocaleString('es')} d`,
|
||||||
|
slowest: slowest > 0 && s.averageDays === slowest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly slowest = computed(() => {
|
||||||
|
const row = this.rows().find((r) => r.slowest);
|
||||||
|
return row ? `${row.label} (${row.average})` : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Shared by the dashboard's insight panels (each host is a .ts-panel).
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-muted {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-issues {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-key {
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-days {
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--tone, var(--ts-brand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-warn {
|
||||||
|
color: var(--ts-c-red, #d14343);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
114
libs/web/tracker/presenters/src/lib/ui/insights/risks.ts
Normal file
114
libs/web/tracker/presenters/src/lib/ui/insights/risks.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { InsightIssue, Risks } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
const plural = (n: number, one: string, many: string) =>
|
||||||
|
`${n} ${n === 1 ? one : many}`;
|
||||||
|
|
||||||
|
/** Overdue, due soon, blocked and stuck: what to look at now. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-risks',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: { class: 'ts-panel', 'aria-labelledby': 'risks-title', role: 'region' },
|
||||||
|
styleUrl: './insight-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="risks-title">Tickets en riesgo</h2>
|
||||||
|
<span class="ts-muted">Lo que conviene mirar hoy</span>
|
||||||
|
</header>
|
||||||
|
@for (group of groups(); track group.title) {
|
||||||
|
@if (group.count) {
|
||||||
|
<section class="ts-risk">
|
||||||
|
<h3 [class.ts-warn]="group.warn">
|
||||||
|
{{ group.title }} <span class="ts-days">{{ group.count }}</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="ts-issues">
|
||||||
|
@for (issue of group.issues; track issue.id) {
|
||||||
|
<li>
|
||||||
|
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||||
|
issue.key
|
||||||
|
}}</a>
|
||||||
|
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||||
|
issue.title
|
||||||
|
}}</a>
|
||||||
|
<span class="ts-days">{{ group.days(issue) }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (group.count > group.issues.length) {
|
||||||
|
<p class="ts-empty">
|
||||||
|
y {{ group.count - group.issues.length }} más
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!total()) {
|
||||||
|
<p class="ts-empty">Nada vencido, bloqueado ni atascado.</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-risk + .ts-risk {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RisksComponent {
|
||||||
|
readonly risks = input.required<Risks>();
|
||||||
|
|
||||||
|
protected readonly total = computed(() => {
|
||||||
|
const r = this.risks();
|
||||||
|
return r.overdueCount + r.dueSoonCount + r.blockedCount + r.stuckCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly groups = computed(() => {
|
||||||
|
const r = this.risks();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Vencidos',
|
||||||
|
warn: true,
|
||||||
|
issues: r.overdue,
|
||||||
|
count: r.overdueCount,
|
||||||
|
days: (i: InsightIssue) => `hace ${plural(i.days, 'día', 'días')}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vencen esta semana',
|
||||||
|
warn: false,
|
||||||
|
issues: r.dueSoon,
|
||||||
|
count: r.dueSoonCount,
|
||||||
|
days: (i: InsightIssue) =>
|
||||||
|
i.days === 0 ? 'hoy' : i.days === 1 ? 'mañana' : `en ${i.days} días`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bloqueados',
|
||||||
|
warn: false,
|
||||||
|
issues: r.blocked,
|
||||||
|
count: r.blockedCount,
|
||||||
|
days: (i: InsightIssue) =>
|
||||||
|
i.days ? plural(i.days, 'día', 'días') : 'hoy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `Atascados (${r.stuckDays} días o más sin moverse)`,
|
||||||
|
warn: false,
|
||||||
|
issues: r.stuck,
|
||||||
|
count: r.stuckCount,
|
||||||
|
days: (i: InsightIssue) =>
|
||||||
|
i.days ? plural(i.days, 'día', 'días') : 'hoy',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
133
libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
Normal file
133
libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { SprintPulse } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
const short = (day: string) =>
|
||||||
|
new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
const decimal = (n: number) =>
|
||||||
|
n.toLocaleString('es', { maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
/** The sprint under way: days left, points done and whether it gets there. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-sprint-pulse',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: { class: 'ts-panel', 'aria-labelledby': 'pulse-title', role: 'region' },
|
||||||
|
styleUrl: './insight-panel.scss',
|
||||||
|
template: `
|
||||||
|
@let s = sprint();
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="pulse-title">{{ s.name }}</h2>
|
||||||
|
<span class="ts-muted">{{ when() }}</span>
|
||||||
|
</header>
|
||||||
|
<p class="ts-pulse__days">
|
||||||
|
<strong>{{ daysText() }}</strong>
|
||||||
|
@if (s.behind) {
|
||||||
|
<span class="ts-pulse__flag">A este ritmo no llega</span>
|
||||||
|
} @else if (s.points && s.donePoints === s.points) {
|
||||||
|
<span class="ts-pulse__flag is-ok">Todo hecho</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div class="ts-pulse__bars">
|
||||||
|
<span class="ts-pulse__label">Tiempo</span>
|
||||||
|
<span class="ts-bar"><span [style.width.%]="timeShare()"></span></span>
|
||||||
|
<span class="ts-days">{{ timeShare() }}%</span>
|
||||||
|
<span class="ts-pulse__label">Puntos</span>
|
||||||
|
<span class="ts-bar" [class.is-behind]="s.behind"
|
||||||
|
><span [style.width.%]="pointShare()"></span
|
||||||
|
></span>
|
||||||
|
<span class="ts-days">{{ s.donePoints }}/{{ s.points }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="ts-empty">
|
||||||
|
{{ s.doneTickets }} de {{ s.tickets }} tickets terminados.
|
||||||
|
@if (paceText(); as pace) {
|
||||||
|
{{ pace }}
|
||||||
|
}
|
||||||
|
<a routerLink="../sprints">Ver el sprint →</a>
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-pulse__days {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
strong {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-pulse__flag {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--ts-c-red, #d14343) 14%, transparent);
|
||||||
|
color: var(--ts-c-red, #d14343);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
&.is-ok {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--ts-c-green, #2e9e6a) 14%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
color: var(--ts-c-green, #2e9e6a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-pulse__bars {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4rem 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.ts-pulse__label {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ts-bar.is-behind {
|
||||||
|
--tone: var(--ts-c-red, #d14343);
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class SprintPulseComponent {
|
||||||
|
readonly sprint = input.required<SprintPulse>();
|
||||||
|
|
||||||
|
protected readonly when = computed(
|
||||||
|
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
||||||
|
);
|
||||||
|
protected readonly daysText = computed(() => {
|
||||||
|
const left = this.sprint().daysLeft;
|
||||||
|
if (left < 0)
|
||||||
|
return 'Terminó hace ' + -left + (left === -1 ? ' día' : ' días');
|
||||||
|
if (left === 0) return 'Último día';
|
||||||
|
return `Quedan ${left} ${left === 1 ? 'día' : 'días'}`;
|
||||||
|
});
|
||||||
|
protected readonly timeShare = computed(() => {
|
||||||
|
const s = this.sprint();
|
||||||
|
const gone = s.totalDays - Math.max(s.daysLeft, 0);
|
||||||
|
return Math.round((Math.min(gone, s.totalDays) / s.totalDays) * 100);
|
||||||
|
});
|
||||||
|
protected readonly pointShare = computed(() => {
|
||||||
|
const s = this.sprint();
|
||||||
|
return s.points ? Math.round((s.donePoints / s.points) * 100) : 0;
|
||||||
|
});
|
||||||
|
protected readonly paceText = computed(() => {
|
||||||
|
const s = this.sprint();
|
||||||
|
if (s.pacePerDay === null || s.neededPerDay === null) return '';
|
||||||
|
return `Se cierran ${decimal(s.pacePerDay)} pts al día; hacen falta ${decimal(s.neededPerDay)}.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user