feat: dashboard reads the sprint, the risks, aging and bottlenecks

A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
  needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
  and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:55:52 +02:00
parent 86e313efaa
commit fe03c5bda8
41 changed files with 2774 additions and 20 deletions

View File

@@ -2,23 +2,47 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
PiNoteComponent,
PiSkeletonComponent,
PiStatCardComponent,
} from '@pi/ui';
import {
InsightsFacade,
ProjectFacade,
provideInsightsFacade,
} from '@task-stream/web/tracker/application';
import {
ProjectRole,
QA_FAILED,
StatusCategory,
} 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';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
selector: 'ts-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent],
imports: [
RouterLink,
PiNoteComponent,
PiSkeletonComponent,
PiStatCardComponent,
SprintPulseComponent,
RisksComponent,
AgingComponent,
BottlenecksComponent,
],
providers: [provideInsightsFacade()],
template: `
@if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen">
@@ -66,6 +90,35 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
}
</section>
@if (insights.error(); as error) {
<pi-note
class="ts-insights-error"
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo leer el estado del proyecto"
[text]="error"
/>
} @else if (insights.insights(); as reading) {
<div class="ts-dashboard ts-insights">
@if (reading.sprint; as sprint) {
<ts-sprint-pulse [sprint]="sprint" />
}
<ts-risks [risks]="reading.risks" />
<ts-aging
[issues]="reading.aging.issues"
[thresholdDays]="reading.aging.thresholdDays"
/>
<ts-bottlenecks
[statuses]="reading.bottlenecks"
[workflow]="facade.workflow()"
/>
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
}
<div class="ts-dashboard">
<section class="ts-panel" aria-labelledby="team-title">
<header class="ts-panel__head">
@@ -232,6 +285,12 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
font-weight: 600;
color: var(--ts-ink);
}
.ts-insights,
.ts-insights-error,
.ts-insights-loading {
display: grid;
margin-bottom: 1.25rem;
}
.ts-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
@@ -363,6 +422,14 @@ import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
})
export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade);
constructor() {
effect(() => {
const projectId = this.facade.project()?.id;
if (projectId) untracked(() => this.insights.open(projectId));
});
}
protected readonly initialsOf = initials;
private count(status: string): number {