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