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

@@ -103,6 +103,11 @@ export interface IssueListFilter {
unassigned?: boolean;
releaseId?: string;
sprintId?: string;
/** Only tickets nobody estimated / with no area. */
noEstimate?: boolean;
noArea?: boolean;
/** Only tickets not updated since this instant (ISO). */
updatedBefore?: string;
/** Only tickets holding these custom values. */
customFields?: CustomFieldValue[];
limit: number;

View File

@@ -55,6 +55,53 @@ export interface StatusTime {
overLimit: boolean;
}
/** When a planned release would be finished, at the team's real pace. */
export interface ReleaseForecast {
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
/** Half of the simulated futures finish by then. */
likelyDate: string | null;
/** 85% of them do. */
safeDate: string | null;
verdict: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE';
}
/** What happened after a moment (my last visit). */
export interface ChangesSince {
since: string;
created: InsightIssue[];
createdCount: number;
finished: InsightIssue[];
finishedCount: number;
/** Sent back from QA. */
returned: InsightIssue[];
returnedCount: number;
mentions: number;
}
/** One person's open work against what they usually close per sprint. */
export interface TeamLoad {
user: UserRef;
openTickets: number;
openPoints: number;
blocked: number;
pointsPerSprint: number | null;
overloaded: boolean;
}
/** Open tickets missing what planning needs. */
export interface BacklogHealth {
open: number;
unestimated: number;
noArea: number;
unassigned: number;
stale: number;
staleDays: number;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
@@ -62,4 +109,9 @@ export interface ProjectInsights {
risks: Risks;
aging: { issues: InsightIssue[]; thresholdDays: number | null };
bottlenecks: StatusTime[];
releases: ReleaseForecast[];
/** Only when asked with a `since`. */
changes: ChangesSince | null;
team: TeamLoad[];
backlog: BacklogHealth;
}

View File

@@ -64,8 +64,11 @@ export abstract class ProjectsGateway {
abstract publicProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
/** Sprint under way, risks, aging and bottlenecks (the dashboard). */
abstract insights(projectId: string): Observable<ProjectInsights>;
/** The dashboard; with `since`, also what changed after it. */
abstract insights(
projectId: string,
since: string | null,
): Observable<ProjectInsights>;
/** Statistics of the last `days` days. */
abstract analytics(
projectId: string,