feat: dashboard weighs quality and splits work by a list field
The insights reading adds quality (open bugs per priority, bugs created and closed per week for 8 weeks, QA returns and passes per area over 90 days, bugs reported in the 14 days after each of the last three releases) and, for each list custom field, open tickets per option. The dashboard shows both; the field to split by can be picked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
CustomFieldType,
|
||||
defaultProjectSettings,
|
||||
IssuePriority,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
IssueLinkType,
|
||||
@@ -302,4 +304,92 @@ describe('computeInsights', () => {
|
||||
staleDays: STALE_DAYS,
|
||||
});
|
||||
});
|
||||
|
||||
it('weighs quality and splits open tickets by a list field', () => {
|
||||
const settings = defaultProjectSettings();
|
||||
settings.customFields = [
|
||||
{
|
||||
id: 'BROWSER',
|
||||
name: 'Navegador',
|
||||
type: CustomFieldType.SELECT,
|
||||
options: ['Chrome', 'Safari'],
|
||||
archived: false,
|
||||
},
|
||||
];
|
||||
const issues = [
|
||||
issue('B1', 'TODO', C.TODO, {
|
||||
type: 'BUG',
|
||||
priority: IssuePriority.HIGH,
|
||||
createdAt: '2026-10-14T09:00:00Z',
|
||||
customFields: { BROWSER: 'Safari' },
|
||||
}),
|
||||
issue('B2', 'DONE', C.DONE, {
|
||||
type: 'BUG',
|
||||
area: 'FRONTEND',
|
||||
createdAt: '2026-10-02T09:00:00Z',
|
||||
}),
|
||||
issue('T1', 'IN_PROGRESS', C.IN_PROGRESS, {
|
||||
type: 'TASK',
|
||||
area: 'FRONTEND',
|
||||
customFields: { BROWSER: 'Safari' },
|
||||
}),
|
||||
];
|
||||
const timeline = [
|
||||
step('B2', '2026-10-05T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('B2', '2026-10-06T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
|
||||
step('B2', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('B2', '2026-10-08T09:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const { quality, fields } = computeInsights({
|
||||
...BASE,
|
||||
settings,
|
||||
issues,
|
||||
timeline,
|
||||
releases: [
|
||||
{
|
||||
id: 'r',
|
||||
name: '1.0',
|
||||
status: ReleaseStatus.RELEASED,
|
||||
releasedAt: '2026-10-01T00:00:00Z',
|
||||
} as ReleaseReadModel,
|
||||
],
|
||||
});
|
||||
expect(quality.openBugsByPriority[1]).toEqual({
|
||||
priority: IssuePriority.HIGH,
|
||||
count: 1,
|
||||
});
|
||||
expect(quality.bugWeeks).toHaveLength(8);
|
||||
expect(quality.bugWeeks.at(-1)).toEqual({
|
||||
week: '2026-10-12',
|
||||
created: 1,
|
||||
closed: 0,
|
||||
});
|
||||
expect(quality.bugWeeks.at(-2)).toEqual({
|
||||
week: '2026-10-05',
|
||||
created: 0,
|
||||
closed: 1,
|
||||
});
|
||||
expect(quality.qaByArea).toEqual([
|
||||
{ area: 'FRONTEND', returned: 1, passed: 1 },
|
||||
]);
|
||||
// B2 came 1 day after 1.0; B1 13 days after.
|
||||
expect(quality.escaped).toEqual([
|
||||
{
|
||||
releaseId: 'r',
|
||||
name: '1.0',
|
||||
releasedAt: '2026-10-01T00:00:00Z',
|
||||
bugs: 2,
|
||||
},
|
||||
]);
|
||||
expect(fields).toEqual([
|
||||
{
|
||||
fieldId: 'BROWSER',
|
||||
counts: [
|
||||
{ value: 'Safari', count: 2 },
|
||||
{ value: 'Chrome', count: 0 },
|
||||
],
|
||||
none: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
BacklogHealthReadModel,
|
||||
CustomFieldType,
|
||||
FieldBreakdownReadModel,
|
||||
IssuePriority,
|
||||
QualityReadModel,
|
||||
ChangesSinceReadModel,
|
||||
InsightIssueReadModel,
|
||||
IssueLinkType,
|
||||
@@ -18,7 +22,12 @@ import {
|
||||
TeamLoadReadModel,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
|
||||
import {
|
||||
computeProjectAnalytics,
|
||||
Step,
|
||||
stepsByIssue,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
|
||||
/*
|
||||
* The dashboard's reading of a project: what needs attention now (risks),
|
||||
@@ -169,9 +178,124 @@ export function computeInsights(
|
||||
new Set(blocked.map((b) => b.id)),
|
||||
),
|
||||
backlog: backlogHealth(open, now),
|
||||
quality: quality(input.settings, live, steps, input.releases, now),
|
||||
fields: fieldBreakdown(input.settings, open),
|
||||
};
|
||||
}
|
||||
|
||||
const PRIORITIES = [
|
||||
IssuePriority.CRITICAL,
|
||||
IssuePriority.HIGH,
|
||||
IssuePriority.MEDIUM,
|
||||
IssuePriority.LOW,
|
||||
IssuePriority.LOWEST,
|
||||
];
|
||||
|
||||
/** Bugs are the tickets whose type is drawn with the bug icon. */
|
||||
function quality(
|
||||
settings: ProjectSettings,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
releases: ReleaseReadModel[],
|
||||
now: number,
|
||||
): QualityReadModel {
|
||||
const bugTypes = new Set(
|
||||
settings.issueTypes.filter((t) => t.icon === 'bug').map((t) => t.id),
|
||||
);
|
||||
const bugs = issues.filter((i) => bugTypes.has(i.type));
|
||||
const openBugs = bugs.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
|
||||
// Created and closed per week, the last 8.
|
||||
const thisWeek = Date.parse(weekOf(now));
|
||||
const weeks = Array.from({ length: 8 }, (_, n) =>
|
||||
new Date(thisWeek - (7 - n) * 7 * DAY).toISOString().slice(0, 10),
|
||||
);
|
||||
const tally = new Map(weeks.map((w) => [w, { created: 0, closed: 0 }]));
|
||||
for (const bug of bugs) {
|
||||
const created = tally.get(weekOf(Date.parse(bug.createdAt)));
|
||||
if (created) created.created++;
|
||||
const list = steps.get(bug.id) ?? [];
|
||||
list.forEach((step, i) => {
|
||||
if (i === 0 || !CLOSED.includes(step.category)) return;
|
||||
if (CLOSED.includes(list[i - 1].category)) return;
|
||||
const week = tally.get(weekOf(step.at));
|
||||
if (week) week.closed++;
|
||||
});
|
||||
}
|
||||
|
||||
// QA's verdicts per area, the last 90 days.
|
||||
const from = now - 90 * DAY;
|
||||
const byArea = new Map<string | null, { returned: number; passed: number }>();
|
||||
for (const issue of issues) {
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
list.forEach((step, i) => {
|
||||
const before = list[i - 1];
|
||||
if (!before || step.at < from) return;
|
||||
if (before.category !== StatusCategory.QA) return;
|
||||
if (step.category === StatusCategory.QA) return;
|
||||
if (step.category === StatusCategory.CANCELLED) return;
|
||||
const row = byArea.get(issue.area) ?? { returned: 0, passed: 0 };
|
||||
if (step.category === StatusCategory.DONE) row.passed++;
|
||||
else row.returned++;
|
||||
byArea.set(issue.area, row);
|
||||
});
|
||||
}
|
||||
|
||||
// Bugs reported in the 14 days after each of the last shipped releases.
|
||||
const escaped = releases
|
||||
.filter((r) => r.status === ReleaseStatus.RELEASED && r.releasedAt)
|
||||
.sort((a, b) =>
|
||||
(b.releasedAt as string).localeCompare(a.releasedAt as string),
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((r) => {
|
||||
const shipped = Date.parse(r.releasedAt as string);
|
||||
return {
|
||||
releaseId: r.id,
|
||||
name: r.name,
|
||||
releasedAt: r.releasedAt as string,
|
||||
bugs: bugs.filter((b) => {
|
||||
const at = Date.parse(b.createdAt);
|
||||
return at > shipped && at <= shipped + 14 * DAY;
|
||||
}).length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
openBugsByPriority: PRIORITIES.map((priority) => ({
|
||||
priority,
|
||||
count: openBugs.filter((b) => b.priority === priority).length,
|
||||
})),
|
||||
bugWeeks: weeks.map((week) => ({ week, ...tally.get(week)! })),
|
||||
qaByArea: [...byArea.entries()]
|
||||
.map(([area, row]) => ({ area, ...row }))
|
||||
.sort((a, b) => b.returned - a.returned || b.passed - a.passed),
|
||||
escaped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open tickets per option of each active list field. */
|
||||
function fieldBreakdown(
|
||||
settings: ProjectSettings,
|
||||
open: IssueReadModel[],
|
||||
): FieldBreakdownReadModel[] {
|
||||
return (settings.customFields ?? [])
|
||||
.filter((f) => !f.archived && f.type === CustomFieldType.SELECT)
|
||||
.map((field) => {
|
||||
const values = open.map((i) => i.customFields?.[field.id] ?? null);
|
||||
return {
|
||||
fieldId: field.id,
|
||||
counts: field.options
|
||||
.map((value) => ({
|
||||
value,
|
||||
count: values.filter((v) => v === value).length,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
none: values.filter((v) => v === null).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */
|
||||
function dailyThroughput(steps: Map<string, Step[]>, now: number): number[] {
|
||||
const today = utcDay(now);
|
||||
|
||||
@@ -128,6 +128,48 @@ export class BacklogHealthReadModel {
|
||||
staleDays!: number;
|
||||
}
|
||||
|
||||
/** Bugs created and closed in one week (Monday, UTC). */
|
||||
export class BugWeekReadModel {
|
||||
week!: string;
|
||||
created!: number;
|
||||
closed!: number;
|
||||
}
|
||||
|
||||
/** What QA sent back, and let through, per area (last 90 days). */
|
||||
export class QaByAreaReadModel {
|
||||
/** Area id; null: tickets with no area. */
|
||||
area!: string | null;
|
||||
returned!: number;
|
||||
passed!: number;
|
||||
}
|
||||
|
||||
/** Bugs reported in the 14 days after a release shipped. */
|
||||
export class EscapedBugsReadModel {
|
||||
releaseId!: string;
|
||||
name!: string;
|
||||
releasedAt!: string;
|
||||
bugs!: number;
|
||||
}
|
||||
|
||||
/** Bugs (types drawn with the bug icon) and QA. */
|
||||
export class QualityReadModel {
|
||||
/** Open bugs per priority (all five, most urgent first). */
|
||||
openBugsByPriority!: { priority: string; count: number }[];
|
||||
/** The last 8 weeks, oldest first. */
|
||||
bugWeeks!: BugWeekReadModel[];
|
||||
qaByArea!: QaByAreaReadModel[];
|
||||
/** The last three shipped releases, newest first. */
|
||||
escaped!: EscapedBugsReadModel[];
|
||||
}
|
||||
|
||||
/** Open tickets per option of a list custom field. */
|
||||
export class FieldBreakdownReadModel {
|
||||
fieldId!: string;
|
||||
counts!: { value: string; count: number }[];
|
||||
/** Open tickets with no value in it. */
|
||||
none!: number;
|
||||
}
|
||||
|
||||
/** What the project dashboard reads beyond the counts. */
|
||||
export class ProjectInsightsReadModel {
|
||||
generatedAt!: string;
|
||||
@@ -143,4 +185,7 @@ export class ProjectInsightsReadModel {
|
||||
/** Members (and others with open tickets), busiest first. */
|
||||
team!: TeamLoadReadModel[];
|
||||
backlog!: BacklogHealthReadModel;
|
||||
quality!: QualityReadModel;
|
||||
/** One per active list (SELECT) custom field. */
|
||||
fields!: FieldBreakdownReadModel[];
|
||||
}
|
||||
|
||||
@@ -928,6 +928,64 @@ export class BacklogHealthGql {
|
||||
@Field(() => Int) staleDays!: number;
|
||||
}
|
||||
|
||||
@ObjectType('PriorityCount')
|
||||
export class PriorityCountGql {
|
||||
@Field(() => IssuePriority) priority!: IssuePriority;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('BugWeek', { description: 'Bugs created and closed in a week' })
|
||||
export class BugWeekGql {
|
||||
@Field({ description: 'Monday (UTC), YYYY-MM-DD' }) week!: string;
|
||||
@Field(() => Int) created!: number;
|
||||
@Field(() => Int) closed!: number;
|
||||
}
|
||||
|
||||
@ObjectType('QaByArea', { description: 'QA verdicts per area (90 days)' })
|
||||
export class QaByAreaGql {
|
||||
@Field(() => String, { nullable: true, description: 'Null: no area' })
|
||||
area!: string | null;
|
||||
@Field(() => Int) returned!: number;
|
||||
@Field(() => Int) passed!: number;
|
||||
}
|
||||
|
||||
@ObjectType('EscapedBugs', {
|
||||
description: 'Bugs reported in the 14 days after a release shipped',
|
||||
})
|
||||
export class EscapedBugsGql {
|
||||
@Field(() => ID) releaseId!: string;
|
||||
@Field() name!: string;
|
||||
@Field() releasedAt!: string;
|
||||
@Field(() => Int) bugs!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Quality', {
|
||||
description: 'Bugs (types with the bug icon) and QA',
|
||||
})
|
||||
export class QualityGql {
|
||||
@Field(() => [PriorityCountGql]) openBugsByPriority!: PriorityCountGql[];
|
||||
@Field(() => [BugWeekGql], { description: 'Last 8 weeks, oldest first' })
|
||||
bugWeeks!: BugWeekGql[];
|
||||
@Field(() => [QaByAreaGql]) qaByArea!: QaByAreaGql[];
|
||||
@Field(() => [EscapedBugsGql]) escaped!: EscapedBugsGql[];
|
||||
}
|
||||
|
||||
@ObjectType('OptionCount')
|
||||
export class OptionCountGql {
|
||||
@Field() value!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('FieldBreakdown', {
|
||||
description: 'Open tickets per option of a list custom field',
|
||||
})
|
||||
export class FieldBreakdownGql {
|
||||
@Field() fieldId!: string;
|
||||
@Field(() => [OptionCountGql]) counts!: OptionCountGql[];
|
||||
@Field(() => Int, { description: 'Open tickets with no value' })
|
||||
none!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectInsights', {
|
||||
description:
|
||||
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
|
||||
@@ -947,4 +1005,9 @@ export class ProjectInsightsGql {
|
||||
changes!: ChangesSinceGql | null;
|
||||
@Field(() => [TeamLoadGql]) team!: TeamLoadGql[];
|
||||
@Field(() => BacklogHealthGql) backlog!: BacklogHealthGql;
|
||||
@Field(() => QualityGql) quality!: QualityGql;
|
||||
@Field(() => [FieldBreakdownGql], {
|
||||
description: 'One per active list custom field',
|
||||
})
|
||||
fields!: FieldBreakdownGql[];
|
||||
}
|
||||
|
||||
@@ -1006,6 +1006,56 @@ export class BacklogHealthDto {
|
||||
@ApiProperty() staleDays!: number;
|
||||
}
|
||||
|
||||
export class PriorityCountDto {
|
||||
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
|
||||
priority!: IssuePriority;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class BugWeekDto {
|
||||
@ApiProperty({ format: 'date', description: 'Monday (UTC)' }) week!: string;
|
||||
@ApiProperty() created!: number;
|
||||
@ApiProperty() closed!: number;
|
||||
}
|
||||
|
||||
export class QaByAreaDto {
|
||||
@ApiProperty({ type: String, nullable: true, description: 'Null: no area' })
|
||||
area!: string | null;
|
||||
@ApiProperty() returned!: number;
|
||||
@ApiProperty() passed!: number;
|
||||
}
|
||||
|
||||
export class EscapedBugsDto {
|
||||
@ApiProperty({ format: 'uuid' }) releaseId!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty() releasedAt!: string;
|
||||
@ApiProperty({ description: 'Bugs reported in the 14 days after' })
|
||||
bugs!: number;
|
||||
}
|
||||
|
||||
export class QualityDto {
|
||||
@ApiProperty({ type: [PriorityCountDto] })
|
||||
openBugsByPriority!: PriorityCountDto[];
|
||||
@ApiProperty({
|
||||
type: [BugWeekDto],
|
||||
description: 'Last 8 weeks, oldest first',
|
||||
})
|
||||
bugWeeks!: BugWeekDto[];
|
||||
@ApiProperty({ type: [QaByAreaDto] }) qaByArea!: QaByAreaDto[];
|
||||
@ApiProperty({ type: [EscapedBugsDto] }) escaped!: EscapedBugsDto[];
|
||||
}
|
||||
|
||||
export class OptionCountDto {
|
||||
@ApiProperty() value!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class FieldBreakdownDto {
|
||||
@ApiProperty() fieldId!: string;
|
||||
@ApiProperty({ type: [OptionCountDto] }) counts!: OptionCountDto[];
|
||||
@ApiProperty({ description: 'Open tickets with no value' }) none!: number;
|
||||
}
|
||||
|
||||
export class ProjectInsightsDto {
|
||||
@ApiProperty() generatedAt!: string;
|
||||
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||
@@ -1022,4 +1072,10 @@ export class ProjectInsightsDto {
|
||||
changes!: ChangesSinceDto | null;
|
||||
@ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[];
|
||||
@ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto;
|
||||
@ApiProperty({ type: QualityDto }) quality!: QualityDto;
|
||||
@ApiProperty({
|
||||
type: [FieldBreakdownDto],
|
||||
description: 'One per active list custom field',
|
||||
})
|
||||
fields!: FieldBreakdownDto[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user