feat: dashboard reads the sprint, the risks, aging and bottlenecks

A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
  needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
  and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:55:52 +02:00
parent 86e313efaa
commit fe03c5bda8
41 changed files with 2774 additions and 20 deletions

View File

@@ -9,3 +9,4 @@ export * from './analytics.read-model';
export * from './notification.read-model';
export * from './saved-filter.read-model';
export * from './sprint.read-model';
export * from './insights.read-model';

View File

@@ -0,0 +1,81 @@
import { UserRefReadModel } from './user-ref.read-model';
/** A ticket named by the dashboard, with the number that put it there. */
export class InsightIssueReadModel {
id!: string;
key!: string;
title!: string;
/** Status id of the project's settings. */
status!: string;
assignee!: UserRefReadModel | null;
/** Days in its current status (aging, stuck) or past/to its due date. */
days!: number;
dueDate!: string | null;
}
/** The sprint under way: time gone against work done. */
export class SprintPulseReadModel {
sprintId!: string;
name!: string;
startDate!: string;
endDate!: string;
/** Days of the sprint, both ends included. */
totalDays!: number;
/** Days left after today (0 on the last day; negative once it is over). */
daysLeft!: number;
tickets!: number;
doneTickets!: number;
points!: number;
donePoints!: number;
/** Points closed per day so far (null on the first day). */
pacePerDay!: number | null;
/** Points per day still needed to finish (null when nothing is left). */
neededPerDay!: number | null;
/** At this pace, what is open would not be closed in time. */
behind!: boolean;
}
/** Tickets to look at now. Lists are the worst first, a few at most. */
export class RiskReadModel {
overdue!: InsightIssueReadModel[];
overdueCount!: number;
/** Due within the next 7 days. */
dueSoon!: InsightIssueReadModel[];
dueSoonCount!: number;
/** Waiting for an open ticket that blocks them (or they depend on). */
blocked!: InsightIssueReadModel[];
blockedCount!: number;
/** In progress or in QA for `stuckDays` or more without moving. */
stuck!: InsightIssueReadModel[];
stuckCount!: number;
stuckDays!: number;
}
/** How long the work under way has been where it is. */
export class AgingReadModel {
issues!: InsightIssueReadModel[];
/** 85% of the tickets finished lately took at most this (cycle time). */
thresholdDays!: number | null;
}
/** Time tickets spend in a status of the flow, and how many are there. */
export class StatusTimeReadModel {
status!: string;
/** Average stay of the tickets that left it in the last 90 days. */
averageDays!: number | null;
/** Stays measured. */
samples!: number;
open!: number;
wipLimit!: number | null;
overLimit!: boolean;
}
/** What the project dashboard reads beyond the counts. */
export class ProjectInsightsReadModel {
generatedAt!: string;
sprint!: SprintPulseReadModel | null;
risks!: RiskReadModel;
aging!: AgingReadModel;
/** In-progress and QA statuses, in board order. */
bottlenecks!: StatusTimeReadModel[];
}