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:
@@ -568,6 +568,21 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
dueTo?: string;
|
||||
@Field({ nullable: true, description: 'Only tickets nobody estimated' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
noEstimate?: boolean;
|
||||
@Field({ nullable: true, description: 'Only tickets with no area' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
noArea?: boolean;
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Only tickets not updated since this instant (ISO)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
updatedBefore?: string;
|
||||
@Field(() => [CustomFieldValueInput], {
|
||||
nullable: true,
|
||||
description: 'Only tickets holding these custom values',
|
||||
|
||||
@@ -91,8 +91,20 @@ export class ProjectResolver {
|
||||
insights(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() project: ProjectReadModel,
|
||||
@Args('since', {
|
||||
type: () => String,
|
||||
nullable: true,
|
||||
description:
|
||||
'ISO instant (e.g. my last visit): also tell what changed after it',
|
||||
})
|
||||
since?: string | null,
|
||||
) {
|
||||
return this.projects.insights(viewerOf(me), project.id);
|
||||
const after = since ? new Date(since) : null;
|
||||
return this.projects.insights(
|
||||
viewerOf(me),
|
||||
project.id,
|
||||
after && !Number.isNaN(after.getTime()) ? after : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => [ReleaseGql])
|
||||
|
||||
@@ -867,8 +867,70 @@ export class StatusTimeGql {
|
||||
@Field() overLimit!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseForecast', {
|
||||
description: 'When a planned release would be finished, at the real pace',
|
||||
})
|
||||
export class ReleaseForecastGql {
|
||||
@Field(() => ID) releaseId!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => String, { nullable: true }) targetDate!: string | null;
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int) finished!: number;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Half of the simulated futures finish by then',
|
||||
})
|
||||
likelyDate!: string | null;
|
||||
@Field(() => String, { nullable: true, description: '85% finish by then' })
|
||||
safeDate!: string | null;
|
||||
@Field({
|
||||
description:
|
||||
'DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE',
|
||||
})
|
||||
verdict!: string;
|
||||
}
|
||||
|
||||
@ObjectType('ChangesSince', { description: 'What happened after a moment' })
|
||||
export class ChangesSinceGql {
|
||||
@Field() since!: string;
|
||||
@Field(() => [InsightIssueGql]) created!: InsightIssueGql[];
|
||||
@Field(() => Int) createdCount!: number;
|
||||
@Field(() => [InsightIssueGql]) finished!: InsightIssueGql[];
|
||||
@Field(() => Int) finishedCount!: number;
|
||||
@Field(() => [InsightIssueGql], { description: 'Sent back from QA' })
|
||||
returned!: InsightIssueGql[];
|
||||
@Field(() => Int) returnedCount!: number;
|
||||
@Field(() => Int, { description: 'Times I was mentioned here' })
|
||||
mentions!: number;
|
||||
}
|
||||
|
||||
@ObjectType('TeamLoad')
|
||||
export class TeamLoadGql {
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field(() => Int) openTickets!: number;
|
||||
@Field(() => Int) openPoints!: number;
|
||||
@Field(() => Int) blocked!: number;
|
||||
@Field(() => Float, {
|
||||
nullable: true,
|
||||
description: 'Points closed per sprint, last three closed sprints',
|
||||
})
|
||||
pointsPerSprint!: number | null;
|
||||
@Field() overloaded!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('BacklogHealth')
|
||||
export class BacklogHealthGql {
|
||||
@Field(() => Int) open!: number;
|
||||
@Field(() => Int) unestimated!: number;
|
||||
@Field(() => Int) noArea!: number;
|
||||
@Field(() => Int) unassigned!: number;
|
||||
@Field(() => Int) stale!: number;
|
||||
@Field(() => Int) staleDays!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectInsights', {
|
||||
description: 'The dashboard: sprint under way, risks, aging and bottlenecks',
|
||||
description:
|
||||
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
|
||||
})
|
||||
export class ProjectInsightsGql {
|
||||
@Field() generatedAt!: string;
|
||||
@@ -877,4 +939,12 @@ export class ProjectInsightsGql {
|
||||
@Field(() => RiskGql) risks!: RiskGql;
|
||||
@Field(() => AgingGql) aging!: AgingGql;
|
||||
@Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[];
|
||||
@Field(() => [ReleaseForecastGql]) releases!: ReleaseForecastGql[];
|
||||
@Field(() => ChangesSinceGql, {
|
||||
nullable: true,
|
||||
description: 'Only when asked with `since`',
|
||||
})
|
||||
changes!: ChangesSinceGql | null;
|
||||
@Field(() => [TeamLoadGql]) team!: TeamLoadGql[];
|
||||
@Field(() => BacklogHealthGql) backlog!: BacklogHealthGql;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user