feat: dashboard forecasts releases, tells what changed and weighs the load

The insights reading grows (GraphQL insights(since:), REST ?since=, MCP):
- planned releases with a likely (p50) and safe (p85) finish date from a
  Monte Carlo over the last six weeks of finished work, one after another,
  and a verdict against their target;
- with `since` (the viewer's last visit, kept in the browser): tickets
  created, finished and sent back from QA, and their mentions;
- each person's open points against what they closed per sprint lately;
- open tickets without estimate, area or assignee, or untouched for 90
  days, each count opening the list filtered (new noEstimate, noArea and
  updatedBefore filters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:50:24 +02:00
parent fe03c5bda8
commit 0d158a2ed7
41 changed files with 2050 additions and 118 deletions

View File

@@ -0,0 +1,82 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { BacklogHealth } from '@task-stream/web/tracker/domain';
/** Open tickets missing what planning needs, each count opening the list. */
@Component({
selector: 'ts-backlog-health',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'health-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="health-title">Salud del backlog</h2>
<span class="ts-muted">De {{ health().open }} tickets abiertos</span>
</header>
<ul class="ts-issues ts-health">
@for (row of rows(); track row.label) {
<li>
<a class="ts-title" routerLink="../list" [queryParams]="row.query">{{
row.label
}}</a>
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
<span class="ts-days" [class.ts-warn]="row.value > 0">{{
row.value
}}</span>
</li>
}
</ul>
`,
styles: `
.ts-health {
gap: 0.5rem;
li {
grid-template-columns: minmax(8rem, 12rem) 1fr 2.5rem;
align-items: center;
}
.ts-days {
text-align: right;
}
.ts-warn {
color: inherit;
font-weight: 600;
}
}
`,
})
export class BacklogHealthComponent {
readonly health = input.required<BacklogHealth>();
protected readonly rows = computed(() => {
const h = this.health();
const share = (n: number) => (h.open ? (n / h.open) * 100 : 0);
return [
{
label: 'Sin estimar',
value: h.unestimated,
query: { missing: 'estimate' },
},
{ label: 'Sin área', value: h.noArea, query: { missing: 'area' } },
{
label: 'Sin asignar',
value: h.unassigned,
query: { missing: 'assignee' },
},
{
label: `Sin tocar en ${h.staleDays} días`,
value: h.stale,
query: { staleDays: h.staleDays },
},
].map((row) => ({ ...row, share: share(row.value) }));
});
}

View File

@@ -0,0 +1,105 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ChangesSince } from '@task-stream/web/tracker/domain';
import { timeAgo } from '../copy';
/** What happened in the project since my last visit. */
@Component({
selector: 'ts-changes-since',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'changes-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="changes-title">Desde tu última visita</h2>
<span class="ts-muted">{{ ago() }}</span>
</header>
<dl class="ts-changes__counts">
@for (count of counts(); track count.label) {
<div>
<dt>{{ count.label }}</dt>
<dd>{{ count.value }}</dd>
</div>
}
</dl>
@for (group of groups(); track group.title) {
@if (group.issues.length) {
<h3>{{ group.title }}</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></span>
</li>
}
</ul>
}
}
@if (quiet()) {
<p class="ts-empty">Nada nuevo desde entonces.</p>
}
`,
styles: `
.ts-changes__counts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
margin: 0 0 0.75rem;
dt {
color: var(--ts-muted);
font-size: 0.75rem;
}
dd {
margin: 0;
font-family: var(--ts-font-mono);
font-size: 1.25rem;
font-weight: 600;
}
}
h3 {
margin: 0.75rem 0 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
}
`,
})
export class ChangesSinceComponent {
readonly changes = input.required<ChangesSince>();
protected readonly ago = computed(() => timeAgo(this.changes().since));
protected readonly counts = computed(() => {
const c = this.changes();
return [
{ label: 'Nuevos', value: c.createdCount },
{ label: 'Terminados', value: c.finishedCount },
{ label: 'Vueltos de QA', value: c.returnedCount },
{ label: 'Menciones', value: c.mentions },
];
});
protected readonly groups = computed(() => {
const c = this.changes();
return [
{ title: 'Vueltos de QA', issues: c.returned },
{ title: 'Nuevos', issues: c.created },
{ title: 'Terminados', issues: c.finished },
];
});
protected readonly quiet = computed(() =>
this.counts().every((c) => !c.value),
);
}

View File

@@ -0,0 +1,114 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ReleaseForecast } from '@task-stream/web/tracker/domain';
const short = (day: string | null) =>
day
? new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
})
: '—';
const VERDICT: Record<
ReleaseForecast['verdict'],
{ label: string; tone: string }
> = {
ON_TRACK: { label: 'Llega', tone: 'var(--ts-c-green, #2e9e6a)' },
AT_RISK: { label: 'Justa', tone: 'var(--ts-c-amber, #c98a12)' },
LATE: { label: 'No llega', tone: 'var(--ts-c-red, #d14343)' },
DONE: { label: 'Todo hecho', tone: 'var(--ts-c-green, #2e9e6a)' },
NO_DATE: { label: 'Sin fecha', tone: 'var(--ts-muted)' },
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
};
/**
* Planned releases with the date they would likely be finished at the
* team's real pace (Monte Carlo over the last six weeks).
*/
@Component({
selector: 'ts-release-forecast',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'forecast-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="forecast-title">Próximas releases</h2>
<span class="ts-muted">Previsión al ritmo de las últimas 6 semanas</span>
</header>
@if (rows().length) {
<ul class="ts-issues ts-forecast">
@for (row of rows(); track row.releaseId) {
<li>
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
<span class="ts-verdict" [style.--tone]="row.tone">{{
row.label
}}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.finished }}/{{ row.total }}</span>
<span class="ts-forecast__dates">
Objetivo {{ row.target }} · probable {{ row.likely }} · segura
{{ row.safe }}
</span>
</li>
}
</ul>
} @else {
<p class="ts-empty">No hay releases planificadas.</p>
}
`,
styles: `
.ts-forecast {
gap: 0.875rem;
li {
grid-template-columns: minmax(0, 1fr) auto;
row-gap: 0.25rem;
align-items: center;
}
.ts-bar {
grid-column: 1;
}
}
.ts-verdict {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--tone) 14%, transparent);
color: var(--tone);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.ts-forecast__dates {
grid-column: 1 / -1;
color: var(--ts-muted);
font-size: 0.75rem;
}
`,
})
export class ReleaseForecastComponent {
readonly releases = input.required<ReleaseForecast[]>();
protected readonly rows = computed(() =>
this.releases().map((r) => ({
...r,
...VERDICT[r.verdict],
share: r.total ? (r.finished / r.total) * 100 : 0,
target: short(r.targetDate),
likely: short(r.likelyDate),
safe: short(r.safeDate),
})),
);
}

View File

@@ -0,0 +1,19 @@
/*
* When I last opened a project's dashboard, kept in this browser only: it
* is a convenience ("what changed since"), so storage may fail or be empty.
*/
const key = (projectId: string) => `task-stream.lastVisit.${projectId}`;
/** The previous visit (ISO), and from now on this one. */
export function takeLastVisit(
projectId: string,
now = new Date(),
): string | null {
try {
const previous = localStorage.getItem(key(projectId));
localStorage.setItem(key(projectId), now.toISOString());
return previous && !Number.isNaN(Date.parse(previous)) ? previous : null;
} catch {
return null;
}
}