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

@@ -19,7 +19,7 @@ import {
const DAY = 86_400_000;
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
interface Step {
export interface Step {
at: number;
status: string;
category: StatusCategory;
@@ -63,7 +63,7 @@ function weeks(from: number, to: number): string[] {
}
/** Status steps per ticket; tickets with no timeline get one from their creation. */
function stepsByIssue(
export function stepsByIssue(
issues: IssueReadModel[],
timeline: IssueTimelineEntry[],
): Map<string, Step[]> {

View File

@@ -0,0 +1,177 @@
import {
defaultProjectSettings,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
SprintReadModel,
SprintStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import { computeInsights, STUCK_DAYS } from './insights';
const NOW = new Date('2026-10-15T12:00:00Z');
const bob = { id: 'bob', name: 'Bob' };
const issue = (
id: string,
status: string,
category: C,
extra: Partial<IssueReadModel> = {},
): IssueReadModel =>
({
id,
key: id,
title: `Ticket ${id}`,
status,
statusCategory: category,
assignee: bob,
reporter: bob,
sprint: null,
storyPoints: null,
dueDate: null,
links: [],
trashedAt: null,
createdAt: '2026-09-01T09:00:00Z',
updatedAt: '2026-10-14T09:00:00Z',
...extra,
}) as IssueReadModel;
let seq = 0;
const step = (
issueId: string,
at: string,
status: string,
category: C,
): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'status',
status,
category,
assigneeId: 'bob',
});
const sprint: SprintReadModel = {
id: 's',
projectId: 'p',
name: 'Sprint 7',
goal: null,
startDate: '2026-10-12',
endDate: '2026-10-25',
status: SprintStatus.ACTIVE,
startedAt: '2026-10-12T08:00:00Z',
closedAt: null,
createdAt: '2026-10-01T08:00:00Z',
version: 2,
};
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
describe('computeInsights', () => {
it('names what is late, due soon, blocked and stuck', () => {
const issues = [
issue('LATE', 'TODO', C.TODO, { dueDate: '2026-10-10' }),
issue('SOON', 'TODO', C.TODO, { dueDate: '2026-10-20' }),
issue('LATER', 'TODO', C.TODO, { dueDate: '2026-11-30' }),
issue('DONE_LATE', 'DONE', C.DONE, { dueDate: '2026-10-01' }),
issue('WAITS', 'TODO', C.TODO, {
links: [
{
type: IssueLinkType.BLOCKS,
direction: 'INWARD',
issueId: 'SLOW',
issueKey: 'SLOW',
},
],
}),
issue('SLOW', 'IN_PROGRESS', C.IN_PROGRESS),
issue('FRESH', 'IN_QA', C.QA),
];
const timeline = [
step('SLOW', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('FRESH', '2026-10-14T10:00:00Z', 'IN_QA', C.QA),
];
const { risks, aging } = computeInsights({
issues,
timeline,
settings: defaultProjectSettings(),
activeSprint: null,
now: NOW,
});
expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]);
expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]);
expect(risks.blocked.map((i) => i.key)).toEqual(['WAITS']);
expect(risks.stuck.map((i) => [i.key, i.days])).toEqual([['SLOW', 14]]);
expect(risks.stuckDays).toBe(STUCK_DAYS);
// Everything under way, the longest first.
expect(aging.issues.map((i) => [i.key, i.days])).toEqual([
['SLOW', 14],
['FRESH', 1],
]);
});
it('measures the sprint under way against the calendar', () => {
const issues = [
issue('A', 'DONE', C.DONE, { ...inSprint, storyPoints: 3 }),
issue('B', 'IN_PROGRESS', C.IN_PROGRESS, { ...inSprint, storyPoints: 8 }),
issue('C', 'TODO', C.TODO, { ...inSprint, storyPoints: 5 }),
issue('X', 'CANCELLED', C.CANCELLED, { ...inSprint, storyPoints: 13 }),
];
const { sprint: pulse } = computeInsights({
issues,
timeline: [],
settings: defaultProjectSettings(),
activeSprint: sprint,
now: NOW,
});
expect(pulse).toMatchObject({
totalDays: 14,
daysLeft: 10,
tickets: 3,
doneTickets: 1,
points: 16,
donePoints: 3,
// 3 points in 3 days; 13 left for 11 days.
pacePerDay: 1,
neededPerDay: 1.2,
behind: true,
});
});
it('finds where tickets wait, and columns over their limit', () => {
const settings = defaultProjectSettings();
settings.statuses.find((s) => s.id === 'IN_REVIEW')!.wipLimit = 1;
const issues = [
issue('A', 'DONE', C.DONE),
issue('B', 'IN_REVIEW', C.IN_PROGRESS),
issue('C', 'IN_REVIEW', C.IN_PROGRESS),
];
const timeline = [
step('A', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('A', '2026-10-03T10:00:00Z', 'IN_REVIEW', C.IN_PROGRESS),
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
];
const rows = computeInsights({
issues,
timeline,
settings,
activeSprint: null,
now: NOW,
}).bottlenecks;
expect(rows.map((r) => r.status)).toEqual([
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
'QA_FAILED',
]);
expect(rows[0]).toMatchObject({ averageDays: 2, samples: 1, open: 0 });
expect(rows[1]).toMatchObject({
averageDays: 4,
open: 2,
wipLimit: 1,
overLimit: true,
});
});
});

View File

@@ -0,0 +1,227 @@
import {
InsightIssueReadModel,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
ProjectInsightsReadModel,
ProjectSettings,
SprintPulseReadModel,
SprintReadModel,
StatusCategory,
StatusTimeReadModel,
} from '@task-stream/api/tracker/domain';
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
/*
* The dashboard's reading of a project: what needs attention now (risks),
* how long the work under way has been where it is (aging) and where it
* waits (bottlenecks). Pure functions over the read model and the
* timeline, like the statistics.
*/
const DAY = 86_400_000;
/** Days without moving that make a ticket under way «stuck». */
export const STUCK_DAYS = 7;
/** How many tickets each list names. */
const LIST = 8;
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
export interface InsightsInput {
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
settings: ProjectSettings;
activeSprint: SprintReadModel | null;
now: Date;
}
const utcDay = (time: number) =>
Date.UTC(
new Date(time).getUTCFullYear(),
new Date(time).getUTCMonth(),
new Date(time).getUTCDate(),
);
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
const round1 = (n: number) => Math.round(n * 10) / 10;
export function computeInsights(
input: InsightsInput,
): ProjectInsightsReadModel {
const now = input.now.getTime();
const today = utcDay(now);
const live = input.issues.filter((i) => !i.trashedAt);
const open = live.filter((i) => !CLOSED.includes(i.statusCategory));
const steps = stepsByIssue(live, input.timeline);
/** When it got to its current status. */
const sinceOf = (issue: IssueReadModel) => {
const list = steps.get(issue.id) ?? [];
const last = list[list.length - 1];
return last && last.status === issue.status
? last.at
: Date.parse(issue.updatedAt);
};
const line = (
issue: IssueReadModel,
days: number,
): InsightIssueReadModel => ({
id: issue.id,
key: issue.key,
title: issue.title,
status: issue.status,
assignee: issue.assignee,
days,
dueDate: issue.dueDate,
});
// --- Risks ---
const due = open
.filter((i) => i.dueDate)
.map((i) => ({
issue: i,
days: (dayIndex(i.dueDate as string) - today) / DAY,
}));
const overdue = due
.filter((d) => d.days < 0)
.sort((a, b) => a.days - b.days)
.map((d) => line(d.issue, -d.days));
const dueSoon = due
.filter((d) => d.days >= 0 && d.days <= 7)
.sort((a, b) => a.days - b.days)
.map((d) => line(d.issue, d.days));
const openIds = new Set(open.map((i) => i.id));
const blocked = open
.filter((i) =>
i.links.some(
(l) =>
openIds.has(l.issueId) &&
((l.type === IssueLinkType.BLOCKS && l.direction === 'INWARD') ||
(l.type === IssueLinkType.DEPENDS_ON && l.direction === 'OUTWARD')),
),
)
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
.sort((a, b) => b.days - a.days);
const underWay = open
.filter((i) => FLOW.includes(i.statusCategory))
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
.sort((a, b) => b.days - a.days);
const stuck = underWay.filter((i) => i.days >= STUCK_DAYS);
// --- Aging: against the cycle time of the last 90 days ---
const analytics = computeProjectAnalytics({
issues: live,
timeline: input.timeline,
statusOrder: input.settings.statuses.map((s) => s.id),
from: new Date(now - 89 * DAY),
to: input.now,
});
return {
generatedAt: input.now.toISOString(),
sprint: input.activeSprint
? sprintPulse(input.activeSprint, live, now)
: null,
risks: {
overdue: overdue.slice(0, LIST),
overdueCount: overdue.length,
dueSoon: dueSoon.slice(0, LIST),
dueSoonCount: dueSoon.length,
blocked: blocked.slice(0, LIST),
blockedCount: blocked.length,
stuck: stuck.slice(0, LIST),
stuckCount: stuck.length,
stuckDays: STUCK_DAYS,
},
aging: {
issues: underWay.slice(0, 15),
thresholdDays: analytics.cycleTime.p85Days,
},
bottlenecks: bottlenecks(input.settings, open, steps, now),
};
}
function sprintPulse(
sprint: SprintReadModel,
issues: IssueReadModel[],
now: number,
): SprintPulseReadModel {
const inSprint = issues.filter(
(i) =>
i.sprint?.id === sprint.id &&
i.statusCategory !== StatusCategory.CANCELLED,
);
const done = inSprint.filter((i) => i.statusCategory === StatusCategory.DONE);
const points = (list: IssueReadModel[]) =>
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
const start = dayIndex(sprint.startDate);
const end = dayIndex(sprint.endDate);
const today = utcDay(now);
const totalDays = Math.round((end - start) / DAY) + 1;
const daysLeft = Math.round((end - today) / DAY);
// Days already worked: today counts once it is half gone.
const elapsed = Math.min(
totalDays,
Math.max(0, Math.round((today - start) / DAY)),
);
const total = points(inSprint);
const closed = points(done);
const remaining = total - closed;
const pace = elapsed > 0 ? round1(closed / elapsed) : null;
const left = Math.max(daysLeft + 1, 0);
const needed =
remaining > 0 ? (left > 0 ? round1(remaining / left) : null) : null;
return {
sprintId: sprint.id,
name: sprint.name,
startDate: sprint.startDate,
endDate: sprint.endDate,
totalDays,
daysLeft,
tickets: inSprint.length,
doneTickets: done.length,
points: total,
donePoints: closed,
pacePerDay: pace,
neededPerDay: needed,
behind:
remaining > 0 &&
(left === 0 || (pace !== null && pace * left < remaining)),
};
}
/** Average stay in each in-progress and QA status (stays that ended lately). */
function bottlenecks(
settings: ProjectSettings,
open: IssueReadModel[],
steps: Map<string, Step[]>,
now: number,
): StatusTimeReadModel[] {
const from = now - 90 * DAY;
const stays = new Map<string, number[]>();
for (const list of steps.values())
list.forEach((step, i) => {
const next = list[i + 1];
if (!next || next.at < from) return;
stays.set(step.status, [
...(stays.get(step.status) ?? []),
next.at - step.at,
]);
});
return settings.statuses
.filter((s) => !s.archived && FLOW.includes(s.category))
.map((s) => {
const measured = stays.get(s.id) ?? [];
const count = open.filter((i) => i.status === s.id).length;
const limit = s.wipLimit ?? null;
return {
status: s.id,
averageDays: measured.length
? round1(measured.reduce((a, b) => a + b, 0) / measured.length / DAY)
: null,
samples: measured.length,
open: count,
wipLimit: limit,
overLimit: limit !== null && count > limit,
};
});
}

View File

@@ -11,6 +11,7 @@ import {
import {
IssuePriority,
ProjectAnalyticsReadModel,
ProjectInsightsReadModel,
ProjectReadModel,
ProjectRole,
ProjectSettings,
@@ -34,6 +35,7 @@ import { IssueFilter } from './ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
import { GetProjectQuery } from './queries/get-project.query';
@@ -321,6 +323,16 @@ export class ProjectsService {
}
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
/** Sprint under way, risks, aging and bottlenecks, for the dashboard. */
insights(
viewerId: string | null,
projectId: string,
): Promise<ProjectInsightsReadModel> {
return this.queryBus.execute(
new GetProjectInsightsQuery(viewerId, projectId),
);
}
analytics(
viewerId: string | null,
projectId: string,

View File

@@ -0,0 +1,44 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectInsightsReadModel,
} from '@task-stream/api/tracker/domain';
import { computeInsights } from '../analytics/insights';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetProjectInsightsQuery } from './get-project-insights.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetProjectInsightsQuery)
export class GetProjectInsightsQueryHandler
implements IQueryHandler<GetProjectInsightsQuery, ProjectInsightsReadModel>
{
constructor(
private readonly access: ReadAccess,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
) {}
async execute(
query: GetProjectInsightsQuery,
): Promise<ProjectInsightsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
const [project, issues, timeline, activeSprint] = await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),
this.findTimeline.findByProject(query.projectId),
this.findSprints.findActive(query.projectId),
]);
return computeInsights({
issues,
timeline,
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
activeSprint,
now: new Date(),
});
}
}

