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

@@ -63,10 +63,13 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
insights(projectId: string): Observable<ProjectInsights> {
insights(
projectId: string,
since: string | null,
): Observable<ProjectInsights> {
return this.gql
.query(ProjectInsightsDocument, { idOrKey: projectId })
.pipe(map((d) => d.project.insights));
.query(ProjectInsightsDocument, { idOrKey: projectId, since })
.pipe(map((d) => d.project.insights as ProjectInsights));
}
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {

View File

@@ -178,6 +178,10 @@ export type IssueFilterInput = {
/** Due on or before this day (YYYY-MM-DD) */
dueTo?: string | null | undefined;
labels?: Array<string> | null | undefined;
/** Only tickets with no area */
noArea?: boolean | null | undefined;
/** Only tickets nobody estimated */
noEstimate?: boolean | null | undefined;
/** Only tickets in no sprint */
noSprint?: boolean | null | undefined;
/** Only the tickets hanging from this one */
@@ -192,6 +196,8 @@ export type IssueFilterInput = {
statuses?: Array<string> | null | undefined;
types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined;
/** Only tickets not updated since this instant (ISO) */
updatedBefore?: string | null | undefined;
/** Only the tickets I follow (none when anonymous) */
watching?: boolean | null | undefined;
};
@@ -1769,6 +1775,7 @@ export type InsightIssueFieldsFragment = {
export type ProjectInsightsQueryVariables = Exact<{
idOrKey: string;
since?: string | null | undefined;
}>;
export type ProjectInsightsQuery = {
@@ -1854,6 +1861,66 @@ export type ProjectInsightsQuery = {
wipLimit: number | null;
overLimit: boolean;
}>;
releases: Array<{
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
likelyDate: string | null;
safeDate: string | null;
verdict: string;
}>;
changes: {
since: string;
createdCount: number;
finishedCount: number;
returnedCount: number;
mentions: number;
created: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
finished: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
returned: Array<{
id: string;
key: string;
title: string;
status: string;
days: number;
dueDate: string | null;
assignee: { id: string; name: string } | null;
}>;
} | null;
team: Array<{
openTickets: number;
openPoints: number;
blocked: number;
pointsPerSprint: number | null;
overloaded: boolean;
user: { id: string; name: string };
}>;
backlog: {
open: number;
unestimated: number;
noArea: number;
unassigned: number;
stale: number;
staleDays: number;
};
};
};
};
@@ -10928,6 +10995,14 @@ export const ProjectInsightsDocument = {
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'since' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
@@ -10952,6 +11027,16 @@ export const ProjectInsightsDocument = {
{
kind: 'Field',
name: { kind: 'Name', value: 'insights' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'since' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'since' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
@@ -11175,6 +11260,202 @@ export const ProjectInsightsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releases' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'targetDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'total' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'likelyDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'safeDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'verdict' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'changes' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'since' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'created' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'createdCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finishedCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returned' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'InsightIssueFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returnedCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'mentions' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'team' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'user' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'UserRefFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'openTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'openPoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blocked' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pointsPerSprint' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overloaded' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'backlog' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'open' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'unestimated' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'noArea' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'unassigned' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'stale' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'staleDays' },
},
],
},
},
],
},
},

View File

@@ -157,10 +157,10 @@ fragment InsightIssueFields on InsightIssue {
dueDate
}
query ProjectInsights($idOrKey: String!) {
query ProjectInsights($idOrKey: String!, $since: String) {
project(idOrKey: $idOrKey) {
id
insights {
insights(since: $since) {
generatedAt
sprint {
sprintId
@@ -210,6 +210,50 @@ query ProjectInsights($idOrKey: String!) {
wipLimit
overLimit
}
releases {
releaseId
name
targetDate
total
finished
likelyDate
safeDate
verdict
}
changes {
since
created {
...InsightIssueFields
}
createdCount
finished {
...InsightIssueFields
}
finishedCount
returned {
...InsightIssueFields
}
returnedCount
mentions
}
team {
user {
...UserRefFields
}
openTickets
openPoints
blocked
pointsPerSprint
overloaded
}
backlog {
open
unestimated
noArea
unassigned
stale
staleDays
}
}
}
}