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

@@ -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,
);