feat: dashboard forecasts releases, tells what changed and weighs the load

The insights reading grows (GraphQL insights(since:), REST ?since=, MCP):
- planned releases with a likely (p50) and safe (p85) finish date from a
  Monte Carlo over the last six weeks of finished work, one after another,
  and a verdict against their target;
- with `since` (the viewer's last visit, kept in the browser): tickets
  created, finished and sent back from QA, and their mentions;
- each person's open points against what they closed per sprint lately;
- open tickets without estimate, area or assignee, or untouched for 90
  days, each count opening the list filtered (new noEstimate, noArea and
  updatedBefore filters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:50:24 +02:00
parent fe03c5bda8
commit 0d158a2ed7
41 changed files with 2050 additions and 118 deletions

View File

@@ -1414,5 +1414,28 @@ describe('task-stream (e2e)', () => {
{ k: key },
);
expect(project.insights.risks.overdueCount).toBe(1);
// What changed since a moment, and what the backlog lacks.
const since = new Date(Date.now() - 60_000).toISOString();
const later = await rest(
'token-ana',
'GET',
`/projects/${key}/insights?since=${encodeURIComponent(since)}`,
);
expect(later.body['changes']).toMatchObject({ since, createdCount: 2 });
expect(later.body['backlog']).toMatchObject({
open: 2,
unestimated: 2,
unassigned: 2,
});
expect(
later.body['team'].map((t: { user: { id: string } }) => t.user.id),
).toContain('101');
const unestimated = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?noEstimate=true&noArea=true`,
);
expect(unestimated.body['total']).toBe(2);
});
});

View File

@@ -1449,6 +1449,16 @@
"example": "WEB",
"type": "string"
}
},
{
"name": "since",
"required": false,
"in": "query",
"description": "ISO instant (e.g. your last visit): the answer also tells what changed after it",
"schema": {
"example": "2026-10-14T08:00:00Z",
"type": "string"
}
}
],
"responses": {
@@ -2507,6 +2517,33 @@
"type": "string"
}
},
{
"name": "noEstimate",
"required": false,
"in": "query",
"description": "Only tickets nobody estimated",
"schema": {
"type": "boolean"
}
},
{
"name": "noArea",
"required": false,
"in": "query",
"description": "Only tickets with no area",
"schema": {
"type": "boolean"
}
},
{
"name": "updatedBefore",
"required": false,
"in": "query",
"description": "Only tickets not updated since this instant (ISO)",
"schema": {
"type": "string"
}
},
{
"name": "field",
"required": false,
@@ -12109,6 +12146,176 @@
"overLimit"
]
},
"ReleaseForecastDto": {
"type": "object",
"properties": {
"releaseId": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"targetDate": {
"type": "string",
"nullable": true,
"format": "date"
},
"total": {
"type": "number"
},
"finished": {
"type": "number"
},
"likelyDate": {
"type": "string",
"nullable": true,
"format": "date",
"description": "Half of the simulated futures finish by then"
},
"safeDate": {
"type": "string",
"nullable": true,
"format": "date",
"description": "85% finish by then"
},
"verdict": {
"type": "string",
"enum": [
"DONE",
"NO_DATE",
"UNKNOWN",
"ON_TRACK",
"AT_RISK",
"LATE"
]
}
},
"required": [
"releaseId",
"name",
"targetDate",
"total",
"finished",
"likelyDate",
"safeDate",
"verdict"
]
},
"ChangesSinceDto": {
"type": "object",
"properties": {
"since": {
"type": "string"
},
"created": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"createdCount": {
"type": "number"
},
"finished": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"finishedCount": {
"type": "number"
},
"returned": {
"description": "Sent back from QA",
"type": "array",
"items": {
"$ref": "#/components/schemas/InsightIssueDto"
}
},
"returnedCount": {
"type": "number"
},
"mentions": {
"type": "number",
"description": "Times you were mentioned here"
}
},
"required": [
"since",
"created",
"createdCount",
"finished",
"finishedCount",
"returned",
"returnedCount",
"mentions"
]
},
"TeamLoadDto": {
"type": "object",
"properties": {
"user": {
"$ref": "#/components/schemas/UserRefDto"
},
"openTickets": {
"type": "number"
},
"openPoints": {
"type": "number"
},
"blocked": {
"type": "number"
},
"pointsPerSprint": {
"type": "number",
"nullable": true,
"description": "Points closed per sprint, last three closed sprints"
},
"overloaded": {
"type": "boolean"
}
},
"required": [
"user",
"openTickets",
"openPoints",
"blocked",
"pointsPerSprint",
"overloaded"
]
},
"BacklogHealthDto": {
"type": "object",
"properties": {
"open": {
"type": "number"
},
"unestimated": {
"type": "number"
},
"noArea": {
"type": "number"
},
"unassigned": {
"type": "number"
},
"stale": {
"type": "number"
},
"staleDays": {
"type": "number"
}
},
"required": [
"open",
"unestimated",
"noArea",
"unassigned",
"stale",
"staleDays"
]
},
"ProjectInsightsDto": {
"type": "object",
"properties": {
@@ -12135,6 +12342,31 @@
"items": {
"$ref": "#/components/schemas/StatusTimeDto"
}
},
"releases": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReleaseForecastDto"
}
},
"changes": {
"nullable": true,
"description": "Only when asked with `since`",
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/ChangesSinceDto"
}
]
},
"team": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TeamLoadDto"
}
},
"backlog": {
"$ref": "#/components/schemas/BacklogHealthDto"
}
},
"required": [
@@ -12142,7 +12374,11 @@
"sprint",
"risks",
"aging",
"bottlenecks"
"bottlenecks",
"releases",
"changes",
"team",
"backlog"
]
},
"ActivityDto": {

View File

@@ -129,6 +129,15 @@ type AutomationTriggerSpec {
status: String
}
type BacklogHealth {
noArea: Int!
open: Int!
stale: Int!
staleDays: Int!
unassigned: Int!
unestimated: Int!
}
type BoardColumn {
issues: [Issue!]!
status: String!
@@ -184,6 +193,22 @@ input ChangeIssueStatusInput {
status: String!
}
"""What happened after a moment"""
type ChangesSince {
created: [InsightIssue!]!
createdCount: Int!
finished: [InsightIssue!]!
finishedCount: Int!
"""Times I was mentioned here"""
mentions: Int!
"""Sent back from QA"""
returned: [InsightIssue!]!
returnedCount: Int!
since: String!
}
"""A step inside a ticket that does not deserve a ticket of its own"""
type ChecklistItem {
done: Boolean!
@@ -598,6 +623,12 @@ input IssueFilterInput {
dueTo: String
labels: [String!]
"""Only tickets with no area"""
noArea: Boolean
"""Only tickets nobody estimated"""
noEstimate: Boolean
"""Only tickets in no sprint"""
noSprint: Boolean
@@ -615,6 +646,9 @@ input IssueFilterInput {
types: [String!]
unassigned: Boolean
"""Only tickets not updated since this instant (ISO)"""
updatedBefore: String
"""Only the tickets I follow (none when anonymous)"""
watching: Boolean
}
@@ -978,7 +1012,10 @@ type Project {
"""
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
"""
insights: ProjectInsights!
insights(
"""ISO instant (e.g. my last visit): also tell what changed after it"""
since: String
): ProjectInsights!
"""Anyone can read it, also without signing in"""
isPublic: Boolean!
@@ -1021,13 +1058,21 @@ type ProjectChange {
version: Int!
}
"""The dashboard: sprint under way, risks, aging and bottlenecks"""
"""
The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes
"""
type ProjectInsights {
aging: Aging!
backlog: BacklogHealth!
bottlenecks: [StatusTime!]!
"""Only when asked with `since`"""
changes: ChangesSince
generatedAt: String!
releases: [ReleaseForecast!]!
risks: Risks!
sprint: SprintPulse
team: [TeamLoad!]!
}
type ProjectMember {
@@ -1237,6 +1282,24 @@ type ReleaseChange {
version: Int!
}
"""When a planned release would be finished, at the real pace"""
type ReleaseForecast {
finished: Int!
"""Half of the simulated futures finish by then"""
likelyDate: String
name: String!
releaseId: ID!
"""85% finish by then"""
safeDate: String
targetDate: String
total: Int!
"""DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE"""
verdict: String!
}
type ReleaseProgress {
byStatus: [CountByStatus!]!
finished: Int!
@@ -1450,6 +1513,17 @@ type Subscription {
sprintChanged(projectId: ID!): SprintChange!
}
type TeamLoad {
blocked: Int!
openPoints: Int!
openTickets: Int!
overloaded: Boolean!
"""Points closed per sprint, last three closed sprints"""
pointsPerSprint: Float
user: UserRef!
}
type Throughput {
count: Int!
user: UserRef