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,5 +1,7 @@
import {
defaultProjectSettings,
ReleaseReadModel,
ReleaseStatus,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
@@ -7,7 +9,13 @@ import {
SprintStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import { computeInsights, STUCK_DAYS } from './insights';
import {
computeInsights,
InsightsInput,
simulateDays,
STALE_DAYS,
STUCK_DAYS,
} from './insights';
const NOW = new Date('2026-10-15T12:00:00Z');
const bob = { id: 'bob', name: 'Bob' };
@@ -68,6 +76,24 @@ const sprint: SprintReadModel = {
};
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
const BASE: Omit<InsightsInput, 'issues' | 'timeline'> = {
settings: defaultProjectSettings(),
activeSprint: null,
sprints: [],
releases: [],
members: [],
since: null,
mentions: 0,
now: NOW,
};
const release = (id: string, targetDate: string | null): ReleaseReadModel =>
({
id,
name: id,
targetDate,
status: ReleaseStatus.PLANNED,
}) as ReleaseReadModel;
describe('computeInsights', () => {
it('names what is late, due soon, blocked and stuck', () => {
const issues = [
@@ -95,9 +121,8 @@ describe('computeInsights', () => {
const { risks, aging } = computeInsights({
issues,
timeline,
...BASE,
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]]);
@@ -121,9 +146,9 @@ describe('computeInsights', () => {
const { sprint: pulse } = computeInsights({
issues,
timeline: [],
...BASE,
settings: defaultProjectSettings(),
activeSprint: sprint,
now: NOW,
});
expect(pulse).toMatchObject({
totalDays: 14,
@@ -155,9 +180,8 @@ describe('computeInsights', () => {
const rows = computeInsights({
issues,
timeline,
...BASE,
settings,
activeSprint: null,
now: NOW,
}).bottlenecks;
expect(rows.map((r) => r.status)).toEqual([
'IN_PROGRESS',
@@ -174,4 +198,108 @@ describe('computeInsights', () => {
overLimit: true,
});
});
it('forecasts planned releases at the pace of the last weeks', () => {
// Two tickets finished a day, every day of the last six weeks.
expect(simulateDays(10, [2, 2, 2])).toEqual({ likely: 5, safe: 5 });
expect(simulateDays(3, [0, 0])).toBeNull();
expect(simulateDays(0, [])).toEqual({ likely: 0, safe: 0 });
const done = Array.from({ length: 12 }, (_, n) => `D${n}`);
const issues = [
...done.map((id) => issue(id, 'DONE', C.DONE)),
...['A1', 'A2', 'A3'].map((id) =>
issue(id, 'TODO', C.TODO, { release: { id: 'a', name: 'a' } }),
),
...['B1', 'B2', 'B3'].map((id) =>
issue(id, 'TODO', C.TODO, { release: { id: 'b', name: 'b' } }),
),
];
// One finished every 3.5 days or so: 12 in the last 42 days.
const timeline = done.map((id, n) =>
step(
id,
new Date(NOW.getTime() - (n * 3 + 1) * 86_400_000).toISOString(),
'DONE',
C.DONE,
),
);
const { releases } = computeInsights({
...BASE,
issues,
timeline,
releases: [release('b', '2026-10-20'), release('a', '2026-12-31')],
});
// Nearer target first; the later one waits for it.
expect(releases.map((r) => [r.releaseId, r.verdict])).toEqual([
['b', 'LATE'],
['a', 'ON_TRACK'],
]);
expect(releases[1].likelyDate! > releases[0].likelyDate!).toBe(true);
});
it('tells what changed since a moment, and who carries what', () => {
const ana = { id: 'ana', name: 'Ana' };
const issues = [
issue('NEW', 'TODO', C.TODO, {
createdAt: '2026-10-15T08:00:00Z',
assignee: ana,
storyPoints: 8,
}),
issue('CLOSED', 'DONE', C.DONE, {
sprint: { id: 'old', name: 'Old' },
storyPoints: 5,
}),
issue('BACK', 'QA_FAILED', C.IN_PROGRESS, { storyPoints: 2 }),
issue('STALE', 'TODO', C.TODO, {
assignee: null,
area: 'FRONTEND',
updatedAt: '2026-01-01T00:00:00Z',
}),
];
const timeline = [
step('CLOSED', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
step('CLOSED', '2026-10-15T09:00:00Z', 'DONE', C.DONE),
step('BACK', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
step('BACK', '2026-10-15T10:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
];
const reading = computeInsights({
...BASE,
issues,
timeline,
since: new Date('2026-10-15T07:00:00Z'),
mentions: 2,
members: [{ user: ana, role: 'MEMBER' as never }],
sprints: [
{ id: 'old', closedAt: '2026-10-11T00:00:00Z' } as SprintReadModel,
],
});
expect(reading.changes).toMatchObject({
createdCount: 1,
finishedCount: 1,
returnedCount: 1,
mentions: 2,
});
expect(reading.changes?.returned[0].key).toBe('BACK');
// Bob closed 5 points in the one closed sprint and holds 2 open.
expect(
reading.team.map((t) => [
t.user.id,
t.openPoints,
t.pointsPerSprint,
t.overloaded,
]),
).toEqual([
['ana', 8, 0, false],
['bob', 2, 5, false],
]);
expect(reading.backlog).toEqual({
open: 3,
unestimated: 1,
noArea: 2,
unassigned: 1,
stale: 1,
staleDays: STALE_DAYS,
});
});
});

View File

@@ -1,14 +1,22 @@
import {
BacklogHealthReadModel,
ChangesSinceReadModel,
InsightIssueReadModel,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
ProjectInsightsReadModel,
ProjectMemberReadModel,
ProjectSettings,
ReleaseForecastReadModel,
ReleaseReadModel,
ReleaseStatus,
SprintPulseReadModel,
SprintReadModel,
StatusCategory,
StatusTimeReadModel,
TeamLoadReadModel,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
@@ -24,6 +32,10 @@ const DAY = 86_400_000;
export const STUCK_DAYS = 7;
/** How many tickets each list names. */
const LIST = 8;
/** Days not updated that make an open ticket «stale». */
export const STALE_DAYS = 90;
/** Days of finished work the forecasts draw their pace from. */
export const PACE_DAYS = 42;
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
@@ -32,6 +44,14 @@ export interface InsightsInput {
timeline: IssueTimelineEntry[];
settings: ProjectSettings;
activeSprint: SprintReadModel | null;
/** Every sprint of the project (closed ones give each person's pace). */
sprints: SprintReadModel[];
releases: ReleaseReadModel[];
members: ProjectMemberReadModel[];
/** The viewer's last visit, to tell what changed since. */
since: Date | null;
/** Times the viewer was mentioned in the project since then. */
mentions: number;
now: Date;
}
@@ -137,6 +157,245 @@ export function computeInsights(
thresholdDays: analytics.cycleTime.p85Days,
},
bottlenecks: bottlenecks(input.settings, open, steps, now),
releases: forecasts(input.releases, live, steps, now),
changes: input.since
? changesSince(input.since.getTime(), live, steps, line, input.mentions)
: null,
team: teamLoad(
input.members,
open,
live,
input.sprints,
new Set(blocked.map((b) => b.id)),
),
backlog: backlogHealth(open, now),
};
}
/** 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);
const counts = new Array<number>(PACE_DAYS).fill(0);
for (const list of steps.values())
list.forEach((step, i) => {
const before = list[i - 1];
if (step.category !== StatusCategory.DONE) return;
if (before && before.category === StatusCategory.DONE) return;
const ago = Math.floor((today - utcDay(step.at)) / DAY);
if (ago >= 0 && ago < PACE_DAYS) counts[ago]++;
});
return counts;
}
/** Small seeded generator: the same data always gives the same forecast. */
function random(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Days to finish `remaining` tickets, drawing each day's throughput from
* the days measured (Monte Carlo): the 50th and 85th percentiles.
*/
export function simulateDays(
remaining: number,
samples: number[],
runs = 500,
): { likely: number; safe: number } | null {
if (remaining <= 0) return { likely: 0, safe: 0 };
if (!samples.some((n) => n > 0)) return null;
const next = random(remaining * 7919 + samples.length);
const results: number[] = [];
for (let run = 0; run < runs; run++) {
let done = 0;
let days = 0;
while (done < remaining && days < 3 * 365) {
done += samples[Math.floor(next() * samples.length)];
days++;
}
results.push(days);
}
results.sort((a, b) => a - b);
return {
likely: results[Math.floor(0.5 * runs)],
safe: results[Math.ceil(0.85 * runs) - 1],
};
}
/**
* Planned releases in target-date order. The team finishes them one after
* another, so each one's forecast counts what is left in the earlier ones.
*/
function forecasts(
releases: ReleaseReadModel[],
issues: IssueReadModel[],
steps: Map<string, Step[]>,
now: number,
): ReleaseForecastReadModel[] {
const pace = dailyThroughput(steps, now);
const today = utcDay(now);
const dayAfter = (days: number) =>
new Date(today + days * DAY).toISOString().slice(0, 10);
let ahead = 0;
return releases
.filter((r) => r.status === ReleaseStatus.PLANNED)
.sort(
(a, b) =>
(a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') ||
a.name.localeCompare(b.name),
)
.map((release) => {
const own = issues.filter(
(i) =>
i.release?.id === release.id &&
i.statusCategory !== StatusCategory.CANCELLED,
);
const finished = own.filter(
(i) => i.statusCategory === StatusCategory.DONE,
).length;
const left = own.length - finished;
ahead += left;
const days = left > 0 ? simulateDays(ahead, pace) : null;
const likelyDate = days ? dayAfter(days.likely) : null;
const safeDate = days ? dayAfter(days.safe) : null;
const verdict: ReleaseForecastReadModel['verdict'] =
left === 0
? 'DONE'
: !days
? 'UNKNOWN'
: !release.targetDate
? 'NO_DATE'
: (safeDate as string) <= release.targetDate
? 'ON_TRACK'
: (likelyDate as string) <= release.targetDate
? 'AT_RISK'
: 'LATE';
return {
releaseId: release.id,
name: release.name,
targetDate: release.targetDate,
total: own.length,
finished,
likelyDate,
safeDate,
verdict,
};
});
}
/** Created, finished and sent back from QA after `since`. */
function changesSince(
since: number,
issues: IssueReadModel[],
steps: Map<string, Step[]>,
line: (issue: IssueReadModel, days: number) => InsightIssueReadModel,
mentions: number,
): ChangesSinceReadModel {
const created = issues
.filter((i) => Date.parse(i.createdAt) > since)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const finished: IssueReadModel[] = [];
const returned: IssueReadModel[] = [];
for (const issue of issues) {
const list = steps.get(issue.id) ?? [];
const recent = list.filter((s, i) => s.at > since && i > 0);
if (
issue.statusCategory === StatusCategory.DONE &&
recent.some((s) => s.category === StatusCategory.DONE)
)
finished.push(issue);
if (
list.some(
(s, i) =>
s.at > since &&
i > 0 &&
list[i - 1].category === StatusCategory.QA &&
!CLOSED.includes(s.category) &&
s.category !== StatusCategory.QA,
)
)
returned.push(issue);
}
return {
since: new Date(since).toISOString(),
created: created.slice(0, LIST).map((i) => line(i, 0)),
createdCount: created.length,
finished: finished.slice(0, LIST).map((i) => line(i, 0)),
finishedCount: finished.length,
returned: returned.slice(0, LIST).map((i) => line(i, 0)),
returnedCount: returned.length,
mentions,
};
}
const pointsOf = (list: IssueReadModel[]) =>
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
/** Open work per person against the points they closed per sprint lately. */
function teamLoad(
members: ProjectMemberReadModel[],
open: IssueReadModel[],
issues: IssueReadModel[],
sprints: SprintReadModel[],
blockedIds: Set<string>,
): TeamLoadReadModel[] {
const people = new Map<string, UserRefReadModel>();
for (const m of members) people.set(m.user.id, m.user);
for (const i of open) if (i.assignee) people.set(i.assignee.id, i.assignee);
const lastClosed = sprints
.filter((s) => s.closedAt)
.sort((a, b) => (b.closedAt as string).localeCompare(a.closedAt as string))
.slice(0, 3)
.map((s) => s.id);
return [...people.values()]
.map((user) => {
const mine = open.filter((i) => i.assignee?.id === user.id);
const closed = issues.filter(
(i) =>
i.assignee?.id === user.id &&
i.statusCategory === StatusCategory.DONE &&
!!i.sprint &&
lastClosed.includes(i.sprint.id),
);
const perSprint = lastClosed.length
? round1(pointsOf(closed) / lastClosed.length)
: null;
const openPoints = pointsOf(mine);
return {
user,
openTickets: mine.length,
openPoints,
blocked: mine.filter((i) => blockedIds.has(i.id)).length,
pointsPerSprint: perSprint,
overloaded: !!perSprint && openPoints > perSprint,
};
})
.sort(
(a, b) =>
b.openPoints - a.openPoints ||
b.openTickets - a.openTickets ||
a.user.name.localeCompare(b.user.name, 'es'),
);
}
function backlogHealth(
open: IssueReadModel[],
now: number,
): BacklogHealthReadModel {
const staleBefore = now - STALE_DAYS * DAY;
return {
open: open.length,
unestimated: open.filter((i) => i.storyPoints === null).length,
noArea: open.filter((i) => !i.area).length,
unassigned: open.filter((i) => !i.assignee).length,
stale: open.filter((i) => Date.parse(i.updatedAt) < staleBefore).length,
staleDays: STALE_DAYS,
};
}

View File

@@ -33,6 +33,12 @@ export interface IssueFilter {
dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** Only tickets nobody estimated. */
noEstimate?: boolean;
/** Only tickets with no area. */
noArea?: boolean;
/** Only tickets not updated since this instant (ISO). */
updatedBefore?: string;
/** Custom field id → the value it must hold. */
customFields?: Record<string, string>;
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */

View File

@@ -323,13 +323,14 @@ 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. */
/** The dashboard's reading; with `since`, what changed after it too. */
insights(
viewerId: string | null,
projectId: string,
since: Date | null = null,
): Promise<ProjectInsightsReadModel> {
return this.queryBus.execute(
new GetProjectInsightsQuery(viewerId, projectId),
new GetProjectInsightsQuery(viewerId, projectId, since),
);
}

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();
}

View File

@@ -70,6 +70,64 @@ export class StatusTimeReadModel {
overLimit!: boolean;
}
/** When a planned release would be finished, at the team's real pace. */
export class ReleaseForecastReadModel {
releaseId!: string;
name!: string;
targetDate!: string | null;
/** Tickets in it (cancelled ones aside). */
total!: number;
finished!: number;
/** Half of the simulated futures finish by then (null: no pace yet). */
likelyDate!: string | null;
/** 85% of them do. */
safeDate!: string | null;
/**
* DONE: nothing left. NO_DATE: no target to compare. UNKNOWN: nothing
* finished lately to measure a pace. ON_TRACK: 85% in time. AT_RISK: half
* in time. LATE: less than half.
*/
verdict!: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE';
}
/** What has happened since a moment (the viewer's last visit). */
export class ChangesSinceReadModel {
since!: string;
created!: InsightIssueReadModel[];
createdCount!: number;
finished!: InsightIssueReadModel[];
finishedCount!: number;
/** Sent back from QA. */
returned!: InsightIssueReadModel[];
returnedCount!: number;
/** Times the viewer was mentioned in this project. */
mentions!: number;
}
/** One person's open work against what they usually close. */
export class TeamLoadReadModel {
user!: UserRefReadModel;
openTickets!: number;
openPoints!: number;
/** Their open tickets waiting for another open one. */
blocked!: number;
/** Points they closed per sprint, over the last three closed sprints. */
pointsPerSprint!: number | null;
/** More open points than they usually close in a sprint. */
overloaded!: boolean;
}
/** Open tickets missing what planning needs. */
export class BacklogHealthReadModel {
open!: number;
unestimated!: number;
noArea!: number;
unassigned!: number;
/** Not updated in `staleDays` days. */
stale!: number;
staleDays!: number;
}
/** What the project dashboard reads beyond the counts. */
export class ProjectInsightsReadModel {
generatedAt!: string;
@@ -78,4 +136,11 @@ export class ProjectInsightsReadModel {
aging!: AgingReadModel;
/** In-progress and QA statuses, in board order. */
bottlenecks!: StatusTimeReadModel[];
/** Planned releases, by target date. */
releases!: ReleaseForecastReadModel[];
/** Null unless asked with a `since`. */
changes!: ChangesSinceReadModel | null;
/** Members (and others with open tickets), busiest first. */
team!: TeamLoadReadModel[];
backlog!: BacklogHealthReadModel;
}

View File

@@ -450,6 +450,9 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}),
};
// Only field ids: a key never becomes an operator.
if (filter.noEstimate) query['storyPoints'] = null;
if (filter.noArea) query['area'] = null;
if (filter.updatedBefore) query['updatedAt'] = { $lt: filter.updatedBefore };
for (const [field, value] of Object.entries(filter.customFields ?? {}))
if (/^[A-Z][A-Z0-9_]{0,29}$/.test(field) && typeof value === 'string')
query[`customFields.${field}`] = value;

View File

@@ -568,6 +568,21 @@ export class IssueFilterInput {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@Field({ nullable: true, description: 'Only tickets nobody estimated' })
@IsOptional()
@IsBoolean()
noEstimate?: boolean;
@Field({ nullable: true, description: 'Only tickets with no area' })
@IsOptional()
@IsBoolean()
noArea?: boolean;
@Field({
nullable: true,
description: 'Only tickets not updated since this instant (ISO)',
})
@IsOptional()
@IsISO8601()
updatedBefore?: string;
@Field(() => [CustomFieldValueInput], {
nullable: true,
description: 'Only tickets holding these custom values',

View File

@@ -91,8 +91,20 @@ export class ProjectResolver {
insights(
@CurrentUser() me: User | undefined,
@Parent() project: ProjectReadModel,
@Args('since', {
type: () => String,
nullable: true,
description:
'ISO instant (e.g. my last visit): also tell what changed after it',
})
since?: string | null,
) {
return this.projects.insights(viewerOf(me), project.id);
const after = since ? new Date(since) : null;
return this.projects.insights(
viewerOf(me),
project.id,
after && !Number.isNaN(after.getTime()) ? after : null,
);
}
@ResolveField(() => [ReleaseGql])

View File

@@ -867,8 +867,70 @@ export class StatusTimeGql {
@Field() overLimit!: boolean;
}
@ObjectType('ReleaseForecast', {
description: 'When a planned release would be finished, at the real pace',
})
export class ReleaseForecastGql {
@Field(() => ID) releaseId!: string;
@Field() name!: string;
@Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => Int) total!: number;
@Field(() => Int) finished!: number;
@Field(() => String, {
nullable: true,
description: 'Half of the simulated futures finish by then',
})
likelyDate!: string | null;
@Field(() => String, { nullable: true, description: '85% finish by then' })
safeDate!: string | null;
@Field({
description:
'DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE',
})
verdict!: string;
}
@ObjectType('ChangesSince', { description: 'What happened after a moment' })
export class ChangesSinceGql {
@Field() since!: string;
@Field(() => [InsightIssueGql]) created!: InsightIssueGql[];
@Field(() => Int) createdCount!: number;
@Field(() => [InsightIssueGql]) finished!: InsightIssueGql[];
@Field(() => Int) finishedCount!: number;
@Field(() => [InsightIssueGql], { description: 'Sent back from QA' })
returned!: InsightIssueGql[];
@Field(() => Int) returnedCount!: number;
@Field(() => Int, { description: 'Times I was mentioned here' })
mentions!: number;
}
@ObjectType('TeamLoad')
export class TeamLoadGql {
@Field(() => UserRefGql) user!: UserRefGql;
@Field(() => Int) openTickets!: number;
@Field(() => Int) openPoints!: number;
@Field(() => Int) blocked!: number;
@Field(() => Float, {
nullable: true,
description: 'Points closed per sprint, last three closed sprints',
})
pointsPerSprint!: number | null;
@Field() overloaded!: boolean;
}
@ObjectType('BacklogHealth')
export class BacklogHealthGql {
@Field(() => Int) open!: number;
@Field(() => Int) unestimated!: number;
@Field(() => Int) noArea!: number;
@Field(() => Int) unassigned!: number;
@Field(() => Int) stale!: number;
@Field(() => Int) staleDays!: number;
}
@ObjectType('ProjectInsights', {
description: 'The dashboard: sprint under way, risks, aging and bottlenecks',
description:
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
})
export class ProjectInsightsGql {
@Field() generatedAt!: string;
@@ -877,4 +939,12 @@ export class ProjectInsightsGql {
@Field(() => RiskGql) risks!: RiskGql;
@Field(() => AgingGql) aging!: AgingGql;
@Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[];
@Field(() => [ReleaseForecastGql]) releases!: ReleaseForecastGql[];
@Field(() => ChangesSinceGql, {
nullable: true,
description: 'Only when asked with `since`',
})
changes!: ChangesSinceGql | null;
@Field(() => [TeamLoadGql]) team!: TeamLoadGql[];
@Field(() => BacklogHealthGql) backlog!: BacklogHealthGql;
}

View File

@@ -330,6 +330,17 @@ export class AnalyticsQueryDto {
days = 90;
}
export class InsightsQueryDto {
@ApiPropertyOptional({
description:
'ISO instant (e.g. your last visit): the answer also tells what changed after it',
example: '2026-10-14T08:00:00Z',
})
@IsOptional()
@IsISO8601()
since?: string;
}
export class ChangeMemberRoleDto {
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
@IsEnum(ProjectRole)
@@ -609,6 +620,25 @@ export class IssueSearchQueryDto extends PageQueryDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@ApiPropertyOptional({ description: 'Only tickets nobody estimated' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
noEstimate?: boolean;
@ApiPropertyOptional({ description: 'Only tickets with no area' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
noArea?: boolean;
@ApiPropertyOptional({
description: 'Only tickets not updated since this instant (ISO)',
})
@IsOptional()
@IsISO8601()
updatedBefore?: string;
@ApiPropertyOptional({
type: [String],
example: ['BROWSER:Safari'],

View File

@@ -943,6 +943,69 @@ export class StatusTimeDto {
@ApiProperty() overLimit!: boolean;
}
export class ReleaseForecastDto {
@ApiProperty({ format: 'uuid' }) releaseId!: string;
@ApiProperty() name!: string;
@ApiProperty({ type: String, nullable: true, format: 'date' })
targetDate!: string | null;
@ApiProperty() total!: number;
@ApiProperty() finished!: number;
@ApiProperty({
type: String,
nullable: true,
format: 'date',
description: 'Half of the simulated futures finish by then',
})
likelyDate!: string | null;
@ApiProperty({
type: String,
nullable: true,
format: 'date',
description: '85% finish by then',
})
safeDate!: string | null;
@ApiProperty({
enum: ['DONE', 'NO_DATE', 'UNKNOWN', 'ON_TRACK', 'AT_RISK', 'LATE'],
})
verdict!: string;
}
export class ChangesSinceDto {
@ApiProperty() since!: string;
@ApiProperty({ type: [InsightIssueDto] }) created!: InsightIssueDto[];
@ApiProperty() createdCount!: number;
@ApiProperty({ type: [InsightIssueDto] }) finished!: InsightIssueDto[];
@ApiProperty() finishedCount!: number;
@ApiProperty({ type: [InsightIssueDto], description: 'Sent back from QA' })
returned!: InsightIssueDto[];
@ApiProperty() returnedCount!: number;
@ApiProperty({ description: 'Times you were mentioned here' })
mentions!: number;
}
export class TeamLoadDto {
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
@ApiProperty() openTickets!: number;
@ApiProperty() openPoints!: number;
@ApiProperty() blocked!: number;
@ApiProperty({
type: Number,
nullable: true,
description: 'Points closed per sprint, last three closed sprints',
})
pointsPerSprint!: number | null;
@ApiProperty() overloaded!: boolean;
}
export class BacklogHealthDto {
@ApiProperty() open!: number;
@ApiProperty() unestimated!: number;
@ApiProperty() noArea!: number;
@ApiProperty() unassigned!: number;
@ApiProperty() stale!: number;
@ApiProperty() staleDays!: number;
}
export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true })
@@ -950,4 +1013,13 @@ export class ProjectInsightsDto {
@ApiProperty({ type: RisksDto }) risks!: RisksDto;
@ApiProperty({ type: AgingDto }) aging!: AgingDto;
@ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[];
@ApiProperty({ type: [ReleaseForecastDto] }) releases!: ReleaseForecastDto[];
@ApiProperty({
type: ChangesSinceDto,
nullable: true,
description: 'Only when asked with `since`',
})
changes!: ChangesSinceDto | null;
@ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[];
@ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto;
}

View File

@@ -171,6 +171,9 @@ export class IssuesController {
watching: query.watching,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
noEstimate: query.noEstimate,
noArea: query.noArea,
updatedBefore: query.updatedBefore,
customFields: query.field?.length
? Object.fromEntries(
query.field.map((f) => [

View File

@@ -35,6 +35,7 @@ import {
ActivityQueryDto,
AddMemberDto,
AnalyticsQueryDto,
InsightsQueryDto,
BoardQueryDto,
ChangeMemberRoleDto,
CreateProjectDto,
@@ -257,10 +258,12 @@ export class ProjectsController {
async insights(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: InsightsQueryDto,
) {
return this.projects.insights(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
query.since ? new Date(query.since) : null,
);
}