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