feat: dashboard maps the activity, follows a ticket's way and the proposals
Three more blocks on the project dashboard, also by REST, GraphQL and MCP: - Activity: what moved each day of the last 12 weeks, in a heatmap, with the busiest weekday and the longest lull. - A ticket's way: for the tickets done in the last 90 days, where the time from created to done went, status by status (waiting included), and how much of it was work (flow efficiency). - The public's proposals: received, waiting, under way, done and discarded, and the most voted still unanswered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeInsights,
|
||||
HEATMAP_DAYS,
|
||||
InsightsInput,
|
||||
simulateDays,
|
||||
STALE_DAYS,
|
||||
@@ -392,4 +393,76 @@ describe('computeInsights', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps 12 weeks of activity, the whole way of done tickets and the proposals', () => {
|
||||
const proposal = { labels: ['propuesta'] };
|
||||
const issues = [
|
||||
// Done: 2 days waiting in TODO, 3 at work, 1 in QA.
|
||||
issue('A', 'DONE', C.DONE, { createdAt: '2026-10-01T10:00:00Z' }),
|
||||
issue('P1', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 2,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P2', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 5,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P3', 'TODO', C.TODO, { ...proposal, ...inSprint }),
|
||||
issue('P4', 'DONE', C.DONE, proposal),
|
||||
issue('P5', 'CANCELLED', C.CANCELLED, proposal),
|
||||
];
|
||||
const timeline = [
|
||||
step('A', '2026-10-01T10:00:00Z', 'TODO', C.TODO),
|
||||
step('A', '2026-10-03T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
step('A', '2026-10-06T10:00:00Z', 'IN_QA', C.QA),
|
||||
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const insights = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
activity: [
|
||||
{ day: '2026-10-15', count: 3 },
|
||||
{ day: '2026-10-14', count: 1 },
|
||||
{ day: '2026-01-01', count: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.heatmap).toHaveLength(HEATMAP_DAYS);
|
||||
// From the Monday 11 weeks back to this week's Sunday.
|
||||
expect(insights.heatmap[0].day).toBe('2026-07-27');
|
||||
expect(insights.heatmap.at(-1)?.day).toBe('2026-10-18');
|
||||
expect(insights.heatmap.find((d) => d.day === '2026-10-15')?.count).toBe(3);
|
||||
|
||||
expect(insights.journey).toEqual({
|
||||
samples: 1,
|
||||
leadDays: 6,
|
||||
flowEfficiency: 50,
|
||||
steps: [
|
||||
{ status: 'TODO', days: 2, share: 33 },
|
||||
{ status: 'IN_PROGRESS', days: 3, share: 50 },
|
||||
{ status: 'IN_QA', days: 1, share: 17 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.proposals).toMatchObject({
|
||||
received: 5,
|
||||
waiting: 2,
|
||||
underWay: 1,
|
||||
done: 1,
|
||||
discarded: 1,
|
||||
});
|
||||
expect(insights.proposals?.topWaiting.map((p) => [p.key, p.votes])).toEqual(
|
||||
[
|
||||
['P2', 5],
|
||||
['P1', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('has no proposals block while nobody has proposed anything', () => {
|
||||
expect(
|
||||
computeInsights({ issues: [], timeline: [], ...BASE }).proposals,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
ActivityDayReadModel,
|
||||
BacklogHealthReadModel,
|
||||
JourneyReadModel,
|
||||
ProposalsFunnelReadModel,
|
||||
CustomFieldType,
|
||||
FieldBreakdownReadModel,
|
||||
IssuePriority,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
stepsByIssue,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
|
||||
/*
|
||||
* The dashboard's reading of a project: what needs attention now (risks),
|
||||
@@ -61,6 +65,8 @@ export interface InsightsInput {
|
||||
since: Date | null;
|
||||
/** Times the viewer was mentioned in the project since then. */
|
||||
mentions: number;
|
||||
/** Activity events per day (`YYYY-MM-DD`), the last 12 weeks or more. */
|
||||
activity?: { day: string; count: number }[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
@@ -72,6 +78,8 @@ const utcDay = (time: number) =>
|
||||
);
|
||||
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
/** Hundredths of a day (about a quarter of an hour): short stays still show. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export function computeInsights(
|
||||
input: InsightsInput,
|
||||
@@ -180,6 +188,127 @@ export function computeInsights(
|
||||
backlog: backlogHealth(open, now),
|
||||
quality: quality(input.settings, live, steps, input.releases, now),
|
||||
fields: fieldBreakdown(input.settings, open),
|
||||
heatmap: heatmap(input.activity ?? [], now),
|
||||
journey: journey(input.settings, live, steps, now),
|
||||
proposals: proposalsFunnel(live, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Days shown by the heatmap: 12 weeks. */
|
||||
export const HEATMAP_DAYS = 84;
|
||||
|
||||
/** The first day of the heatmap: the Monday 11 weeks before this week's. */
|
||||
export function heatmapStart(now: number): string {
|
||||
return new Date(Date.parse(weekOf(now)) - 11 * 7 * DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** 12 weeks of activity, a day each, zero where nothing happened. */
|
||||
function heatmap(
|
||||
activity: { day: string; count: number }[],
|
||||
now: number,
|
||||
): ActivityDayReadModel[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of activity)
|
||||
counts.set(a.day, (counts.get(a.day) ?? 0) + a.count);
|
||||
const start = Date.parse(heatmapStart(now));
|
||||
return Array.from({ length: HEATMAP_DAYS }, (_, n) => {
|
||||
const day = new Date(start + n * DAY).toISOString().slice(0, 10);
|
||||
return { day, count: counts.get(day) ?? 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the time of a closed ticket goes, from created to done, status by
|
||||
* status (waiting included), for the tickets done in the last 90 days.
|
||||
*/
|
||||
function journey(
|
||||
settings: ProjectSettings,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): JourneyReadModel {
|
||||
const from = now - 90 * DAY;
|
||||
const spent = new Map<string, number>();
|
||||
let lead = 0;
|
||||
let working = 0;
|
||||
let samples = 0;
|
||||
for (const issue of issues) {
|
||||
if (issue.statusCategory !== StatusCategory.DONE) continue;
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
// The last arrival at a done status, from somewhere else.
|
||||
let end = -1;
|
||||
list.forEach((step, i) => {
|
||||
if (
|
||||
i > 0 &&
|
||||
step.category === StatusCategory.DONE &&
|
||||
list[i - 1].category !== StatusCategory.DONE
|
||||
)
|
||||
end = i;
|
||||
});
|
||||
if (end < 0 || list[end].at < from) continue;
|
||||
samples++;
|
||||
lead += list[end].at - list[0].at;
|
||||
for (let i = 0; i < end; i++) {
|
||||
const stay = list[i + 1].at - list[i].at;
|
||||
spent.set(list[i].status, (spent.get(list[i].status) ?? 0) + stay);
|
||||
if (list[i].category === StatusCategory.IN_PROGRESS) working += stay;
|
||||
}
|
||||
}
|
||||
const order = settings.statuses.map((s) => s.id);
|
||||
const rank = (id: string) =>
|
||||
order.includes(id) ? order.indexOf(id) : order.length;
|
||||
return {
|
||||
samples,
|
||||
leadDays: samples ? round2(lead / samples / DAY) : null,
|
||||
flowEfficiency: lead ? Math.round((working / lead) * 100) : null,
|
||||
steps: [...spent.entries()]
|
||||
.filter(([, time]) => time > 0)
|
||||
.sort(([a], [b]) => rank(a) - rank(b))
|
||||
.map(([status, time]) => ({
|
||||
status,
|
||||
days: round2(time / samples / DAY),
|
||||
share: Math.round((time / lead) * 100),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The public's proposals, from received to done or discarded. */
|
||||
function proposalsFunnel(
|
||||
issues: IssueReadModel[],
|
||||
now: number,
|
||||
): ProposalsFunnelReadModel | null {
|
||||
const proposals = issues.filter((i) =>
|
||||
(i.labels ?? []).includes(PROPOSAL_LABEL),
|
||||
);
|
||||
if (!proposals.length) return null;
|
||||
const isWaiting = (i: IssueReadModel) =>
|
||||
i.statusCategory === StatusCategory.TODO && !i.sprint && !i.release;
|
||||
const open = proposals.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
const waiting = open.filter(isWaiting);
|
||||
const age = (i: IssueReadModel) =>
|
||||
Math.floor((now - Date.parse(i.createdAt)) / DAY);
|
||||
return {
|
||||
received: proposals.length,
|
||||
receivedLately: proposals.filter((i) => age(i) < 30).length,
|
||||
waiting: waiting.length,
|
||||
underWay: open.length - waiting.length,
|
||||
done: proposals.filter((i) => i.statusCategory === StatusCategory.DONE)
|
||||
.length,
|
||||
discarded: proposals.filter(
|
||||
(i) => i.statusCategory === StatusCategory.CANCELLED,
|
||||
).length,
|
||||
topWaiting: waiting
|
||||
.sort((a, b) => (b.votes ?? 0) - (a.votes ?? 0) || age(b) - age(a))
|
||||
.slice(0, 5)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
key: i.key,
|
||||
title: i.title,
|
||||
votes: i.votes ?? 0,
|
||||
days: age(i),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
ProjectInsightsReadModel,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights } from '../analytics/insights';
|
||||
import { computeInsights, heatmapStart } from '../analytics/insights';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
@@ -27,13 +28,15 @@ export class GetProjectInsightsQueryHandler
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly notifications: NotificationsRepository,
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetProjectInsightsQuery,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline, sprints, releases, mentions] =
|
||||
const now = new Date();
|
||||
const [project, issues, timeline, sprints, releases, mentions, activity] =
|
||||
await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
@@ -41,6 +44,10 @@ export class GetProjectInsightsQueryHandler
|
||||
this.findSprints.findByProject(query.projectId),
|
||||
this.findReleases.findByProject(query.projectId),
|
||||
this.mentionsSince(query),
|
||||
this.findActivity.dailyCounts(
|
||||
[query.projectId],
|
||||
heatmapStart(now.getTime()),
|
||||
),
|
||||
]);
|
||||
return computeInsights({
|
||||
issues,
|
||||
@@ -53,7 +60,8 @@ export class GetProjectInsightsQueryHandler
|
||||
members: project?.members ?? [],
|
||||
since: query.since,
|
||||
mentions,
|
||||
now: new Date(),
|
||||
activity,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user