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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { ProjectInsights } from '@task-stream/web/tracker/domain';
|
||||
export const insightsPageEvents = eventGroup({
|
||||
source: 'Dashboard Page',
|
||||
events: {
|
||||
opened: type<{ projectId: string }>(),
|
||||
/** `since`: my last visit, to tell what changed after it. */
|
||||
opened: type<{ projectId: string; since: string | null }>(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ export class InsightsFacade {
|
||||
readonly loaded = this.store.loaded;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads the project's reading and keeps it up to date. */
|
||||
open(projectId: string): void {
|
||||
this.dispatch.opened({ projectId });
|
||||
/**
|
||||
* Loads the project's reading and keeps it up to date; with `since` (my
|
||||
* last visit), it also tells what changed after it.
|
||||
*/
|
||||
open(projectId: string, since: string | null = null): void {
|
||||
this.dispatch.opened({ projectId, since });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const InsightsStore = signalStore(
|
||||
debounceTime(800),
|
||||
startWith(null),
|
||||
switchMap(() =>
|
||||
projects.insights(payload.projectId).pipe(
|
||||
projects.insights(payload.projectId, payload.since).pipe(
|
||||
mapResponse({
|
||||
next: (insights) => insightsApiEvents.loaded(insights),
|
||||
error: (error) =>
|
||||
|
||||
@@ -103,6 +103,11 @@ export interface IssueListFilter {
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
sprintId?: string;
|
||||
/** Only tickets nobody estimated / with no area. */
|
||||
noEstimate?: boolean;
|
||||
noArea?: boolean;
|
||||
/** Only tickets not updated since this instant (ISO). */
|
||||
updatedBefore?: string;
|
||||
/** Only tickets holding these custom values. */
|
||||
customFields?: CustomFieldValue[];
|
||||
limit: number;
|
||||
|
||||
@@ -55,6 +55,53 @@ export interface StatusTime {
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
/** When a planned release would be finished, at the team's real pace. */
|
||||
export interface ReleaseForecast {
|
||||
releaseId: string;
|
||||
name: string;
|
||||
targetDate: string | null;
|
||||
total: number;
|
||||
finished: number;
|
||||
/** Half of the simulated futures finish by then. */
|
||||
likelyDate: string | null;
|
||||
/** 85% of them do. */
|
||||
safeDate: string | null;
|
||||
verdict: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE';
|
||||
}
|
||||
|
||||
/** What happened after a moment (my last visit). */
|
||||
export interface ChangesSince {
|
||||
since: string;
|
||||
created: InsightIssue[];
|
||||
createdCount: number;
|
||||
finished: InsightIssue[];
|
||||
finishedCount: number;
|
||||
/** Sent back from QA. */
|
||||
returned: InsightIssue[];
|
||||
returnedCount: number;
|
||||
mentions: number;
|
||||
}
|
||||
|
||||
/** One person's open work against what they usually close per sprint. */
|
||||
export interface TeamLoad {
|
||||
user: UserRef;
|
||||
openTickets: number;
|
||||
openPoints: number;
|
||||
blocked: number;
|
||||
pointsPerSprint: number | null;
|
||||
overloaded: boolean;
|
||||
}
|
||||
|
||||
/** Open tickets missing what planning needs. */
|
||||
export interface BacklogHealth {
|
||||
open: number;
|
||||
unestimated: number;
|
||||
noArea: number;
|
||||
unassigned: number;
|
||||
stale: number;
|
||||
staleDays: number;
|
||||
}
|
||||
|
||||
/** The dashboard's reading of a project beyond the counts. */
|
||||
export interface ProjectInsights {
|
||||
generatedAt: string;
|
||||
@@ -62,4 +109,9 @@ export interface ProjectInsights {
|
||||
risks: Risks;
|
||||
aging: { issues: InsightIssue[]; thresholdDays: number | null };
|
||||
bottlenecks: StatusTime[];
|
||||
releases: ReleaseForecast[];
|
||||
/** Only when asked with a `since`. */
|
||||
changes: ChangesSince | null;
|
||||
team: TeamLoad[];
|
||||
backlog: BacklogHealth;
|
||||
}
|
||||
|
||||
@@ -64,8 +64,11 @@ export abstract class ProjectsGateway {
|
||||
abstract publicProjects(): Observable<Project[]>;
|
||||
abstract project(idOrKey: string): Observable<Project>;
|
||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
||||
/** Sprint under way, risks, aging and bottlenecks (the dashboard). */
|
||||
abstract insights(projectId: string): Observable<ProjectInsights>;
|
||||
/** The dashboard; with `since`, also what changed after it. */
|
||||
abstract insights(
|
||||
projectId: string,
|
||||
since: string | null,
|
||||
): Observable<ProjectInsights>;
|
||||
/** Statistics of the last `days` days. */
|
||||
abstract analytics(
|
||||
projectId: string,
|
||||
|
||||
@@ -63,10 +63,13 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
.pipe(map((d) => toStats(d.project.stats)));
|
||||
}
|
||||
|
||||
insights(projectId: string): Observable<ProjectInsights> {
|
||||
insights(
|
||||
projectId: string,
|
||||
since: string | null,
|
||||
): Observable<ProjectInsights> {
|
||||
return this.gql
|
||||
.query(ProjectInsightsDocument, { idOrKey: projectId })
|
||||
.pipe(map((d) => d.project.insights));
|
||||
.query(ProjectInsightsDocument, { idOrKey: projectId, since })
|
||||
.pipe(map((d) => d.project.insights as ProjectInsights));
|
||||
}
|
||||
|
||||
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
|
||||
|
||||
@@ -178,6 +178,10 @@ export type IssueFilterInput = {
|
||||
/** Due on or before this day (YYYY-MM-DD) */
|
||||
dueTo?: string | null | undefined;
|
||||
labels?: Array<string> | null | undefined;
|
||||
/** Only tickets with no area */
|
||||
noArea?: boolean | null | undefined;
|
||||
/** Only tickets nobody estimated */
|
||||
noEstimate?: boolean | null | undefined;
|
||||
/** Only tickets in no sprint */
|
||||
noSprint?: boolean | null | undefined;
|
||||
/** Only the tickets hanging from this one */
|
||||
@@ -192,6 +196,8 @@ export type IssueFilterInput = {
|
||||
statuses?: Array<string> | null | undefined;
|
||||
types?: Array<string> | null | undefined;
|
||||
unassigned?: boolean | null | undefined;
|
||||
/** Only tickets not updated since this instant (ISO) */
|
||||
updatedBefore?: string | null | undefined;
|
||||
/** Only the tickets I follow (none when anonymous) */
|
||||
watching?: boolean | null | undefined;
|
||||
};
|
||||
@@ -1769,6 +1775,7 @@ export type InsightIssueFieldsFragment = {
|
||||
|
||||
export type ProjectInsightsQueryVariables = Exact<{
|
||||
idOrKey: string;
|
||||
since?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type ProjectInsightsQuery = {
|
||||
@@ -1854,6 +1861,66 @@ export type ProjectInsightsQuery = {
|
||||
wipLimit: number | null;
|
||||
overLimit: boolean;
|
||||
}>;
|
||||
releases: Array<{
|
||||
releaseId: string;
|
||||
name: string;
|
||||
targetDate: string | null;
|
||||
total: number;
|
||||
finished: number;
|
||||
likelyDate: string | null;
|
||||
safeDate: string | null;
|
||||
verdict: string;
|
||||
}>;
|
||||
changes: {
|
||||
since: string;
|
||||
createdCount: number;
|
||||
finishedCount: number;
|
||||
returnedCount: number;
|
||||
mentions: number;
|
||||
created: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
finished: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
returned: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
} | null;
|
||||
team: Array<{
|
||||
openTickets: number;
|
||||
openPoints: number;
|
||||
blocked: number;
|
||||
pointsPerSprint: number | null;
|
||||
overloaded: boolean;
|
||||
user: { id: string; name: string };
|
||||
}>;
|
||||
backlog: {
|
||||
open: number;
|
||||
unestimated: number;
|
||||
noArea: number;
|
||||
unassigned: number;
|
||||
stale: number;
|
||||
staleDays: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -10928,6 +10995,14 @@ export const ProjectInsightsDocument = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -10952,6 +11027,16 @@ export const ProjectInsightsDocument = {
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'insights' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
@@ -11175,6 +11260,202 @@ export const ProjectInsightsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'releases' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'name' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'targetDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'total' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finished' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'likelyDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'safeDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'verdict' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'changes' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'created' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createdCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finished' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finishedCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'returned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'returnedCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'mentions' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'team' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'user' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'UserRefFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'openTickets' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'openPoints' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'blocked' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'pointsPerSprint' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overloaded' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'backlog' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'open' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unestimated' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'noArea' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unassigned' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'stale' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'staleDays' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -157,10 +157,10 @@ fragment InsightIssueFields on InsightIssue {
|
||||
dueDate
|
||||
}
|
||||
|
||||
query ProjectInsights($idOrKey: String!) {
|
||||
query ProjectInsights($idOrKey: String!, $since: String) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
insights {
|
||||
insights(since: $since) {
|
||||
generatedAt
|
||||
sprint {
|
||||
sprintId
|
||||
@@ -210,6 +210,50 @@ query ProjectInsights($idOrKey: String!) {
|
||||
wipLimit
|
||||
overLimit
|
||||
}
|
||||
releases {
|
||||
releaseId
|
||||
name
|
||||
targetDate
|
||||
total
|
||||
finished
|
||||
likelyDate
|
||||
safeDate
|
||||
verdict
|
||||
}
|
||||
changes {
|
||||
since
|
||||
created {
|
||||
...InsightIssueFields
|
||||
}
|
||||
createdCount
|
||||
finished {
|
||||
...InsightIssueFields
|
||||
}
|
||||
finishedCount
|
||||
returned {
|
||||
...InsightIssueFields
|
||||
}
|
||||
returnedCount
|
||||
mentions
|
||||
}
|
||||
team {
|
||||
user {
|
||||
...UserRefFields
|
||||
}
|
||||
openTickets
|
||||
openPoints
|
||||
blocked
|
||||
pointsPerSprint
|
||||
overloaded
|
||||
}
|
||||
backlog {
|
||||
open
|
||||
unestimated
|
||||
noArea
|
||||
unassigned
|
||||
stale
|
||||
staleDays
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,17 @@ import {
|
||||
ProjectRole,
|
||||
QA_FAILED,
|
||||
StatusCategory,
|
||||
TeamLoad,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
import { AgingComponent } from '../ui/insights/aging';
|
||||
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
|
||||
import { RisksComponent } from '../ui/insights/risks';
|
||||
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
||||
import { ChangesSinceComponent } from '../ui/insights/changes-since';
|
||||
import { BacklogHealthComponent } from '../ui/insights/backlog-health';
|
||||
import { takeLastVisit } from '../ui/last-visit';
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
@@ -41,6 +46,9 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
RisksComponent,
|
||||
AgingComponent,
|
||||
BottlenecksComponent,
|
||||
ReleaseForecastComponent,
|
||||
ChangesSinceComponent,
|
||||
BacklogHealthComponent,
|
||||
],
|
||||
providers: [provideInsightsFacade()],
|
||||
template: `
|
||||
@@ -102,10 +110,16 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
/>
|
||||
} @else if (insights.insights(); as reading) {
|
||||
<div class="ts-dashboard ts-insights">
|
||||
@if (reading.changes; as changes) {
|
||||
<ts-changes-since [changes]="changes" />
|
||||
}
|
||||
@if (reading.sprint; as sprint) {
|
||||
<ts-sprint-pulse [sprint]="sprint" />
|
||||
}
|
||||
<ts-risks [risks]="reading.risks" />
|
||||
@if (reading.releases.length) {
|
||||
<ts-release-forecast [releases]="reading.releases" />
|
||||
}
|
||||
<ts-aging
|
||||
[issues]="reading.aging.issues"
|
||||
[thresholdDays]="reading.aging.thresholdDays"
|
||||
@@ -114,6 +128,7 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
[statuses]="reading.bottlenecks"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
<ts-backlog-health [health]="reading.backlog" />
|
||||
</div>
|
||||
} @else {
|
||||
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
|
||||
@@ -155,6 +170,12 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
} @else {
|
||||
<span class="ts-pill">Ya no es miembro</span>
|
||||
}
|
||||
@if (loadOf().get(person.id); as load) {
|
||||
@if (load.overloaded) {
|
||||
<span class="ts-pill ts-pill--warn">Sobrecarga</span>
|
||||
}
|
||||
<span class="ts-team__load">{{ loadText(load) }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="ts-bar" aria-hidden="true"
|
||||
><span
|
||||
@@ -382,6 +403,14 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-team__load {
|
||||
flex-basis: 100%;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-pill--warn {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
.ts-team__go {
|
||||
color: var(--ts-brand);
|
||||
opacity: 0;
|
||||
@@ -424,10 +453,15 @@ export class DashboardPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly insights = inject(InsightsFacade);
|
||||
|
||||
/** Opened once per project: the project itself changes on every update. */
|
||||
private openedFor: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.facade.project()?.id;
|
||||
if (projectId) untracked(() => this.insights.open(projectId));
|
||||
if (!projectId || projectId === this.openedFor) return;
|
||||
this.openedFor = projectId;
|
||||
untracked(() => this.insights.open(projectId, takeLastVisit(projectId)));
|
||||
});
|
||||
}
|
||||
protected readonly initialsOf = initials;
|
||||
@@ -557,6 +591,25 @@ export class DashboardPage {
|
||||
(a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'),
|
||||
);
|
||||
});
|
||||
/** Open points and usual pace per person (from the insights). */
|
||||
protected readonly loadOf = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(this.insights.insights()?.team ?? []).map((t) => [t.user.id, t]),
|
||||
),
|
||||
);
|
||||
protected loadText(load: TeamLoad): string {
|
||||
const parts = [`${load.openPoints} pts abiertos`];
|
||||
if (load.pointsPerSprint !== null)
|
||||
parts.push(
|
||||
`cierra ${load.pointsPerSprint.toLocaleString('es')} por sprint`,
|
||||
);
|
||||
if (load.blocked)
|
||||
parts.push(
|
||||
`${load.blocked} ${load.blocked === 1 ? 'bloqueado' : 'bloqueados'}`,
|
||||
);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
protected readonly unassigned = computed(
|
||||
() => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -130,6 +131,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (onlyLabel(); as only) {
|
||||
<p class="ts-list__only">
|
||||
Solo {{ only }}
|
||||
<button type="button" class="ts-plain-link" (click)="clearOnly()">
|
||||
Quitar
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (facade.error(); as error) {
|
||||
<pi-note
|
||||
@@ -344,6 +353,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
.ts-list__count {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-list__only {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-list {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
@@ -452,6 +469,38 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
export class ListPage {
|
||||
/** `?release=<id>`: opens already filtered by that release. */
|
||||
readonly release = input<string | undefined>(undefined);
|
||||
/** `?missing=estimate|area|assignee` (from the dashboard's backlog health). */
|
||||
readonly missing = input<string | undefined>(undefined);
|
||||
/** `?staleDays=90`: only tickets not updated in that many days. */
|
||||
readonly staleDays = input<string | undefined>(undefined);
|
||||
/** What the URL asked for, until cleared here. */
|
||||
protected readonly gap = linkedSignal(() =>
|
||||
this.missing() === 'estimate' || this.missing() === 'area'
|
||||
? this.missing()
|
||||
: null,
|
||||
);
|
||||
protected readonly stale = linkedSignal(() => {
|
||||
const days = Math.round(Number(this.staleDays()));
|
||||
return days > 0 ? days : null;
|
||||
});
|
||||
private readonly unassignedFromUrl = effect(() => {
|
||||
if (this.missing() === 'assignee')
|
||||
untracked(() => this.assignee.set(NOBODY));
|
||||
});
|
||||
protected readonly onlyLabel = computed(() =>
|
||||
[
|
||||
this.gap() === 'estimate' ? 'sin estimar' : null,
|
||||
this.gap() === 'area' ? 'sin área' : null,
|
||||
this.stale() ? `sin tocar en ${this.stale()} días` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' y '),
|
||||
);
|
||||
|
||||
protected clearOnly(): void {
|
||||
this.gap.set(null);
|
||||
this.stale.set(null);
|
||||
}
|
||||
protected readonly facade = inject(ListFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
protected readonly NOBODY = NOBODY;
|
||||
@@ -577,6 +626,13 @@ export class ListPage {
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
noEstimate: this.gap() === 'estimate' || undefined,
|
||||
noArea: this.gap() === 'area' || undefined,
|
||||
updatedBefore: this.stale()
|
||||
? new Date(
|
||||
Date.now() - (this.stale() as number) * 86_400_000,
|
||||
).toISOString()
|
||||
: undefined,
|
||||
customFields: fields.length
|
||||
? fields.map(([fieldId, value]) => ({ fieldId, value }))
|
||||
: undefined,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { BacklogHealth } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** Open tickets missing what planning needs, each count opening the list. */
|
||||
@Component({
|
||||
selector: 'ts-backlog-health',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'health-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="health-title">Salud del backlog</h2>
|
||||
<span class="ts-muted">De {{ health().open }} tickets abiertos</span>
|
||||
</header>
|
||||
<ul class="ts-issues ts-health">
|
||||
@for (row of rows(); track row.label) {
|
||||
<li>
|
||||
<a class="ts-title" routerLink="../list" [queryParams]="row.query">{{
|
||||
row.label
|
||||
}}</a>
|
||||
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
|
||||
<span class="ts-days" [class.ts-warn]="row.value > 0">{{
|
||||
row.value
|
||||
}}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
`,
|
||||
styles: `
|
||||
.ts-health {
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
grid-template-columns: minmax(8rem, 12rem) 1fr 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
.ts-warn {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BacklogHealthComponent {
|
||||
readonly health = input.required<BacklogHealth>();
|
||||
|
||||
protected readonly rows = computed(() => {
|
||||
const h = this.health();
|
||||
const share = (n: number) => (h.open ? (n / h.open) * 100 : 0);
|
||||
return [
|
||||
{
|
||||
label: 'Sin estimar',
|
||||
value: h.unestimated,
|
||||
query: { missing: 'estimate' },
|
||||
},
|
||||
{ label: 'Sin área', value: h.noArea, query: { missing: 'area' } },
|
||||
{
|
||||
label: 'Sin asignar',
|
||||
value: h.unassigned,
|
||||
query: { missing: 'assignee' },
|
||||
},
|
||||
{
|
||||
label: `Sin tocar en ${h.staleDays} días`,
|
||||
value: h.stale,
|
||||
query: { staleDays: h.staleDays },
|
||||
},
|
||||
].map((row) => ({ ...row, share: share(row.value) }));
|
||||
});
|
||||
}
|
||||
105
libs/web/tracker/presenters/src/lib/ui/insights/changes-since.ts
Normal file
105
libs/web/tracker/presenters/src/lib/ui/insights/changes-since.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ChangesSince } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from '../copy';
|
||||
|
||||
/** What happened in the project since my last visit. */
|
||||
@Component({
|
||||
selector: 'ts-changes-since',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'changes-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="changes-title">Desde tu última visita</h2>
|
||||
<span class="ts-muted">{{ ago() }}</span>
|
||||
</header>
|
||||
<dl class="ts-changes__counts">
|
||||
@for (count of counts(); track count.label) {
|
||||
<div>
|
||||
<dt>{{ count.label }}</dt>
|
||||
<dd>{{ count.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@for (group of groups(); track group.title) {
|
||||
@if (group.issues.length) {
|
||||
<h3>{{ group.title }}</h3>
|
||||
<ul class="ts-issues">
|
||||
@for (issue of group.issues; track issue.id) {
|
||||
<li>
|
||||
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.title
|
||||
}}</a>
|
||||
<span></span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
@if (quiet()) {
|
||||
<p class="ts-empty">Nada nuevo desde entonces.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-changes__counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
dt {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ChangesSinceComponent {
|
||||
readonly changes = input.required<ChangesSince>();
|
||||
|
||||
protected readonly ago = computed(() => timeAgo(this.changes().since));
|
||||
protected readonly counts = computed(() => {
|
||||
const c = this.changes();
|
||||
return [
|
||||
{ label: 'Nuevos', value: c.createdCount },
|
||||
{ label: 'Terminados', value: c.finishedCount },
|
||||
{ label: 'Vueltos de QA', value: c.returnedCount },
|
||||
{ label: 'Menciones', value: c.mentions },
|
||||
];
|
||||
});
|
||||
protected readonly groups = computed(() => {
|
||||
const c = this.changes();
|
||||
return [
|
||||
{ title: 'Vueltos de QA', issues: c.returned },
|
||||
{ title: 'Nuevos', issues: c.created },
|
||||
{ title: 'Terminados', issues: c.finished },
|
||||
];
|
||||
});
|
||||
protected readonly quiet = computed(() =>
|
||||
this.counts().every((c) => !c.value),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ReleaseForecast } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const short = (day: string | null) =>
|
||||
day
|
||||
? new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
: '—';
|
||||
|
||||
const VERDICT: Record<
|
||||
ReleaseForecast['verdict'],
|
||||
{ label: string; tone: string }
|
||||
> = {
|
||||
ON_TRACK: { label: 'Llega', tone: 'var(--ts-c-green, #2e9e6a)' },
|
||||
AT_RISK: { label: 'Justa', tone: 'var(--ts-c-amber, #c98a12)' },
|
||||
LATE: { label: 'No llega', tone: 'var(--ts-c-red, #d14343)' },
|
||||
DONE: { label: 'Todo hecho', tone: 'var(--ts-c-green, #2e9e6a)' },
|
||||
NO_DATE: { label: 'Sin fecha', tone: 'var(--ts-muted)' },
|
||||
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Planned releases with the date they would likely be finished at the
|
||||
* team's real pace (Monte Carlo over the last six weeks).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-release-forecast',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'forecast-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="forecast-title">Próximas releases</h2>
|
||||
<span class="ts-muted">Previsión al ritmo de las últimas 6 semanas</span>
|
||||
</header>
|
||||
@if (rows().length) {
|
||||
<ul class="ts-issues ts-forecast">
|
||||
@for (row of rows(); track row.releaseId) {
|
||||
<li>
|
||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||
<span class="ts-verdict" [style.--tone]="row.tone">{{
|
||||
row.label
|
||||
}}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="row.share"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ row.finished }}/{{ row.total }}</span>
|
||||
<span class="ts-forecast__dates">
|
||||
Objetivo {{ row.target }} · probable {{ row.likely }} · segura
|
||||
{{ row.safe }}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-empty">No hay releases planificadas.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-forecast {
|
||||
gap: 0.875rem;
|
||||
li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
row-gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-bar {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
.ts-verdict {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--tone) 14%, transparent);
|
||||
color: var(--tone);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-forecast__dates {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ReleaseForecastComponent {
|
||||
readonly releases = input.required<ReleaseForecast[]>();
|
||||
|
||||
protected readonly rows = computed(() =>
|
||||
this.releases().map((r) => ({
|
||||
...r,
|
||||
...VERDICT[r.verdict],
|
||||
share: r.total ? (r.finished / r.total) * 100 : 0,
|
||||
target: short(r.targetDate),
|
||||
likely: short(r.likelyDate),
|
||||
safe: short(r.safeDate),
|
||||
})),
|
||||
);
|
||||
}
|
||||
19
libs/web/tracker/presenters/src/lib/ui/last-visit.ts
Normal file
19
libs/web/tracker/presenters/src/lib/ui/last-visit.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* When I last opened a project's dashboard, kept in this browser only: it
|
||||
* is a convenience ("what changed since"), so storage may fail or be empty.
|
||||
*/
|
||||
const key = (projectId: string) => `task-stream.lastVisit.${projectId}`;
|
||||
|
||||
/** The previous visit (ISO), and from now on this one. */
|
||||
export function takeLastVisit(
|
||||
projectId: string,
|
||||
now = new Date(),
|
||||
): string | null {
|
||||
try {
|
||||
const previous = localStorage.getItem(key(projectId));
|
||||
localStorage.setItem(key(projectId), now.toISOString());
|
||||
return previous && !Number.isNaN(Date.parse(previous)) ? previous : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user