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

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

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

View File

@@ -5,6 +5,7 @@ import {
MemberInput,
Project,
ProjectAnalytics,
ProjectInsights,
ProjectSettings,
ProjectsGateway,
ProjectStats,
@@ -18,6 +19,7 @@ import {
DeleteProjectDocument,
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectInsightsDocument,
ProjectDocument,
ProjectStatsDocument,
PublicProjectsDocument,
@@ -61,6 +63,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.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> {
return this.gql
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days })

View File

@@ -1757,6 +1757,107 @@ export type DeleteProjectMutation = {
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<{
idOrKey: string;
}>;
@@ -3275,6 +3376,58 @@ export const NotificationFieldsFragmentDoc = {
},
],
} 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 = {
kind: 'Document',
definitions: [
@@ -10753,6 +10906,336 @@ export const DeleteProjectDocument = {
DeleteProjectMutation,
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 = {
kind: 'Document',
definitions: [

View File

@@ -144,3 +144,72 @@ mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
...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
}
}
}
}