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);
|
||||
|
||||
Reference in New Issue
Block a user