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

@@ -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',

View File

@@ -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])

View File

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