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

@@ -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[];
}