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:
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user