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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './lib/facades/home.facade';
|
||||
export * from './lib/facades/explore.facade';
|
||||
export * from './lib/facades/inbox.facade';
|
||||
export * from './lib/facades/epics.facade';
|
||||
export * from './lib/facades/insights.facade';
|
||||
export * from './lib/facades/backlog.facade';
|
||||
export * from './lib/facades/list.facade';
|
||||
export * from './lib/facades/propose.facade';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import { ProjectInsights } from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const insightsPageEvents = eventGroup({
|
||||
source: 'Dashboard Page',
|
||||
events: {
|
||||
opened: type<{ projectId: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const insightsApiEvents = eventGroup({
|
||||
source: 'Insights API',
|
||||
events: {
|
||||
loaded: type<ProjectInsights>(),
|
||||
loadFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { insightsPageEvents } from '../events/insights.events';
|
||||
import { InsightsStore } from '../stores/insights.store';
|
||||
|
||||
/** "Seguimiento": sprint under way, risks, aging and bottlenecks. */
|
||||
@Injectable()
|
||||
export class InsightsFacade {
|
||||
private readonly store = inject(InsightsStore);
|
||||
private readonly dispatch = injectDispatch(insightsPageEvents);
|
||||
|
||||
readonly insights = this.store.insights;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
readonly loaded = this.store.loaded;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads the project's reading and keeps it up to date. */
|
||||
open(projectId: string): void {
|
||||
this.dispatch.opened({ projectId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of the dashboard, for its route component. */
|
||||
export function provideInsightsFacade(): Provider[] {
|
||||
return [InsightsStore, InsightsFacade];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import {
|
||||
ProjectInsights,
|
||||
ProjectsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { debounceTime, merge, startWith, switchMap } from 'rxjs';
|
||||
import {
|
||||
insightsApiEvents,
|
||||
insightsPageEvents,
|
||||
} from '../events/insights.events';
|
||||
import { describeError } from '../notifications';
|
||||
|
||||
interface InsightsState {
|
||||
insights: ProjectInsights | null;
|
||||
loaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** The dashboard's reading of a project, live. Scoped to the dashboard. */
|
||||
export const InsightsStore = signalStore(
|
||||
withState<InsightsState>({ insights: null, loaded: false, error: null }),
|
||||
withReducer(
|
||||
on(insightsPageEvents.opened, () => ({ loaded: false, error: null })),
|
||||
on(insightsApiEvents.loaded, ({ payload }) => ({
|
||||
insights: payload,
|
||||
loaded: true,
|
||||
error: null,
|
||||
})),
|
||||
on(insightsApiEvents.loadFailed, ({ payload }) => ({
|
||||
loaded: true,
|
||||
error: payload,
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
_store,
|
||||
events = inject(Events),
|
||||
projects = inject(ProjectsGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
) => ({
|
||||
// Any ticket or sprint may change the reading: reload, calmly.
|
||||
load$: events.on(insightsPageEvents.opened).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
merge(
|
||||
realtime.issueChanges(payload.projectId),
|
||||
realtime.sprintChanges(payload.projectId),
|
||||
realtime.reconnects(),
|
||||
).pipe(
|
||||
debounceTime(800),
|
||||
startWith(null),
|
||||
switchMap(() =>
|
||||
projects.insights(payload.projectId).pipe(
|
||||
mapResponse({
|
||||
next: (insights) => insightsApiEvents.loaded(insights),
|
||||
error: (error) =>
|
||||
insightsApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -12,3 +12,4 @@ export * from './lib/models/roadmap';
|
||||
export * from './lib/models/shortcuts';
|
||||
export * from './lib/models/due';
|
||||
export * from './lib/models/calendar';
|
||||
export * from './lib/models/insights';
|
||||
|
||||
65
libs/web/tracker/domain/src/lib/models/insights.ts
Normal file
65
libs/web/tracker/domain/src/lib/models/insights.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { UserRef } from './models';
|
||||
|
||||
/** A ticket the dashboard names, with the number that put it there. */
|
||||
export interface InsightIssue {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
/** Status id of the project's settings. */
|
||||
status: string;
|
||||
assignee: UserRef | null;
|
||||
/** Days in its status (aging, stuck) or past / to its due date. */
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
/** The sprint under way: time gone against work done. */
|
||||
export interface SprintPulse {
|
||||
sprintId: string;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalDays: number;
|
||||
/** Days left after today (negative once it is over). */
|
||||
daysLeft: number;
|
||||
tickets: number;
|
||||
doneTickets: number;
|
||||
points: number;
|
||||
donePoints: number;
|
||||
pacePerDay: number | null;
|
||||
neededPerDay: number | null;
|
||||
/** At this pace, what is open would not be closed in time. */
|
||||
behind: boolean;
|
||||
}
|
||||
|
||||
/** Tickets to look at now (worst first, a few of each). */
|
||||
export interface Risks {
|
||||
overdue: InsightIssue[];
|
||||
overdueCount: number;
|
||||
dueSoon: InsightIssue[];
|
||||
dueSoonCount: number;
|
||||
blocked: InsightIssue[];
|
||||
blockedCount: number;
|
||||
stuck: InsightIssue[];
|
||||
stuckCount: number;
|
||||
stuckDays: number;
|
||||
}
|
||||
|
||||
/** Time tickets spend in a status of the flow, and how many are there. */
|
||||
export interface StatusTime {
|
||||
status: string;
|
||||
averageDays: number | null;
|
||||
samples: number;
|
||||
open: number;
|
||||
wipLimit: number | null;
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
/** The dashboard's reading of a project beyond the counts. */
|
||||
export interface ProjectInsights {
|
||||
generatedAt: string;
|
||||
sprint: SprintPulse | null;
|
||||
risks: Risks;
|
||||
aging: { issues: InsightIssue[]; thresholdDays: number | null };
|
||||
bottlenecks: StatusTime[];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProjectInsights } from '../models/insights';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
Activity,
|
||||
@@ -63,6 +64,8 @@ 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>;
|
||||
/** Statistics of the last `days` days. */
|
||||
abstract analytics(
|
||||
projectId: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MemberInput,
|
||||
Project,
|
||||
ProjectAnalytics,
|
||||
ProjectInsights,
|
||||
ProjectSettings,
|
||||
ProjectsGateway,
|
||||
ProjectStats,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
DeleteProjectDocument,
|
||||
MyProjectsDocument,
|
||||
ProjectAnalyticsDocument,
|
||||
ProjectInsightsDocument,
|
||||
ProjectDocument,
|
||||
ProjectStatsDocument,
|
||||
PublicProjectsDocument,
|
||||
@@ -61,6 +63,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
.pipe(map((d) => toStats(d.project.stats)));
|
||||
}
|
||||
|
||||
insights(projectId: string): Observable<ProjectInsights> {
|
||||
return this.gql
|
||||
.query(ProjectInsightsDocument, { idOrKey: projectId })
|
||||
.pipe(map((d) => d.project.insights));
|
||||
}
|
||||
|
||||
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
|
||||
return this.gql
|
||||
.query(ProjectAnalyticsDocument, { idOrKey: projectId, days })
|
||||
|
||||
@@ -1757,6 +1757,107 @@ export type DeleteProjectMutation = {
|
||||
deleteProject: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type InsightIssueFieldsFragment = {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
export type ProjectInsightsQueryVariables = Exact<{
|
||||
idOrKey: string;
|
||||
}>;
|
||||
|
||||
export type ProjectInsightsQuery = {
|
||||
project: {
|
||||
id: string;
|
||||
insights: {
|
||||
generatedAt: string;
|
||||
sprint: {
|
||||
sprintId: string;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalDays: number;
|
||||
daysLeft: number;
|
||||
tickets: number;
|
||||
doneTickets: number;
|
||||
points: number;
|
||||
donePoints: number;
|
||||
pacePerDay: number | null;
|
||||
neededPerDay: number | null;
|
||||
behind: boolean;
|
||||
} | null;
|
||||
risks: {
|
||||
overdueCount: number;
|
||||
dueSoonCount: number;
|
||||
blockedCount: number;
|
||||
stuckCount: number;
|
||||
stuckDays: number;
|
||||
overdue: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
dueSoon: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
blocked: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
stuck: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
};
|
||||
aging: {
|
||||
thresholdDays: number | null;
|
||||
issues: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
};
|
||||
bottlenecks: Array<{
|
||||
status: string;
|
||||
averageDays: number | null;
|
||||
samples: number;
|
||||
open: number;
|
||||
wipLimit: number | null;
|
||||
overLimit: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ReleasesQueryVariables = Exact<{
|
||||
idOrKey: string;
|
||||
}>;
|
||||
@@ -3275,6 +3376,58 @@ export const NotificationFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
|
||||
export const InsightIssueFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'InsightIssueFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'InsightIssue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<InsightIssueFieldsFragment, unknown>;
|
||||
export const SavedFilterFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -10753,6 +10906,336 @@ export const DeleteProjectDocument = {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables
|
||||
>;
|
||||
export const ProjectInsightsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'ProjectInsights' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'idOrKey' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'project' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'idOrKey' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'idOrKey' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'insights' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'generatedAt' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprint' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprintId' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'name' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'startDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'endDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'totalDays' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'daysLeft' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'tickets' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'doneTickets' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'points' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'donePoints' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'pacePerDay' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'neededPerDay' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'behind' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'risks' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overdueCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'dueSoon' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'dueSoonCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'blocked' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'blockedCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'stuck' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'stuckCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'stuckDays' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'aging' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issues' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'thresholdDays' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'bottlenecks' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'status' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'averageDays' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'samples' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'open' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'wipLimit' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overLimit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'InsightIssueFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'InsightIssue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'days' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
ProjectInsightsQuery,
|
||||
ProjectInsightsQueryVariables
|
||||
>;
|
||||
export const ReleasesDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -144,3 +144,72 @@ mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
fragment InsightIssueFields on InsightIssue {
|
||||
id
|
||||
key
|
||||
title
|
||||
status
|
||||
assignee {
|
||||
...UserRefFields
|
||||
}
|
||||
days
|
||||
dueDate
|
||||
}
|
||||
|
||||
query ProjectInsights($idOrKey: String!) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
insights {
|
||||
generatedAt
|
||||
sprint {
|
||||
sprintId
|
||||
name
|
||||
startDate
|
||||
endDate
|
||||
totalDays
|
||||
daysLeft
|
||||
tickets
|
||||
doneTickets
|
||||
points
|
||||
donePoints
|
||||
pacePerDay
|
||||
neededPerDay
|
||||
behind
|
||||
}
|
||||
risks {
|
||||
overdue {
|
||||
...InsightIssueFields
|
||||
}
|
||||
overdueCount
|
||||
dueSoon {
|
||||
...InsightIssueFields
|
||||
}
|
||||
dueSoonCount
|
||||
blocked {
|
||||
...InsightIssueFields
|
||||
}
|
||||
blockedCount
|
||||
stuck {
|
||||
...InsightIssueFields
|
||||
}
|
||||
stuckCount
|
||||
stuckDays
|
||||
}
|
||||
aging {
|
||||
issues {
|
||||
...InsightIssueFields
|
||||
}
|
||||
thresholdDays
|
||||
}
|
||||
bottlenecks {
|
||||
status
|
||||
averageDays
|
||||
samples
|
||||
open
|
||||
wipLimit
|
||||
overLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,47 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiSkeletonComponent,
|
||||
PiStatCardComponent,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
InsightsFacade,
|
||||
ProjectFacade,
|
||||
provideInsightsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
ProjectRole,
|
||||
QA_FAILED,
|
||||
StatusCategory,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
import { AgingComponent } from '../ui/insights/aging';
|
||||
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
|
||||
import { RisksComponent } from '../ui/insights/risks';
|
||||
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
selector: 'ts-dashboard-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent],
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiNoteComponent,
|
||||
PiSkeletonComponent,
|
||||
PiStatCardComponent,
|
||||
SprintPulseComponent,
|
||||
RisksComponent,
|
||||
AgingComponent,
|
||||
BottlenecksComponent,
|
||||
],
|
||||
providers: [provideInsightsFacade()],
|
||||
template: `
|
||||
@if (facade.stats(); as stats) {
|
||||
<section class="ts-kpis" aria-label="Resumen">
|
||||
@@ -66,6 +90,35 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (insights.error(); as error) {
|
||||
<pi-note
|
||||
class="ts-insights-error"
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudo leer el estado del proyecto"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else if (insights.insights(); as reading) {
|
||||
<div class="ts-dashboard ts-insights">
|
||||
@if (reading.sprint; as sprint) {
|
||||
<ts-sprint-pulse [sprint]="sprint" />
|
||||
}
|
||||
<ts-risks [risks]="reading.risks" />
|
||||
<ts-aging
|
||||
[issues]="reading.aging.issues"
|
||||
[thresholdDays]="reading.aging.thresholdDays"
|
||||
/>
|
||||
<ts-bottlenecks
|
||||
[statuses]="reading.bottlenecks"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
|
||||
}
|
||||
|
||||
<div class="ts-dashboard">
|
||||
<section class="ts-panel" aria-labelledby="team-title">
|
||||
<header class="ts-panel__head">
|
||||
@@ -232,6 +285,12 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
font-weight: 600;
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
.ts-insights,
|
||||
.ts-insights-error,
|
||||
.ts-insights-loading {
|
||||
display: grid;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ts-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||
@@ -363,6 +422,14 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
})
|
||||
export class DashboardPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly insights = inject(InsightsFacade);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.facade.project()?.id;
|
||||
if (projectId) untracked(() => this.insights.open(projectId));
|
||||
});
|
||||
}
|
||||
protected readonly initialsOf = initials;
|
||||
|
||||
private count(status: string): number {
|
||||
|
||||
103
libs/web/tracker/presenters/src/lib/ui/insights/aging.ts
Normal file
103
libs/web/tracker/presenters/src/lib/ui/insights/aging.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { InsightIssue } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/**
|
||||
* Each ticket under way by the days it has been in its status, against the
|
||||
* project's usual: 85% of the tickets finished lately took at most that.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-aging',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', 'aria-labelledby': 'aging-title', role: 'region' },
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="aging-title">Antigüedad de lo que está en curso</h2>
|
||||
<span class="ts-muted">{{ legend() }}</span>
|
||||
</header>
|
||||
@if (issues().length) {
|
||||
<ul class="ts-issues ts-aging">
|
||||
@for (issue of issues(); track issue.id) {
|
||||
<li [class.is-old]="isOld(issue)">
|
||||
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.title
|
||||
}}</a>
|
||||
<span class="ts-bar" [attr.title]="issue.days + ' días'">
|
||||
<span [style.width.%]="share(issue.days)"></span>
|
||||
@if (threshold() !== null) {
|
||||
<i
|
||||
class="ts-aging__mark"
|
||||
[style.left.%]="share(threshold()!)"
|
||||
></i>
|
||||
}
|
||||
</span>
|
||||
<span class="ts-days">{{ issue.days }} d</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-empty">Nada en curso ahora mismo.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-aging li {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(4rem, 7rem) 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-aging .ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
.ts-aging li.is-old {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
.ts-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
.ts-aging__mark {
|
||||
position: absolute;
|
||||
top: -0.1875rem;
|
||||
bottom: -0.1875rem;
|
||||
width: 2px;
|
||||
background: var(--ts-ink-soft);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AgingComponent {
|
||||
readonly issues = input.required<InsightIssue[]>();
|
||||
/** p85 cycle time; none until something took at least part of a day. */
|
||||
readonly thresholdDays = input<number | null>(null);
|
||||
protected readonly threshold = computed(() => {
|
||||
const days = this.thresholdDays();
|
||||
return days && days > 0 ? days : null;
|
||||
});
|
||||
|
||||
private readonly max = computed(() =>
|
||||
Math.max(1, this.threshold() ?? 0, ...this.issues().map((i) => i.days)),
|
||||
);
|
||||
|
||||
protected share(days: number): number {
|
||||
return Math.min(100, (days / this.max()) * 100);
|
||||
}
|
||||
|
||||
protected isOld(issue: InsightIssue): boolean {
|
||||
const t = this.threshold();
|
||||
return t !== null && issue.days > t;
|
||||
}
|
||||
|
||||
protected readonly legend = computed(() => {
|
||||
const t = this.threshold();
|
||||
return t === null
|
||||
? 'Días en su estado actual'
|
||||
: `La raya: ${t.toLocaleString('es')} días, lo que tarda el 85% de los tickets`;
|
||||
});
|
||||
}
|
||||
105
libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts
Normal file
105
libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { StatusTime, Workflow } from '@task-stream/web/tracker/domain';
|
||||
import { statusLook } from '../copy';
|
||||
|
||||
/** Where tickets wait: average days in each status of the flow. */
|
||||
@Component({
|
||||
selector: 'ts-bottlenecks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'bottlenecks-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="bottlenecks-title">Cuellos de botella</h2>
|
||||
<span class="ts-muted">Días de media en cada estado (90 días)</span>
|
||||
</header>
|
||||
@if (rows().length) {
|
||||
<ul class="ts-issues ts-necks">
|
||||
@for (row of rows(); track row.status) {
|
||||
<li [style.--tone]="row.tone" [class.is-slowest]="row.slowest">
|
||||
<span class="ts-title">{{ row.label }}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="row.share"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ row.average }}</span>
|
||||
<span
|
||||
class="ts-days"
|
||||
[class.ts-warn]="row.overLimit"
|
||||
[attr.title]="
|
||||
row.wipLimit !== null
|
||||
? 'Hay ' + row.open + ' con un límite de ' + row.wipLimit
|
||||
: 'Hay ' + row.open + ' ahora'
|
||||
"
|
||||
>{{ row.open
|
||||
}}{{ row.wipLimit !== null ? '/' + row.wipLimit : '' }}</span
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (slowest(); as slowest) {
|
||||
<p class="ts-empty ts-necks__note">
|
||||
Donde más se paran: <strong>{{ slowest }}</strong
|
||||
>.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="ts-empty">El flujo no tiene estados en curso o de QA.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-necks {
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
grid-template-columns: minmax(6rem, 9rem) 1fr 3.5rem 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
li.is-slowest .ts-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.ts-necks__note {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BottlenecksComponent {
|
||||
readonly statuses = input.required<StatusTime[]>();
|
||||
readonly workflow = input.required<Workflow>();
|
||||
|
||||
protected readonly rows = computed(() => {
|
||||
const list = this.statuses();
|
||||
const max = Math.max(1, ...list.map((s) => s.averageDays ?? 0));
|
||||
const slowest = Math.max(...list.map((s) => s.averageDays ?? 0));
|
||||
return list.map((s) => {
|
||||
const look = statusLook(this.workflow(), s.status);
|
||||
return {
|
||||
...s,
|
||||
label: look.label,
|
||||
tone: look.tone,
|
||||
share: ((s.averageDays ?? 0) / max) * 100,
|
||||
average:
|
||||
s.averageDays === null
|
||||
? '—'
|
||||
: `${s.averageDays.toLocaleString('es')} d`,
|
||||
slowest: slowest > 0 && s.averageDays === slowest,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly slowest = computed(() => {
|
||||
const row = this.rows().find((r) => r.slowest);
|
||||
return row ? `${row.label} (${row.average})` : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Shared by the dashboard's insight panels (each host is a .ts-panel).
|
||||
:host {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.ts-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-issues {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ts-key {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
.ts-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-days {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-bar {
|
||||
position: relative;
|
||||
height: 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--tone, var(--ts-brand));
|
||||
}
|
||||
}
|
||||
|
||||
.ts-warn {
|
||||
color: var(--ts-c-red, #d14343);
|
||||
}
|
||||
|
||||
.ts-empty {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
114
libs/web/tracker/presenters/src/lib/ui/insights/risks.ts
Normal file
114
libs/web/tracker/presenters/src/lib/ui/insights/risks.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { InsightIssue, Risks } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** Overdue, due soon, blocked and stuck: what to look at now. */
|
||||
@Component({
|
||||
selector: 'ts-risks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', 'aria-labelledby': 'risks-title', role: 'region' },
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="risks-title">Tickets en riesgo</h2>
|
||||
<span class="ts-muted">Lo que conviene mirar hoy</span>
|
||||
</header>
|
||||
@for (group of groups(); track group.title) {
|
||||
@if (group.count) {
|
||||
<section class="ts-risk">
|
||||
<h3 [class.ts-warn]="group.warn">
|
||||
{{ group.title }} <span class="ts-days">{{ group.count }}</span>
|
||||
</h3>
|
||||
<ul class="ts-issues">
|
||||
@for (issue of group.issues; track issue.id) {
|
||||
<li>
|
||||
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.title
|
||||
}}</a>
|
||||
<span class="ts-days">{{ group.days(issue) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (group.count > group.issues.length) {
|
||||
<p class="ts-empty">
|
||||
y {{ group.count - group.issues.length }} más
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (!total()) {
|
||||
<p class="ts-empty">Nada vencido, bloqueado ni atascado.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-risk + .ts-risk {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RisksComponent {
|
||||
readonly risks = input.required<Risks>();
|
||||
|
||||
protected readonly total = computed(() => {
|
||||
const r = this.risks();
|
||||
return r.overdueCount + r.dueSoonCount + r.blockedCount + r.stuckCount;
|
||||
});
|
||||
|
||||
protected readonly groups = computed(() => {
|
||||
const r = this.risks();
|
||||
return [
|
||||
{
|
||||
title: 'Vencidos',
|
||||
warn: true,
|
||||
issues: r.overdue,
|
||||
count: r.overdueCount,
|
||||
days: (i: InsightIssue) => `hace ${plural(i.days, 'día', 'días')}`,
|
||||
},
|
||||
{
|
||||
title: 'Vencen esta semana',
|
||||
warn: false,
|
||||
issues: r.dueSoon,
|
||||
count: r.dueSoonCount,
|
||||
days: (i: InsightIssue) =>
|
||||
i.days === 0 ? 'hoy' : i.days === 1 ? 'mañana' : `en ${i.days} días`,
|
||||
},
|
||||
{
|
||||
title: 'Bloqueados',
|
||||
warn: false,
|
||||
issues: r.blocked,
|
||||
count: r.blockedCount,
|
||||
days: (i: InsightIssue) =>
|
||||
i.days ? plural(i.days, 'día', 'días') : 'hoy',
|
||||
},
|
||||
{
|
||||
title: `Atascados (${r.stuckDays} días o más sin moverse)`,
|
||||
warn: false,
|
||||
issues: r.stuck,
|
||||
count: r.stuckCount,
|
||||
days: (i: InsightIssue) =>
|
||||
i.days ? plural(i.days, 'día', 'días') : 'hoy',
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
133
libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
Normal file
133
libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { SprintPulse } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const short = (day: string) =>
|
||||
new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const decimal = (n: number) =>
|
||||
n.toLocaleString('es', { maximumFractionDigits: 1 });
|
||||
|
||||
/** The sprint under way: days left, points done and whether it gets there. */
|
||||
@Component({
|
||||
selector: 'ts-sprint-pulse',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', 'aria-labelledby': 'pulse-title', role: 'region' },
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
@let s = sprint();
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="pulse-title">{{ s.name }}</h2>
|
||||
<span class="ts-muted">{{ when() }}</span>
|
||||
</header>
|
||||
<p class="ts-pulse__days">
|
||||
<strong>{{ daysText() }}</strong>
|
||||
@if (s.behind) {
|
||||
<span class="ts-pulse__flag">A este ritmo no llega</span>
|
||||
} @else if (s.points && s.donePoints === s.points) {
|
||||
<span class="ts-pulse__flag is-ok">Todo hecho</span>
|
||||
}
|
||||
</p>
|
||||
<div class="ts-pulse__bars">
|
||||
<span class="ts-pulse__label">Tiempo</span>
|
||||
<span class="ts-bar"><span [style.width.%]="timeShare()"></span></span>
|
||||
<span class="ts-days">{{ timeShare() }}%</span>
|
||||
<span class="ts-pulse__label">Puntos</span>
|
||||
<span class="ts-bar" [class.is-behind]="s.behind"
|
||||
><span [style.width.%]="pointShare()"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ s.donePoints }}/{{ s.points }}</span>
|
||||
</div>
|
||||
<p class="ts-empty">
|
||||
{{ s.doneTickets }} de {{ s.tickets }} tickets terminados.
|
||||
@if (paceText(); as pace) {
|
||||
{{ pace }}
|
||||
}
|
||||
<a routerLink="../sprints">Ver el sprint →</a>
|
||||
</p>
|
||||
`,
|
||||
styles: `
|
||||
.ts-pulse__days {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 1rem;
|
||||
strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
.ts-pulse__flag {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-c-red, #d14343) 14%, transparent);
|
||||
color: var(--ts-c-red, #d14343);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
&.is-ok {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--ts-c-green, #2e9e6a) 14%,
|
||||
transparent
|
||||
);
|
||||
color: var(--ts-c-green, #2e9e6a);
|
||||
}
|
||||
}
|
||||
.ts-pulse__bars {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ts-pulse__label {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-bar.is-behind {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
a {
|
||||
color: var(--ts-brand);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SprintPulseComponent {
|
||||
readonly sprint = input.required<SprintPulse>();
|
||||
|
||||
protected readonly when = computed(
|
||||
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
||||
);
|
||||
protected readonly daysText = computed(() => {
|
||||
const left = this.sprint().daysLeft;
|
||||
if (left < 0)
|
||||
return 'Terminó hace ' + -left + (left === -1 ? ' día' : ' días');
|
||||
if (left === 0) return 'Último día';
|
||||
return `Quedan ${left} ${left === 1 ? 'día' : 'días'}`;
|
||||
});
|
||||
protected readonly timeShare = computed(() => {
|
||||
const s = this.sprint();
|
||||
const gone = s.totalDays - Math.max(s.daysLeft, 0);
|
||||
return Math.round((Math.min(gone, s.totalDays) / s.totalDays) * 100);
|
||||
});
|
||||
protected readonly pointShare = computed(() => {
|
||||
const s = this.sprint();
|
||||
return s.points ? Math.round((s.donePoints / s.points) * 100) : 0;
|
||||
});
|
||||
protected readonly paceText = computed(() => {
|
||||
const s = this.sprint();
|
||||
if (s.pacePerDay === null || s.neededPerDay === null) return '';
|
||||
return `Se cierran ${decimal(s.pacePerDay)} pts al día; hacen falta ${decimal(s.neededPerDay)}.`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user