feat: dashboard forecasts releases, tells what changed and weighs the load

The insights reading grows (GraphQL insights(since:), REST ?since=, MCP):
- planned releases with a likely (p50) and safe (p85) finish date from a
  Monte Carlo over the last six weeks of finished work, one after another,
  and a verdict against their target;
- with `since` (the viewer's last visit, kept in the browser): tickets
  created, finished and sent back from QA, and their mentions;
- each person's open points against what they closed per sprint lately;
- open tickets without estimate, area or assignee, or untouched for 90
  days, each count opening the list filtered (new noEstimate, noArea and
  updatedBefore filters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:50:24 +02:00
parent fe03c5bda8
commit 0d158a2ed7
41 changed files with 2050 additions and 118 deletions

View File

@@ -1,11 +1,15 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
NotificationKind,
ProjectInsightsReadModel,
SprintStatus,
} 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 { FindReleasesRepository } from '../ports/find-releases.repository';
import { NotificationsRepository } from '../ports/notifications.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetProjectInsightsQuery } from './get-project-insights.query';
@@ -21,24 +25,51 @@ export class GetProjectInsightsQueryHandler
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly notifications: NotificationsRepository,
) {}
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),
]);
const [project, issues, timeline, sprints, releases, mentions] =
await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),
this.findTimeline.findByProject(query.projectId),
this.findSprints.findByProject(query.projectId),
this.findReleases.findByProject(query.projectId),
this.mentionsSince(query),
]);
return computeInsights({
issues,
timeline,
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
activeSprint,
activeSprint:
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
sprints,
releases,
members: project?.members ?? [],
since: query.since,
mentions,
now: new Date(),
});
}
/** The viewer's mentions in this project after `since` (latest 200). */
private async mentionsSince(query: GetProjectInsightsQuery): Promise<number> {
if (!query.since || !query.viewerId) return 0;
const since = query.since.toISOString();
const latest = await this.notifications.findForUser(query.viewerId, {
unreadOnly: false,
limit: 200,
});
return latest.filter(
(n) =>
n.projectId === query.projectId &&
n.kind === NotificationKind.MENTIONED &&
n.createdAt > since,
).length;
}
}

View File

@@ -1,12 +1,17 @@
import { Query } from '@nestjs/cqrs';
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
/** The dashboard's reading of a project: sprint, risks, aging, bottlenecks. */
/**
* The dashboard's reading of a project: sprint, risks, aging, bottlenecks,
* release forecasts, team load, backlog health and, with `since`, what
* changed after it.
*/
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
public readonly since: Date | null = null,
) {
super();
}