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:
@@ -21,12 +21,17 @@ import {
|
||||
ProjectRole,
|
||||
QA_FAILED,
|
||||
StatusCategory,
|
||||
TeamLoad,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
import { AgingComponent } from '../ui/insights/aging';
|
||||
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
|
||||
import { RisksComponent } from '../ui/insights/risks';
|
||||
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
||||
import { ChangesSinceComponent } from '../ui/insights/changes-since';
|
||||
import { BacklogHealthComponent } from '../ui/insights/backlog-health';
|
||||
import { takeLastVisit } from '../ui/last-visit';
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
@@ -41,6 +46,9 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
RisksComponent,
|
||||
AgingComponent,
|
||||
BottlenecksComponent,
|
||||
ReleaseForecastComponent,
|
||||
ChangesSinceComponent,
|
||||
BacklogHealthComponent,
|
||||
],
|
||||
providers: [provideInsightsFacade()],
|
||||
template: `
|
||||
@@ -102,10 +110,16 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
/>
|
||||
} @else if (insights.insights(); as reading) {
|
||||
<div class="ts-dashboard ts-insights">
|
||||
@if (reading.changes; as changes) {
|
||||
<ts-changes-since [changes]="changes" />
|
||||
}
|
||||
@if (reading.sprint; as sprint) {
|
||||
<ts-sprint-pulse [sprint]="sprint" />
|
||||
}
|
||||
<ts-risks [risks]="reading.risks" />
|
||||
@if (reading.releases.length) {
|
||||
<ts-release-forecast [releases]="reading.releases" />
|
||||
}
|
||||
<ts-aging
|
||||
[issues]="reading.aging.issues"
|
||||
[thresholdDays]="reading.aging.thresholdDays"
|
||||
@@ -114,6 +128,7 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
[statuses]="reading.bottlenecks"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
<ts-backlog-health [health]="reading.backlog" />
|
||||
</div>
|
||||
} @else {
|
||||
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
|
||||
@@ -155,6 +170,12 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
} @else {
|
||||
<span class="ts-pill">Ya no es miembro</span>
|
||||
}
|
||||
@if (loadOf().get(person.id); as load) {
|
||||
@if (load.overloaded) {
|
||||
<span class="ts-pill ts-pill--warn">Sobrecarga</span>
|
||||
}
|
||||
<span class="ts-team__load">{{ loadText(load) }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="ts-bar" aria-hidden="true"
|
||||
><span
|
||||
@@ -382,6 +403,14 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-team__load {
|
||||
flex-basis: 100%;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-pill--warn {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
.ts-team__go {
|
||||
color: var(--ts-brand);
|
||||
opacity: 0;
|
||||
@@ -424,10 +453,15 @@ export class DashboardPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly insights = inject(InsightsFacade);
|
||||
|
||||
/** Opened once per project: the project itself changes on every update. */
|
||||
private openedFor: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.facade.project()?.id;
|
||||
if (projectId) untracked(() => this.insights.open(projectId));
|
||||
if (!projectId || projectId === this.openedFor) return;
|
||||
this.openedFor = projectId;
|
||||
untracked(() => this.insights.open(projectId, takeLastVisit(projectId)));
|
||||
});
|
||||
}
|
||||
protected readonly initialsOf = initials;
|
||||
@@ -557,6 +591,25 @@ export class DashboardPage {
|
||||
(a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'),
|
||||
);
|
||||
});
|
||||
/** Open points and usual pace per person (from the insights). */
|
||||
protected readonly loadOf = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(this.insights.insights()?.team ?? []).map((t) => [t.user.id, t]),
|
||||
),
|
||||
);
|
||||
protected loadText(load: TeamLoad): string {
|
||||
const parts = [`${load.openPoints} pts abiertos`];
|
||||
if (load.pointsPerSprint !== null)
|
||||
parts.push(
|
||||
`cierra ${load.pointsPerSprint.toLocaleString('es')} por sprint`,
|
||||
);
|
||||
if (load.blocked)
|
||||
parts.push(
|
||||
`${load.blocked} ${load.blocked === 1 ? 'bloqueado' : 'bloqueados'}`,
|
||||
);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
protected readonly unassigned = computed(
|
||||
() => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -130,6 +131,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (onlyLabel(); as only) {
|
||||
<p class="ts-list__only">
|
||||
Solo {{ only }}
|
||||
<button type="button" class="ts-plain-link" (click)="clearOnly()">
|
||||
Quitar
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (facade.error(); as error) {
|
||||
<pi-note
|
||||
@@ -344,6 +353,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
.ts-list__count {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-list__only {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-list {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
@@ -452,6 +469,38 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
export class ListPage {
|
||||
/** `?release=<id>`: opens already filtered by that release. */
|
||||
readonly release = input<string | undefined>(undefined);
|
||||
/** `?missing=estimate|area|assignee` (from the dashboard's backlog health). */
|
||||
readonly missing = input<string | undefined>(undefined);
|
||||
/** `?staleDays=90`: only tickets not updated in that many days. */
|
||||
readonly staleDays = input<string | undefined>(undefined);
|
||||
/** What the URL asked for, until cleared here. */
|
||||
protected readonly gap = linkedSignal(() =>
|
||||
this.missing() === 'estimate' || this.missing() === 'area'
|
||||
? this.missing()
|
||||
: null,
|
||||
);
|
||||
protected readonly stale = linkedSignal(() => {
|
||||
const days = Math.round(Number(this.staleDays()));
|
||||
return days > 0 ? days : null;
|
||||
});
|
||||
private readonly unassignedFromUrl = effect(() => {
|
||||
if (this.missing() === 'assignee')
|
||||
untracked(() => this.assignee.set(NOBODY));
|
||||
});
|
||||
protected readonly onlyLabel = computed(() =>
|
||||
[
|
||||
this.gap() === 'estimate' ? 'sin estimar' : null,
|
||||
this.gap() === 'area' ? 'sin área' : null,
|
||||
this.stale() ? `sin tocar en ${this.stale()} días` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' y '),
|
||||
);
|
||||
|
||||
protected clearOnly(): void {
|
||||
this.gap.set(null);
|
||||
this.stale.set(null);
|
||||
}
|
||||
protected readonly facade = inject(ListFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
protected readonly NOBODY = NOBODY;
|
||||
@@ -577,6 +626,13 @@ export class ListPage {
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
noEstimate: this.gap() === 'estimate' || undefined,
|
||||
noArea: this.gap() === 'area' || undefined,
|
||||
updatedBefore: this.stale()
|
||||
? new Date(
|
||||
Date.now() - (this.stale() as number) * 86_400_000,
|
||||
).toISOString()
|
||||
: undefined,
|
||||
customFields: fields.length
|
||||
? fields.map(([fieldId, value]) => ({ fieldId, value }))
|
||||
: undefined,
|
||||
|
||||
@@ -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) }));
|
||||
});
|
||||
}
|
||||
105
libs/web/tracker/presenters/src/lib/ui/insights/changes-since.ts
Normal file
105
libs/web/tracker/presenters/src/lib/ui/insights/changes-since.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
19
libs/web/tracker/presenters/src/lib/ui/last-visit.ts
Normal file
19
libs/web/tracker/presenters/src/lib/ui/last-visit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user