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

@@ -330,6 +330,17 @@ export class AnalyticsQueryDto {
days = 90;
}
export class InsightsQueryDto {
@ApiPropertyOptional({
description:
'ISO instant (e.g. your last visit): the answer also tells what changed after it',
example: '2026-10-14T08:00:00Z',
})
@IsOptional()
@IsISO8601()
since?: string;
}
export class ChangeMemberRoleDto {
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
@IsEnum(ProjectRole)
@@ -609,6 +620,25 @@ export class IssueSearchQueryDto extends PageQueryDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@ApiPropertyOptional({ description: 'Only tickets nobody estimated' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
noEstimate?: boolean;
@ApiPropertyOptional({ description: 'Only tickets with no area' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
noArea?: boolean;
@ApiPropertyOptional({
description: 'Only tickets not updated since this instant (ISO)',
})
@IsOptional()
@IsISO8601()
updatedBefore?: string;
@ApiPropertyOptional({
type: [String],
example: ['BROWSER:Safari'],

View File

@@ -943,6 +943,69 @@ export class StatusTimeDto {
@ApiProperty() overLimit!: boolean;
}
export class ReleaseForecastDto {
@ApiProperty({ format: 'uuid' }) releaseId!: string;
@ApiProperty() name!: string;
@ApiProperty({ type: String, nullable: true, format: 'date' })
targetDate!: string | null;
@ApiProperty() total!: number;
@ApiProperty() finished!: number;
@ApiProperty({
type: String,
nullable: true,
format: 'date',
description: 'Half of the simulated futures finish by then',
})
likelyDate!: string | null;
@ApiProperty({
type: String,
nullable: true,
format: 'date',
description: '85% finish by then',
})
safeDate!: string | null;
@ApiProperty({
enum: ['DONE', 'NO_DATE', 'UNKNOWN', 'ON_TRACK', 'AT_RISK', 'LATE'],
})
verdict!: string;
}
export class ChangesSinceDto {
@ApiProperty() since!: string;
@ApiProperty({ type: [InsightIssueDto] }) created!: InsightIssueDto[];
@ApiProperty() createdCount!: number;
@ApiProperty({ type: [InsightIssueDto] }) finished!: InsightIssueDto[];
@ApiProperty() finishedCount!: number;
@ApiProperty({ type: [InsightIssueDto], description: 'Sent back from QA' })
returned!: InsightIssueDto[];
@ApiProperty() returnedCount!: number;
@ApiProperty({ description: 'Times you were mentioned here' })
mentions!: number;
}
export class TeamLoadDto {
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
@ApiProperty() openTickets!: number;
@ApiProperty() openPoints!: number;
@ApiProperty() blocked!: number;
@ApiProperty({
type: Number,
nullable: true,
description: 'Points closed per sprint, last three closed sprints',
})
pointsPerSprint!: number | null;
@ApiProperty() overloaded!: boolean;
}
export class BacklogHealthDto {
@ApiProperty() open!: number;
@ApiProperty() unestimated!: number;
@ApiProperty() noArea!: number;
@ApiProperty() unassigned!: number;
@ApiProperty() stale!: number;
@ApiProperty() staleDays!: number;
}
export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true })
@@ -950,4 +1013,13 @@ export class ProjectInsightsDto {
@ApiProperty({ type: RisksDto }) risks!: RisksDto;
@ApiProperty({ type: AgingDto }) aging!: AgingDto;
@ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[];
@ApiProperty({ type: [ReleaseForecastDto] }) releases!: ReleaseForecastDto[];
@ApiProperty({
type: ChangesSinceDto,
nullable: true,
description: 'Only when asked with `since`',
})
changes!: ChangesSinceDto | null;
@ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[];
@ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto;
}

View File

@@ -171,6 +171,9 @@ export class IssuesController {
watching: query.watching,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
noEstimate: query.noEstimate,
noArea: query.noArea,
updatedBefore: query.updatedBefore,
customFields: query.field?.length
? Object.fromEntries(
query.field.map((f) => [

View File

@@ -35,6 +35,7 @@ import {
ActivityQueryDto,
AddMemberDto,
AnalyticsQueryDto,
InsightsQueryDto,
BoardQueryDto,
ChangeMemberRoleDto,
CreateProjectDto,
@@ -257,10 +258,12 @@ export class ProjectsController {
async insights(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: InsightsQueryDto,
) {
return this.projects.insights(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
query.since ? new Date(query.since) : null,
);
}