feat(web): project health as a traffic light, with its causes

The project dashboard opens with «Salud del proyecto»: good, attention or
at risk, the worst of its signals, each with the cause and the tickets
behind it: stuck work, overdue and blocked tickets, critical or several
high bugs, QA sending back 30 % or more (with enough verdicts), releases
late or at risk by the forecast, columns over their WIP limit. Worked
out in the web from the insights it already reads (projectHealth). The
portfolio shows each project's light too, with its causes on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:08:21 +02:00
parent 8ed16f133e
commit 79b8094011
8 changed files with 504 additions and 3 deletions

View File

@@ -47,6 +47,7 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
import { JourneyComponent } from '../ui/insights/journey';
import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
import { ProjectHealthComponent } from '../ui/insights/project-health';
/** Seconds each block stays in «Modo pantalla». */
const SLIDE_SECONDS = 15;
@@ -72,6 +73,7 @@ const SLIDE_SECONDS = 15;
ActivityHeatmapComponent,
JourneyComponent,
ProposalsFunnelComponent,
ProjectHealthComponent,
],
providers: [provideInsightsFacade()],
host: { '[class.is-screen]': 'screen()' },
@@ -225,6 +227,7 @@ const SLIDE_SECONDS = 15;
/>
} @else if (insights.insights(); as reading) {
<div class="ts-dashboard ts-insights">
<ts-project-health [insights]="reading" />
@if (reading.changes; as changes) {
<ts-changes-since [changes]="changes" />
}

View File

@@ -14,7 +14,12 @@ import {
PortfolioFacade,
providePortfolioFacade,
} from '@task-stream/web/tracker/application';
import { ReleaseForecast, SprintPulse } from '@task-stream/web/tracker/domain';
import {
HealthLevel,
portfolioHealth,
ReleaseForecast,
SprintPulse,
} from '@task-stream/web/tracker/domain';
import { projectTone } from '../ui/copy';
import { verdictOf } from '../ui/insights/release-forecast';
@@ -94,6 +99,14 @@ const short = (day: string | null) =>
<h2 [id]="'pf-' + p.projectId">
<a [routerLink]="['/p', p.key, 'dashboard']">{{ p.name }}</a>
</h2>
@if (healthOf(p); as health) {
<span
class="ts-portfolio__health"
[attr.data-level]="health.level"
[attr.title]="health.causes.join(' · ') || 'Nada que mirar'"
>{{ lightLabel[health.level] }}</span
>
}
</header>
<p class="ts-portfolio__counts">
<span
@@ -239,6 +252,29 @@ const short = (day: string | null) =>
padding: 1.25rem 1.5rem;
border-top: 3px solid var(--tone);
}
.ts-portfolio__health {
--light: var(--ts-c-green);
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-left: auto;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
&::before {
content: '';
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--light);
}
&[data-level='ATTENTION'] {
--light: var(--ts-c-amber);
}
&[data-level='RISK'] {
--light: var(--ts-c-red);
}
}
.ts-portfolio__head {
display: flex;
align-items: baseline;
@@ -345,6 +381,12 @@ export class PortfolioPage implements OnInit {
protected readonly portfolio = inject(PortfolioFacade);
protected readonly tone = projectTone;
protected readonly day = short;
protected readonly healthOf = portfolioHealth;
protected readonly lightLabel: Record<HealthLevel, string> = {
GOOD: 'Bien',
ATTENTION: 'Atención',
RISK: 'En riesgo',
};
ngOnInit(): void {
this.portfolio.load();

View File

@@ -0,0 +1,155 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
HealthLevel,
ProjectInsights,
projectHealth,
} from '@task-stream/web/tracker/domain';
const LIGHT: Record<HealthLevel, { label: string; hint: string }> = {
GOOD: { label: 'Bien', hint: 'Nada atascado, vencido ni en riesgo' },
ATTENTION: { label: 'Atención', hint: 'Hay cosas que conviene mirar' },
RISK: { label: 'En riesgo', hint: 'Hay algo que necesita acción' },
};
/**
* The project's health as a traffic light, worked out from its insights,
* with each cause and the tickets behind it.
*/
@Component({
selector: 'ts-project-health',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'health-title',
role: 'region',
'[attr.data-level]': 'health().level',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-health__head">
<span class="ts-health__light" aria-hidden="true"></span>
<div>
<h2 id="health-title">
Salud del proyecto: <strong>{{ light().label }}</strong>
</h2>
<p class="ts-muted">{{ light().hint }}</p>
</div>
</header>
@if (health().signals.length) {
<ul class="ts-health__signals">
@for (signal of health().signals; track signal.key + signal.title) {
<li [attr.data-level]="signal.level">
<span class="ts-health__dot" aria-hidden="true"></span>
<span class="visually-hidden">{{
signal.level === 'RISK' ? 'Riesgo:' : 'Atención:'
}}</span>
<span class="ts-health__title">{{ signal.title }}</span>
@if (signal.keys.length) {
<span class="ts-health__keys">
@for (key of signal.keys.slice(0, 4); track key) {
<a class="ts-key" [routerLink]="['../issues', key]">{{
key
}}</a>
}
@if (signal.keys.length > 4) {
<span class="ts-muted">+{{ signal.keys.length - 4 }}</span>
}
</span>
}
</li>
}
</ul>
}
`,
styles: `
:host {
--light: var(--ts-c-green);
grid-column: 1 / -1;
border-left: 4px solid var(--light);
}
:host([data-level='ATTENTION']) {
--light: var(--ts-c-amber);
}
:host([data-level='RISK']) {
--light: var(--ts-c-red);
}
.ts-health__head {
display: flex;
align-items: center;
gap: 0.875rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
strong {
color: var(--light);
font-weight: 800;
}
p {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
}
}
.ts-health__light {
flex: none;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--light);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--light) 20%, transparent);
}
.ts-health__signals {
display: grid;
gap: 0.5rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
li {
--dot: var(--ts-c-amber);
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.625rem;
font-size: 0.9375rem;
&[data-level='RISK'] {
--dot: var(--ts-c-red);
}
}
}
.ts-health__dot {
flex: none;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--dot);
transform: translateY(-1px);
}
.ts-health__title {
flex: 1 1 18rem;
}
.ts-health__keys {
display: inline-flex;
flex-wrap: wrap;
gap: 0.5rem;
a {
text-decoration: none;
&:hover {
color: var(--ts-brand);
}
}
}
`,
})
export class ProjectHealthComponent {
readonly insights = input.required<ProjectInsights>();
protected readonly health = computed(() => projectHealth(this.insights()));
protected readonly light = computed(() => LIGHT[this.health().level]);
}