View File

@@ -0,0 +1,13 @@
import { Query } from '@nestjs/cqrs';
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
/** The dashboard's reading of a project: sprint, risks, aging, bottlenecks. */
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -6,6 +6,7 @@ export * from './get-issue.query';
export * from './get-issues-by-ids.query';
export * from './get-my-projects.query';
export * from './get-project-analytics.query';
export * from './get-project-insights.query';
export * from './get-project-stats.query';
export * from './get-release-burnup.query';
export * from './get-project.query';

View File

@@ -9,6 +9,7 @@ import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
import { GetProjectQueryHandler } from './get-project.query-handler';
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
import { GetReleaseQueryHandler } from './get-release.query-handler';
@@ -34,6 +35,7 @@ export const QUERY_HANDLERS = [
GetMyProjectsQueryHandler,
GetProjectQueryHandler,
GetProjectAnalyticsQueryHandler,
GetProjectInsightsQueryHandler,
GetProjectStatsQueryHandler,
GetReleaseBurnupQueryHandler,
GetReleaseQueryHandler,

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[];
}

View File

@@ -31,6 +31,7 @@ import {
BoardColumnGql,
CommandResultGql,
ProjectAnalyticsGql,
ProjectInsightsGql,
ProjectGql,
ProjectSettingsGql,
ProjectStatsGql,
@@ -83,6 +84,17 @@ export class ProjectResolver {
return this.projects.analytics(viewerOf(me), project.id, days);
}
@ResolveField(() => ProjectInsightsGql, {
description:
'The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks',
})
insights(
@CurrentUser() me: User | undefined,
@Parent() project: ProjectReadModel,
) {
return this.projects.insights(viewerOf(me), project.id);
}
@ResolveField(() => [ReleaseGql])
releases(
@CurrentUser() me: User | undefined,

View File

@@ -794,3 +794,87 @@ export class ReleaseBurnupGql {
scope!: number[];
@Field(() => [Int]) done!: number[];
}
@ObjectType('InsightIssue', {
description:
'A ticket the dashboard names, with the number that put it there',
})
export class InsightIssueGql {
@Field(() => ID) id!: string;
@Field() key!: string;
@Field() title!: string;
@Field() status!: string;
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@Field(() => Float, {
description: 'Days in its status (aging, stuck) or past / to its due date',
})
days!: number;
@Field(() => String, { nullable: true }) dueDate!: string | null;
}
@ObjectType('SprintPulse', {
description: 'The sprint under way: time gone against work done',
})
export class SprintPulseGql {
@Field(() => ID) sprintId!: string;
@Field() name!: string;
@Field() startDate!: string;
@Field() endDate!: string;
@Field(() => Int) totalDays!: number;
@Field(() => Int, { description: 'Days left after today' })
daysLeft!: number;
@Field(() => Int) tickets!: number;
@Field(() => Int) doneTickets!: number;
@Field(() => Int) points!: number;
@Field(() => Int) donePoints!: number;
@Field(() => Float, { nullable: true }) pacePerDay!: number | null;
@Field(() => Float, { nullable: true }) neededPerDay!: number | null;
@Field({ description: 'At this pace, what is open would not be closed' })
behind!: boolean;
}
@ObjectType('Risks', { description: 'Tickets to look at now (worst first)' })
export class RiskGql {
@Field(() => [InsightIssueGql]) overdue!: InsightIssueGql[];
@Field(() => Int) overdueCount!: number;
@Field(() => [InsightIssueGql], { description: 'Due within 7 days' })
dueSoon!: InsightIssueGql[];
@Field(() => Int) dueSoonCount!: number;
@Field(() => [InsightIssueGql]) blocked!: InsightIssueGql[];
@Field(() => Int) blockedCount!: number;
@Field(() => [InsightIssueGql]) stuck!: InsightIssueGql[];
@Field(() => Int) stuckCount!: number;
@Field(() => Int) stuckDays!: number;
}
@ObjectType('Aging')
export class AgingGql {
@Field(() => [InsightIssueGql]) issues!: InsightIssueGql[];
@Field(() => Float, {
nullable: true,
description: 'p85 cycle time of the last 90 days',
})
thresholdDays!: number | null;
}
@ObjectType('StatusTime')
export class StatusTimeGql {
@Field() status!: string;
@Field(() => Float, { nullable: true }) averageDays!: number | null;
@Field(() => Int) samples!: number;
@Field(() => Int) open!: number;
@Field(() => Int, { nullable: true }) wipLimit!: number | null;
@Field() overLimit!: boolean;
}
@ObjectType('ProjectInsights', {
description: 'The dashboard: sprint under way, risks, aging and bottlenecks',
})
export class ProjectInsightsGql {
@Field() generatedAt!: string;
@Field(() => SprintPulseGql, { nullable: true })
sprint!: SprintPulseGql | null;
@Field(() => RiskGql) risks!: RiskGql;
@Field(() => AgingGql) aging!: AgingGql;
@Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[];
}

View File

@@ -876,3 +876,78 @@ export class NotificationSettingsResponseDto {
})
pushPublicKey!: string | null;
}
export class InsightIssueDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'WEB-12' }) key!: string;
@ApiProperty() title!: string;
@ApiProperty({ description: 'Status id' }) status!: string;
@ApiProperty({ type: UserRefDto, nullable: true })
assignee!: UserRefDto | null;
@ApiProperty({
description: 'Days in its status (aging, stuck) or past / to its due date',
})
days!: number;
@ApiProperty({ type: String, nullable: true, format: 'date' })
dueDate!: string | null;
}
export class SprintPulseDto {
@ApiProperty({ format: 'uuid' }) sprintId!: string;
@ApiProperty() name!: string;
@ApiProperty({ format: 'date' }) startDate!: string;
@ApiProperty({ format: 'date' }) endDate!: string;
@ApiProperty() totalDays!: number;
@ApiProperty({ description: 'Days left after today' }) daysLeft!: number;
@ApiProperty() tickets!: number;
@ApiProperty() doneTickets!: number;
@ApiProperty() points!: number;
@ApiProperty() donePoints!: number;
@ApiProperty({ type: Number, nullable: true }) pacePerDay!: number | null;
@ApiProperty({ type: Number, nullable: true }) neededPerDay!: number | null;
@ApiProperty({
description: 'At this pace, what is open would not be closed',
})
behind!: boolean;
}
export class RisksDto {
@ApiProperty({ type: [InsightIssueDto] }) overdue!: InsightIssueDto[];
@ApiProperty() overdueCount!: number;
@ApiProperty({ type: [InsightIssueDto], description: 'Due within 7 days' })
dueSoon!: InsightIssueDto[];
@ApiProperty() dueSoonCount!: number;
@ApiProperty({ type: [InsightIssueDto] }) blocked!: InsightIssueDto[];
@ApiProperty() blockedCount!: number;
@ApiProperty({ type: [InsightIssueDto] }) stuck!: InsightIssueDto[];
@ApiProperty() stuckCount!: number;
@ApiProperty() stuckDays!: number;
}
export class AgingDto {
@ApiProperty({ type: [InsightIssueDto] }) issues!: InsightIssueDto[];
@ApiProperty({
type: Number,
nullable: true,
description: 'p85 cycle time of the last 90 days',
})
thresholdDays!: number | null;
}
export class StatusTimeDto {
@ApiProperty() status!: string;
@ApiProperty({ type: Number, nullable: true }) averageDays!: number | null;
@ApiProperty() samples!: number;
@ApiProperty() open!: number;
@ApiProperty({ type: Number, nullable: true }) wipLimit!: number | null;
@ApiProperty() overLimit!: boolean;
}
export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true })
sprint!: SprintPulseDto | null;
@ApiProperty({ type: RisksDto }) risks!: RisksDto;
@ApiProperty({ type: AgingDto }) aging!: AgingDto;
@ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[];
}

View File

@@ -47,6 +47,7 @@ import {
BoardColumnDto,
CommandResultDto,
ProjectAnalyticsDto,
ProjectInsightsDto,
ProjectDto,
ProjectStatsDto,
} from './dto/responses.dto';
@@ -243,6 +244,26 @@ export class ProjectsController {
);
}
@Get(':project/insights')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: 'The dashboard',
description:
'Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.',
})
@ApiOkResponse({ type: ProjectInsightsDto })
@ApiReadErrors()
async insights(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
return this.projects.insights(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
);
}
@Get(':project/activity')
@ApiOptionalPireonAuth()
@PROJECT