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:
@@ -31,6 +31,7 @@ import {
|
||||
BoardColumnGql,
|
||||
CommandResultGql,
|
||||
ProjectAnalyticsGql,
|
||||
ProjectInsightsGql,
|
||||
ProjectGql,
|
||||
ProjectSettingsGql,
|
||||
ProjectStatsGql,
|
||||
@@ -83,6 +84,17 @@ export class ProjectResolver {
|
||||
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])
|
||||
releases(
|
||||
@CurrentUser() me: User | undefined,
|
||||
|
||||
@@ -794,3 +794,87 @@ export class ReleaseBurnupGql {
|
||||
scope!: 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;
|
||||
}
|
||||
|
||||
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,
|
||||
CommandResultDto,
|
||||
ProjectAnalyticsDto,
|
||||
ProjectInsightsDto,
|
||||
ProjectDto,
|
||||
ProjectStatsDto,
|
||||
} 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')
|
||||
@ApiOptionalPireonAuth()
|
||||
@PROJECT
|
||||
|
||||
Reference in New Issue
Block a user