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:
103
libs/web/tracker/presenters/src/lib/ui/insights/aging.ts
Normal file
103
libs/web/tracker/presenters/src/lib/ui/insights/aging.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { InsightIssue } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/**
|
||||
* Each ticket under way by the days it has been in its status, against the
|
||||
* project's usual: 85% of the tickets finished lately took at most that.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-aging',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', 'aria-labelledby': 'aging-title', role: 'region' },
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="aging-title">Antigüedad de lo que está en curso</h2>
|
||||
<span class="ts-muted">{{ legend() }}</span>
|
||||
</header>
|
||||
@if (issues().length) {
|
||||
<ul class="ts-issues ts-aging">
|
||||
@for (issue of issues(); track issue.id) {
|
||||
<li [class.is-old]="isOld(issue)">
|
||||
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.title
|
||||
}}</a>
|
||||
<span class="ts-bar" [attr.title]="issue.days + ' días'">
|
||||
<span [style.width.%]="share(issue.days)"></span>
|
||||
@if (threshold() !== null) {
|
||||
<i
|
||||
class="ts-aging__mark"
|
||||
[style.left.%]="share(threshold()!)"
|
||||
></i>
|
||||
}
|
||||
</span>
|
||||
<span class="ts-days">{{ issue.days }} d</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-empty">Nada en curso ahora mismo.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-aging li {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(4rem, 7rem) 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-aging .ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
.ts-aging li.is-old {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
.ts-bar {
|
||||
overflow: visible;
|
||||
}
|
||||
.ts-aging__mark {
|
||||
position: absolute;
|
||||
top: -0.1875rem;
|
||||
bottom: -0.1875rem;
|
||||
width: 2px;
|
||||
background: var(--ts-ink-soft);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AgingComponent {
|
||||
readonly issues = input.required<InsightIssue[]>();
|
||||
/** p85 cycle time; none until something took at least part of a day. */
|
||||
readonly thresholdDays = input<number | null>(null);
|
||||
protected readonly threshold = computed(() => {
|
||||
const days = this.thresholdDays();
|
||||
return days && days > 0 ? days : null;
|
||||
});
|
||||
|
||||
private readonly max = computed(() =>
|
||||
Math.max(1, this.threshold() ?? 0, ...this.issues().map((i) => i.days)),
|
||||
);
|
||||
|
||||
protected share(days: number): number {
|
||||
return Math.min(100, (days / this.max()) * 100);
|
||||
}
|
||||
|
||||
protected isOld(issue: InsightIssue): boolean {
|
||||
const t = this.threshold();
|
||||
return t !== null && issue.days > t;
|
||||
}
|
||||
|
||||
protected readonly legend = computed(() => {
|
||||
const t = this.threshold();
|
||||
return t === null
|
||||
? 'Días en su estado actual'
|
||||
: `La raya: ${t.toLocaleString('es')} días, lo que tarda el 85% de los tickets`;
|
||||
});
|
||||
}
|
||||
105
libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts
Normal file
105
libs/web/tracker/presenters/src/lib/ui/insights/bottlenecks.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { StatusTime, Workflow } from '@task-stream/web/tracker/domain';
|
||||
import { statusLook } from '../copy';
|
||||
|
||||
/** Where tickets wait: average days in each status of the flow. */
|
||||
@Component({
|
||||
selector: 'ts-bottlenecks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'bottlenecks-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="bottlenecks-title">Cuellos de botella</h2>
|
||||
<span class="ts-muted">Días de media en cada estado (90 días)</span>
|
||||
</header>
|
||||
@if (rows().length) {
|
||||
<ul class="ts-issues ts-necks">
|
||||
@for (row of rows(); track row.status) {
|
||||
<li [style.--tone]="row.tone" [class.is-slowest]="row.slowest">
|
||||
<span class="ts-title">{{ row.label }}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="row.share"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ row.average }}</span>
|
||||
<span
|
||||
class="ts-days"
|
||||
[class.ts-warn]="row.overLimit"
|
||||
[attr.title]="
|
||||
row.wipLimit !== null
|
||||
? 'Hay ' + row.open + ' con un límite de ' + row.wipLimit
|
||||
: 'Hay ' + row.open + ' ahora'
|
||||
"
|
||||
>{{ row.open
|
||||
}}{{ row.wipLimit !== null ? '/' + row.wipLimit : '' }}</span
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (slowest(); as slowest) {
|
||||
<p class="ts-empty ts-necks__note">
|
||||
Donde más se paran: <strong>{{ slowest }}</strong
|
||||
>.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="ts-empty">El flujo no tiene estados en curso o de QA.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-necks {
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
grid-template-columns: minmax(6rem, 9rem) 1fr 3.5rem 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
li.is-slowest .ts-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.ts-necks__note {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BottlenecksComponent {
|
||||
readonly statuses = input.required<StatusTime[]>();
|
||||
readonly workflow = input.required<Workflow>();
|
||||
|
||||
protected readonly rows = computed(() => {
|
||||
const list = this.statuses();
|
||||
const max = Math.max(1, ...list.map((s) => s.averageDays ?? 0));
|
||||
const slowest = Math.max(...list.map((s) => s.averageDays ?? 0));
|
||||
return list.map((s) => {
|
||||
const look = statusLook(this.workflow(), s.status);
|
||||
return {
|
||||
...s,
|
||||
label: look.label,
|
||||
tone: look.tone,
|
||||
share: ((s.averageDays ?? 0) / max) * 100,
|
||||
average:
|
||||
s.averageDays === null
|
||||
? '—'
|
||||
: `${s.averageDays.toLocaleString('es')} d`,
|
||||
slowest: slowest > 0 && s.averageDays === slowest,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly slowest = computed(() => {
|
||||
const row = this.rows().find((r) => r.slowest);
|
||||
return row ? `${row.label} (${row.average})` : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Shared by the dashboard's insight panels (each host is a .ts-panel).
|
||||
:host {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.ts-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-issues {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ts-key {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
.ts-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-days {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-bar {
|
||||
position: relative;
|
||||
height: 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--tone, var(--ts-brand));
|
||||
}
|
||||
}
|
||||
|
||||
.ts-warn {
|
||||
color: var(--ts-c-red, #d14343);
|
||||
}
|
||||
|
||||
.ts-empty {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
114
libs/web/tracker/presenters/src/lib/ui/insights/risks.ts
Normal file
114
libs/web/tracker/presenters/src/lib/ui/insights/risks.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { InsightIssue, Risks } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** Overdue, due soon, blocked and stuck: what to look at now. */
|
||||
@Component({
|
||||
selector: 'ts-risks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', 'aria-labelledby': 'risks-title', role: 'region' },
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="risks-title">Tickets en riesgo</h2>
|
||||
<span class="ts-muted">Lo que conviene mirar hoy</span>
|
||||
</header>
|
||||
@for (group of groups(); track group.title) {
|
||||
@if (group.count) {
|
||||
<section class="ts-risk">
|
||||
<h3 [class.ts-warn]="group.warn">
|
||||
{{ group.title }} <span class="ts-days">{{ group.count }}</span>
|
||||
</h3>
|
||||
<ul class="ts-issues">
|
||||
@for (issue of group.issues; track issue.id) {
|
||||
<li>
|
||||
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.title
|
||||
}}</a>
|
||||
<span class="ts-days">{{ group.days(issue) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (group.count > group.issues.length) {
|
||||
<p class="ts-empty">
|
||||
y {{ group.count - group.issues.length }} más
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (!total()) {
|
||||
<p class="ts-empty">Nada vencido, bloqueado ni atascado.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-risk + .ts-risk {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RisksComponent {
|
||||
readonly risks = input.required<Risks>();
|
||||
|
||||
protected readonly total = computed(() => {
|
||||
const r = this.risks();
|
||||
return r.overdueCount + r.dueSoonCount + r.blockedCount + r.stuckCount;
|
||||
});
|
||||
|
||||
protected readonly groups = computed(() => {
|
||||
const r = this.risks();
|
||||
return [
|
||||
{
|
||||
title: 'Vencidos',
|
||||
warn: true,
|
||||
issues: r.overdue,
|
||||
count: r.overdueCount,
|
||||
days: (i: InsightIssue) => `hace ${plural(i.days, 'día', 'días')}`,
|
||||
},
|
||||
{
|
||||
title: 'Vencen esta semana',
|
||||
warn: false,
|
||||
issues: r.dueSoon,
|
||||
count: r.dueSoonCount,
|
||||
days: (i: InsightIssue) =>
|
||||
i.days === 0 ? 'hoy' : i.days === 1 ? 'mañana' : `en ${i.days} días`,
|
||||
},
|
||||
{
|
||||
title: 'Bloqueados',
|
||||
warn: false,
|
||||
issues: r.blocked,
|
||||
count: r.blockedCount,
|
||||
days: (i: InsightIssue) =>
|
||||
i.days ? plural(i.days, 'día', 'días') : 'hoy',
|
||||
},
|
||||
{
|
||||
title: `Atascados (${r.stuckDays} días o más sin moverse)`,
|
||||
warn: false,
|
||||
issues: r.stuck,
|
||||
count: r.stuckCount,
|
||||
days: (i: InsightIssue) =>
|
||||
i.days ? plural(i.days, 'día', 'días') : 'hoy',
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
133
libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
Normal file
133
libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { SprintPulse } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const short = (day: string) =>
|
||||
new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const decimal = (n: number) =>
|
||||
n.toLocaleString('es', { maximumFractionDigits: 1 });
|
||||
|
||||
/** The sprint under way: days left, points done and whether it gets there. */
|
||||
@Component({
|
||||
selector: 'ts-sprint-pulse',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', 'aria-labelledby': 'pulse-title', role: 'region' },
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
@let s = sprint();
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="pulse-title">{{ s.name }}</h2>
|
||||
<span class="ts-muted">{{ when() }}</span>
|
||||
</header>
|
||||
<p class="ts-pulse__days">
|
||||
<strong>{{ daysText() }}</strong>
|
||||
@if (s.behind) {
|
||||
<span class="ts-pulse__flag">A este ritmo no llega</span>
|
||||
} @else if (s.points && s.donePoints === s.points) {
|
||||
<span class="ts-pulse__flag is-ok">Todo hecho</span>
|
||||
}
|
||||
</p>
|
||||
<div class="ts-pulse__bars">
|
||||
<span class="ts-pulse__label">Tiempo</span>
|
||||
<span class="ts-bar"><span [style.width.%]="timeShare()"></span></span>
|
||||
<span class="ts-days">{{ timeShare() }}%</span>
|
||||
<span class="ts-pulse__label">Puntos</span>
|
||||
<span class="ts-bar" [class.is-behind]="s.behind"
|
||||
><span [style.width.%]="pointShare()"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ s.donePoints }}/{{ s.points }}</span>
|
||||
</div>
|
||||
<p class="ts-empty">
|
||||
{{ s.doneTickets }} de {{ s.tickets }} tickets terminados.
|
||||
@if (paceText(); as pace) {
|
||||
{{ pace }}
|
||||
}
|
||||
<a routerLink="../sprints">Ver el sprint →</a>
|
||||
</p>
|
||||
`,
|
||||
styles: `
|
||||
.ts-pulse__days {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 1rem;
|
||||
strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
.ts-pulse__flag {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-c-red, #d14343) 14%, transparent);
|
||||
color: var(--ts-c-red, #d14343);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
&.is-ok {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--ts-c-green, #2e9e6a) 14%,
|
||||
transparent
|
||||
);
|
||||
color: var(--ts-c-green, #2e9e6a);
|
||||
}
|
||||
}
|
||||
.ts-pulse__bars {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ts-pulse__label {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-bar.is-behind {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
a {
|
||||
color: var(--ts-brand);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SprintPulseComponent {
|
||||
readonly sprint = input.required<SprintPulse>();
|
||||
|
||||
protected readonly when = computed(
|
||||
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
||||
);
|
||||
protected readonly daysText = computed(() => {
|
||||
const left = this.sprint().daysLeft;
|
||||
if (left < 0)
|
||||
return 'Terminó hace ' + -left + (left === -1 ? ' día' : ' días');
|
||||
if (left === 0) return 'Último día';
|
||||
return `Quedan ${left} ${left === 1 ? 'día' : 'días'}`;
|
||||
});
|
||||
protected readonly timeShare = computed(() => {
|
||||
const s = this.sprint();
|
||||
const gone = s.totalDays - Math.max(s.daysLeft, 0);
|
||||
return Math.round((Math.min(gone, s.totalDays) / s.totalDays) * 100);
|
||||
});
|
||||
protected readonly pointShare = computed(() => {
|
||||
const s = this.sprint();
|
||||
return s.points ? Math.round((s.donePoints / s.points) * 100) : 0;
|
||||
});
|
||||
protected readonly paceText = computed(() => {
|
||||
const s = this.sprint();
|
||||
if (s.pacePerDay === null || s.neededPerDay === null) return '';
|
||||
return `Se cierran ${decimal(s.pacePerDay)} pts al día; hacen falta ${decimal(s.neededPerDay)}.`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user