feat: dashboard weighs quality and splits work by a list field

The insights reading adds quality (open bugs per priority, bugs created
and closed per week for 8 weeks, QA returns and passes per area over 90
days, bugs reported in the 14 days after each of the last three
releases) and, for each list custom field, open tickets per option. The
dashboard shows both; the field to split by can be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:06:11 +02:00
parent 0d158a2ed7
commit 76312c079d
22 changed files with 1264 additions and 74 deletions

View File

@@ -1,3 +1,4 @@
import { IssuePriority } from './enums';
import { UserRef } from './models';
/** A ticket the dashboard names, with the number that put it there. */
@@ -102,6 +103,29 @@ export interface BacklogHealth {
staleDays: number;
}
/** Bugs (types drawn with the bug icon) and QA. */
export interface Quality {
openBugsByPriority: { priority: IssuePriority; count: number }[];
/** The last 8 weeks (Monday), oldest first. */
bugWeeks: { week: string; created: number; closed: number }[];
/** QA's verdicts per area, the last 90 days (area null: none). */
qaByArea: { area: string | null; returned: number; passed: number }[];
/** Bugs reported in the 14 days after each of the last releases. */
escaped: {
releaseId: string;
name: string;
releasedAt: string;
bugs: number;
}[];
}
/** Open tickets per option of a list custom field. */
export interface FieldBreakdown {
fieldId: string;
counts: { value: string; count: number }[];
none: number;
}
/** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights {
generatedAt: string;
@@ -114,4 +138,6 @@ export interface ProjectInsights {
changes: ChangesSince | null;
team: TeamLoad[];
backlog: BacklogHealth;
quality: Quality;
fields: FieldBreakdown[];
}

View File

@@ -1921,6 +1921,26 @@ export type ProjectInsightsQuery = {
stale: number;
staleDays: number;
};
quality: {
openBugsByPriority: Array<{ priority: IssuePriority; count: number }>;
bugWeeks: Array<{ week: string; created: number; closed: number }>;
qaByArea: Array<{
area: string | null;
returned: number;
passed: number;
}>;
escaped: Array<{
releaseId: string;
name: string;
releasedAt: string;
bugs: number;
}>;
};
fields: Array<{
fieldId: string;
none: number;
counts: Array<{ value: string; count: number }>;
}>;
};
};
};
@@ -11456,6 +11476,136 @@ export const ProjectInsightsDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'quality' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'openBugsByPriority',
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'priority' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugWeeks' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'week' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'created' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'closed' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'qaByArea' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'area' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'returned' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'passed' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'escaped' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releasedAt' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugs' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'fields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'fieldId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'counts' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'value' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'none' },
},
],
},
},
],
},
},

View File

@@ -254,6 +254,36 @@ query ProjectInsights($idOrKey: String!, $since: String) {
stale
staleDays
}
quality {
openBugsByPriority {
priority
count
}
bugWeeks {
week
created
closed
}
qaByArea {
area
returned
passed
}
escaped {
releaseId
name
releasedAt
bugs
}
}
fields {
fieldId
counts {
value
count
}
none
}
}
}
}

View File

@@ -32,6 +32,8 @@ 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';
import { QualityComponent } from '../ui/insights/quality';
import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
@@ -49,6 +51,8 @@ import { takeLastVisit } from '../ui/last-visit';
ReleaseForecastComponent,
ChangesSinceComponent,
BacklogHealthComponent,
QualityComponent,
FieldBreakdownComponent,
],
providers: [provideInsightsFacade()],
template: `
@@ -129,6 +133,16 @@ import { takeLastVisit } from '../ui/last-visit';
[workflow]="facade.workflow()"
/>
<ts-backlog-health [health]="reading.backlog" />
<ts-quality
[quality]="reading.quality"
[workflow]="facade.workflow()"
/>
@if (reading.fields.length) {
<ts-field-breakdown
[fields]="reading.fields"
[workflow]="facade.workflow()"
/>
}
</div>
} @else {
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />

View File

@@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
} from '@angular/core';
import { PiSelectFieldComponent, type PiSelectFieldOption } from '@pi/ui';
import { FieldBreakdown, Workflow } from '@task-stream/web/tracker/domain';
/** Open tickets per option of a list custom field («bugs por navegador»). */
@Component({
selector: 'ts-field-breakdown',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSelectFieldComponent],
host: {
class: 'ts-panel',
'aria-labelledby': 'breakdown-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="breakdown-title">Abiertos por {{ name() }}</h2>
@if (options().length > 1) {
<pi-select-field
class="ts-breakdown__pick"
[options]="options()"
[(value)]="chosen"
/>
}
</header>
@if (current(); as field) {
<ul class="ts-issues ts-breakdown">
@for (row of rows(); track row.value) {
<li>
<span class="ts-title">{{ row.value }}</span>
<span class="ts-bar"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days">{{ row.count }}</span>
</li>
}
<li class="ts-muted">
<span class="ts-title">Sin valor</span>
<span class="ts-bar"
><span [style.width.%]="noneShare()"></span
></span>
<span class="ts-days">{{ field.none }}</span>
</li>
</ul>
}
`,
styles: `
.ts-breakdown li {
grid-template-columns: minmax(6rem, 10rem) 1fr 2.5rem;
align-items: center;
}
.ts-breakdown .ts-days {
text-align: right;
}
.ts-breakdown__pick {
min-width: 10rem;
}
`,
})
export class FieldBreakdownComponent {
readonly fields = input.required<FieldBreakdown[]>();
readonly workflow = input.required<Workflow>();
protected readonly options = computed<PiSelectFieldOption<string>[]>(() =>
this.fields().map((f) => ({
value: f.fieldId,
label: this.nameOf(f.fieldId),
})),
);
/** The field shown; the first one until another is picked. */
protected readonly chosen = linkedSignal<string | null>(
() => this.fields()[0]?.fieldId ?? null,
);
protected readonly current = computed(
() =>
this.fields().find((f) => f.fieldId === this.chosen()) ??
this.fields()[0] ??
null,
);
protected readonly name = computed(() => {
const field = this.current();
return field ? this.nameOf(field.fieldId).toLocaleLowerCase('es') : '';
});
private readonly max = computed(() => {
const field = this.current();
return Math.max(
1,
field?.none ?? 0,
...(field?.counts.map((c) => c.count) ?? []),
);
});
protected readonly rows = computed(() =>
(this.current()?.counts ?? []).map((c) => ({
...c,
share: (c.count / this.max()) * 100,
})),
);
protected readonly noneShare = computed(
() => ((this.current()?.none ?? 0) / this.max()) * 100,
);
private nameOf(fieldId: string): string {
return this.workflow().customField(fieldId)?.name ?? fieldId;
}
}

View File

@@ -0,0 +1,202 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Quality, Workflow } from '@task-stream/web/tracker/domain';
import { PRIORITY } from '../copy';
const short = (day: string) =>
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
/** Open bugs, how they come and go, what QA sends back and what escapes. */
@Component({
selector: 'ts-quality',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'quality-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="quality-title">Calidad</h2>
<span class="ts-muted">Bugs: los tipos con icono de bicho</span>
</header>
<h3>Bugs abiertos ({{ openBugs() }})</h3>
<ul class="ts-issues ts-quality__rows">
@for (row of priorities(); track row.priority) {
<li>
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
<span class="ts-days">{{ row.count }}</span>
</li>
}
</ul>
<h3>Últimas 8 semanas</h3>
<div class="ts-quality__weeks" role="img" [attr.aria-label]="weeksLabel()">
@for (week of weeks(); track week.week) {
<span class="ts-quality__week" [attr.title]="week.title">
<i class="is-created" [style.height.%]="week.createdShare"></i>
<i class="is-closed" [style.height.%]="week.closedShare"></i>
</span>
}
</div>
<p class="ts-quality__legend">
<span class="is-created">Nuevos</span>
<span class="is-closed">Cerrados</span>
</p>
@if (areas().length) {
<h3>QA por área (90 días)</h3>
<ul class="ts-issues ts-quality__rows">
@for (row of areas(); track row.label) {
<li>
<span class="ts-title">{{ row.label }}</span>
<span class="ts-bar ts-bar--warn"
><span [style.width.%]="row.share"></span
></span>
<span class="ts-days"
>{{ row.returned }}/{{ row.returned + row.passed }} vueltos</span
>
</li>
}
</ul>
}
@if (quality().escaped.length) {
<h3>Bugs en los 14 días tras cada release</h3>
<ul class="ts-issues">
@for (row of quality().escaped; track row.releaseId) {
<li>
<span class="ts-key">{{ row.name }}</span>
<span class="ts-title ts-muted">{{ shipped(row.releasedAt) }}</span>
<span class="ts-days" [class.ts-warn]="row.bugs > 0">{{
row.bugs
}}</span>
</li>
}
</ul>
}
`,
styles: `
h3 {
margin: 1rem 0 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
&:first-of-type {
margin-top: 0;
}
}
.ts-quality__rows li {
grid-template-columns: minmax(6rem, 9rem) 1fr 5.5rem;
align-items: center;
}
.ts-quality__rows .ts-days {
text-align: right;
}
.ts-bar--warn {
--tone: var(--ts-c-red, #d14343);
}
.ts-quality__weeks {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
height: 4rem;
}
.ts-quality__week {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
border-bottom: 1px solid var(--ts-line);
i {
width: 40%;
min-height: 1px;
border-radius: 2px 2px 0 0;
}
}
.is-created {
--tone: var(--ts-c-red, #d14343);
}
.is-closed {
--tone: var(--ts-c-green, #2e9e6a);
}
.ts-quality__week i {
background: var(--tone);
}
.ts-quality__legend {
display: flex;
gap: 1rem;
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.75rem;
span::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.3rem;
border-radius: 2px;
background: var(--tone);
}
}
`,
})
export class QualityComponent {
readonly quality = input.required<Quality>();
readonly workflow = input.required<Workflow>();
protected readonly openBugs = computed(() =>
this.quality().openBugsByPriority.reduce((sum, r) => sum + r.count, 0),
);
protected readonly priorities = computed(() => {
const max = Math.max(
1,
...this.quality().openBugsByPriority.map((r) => r.count),
);
return this.quality().openBugsByPriority.map((r) => ({
...r,
label: PRIORITY[r.priority].label,
share: (r.count / max) * 100,
}));
});
protected readonly weeks = computed(() => {
const list = this.quality().bugWeeks;
const max = Math.max(1, ...list.flatMap((w) => [w.created, w.closed]));
return list.map((w) => ({
...w,
createdShare: (w.created / max) * 100,
closedShare: (w.closed / max) * 100,
title: `Semana del ${short(w.week)}: ${w.created} nuevos, ${w.closed} cerrados`,
}));
});
protected readonly weeksLabel = computed(() =>
this.weeks()
.map((w) => w.title)
.join('. '),
);
protected readonly areas = computed(() => {
const rows = this.quality().qaByArea;
return rows.map((r) => ({
...r,
label: r.area ? this.workflow().area(r.area).name : 'Sin área',
share:
r.returned + r.passed
? (r.returned / (r.returned + r.passed)) * 100
: 0,
}));
});
protected shipped(day: string): string {
return `publicada el ${short(day)}`;
}
}