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:
@@ -19,6 +19,7 @@
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
|
||||
| 🚦 Salud | Un semáforo por proyecto (en su panel y en la cartera) que dice qué va mal y por qué: atascados, vencidos, bugs urgentes, QA, releases tarde |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
|
||||
@@ -305,14 +305,14 @@ visualización de la plataforma:
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión |
|
||||
| `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from './lib/models/models';
|
||||
export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/my-day';
|
||||
export * from './lib/models/health';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
|
||||
126
libs/web/tracker/domain/src/lib/models/health.spec.ts
Normal file
126
libs/web/tracker/domain/src/lib/models/health.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { IssuePriority } from './enums';
|
||||
import { portfolioHealth, projectHealth } from './health';
|
||||
import { ProjectInsights } from './insights';
|
||||
|
||||
const issue = (key: string) => ({ key }) as never;
|
||||
|
||||
const insights = (over: Partial<ProjectInsights> = {}): ProjectInsights =>
|
||||
({
|
||||
risks: {
|
||||
overdue: [],
|
||||
overdueCount: 0,
|
||||
dueSoon: [],
|
||||
dueSoonCount: 0,
|
||||
blocked: [],
|
||||
blockedCount: 0,
|
||||
stuck: [],
|
||||
stuckCount: 0,
|
||||
stuckDays: 5,
|
||||
},
|
||||
quality: {
|
||||
openBugsByPriority: [],
|
||||
bugWeeks: [],
|
||||
qaByArea: [],
|
||||
escaped: [],
|
||||
},
|
||||
releases: [],
|
||||
bottlenecks: [],
|
||||
...over,
|
||||
}) as ProjectInsights;
|
||||
|
||||
describe('project health', () => {
|
||||
it('is good with nothing to say', () => {
|
||||
expect(projectHealth(insights())).toEqual({ level: 'GOOD', signals: [] });
|
||||
});
|
||||
|
||||
it('turns to attention, then risk, with the cause and the tickets behind it', () => {
|
||||
const base = insights();
|
||||
const stuck = projectHealth({
|
||||
...base,
|
||||
risks: { ...base.risks, stuck: [issue('WEB-1')], stuckCount: 1 },
|
||||
});
|
||||
expect(stuck.level).toBe('ATTENTION');
|
||||
expect(stuck.signals[0]).toMatchObject({
|
||||
key: 'stuck',
|
||||
title: '1 ticket lleva 5 días o más sin moverse',
|
||||
keys: ['WEB-1'],
|
||||
});
|
||||
|
||||
const bad = projectHealth({
|
||||
...base,
|
||||
risks: { ...base.risks, overdueCount: 4, overdue: [] },
|
||||
quality: {
|
||||
...base.quality,
|
||||
openBugsByPriority: [{ priority: IssuePriority.CRITICAL, count: 1 }],
|
||||
qaByArea: [{ area: null, returned: 3, passed: 3 }],
|
||||
},
|
||||
releases: [
|
||||
{
|
||||
releaseId: 'r',
|
||||
name: 'v2.0',
|
||||
targetDate: '2026-11-15',
|
||||
total: 10,
|
||||
finished: 2,
|
||||
likelyDate: '2026-12-01',
|
||||
safeDate: null,
|
||||
verdict: 'LATE',
|
||||
},
|
||||
],
|
||||
bottlenecks: [
|
||||
{
|
||||
status: 'IN_PROGRESS',
|
||||
averageDays: 1,
|
||||
samples: 3,
|
||||
open: 9,
|
||||
wipLimit: 5,
|
||||
overLimit: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(bad.level).toBe('RISK');
|
||||
expect(bad.signals.map((s) => [s.key, s.level])).toEqual([
|
||||
['overdue', 'RISK'],
|
||||
['bugs', 'RISK'],
|
||||
['qa', 'RISK'],
|
||||
['releases', 'RISK'],
|
||||
['wip', 'ATTENTION'],
|
||||
]);
|
||||
expect(bad.signals[3].title).toContain('v2.0 no llega a su fecha');
|
||||
});
|
||||
|
||||
it('does not read a trend into a handful of QA verdicts', () => {
|
||||
const base = insights();
|
||||
expect(
|
||||
projectHealth({
|
||||
...base,
|
||||
quality: {
|
||||
...base.quality,
|
||||
qaByArea: [{ area: null, returned: 2, passed: 1 }],
|
||||
},
|
||||
}).level,
|
||||
).toBe('GOOD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('portfolio health', () => {
|
||||
it('reads the light from the counts and the forecasts', () => {
|
||||
const project = {
|
||||
stuck: 0,
|
||||
overdue: 1,
|
||||
blocked: 0,
|
||||
releases: [{ name: 'v2.0', verdict: 'LATE' }],
|
||||
} as never;
|
||||
expect(portfolioHealth(project)).toEqual({
|
||||
level: 'RISK',
|
||||
causes: ['1 vencido', 'v2.0 llega tarde'],
|
||||
});
|
||||
expect(
|
||||
portfolioHealth({
|
||||
stuck: 0,
|
||||
overdue: 0,
|
||||
blocked: 0,
|
||||
releases: [],
|
||||
} as never),
|
||||
).toEqual({ level: 'GOOD', causes: [] });
|
||||
});
|
||||
});
|
||||
173
libs/web/tracker/domain/src/lib/models/health.ts
Normal file
173
libs/web/tracker/domain/src/lib/models/health.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { IssuePriority } from './enums';
|
||||
import { PortfolioProject, ProjectInsights } from './insights';
|
||||
|
||||
/** How a project looks, as a traffic light. */
|
||||
export type HealthLevel = 'GOOD' | 'ATTENTION' | 'RISK';
|
||||
|
||||
/** One thing that colours the light, with its cause. */
|
||||
export interface HealthSignal {
|
||||
key: 'stuck' | 'overdue' | 'blocked' | 'bugs' | 'qa' | 'releases' | 'wip';
|
||||
level: Exclude<HealthLevel, 'GOOD'>;
|
||||
/** What is wrong, in a line. */
|
||||
title: string;
|
||||
/** Tickets behind it (keys), when there are. */
|
||||
keys: string[];
|
||||
/** Where to look: a dashboard section id. */
|
||||
section: string;
|
||||
}
|
||||
|
||||
export interface ProjectHealth {
|
||||
level: HealthLevel;
|
||||
/** Worst first. */
|
||||
signals: HealthSignal[];
|
||||
}
|
||||
|
||||
const RANK: Record<HealthLevel, number> = { GOOD: 0, ATTENTION: 1, RISK: 2 };
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n} ${n === 1 ? one : many}`;
|
||||
|
||||
const shortDate = (day: string) =>
|
||||
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* The project's health from its insights: stuck work, overdue and blocked
|
||||
* tickets, urgent bugs, QA sending too much back, releases that will not
|
||||
* make it and columns over their WIP limit. The light is the worst signal.
|
||||
*/
|
||||
export function projectHealth(insights: ProjectInsights): ProjectHealth {
|
||||
const signals: HealthSignal[] = [];
|
||||
const { risks, quality, releases, bottlenecks } = insights;
|
||||
const keys = (issues: { key: string }[]) => issues.map((i) => i.key);
|
||||
|
||||
if (risks.stuckCount)
|
||||
signals.push({
|
||||
key: 'stuck',
|
||||
level: risks.stuckCount >= 3 ? 'RISK' : 'ATTENTION',
|
||||
title: `${plural(risks.stuckCount, 'ticket lleva', 'tickets llevan')} ${risks.stuckDays} días o más sin moverse`,
|
||||
keys: keys(risks.stuck),
|
||||
section: 'risks',
|
||||
});
|
||||
if (risks.overdueCount)
|
||||
signals.push({
|
||||
key: 'overdue',
|
||||
level: risks.overdueCount >= 3 ? 'RISK' : 'ATTENTION',
|
||||
title: `${plural(risks.overdueCount, 'ticket ha pasado', 'tickets han pasado')} su fecha límite`,
|
||||
keys: keys(risks.overdue),
|
||||
section: 'risks',
|
||||
});
|
||||
if (risks.blockedCount)
|
||||
signals.push({
|
||||
key: 'blocked',
|
||||
level: risks.blockedCount >= 5 ? 'RISK' : 'ATTENTION',
|
||||
title: `${plural(risks.blockedCount, 'ticket espera', 'tickets esperan')} a otro que lo bloquea`,
|
||||
keys: keys(risks.blocked),
|
||||
section: 'risks',
|
||||
});
|
||||
|
||||
const bugs = (priority: IssuePriority) =>
|
||||
quality.openBugsByPriority.find((b) => b.priority === priority)?.count ?? 0;
|
||||
const critical = bugs(IssuePriority.CRITICAL);
|
||||
const high = bugs(IssuePriority.HIGH);
|
||||
if (critical || high >= 3)
|
||||
signals.push({
|
||||
key: 'bugs',
|
||||
level: critical ? 'RISK' : 'ATTENTION',
|
||||
title: critical
|
||||
? `${plural(critical, 'bug crítico abierto', 'bugs críticos abiertos')}${high ? ` y ${plural(high, 'de prioridad alta', 'de prioridad alta')}` : ''}`
|
||||
: `${high} bugs de prioridad alta abiertos`,
|
||||
keys: [],
|
||||
section: 'quality',
|
||||
});
|
||||
|
||||
const returned = quality.qaByArea.reduce((n, a) => n + a.returned, 0);
|
||||
const passed = quality.qaByArea.reduce((n, a) => n + a.passed, 0);
|
||||
const reviewed = returned + passed;
|
||||
// Too few to tell a trend from bad luck.
|
||||
if (reviewed >= 5 && returned / reviewed >= 0.3) {
|
||||
const share = Math.round((returned / reviewed) * 100);
|
||||
signals.push({
|
||||
key: 'qa',
|
||||
level: share >= 50 ? 'RISK' : 'ATTENTION',
|
||||
title: `QA devuelve el ${share} % de lo que revisa (90 días)`,
|
||||
keys: [],
|
||||
section: 'quality',
|
||||
});
|
||||
}
|
||||
|
||||
for (const release of releases) {
|
||||
if (release.verdict !== 'LATE' && release.verdict !== 'AT_RISK') continue;
|
||||
const target = release.targetDate ? shortDate(release.targetDate) : '';
|
||||
const likely = release.likelyDate ? shortDate(release.likelyDate) : '';
|
||||
signals.push({
|
||||
key: 'releases',
|
||||
level: release.verdict === 'LATE' ? 'RISK' : 'ATTENTION',
|
||||
title:
|
||||
release.verdict === 'LATE'
|
||||
? `${release.name} no llega a su fecha (${target}): al ritmo real, hacia el ${likely}`
|
||||
: `${release.name} va justa para el ${target}: la mitad de las veces no llega`,
|
||||
keys: [],
|
||||
section: 'releases',
|
||||
});
|
||||
}
|
||||
|
||||
const over = bottlenecks.filter((b) => b.overLimit);
|
||||
if (over.length)
|
||||
signals.push({
|
||||
key: 'wip',
|
||||
level: 'ATTENTION',
|
||||
title: `${plural(over.length, 'columna pasa', 'columnas pasan')} de su límite WIP`,
|
||||
keys: [],
|
||||
section: 'bottlenecks',
|
||||
});
|
||||
|
||||
signals.sort((a, b) => RANK[b.level] - RANK[a.level]);
|
||||
return {
|
||||
level: signals.reduce<HealthLevel>(
|
||||
(worst, s) => (RANK[s.level] > RANK[worst] ? s.level : worst),
|
||||
'GOOD',
|
||||
),
|
||||
signals,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same light from what the portfolio knows of a project (overdue,
|
||||
* stuck and blocked tickets, release forecasts), with its causes in a line.
|
||||
*/
|
||||
export function portfolioHealth(project: PortfolioProject): {
|
||||
level: HealthLevel;
|
||||
causes: string[];
|
||||
} {
|
||||
const causes: string[] = [];
|
||||
let level: HealthLevel = 'GOOD';
|
||||
const raise = (to: HealthLevel) => {
|
||||
if (RANK[to] > RANK[level]) level = to;
|
||||
};
|
||||
if (project.stuck) {
|
||||
raise(project.stuck >= 3 ? 'RISK' : 'ATTENTION');
|
||||
causes.push(plural(project.stuck, 'atascado', 'atascados'));
|
||||
}
|
||||
if (project.overdue) {
|
||||
raise(project.overdue >= 3 ? 'RISK' : 'ATTENTION');
|
||||
causes.push(plural(project.overdue, 'vencido', 'vencidos'));
|
||||
}
|
||||
if (project.blocked >= 5) raise('RISK');
|
||||
else if (project.blocked) raise('ATTENTION');
|
||||
if (project.blocked)
|
||||
causes.push(plural(project.blocked, 'bloqueado', 'bloqueados'));
|
||||
for (const release of project.releases) {
|
||||
if (release.verdict === 'LATE') {
|
||||
raise('RISK');
|
||||
causes.push(`${release.name} llega tarde`);
|
||||
} else if (release.verdict === 'AT_RISK') {
|
||||
raise('ATTENTION');
|
||||
causes.push(`${release.name} va justa`);
|
||||
}
|
||||
}
|
||||
return { level, causes };
|
||||
}
|
||||
@@ -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