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:
@@ -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[]> {
|
||||
|
||||
177
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
177
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
227
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
227
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user